From f35702aa9d84f3fd4871e70afdc931f34b227460 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Fri, 9 May 2025 00:11:18 +0100 Subject: [PATCH 1/9] refactor: Decouple Server Core from Transport & Improve API - Replaced Server->run() with Server->listen(Transport) for explicit binding. - Separated core Server logic from transport implementations (Stdio/Http). - Introduced ServerBuilder for configuration and ServerProtocolHandler for mediation. - Made attribute discovery an explicit step via Server->discover(). - Refined caching to only cache discovered elements, respecting manual registrations. - Replaced ConfigurationRepository with Configuration VO and Capabilities VO. - Simplified core component dependencies (Processor, Registry, ClientStateManager). - Renamed TransportState to ClientStateManager. - Renamed McpException to McpServerException and added specific exception types. - Updated transport implementations (StdioServerTransport, HttpServerTransport). - Improved default BasicContainer with simple auto-wiring. - Revised documentation (README) and examples to reflect new architecture. --- CHANGELOG.md | 2 +- composer.json | 1 + .../McpElements.php | 120 ++ .../standalone_http_userprofile/server.php | 57 + .../McpElements.php | 127 ++ .../standalone_stdio_calculator/server.php | 51 + samples/php_http/.gitignore | 26 - samples/php_http/SampleMcpElements.php | 89 -- samples/php_http/composer.json | 28 - samples/php_http/server.php | 111 -- samples/php_stdio/.gitignore | 25 - samples/php_stdio/SampleMcpElements.php | 89 -- samples/php_stdio/composer.json | 28 - samples/php_stdio/server.php | 36 - samples/reactphp_http/.gitignore | 26 - samples/reactphp_http/SampleMcpElements.php | 89 -- samples/reactphp_http/composer.json | 29 - samples/reactphp_http/server.php | 114 -- samples/sample_messages.md | 49 - src/Attributes/McpPrompt.php | 3 +- src/Attributes/McpResource.php | 3 +- src/Attributes/McpResourceTemplate.php | 3 +- src/Attributes/McpTool.php | 3 +- src/ClientStateManager.php | 526 +++++++++ src/Configuration.php | 41 + .../ConfigurationRepositoryInterface.php | 21 - src/Contracts/LoggerAwareInterface.php | 17 + src/Contracts/LoopAwareInterface.php | 17 + src/Contracts/ServerTransportInterface.php | 53 + src/Contracts/TransportHandlerInterface.php | 45 - src/Defaults/ArrayCache.php | 110 -- src/Defaults/ArrayConfigurationRepository.php | 84 -- src/Defaults/BasicContainer.php | 152 ++- src/Defaults/NotFoundException.php | 12 - src/Defaults/StreamLogger.php | 357 ------ src/Exception/ConfigurationException.php | 16 + src/Exception/DefinitionException.php | 15 + src/Exception/DiscoveryException.php | 13 + src/Exception/McpServerException.php | 140 +++ src/Exception/ProtocolException.php | 27 + src/Exception/TransportException.php | 23 + src/Exceptions/McpException.php | 114 -- src/JsonRpc/Batch.php | 6 +- src/JsonRpc/Error.php | 6 + src/JsonRpc/Notification.php | 12 +- src/JsonRpc/Request.php | 10 +- src/JsonRpc/Response.php | 120 +- src/Model/Capabilities.php | 112 ++ src/Processor.php | 430 +++---- src/ProtocolHandler.php | 271 +++++ src/Registry.php | 504 +++++--- src/Server.php | 468 +++----- src/ServerBuilder.php | 352 ++++++ src/State/TransportState.php | 324 ----- src/Support/Discoverer.php | 18 +- src/Support/DocBlockParser.php | 11 +- src/Transports/HttpServerTransport.php | 337 ++++++ src/Transports/HttpTransportHandler.php | 222 ---- .../ReactPhpHttpTransportHandler.php | 210 ---- src/Transports/StdioServerTransport.php | 223 ++++ src/Transports/StdioTransportHandler.php | 275 ----- tests/JsonRpc/ResponseTest.php | 210 ---- tests/ProcessorTest.php | 1042 ----------------- tests/RegistryTest.php | 295 ----- tests/ServerTest.php | 308 ----- tests/State/TransportStateTest.php | 425 ------- tests/Transports/HttpTransportHandlerTest.php | 391 ------- .../Transports/StdioTransportHandlerTest.php | 304 ----- tests/{ => Unit}/Attributes/McpPromptTest.php | 2 +- .../Attributes/McpResourceTemplateTest.php | 2 +- .../{ => Unit}/Attributes/McpResourceTest.php | 2 +- tests/{ => Unit}/Attributes/McpToolTest.php | 2 +- tests/Unit/ClientStateManagerTest.php | 402 +++++++ tests/Unit/ConfigurationTest.php | 105 ++ .../Definitions/PromptDefinitionTest.php | 8 +- .../Definitions/ResourceDefinitionTest.php | 4 +- .../ResourceTemplateDefinitionTest.php | 4 +- .../Definitions/ToolDefinitionTest.php | 8 +- tests/{ => Unit}/JsonRpc/BatchTest.php | 12 +- tests/{ => Unit}/JsonRpc/ErrorTest.php | 2 +- tests/{ => Unit}/JsonRpc/MessageTest.php | 2 +- tests/{ => Unit}/JsonRpc/NotificationTest.php | 46 +- tests/{ => Unit}/JsonRpc/RequestTest.php | 69 +- tests/Unit/JsonRpc/ResponseTest.php | 284 +++++ tests/{ => Unit}/JsonRpc/ResultTest.php | 2 +- .../JsonRpc/Results/EmptyResultTest.php | 2 +- tests/Unit/ProcessorTest.php | 283 +++++ tests/Unit/ProtocolHandlerTest.php | 236 ++++ tests/Unit/RegistryTest.php | 408 +++++++ tests/Unit/ServerBuilderTest.php | 364 ++++++ tests/Unit/ServerTest.php | 278 +++++ .../Support/ArgumentPreparerTest.php | 48 +- .../Support/AttributeFinderTest.php | 9 +- tests/{ => Unit}/Support/DiscovererTest.php | 14 +- .../{ => Unit}/Support/DocBlockParserTest.php | 6 +- .../Support/SchemaGeneratorTest.php | 2 +- .../Support/SchemaValidatorTest.php | 11 +- .../Support/UriTemplateMatcherTest.php | 34 +- .../Traits/ResponseFormatterTest.php | 8 +- .../Transports/HttpServerTransportTest.php | 418 +++++++ .../Transports/StdioServerTransportTest.php | 254 ++++ 101 files changed, 6602 insertions(+), 6507 deletions(-) create mode 100644 examples/standalone_http_userprofile/McpElements.php create mode 100644 examples/standalone_http_userprofile/server.php create mode 100644 examples/standalone_stdio_calculator/McpElements.php create mode 100644 examples/standalone_stdio_calculator/server.php delete mode 100644 samples/php_http/.gitignore delete mode 100644 samples/php_http/SampleMcpElements.php delete mode 100644 samples/php_http/composer.json delete mode 100644 samples/php_http/server.php delete mode 100644 samples/php_stdio/.gitignore delete mode 100644 samples/php_stdio/SampleMcpElements.php delete mode 100644 samples/php_stdio/composer.json delete mode 100644 samples/php_stdio/server.php delete mode 100644 samples/reactphp_http/.gitignore delete mode 100644 samples/reactphp_http/SampleMcpElements.php delete mode 100644 samples/reactphp_http/composer.json delete mode 100644 samples/reactphp_http/server.php delete mode 100644 samples/sample_messages.md create mode 100644 src/ClientStateManager.php create mode 100644 src/Configuration.php delete mode 100644 src/Contracts/ConfigurationRepositoryInterface.php create mode 100644 src/Contracts/LoggerAwareInterface.php create mode 100644 src/Contracts/LoopAwareInterface.php create mode 100644 src/Contracts/ServerTransportInterface.php delete mode 100644 src/Contracts/TransportHandlerInterface.php delete mode 100644 src/Defaults/ArrayCache.php delete mode 100644 src/Defaults/ArrayConfigurationRepository.php delete mode 100644 src/Defaults/NotFoundException.php delete mode 100644 src/Defaults/StreamLogger.php create mode 100644 src/Exception/ConfigurationException.php create mode 100644 src/Exception/DefinitionException.php create mode 100644 src/Exception/DiscoveryException.php create mode 100644 src/Exception/McpServerException.php create mode 100644 src/Exception/ProtocolException.php create mode 100644 src/Exception/TransportException.php delete mode 100644 src/Exceptions/McpException.php create mode 100644 src/Model/Capabilities.php create mode 100644 src/ProtocolHandler.php create mode 100644 src/ServerBuilder.php delete mode 100644 src/State/TransportState.php create mode 100644 src/Transports/HttpServerTransport.php delete mode 100644 src/Transports/HttpTransportHandler.php delete mode 100644 src/Transports/ReactPhpHttpTransportHandler.php create mode 100644 src/Transports/StdioServerTransport.php delete mode 100644 src/Transports/StdioTransportHandler.php delete mode 100644 tests/JsonRpc/ResponseTest.php delete mode 100644 tests/ProcessorTest.php delete mode 100644 tests/RegistryTest.php delete mode 100644 tests/ServerTest.php delete mode 100644 tests/State/TransportStateTest.php delete mode 100644 tests/Transports/HttpTransportHandlerTest.php delete mode 100644 tests/Transports/StdioTransportHandlerTest.php rename tests/{ => Unit}/Attributes/McpPromptTest.php (95%) rename tests/{ => Unit}/Attributes/McpResourceTemplateTest.php (97%) rename tests/{ => Unit}/Attributes/McpResourceTest.php (97%) rename tests/{ => Unit}/Attributes/McpToolTest.php (95%) create mode 100644 tests/Unit/ClientStateManagerTest.php create mode 100644 tests/Unit/ConfigurationTest.php rename tests/{ => Unit}/Definitions/PromptDefinitionTest.php (97%) rename tests/{ => Unit}/Definitions/ResourceDefinitionTest.php (98%) rename tests/{ => Unit}/Definitions/ResourceTemplateDefinitionTest.php (98%) rename tests/{ => Unit}/Definitions/ToolDefinitionTest.php (97%) rename tests/{ => Unit}/JsonRpc/BatchTest.php (94%) rename tests/{ => Unit}/JsonRpc/ErrorTest.php (97%) rename tests/{ => Unit}/JsonRpc/MessageTest.php (94%) rename tests/{ => Unit}/JsonRpc/NotificationTest.php (68%) rename tests/{ => Unit}/JsonRpc/RequestTest.php (58%) create mode 100644 tests/Unit/JsonRpc/ResponseTest.php rename tests/{ => Unit}/JsonRpc/ResultTest.php (96%) rename tests/{ => Unit}/JsonRpc/Results/EmptyResultTest.php (94%) create mode 100644 tests/Unit/ProcessorTest.php create mode 100644 tests/Unit/ProtocolHandlerTest.php create mode 100644 tests/Unit/RegistryTest.php create mode 100644 tests/Unit/ServerBuilderTest.php create mode 100644 tests/Unit/ServerTest.php rename tests/{ => Unit}/Support/ArgumentPreparerTest.php (88%) rename tests/{ => Unit}/Support/AttributeFinderTest.php (97%) rename tests/{ => Unit}/Support/DiscovererTest.php (96%) rename tests/{ => Unit}/Support/DocBlockParserTest.php (97%) rename tests/{ => Unit}/Support/SchemaGeneratorTest.php (99%) rename tests/{ => Unit}/Support/SchemaValidatorTest.php (97%) rename tests/{ => Unit}/Support/UriTemplateMatcherTest.php (87%) rename tests/{ => Unit}/Traits/ResponseFormatterTest.php (99%) create mode 100644 tests/Unit/Transports/HttpServerTransportTest.php create mode 100644 tests/Unit/Transports/StdioServerTransportTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d6758e..3def187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to `php-mcp/server` will be documented in this file. ### Changed -* **Dependency Injection:** Refactored internal dependency management. Core server components (`Processor`, `Registry`, `TransportState`, etc.) now resolve `LoggerInterface`, `CacheInterface`, and `ConfigurationRepositoryInterface` Just-In-Time from the provided PSR-11 container. See **Breaking Changes** for implications. +* **Dependency Injection:** Refactored internal dependency management. Core server components (`Processor`, `Registry`, `ClientStateManager`, etc.) now resolve `LoggerInterface`, `CacheInterface`, and `ConfigurationRepositoryInterface` Just-In-Time from the provided PSR-11 container. See **Breaking Changes** for implications. * **Default Logging Behavior:** Logging is now **disabled by default**. To enable logging, provide a `LoggerInterface` implementation via `withLogger()` (when using the default container) or by registering it within your custom PSR-11 container. * **Transport Handler Constructors:** Transport Handlers (e.g., `StdioTransportHandler`, `HttpTransportHandler`) now primarily accept the `Server` instance in their constructor, simplifying their instantiation. diff --git a/composer.json b/composer.json index 8fcdffc..faefa3d 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "friendsofphp/php-cs-fixer": "^3.75", "mockery/mockery": "^1.6", "pestphp/pest": "^2.36.0|^3.5.0", + "react/async": "^4.0", "react/http": "^1.11", "symfony/var-dumper": "^6.4.11|^7.1.5" }, diff --git a/examples/standalone_http_userprofile/McpElements.php b/examples/standalone_http_userprofile/McpElements.php new file mode 100644 index 0000000..59cb20c --- /dev/null +++ b/examples/standalone_http_userprofile/McpElements.php @@ -0,0 +1,120 @@ + ['name' => 'Alice', 'email' => 'alice@example.com', 'role' => 'admin'], + '102' => ['name' => 'Bob', 'email' => 'bob@example.com', 'role' => 'user'], + '103' => ['name' => 'Charlie', 'email' => 'charlie@example.com', 'role' => 'user'], + ]; + + private LoggerInterface $logger; + + public function __construct(LoggerInterface $logger) + { + $this->logger = $logger; + $this->logger->debug('HttpUserProfileExample McpElements instantiated.'); + } + + /** + * Retrieves the profile data for a specific user. + * + * @param string $userId The ID of the user (from URI). + * @return array User profile data. + * + * @throws McpServerException If the user is not found. + */ + #[McpResourceTemplate( + uriTemplate: 'user://{userId}/profile', + name: 'user_profile', + description: 'Get profile information for a specific user ID.', + mimeType: 'application/json' + )] + public function getUserProfile(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 + throw McpServerException::invalidParams("User profile not found for ID: {$userId}"); + } + + return $this->users[$userId]; + } + + /** + * Retrieves a list of all known user IDs. + * + * @return array List of user IDs. + */ + #[McpResource( + uri: 'user://list/ids', + name: 'user_id_list', + description: 'Provides a list of all available user IDs.', + mimeType: 'application/json' + )] + public function listUserIds(): array + { + $this->logger->info('Reading resource: user ID list'); + + return array_keys($this->users); + } + + /** + * Sends a welcome message to a user. + * (This is a placeholder - in a real app, it might queue an email) + * + * @param string $userId The ID of the user to message. + * @param string|null $customMessage An optional custom message part. + * @return array Status of the operation. + */ + #[McpTool(name: 'send_welcome')] + public function sendWelcomeMessage(string $userId, ?string $customMessage = null): array + { + $this->logger->info('Executing tool: send_welcome', ['userId' => $userId]); + if (! isset($this->users[$userId])) { + return ['success' => false, 'error' => "User ID {$userId} not found."]; + } + $user = $this->users[$userId]; + $message = "Welcome, {$user['name']}!"; + if ($customMessage) { + $message .= ' '.$customMessage; + } + // Simulate sending + $this->logger->info("Simulated sending message to {$user['email']}: {$message}"); + + return ['success' => true, 'message_sent' => $message]; + } + + /** + * Generates a prompt to write a bio for a user. + * + * @param string $userId The user ID to generate the bio for. + * @param string $tone Desired tone (e.g., 'formal', 'casual'). + * @return array Prompt messages. + * + * @throws McpServerException If user not found. + */ + #[McpPrompt(name: 'generate_bio_prompt')] + public function generateBio(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}"); + } + $user = $this->users[$userId]; + + return [ + ['role' => 'user', 'content' => "Write a short, {$tone} biography for {$user['name']} (Role: {$user['role']}, Email: {$user['email']}). Highlight their role within the system."], + ]; + } +} diff --git a/examples/standalone_http_userprofile/server.php b/examples/standalone_http_userprofile/server.php new file mode 100644 index 0000000..e09c697 --- /dev/null +++ b/examples/standalone_http_userprofile/server.php @@ -0,0 +1,57 @@ +#!/usr/bin/env php +info('Starting MCP HTTP User Profile Server...'); + + // --- Setup DI Container for DI in McpElements class --- + $container = new BasicContainer(); + $container->set(LoggerInterface::class, $logger); + + $server = Server::make() + ->withServerInfo('HTTP User Profiles', '1.0.0') + ->withLogger($logger) + ->withContainer($container) + ->build(); + + $server->discover(__DIR__, ['.']); + + $transport = new HttpServerTransport( + host: '127.0.0.1', + port: 8080, + mcpPathPrefix: '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/examples/standalone_stdio_calculator/McpElements.php b/examples/standalone_stdio_calculator/McpElements.php new file mode 100644 index 0000000..6b71010 --- /dev/null +++ b/examples/standalone_stdio_calculator/McpElements.php @@ -0,0 +1,127 @@ + 2, + 'allow_negative' => true, + ]; + + /** + * Performs a calculation based on the operation. + * + * Supports 'add', 'subtract', 'multiply', 'divide'. + * Obeys the 'precision' and 'allow_negative' settings from the config resource. + * + * @param float $a The first operand. + * @param float $b The second operand. + * @param string $operation The operation ('add', 'subtract', 'multiply', 'divide'). + * @return float|string The result of the calculation, or an error message string. + */ + #[McpTool(name: 'calculate')] + public function calculate(float $a, float $b, string $operation): float|string + { + // Use STDERR for logs + fwrite(STDERR, "Calculate tool called: a=$a, b=$b, op=$operation\n"); + + $op = strtolower($operation); + $result = null; + + switch ($op) { + case 'add': + $result = $a + $b; + break; + case 'subtract': + $result = $a - $b; + break; + case 'multiply': + $result = $a * $b; + break; + case 'divide': + if ($b == 0) { + return 'Error: Division by zero.'; + } + $result = $a / $b; + break; + default: + return "Error: Unknown operation '{$operation}'. Supported: add, subtract, multiply, divide."; + } + + if (! $this->config['allow_negative'] && $result < 0) { + return 'Error: Negative results are disabled.'; + } + + return round($result, $this->config['precision']); + } + + /** + * Provides the current calculator configuration. + * Can be read by clients to understand precision etc. + * + * @return array The configuration array. + */ + #[McpResource( + uri: 'config://calculator/settings', + name: 'calculator_config', + description: 'Current settings for the calculator tool (precision, allow_negative).', + mimeType: 'application/json' // Return as JSON + )] + public function getConfiguration(): array + { + fwrite(STDERR, "Resource config://calculator/settings read.\n"); + + return $this->config; + } + + /** + * Updates a specific configuration setting. + * Note: This requires more robust validation in a real app. + * + * @param string $setting The setting key ('precision' or 'allow_negative'). + * @param mixed $value The new value (int for precision, bool for allow_negative). + * @return array Success message or error. + */ + #[McpTool(name: 'update_setting')] + public function updateSetting(string $setting, mixed $value): array + { + fwrite(STDERR, "Update Setting tool called: setting=$setting, value=".var_export($value, true)."\n"); + if (! array_key_exists($setting, $this->config)) { + return ['success' => false, 'error' => "Unknown setting '{$setting}'."]; + } + + if ($setting === 'precision') { + if (! is_int($value) || $value < 0 || $value > 10) { + return ['success' => false, 'error' => 'Invalid precision value. Must be integer between 0 and 10.']; + } + $this->config['precision'] = $value; + + // In real app, notify subscribers of config://calculator/settings change + // $registry->notifyResourceChanged('config://calculator/settings'); + return ['success' => true, 'message' => "Precision updated to {$value}."]; + } + + if ($setting === 'allow_negative') { + if (! is_bool($value)) { + // Attempt basic cast for flexibility + if (in_array(strtolower((string) $value), ['true', '1', 'yes', 'on'])) { + $value = true; + } elseif (in_array(strtolower((string) $value), ['false', '0', 'no', 'off'])) { + $value = false; + } else { + return ['success' => false, 'error' => 'Invalid allow_negative value. Must be boolean (true/false).']; + } + } + $this->config['allow_negative'] = $value; + + // $registry->notifyResourceChanged('config://calculator/settings'); + return ['success' => true, 'message' => 'Allow negative results set to '.($value ? 'true' : 'false').'.']; + } + + return ['success' => false, 'error' => 'Internal error handling setting.']; // Should not happen + } +} diff --git a/examples/standalone_stdio_calculator/server.php b/examples/standalone_stdio_calculator/server.php new file mode 100644 index 0000000..00e8652 --- /dev/null +++ b/examples/standalone_stdio_calculator/server.php @@ -0,0 +1,51 @@ +#!/usr/bin/env php +info('Starting MCP Stdio Calculator Server...'); + + $server = Server::make() + ->withServerInfo('Stdio Calculator', '1.1.0') + ->withLogger($logger) + ->build(); + + $server->discover(__DIR__, ['.']); + + $transport = new StdioServerTransport(); + + $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/samples/php_http/.gitignore b/samples/php_http/.gitignore deleted file mode 100644 index 2668607..0000000 --- a/samples/php_http/.gitignore +++ /dev/null @@ -1,26 +0,0 @@ -# Composer dependencies -/vendor/ -/composer.lock - -# Editor directories and files -/.idea -/.vscode -*.sublime-project -*.sublime-workspace - - -# Operating system files -.DS_Store -Thumbs.db - -# Local environment files -/.env -/.env.backup -/.env.local - -# Local Composer dependencies -composer.phar - - -# Log files -*.log \ No newline at end of file diff --git a/samples/php_http/SampleMcpElements.php b/samples/php_http/SampleMcpElements.php deleted file mode 100644 index d5cc942..0000000 --- a/samples/php_http/SampleMcpElements.php +++ /dev/null @@ -1,89 +0,0 @@ - $input, 'count' => $count]; - } - - /** - * Generates a simple story prompt. - * - * @param string $subject The main subject of the story. - * @param string $genre The genre (e.g., fantasy, sci-fi). - */ - #[McpPrompt(name: 'create_story', description: 'Creates a short story premise.')] - public function storyPrompt(string $subject, string $genre = 'fantasy'): array - { - // In a real scenario, this would return the prompt string - return [ - [ - 'role' => 'user', - 'content' => "Write a short {$genre} story about {$subject}.", - ], - ]; - } - - #[McpPrompt] - public function simplePrompt(): array - { - return [ - [ - 'role' => 'user', - 'content' => 'This is a simple prompt with no arguments.', - ], - ]; - } - - #[McpResource(uri: 'config://app/name', name: 'app_name', description: 'The application name.', mimeType: 'text/plain')] - public function getAppName(): string - { - // In a real scenario, this would fetch the config value - return 'My MCP App'; - } - - #[McpResource(uri: 'file://data/users.csv', name: 'users_csv', mimeType: 'text/csv')] - public function getUserData(): string - { - // In a real scenario, this would return file content - return "id,name\n1,Alice\n2,Bob"; - } - - #[McpResourceTemplate(uriTemplate: 'user://{userId}/profile', name: 'user_profile', mimeType: 'application/json')] - public function getUserProfileTemplate(string $userId): array - { - // In a real scenario, this would fetch user data based on userId - return ['id' => $userId, 'name' => 'User '.$userId, 'email' => $userId.'@example.com']; - } -} diff --git a/samples/php_http/composer.json b/samples/php_http/composer.json deleted file mode 100644 index d47343e..0000000 --- a/samples/php_http/composer.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "php-mcp/vanilla-sample", - "description": "Vanilla PHP HTTP+SSE example for php-mcp", - "type": "project", - "require": { - "php": ">=8.1", - "php-mcp/server": "@dev" - }, - "repositories": [ - { - "type": "path", - "url": "../../", - "options": { - "symlink": true - } - } - ], - "autoload": { - "psr-4": { - "Test\\": "" - } - }, - "minimum-stability": "dev", - "prefer-stable": true, - "config": { - "sort-packages": true - } -} \ No newline at end of file diff --git a/samples/php_http/server.php b/samples/php_http/server.php deleted file mode 100644 index c6077b5..0000000 --- a/samples/php_http/server.php +++ /dev/null @@ -1,111 +0,0 @@ -withLogger($logger) - ->withBasePath(__DIR__) - ->discover(); - -$httpHandler = new HttpTransportHandler($server); - -// --- Basic Routing & Client ID --- -$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; -$path = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH); -$queryParams = []; -parse_str($_SERVER['QUERY_STRING'] ?? '', $queryParams); - -$postEndpoint = '/mcp/message'; -$sseEndpoint = '/mcp/sse'; - -$logger->info('Request received', ['method' => $method, 'path' => $path]); - -// --- POST Endpoint Handling --- -if ($method === 'POST' && str_starts_with($path, $postEndpoint)) { - $clientId = $queryParams['clientId'] ?? null; - if (! $clientId || ! is_string($clientId)) { - http_response_code(400); - echo 'Error: Missing or invalid clientId query parameter'; - exit; - } - $logger->info('POST Processing', ['client_id' => $clientId]); - - if (! str_contains($_SERVER['CONTENT_TYPE'] ?? '', 'application/json')) { - http_response_code(415); - echo 'Error: Content-Type must be application/json'; - exit; - } - $requestBody = file_get_contents('php://input'); - if ($requestBody === false || empty($requestBody)) { - http_response_code(400); - echo 'Error: Empty request body'; - exit; - } - - try { - $httpHandler->handleInput($requestBody, $clientId); - http_response_code(202); // Accepted - } catch (JsonException $e) { - http_response_code(400); - echo 'Error: Invalid JSON'; - } catch (Throwable $e) { - http_response_code(500); - echo 'Error: Internal Server Error'; - } - exit; -} - -// --- SSE Endpoint Handling --- -if ($method === 'GET' && $path === $sseEndpoint) { - // Generate a unique ID for this SSE connection - $clientId = 'client_'.bin2hex(random_bytes(16)); - $logger->info('SSE connection opening', ['client_id' => $clientId]); - - ignore_user_abort(true); - set_time_limit(0); - - header('Content-Type: text/event-stream'); - header('Cache-Control: no-cache'); - header('Connection: keep-alive'); - header('X-Accel-Buffering: no'); - - try { - $scheme = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; - $host = $_SERVER['HTTP_HOST'] ?? 'localhost:8080'; // Guess host/port - $postEndpointWithClientId = $postEndpoint.'?clientId='.urlencode($clientId); - - // Use the default callback within the handler - $httpHandler->handleSseConnection($clientId, $postEndpointWithClientId); - } catch (Throwable $e) { - // Log errors, excluding potential disconnect exceptions if needed - if (! ($e instanceof \RuntimeException && str_contains($e->getMessage(), 'disconnected'))) { - $logger->error('SSE stream loop terminated', ['client_id' => $clientId, 'reason' => $e->getMessage()]); - } - } finally { - $httpHandler->cleanupClient($clientId); - $logger->info('SSE connection closed and client cleaned up', ['client_id' => $clientId]); - } - exit; -} - -// --- Fallback 404 --- -http_response_code(404); -echo 'Not Found'; -$logger->warning('404 Not Found', ['method' => $method, 'path' => $path]); diff --git a/samples/php_stdio/.gitignore b/samples/php_stdio/.gitignore deleted file mode 100644 index ecdfe52..0000000 --- a/samples/php_stdio/.gitignore +++ /dev/null @@ -1,25 +0,0 @@ -# Composer dependencies -/vendor/ -/composer.lock - -# Editor directories and files -/.idea -/.vscode -*.sublime-project -*.sublime-workspace - - -# Operating system files -.DS_Store -Thumbs.db - -# Local environment files -/.env -/.env.backup -/.env.local - -# Local Composer dependencies -composer.phar - -# Log files -*.log \ No newline at end of file diff --git a/samples/php_stdio/SampleMcpElements.php b/samples/php_stdio/SampleMcpElements.php deleted file mode 100644 index d5cc942..0000000 --- a/samples/php_stdio/SampleMcpElements.php +++ /dev/null @@ -1,89 +0,0 @@ - $input, 'count' => $count]; - } - - /** - * Generates a simple story prompt. - * - * @param string $subject The main subject of the story. - * @param string $genre The genre (e.g., fantasy, sci-fi). - */ - #[McpPrompt(name: 'create_story', description: 'Creates a short story premise.')] - public function storyPrompt(string $subject, string $genre = 'fantasy'): array - { - // In a real scenario, this would return the prompt string - return [ - [ - 'role' => 'user', - 'content' => "Write a short {$genre} story about {$subject}.", - ], - ]; - } - - #[McpPrompt] - public function simplePrompt(): array - { - return [ - [ - 'role' => 'user', - 'content' => 'This is a simple prompt with no arguments.', - ], - ]; - } - - #[McpResource(uri: 'config://app/name', name: 'app_name', description: 'The application name.', mimeType: 'text/plain')] - public function getAppName(): string - { - // In a real scenario, this would fetch the config value - return 'My MCP App'; - } - - #[McpResource(uri: 'file://data/users.csv', name: 'users_csv', mimeType: 'text/csv')] - public function getUserData(): string - { - // In a real scenario, this would return file content - return "id,name\n1,Alice\n2,Bob"; - } - - #[McpResourceTemplate(uriTemplate: 'user://{userId}/profile', name: 'user_profile', mimeType: 'application/json')] - public function getUserProfileTemplate(string $userId): array - { - // In a real scenario, this would fetch user data based on userId - return ['id' => $userId, 'name' => 'User '.$userId, 'email' => $userId.'@example.com']; - } -} diff --git a/samples/php_stdio/composer.json b/samples/php_stdio/composer.json deleted file mode 100644 index bcdad93..0000000 --- a/samples/php_stdio/composer.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "php-mcp/stdio-sample", - "description": "STDIO example for php-mcp", - "type": "project", - "require": { - "php": ">=8.1", - "php-mcp/server": "@dev" - }, - "repositories": [ - { - "type": "path", - "url": "../../", - "options": { - "symlink": true - } - } - ], - "autoload": { - "psr-4": { - "Test\\": "" - } - }, - "minimum-stability": "dev", - "prefer-stable": true, - "config": { - "sort-packages": true - } -} \ No newline at end of file diff --git a/samples/php_stdio/server.php b/samples/php_stdio/server.php deleted file mode 100644 index c1b7201..0000000 --- a/samples/php_stdio/server.php +++ /dev/null @@ -1,36 +0,0 @@ -withBasePath(__DIR__) - ->withLogger($logger) - ->withTool([SampleMcpElements::class, 'simpleTool'], 'greeter') - ->withResource([SampleMcpElements::class, 'getUserData'], 'user://data') - ->discover(); - -$exitCode = $server->run('stdio'); - -exit($exitCode); diff --git a/samples/reactphp_http/.gitignore b/samples/reactphp_http/.gitignore deleted file mode 100644 index 2668607..0000000 --- a/samples/reactphp_http/.gitignore +++ /dev/null @@ -1,26 +0,0 @@ -# Composer dependencies -/vendor/ -/composer.lock - -# Editor directories and files -/.idea -/.vscode -*.sublime-project -*.sublime-workspace - - -# Operating system files -.DS_Store -Thumbs.db - -# Local environment files -/.env -/.env.backup -/.env.local - -# Local Composer dependencies -composer.phar - - -# Log files -*.log \ No newline at end of file diff --git a/samples/reactphp_http/SampleMcpElements.php b/samples/reactphp_http/SampleMcpElements.php deleted file mode 100644 index d5cc942..0000000 --- a/samples/reactphp_http/SampleMcpElements.php +++ /dev/null @@ -1,89 +0,0 @@ - $input, 'count' => $count]; - } - - /** - * Generates a simple story prompt. - * - * @param string $subject The main subject of the story. - * @param string $genre The genre (e.g., fantasy, sci-fi). - */ - #[McpPrompt(name: 'create_story', description: 'Creates a short story premise.')] - public function storyPrompt(string $subject, string $genre = 'fantasy'): array - { - // In a real scenario, this would return the prompt string - return [ - [ - 'role' => 'user', - 'content' => "Write a short {$genre} story about {$subject}.", - ], - ]; - } - - #[McpPrompt] - public function simplePrompt(): array - { - return [ - [ - 'role' => 'user', - 'content' => 'This is a simple prompt with no arguments.', - ], - ]; - } - - #[McpResource(uri: 'config://app/name', name: 'app_name', description: 'The application name.', mimeType: 'text/plain')] - public function getAppName(): string - { - // In a real scenario, this would fetch the config value - return 'My MCP App'; - } - - #[McpResource(uri: 'file://data/users.csv', name: 'users_csv', mimeType: 'text/csv')] - public function getUserData(): string - { - // In a real scenario, this would return file content - return "id,name\n1,Alice\n2,Bob"; - } - - #[McpResourceTemplate(uriTemplate: 'user://{userId}/profile', name: 'user_profile', mimeType: 'application/json')] - public function getUserProfileTemplate(string $userId): array - { - // In a real scenario, this would fetch user data based on userId - return ['id' => $userId, 'name' => 'User '.$userId, 'email' => $userId.'@example.com']; - } -} diff --git a/samples/reactphp_http/composer.json b/samples/reactphp_http/composer.json deleted file mode 100644 index 96484c8..0000000 --- a/samples/reactphp_http/composer.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "php-mcp/reactphp-sample", - "description": "ReactPHP HTTP+SSE example for php-mcp", - "type": "project", - "require": { - "php": ">=8.1", - "php-mcp/server": "@dev", - "react/http": "^1.11" - }, - "repositories": [ - { - "type": "path", - "url": "../../", - "options": { - "symlink": true - } - } - ], - "autoload": { - "psr-4": { - "Test\\": "" - } - }, - "minimum-stability": "dev", - "prefer-stable": true, - "config": { - "sort-packages": true - } -} diff --git a/samples/reactphp_http/server.php b/samples/reactphp_http/server.php deleted file mode 100644 index 621decc..0000000 --- a/samples/reactphp_http/server.php +++ /dev/null @@ -1,114 +0,0 @@ -withLogger($logger) - ->withBasePath(__DIR__) - ->discover(); - -$transportHandler = new ReactPhpHttpTransportHandler($server); - -// --- ReactPHP HTTP Server Setup --- -$postEndpoint = '/mcp/message'; -$sseEndpoint = '/mcp/sse'; -$listenAddress = '127.0.0.1:8080'; - -$http = new HttpServer(function (ServerRequestInterface $request) use ($logger, $transportHandler, $postEndpoint, $sseEndpoint): ResponseInterface|Promise { - $path = $request->getUri()->getPath(); - $method = $request->getMethod(); - $responseHeaders = ['Access-Control-Allow-Origin' => '*']; - - $logger->info('Received request', ['method' => $method, 'path' => $path]); - - // POST Endpoint Handling - if ($method === 'POST' && str_starts_with($path, $postEndpoint)) { - $queryParams = $request->getQueryParams(); - $clientId = $queryParams['clientId'] ?? null; - - if (! $clientId || ! is_string($clientId)) { - return new Response(400, $responseHeaders, 'Error: Missing or invalid clientId query parameter'); - } - if (! str_contains($request->getHeaderLine('Content-Type'), 'application/json')) { - return new Response(415, $responseHeaders, 'Error: Content-Type must be application/json'); - } - - $requestBody = (string) $request->getBody(); - if (empty($requestBody)) { - return new Response(400, $responseHeaders, 'Error: Empty request body'); - } - - try { - $transportHandler->handleInput($requestBody, $clientId); - - return new Response(202, $responseHeaders); // Accepted - } catch (JsonException $e) { - return new Response(400, $responseHeaders, "Error: Invalid JSON - {$e->getMessage()}"); - } catch (Throwable $e) { - return new Response(500, $responseHeaders, 'Error: Internal Server Error'); - } - } - - // SSE Endpoint Handling - if ($method === 'GET' && $path === $sseEndpoint) { - $clientId = 'client_'.bin2hex(random_bytes(16)); - - $logger->info('ReactPHP SSE connection opening', ['client_id' => $clientId]); - - $stream = new ThroughStream; - - $postEndpointWithClientId = $postEndpoint.'?clientId='.urlencode($clientId); - - $transportHandler->setClientSseStream($clientId, $stream); - - $transportHandler->handleSseConnection($clientId, $postEndpointWithClientId); - - $sseHeaders = [ - 'Content-Type' => 'text/event-stream', - 'Cache-Control' => 'no-cache', - 'Connection' => 'keep-alive', - 'X-Accel-Buffering' => 'no', - 'Access-Control-Allow-Origin' => '*', - ]; - - return new Response(200, $sseHeaders, $stream); - } - - // Fallback 404 - return new Response(404, $responseHeaders, 'Not Found'); -}); - -$socket = new SocketServer($listenAddress); - -$logger->info("ReactPHP MCP Server listening on {$listenAddress}"); -$logger->info("SSE Endpoint: http://{$listenAddress}{$sseEndpoint}"); -$logger->info("POST Endpoint: (Sent via SSE 'endpoint' event)"); -echo "ReactPHP MCP Server listening on http://{$listenAddress}\n"; - -$http->listen($socket); diff --git a/samples/sample_messages.md b/samples/sample_messages.md deleted file mode 100644 index 021457b..0000000 --- a/samples/sample_messages.md +++ /dev/null @@ -1,49 +0,0 @@ - -```json -{"jsonrpc": "2.0","id": 1,"method": "initialize","params": {"protocolVersion": "2024-11-05","capabilities": {"roots": {"listChanged": true},"sampling": {}},"clientInfo": {"name": "ExampleClient","version": "1.0.0"}}} -``` - -```json -{"jsonrpc": "2.0", "method": "notifications/initialized"} -``` - -```json -{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name": "greet_user","arguments":{"name":"Kyrian"}}} -{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name": "get_last_sum","arguments":{}}} -``` - -```json -{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name": "anotherTool", "arguments": {"input": "test data"}}} -``` - -```json -{"jsonrpc":"2.0","id":4,"method":"tools/list"} -``` - -```json -{"jsonrpc":"2.0","id":5,"method":"resources/list"} -``` - -```json -{"jsonrpc":"2.0","id":6,"method":"resources/read","params":{"uri": "config://app/name"}} -``` - -```json -{"jsonrpc":"2.0","id":7,"method":"resources/read","params":{"uri": "file://data/users.csv"}} -``` - -```json -{"jsonrpc":"2.0","id":8,"method":"resources/templates/list"} -``` - -```json -{"jsonrpc":"2.0","id":9,"method":"prompts/list"} -``` - -```json -{"jsonrpc":"2.0","id":10,"method":"prompts/get","params":{"name": "create_story", "arguments": {"subject": "a lost robot", "genre": "sci-fi"}}} -``` - -```json -{"jsonrpc":"2.0","id":11,"method":"prompts/get","params":{"name": "simplePrompt"}} -``` diff --git a/src/Attributes/McpPrompt.php b/src/Attributes/McpPrompt.php index 61aeb50..4e6d167 100644 --- a/src/Attributes/McpPrompt.php +++ b/src/Attributes/McpPrompt.php @@ -18,5 +18,6 @@ final class McpPrompt public function __construct( public ?string $name = null, public ?string $description = null, - ) {} + ) { + } } diff --git a/src/Attributes/McpResource.php b/src/Attributes/McpResource.php index 7713f20..f4b0774 100644 --- a/src/Attributes/McpResource.php +++ b/src/Attributes/McpResource.php @@ -26,5 +26,6 @@ public function __construct( public ?string $mimeType = null, public ?int $size = null, public array $annotations = [], - ) {} + ) { + } } diff --git a/src/Attributes/McpResourceTemplate.php b/src/Attributes/McpResourceTemplate.php index f383a8b..95a7c88 100644 --- a/src/Attributes/McpResourceTemplate.php +++ b/src/Attributes/McpResourceTemplate.php @@ -24,5 +24,6 @@ public function __construct( public ?string $description = null, public ?string $mimeType = null, public array $annotations = [], - ) {} + ) { + } } diff --git a/src/Attributes/McpTool.php b/src/Attributes/McpTool.php index 378ddd4..3298aa0 100644 --- a/src/Attributes/McpTool.php +++ b/src/Attributes/McpTool.php @@ -14,5 +14,6 @@ class McpTool public function __construct( public ?string $name = null, public ?string $description = null, - ) {} + ) { + } } diff --git a/src/ClientStateManager.php b/src/ClientStateManager.php new file mode 100644 index 0000000..900a11b --- /dev/null +++ b/src/ClientStateManager.php @@ -0,0 +1,526 @@ +logger = $logger; + $this->cache = $cache; + $this->cachePrefix = $cachePrefix; + $this->cacheTtl = max(60, $cacheTtl); // Minimum TTL of 60 seconds + } + + private function getCacheKey(string $key, ?string $clientId = null): string + { + return $clientId ? "{$this->cachePrefix}{$key}_{$clientId}" : "{$this->cachePrefix}{$key}"; + } + + // --- Initialization --- + + public function isInitialized(string $clientId): bool + { + if (! $this->cache) { + return false; + } + + return (bool) $this->cache->get($this->getCacheKey('initialized', $clientId), false); + } + + public function markInitialized(string $clientId): void + { + if (! $this->cache) { + $this->logger->warning('Cannot mark client as initialized, cache not available.', ['clientId' => $clientId]); + + return; + } + try { + $this->cache->set($this->getCacheKey('initialized', $clientId), true, $this->cacheTtl); + $this->updateClientActivity($clientId); + $this->logger->info('MCP State: Client marked initialized.', ['client_id' => $clientId]); + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('MCP State: Failed to mark client as initialized in cache (invalid key).', ['clientId' => $clientId, 'exception' => $e]); + } catch (Throwable $e) { + $this->logger->error('MCP State: Failed to mark client as initialized in cache.', ['clientId' => $clientId, 'exception' => $e]); + } + } + + public function storeClientInfo(array $clientInfo, string $protocolVersion, string $clientId): void + { + if (! $this->cache) { + return; + } + try { + $this->cache->set($this->getCacheKey('client_info', $clientId), $clientInfo, $this->cacheTtl); + $this->cache->set($this->getCacheKey('protocol_version', $clientId), $protocolVersion, $this->cacheTtl); + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('MCP State: Failed to store client info in cache (invalid key).', ['clientId' => $clientId, 'exception' => $e]); + } catch (Throwable $e) { + $this->logger->error('MCP State: Failed to store client info in cache.', ['clientId' => $clientId, 'exception' => $e]); + } + } + + public function getClientInfo(string $clientId): ?array + { + if (! $this->cache) { + return null; + } + try { + return $this->cache->get($this->getCacheKey('client_info', $clientId)); + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('MCP State: Failed to get client info from cache (invalid key).', ['clientId' => $clientId, 'exception' => $e]); + + return null; + } catch (Throwable $e) { + $this->logger->error('MCP State: Failed to get client info from cache.', ['clientId' => $clientId, 'exception' => $e]); + + return null; + } + } + + public function getProtocolVersion(string $clientId): ?string + { + if (! $this->cache) { + return null; + } + try { + return $this->cache->get($this->getCacheKey('protocol_version', $clientId)); + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('MCP State: Failed to get protocol version from cache (invalid key).', ['clientId' => $clientId, 'exception' => $e]); + + return null; + } catch (Throwable $e) { + $this->logger->error('MCP State: Failed to get protocol version from cache.', ['clientId' => $clientId, 'exception' => $e]); + + return null; + } + } + + // --- Subscriptions (methods need cache check) --- + + public function addResourceSubscription(string $clientId, string $uri): void + { + if (! $this->cache) { + $this->logger->warning('Cannot add resource subscription, cache not available.', ['clientId' => $clientId, 'uri' => $uri]); + + return; + } + try { + $clientSubKey = $this->getCacheKey('client_subscriptions', $clientId); + $resourceSubKey = $this->getCacheKey('resource_subscriptions', $uri); + + // It's safer to get existing, modify, then set, though slightly less atomic + $clientSubscriptions = $this->cache->get($clientSubKey, []); + $resourceSubscriptions = $this->cache->get($resourceSubKey, []); + + $clientSubscriptions = is_array($clientSubscriptions) ? $clientSubscriptions : []; + $resourceSubscriptions = is_array($resourceSubscriptions) ? $resourceSubscriptions : []; + + $clientSubscriptions[$uri] = true; + $resourceSubscriptions[$clientId] = true; + + $this->cache->set($clientSubKey, $clientSubscriptions, $this->cacheTtl); + $this->cache->set($resourceSubKey, $resourceSubscriptions, $this->cacheTtl); + + $this->logger->debug('MCP State: Client subscribed to resource.', ['clientId' => $clientId, 'uri' => $uri]); + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('MCP State: Failed to add resource subscription (invalid key).', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); + } catch (Throwable $e) { + $this->logger->error('MCP State: Failed to add resource subscription.', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); + } + } + + public function removeResourceSubscription(string $clientId, string $uri): void + { + if (! $this->cache) { + return; + } + try { + $clientSubKey = $this->getCacheKey('client_subscriptions', $clientId); + $resourceSubKey = $this->getCacheKey('resource_subscriptions', $uri); + + $clientSubscriptions = $this->cache->get($clientSubKey, []); + $resourceSubscriptions = $this->cache->get($resourceSubKey, []); + $clientSubscriptions = is_array($clientSubscriptions) ? $clientSubscriptions : []; + $resourceSubscriptions = is_array($resourceSubscriptions) ? $resourceSubscriptions : []; + + $clientChanged = false; + if (isset($clientSubscriptions[$uri])) { + unset($clientSubscriptions[$uri]); + $clientChanged = true; + } + + $resourceChanged = false; + if (isset($resourceSubscriptions[$clientId])) { + unset($resourceSubscriptions[$clientId]); + $resourceChanged = true; + } + + if ($clientChanged) { + if (empty($clientSubscriptions)) { + $this->cache->delete($clientSubKey); + } else { + $this->cache->set($clientSubKey, $clientSubscriptions, $this->cacheTtl); + } + } + if ($resourceChanged) { + if (empty($resourceSubscriptions)) { + $this->cache->delete($resourceSubKey); + } else { + $this->cache->set($resourceSubKey, $resourceSubscriptions, $this->cacheTtl); + } + } + + if ($clientChanged || $resourceChanged) { + $this->logger->debug('MCP State: Client unsubscribed from resource.', ['clientId' => $clientId, 'uri' => $uri]); + } + + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('MCP State: Failed to remove resource subscription (invalid key).', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); + } catch (Throwable $e) { + $this->logger->error('MCP State: Failed to remove resource subscription.', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); + } + } + + public function removeAllResourceSubscriptions(string $clientId): void + { + if (! $this->cache) { + return; + } + try { + $clientSubKey = $this->getCacheKey('client_subscriptions', $clientId); + $clientSubscriptions = $this->cache->get($clientSubKey, []); + $clientSubscriptions = is_array($clientSubscriptions) ? $clientSubscriptions : []; + + if (empty($clientSubscriptions)) { + return; + } + + $uris = array_keys($clientSubscriptions); + $keysToDeleteFromResources = []; + $keysToUpdateResources = []; + $updatedResourceSubs = []; + + foreach ($uris as $uri) { + $resourceSubKey = $this->getCacheKey('resource_subscriptions', $uri); + $resourceSubscriptions = $this->cache->get($resourceSubKey, []); + $resourceSubscriptions = is_array($resourceSubscriptions) ? $resourceSubscriptions : []; + + if (isset($resourceSubscriptions[$clientId])) { + unset($resourceSubscriptions[$clientId]); + if (empty($resourceSubscriptions)) { + $keysToDeleteFromResources[] = $resourceSubKey; + } else { + $keysToUpdateResources[] = $resourceSubKey; + $updatedResourceSubs[$resourceSubKey] = $resourceSubscriptions; + } + } + } + + // Perform cache operations + if (! empty($keysToDeleteFromResources)) { + $this->cache->deleteMultiple($keysToDeleteFromResources); + } + foreach ($keysToUpdateResources as $key) { + $this->cache->set($key, $updatedResourceSubs[$key], $this->cacheTtl); + } + $this->cache->delete($clientSubKey); // Remove client's master list + + $this->logger->debug('MCP State: Client removed all resource subscriptions.', ['clientId' => $clientId, 'count' => count($uris)]); + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('MCP State: Failed to remove all resource subscriptions (invalid key).', ['clientId' => $clientId, 'exception' => $e]); + } catch (Throwable $e) { + $this->logger->error('MCP State: Failed to remove all resource subscriptions.', ['clientId' => $clientId, 'exception' => $e]); + } + } + + /** @return array */ + public function getResourceSubscribers(string $uri): array + { + if (! $this->cache) { + return []; + } + try { + $resourceSubscriptions = $this->cache->get($this->getCacheKey('resource_subscriptions', $uri), []); + + return is_array($resourceSubscriptions) ? array_keys($resourceSubscriptions) : []; + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('MCP State: Failed to get resource subscribers (invalid key).', ['uri' => $uri, 'exception' => $e]); + + return []; + } catch (Throwable $e) { + $this->logger->error('MCP State: Failed to get resource subscribers.', ['uri' => $uri, 'exception' => $e]); + + return []; + } + } + + public function isSubscribedToResource(string $clientId, string $uri): bool + { + if (! $this->cache) { + return false; + } + try { + $clientSubscriptions = $this->cache->get($this->getCacheKey('client_subscriptions', $clientId), []); + + return is_array($clientSubscriptions) && isset($clientSubscriptions[$uri]); + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('MCP State: Failed to check resource subscription (invalid key).', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); + + return false; + } catch (Throwable $e) { + $this->logger->error('MCP State: Failed to check resource subscription.', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); + + return false; + } + } + + // --- Message Queue (methods need cache check) --- + + public function queueMessage(string $clientId, Message|array $message): void + { + if (! $this->cache) { + $this->logger->warning('Cannot queue message, cache not available.', ['clientId' => $clientId]); + + return; + } + try { + $key = $this->getCacheKey('messages', $clientId); + $messages = $this->cache->get($key, []); + $messages = is_array($messages) ? $messages : []; + + $newMessages = []; + if (is_array($message)) { + foreach ($message as $singleMessage) { + if ($singleMessage instanceof Message) { + $newMessages[] = $singleMessage->toArray(); + } + } + } elseif ($message instanceof Message) { + $newMessages[] = $message->toArray(); + } + + if (! empty($newMessages)) { + $this->cache->set($key, array_merge($messages, $newMessages), $this->cacheTtl); + $this->logger->debug('MCP State: Queued message(s).', ['clientId' => $clientId, 'count' => count($newMessages)]); + } + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('MCP State: Failed to queue message (invalid key).', ['clientId' => $clientId, 'exception' => $e]); + } catch (Throwable $e) { + $this->logger->error('MCP State: Failed to queue message.', ['clientId' => $clientId, 'exception' => $e]); + } + } + + public function queueMessageForAll(Message|array $message): void + { + if (! $this->cache) { + $this->logger->warning('Cannot queue message for all, cache not available.'); + + return; + } + $clients = $this->getActiveClients(); // getActiveClients handles cache check internally + if (empty($clients)) { + $this->logger->debug('MCP State: No active clients found to queue message for.'); + + return; + } + $this->logger->debug('MCP State: Queuing message for all active clients.', ['count' => count($clients)]); + foreach ($clients as $clientId) { + $this->queueMessage($clientId, $message); + } + } + + /** @return array */ + public function getQueuedMessages(string $clientId): array + { + if (! $this->cache) { + return []; + } + try { + $key = $this->getCacheKey('messages', $clientId); + $messages = $this->cache->get($key, []); + $messages = is_array($messages) ? $messages : []; + + if (! empty($messages)) { + $this->cache->delete($key); + $this->logger->debug('MCP State: Retrieved and cleared queued messages.', ['clientId' => $clientId, 'count' => count($messages)]); + } + + return $messages; + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('MCP State: Failed to get/delete queued messages (invalid key).', ['clientId' => $clientId, 'exception' => $e]); + + return []; + } catch (Throwable $e) { + $this->logger->error('MCP State: Failed to get/delete queued messages.', ['clientId' => $clientId, 'exception' => $e]); + + return []; + } + } + + // --- Client Management --- + + public function cleanupClient(string $clientId, bool $removeFromActiveList = true): void + { + $this->removeAllResourceSubscriptions($clientId); + + if (! $this->cache) { + $this->logger->warning('Cannot perform full client cleanup, cache not available.', ['clientId' => $clientId]); + + return; + } + + try { + if ($removeFromActiveList) { + $activeClientsKey = $this->getCacheKey('active_clients'); + $activeClients = $this->cache->get($activeClientsKey, []); + $activeClients = is_array($activeClients) ? $activeClients : []; + if (isset($activeClients[$clientId])) { + unset($activeClients[$clientId]); + $this->cache->set($activeClientsKey, $activeClients, $this->cacheTtl); + } + } + + // Delete other client-specific data + $keysToDelete = [ + $this->getCacheKey('initialized', $clientId), + $this->getCacheKey('client_info', $clientId), + $this->getCacheKey('protocol_version', $clientId), + $this->getCacheKey('messages', $clientId), + // client_subscriptions key already deleted by removeAllResourceSubscriptions if needed + ]; + $this->cache->deleteMultiple($keysToDelete); + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('MCP State: Failed to remove client data from cache (invalid key).', ['clientId' => $clientId, 'exception' => $e]); + } catch (Throwable $e) { + $this->logger->error('MCP State: Failed to remove client data from cache.', ['clientId' => $clientId, 'exception' => $e]); + } + } + + public function updateClientActivity(string $clientId): void + { + if (! $this->cache) { + return; + } + try { + $key = $this->getCacheKey('active_clients'); + $activeClients = $this->cache->get($key, []); + $activeClients = is_array($activeClients) ? $activeClients : []; + $activeClients[$clientId] = time(); // Using integer timestamp + $this->cache->set($key, $activeClients, $this->cacheTtl); + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('MCP State: Failed to update client activity (invalid key).', ['clientId' => $clientId, 'exception' => $e]); + } catch (Throwable $e) { + $this->logger->error('MCP State: Failed to update client activity.', ['clientId' => $clientId, 'exception' => $e]); + } + } + + /** @return array Active client IDs */ + public function getActiveClients(int $inactiveThreshold = 300): array + { + if (! $this->cache) { + return []; + } + try { + $activeClientsKey = $this->getCacheKey('active_clients'); + $activeClients = $this->cache->get($activeClientsKey, []); + $activeClients = is_array($activeClients) ? $activeClients : []; + + $currentTime = time(); + $result = []; + $clientsToCleanUp = []; + $listChanged = false; + + foreach ($activeClients as $clientId => $lastSeen) { + if (! is_int($lastSeen)) { // Data sanity check + $this->logger->warning('Invalid timestamp found in active clients list, removing.', ['clientId' => $clientId, 'value' => $lastSeen]); + $clientsToCleanUp[] = $clientId; + $listChanged = true; + + continue; + } + if ($currentTime - $lastSeen < $inactiveThreshold) { + $result[] = $clientId; + } else { + $this->logger->info('MCP State: Client considered inactive, scheduling cleanup.', ['clientId' => $clientId, 'last_seen' => $lastSeen]); + $clientsToCleanUp[] = $clientId; + $listChanged = true; + } + } + + if ($listChanged) { + $updatedActiveClients = $activeClients; + foreach ($clientsToCleanUp as $idToClean) { + unset($updatedActiveClients[$idToClean]); + } + $this->cache->set($activeClientsKey, $updatedActiveClients, $this->cacheTtl); + + // Perform cleanup for inactive clients (without removing from list again) + foreach ($clientsToCleanUp as $idToClean) { + $this->cleanupClient($idToClean, false); + } + } + + return $result; + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('MCP State: Failed to get active clients (invalid key).', ['exception' => $e]); + + return []; + } catch (Throwable $e) { + $this->logger->error('MCP State: Failed to get active clients.', ['exception' => $e]); + + return []; + } + } + + /** Retrieves the last activity timestamp for a specific client. */ + public function getLastActivityTime(string $clientId): ?int // Return int (Unix timestamp) + { + if (! $this->cache) { + return null; + } + try { + $activeClientsKey = $this->getCacheKey('active_clients'); + $activeClients = $this->cache->get($activeClientsKey, []); + $activeClients = is_array($activeClients) ? $activeClients : []; + + $lastSeen = $activeClients[$clientId] ?? null; + + return is_int($lastSeen) ? $lastSeen : null; + + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('MCP State: Failed to get client activity time (invalid key).', ['clientId' => $clientId, 'exception' => $e]); + + return null; + } catch (Throwable $e) { + $this->logger->error('MCP State: Failed to get client activity time.', ['clientId' => $clientId, 'exception' => $e]); + + return null; + } + } +} diff --git a/src/Configuration.php b/src/Configuration.php new file mode 100644 index 0000000..8825856 --- /dev/null +++ b/src/Configuration.php @@ -0,0 +1,41 @@ + Resolves on successful send/queue, rejects on specific send error. + */ + public function sendToClientAsync(string $clientId, string $rawFramedMessage): PromiseInterface; + + /** + * Stops the transport listener gracefully and closes all active connections. + * MUST eventually emit a 'close' event for the transport itself. + * Individual client disconnects should emit 'client_disconnected' events. + */ + public function close(): void; +} diff --git a/src/Contracts/TransportHandlerInterface.php b/src/Contracts/TransportHandlerInterface.php deleted file mode 100644 index 4981a5d..0000000 --- a/src/Contracts/TransportHandlerInterface.php +++ /dev/null @@ -1,45 +0,0 @@ -has($key)) { - return $default; - } - - return $this->store[$key]; - } - - public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool - { - $this->store[$key] = $value; - $this->expiries[$key] = $this->calculateExpiry($ttl); - - return true; - } - - public function delete(string $key): bool - { - unset($this->store[$key], $this->expiries[$key]); - - return true; - } - - public function clear(): bool - { - $this->store = []; - $this->expiries = []; - - return true; - } - - public function getMultiple(iterable $keys, mixed $default = null): iterable - { - $result = []; - foreach ($keys as $key) { - $result[$key] = $this->get($key, $default); - } - - return $result; - } - - public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool - { - $expiry = $this->calculateExpiry($ttl); - foreach ($values as $key => $value) { - $this->store[$key] = $value; - $this->expiries[$key] = $expiry; - } - - return true; - } - - public function deleteMultiple(iterable $keys): bool - { - foreach ($keys as $key) { - unset($this->store[$key], $this->expiries[$key]); - } - - return true; - } - - public function has(string $key): bool - { - if (! isset($this->store[$key])) { - return false; - } - // Check expiry - if (isset($this->expiries[$key]) && $this->expiries[$key] !== null && time() >= $this->expiries[$key]) { - $this->delete($key); - - return false; - } - - return true; - } - - private function calculateExpiry(DateInterval|int|null $ttl): ?int - { - if ($ttl === null) { - return null; // No expiry - } - if (is_int($ttl)) { - return time() + $ttl; - } - if ($ttl instanceof DateInterval) { - return (new DateTime())->add($ttl)->getTimestamp(); - } - - // Invalid TTL type, treat as no expiry - return null; - } -} diff --git a/src/Defaults/ArrayConfigurationRepository.php b/src/Defaults/ArrayConfigurationRepository.php deleted file mode 100644 index 5dba762..0000000 --- a/src/Defaults/ArrayConfigurationRepository.php +++ /dev/null @@ -1,84 +0,0 @@ -items = $items; - } - - public function has(string $key): bool - { - return $this->get($key) !== null; // Simplistic check, might need refinement for explicit nulls - } - - public function get(string $key, mixed $default = null): mixed - { - if (array_key_exists($key, $this->items)) { - return $this->items[$key]; - } - - if (strpos($key, '.') === false) { - return $default; - } - - $items = $this->items; - foreach (explode('.', $key) as $segment) { - if (is_array($items) && array_key_exists($segment, $items)) { - $items = $items[$segment]; - } else { - return $default; - } - } - - return $items; - } - - public function set(string $key, mixed $value): void - { - $keys = explode('.', $key); - $items = &$this->items; - - while (count($keys) > 1) { - $key = array_shift($keys); - if (! isset($items[$key]) || ! is_array($items[$key])) { - $items[$key] = []; - } - $items = &$items[$key]; - } - - $items[array_shift($keys)] = $value; - } - - public function offsetExists(mixed $key): bool - { - return $this->has($key); - } - - public function offsetGet(mixed $key): mixed - { - return $this->get($key); - } - - public function offsetSet(mixed $key, mixed $value): void - { - $this->set($key, $value); - } - - public function offsetUnset(mixed $key): void - { - $this->set($key, null); // Or implement actual unset logic if needed - } - - public function all(): array - { - return $this->items; - } -} diff --git a/src/Defaults/BasicContainer.php b/src/Defaults/BasicContainer.php index 5017527..4f52172 100644 --- a/src/Defaults/BasicContainer.php +++ b/src/Defaults/BasicContainer.php @@ -1,90 +1,186 @@ Simple cache for already created instances */ + /** @var array Cache for already created instances (shared singletons) */ private array $instances = []; + /** @var array Track classes currently being resolved to detect circular dependencies */ + private array $resolving = []; + /** * Finds an entry of the container by its identifier and returns it. * - * @param string $id Identifier of the entry to look for. + * @param string $id Identifier of the entry to look for (usually a FQCN). * @return mixed Entry. * * @throws NotFoundExceptionInterface No entry was found for **this** identifier. - * @throws ContainerExceptionInterface Error while retrieving the entry. + * @throws ContainerExceptionInterface Error while retrieving the entry (e.g., dependency resolution failure, circular dependency). */ public function get(string $id): mixed { + // 1. Check instance cache if (isset($this->instances[$id])) { return $this->instances[$id]; } - if (! class_exists($id)) { - throw new NotFoundException("Class or entry '{$id}' not found."); + // 2. Check if class exists + if (! class_exists($id) && ! interface_exists($id)) { // Also check interface for bindings + throw new NotFoundException("Class, interface, or entry '{$id}' not found."); + } + + // 7. Circular Dependency Check + if (isset($this->resolving[$id])) { + throw new ContainerException("Circular dependency detected while resolving '{$id}'. Resolution path: ".implode(' -> ', array_keys($this->resolving))." -> {$id}"); } + $this->resolving[$id] = true; // Mark as currently resolving + try { + // 3. Reflect on the class $reflector = new ReflectionClass($id); + + // Check if class is instantiable (abstract classes, interfaces cannot be directly instantiated) if (! $reflector->isInstantiable()) { - throw new ContainerException("Class '{$id}' is not instantiable."); + // We might have an interface bound to a concrete class via set() + // This check is slightly redundant due to class_exists but good practice + throw new ContainerException("Class '{$id}' is not instantiable (e.g., abstract class or interface without explicit binding)."); } + // 4. Get the constructor $constructor = $reflector->getConstructor(); - if ($constructor && $constructor->getNumberOfRequiredParameters() > 0) { - throw new ContainerException("Cannot auto-instantiate class '{$id}' with required constructor parameters using this basic container."); + + // 5. If no constructor or constructor has no parameters, instantiate directly + if ($constructor === null || $constructor->getNumberOfParameters() === 0) { + $instance = $reflector->newInstance(); + } else { + // 6. Constructor has parameters, attempt to resolve them + $parameters = $constructor->getParameters(); + $resolvedArgs = []; + + foreach ($parameters as $parameter) { + $resolvedArgs[] = $this->resolveParameter($parameter, $id); + } + + // Instantiate with resolved arguments + $instance = $reflector->newInstanceArgs($resolvedArgs); } - $instance = $reflector->newInstance(); - $this->instances[$id] = $instance; // Cache the instance + // Cache the instance + $this->instances[$id] = $instance; return $instance; } catch (ReflectionException $e) { - throw new ContainerException("Failed to reflect class '{$id}'.", 0, $e); - } catch (\Throwable $e) { - // Catch any other errors during instantiation - throw new ContainerException("Failed to instantiate class '{$id}'.", 0, $e); + throw new ContainerException("Reflection failed for '{$id}'.", 0, $e); + } catch (ContainerExceptionInterface $e) { // Re-throw container exceptions directly + throw $e; + } catch (Throwable $e) { // Catch other instantiation errors + throw new ContainerException("Failed to instantiate or resolve dependencies for '{$id}': ".$e->getMessage(), (int) $e->getCode(), $e); + } finally { + // 7. Remove from resolving stack once done (success or failure) + unset($this->resolving[$id]); } } /** - * Returns true if the container can return an entry for the given identifier. - * Returns false otherwise. + * Attempts to resolve a single constructor parameter. * - * `has($id)` returning true does not mean that `get($id)` will not throw an exception. - * It does however mean that `get($id)` will not throw a `NotFoundExceptionInterface`. - * - * @param string $id Identifier of the entry to look for. + * @throws ContainerExceptionInterface If a required dependency cannot be resolved. + */ + private function resolveParameter(ReflectionParameter $parameter, string $consumerClassId): mixed + { + // Check for type hint + $type = $parameter->getType(); + + if ($type instanceof ReflectionNamedType && ! $type->isBuiltin()) { + // Type hint is a class or interface name + $typeName = $type->getName(); + try { + // Recursively get the dependency + return $this->get($typeName); + } catch (NotFoundExceptionInterface $e) { + // Dependency class not found, fail ONLY if required + if (! $parameter->isOptional() && ! $parameter->allowsNull()) { + throw new ContainerException("Unresolvable dependency '{$typeName}' required by '{$consumerClassId}' constructor parameter \${$parameter->getName()}.", 0, $e); + } + // If optional or nullable, proceed (will check allowsNull/Default below) + } catch (ContainerExceptionInterface $e) { + // Dependency itself failed to resolve (e.g., its own deps, circular) + throw new ContainerException("Failed to resolve dependency '{$typeName}' for '{$consumerClassId}' parameter \${$parameter->getName()}: ".$e->getMessage(), 0, $e); + } + } + + // Check if parameter has a default value + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + // Check if parameter allows null (and wasn't resolved above) + if ($parameter->allowsNull()) { + return null; + } + + // Check if it was a built-in type without a default (unresolvable by this basic container) + if ($type instanceof ReflectionNamedType && $type->isBuiltin()) { + throw new ContainerException("Cannot auto-wire built-in type '{$type->getName()}' for required parameter \${$parameter->getName()} in '{$consumerClassId}' constructor. Provide a default value or use a more advanced container."); + } + + // Check if it was a union/intersection type without a default (also unresolvable) + if ($type !== null && ! $type instanceof ReflectionNamedType) { + throw new ContainerException("Cannot auto-wire complex type (union/intersection) for required parameter \${$parameter->getName()} in '{$consumerClassId}' constructor. Provide a default value or use a more advanced container."); + } + + // If we reach here, it's an untyped, required parameter without a default. + // Or potentially an unresolvable optional class dependency where null is not allowed (edge case). + throw new ContainerException("Cannot resolve required parameter \${$parameter->getName()} for '{$consumerClassId}' constructor (untyped or unresolvable complex type)."); + } + + /** + * Returns true if the container can return an entry for the given identifier. + * Checks explicitly set instances and if the class/interface exists. + * Does not guarantee `get()` will succeed if auto-wiring fails. */ public function has(string $id): bool { - // Only checks if the class exists, not if it's instantiable by this container - return class_exists($id); + return isset($this->instances[$id]) || class_exists($id) || interface_exists($id); } /** - * Adds a pre-built instance to the container (simple singleton behavior). - * Not part of PSR-11, but useful for basic setup. + * Adds a pre-built instance or a factory/binding to the container. + * This basic version only supports pre-built instances (singletons). */ public function set(string $id, object $instance): void { + // Could add support for closures/factories later if needed $this->instances[$id] = $instance; } } -// Basic ContainerException (not required by PSR-11 but good practice) -class ContainerException extends \Exception implements \Psr\Container\ContainerExceptionInterface +// Keep custom exception classes as they are PSR-11 compliant placeholders +class ContainerException extends \Exception implements ContainerExceptionInterface +{ +} +class NotFoundException extends \Exception implements NotFoundExceptionInterface { } diff --git a/src/Defaults/NotFoundException.php b/src/Defaults/NotFoundException.php deleted file mode 100644 index 8dd4c52..0000000 --- a/src/Defaults/NotFoundException.php +++ /dev/null @@ -1,12 +0,0 @@ - PSR-3 log levels mapped to severity integers */ - protected static array $levels = [ - LogLevel::DEBUG => 100, - LogLevel::INFO => 200, - LogLevel::NOTICE => 250, - LogLevel::WARNING => 300, - LogLevel::ERROR => 400, - LogLevel::CRITICAL => 500, - LogLevel::ALERT => 550, - LogLevel::EMERGENCY => 600, - ]; - - /** @var int Minimum log level severity for this handler */ - protected int $minimumLevelSeverity; - - /** @var string Channel name for the logger */ - protected string $channel; - - /** - * @param resource|string $stream Stream resource or file path. - * @param string $minimumLevel Minimum PSR-3 log level to handle. - * @param string $channel Logger channel name. - * @param int|null $filePermission Optional file permissions (defaults to 0644). - * @param bool $useLocking Enable file locking. - * @param string $fileOpenMode fopen mode. - * - * @throws InvalidArgumentException If stream is not a resource or string. - * @throws InvalidArgumentException If minimumLevel is not a valid PSR-3 log level. - */ - public function __construct( - mixed $stream = STDOUT, - string $minimumLevel = LogLevel::DEBUG, - string $channel = 'mcp', - ?int $filePermission = 0644, - bool $useLocking = false, - string $fileOpenMode = 'a' - ) { - if (! isset(static::$levels[$minimumLevel])) { - throw new InvalidArgumentException("Invalid minimum log level specified: {$minimumLevel}"); - } - $this->minimumLevelSeverity = static::$levels[$minimumLevel]; - $this->channel = $channel; - - if (is_resource($stream)) { - $this->stream = $stream; - } elseif (is_string($stream)) { - $this->url = self::canonicalizePath($stream); - } else { - throw new InvalidArgumentException('A stream must be a resource or a string path.'); - } - - $this->fileOpenMode = $fileOpenMode; - $this->filePermission = $filePermission; - $this->useLocking = $useLocking; - } - - /** - * Logs with an arbitrary level. - * - * @param mixed $level PSR-3 log level constant (e.g., LogLevel::INFO) - * @param string|Stringable $message Message to log - * @param array $context Optional context data - * - * @throws RuntimeException If writing to stream fails. - * @throws LogicException If stream URL is missing. - */ - public function log($level, Stringable|string $message, array $context = []): void - { - if (! isset(static::$levels[$level])) { - // Silently ignore invalid levels? Or throw? PSR-3 says MUST accept any level. - // For internal comparison, we need a valid level. Let's treat unknowns as debug? - // Or maybe throw if it's not one of the standard strings? - // For now, let's ignore if level is unknown for severity check. - $logLevelSeverity = static::$levels[LogLevel::DEBUG]; // Fallback? - // Alternative: Throw new \Psr\Log\InvalidArgumentException("Invalid log level: {$level}"); - } else { - $logLevelSeverity = static::$levels[$level]; - } - - // Check minimum level - if ($logLevelSeverity < $this->minimumLevelSeverity) { - return; - } - - // Ensure stream is open - if (! is_resource($this->stream)) { - if ($this->url === null || $this->url === '') { - throw new LogicException('Missing stream url, the stream cannot be opened. This may be caused by a premature call to close().'); - } - $this->createDir($this->url); - $this->errorMessage = null; - set_error_handler([$this, 'customErrorHandler']); - try { - $stream = fopen($this->url, $this->fileOpenMode); - if ($this->filePermission !== null) { - @chmod($this->url, $this->filePermission); - } - } finally { - restore_error_handler(); - } - - if (! is_resource($stream)) { - $this->stream = null; - throw new RuntimeException(sprintf('The stream "%s" could not be opened: %s', $this->url, $this->errorMessage ?? 'Unknown error')); - } - $this->stream = $stream; - } - - // Format message - // Normalize message - $message = (string) $message; - // Interpolate context - $interpolatedMessage = $this->interpolate($message, $context); - // Build record - $record = [ - 'message' => $interpolatedMessage, - 'context' => $context, - 'level' => $logLevelSeverity, - 'level_name' => strtoupper($level), - 'channel' => $this->channel, - 'datetime' => microtime(true), // Get precise time - 'extra' => [], - ]; - // Format record - $formattedMessage = $this->formatRecord($record); - - // Write to stream - $stream = $this->stream; - if ($this->useLocking) { - flock($stream, LOCK_EX); - } - - $this->errorMessage = null; - set_error_handler([$this, 'customErrorHandler']); - try { - fwrite($stream, $formattedMessage); - } finally { - restore_error_handler(); - } - - if ($this->errorMessage !== null) { - $error = $this->errorMessage; - // Retry logic - if (! $this->retrying && $this->url !== null && $this->url !== 'php://memory') { - $this->retrying = true; - $this->close(); - $this->log($level, $message, $context); // Retry the original message and context - $this->retrying = false; // Reset after retry attempt - - return; - } - // If retry also failed or not applicable - throw new RuntimeException(sprintf('Could not write to stream "%s": %s', $this->url ?? 'Resource', $error)); - } - - $this->retrying = false; - if ($this->useLocking) { - flock($stream, LOCK_UN); - } - } - - /** - * Closes the stream. - */ - public function close(): void - { - if ($this->url !== null && is_resource($this->stream)) { - fclose($this->stream); - } - $this->stream = null; - $this->dirCreated = null; // Reset dir creation status - } - - /** - * Interpolates context values into the message placeholders. - * Basic implementation. - */ - protected function interpolate(string $message, array $context): string - { - if (! str_contains($message, '{')) { - return $message; - } - - $replacements = []; - foreach ($context as $key => $val) { - if ($val === null || is_scalar($val) || (is_object($val) && method_exists($val, '__toString'))) { - $replacements['{'.$key.'}'] = $val; - } elseif (is_object($val)) { - $replacements['{'.$key.'}'] = '[object '.get_class($val).']'; - } else { - $replacements['{'.$key.'}'] = '['.gettype($val).']'; - } - } - - return strtr($message, $replacements); - } - - /** - * Formats a log record into a string for writing. - */ - protected function formatRecord(array $record): string - { - $vars = $record; - $vars['datetime'] = date(static::DATE_FORMAT, (int) $record['datetime']); - // Format context array for log string - $vars['context'] = ! empty($record['context']) ? json_encode($record['context']) : ''; - // Format extra array (if used) - $vars['extra'] = ! empty($record['extra']) ? json_encode($record['extra']) : ''; - - $output = static::LOG_FORMAT; - foreach ($vars as $var => $val) { - if (str_contains($output, '%'.$var.'%')) { - $output = str_replace('%'.$var.'%', (string) $val, $output); - } - } - - // Remove leftover placeholders - $output = preg_replace('/%(?:[a-zA-Z0-9_]+)%/', '', $output) ?? $output; - - return $output; - } - - // --- Stream/File Handling Helpers --- - - /** - * Custom error handler to capture stream operation errors. - */ - private function customErrorHandler(int $code, string $msg): bool - { - $this->errorMessage = preg_replace('{^(fopen|mkdir|fwrite|chmod)\(.*?\): }U', '', $msg); // Added chmod - - return true; - } - - /** - * Creates the directory for the stream if it doesn't exist. - */ - private function createDir(string $url): void - { - if ($this->dirCreated === true) { - return; - } - - $dir = $this->getDirFromStream($url); - if ($dir !== null && ! is_dir($dir)) { - $this->errorMessage = null; - set_error_handler([$this, 'customErrorHandler']); - $status = mkdir($dir, 0777, true); // Use default permissions, let system decide - restore_error_handler(); - - if ($status === false && ! is_dir($dir)) { // Check again if directory exists after race condition - throw new RuntimeException(sprintf('Could not create directory "%s": %s', $dir, $this->errorMessage ?? 'Unknown error')); - } - } - $this->dirCreated = true; - } - - /** - * Gets the directory path from a stream URL/path. - */ - private function getDirFromStream(string $stream): ?string - { - if (str_starts_with($stream, 'file://')) { - return dirname(substr($stream, 7)); - } - // Check if it looks like a path without scheme - if (! str_contains($stream, '://')) { - return dirname($stream); - } - - // Other schemes (php://stdout, etc.) don't have a directory - return null; - } - - /** - * Canonicalizes a path (turns relative into absolute). - */ - public static function canonicalizePath(string $path): string - { - $prefix = ''; - if (str_starts_with($path, 'file://')) { - $path = substr($path, 7); - $prefix = 'file://'; - } - - // If it contains a scheme or is already absolute (Unix/Windows/UNC) - if (str_contains($path, '://') || str_starts_with($path, '/') || preg_match('{^[a-zA-Z]:[/\\]}', $path) || str_starts_with($path, '\\')) { - return $prefix.$path; - } - - // Turn relative path into absolute - $absolutePath = getcwd(); - if ($absolutePath === false) { - throw new RuntimeException('Could not determine current working directory.'); - } - $path = $absolutePath.'/'.$path; - - // Basic path normalization (remove . and ..) - // This is a simplified approach - $path = preg_replace('{[/\\]\.\.?([/\\]|$)}', '/', $path); - - return $prefix.$path; - } - - /** - * Closes the stream on destruction. - */ - public function __destruct() - { - $this->close(); - } -} diff --git a/src/Exception/ConfigurationException.php b/src/Exception/ConfigurationException.php new file mode 100644 index 0000000..04e564c --- /dev/null +++ b/src/Exception/ConfigurationException.php @@ -0,0 +1,16 @@ +data = $data; + } + + /** + * Get additional error data. + * + * @return mixed|null + */ + public function getData(): mixed + { + return $this->data; + } + + /** + * Formats the exception into a JSON-RPC 2.0 error object structure. + * Specific exceptions should override this or provide factories with correct codes. + */ + public function toJsonRpcError(): JsonRpcError + { + $code = ($this->code >= -32768 && $this->code <= -32000) ? $this->code : self::CODE_INTERNAL_ERROR; + + return new JsonRpcError($code, $this->getMessage(), $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); + } + + public static function invalidRequest(?string $details = 'Invalid Request', ?Throwable $previous = null): self + { + return new ProtocolException($details, self::CODE_INVALID_REQUEST, null, $previous); + } + + public static function methodNotFound(string $methodName, ?Throwable $previous = null): self + { + return new ProtocolException("Method not found: {$methodName}", self::CODE_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); + } + + public static function internalError(?string $details = 'Internal server error', ?Throwable $previous = null): self + { + $message = 'Internal error'; + if ($details && is_string($details)) { + $message .= ': '.$details; + } elseif ($previous && $details === null) { + $message .= ' (See server logs)'; + } + + return new McpServerException($message, self::CODE_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(); + } + + return new McpServerException($message, self::CODE_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(); + } + + return new McpServerException($message, self::CODE_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(); + } + + return new McpServerException($message, self::CODE_INTERNAL_ERROR, null, $previous); + } +} diff --git a/src/Exception/ProtocolException.php b/src/Exception/ProtocolException.php new file mode 100644 index 0000000..95f76cc --- /dev/null +++ b/src/Exception/ProtocolException.php @@ -0,0 +1,27 @@ +code >= -32700 && $this->code <= -32600) ? $this->code : self::CODE_INVALID_REQUEST; + + return new \PhpMcp\Server\JsonRpc\Error( + $code, + $this->getMessage(), + $this->getData() + ); + } +} diff --git a/src/Exception/TransportException.php b/src/Exception/TransportException.php new file mode 100644 index 0000000..695bc7a --- /dev/null +++ b/src/Exception/TransportException.php @@ -0,0 +1,23 @@ +getMessage(), + null + ); + } +} diff --git a/src/Exceptions/McpException.php b/src/Exceptions/McpException.php deleted file mode 100644 index eefca1c..0000000 --- a/src/Exceptions/McpException.php +++ /dev/null @@ -1,114 +0,0 @@ -data = $data; - } - - /** - * Get additional error data. - * - * @return mixed|null - */ - public function getData(): mixed - { - return $this->data; - } - - // --- Static Factory Methods for Common Errors --- - - public static function parseError(string $details, ?Throwable $previous = null): self - { - return new self('Could not parse message: '.$details, self::CODE_PARSE_ERROR, null, $previous); - } - - public static function invalidRequest(?string $details = null, ?Throwable $previous = null): self - { - return new self('Invalid Request: '.$details, self::CODE_INVALID_REQUEST, null, $previous); - } - - public static function methodNotFound(string $methodName, ?Throwable $previous = null): self - { - return new self("Method not found: {$methodName}", self::CODE_METHOD_NOT_FOUND, null, $previous); - } - - public static function toolNotFound(string $toolName, ?Throwable $previous = null): self - { - return new self("Tool not found: {$toolName}", self::CODE_METHOD_NOT_FOUND, null, $previous); - } - - public static function invalidParams($message = null, $data = null, ?Throwable $previous = null): self - { - return new self($message ?? 'Invalid params', self::CODE_INVALID_PARAMS, $data, $previous); - } - - public static function internalError(?string $details = null, ?Throwable $previous = null): self - { - $message = 'Internal error'; - if ($details && is_string($details)) { - $message .= ': '.$details; - } elseif ($previous) { - $message .= ' (See server logs)'; - } - - return new self($message, self::CODE_INTERNAL_ERROR, null, $previous); - } - - public static function methodExecutionFailed(string $methodName, ?Throwable $previous = null): self - { - return new self("Execution failed for method '{$methodName}': {$previous->getMessage()}", self::CODE_INTERNAL_ERROR, null, $previous); - } - - /** - * Formats the exception into a JSON-RPC 2.0 error object structure. - */ - public function toJsonRpcError(): Error - { - return new Error($this->getCode(), $this->getMessage(), $this->getData()); - } -} diff --git a/src/JsonRpc/Batch.php b/src/JsonRpc/Batch.php index 9303242..988bb4d 100644 --- a/src/JsonRpc/Batch.php +++ b/src/JsonRpc/Batch.php @@ -2,7 +2,7 @@ namespace PhpMcp\Server\JsonRpc; -use PhpMcp\Server\Exceptions\McpException; +use PhpMcp\Server\Exception\ProtocolException; class Batch { @@ -35,14 +35,14 @@ public function __construct(array $requests = []) public static function fromArray(array $data): self { if (empty($data)) { - throw McpException::invalidRequest('A batch must contain at least one request.'); + throw ProtocolException::invalidRequest('A batch must contain at least one request.'); } $batch = new self(); foreach ($data as $item) { if (! is_array($item)) { - throw McpException::invalidRequest('Each item in a batch must be a valid JSON-RPC object.'); + 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 diff --git a/src/JsonRpc/Error.php b/src/JsonRpc/Error.php index 1dcb76e..2cf28b0 100644 --- a/src/JsonRpc/Error.php +++ b/src/JsonRpc/Error.php @@ -2,6 +2,8 @@ namespace PhpMcp\Server\JsonRpc; +use PhpMcp\Server\Exception\ProtocolException; + class Error { /** @@ -25,6 +27,10 @@ public function __construct( */ public static function fromArray(array $data): self { + if (! isset($data['code']) || ! is_int($data['code'])) { + throw ProtocolException::invalidRequest('Invalid or missing "code" field.'); + } + return new self( $data['code'], $data['message'], diff --git a/src/JsonRpc/Notification.php b/src/JsonRpc/Notification.php index 121b23c..cea9ceb 100644 --- a/src/JsonRpc/Notification.php +++ b/src/JsonRpc/Notification.php @@ -2,7 +2,7 @@ namespace PhpMcp\Server\JsonRpc; -use PhpMcp\Server\Exceptions\McpException; +use PhpMcp\Server\Exception\ProtocolException; class Notification extends Message { @@ -40,12 +40,17 @@ public static function fromArray(array $data): self { // Validate JSON-RPC 2.0 if (! isset($data['jsonrpc']) || $data['jsonrpc'] !== '2.0') { - throw McpException::invalidRequest('Invalid or missing "jsonrpc" version. Must be "2.0".'); + throw ProtocolException::invalidRequest('Invalid or missing "jsonrpc" version. Must be "2.0".'); } // Validate method name if (! isset($data['method']) || ! is_string($data['method'])) { - throw McpException::invalidRequest('Invalid or missing "method" field.'); + throw ProtocolException::invalidRequest('Invalid or missing "method" field.'); + } + + // Validate params is an array (if set) + if (isset($data['params']) && ! is_array($data['params'])) { + throw ProtocolException::invalidRequest('Invalid or missing "params" field.'); } return new self( @@ -55,7 +60,6 @@ public static function fromArray(array $data): self ); } - public function toArray(): array { $result = [ diff --git a/src/JsonRpc/Request.php b/src/JsonRpc/Request.php index 84818f5..5083c89 100644 --- a/src/JsonRpc/Request.php +++ b/src/JsonRpc/Request.php @@ -2,7 +2,7 @@ namespace PhpMcp\Server\JsonRpc; -use PhpMcp\Server\Exceptions\McpException; +use PhpMcp\Server\Exception\ProtocolException; class Request extends Message { @@ -33,24 +33,24 @@ public static function fromArray(array $data): self { // Validate JSON-RPC 2.0 if (! isset($data['jsonrpc']) || $data['jsonrpc'] !== '2.0') { - throw McpException::invalidRequest('Invalid or missing "jsonrpc" version. Must be "2.0".'); + throw ProtocolException::invalidRequest('Invalid or missing "jsonrpc" version. Must be "2.0".'); } // Validate method if (! isset($data['method']) || ! is_string($data['method'])) { - throw McpException::invalidRequest('Invalid or missing "method" field.'); + throw ProtocolException::invalidRequest('Invalid or missing "method" field.'); } // Validate ID if (! isset($data['id'])) { - throw McpException::invalidRequest('Invalid or missing "id" field.'); + throw ProtocolException::invalidRequest('Invalid or missing "id" field.'); } // Check params if present (optional) $params = []; if (isset($data['params'])) { if (! is_array($data['params'])) { - throw McpException::invalidRequest('The "params" field must be an array or object.'); + throw ProtocolException::invalidRequest('The "params" field must be an array or object.'); } $params = $data['params']; } diff --git a/src/JsonRpc/Response.php b/src/JsonRpc/Response.php index 48acc71..6cea195 100644 --- a/src/JsonRpc/Response.php +++ b/src/JsonRpc/Response.php @@ -2,11 +2,13 @@ namespace PhpMcp\Server\JsonRpc; -use PhpMcp\Server\Exceptions\McpException; use JsonSerializable; +use PhpMcp\Server\Exception\ProtocolException; /** * Represents a JSON-RPC response message. + * + * @template T */ class Response extends Message implements JsonSerializable { @@ -14,21 +16,33 @@ class Response extends Message implements JsonSerializable * Create a new JSON-RPC 2.0 response. * * @param string $jsonrpc JSON-RPC version (always "2.0") - * @param string|int $id Request ID this response is for (must match the request) - * @param Result $result Method result (for success) - can be a Result object or array + * @param string|int|null $id Request ID this response is for (must match the request) + * @param T|null $result Method result (for success) - can be a Result object or array * @param Error|null $error Error object (for failure) */ public function __construct( public readonly string $jsonrpc, - public readonly string|int $id, - public readonly ?Result $result = null, + public readonly string|int|null $id, + public readonly mixed $result = null, public readonly ?Error $error = null, ) { - // Responses must have either result or error, not both - if ($this->result !== null && $this->error !== null) { - throw new \InvalidArgumentException( - 'A JSON-RPC response cannot have both result and error.' - ); + // Responses must have either result or error, not both, UNLESS ID is null (error response) + if ($this->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.'); } } @@ -37,46 +51,64 @@ public function __construct( * * @param array $data Raw decoded JSON-RPC response data * - * @throws McpError If the data doesn't conform to JSON-RPC 2.0 structure + * @throws ProtocolException If the data doesn't conform to JSON-RPC 2.0 structure */ public static function fromArray(array $data): self { - // Validate JSON-RPC 2.0 if (! isset($data['jsonrpc']) || $data['jsonrpc'] !== '2.0') { - throw McpException::invalidRequest('Invalid or missing "jsonrpc" version. Must be "2.0".'); + throw new ProtocolException('Invalid or missing "jsonrpc" version. Must be "2.0".'); } - // Validate contains either result or error - if (! isset($data['result']) && ! isset($data['error'])) { - throw McpException::invalidRequest('Response must contain either "result" or "error".'); + // 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.'); } - // Validate ID - if (! isset($data['id'])) { - throw McpException::invalidRequest('Invalid or missing "id" field.'); + $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.'); + } } - // Handle error if present $error = null; - if (isset($data['error'])) { - if (! is_array($data['error'])) { - throw McpException::invalidRequest('The "error" field must be an object.'); - } + $result = null; // Keep result structure flexible (any JSON type) - if (! isset($data['error']['code']) || ! isset($data['error']['message'])) { - throw McpException::invalidRequest('Error object must contain "code" and "message" fields.'); + if ($hasError) { + if (! is_array($data['error'])) { // Error MUST be an object + throw new ProtocolException('Invalid "error" field in response: must be an object.'); } - - $error = Error::fromArray($data['error']); + 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 } - - return new self( - $data['jsonrpc'], - $data['id'], - $data['result'] ?? null, - $error, - ); + 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()); + } } /** @@ -87,26 +119,18 @@ public static function fromArray(array $data): self */ public static function success(Result $result, mixed $id): self { - return new self( - jsonrpc: '2.0', - result: $result, - id: $id, - ); + return new self(jsonrpc: '2.0', result: $result, id: $id); } /** * Create an error response. * * @param Error $error Error object - * @param mixed $id Request ID (can be null for parse errors) + * @param string|int|null $id Request ID (can be null for parse errors) */ - public static function error(Error $error, mixed $id): self + public static function error(Error $error, string|int|null $id): self { - return new self( - jsonrpc: '2.0', - error: $error, - id: $id, - ); + return new self(jsonrpc: '2.0', error: $error, id: $id); } /** @@ -136,7 +160,7 @@ public function toArray(): array ]; if ($this->isSuccess()) { - $result['result'] = $this->result->toArray(); + $result['result'] = is_array($this->result) ? $this->result : $this->result->toArray(); } else { $result['error'] = $this->error->toArray(); } diff --git a/src/Model/Capabilities.php b/src/Model/Capabilities.php new file mode 100644 index 0000000..1b8583f --- /dev/null +++ b/src/Model/Capabilities.php @@ -0,0 +1,112 @@ +|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/Processor.php b/src/Processor.php index ff29aef..6c2b330 100644 --- a/src/Processor.php +++ b/src/Processor.php @@ -1,10 +1,11 @@ config = $this->container->get(ConfigurationRepositoryInterface::class); - $this->logger = $this->container->get(LoggerInterface::class); - - $this->transportState = $transportState ?? new TransportState($this->container); - $this->schemaValidator = $schemaValidator ?? new SchemaValidator($this->logger); - $this->argumentPreparer = $argumentPreparer ?? new ArgumentPreparer($this->logger); + $this->configuration = $configuration; + $this->registry = $registry; + $this->clientStateManager = $clientStateManager; + $this->container = $container; + $this->logger = $configuration->logger; + + $this->schemaValidator = $schemaValidator ?? new SchemaValidator($this->configuration->logger); + $this->argumentPreparer = $argumentPreparer ?? new ArgumentPreparer($this->configuration->logger); } - /** - * Process a JSON-RPC request. - * - * @param Request|Notification $request The JSON-RPC request to process - * @param string $clientId The client ID associated with this request - * @return Response|null The JSON-RPC response or null if the request is a notification - */ public function process(Request|Notification $message, string $clientId): ?Response { $method = $message->method; @@ -92,18 +86,17 @@ public function process(Request|Notification $message, string $clientId): ?Respo } elseif ($method === 'notifications/initialized') { $this->handleNotificationInitialized($params, $clientId); - return null; // Explicitly return null for notifications + return null; } else { - // All other methods require initialization $this->validateClientInitialized($clientId); [$type, $action] = $this->parseMethod($method); - $this->validateCapabilityEnabled($type); // Check if capability is enabled + $this->validateCapabilityEnabled($type); $result = match ($type) { 'tools' => match ($action) { 'list' => $this->handleToolList($params), 'call' => $this->handleToolCall($params), - default => throw McpException::methodNotFound($method), + default => throw McpServerException::methodNotFound($method), }, 'resources' => match ($action) { 'list' => $this->handleResourcesList($params), @@ -111,46 +104,40 @@ public function process(Request|Notification $message, string $clientId): ?Respo 'subscribe' => $this->handleResourceSubscribe($params, $clientId), 'unsubscribe' => $this->handleResourceUnsubscribe($params, $clientId), 'templates/list' => $this->handleResourceTemplateList($params), - default => throw McpException::methodNotFound($method), + default => throw McpServerException::methodNotFound($method), }, 'prompts' => match ($action) { 'list' => $this->handlePromptsList($params), 'get' => $this->handlePromptGet($params), - default => throw McpException::methodNotFound($method), + default => throw McpServerException::methodNotFound($method), }, 'logging' => match ($action) { 'setLevel' => $this->handleLoggingSetLevel($params), - default => throw McpException::methodNotFound($method), + default => throw McpServerException::methodNotFound($method), }, - default => throw McpException::methodNotFound($method), + default => throw McpServerException::methodNotFound($method), }; } - // Only create a response if there's an ID (i.e., it was a Request) - // Ensure $result is not null for requests that should have results 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 McpException::internalError("Processing method '{$method}' failed to return a result."); + throw McpServerException::internalError("Processing method '{$method}' failed to return a result."); } return isset($id) ? Response::success($result, id: $id) : null; - } catch (McpException $e) { - $this->logger->debug('MCP Processor caught McpError', ['method' => $method, 'code' => $e->getCode(), 'message' => $e->getMessage(), 'data' => $e->getData()]); + } 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->getMessage()]); - - $mcpError = McpException::methodExecutionFailed($method, $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; } } - /** - * Parse method string like "type/action" or "type/nested/action" - */ private function parseMethod(string $method): array { if (str_contains($method, '/')) { @@ -163,169 +150,165 @@ private function parseMethod(string $method): array return [$method, '']; } - /** - * Validate if the client is initialized. - * - * @param string $clientId The client ID - * - * @throws McpError If the client is not initialized - */ private function validateClientInitialized(string $clientId): void { - if (! $this->transportState->isInitialized($clientId)) { - throw McpException::invalidRequest('Client not initialized. Please send an initialized notification first.'); + if (! $this->clientStateManager->isInitialized($clientId)) { + throw McpServerException::invalidRequest('Client not initialized.'); } } - /** - * Check if a capability type is enabled in the config. - * - * @param string $type The capability type (tools, resources, prompts) - * - * @throws McpError If the capability is disabled - */ private function validateCapabilityEnabled(string $type): void { - $configKey = match ($type) { - 'tools' => 'mcp.capabilities.tools.enabled', - 'resources' => 'mcp.capabilities.resources.enabled', - 'prompts' => 'mcp.capabilities.prompts.enabled', - 'logging' => 'mcp.capabilities.logging.enabled', - default => null, + $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 ($configKey === null) { - return; - } // Unknown capability type, assume enabled or let method fail - - if (! $this->config->get($configKey, false)) { // Default to false if not specified - throw McpException::methodNotFound("MCP capability '{$type}' is not enabled on this server."); + if (! $enabled) { + $methodSegment = explode('/', $type)[0]; + throw McpServerException::methodNotFound("MCP capability '{$methodSegment}' is not enabled on this server."); } } - // --- Handler Implementations --- + // --- Handler Implementations (Updated to use $this->configuration) --- private function handleInitialize(array $params, string $clientId): InitializeResult { $clientProtocolVersion = $params['protocolVersion'] ?? null; if (! $clientProtocolVersion) { - throw McpException::invalidParams("Missing 'protocolVersion' parameter for initialize request."); + throw McpServerException::invalidParams("Missing 'protocolVersion' parameter."); } - if (! in_array($clientProtocolVersion, $this->supportedProtocolVersions)) { + if (! in_array($clientProtocolVersion, self::SUPPORTED_PROTOCOL_VERSIONS)) { $this->logger->warning("Client requested unsupported protocol version: {$clientProtocolVersion}", [ - 'supportedVersions' => $this->supportedProtocolVersions, + 'supportedVersions' => self::SUPPORTED_PROTOCOL_VERSIONS, ]); - // Continue with our preferred version, client should disconnect if it can't support it } - $serverProtocolVersion = $this->config->get( - 'mcp.protocol_version', - $this->supportedProtocolVersions[count($this->supportedProtocolVersions) - 1] - ); + $serverProtocolVersion = self::SUPPORTED_PROTOCOL_VERSIONS[count(self::SUPPORTED_PROTOCOL_VERSIONS) - 1]; $clientInfo = $params['clientInfo'] ?? null; if (! is_array($clientInfo)) { - throw McpException::invalidParams("Missing or invalid 'clientInfo' parameter for initialize request."); + throw McpServerException::invalidParams("Missing or invalid 'clientInfo' parameter."); } - $this->transportState->storeClientInfo($clientInfo, $serverProtocolVersion, $clientId); + $this->clientStateManager->storeClientInfo($clientInfo, $serverProtocolVersion, $clientId); $serverInfo = [ - 'name' => $this->config->get('mcp.server.name', 'PHP MCP Server'), - 'version' => $this->config->get('mcp.server.version', '1.0.0'), + 'name' => $this->configuration->serverName, + 'version' => $this->configuration->serverVersion, ]; - $capabilities = []; - if ($this->config->get('mcp.capabilities.tools.enabled', false) && $this->registry->allTools()->count() > 0) { - $capabilities['tools'] = ['listChanged' => $this->config->get('mcp.capabilities.tools.listChanged', false)]; - } - if ($this->config->get('mcp.capabilities.resources.enabled', false) && ($this->registry->allResources()->count() > 0 || $this->registry->allResourceTemplates()->count() > 0)) { - $cap = []; - if ($this->config->get('mcp.capabilities.resources.subscribe', false)) { - $cap['subscribe'] = true; - } - if ($this->config->get('mcp.capabilities.resources.listChanged', false)) { - $cap['listChanged'] = true; - } - if (! empty($cap)) { - $capabilities['resources'] = $cap; - } - } - if ($this->config->get('mcp.capabilities.prompts.enabled', false) && $this->registry->allPrompts()->count() > 0) { - $capabilities['prompts'] = ['listChanged' => $this->config->get('mcp.capabilities.prompts.listChanged', false)]; - } - if ($this->config->get('mcp.capabilities.logging.enabled', false)) { - $capabilities['logging'] = new \stdClass; - } + $serverCapabilities = $this->configuration->capabilities; + $responseCapabilities = $serverCapabilities->toInitializeResponseArray(); - $instructions = $this->config->get('mcp.instructions'); + $instructions = $serverCapabilities->instructions; - return new InitializeResult($serverInfo, $serverProtocolVersion, $capabilities, $instructions); + return new InitializeResult($serverInfo, $serverProtocolVersion, $responseCapabilities, $instructions); } + // handlePing remains the same private function handlePing(string $clientId): EmptyResult { - // Ping response has no specific content, just acknowledges - return new EmptyResult; + return new EmptyResult(); } - // --- Notification Handlers --- - + // handleNotificationInitialized remains the same (uses ClientStateManager) private function handleNotificationInitialized(array $params, string $clientId): EmptyResult { - $this->transportState->markInitialized($clientId); + $this->clientStateManager->markInitialized($clientId); - return new EmptyResult; + return new EmptyResult(); // Return EmptyResult, Response is handled by caller } - // --- Tool Handlers --- + // --- List Handlers (Updated pagination limit source) --- private function handleToolList(array $params): ListToolsResult { $cursor = $params['cursor'] ?? null; - $limit = $this->config->get('mcp.pagination_limit', 50); + // Use a fixed limit or add to Configuration + $limit = 50; // $this->configuration->paginationLimit ?? 50; + $offset = $this->decodeCursor($cursor); + $allItems = $this->registry->allTools()->getArrayCopy(); // Get as array + $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 = 50; // $this->configuration->paginationLimit ?? 50; + $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 = 50; // $this->configuration->paginationLimit ?? 50; $offset = $this->decodeCursor($cursor); + $allItems = $this->registry->allResourceTemplates()->getArrayCopy(); + $pagedItems = array_slice($allItems, $offset, $limit); + $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); - $allToolsArray = $this->registry->allTools()->getArrayCopy(); - $pagedTools = array_slice($allToolsArray, $offset, $limit); - $nextCursor = $this->encodeNextCursor($offset, count($pagedTools), count($allToolsArray), $limit); + return new ListResourceTemplatesResult(array_values($pagedItems), $nextCursor); + } + + private function handlePromptsList(array $params): ListPromptsResult + { + $cursor = $params['cursor'] ?? null; + $limit = 50; // $this->configuration->paginationLimit ?? 50; + $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 ListToolsResult(array_values($pagedTools), $nextCursor); + return new ListPromptsResult(array_values($pagedItems), $nextCursor); } + // --- Action Handlers --- + private function handleToolCall(array $params): CallToolResult { $toolName = $params['name'] ?? null; $argumentsRaw = $params['arguments'] ?? null; if (! is_string($toolName) || empty($toolName)) { - throw McpException::invalidParams("Missing or invalid 'name' parameter for tools/call."); - } - - if ($argumentsRaw !== null && ! is_array($argumentsRaw)) { - throw McpException::invalidParams("Parameter 'arguments' must be an object or null for tools/call."); + throw McpServerException::invalidParams("Missing or invalid 'name' parameter for tools/call."); } - if (empty($argumentsRaw)) { - $argumentsRaw = new stdClass; + if ($argumentsRaw === null) { + $argumentsRaw = new stdClass(); + } elseif (! is_array($argumentsRaw) && ! $argumentsRaw instanceof stdClass) { + throw McpServerException::invalidParams("Parameter 'arguments' must be an object/array for tools/call."); } $definition = $this->registry->findTool($toolName); if (! $definition) { - throw McpException::methodNotFound("Tool '{$toolName}' not found."); + throw McpServerException::methodNotFound("Tool '{$toolName}' not found."); // Method not found seems appropriate } $inputSchema = $definition->getInputSchema(); + $argumentsForValidation = is_object($argumentsRaw) ? (array) $argumentsRaw : $argumentsRaw; - $argumentsForValidation = $argumentsRaw; $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($argumentsForValidation, $inputSchema); - if (! empty($validationErrors)) { - throw McpException::invalidParams(data: ['validation_errors' => $validationErrors]); + throw McpServerException::invalidParams(data: ['validation_errors' => $validationErrors]); } - $argumentsForPhpCall = (array) ($argumentsRaw ?? []); + $argumentsForPhpCall = (array) $argumentsRaw; // Need array for ArgumentPreparer try { $instance = $this->container->get($definition->getClassName()); @@ -339,167 +322,126 @@ private function handleToolCall(array $params): CallToolResult ); $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.', ['exception' => $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.', ['exception' => $toolError->getMessage()]); + $this->logger->error('MCP SDK: Tool execution failed.', ['tool' => $toolName, 'exception' => $toolError]); $errorContent = $this->formatToolErrorResult($toolError); return new CallToolResult($errorContent, true); } } - // --- Resource Handlers --- - - private function handleResourcesList(array $params): ListResourcesResult - { - $cursor = $params['cursor'] ?? null; - $limit = $this->config->get('mcp.pagination_limit', 50); - $offset = $this->decodeCursor($cursor); - - $allResourcesArray = $this->registry->allResources()->getArrayCopy(); - $pagedResources = array_slice($allResourcesArray, $offset, $limit); - $nextCursor = $this->encodeNextCursor($offset, count($pagedResources), count($allResourcesArray), $limit); - - return new ListResourcesResult(array_values($pagedResources), $nextCursor); - } - - private function handleResourceTemplateList(array $params): ListResourceTemplatesResult - { - $cursor = $params['cursor'] ?? null; - $limit = $this->config->get('mcp.pagination_limit', 50); - $offset = $this->decodeCursor($cursor); - - $allTemplatesArray = $this->registry->allResourceTemplates()->getArrayCopy(); - $pagedTemplates = array_slice($allTemplatesArray, $offset, $limit); - $nextCursor = $this->encodeNextCursor($offset, count($pagedTemplates), count($allTemplatesArray), $limit); - - return new ListResourceTemplatesResult(array_values($pagedTemplates), $nextCursor); - } - private function handleResourceRead(array $params): ReadResourceResult { $uri = $params['uri'] ?? null; if (! is_string($uri) || empty($uri)) { - throw McpException::invalidParams("Missing or invalid 'uri' parameter for resources/read."); + throw McpServerException::invalidParams("Missing or invalid 'uri' parameter for resources/read."); } - // First try exact resource match - $definition = $this->registry->findResourceByUri($uri); + $definition = null; $uriVariables = []; + // Try exact match first + $definition = $this->registry->findResourceByUri($uri); + // If no exact match, try template matching if (! $definition) { $templateResult = $this->registry->findResourceTemplateByUri($uri); - if (! $templateResult) { - throw McpException::invalidParams("Resource URI '{$uri}' not found or no handler available."); + if ($templateResult) { + $definition = $templateResult['definition']; + $uriVariables = $templateResult['variables']; + } else { + throw McpServerException::invalidParams("Resource URI '{$uri}' not found or no handler available."); } - - $definition = $templateResult['definition']; - $uriVariables = $templateResult['variables']; } try { $instance = $this->container->get($definition->getClassName()); + $methodName = $definition->getMethodName(); $methodParams = array_merge($uriVariables, ['uri' => $uri]); $args = $this->argumentPreparer->prepareMethodArguments( $instance, - $definition->getMethodName(), + $methodName, $methodParams, [] ); - $readResult = $instance->{$definition->getMethodName()}(...$args); - + $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 McpException::internalError("Failed to serialize resource content for '{$uri}': {$e->getMessage()}", $e); - } catch (McpException $e) { - throw $e; + throw McpServerException::internalError("Failed to serialize resource content for '{$uri}'.", $e); + } catch (McpServerException $e) { + throw $e; // Re-throw known MCP errors } catch (Throwable $e) { - $this->logger->error('MCP SDK: Resource read failed.', ['exception' => $e->getMessage()]); - - throw McpException::internalError("Failed to read resource '{$uri}': {$e->getMessage()}", $e); + $this->logger->error('MCP SDK: Resource read failed.', ['uri' => $uri, 'exception' => $e]); + throw McpServerException::resourceReadFailed($uri, $e); // Use specific factory } } private function handleResourceSubscribe(array $params, string $clientId): EmptyResult { $uri = $params['uri'] ?? null; - if (! is_string($uri) || empty($uri)) { - throw McpException::invalidParams("Missing or invalid 'uri' parameter for resources/subscribe."); - } - if (! $this->config->get('mcp.capabilities.resources.subscribe', false)) { - throw McpException::methodNotFound('Resource subscription is not supported by this server.'); + throw McpServerException::invalidParams("Missing or invalid 'uri' parameter for resources/subscribe."); } - $this->transportState->addResourceSubscription($clientId, $uri); + $this->validateCapabilityEnabled('resources/subscribe'); + + $this->clientStateManager->addResourceSubscription($clientId, $uri); - return new EmptyResult; + return new EmptyResult(); } private function handleResourceUnsubscribe(array $params, string $clientId): EmptyResult { $uri = $params['uri'] ?? null; if (! is_string($uri) || empty($uri)) { - throw McpException::invalidParams("Missing or invalid 'uri' parameter for resources/unsubscribe."); + throw McpServerException::invalidParams("Missing or invalid 'uri' parameter for resources/unsubscribe."); } - $this->transportState->removeResourceSubscription($clientId, $uri); + $this->validateCapabilityEnabled('resources/unsubscribe'); - return new EmptyResult; - } + $this->clientStateManager->removeResourceSubscription($clientId, $uri); - // --- Prompt Handlers --- - - private function handlePromptsList(array $params): ListPromptsResult - { - $cursor = $params['cursor'] ?? null; - $limit = $this->config->get('mcp.pagination_limit', 50); - $offset = $this->decodeCursor($cursor); - - $allPromptsArray = $this->registry->allPrompts()->getArrayCopy(); - $pagedPrompts = array_slice($allPromptsArray, $offset, $limit); - $nextCursor = $this->encodeNextCursor($offset, count($pagedPrompts), count($allPromptsArray), $limit); - - return new ListPromptsResult(array_values($pagedPrompts), $nextCursor); + return new EmptyResult(); } private function handlePromptGet(array $params): GetPromptResult { $promptName = $params['name'] ?? null; - $arguments = $params['arguments'] ?? []; // Arguments for templating + $arguments = $params['arguments'] ?? []; if (! is_string($promptName) || empty($promptName)) { - throw McpException::invalidParams("Missing or invalid 'name' parameter for prompts/get."); + throw McpServerException::invalidParams("Missing or invalid 'name' parameter for prompts/get."); } - if (! is_array($arguments)) { - throw McpException::invalidParams("Parameter 'arguments' must be an object 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 McpException::invalidParams("Prompt '{$promptName}' not found."); + throw McpServerException::invalidParams("Prompt '{$promptName}' not found."); } - // Validate provided arguments against PromptDefinition arguments (required check) + $arguments = (array) $arguments; + foreach ($definition->getArguments() as $argDef) { if ($argDef->isRequired() && ! array_key_exists($argDef->getName(), $arguments)) { - throw McpException::invalidParams("Missing required argument '{$argDef->getName()}' for prompt '{$promptName}'."); + throw McpServerException::invalidParams("Missing required argument '{$argDef->getName()}' for prompt '{$promptName}'."); } } @@ -507,56 +449,52 @@ private function handlePromptGet(array $params): GetPromptResult $instance = $this->container->get($definition->getClassName()); $methodName = $definition->getMethodName(); - // Prepare arguments for the prompt generator method (likely just the template vars) + // Prepare arguments for the prompt generator method $args = $this->argumentPreparer->prepareMethodArguments( $instance, $methodName, - $arguments, // Pass template arguments - [] // Schema not directly applicable here? Or parse args into schema? Pass empty for now. + $arguments, + [] // No input schema for prompts ); - // Execute the prompt generator method $promptGenerationResult = $instance->{$methodName}(...$args); - $messages = $this->formatPromptMessages($promptGenerationResult); - return new GetPromptResult( - $messages, - $definition->getDescription() - ); + 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 McpException::internalError("Failed to serialize prompt messages for '{$promptName}': {$e->getMessage()}", $e); - } catch (McpException $e) { - throw $e; + 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.', ['exception' => $e->getMessage()]); - - throw McpException::internalError("Failed to generate prompt '{$promptName}': {$e->getMessage()}", $e); + $this->logger->error('MCP SDK: Prompt generation failed.', ['promptName' => $promptName, 'exception' => $e]); + throw McpServerException::promptGenerationFailed($promptName, $e); // Use specific factory } } - // --- Logging Handlers --- + // handleLoggingSetLevel needs a way to persist the setting. + // Using ClientStateManager might be okay, or a dedicated config service. + // For Phase 1, let's just log it. private function handleLoggingSetLevel(array $params): EmptyResult { $level = $params['level'] ?? null; $validLevels = ['emergency', 'alert', 'critical', 'error', 'warning', 'notice', 'info', 'debug']; if (! is_string($level) || ! in_array(strtolower($level), $validLevels)) { - throw McpException::invalidParams("Invalid or missing 'level' parameter. Must be one of: ".implode(', ', $validLevels)); + throw McpServerException::invalidParams("Invalid or missing 'level'. Must be one of: ".implode(', ', $validLevels)); } - // Store the requested log level (e.g., in session, config, or state manager) - // This level should then be checked by the logging notification sender. - $this->logger->info('MCP logging level set request: '.$level); - $this->config->set('mcp.runtime.log_level', strtolower($level)); // Example: Store in runtime config + $this->validateCapabilityEnabled('logging'); - return new EmptyResult; // Success is empty result + $this->logger->info('MCP logging level set request received', ['level' => $level]); + // In a real implementation, update the logger's minimum level or store this preference. + // $this->clientStateManager->setClientLogLevel($clientId, strtolower($level)); // Example state storage + + return new EmptyResult(); } - // --- Pagination Helpers --- + // --- Pagination Helpers (Unchanged) --- - /** Decodes the opaque cursor string into an offset */ private function decodeCursor(?string $cursor): int { if ($cursor === null) { @@ -566,18 +504,16 @@ private function decodeCursor(?string $cursor): int if ($decoded === false) { $this->logger->warning('Received invalid pagination cursor (not base64)', ['cursor' => $cursor]); - return 0; // Treat invalid cursor as start + return 0; } - // Expect format "offset=N" if (preg_match('/^offset=(\d+)$/', $decoded, $matches)) { return (int) $matches[1]; } $this->logger->warning('Received invalid pagination cursor format', ['cursor' => $decoded]); - return 0; // Treat invalid format as start + return 0; } - /** Encodes the next cursor string if more items exist */ private function encodeNextCursor(int $currentOffset, int $returnedCount, int $totalCount, int $limit): ?string { $nextOffset = $currentOffset + $returnedCount; @@ -585,6 +521,6 @@ private function encodeNextCursor(int $currentOffset, int $returnedCount, int $t return base64_encode("offset={$nextOffset}"); } - return null; // No more items + return null; } } diff --git a/src/ProtocolHandler.php b/src/ProtocolHandler.php new file mode 100644 index 0000000..843756c --- /dev/null +++ b/src/ProtocolHandler.php @@ -0,0 +1,271 @@ +transport !== null) { + $this->unbindTransport(); + } + + $this->transport = $transport; + + $this->listeners = [ + 'message' => [$this, 'handleRawMessage'], + 'client_connected' => [$this, 'handleClientConnected'], + 'client_disconnected' => [$this, 'handleClientDisconnected'], + 'error' => [$this, 'handleTransportError'], + ]; + + $this->transport->on('message', $this->listeners['message']); + $this->transport->on('client_connected', $this->listeners['client_connected']); + $this->transport->on('client_disconnected', $this->listeners['client_disconnected']); + $this->transport->on('error', $this->listeners['error']); + } + + /** + * Detaches listeners from the current transport. + */ + public function unbindTransport(): void + { + if ($this->transport && ! empty($this->listeners)) { + $this->transport->removeListener('message', $this->listeners['message']); + $this->transport->removeListener('client_connected', $this->listeners['client_connected']); + $this->transport->removeListener('client_disconnected', $this->listeners['client_disconnected']); + $this->transport->removeListener('error', $this->listeners['error']); + } + + $this->transport = null; + $this->listeners = []; + } + + /** + * Handles a raw message frame received from the transport. + * + * Parses JSON, validates structure, processes via Processor, sends Response/Error. + */ + public function handleRawMessage(string $rawJsonRpcFrame, string $clientId): void + { + $this->logger->debug('Received message', ['clientId' => $clientId, 'frame' => $rawJsonRpcFrame]); + $responseToSend = null; + $parsedMessage = null; + $messageData = null; + + 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).'); + } + + $parsedMessage = $this->parseMessageData($messageData); + + if ($parsedMessage === null) { + throw McpServerException::invalidRequest('Invalid MCP/JSON-RPC message structure.'); + } + + $responseToSend = $this->processor->process($parsedMessage, $clientId); + + } catch (JsonException $e) { + $this->logger->error("JSON Parse Error for client {$clientId}", ['error' => $e->getMessage()]); + // ID is null for Parse Error according to JSON-RPC 2.0 spec + $responseToSend = Response::error(McpServerException::parseError($e->getMessage())->toJsonRpcError(), null); + } 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); + } 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); + } + + if ($responseToSend instanceof Response) { + $this->sendResponse($clientId, $responseToSend); + } elseif ($parsedMessage instanceof Request && $responseToSend === null) { + // Should not happen if Processor is correct, but safeguard + $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); + } + // If $parsedMessage was a Notification, $responseToSend should be null, and we send nothing. + } + + /** Safely gets the request ID from potentially parsed or raw message data */ + private function getRequestId(Request|Notification|null $parsed, ?array $rawData): string|int|null + { + if ($parsed instanceof Request) { + return $parsed->id; + } + // Attempt fallback to raw data if parsing failed but JSON decoded + if (is_array($rawData) && isset($rawData['id']) && (is_string($rawData['id']) || is_int($rawData['id']))) { + return $rawData['id']; + } + + // Null ID for parse errors or notifications + return null; + } + + /** Sends a Response object via the transport */ + private function sendResponse(string $clientId, Response $response): void + { + if ($this->transport === null) { + $this->logger->error('Cannot send response, transport is not bound.', ['clientId' => $clientId]); + + return; + } + + try { + $responseData = $response->toArray(); + $jsonResponse = json_encode($responseData, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + // Frame the message (e.g., add newline for stdio) - Transport *should* handle framing? + // Let's assume transport needs the raw JSON string for now. Framing added here. + // TODO: Revisit if framing should be transport's responsibility. For now, add newline. + $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()]); + // We can't send *this* error back easily if encoding failed. + } catch (Throwable $e) { + $this->logger->error('Unexpected error during response preparation/sending.', ['clientId' => $clientId, 'responseId' => $response->id, 'exception' => $e]); + } + } + + /** + * Sends a Notification object via the transport to a specific client. + * + * (Primarily used internally or by advanced framework integrations) + */ + public function sendNotification(string $clientId, Notification $notification): PromiseInterface + { + if ($this->transport === null) { + $this->logger->error('Cannot send notification, transport not bound.', ['clientId' => $clientId]); + + return reject(new McpServerException('Transport not bound')); + } + try { + $jsonNotification = json_encode($notification->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $framedMessage = $jsonNotification."\n"; // Add framing + $this->logger->debug('Sending notification', ['clientId' => $clientId, 'method' => $notification->method]); + + 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()]); + + 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]); + + return reject(new McpServerException('Failed to send notification: '.$e->getMessage(), 0, $e)); + } + } + + // --- Transport Event Handlers --- + + /** Handles 'client_connected' event from the transport */ + public function handleClientConnected(string $clientId): void + { + $this->logger->info('Client connected', ['clientId' => $clientId]); + } + + /** Handles 'client_disconnected' event from the transport */ + public function handleClientDisconnected(string $clientId, ?string $reason = null): void + { + $this->logger->info('Client disconnected', ['clientId' => $clientId, 'reason' => $reason ?? 'N/A']); + $this->clientStateManager->cleanupClient($clientId); + } + + /** Handles 'error' event from the transport */ + public function handleTransportError(Throwable $error, ?string $clientId = null): void + { + $context = ['error' => $error->getMessage(), 'exception_class' => get_class($error)]; + + if ($clientId) { + $context['clientId'] = $clientId; + $this->logger->error('Transport error for client', $context); + $this->clientStateManager->cleanupClient($clientId); + // Should we close the transport here? Depends if error is fatal for the client only or the whole transport. + // If the transport can recover or handles other clients, maybe not. Let transport decide? + } else { + $this->logger->error('General transport error', $context); + // This might be fatal, perhaps signal the main loop to stop? + // Or maybe just log it. For now, log only. + } + } + + /** 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 fbd0505..70aae1e 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -5,27 +5,29 @@ namespace PhpMcp\Server; use ArrayObject; -use PhpMcp\Server\Contracts\ConfigurationRepositoryInterface as ConfigRepository; use PhpMcp\Server\Definitions\PromptDefinition; use PhpMcp\Server\Definitions\ResourceDefinition; use PhpMcp\Server\Definitions\ResourceTemplateDefinition; use PhpMcp\Server\Definitions\ToolDefinition; +use PhpMcp\Server\Exception\DefinitionException; use PhpMcp\Server\JsonRpc\Notification; -use PhpMcp\Server\State\TransportState; use PhpMcp\Server\Support\UriTemplateMatcher; -use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; +use Psr\SimpleCache\InvalidArgumentException as CacheInvalidArgumentException; use Throwable; class Registry { - private CacheInterface $cache; + private const DISCOVERED_ELEMENTS_CACHE_KEY = 'mcp_server_discovered_elements'; + + private ?CacheInterface $cache; private LoggerInterface $logger; - private TransportState $transportState; + private ?ClientStateManager $clientStateManager = null; + // Main collections hold BOTH manual and discovered/cached elements /** @var ArrayObject */ private ArrayObject $tools; @@ -38,7 +40,20 @@ class Registry /** @var ArrayObject */ private ArrayObject $resourceTemplates; - private bool $isLoaded = false; + // Track keys/names of MANUALLY registered elements + /** @var array */ + private array $manualToolNames = []; + + /** @var array */ + private array $manualResourceUris = []; + + /** @var array */ + private array $manualPromptNames = []; + + /** @var array */ + private array $manualTemplateUris = []; + + private bool $discoveredElementsLoaded = false; // --- Notification Callbacks --- /** @var callable|null */ @@ -50,53 +65,84 @@ class Registry /** @var callable|null */ private $notifyPromptsChanged = null; - private string $cacheKey; - public function __construct( - private readonly ContainerInterface $container, - ?TransportState $transportState = null, + LoggerInterface $logger, + ?CacheInterface $cache = null, + ?ClientStateManager $clientStateManager = null ) { - $this->cache = $this->container->get(CacheInterface::class); - $this->logger = $this->container->get(LoggerInterface::class); - $config = $this->container->get(ConfigRepository::class); + $this->logger = $logger; + $this->cache = $cache; + $this->clientStateManager = $clientStateManager; $this->initializeCollections(); $this->initializeDefaultNotifiers(); - $this->transportState = $transportState ?? new TransportState($this->container); + if ($this->cache) { + $this->loadDiscoveredElementsFromCache(); + } else { + $this->discoveredElementsLoaded = true; + $this->logger->debug('MCP Registry: Cache not provided, skipping initial cache load.'); + } + } - $this->cacheKey = $config->get('mcp.cache.prefix', 'mcp_').'elements'; + /** + * Checks if discovery has been run OR elements loaded from cache. + * + * Note: Manual elements can exist even if this is false initially. + */ + public function discoveryRanOrCached(): bool + { + return $this->discoveredElementsLoaded; + } - $this->loadElementsFromCache(); + /** 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 { - $this->tools = new ArrayObject; - $this->resources = new ArrayObject; - $this->prompts = new ArrayObject; - $this->resourceTemplates = new ArrayObject; + $this->tools = new ArrayObject(); + $this->resources = new ArrayObject(); + $this->prompts = new ArrayObject(); + $this->resourceTemplates = new ArrayObject(); + + $this->manualToolNames = []; + $this->manualResourceUris = []; + $this->manualPromptNames = []; + $this->manualTemplateUris = []; } private function initializeDefaultNotifiers(): void { $this->notifyToolsChanged = function () { - $notification = Notification::make('notifications/tools/list_changed'); - $this->transportState->queueMessageForAll($notification); + if ($this->clientStateManager) { + $notification = Notification::make('notifications/tools/list_changed'); + $this->clientStateManager->queueMessageForAll($notification); + } }; $this->notifyResourcesChanged = function () { - $notification = Notification::make('notifications/resources/list_changed'); - $this->transportState->queueMessageForAll($notification); + if ($this->clientStateManager) { + $notification = Notification::make('notifications/resources/list_changed'); + $this->clientStateManager->queueMessageForAll($notification); + } }; $this->notifyPromptsChanged = function () { - $notification = Notification::make('notifications/prompts/list_changed'); - $this->transportState->queueMessageForAll($notification); + if ($this->clientStateManager) { + $notification = Notification::make('notifications/prompts/list_changed'); + $this->clientStateManager->queueMessageForAll($notification); + } }; } - // --- Public Setters to Override Defaults --- + // --- Notifier Methods --- + public function setToolsChangedNotifier(?callable $notifier): void { $this->notifyToolsChanged = $notifier; @@ -112,141 +158,308 @@ public function setPromptsChangedNotifier(?callable $notifier): void $this->notifyPromptsChanged = $notifier; } - public function isLoaded(): bool - { - return $this->isLoaded; - } + // --- Registration Methods --- - public function registerTool(ToolDefinition $tool): void + public function registerTool(ToolDefinition $tool, bool $isManual = false): void { $toolName = $tool->getName(); - $alreadyExists = $this->tools->offsetExists($toolName); - if ($alreadyExists) { - $this->logger->warning("MCP: Replacing existing tool '{$toolName}'"); + $exists = $this->tools->offsetExists($toolName); + $wasManual = isset($this->manualToolNames[$toolName]); + + if ($exists && ! $isManual && $wasManual) { + $this->logger->debug("MCP Registry: Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one."); + + return; // Manual registration takes precedence } + + if ($exists) { + $this->logger->warning('MCP Registry: Replacing existing '.($wasManual ? 'manual' : 'discovered')." tool '{$toolName}' with ".($isManual ? 'manual' : 'discovered').' definition.'); + } + $this->tools[$toolName] = $tool; - if (! $alreadyExists && $this->notifyToolsChanged) { + if ($isManual) { + $this->manualToolNames[$toolName] = true; + } elseif ($wasManual) { + unset($this->manualToolNames[$toolName]); + } + + if (! $exists && $this->notifyToolsChanged) { ($this->notifyToolsChanged)(); } } - public function registerResource(ResourceDefinition $resource): void + public function registerResource(ResourceDefinition $resource, bool $isManual = false): void { $uri = $resource->getUri(); - $alreadyExists = $this->resources->offsetExists($uri); - if ($alreadyExists) { - $this->logger->warning("MCP: Replacing existing resource '{$uri}'"); + $exists = $this->resources->offsetExists($uri); + $wasManual = isset($this->manualResourceUris[$uri]); + + if ($exists && ! $isManual && $wasManual) { + $this->logger->debug("MCP Registry: 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')." resource '{$uri}' with ".($isManual ? 'manual' : 'discovered').' definition.'); + } + $this->resources[$uri] = $resource; + if ($isManual) { + $this->manualResourceUris[$uri] = true; + } elseif ($wasManual) { + unset($this->manualResourceUris[$uri]); + } - if (! $alreadyExists && $this->notifyResourcesChanged) { + if (! $exists && $this->notifyResourcesChanged) { ($this->notifyResourcesChanged)(); } } - public function registerResourceTemplate(ResourceTemplateDefinition $template): void + public function registerResourceTemplate(ResourceTemplateDefinition $template, bool $isManual = false): void { $uriTemplate = $template->getUriTemplate(); - $alreadyExists = $this->resourceTemplates->offsetExists($uriTemplate); - if ($alreadyExists) { - $this->logger->warning("MCP: Replacing existing resource template '{$uriTemplate}'"); + $exists = $this->resourceTemplates->offsetExists($uriTemplate); + $wasManual = isset($this->manualTemplateUris[$uriTemplate]); + + if ($exists && ! $isManual && $wasManual) { + $this->logger->debug("MCP Registry: 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')." 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 } - public function registerPrompt(PromptDefinition $prompt): void + public function registerPrompt(PromptDefinition $prompt, bool $isManual = false): void { $promptName = $prompt->getName(); - $alreadyExists = $this->prompts->offsetExists($promptName); - if ($alreadyExists) { - $this->logger->warning("MCP: Replacing existing prompt '{$promptName}'"); + $exists = $this->prompts->offsetExists($promptName); + $wasManual = isset($this->manualPromptNames[$promptName]); + + if ($exists && ! $isManual && $wasManual) { + $this->logger->debug("MCP Registry: Ignoring discovered prompt '{$promptName}' 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]); + } - if (! $alreadyExists && $this->notifyPromptsChanged) { + if (! $exists && $this->notifyPromptsChanged) { ($this->notifyPromptsChanged)(); } } - public function loadElementsFromCache(bool $force = false): void + // --- Cache Handling Methods --- + + public function loadDiscoveredElementsFromCache(bool $force = false): void { - if ($this->isLoaded && ! $force) { + if ($this->cache === null) { + $this->logger->debug('MCP Registry: Cache load skipped, cache not available.'); + $this->discoveredElementsLoaded = true; + return; } - $cached = $this->cache->get($this->cacheKey); + if ($this->discoveredElementsLoaded && ! $force) { + return; // Already loaded or ran discovery this session + } - if (is_array($cached) && isset($cached['tools'])) { - $this->logger->debug('MCP: Loading elements from cache.', ['key' => $this->cacheKey]); + $this->clearDiscoveredElements(false); // Don't delete cache, just clear internal collections - foreach ($cached['tools'] ?? [] as $tool) { - $toolDefinition = $tool instanceof ToolDefinition ? $tool : ToolDefinition::fromArray($tool); - $this->registerTool($toolDefinition); + try { + $cached = $this->cache->get(self::DISCOVERED_ELEMENTS_CACHE_KEY); + + 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."); + } + } + + 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."); + } + } + + 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."); + } + } + + 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."); + } + } + + $this->logger->debug("MCP Registry: Loaded {$loadCount} elements from cache."); + + $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]); + } + } - foreach ($cached['resources'] ?? [] as $resource) { - $resourceDefinition = $resource instanceof ResourceDefinition ? $resource : ResourceDefinition::fromArray($resource); - $this->registerResource($resourceDefinition); + public function saveDiscoveredElementsToCache(): bool + { + if ($this->cache === null) { + $this->logger->debug('MCP Registry: Cache save skipped, cache not available.'); + + return false; + } + + $discoveredData = [ + 'tools' => [], 'resources' => [], 'prompts' => [], 'resourceTemplates' => [], + ]; + + foreach ($this->tools as $name => $tool) { + if (! isset($this->manualToolNames[$name])) { + $discoveredData['tools'][$name] = $tool; } + } - foreach ($cached['prompts'] ?? [] as $prompt) { - $promptDefinition = $prompt instanceof PromptDefinition ? $prompt : PromptDefinition::fromArray($prompt); - $this->registerPrompt($promptDefinition); + foreach ($this->resources as $uri => $resource) { + if (! isset($this->manualResourceUris[$uri])) { + $discoveredData['resources'][$uri] = $resource; } + } - foreach ($cached['resourceTemplates'] ?? [] as $template) { - $resourceTemplateDefinition = $template instanceof ResourceTemplateDefinition ? $template : ResourceTemplateDefinition::fromArray($template); - $this->registerResourceTemplate($resourceTemplateDefinition); + foreach ($this->prompts as $name => $prompt) { + if (! isset($this->manualPromptNames[$name])) { + $discoveredData['prompts'][$name] = $prompt; } } - $this->isLoaded = true; - } + foreach ($this->resourceTemplates as $uriTemplate => $template) { + if (! isset($this->manualTemplateUris[$uriTemplate])) { + $discoveredData['resourceTemplates'][$uriTemplate] = $template; + } + } - public function saveElementsToCache(): bool - { - $data = [ - 'tools' => $this->tools->getArrayCopy(), - 'resources' => $this->resources->getArrayCopy(), - 'prompts' => $this->prompts->getArrayCopy(), - 'resourceTemplates' => $this->resourceTemplates->getArrayCopy(), - ]; try { - $this->cache->set($this->cacheKey, $data); - $this->logger->debug('MCP: Elements saved to cache.', ['key' => $this->cacheKey]); + $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]); + } else { + $this->logger->warning('MCP Registry: Cache set operation returned false.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); + } - return true; - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - $this->logger->error('MCP: Failed to save elements to cache.', ['key' => $this->cacheKey, 'exception' => $e]); + 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]); + + return false; + } catch (Throwable $e) { + $this->logger->error('MCP Registry: Unexpected error saving to cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); return false; } } - public function clearCache(): void + public function clearDiscoveredElements(bool $deleteFromCache = true): void { - try { - $this->cache->delete($this->cacheKey); - $this->initializeCollections(); - $this->isLoaded = false; - $this->logger->debug('MCP: Element cache cleared.'); + $this->logger->debug('Clearing discovered elements...', ['deleteCacheFile' => $deleteFromCache]); + + // Clear cache file if requested + if ($deleteFromCache && $this->cache !== null) { + try { + $this->cache->delete(self::DISCOVERED_ELEMENTS_CACHE_KEY); + $this->logger->info('MCP Registry: Discovered elements cache cleared.'); + } catch (Throwable $e) { + $this->logger->error('MCP Registry: Error clearing discovered elements cache.', ['exception' => $e]); + } + } + + // Clear internal collections of non-manual items + $clearCount = 0; - if ($this->notifyToolsChanged) { - ($this->notifyToolsChanged)(); + foreach ($this->tools as $name => $tool) { + if (! isset($this->manualToolNames[$name])) { + unset($this->tools[$name]); + $clearCount++; } - if ($this->notifyResourcesChanged) { - ($this->notifyResourcesChanged)(); + } + foreach ($this->resources as $uri => $resource) { + if (! isset($this->manualResourceUris[$uri])) { + unset($this->resources[$uri]); + $clearCount++; } - if ($this->notifyPromptsChanged) { - ($this->notifyPromptsChanged)(); + } + foreach ($this->prompts as $name => $prompt) { + if (! isset($this->manualPromptNames[$name])) { + unset($this->prompts[$name]); + $clearCount++; } - - } catch (Throwable $e) { - $this->logger->error('MCP: Failed to clear element cache.', ['exception' => $e]); } + foreach ($this->resourceTemplates as $uriTemplate => $template) { + if (! isset($this->manualTemplateUris[$uriTemplate])) { + unset($this->resourceTemplates[$uriTemplate]); + $clearCount++; + } + } + + $this->discoveredElementsLoaded = false; // Mark as needing discovery/cache load again + $this->logger->debug("Removed {$clearCount} discovered elements from internal registry."); } + // --- Finder Methods --- + public function findTool(string $name): ?ToolDefinition { return $this->tools[$name] ?? null; @@ -265,24 +478,31 @@ public function findResourceByUri(string $uri): ?ResourceDefinition public function findResourceTemplateByUri(string $uri): ?array { foreach ($this->resourceTemplates as $templateDefinition) { - /** @var ResourceTemplateDefinition $templateDefinition */ - $matcher = new UriTemplateMatcher($templateDefinition->getUriTemplate()); - $variables = $matcher->match($uri); - - if ($variables !== null) { - $this->logger->debug('MCP: Matched URI to template.', ['uri' => $uri, 'template' => $templateDefinition->getUriTemplate()]); - - return [ - 'definition' => $templateDefinition, - 'variables' => $variables, - ]; + 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()]); + + return ['definition' => $templateDefinition, 'variables' => $variables]; + } + } catch (\InvalidArgumentException $e) { + $this->logger->warning('Invalid resource template encountered during matching', [ + 'template' => $templateDefinition->getUriTemplate(), + 'error' => $e->getMessage(), + ]); + + continue; } } - $this->logger->debug('MCP: No template matched URI.', ['uri' => $uri]); + $this->logger->debug('MCP Registry: No template matched URI.', ['uri' => $uri]); return null; } + // --- Getter Methods --- + /** @return ArrayObject */ public function allTools(): ArrayObject { @@ -306,70 +526,4 @@ public function allResourceTemplates(): ArrayObject { return $this->resourceTemplates; } - - // --- Methods for Server Responses --- - - /** - * Get all tool definitions formatted as arrays for MCP responses. - * - * @return list An array of tool definition arrays. - */ - public function getToolDefinitionsAsArray(): array - { - $result = []; - foreach ($this->tools as $tool) { - /** @var ToolDefinition $tool */ - $result[] = $tool->toArray(); - } - - return array_values($result); // Ensure list (numeric keys) - } - - /** - * Get all resource definitions formatted as arrays for MCP responses. - * - * @return list An array of resource definition arrays. - */ - public function getResourceDefinitionsAsArray(): array - { - $result = []; - foreach ($this->resources as $resource) { - /** @var ResourceDefinition $resource */ - $result[] = $resource->toArray(); - } - - return array_values($result); - } - - /** - * Get all prompt definitions formatted as arrays for MCP responses. - * - * @return list An array of prompt definition arrays. - */ - public function getPromptDefinitionsAsArray(): array - { - $result = []; - foreach ($this->prompts as $prompt) { - /** @var PromptDefinition $prompt */ - $result[] = $prompt->toArray(); - } - - return array_values($result); - } - - /** - * Get all resource template definitions formatted as arrays for MCP responses. - * - * @return list An array of resource template definition arrays. - */ - public function getResourceTemplateDefinitionsAsArray(): array - { - $result = []; - foreach ($this->resourceTemplates as $template) { - /** @var ResourceTemplateDefinition $template */ - $result[] = $template->toArray(); - } - - return array_values($result); - } } diff --git a/src/Server.php b/src/Server.php index 13b748b..5b9d32b 100644 --- a/src/Server.php +++ b/src/Server.php @@ -4,389 +4,233 @@ namespace PhpMcp\Server; -use InvalidArgumentException; use LogicException; -use PhpMcp\Server\Contracts\ConfigurationRepositoryInterface; -use PhpMcp\Server\Defaults\ArrayConfigurationRepository; -use PhpMcp\Server\Defaults\BasicContainer; -use PhpMcp\Server\Defaults\FileCache; -use PhpMcp\Server\Definitions\PromptDefinition; -use PhpMcp\Server\Definitions\ResourceDefinition; -use PhpMcp\Server\Definitions\ResourceTemplateDefinition; -use PhpMcp\Server\Definitions\ToolDefinition; +use PhpMcp\Server\Contracts\LoggerAwareInterface; +use PhpMcp\Server\Contracts\LoopAwareInterface; +use PhpMcp\Server\Contracts\ServerTransportInterface; +use PhpMcp\Server\Exception\ConfigurationException; +use PhpMcp\Server\Exception\DiscoveryException; use PhpMcp\Server\Support\Discoverer; -use PhpMcp\Server\Support\DocBlockParser; -use PhpMcp\Server\Support\SchemaGenerator; -use PhpMcp\Server\Transports\StdioTransportHandler; -use Psr\Container\ContainerInterface; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; -use Psr\SimpleCache\CacheInterface; -use ReflectionClass; -use ReflectionMethod; +use Throwable; /** - * Main MCP Server class providing a fluent interface for configuration and running. + * Core MCP Server instance. + * + * Holds the configured MCP logic (Registry, Processor, State, Configuration) + * but is transport-agnostic. It relies on a ServerTransportInterface implementation, + * provided via the listen() method, to handle network communication. + * + * Instances should be created via the ServerBuilder. */ class Server { - private ?LoggerInterface $logger = null; + protected ?ProtocolHandler $protocolHandler = null; - private ?ContainerInterface $container = null; + protected bool $discoveryRan = false; - private ?Registry $registry = null; - - private string $basePath; - - private array $scanDirs; - - private array $excludeDirs; - - public function __construct() - { - $container = new BasicContainer; - - $config = new ArrayConfigurationRepository($this->getDefaultConfigValues()); - $logger = new NullLogger; - $cache = new FileCache(__DIR__.'/../cache/mcp_cache'); - - $container->set(ConfigurationRepositoryInterface::class, $config); - $container->set(LoggerInterface::class, $logger); - $container->set(CacheInterface::class, $cache); - - $this->basePath = realpath(__DIR__.'/..') ?: __DIR__.'/..'; - $this->scanDirs = ['.', 'src/MCP']; - $this->excludeDirs = ['vendor', 'tests', 'test', 'samples', 'docs', 'storage', 'cache', 'node_modules']; - $this->container = $container; - } + protected bool $isListening = false; /** - * Static factory method to create a new Server instance. + * @internal Use ServerBuilder::make()->...->build(). + * + * @param Configuration $configuration Core configuration and dependencies. + * @param Registry $registry Holds registered MCP element definitions. + * @param Processor $processor Handles processing of MCP requests. + * @param ClientStateManager $clientStateManager Manages client runtime state. + * @param array|null $discoveryConfig Configuration for attribute discovery, or null if disabled/not set. */ - public static function make(): self - { - $instance = new self; - - return $instance; + public function __construct( + protected readonly Configuration $configuration, + protected readonly Registry $registry, + protected readonly Processor $processor, + protected readonly ClientStateManager $clientStateManager, + ) { } - private function getDefaultConfigValues(): array + public static function make(): ServerBuilder { - return [ - 'mcp' => [ - 'server' => ['name' => 'PHP MCP Server', 'version' => '1.0.0'], - 'protocol_versions' => ['2024-11-05'], - 'pagination_limit' => 50, - 'capabilities' => [ - 'tools' => ['enabled' => true, 'listChanged' => true], - 'resources' => ['enabled' => true, 'subscribe' => true, 'listChanged' => true], - 'prompts' => ['enabled' => true, 'listChanged' => true], - 'logging' => ['enabled' => false], - ], - 'cache' => ['key' => 'mcp.elements.cache', 'ttl' => 3600, 'prefix' => 'mcp_state_'], - 'runtime' => ['log_level' => 'info'], - ], - ]; + return new ServerBuilder(); } - public function withContainer(ContainerInterface $container): self - { - $this->container = $container; - - return $this; - } - - public function withLogger(LoggerInterface $logger): self - { - $this->logger = $logger; - - if ($this->container instanceof BasicContainer) { - $this->container->set(LoggerInterface::class, $logger); + /** + * Runs the attribute discovery process based on the configuration + * provided during build time. Caches results if cache is available. + * Can be called explicitly, but is also called by ServerBuilder::build() + * if discovery paths are configured. + * + * @param bool $force Re-run discovery even if already run. + * @param bool $useCache Attempt to load from/save to cache. Defaults to true if cache is available. + * + * @throws DiscoveryException If discovery process encounters errors. + * @throws ConfigurationException If discovery paths were not configured. + */ + public function discover( + string $basePath, + array $scanDirs = ['.', 'src'], + array $excludeDirs = [], + bool $force = false, + bool $saveToCache = true + ): void { + $realBasePath = realpath($basePath); + if ($realBasePath === false || ! is_dir($realBasePath)) { + throw new \InvalidArgumentException("Invalid discovery base path provided to discover(): {$basePath}"); } - return $this; - } - - public function withCache(CacheInterface $cache): self - { - if ($this->container instanceof BasicContainer) { - $this->container->set(CacheInterface::class, $cache); - } + $excludeDirs = array_merge($excludeDirs, ['vendor', 'tests', 'test', 'storage', 'cache', 'samples', 'docs', 'node_modules']); - return $this; - } + if ($this->discoveryRan && ! $force) { + $this->configuration->logger->debug('Discovery skipped: Already run or loaded from cache.'); - public function withConfig(ConfigurationRepositoryInterface $config): self - { - if ($this->container instanceof BasicContainer) { - $this->container->set(ConfigurationRepositoryInterface::class, $config); + return; } - return $this; - } + $cacheAvailable = $this->configuration->cache !== null; + $shouldSaveCache = $saveToCache && $cacheAvailable; - public function withBasePath(string $path): self - { - if (! is_dir($path)) { - throw new InvalidArgumentException("Base path is not a valid directory: {$path}"); - } + $this->configuration->logger->info('Starting MCP element discovery...', [ + 'basePath' => $realBasePath, 'force' => $force, 'saveToCache' => $shouldSaveCache, + ]); - $this->basePath = realpath($path) ?: $path; + $this->registry->clearDiscoveredElements($shouldSaveCache); - return $this; - } + try { + $discoverer = new Discoverer($this->registry, $this->configuration->logger); - /** - * Explicitly set the directories to scan relative to the base path. - * - * @param string[] $dirs Array of relative directory paths (e.g., ['.', 'src/MCP']). - * @return $this - */ - public function withScanDirectories(array $dirs): self - { - $this->scanDirs = $dirs; + $discoverer->discover($realBasePath, $scanDirs, $excludeDirs); - return $this; - } + $this->discoveryRan = true; + $this->configuration->logger->info('Element discovery process finished.'); - /** - * Explicitly set the directories to exclude from the scan, relative to the base path. - * - * @param string[] $dirs Array of relative directory paths (e.g., ['vendor', 'tests', 'test', 'samples', 'docs', 'storage', 'cache', 'node_modules']). - * @return $this - */ - public function withExcludeDirectories(array $dirs): self - { - $this->excludeDirs = array_merge($this->excludeDirs, $dirs); - - return $this; + if ($shouldSaveCache) { + $this->registry->saveDiscoveredElementsToCache(); + } + } catch (Throwable $e) { + $this->discoveryRan = false; + $this->configuration->logger->critical('MCP element discovery failed.', ['exception' => $e]); + throw new DiscoveryException("Element discovery failed: {$e->getMessage()}", $e->getCode(), $e); + } } /** - * Manually register a tool with the server. + * Binds the server's MCP logic to the provided transport and starts the transport's listener, + * then runs the event loop, making this a BLOCKING call suitable for standalone servers. * - * @param array|class-string $handler The handler to register, containing a class name and method name. - * @param string|null $name The name of the tool. - * @param string|null $description The description of the tool. - * @return $this - */ - public function withTool(array|string $handler, ?string $name = null, ?string $description = null): self - { - $reflectionMethod = $this->validateAndGetReflectionMethod($handler); - $className = $reflectionMethod->getDeclaringClass()->getName(); - $methodName = $reflectionMethod->getName(); - $isInvokable = $methodName === '__invoke'; - - $docBlockParser = new DocBlockParser($this->container); - $schemaGenerator = new SchemaGenerator($docBlockParser); - - $name = $name ?? ($isInvokable ? (new ReflectionClass($className))->getShortName() : $methodName); - $definition = ToolDefinition::fromReflection($reflectionMethod, $name, $description, $docBlockParser, $schemaGenerator); - - $registry = $this->getRegistry(); - $registry->registerTool($definition); - - $this->logger->debug('MCP: Manually registered tool.', ['name' => $definition->getName(), 'handler' => "{$className}::{$methodName}"]); - - return $this; - } - - /** - * Manually register a resource with the server. + * For framework integration where the loop is managed externally, use `getProtocolHandler()` + * and bind it to your framework's transport mechanism manually. + * + * @param ServerTransportInterface $transport The transport to listen with. * - * @param array|class-string $handler The handler to register, containing a class name and method name. - * @param string $uri The URI of the resource. - * @param string|null $name The name of the resource. - * @param string|null $description The description of the resource. - * @param string|null $mimeType The MIME type of the resource. - * @param int|null $size The size of the resource. - * @param array|null $annotations The annotations of the resource. + * @throws LogicException If called after already listening. + * @throws Throwable If transport->listen() fails immediately. */ - 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 listen(ServerTransportInterface $transport): void { - $reflectionMethod = $this->validateAndGetReflectionMethod($handler); - $className = $reflectionMethod->getDeclaringClass()->getName(); - $methodName = $reflectionMethod->getName(); - $isInvokable = $methodName === '__invoke'; + if ($this->isListening) { + throw new LogicException('Server is already listening via a transport.'); + } - $docBlockParser = new DocBlockParser($this->container); + $this->warnIfNoElements(); - $name = $name ?? ($isInvokable ? (new ReflectionClass($className))->getShortName() : $methodName); - $definition = ResourceDefinition::fromReflection($reflectionMethod, $name, $description, $uri, $mimeType, $size, $annotations, $docBlockParser); + if ($transport instanceof LoggerAwareInterface) { + $transport->setLogger($this->configuration->logger); + } + if ($transport instanceof LoopAwareInterface) { + $transport->setLoop($this->configuration->loop); + } - $registry = $this->getRegistry(); - $registry->registerResource($definition); + $protocolHandler = $this->getProtocolHandler(); - $this->logger->debug('MCP: Manually registered resource.', ['name' => $definition->getName(), 'handler' => "{$className}::{$methodName}"]); + $closeHandlerCallback = function (?string $reason = null) use ($protocolHandler) { + $this->isListening = false; + $this->configuration->logger->info('Transport closed.', ['reason' => $reason ?? 'N/A']); + $protocolHandler->unbindTransport(); + $this->configuration->loop->stop(); + }; - return $this; - } + $transport->once('close', $closeHandlerCallback); - /** - * Manually register a prompt with the server. - * - * @param array|class-string $handler The handler to register, containing a class name and method name. - * @param string|null $name The name of the prompt. - * @param string|null $description The description of the prompt. - */ - public function withPrompt(array|string $handler, ?string $name = null, ?string $description = null): self - { - $reflectionMethod = $this->validateAndGetReflectionMethod($handler); - $className = $reflectionMethod->getDeclaringClass()->getName(); - $methodName = $reflectionMethod->getName(); - $isInvokable = $methodName === '__invoke'; + $protocolHandler->bindTransport($transport); - $docBlockParser = new DocBlockParser($this->container); - $name = $name ?? ($isInvokable ? (new ReflectionClass($className))->getShortName() : $methodName); - $definition = PromptDefinition::fromReflection($reflectionMethod, $name, $description, $docBlockParser); + try { + $transport->listen(); - $registry = $this->getRegistry(); - $registry->registerPrompt($definition); + $this->isListening = true; - $this->logger->debug('MCP: Manually registered prompt.', ['name' => $definition->getName(), 'handler' => "{$className}::{$methodName}"]); + $this->configuration->loop->run(); // BLOCKING - return $this; + } catch (Throwable $e) { + $this->configuration->logger->critical('Failed to start listening or event loop crashed.', ['exception' => $e]); + if ($this->isListening) { + $protocolHandler->unbindTransport(); + $transport->removeListener('close', $closeHandlerCallback); // Remove listener + $transport->close(); + } + $this->isListening = false; + throw $e; + } finally { + if ($this->isListening) { + $protocolHandler->unbindTransport(); + $transport->removeListener('close', $closeHandlerCallback); + $transport->close(); + } + $this->isListening = false; + $this->configuration->logger->info("Server '{$this->configuration->serverName}' listener shut down."); + } } - /** - * Manually register a resource template with the server. - * - * @param array|class-string $handler The handler to register, containing a class name and method name. - * @param string|null $name The name of the resource template. - * @param string|null $description The description of the resource template. - * @param string|null $uriTemplate The URI template of the resource template. - * @param string|null $mimeType The MIME type of the resource template. - * @param array|null $annotations The annotations of the resource template. - */ - public function withResourceTemplate(array|string $handler, ?string $name = null, ?string $description = null, ?string $uriTemplate = null, ?string $mimeType = null, ?array $annotations = []): self + protected function warnIfNoElements(): void { - $reflectionMethod = $this->validateAndGetReflectionMethod($handler); - $className = $reflectionMethod->getDeclaringClass()->getName(); - $methodName = $reflectionMethod->getName(); - $isInvokable = $methodName === '__invoke'; - - $docBlockParser = new DocBlockParser($this->container); - $name = $name ?? ($isInvokable ? (new ReflectionClass($className))->getShortName() : $methodName); - $definition = ResourceTemplateDefinition::fromReflection($reflectionMethod, $name, $description, $uriTemplate, $mimeType, $annotations, $docBlockParser); - - $registry = $this->getRegistry(); - $registry->registerResourceTemplate($definition); - - $this->logger->debug('MCP: Manually registered resource template.', ['name' => $definition->getName(), 'handler' => "{$className}::{$methodName}"]); - - return $this; + if (! $this->registry->hasElements() && ! $this->discoveryRan) { + $this->configuration->logger->warning( + 'Starting listener, but no MCP elements are registered and discovery has not been run. '. + 'Call $server->discover(...) at least once to find and cache elements before listen().' + ); + } elseif (! $this->registry->hasElements() && $this->discoveryRan) { + $this->configuration->logger->warning( + 'Starting listener, but no MCP elements were found after discovery/cache load.' + ); + } } /** - * Validates a handler and returns its ReflectionMethod. - * - * @param array|string $handler The handler to validate - * @return ReflectionMethod The reflection method for the handler + * Gets the ProtocolHandler instance associated with this server. * - * @throws InvalidArgumentException If the handler is invalid + * Useful for framework integrations where the event loop and transport + * communication are managed externally. */ - private function validateAndGetReflectionMethod(array|string $handler): ReflectionMethod - { - $className = null; - $methodName = null; - - if (is_array($handler)) { - if (count($handler) !== 2 || ! is_string($handler[0]) || ! is_string($handler[1])) { - throw new InvalidArgumentException('Invalid handler format. Expected [ClassName::class, \'methodName\'].'); - } - [$className, $methodName] = $handler; - if (! class_exists($className)) { - throw new InvalidArgumentException("Class '{$className}' not found for array handler."); - } - if (! method_exists($className, $methodName)) { - throw new InvalidArgumentException("Method '{$methodName}' not found in class '{$className}' for array handler."); - } - } elseif (is_string($handler) && class_exists($handler)) { - $className = $handler; - $methodName = '__invoke'; - if (! method_exists($className, $methodName)) { - throw new InvalidArgumentException("Invokable class '{$className}' must have a public '__invoke' method."); - } - } else { - throw new InvalidArgumentException('Invalid handler format. Expected [ClassName::class, \'methodName\'] or InvokableClassName::class string.'); + public function getProtocolHandler(): ProtocolHandler + { + if ($this->protocolHandler === null) { + $this->protocolHandler = new ProtocolHandler( + $this->processor, + $this->clientStateManager, + $this->configuration->logger, + $this->configuration->loop + ); } - try { - $reflectionMethod = new ReflectionMethod($className, $methodName); - - if ($reflectionMethod->isStatic()) { - throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be static."); - } - if (! $reflectionMethod->isPublic()) { - throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' must be public."); - } - if ($reflectionMethod->isAbstract()) { - throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be abstract."); - } - if ($reflectionMethod->isConstructor() || $reflectionMethod->isDestructor()) { - throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be a constructor or destructor."); - } - - return $reflectionMethod; - - } catch (\ReflectionException $e) { - throw new InvalidArgumentException("Reflection error for handler '{$className}::{$methodName}': {$e->getMessage()}", 0, $e); - } + return $this->protocolHandler; } - // --- Core Actions --- // + // --- Getters for Core Components --- - public function discover(bool $cache = true): self + public function getConfiguration(): Configuration { - $registry = $this->getRegistry(); - - $discoverer = new Discoverer($this->container, $registry); - - $discoverer->discover($this->basePath, $this->scanDirs, $this->excludeDirs); - - if ($cache) { - $registry->saveElementsToCache(); - } - - return $this; - } - - public function run(?string $transport = null): int - { - if ($transport === null) { - $sapi = php_sapi_name(); - $transport = (str_starts_with($sapi, 'cli') || str_starts_with($sapi, 'phpdbg')) ? 'stdio' : 'http'; - $this->logger->info('Auto-detected transport', ['sapi' => $sapi, 'transport' => $transport]); - } - - $handler = match (strtolower($transport)) { - 'stdio' => new StdioTransportHandler($this), - 'reactphp' => throw new LogicException('MCP: reactphp transport cannot be run directly via Server::run(). Integrate ReactPhpHttpTransportHandler into your ReactPHP server.'), - 'http' => throw new LogicException("Cannot run HTTP transport directly via Server::run(). Instantiate \PhpMcp\Server\Transports\HttpTransportHandler and integrate it into your HTTP server/framework."), - default => throw new LogicException("Unsupported transport: {$transport}"), - }; - - return $handler->start(); + return $this->configuration; } public function getRegistry(): Registry { - if (is_null($this->registry)) { - $this->registry = new Registry($this->container); - } - return $this->registry; } public function getProcessor(): Processor { - $registry = $this->getRegistry(); - - return new Processor($this->container, $registry); + return $this->processor; } - public function getContainer(): ContainerInterface + public function getClientStateManager(): ClientStateManager { - return $this->container; + return $this->clientStateManager; } } diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php new file mode 100644 index 0000000..080260e --- /dev/null +++ b/src/ServerBuilder.php @@ -0,0 +1,352 @@ +name = trim($name); + $this->version = trim($version); + + return $this; + } + + /** Configures the server's declared capabilities. */ + public function withCapabilities(Capabilities $capabilities): self + { + $this->capabilities = $capabilities; + + return $this; + } + + /** Provides a PSR-3 logger instance. Defaults to NullLogger. */ + public function withLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + /** + * 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). + */ + public function withCache(CacheInterface $cache, int $definitionCacheTtl = 3600): self + { + $this->cache = $cache; + $this->definitionCacheTtl = $definitionCacheTtl > 0 ? $definitionCacheTtl : 3600; + + return $this; + } + + /** + * Provides a PSR-11 DI container, primarily for resolving user-defined handler classes. + * Defaults to a basic internal container. + */ + public function withContainer(ContainerInterface $container): self + { + $this->container = $container; + + return $this; + } + + /** Provides a ReactPHP Event Loop instance. Defaults to Loop::get(). */ + public function withLoop(LoopInterface $loop): self + { + $this->loop = $loop; + + return $this; + } + + /** Manually registers a tool handler. */ + public function withTool(array|string $handler, ?string $name = null, ?string $description = null): self + { + $this->manualTools[] = compact('handler', 'name', 'description'); + + return $this; + } + + /** 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 + { + $this->manualResources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations'); + + return $this; + } + + /** 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 + { + $this->manualResourceTemplates[] = compact('handler', 'uriTemplate', 'name', 'description', 'mimeType', 'annotations'); + + return $this; + } + + /** Manually registers a prompt handler. */ + public function withPrompt(array|string $handler, ?string $name = null, ?string $description = null): self + { + $this->manualPrompts[] = compact('handler', 'name', 'description'); + + return $this; + } + + /** + * Builds the fully configured Server instance. + * + * @throws ConfigurationException If required configuration is missing. + */ + public function build(): Server + { + if ($this->name === null || $this->version === null || $this->name === '' || $this->version === '') { + throw new ConfigurationException('Server name and version must be provided using withServerInfo().'); + } + + $loop = $this->loop ?? Loop::get(); + $logger = $this->logger ?? new NullLogger(); + $container = $this->container ?? new BasicContainer(); + + $cache = $this->cache; + if ($cache === null) { + $defaultCacheDir = dirname(__DIR__, 2).'/cache'; + if (! is_dir($defaultCacheDir)) { + @mkdir($defaultCacheDir, 0775, true); + } + + $cacheFile = $defaultCacheDir.'/mcp_server_registry.cache'; + if (is_dir($defaultCacheDir) && (is_writable($defaultCacheDir) || is_writable($cacheFile))) { + try { + $cache = new FileCache($cacheFile); + } catch (\InvalidArgumentException $e) { + $logger->warning('Failed to initialize default FileCache, cache disabled.', ['path' => $cacheFile, 'error' => $e->getMessage()]); + $cache = null; + } + } else { + $logger->warning('Default cache directory not found or not writable, cache disabled.', ['path' => $defaultCacheDir]); + $cache = null; + } + } + + $capabilities = $this->capabilities ?? Capabilities::forServer(); + + $configuration = new Configuration( + serverName: $this->name, + serverVersion: $this->version, + capabilities: $capabilities, + logger: $logger, + loop: $loop, + cache: $cache, + container: $container, + definitionCacheTtl: $this->definitionCacheTtl ?? 3600 + ); + + $clientStateManager = new ClientStateManager($configuration->logger, $configuration->cache, 'mcp_state_', $configuration->definitionCacheTtl); + $registry = new Registry($configuration->logger, $configuration->cache, $clientStateManager); + $processor = new Processor($configuration, $registry, $clientStateManager, $configuration->container); + + $this->performManualRegistrations($registry, $configuration->logger); + + $server = new Server( + $configuration, + $registry, + $processor, + $clientStateManager, + ); + + return $server; + } + + /** + * Helper to perform the actual registration based on stored data. + * Moved into the builder. + */ + private function performManualRegistrations(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); + + // Register Tools + foreach ($this->manualTools as $data) { + try { + $methodRefl = $this->validateAndGetReflectionMethod($data['handler']); + $def = Definitions\ToolDefinition::fromReflection( + $methodRefl, + $data['name'], + $data['description'], + $docBlockParser, + $schemaGenerator + ); + $registry->registerTool($def, true); + $logger->debug("Registered manual tool '{$def->getName()}'"); + } catch (Throwable $e) { + $errorCount++; + $logger->error('Failed to register manual tool', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); + } + } + + // Register Resources + foreach ($this->manualResources as $data) { + try { + $methodRefl = $this->validateAndGetReflectionMethod($data['handler']); + $def = Definitions\ResourceDefinition::fromReflection( + $methodRefl, + $data['name'], + $data['description'], + $data['uri'], + $data['mimeType'], + $data['size'], + $data['annotations'], + $docBlockParser + ); + $registry->registerResource($def, true); + $logger->debug("Registered manual resource '{$def->getUri()}'"); + } catch (Throwable $e) { + $errorCount++; + $logger->error('Failed to register manual resource', ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e]); + } + } + + // Register Templates + foreach ($this->manualResourceTemplates as $data) { + try { + $methodRefl = $this->validateAndGetReflectionMethod($data['handler']); + $def = Definitions\ResourceTemplateDefinition::fromReflection( + $methodRefl, + $data['name'], + $data['description'], + $data['uriTemplate'], + $data['mimeType'], + $data['annotations'], + $docBlockParser + ); + $registry->registerResourceTemplate($def, true); + $logger->debug("Registered manual template '{$def->getUriTemplate()}'"); + } catch (Throwable $e) { + $errorCount++; + $logger->error('Failed to register manual template', ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e]); + } + } + + // Register Prompts + foreach ($this->manualPrompts as $data) { + try { + $methodRefl = $this->validateAndGetReflectionMethod($data['handler']); + $def = Definitions\PromptDefinition::fromReflection( + $methodRefl, + $data['name'], + $data['description'], + $docBlockParser + ); + $registry->registerPrompt($def, true); + $logger->debug("Registered manual prompt '{$def->getName()}'"); + } catch (Throwable $e) { + $errorCount++; + $logger->error('Failed to register manual prompt', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); + } + } + + if ($errorCount > 0) { + throw new DefinitionException("{$errorCount} error(s) occurred during manual element registration. Check logs."); + } + + $logger->debug('Manual element registration complete.'); + } + + /** @internal Helper copied from old Server class for validation */ + private function validateAndGetReflectionMethod(array|string $handler): \ReflectionMethod + { + $className = null; + $methodName = null; + + if (is_array($handler)) { + if (count($handler) !== 2 || ! is_string($handler[0]) || ! is_string($handler[1])) { + throw new \InvalidArgumentException('Invalid array handler format. Expected [ClassName::class, \'methodName\'].'); + } + [$className, $methodName] = $handler; + if (! class_exists($className)) { + throw new \InvalidArgumentException("Class '{$className}' not found for array handler."); + } + if (! method_exists($className, $methodName)) { + throw new \InvalidArgumentException("Method '{$methodName}' not found in class '{$className}' for array handler."); + } + } elseif (is_string($handler) && class_exists($handler)) { + $className = $handler; + $methodName = '__invoke'; + if (! method_exists($className, $methodName)) { + throw new \InvalidArgumentException("Invokable class '{$className}' must have a public '__invoke' method."); + } + } else { + throw new \InvalidArgumentException('Invalid handler format. Expected [ClassName::class, \'methodName\'] or InvokableClassName::class string.'); + } + + try { + $reflectionMethod = new \ReflectionMethod($className, $methodName); + if ($reflectionMethod->isStatic()) { + throw new \InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be static."); + } + if (! $reflectionMethod->isPublic()) { + throw new \InvalidArgumentException("Handler method '{$className}::{$methodName}' must be public."); + } + if ($reflectionMethod->isAbstract()) { + throw new \InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be abstract."); + } + if ($reflectionMethod->isConstructor() || $reflectionMethod->isDestructor()) { + throw new \InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be a constructor or destructor."); + } + + return $reflectionMethod; + } catch (\ReflectionException $e) { + throw new \InvalidArgumentException("Reflection error for handler '{$className}::{$methodName}': {$e->getMessage()}", 0, $e); + } + } +} diff --git a/src/State/TransportState.php b/src/State/TransportState.php deleted file mode 100644 index d4ae4f4..0000000 --- a/src/State/TransportState.php +++ /dev/null @@ -1,324 +0,0 @@ -cache = $this->container->get(CacheInterface::class); - $this->logger = $this->container->get(LoggerInterface::class); - $config = $this->container->get(ConfigRepository::class); - - $this->cachePrefix = $config->get('mcp.cache.prefix', 'mcp_'); - $this->cacheTtl = $config->get('mcp.cache.ttl', 3600); - } - - private function getCacheKey(string $key, ?string $clientId = null): string - { - return $clientId ? "{$this->cachePrefix}{$key}_{$clientId}" : "{$this->cachePrefix}{$key}"; - } - - // --- Initialization --- - - public function isInitialized(string $clientId): bool - { - return (bool) $this->cache->get($this->getCacheKey('initialized', $clientId), false); - } - - public function markInitialized(string $clientId): void - { - try { - $this->cache->set($this->getCacheKey('initialized', $clientId), true, $this->cacheTtl); - $this->updateClientActivity($clientId); - $this->logger->info('MCP: Client initialized.', ['client_id' => $clientId]); - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - $this->logger->error('Failed to mark client as initialized in cache.', ['client_id' => $clientId, 'exception' => $e]); - } - } - - public function storeClientInfo(array $clientInfo, string $protocolVersion, string $clientId): void - { - try { - $this->cache->set($this->getCacheKey('client_info', $clientId), $clientInfo, $this->cacheTtl); - $this->cache->set($this->getCacheKey('protocol_version', $clientId), $protocolVersion, $this->cacheTtl); - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - $this->logger->error('Failed to store client info in cache.', ['client_id' => $clientId, 'exception' => $e]); - } - } - - public function getClientInfo(string $clientId): ?array - { - return $this->cache->get($this->getCacheKey('client_info', $clientId)); - } - - public function getProtocolVersion(string $clientId): ?string - { - return $this->cache->get($this->getCacheKey('protocol_version', $clientId)); - } - - // --- Subscriptions --- - - public function addResourceSubscription(string $clientId, string $uri): void - { - try { - $clientSubKey = $this->getCacheKey('client_subscriptions', $clientId); - $resourceSubKey = $this->getCacheKey('resource_subscriptions', $uri); - - $clientSubscriptions = $this->cache->get($clientSubKey, []); - $resourceSubscriptions = $this->cache->get($resourceSubKey, []); - - $clientSubscriptions[$uri] = true; - $resourceSubscriptions[$clientId] = true; - - $this->cache->set($clientSubKey, $clientSubscriptions, $this->cacheTtl); - $this->cache->set($resourceSubKey, $resourceSubscriptions, $this->cacheTtl); - - $this->logger->debug('MCP Client subscribed to resource.', ['client_id' => $clientId, 'uri' => $uri]); - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - $this->logger->error('Failed to add resource subscription to cache.', ['client_id' => $clientId, 'uri' => $uri, 'exception' => $e]); - } - } - - public function removeResourceSubscription(string $clientId, string $uri): void - { - try { - $clientSubKey = $this->getCacheKey('client_subscriptions', $clientId); - $resourceSubKey = $this->getCacheKey('resource_subscriptions', $uri); - - $clientSubscriptions = $this->cache->get($clientSubKey, []); - $resourceSubscriptions = $this->cache->get($resourceSubKey, []); - - if (isset($clientSubscriptions[$uri])) { - unset($clientSubscriptions[$uri]); - $this->cache->set($clientSubKey, $clientSubscriptions, $this->cacheTtl); - } - - if (isset($resourceSubscriptions[$clientId])) { - unset($resourceSubscriptions[$clientId]); - $this->cache->set($resourceSubKey, $resourceSubscriptions, $this->cacheTtl); - } - - $this->logger->debug('MCP Client unsubscribed from resource.', ['client_id' => $clientId, 'uri' => $uri]); - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - $this->logger->error('Failed to remove resource subscription from cache.', ['client_id' => $clientId, 'uri' => $uri, 'exception' => $e]); - } - } - - public function removeAllResourceSubscriptions(string $clientId): void - { - try { - $clientSubKey = $this->getCacheKey('client_subscriptions', $clientId); - $clientSubscriptions = $this->cache->get($clientSubKey, []); - - if (empty($clientSubscriptions)) { - return; - } - - $uris = array_keys($clientSubscriptions); - $resourceSubKeys = []; - foreach ($uris as $uri) { - $resourceSubKey = $this->getCacheKey('resource_subscriptions', $uri); - $resourceSubscriptions = $this->cache->get($resourceSubKey, []); - if (isset($resourceSubscriptions[$clientId])) { - unset($resourceSubscriptions[$clientId]); - // Only update if changes were made - if (empty($resourceSubscriptions)) { - $this->cache->delete($resourceSubKey); - } else { - $this->cache->set($resourceSubKey, $resourceSubscriptions, $this->cacheTtl); - } - } - } - - // Remove the client's subscription list - $this->cache->delete($clientSubKey); - - $this->logger->debug('MCP: Client removed all resource subscriptions.', ['client_id' => $clientId, 'count' => count($uris)]); - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - $this->logger->error('Failed to remove all resource subscriptions from cache.', ['client_id' => $clientId, 'exception' => $e]); - } - } - - /** @return array */ - public function getResourceSubscribers(string $uri): array - { - $resourceSubscriptions = $this->cache->get($this->getCacheKey('resource_subscriptions', $uri), []); - - return array_keys($resourceSubscriptions); - } - - public function isSubscribedToResource(string $clientId, string $uri): bool - { - $clientSubscriptions = $this->cache->get($this->getCacheKey('client_subscriptions', $clientId), []); - - return isset($clientSubscriptions[$uri]); - } - - // --- Message Queue --- - - public function queueMessage(string $clientId, Message|array $message): void - { - try { - $key = $this->getCacheKey('messages', $clientId); - // Use locking or atomic operations if cache driver supports it to prevent race conditions - $messages = $this->cache->get($key, []); - - $newMessages = []; - if (is_array($message)) { - foreach ($message as $singleMessage) { - if ($singleMessage instanceof Message) { - $newMessages[] = $singleMessage->toArray(); - } - } - } elseif ($message instanceof Message) { - $newMessages[] = $message->toArray(); - } - - if (! empty($newMessages)) { - $this->cache->set($key, array_merge($messages, $newMessages), $this->cacheTtl); - } - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - $this->logger->error('Failed to queue message in cache.', ['client_id' => $clientId, 'exception' => $e]); - } - } - - public function queueMessageForAll(Message|array $message): void - { - $clients = $this->getActiveClients(); - foreach ($clients as $clientId) { - $this->queueMessage($clientId, $message); - } - } - - /** @return array */ - public function getQueuedMessages(string $clientId): array - { - try { - $key = $this->getCacheKey('messages', $clientId); - - // Use atomic get-and-delete if cache driver supports it - $messages = $this->cache->get($key, []); - if (! empty($messages)) { - $this->cache->delete($key); - } - - return $messages; - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - $this->logger->error('Failed to get queued messages from cache.', ['client_id' => $clientId, 'exception' => $e->getMessage()]); - - return []; - } - } - - // --- Client Management --- - - public function cleanupClient(string $clientId, bool $removeFromActiveList = true): void - { - $this->removeAllResourceSubscriptions($clientId); - - try { - if ($removeFromActiveList) { - $activeClientsKey = $this->getCacheKey('active_clients'); - $activeClients = $this->cache->get($activeClientsKey, []); - unset($activeClients[$clientId]); - $this->cache->set($activeClientsKey, $activeClients, $this->cacheTtl); - } - - // Delete other client-specific data - $keysToDelete = [ - $this->getCacheKey('initialized', $clientId), - $this->getCacheKey('client_info', $clientId), - $this->getCacheKey('protocol_version', $clientId), - $this->getCacheKey('messages', $clientId), - $this->getCacheKey('client_subscriptions', $clientId), - ]; - $this->cache->deleteMultiple($keysToDelete); - - $this->logger->info('MCP: Client removed.', ['client_id' => $clientId]); - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - $this->logger->error('Failed to remove client data from cache.', ['client_id' => $clientId, 'exception' => $e]); - } - } - - public function updateClientActivity(string $clientId): void - { - try { - $key = $this->getCacheKey('active_clients'); - $activeClients = $this->cache->get($key, []); - $activeClients[$clientId] = time(); - $this->cache->set($key, $activeClients, $this->cacheTtl); - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - $this->logger->error('Failed to update client activity in cache.', ['client_id' => $clientId, 'exception' => $e]); - } - } - - /** @return array */ - public function getActiveClients(int $inactiveThreshold = 300): array - { - $activeClients = $this->cache->get($this->getCacheKey('active_clients'), []); - $currentTime = time(); - $result = []; - $clientsToCleanUp = []; - - foreach ($activeClients as $clientId => $lastSeen) { - if ($currentTime - $lastSeen < $inactiveThreshold) { - $result[] = $clientId; - } else { - $this->logger->info('MCP: Client considered inactive, removing from active list.', ['client_id' => $clientId, 'last_seen' => $lastSeen]); - $clientsToCleanUp[] = $clientId; - unset($activeClients[$clientId]); - } - } - - if (! empty($clientsToCleanUp)) { - try { - $this->cache->set($this->getCacheKey('active_clients'), $activeClients, $this->cacheTtl); - - foreach ($clientsToCleanUp as $clientId) { - $this->cleanupClient($clientId, false); - } - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - $this->logger->error('Failed to save cleaned active client list to cache.', ['exception' => $e]); - } - } - - return $result; - } - - /** - * Retrieves the last activity timestamp for a specific client. - * - * @return float|null The Unix timestamp (with microseconds) of the last activity, or null if unknown. - */ - public function getLastActivityTime(string $clientId): ?float - { - try { - $activeClientsKey = $this->getCacheKey('active_clients'); - $activeClients = $this->cache->get($activeClientsKey, []); - - return $activeClients[$clientId] ?? null; - } catch (\Psr\SimpleCache\InvalidArgumentException $e) { - $this->logger->error('Failed to get client activity time from cache.', ['client_id' => $clientId, 'exception' => $e]); - - return null; - } - } -} diff --git a/src/Support/Discoverer.php b/src/Support/Discoverer.php index 2f23b43..e28a2ed 100644 --- a/src/Support/Discoverer.php +++ b/src/Support/Discoverer.php @@ -14,7 +14,6 @@ use PhpMcp\Server\Definitions\ToolDefinition; use PhpMcp\Server\Exceptions\McpException; use PhpMcp\Server\Registry; -use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use ReflectionAttribute; use ReflectionClass; @@ -26,10 +25,6 @@ class Discoverer { - private Registry $registry; - - private LoggerInterface $logger; - private AttributeFinder $attributeFinder; private DocBlockParser $docBlockParser; @@ -37,16 +32,14 @@ class Discoverer private SchemaGenerator $schemaGenerator; public function __construct( - private ContainerInterface $container, - Registry $registry, + private Registry $registry, + private LoggerInterface $logger, ?DocBlockParser $docBlockParser = null, ?SchemaGenerator $schemaGenerator = null, ?AttributeFinder $attributeFinder = null ) { - $this->registry = $registry; - $this->logger = $this->container->get(LoggerInterface::class); - $this->attributeFinder = $attributeFinder ?? new AttributeFinder; - $this->docBlockParser = $docBlockParser ?? new DocBlockParser($this->container); + $this->attributeFinder = $attributeFinder ?? new AttributeFinder(); + $this->docBlockParser = $docBlockParser ?? new DocBlockParser($this->logger); $this->schemaGenerator = $schemaGenerator ?? new SchemaGenerator($this->docBlockParser); } @@ -59,7 +52,6 @@ public function __construct( */ public function discover(string $basePath, array $directories, array $excludeDirs = []): void { - $this->logger->debug('MCP: Starting attribute discovery.', ['basePath' => $basePath, 'paths' => $directories]); $startTime = microtime(true); $discoveredCount = [ 'tools' => 0, @@ -69,7 +61,7 @@ public function discover(string $basePath, array $directories, array $excludeDir ]; try { - $finder = new Finder; + $finder = new Finder(); $absolutePaths = []; foreach ($directories as $dir) { $path = rtrim($basePath, '/').'/'.ltrim($dir, '/'); diff --git a/src/Support/DocBlockParser.php b/src/Support/DocBlockParser.php index 21084a0..2fa7a1d 100644 --- a/src/Support/DocBlockParser.php +++ b/src/Support/DocBlockParser.php @@ -6,7 +6,6 @@ use phpDocumentor\Reflection\DocBlock\Tags\Param; use phpDocumentor\Reflection\DocBlock\Tags\Return_; use phpDocumentor\Reflection\DocBlockFactory; -use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Throwable; @@ -17,12 +16,9 @@ class DocBlockParser { private DocBlockFactory $docBlockFactory; - private LoggerInterface $logger; - - public function __construct(ContainerInterface $container) + public function __construct(private LoggerInterface $logger) { $this->docBlockFactory = DocBlockFactory::createInstance(); - $this->logger = $container->get(LoggerInterface::class); } /** @@ -37,7 +33,10 @@ public function parseDocBlock(?string $docComment): ?DocBlock return $this->docBlockFactory->create($docComment); } catch (Throwable $e) { // Log error or handle gracefully if invalid DocBlock syntax is encountered - $this->logger->warning('Failed to parse DocBlock', ['error' => $e->getMessage(), 'exception' => $e]); + $this->logger->warning('Failed to parse DocBlock', [ + 'error' => $e->getMessage(), + 'exception_trace' => $e->getTraceAsString(), + ]); return null; } diff --git a/src/Transports/HttpServerTransport.php b/src/Transports/HttpServerTransport.php new file mode 100644 index 0000000..b001ee3 --- /dev/null +++ b/src/Transports/HttpServerTransport.php @@ -0,0 +1,337 @@ + clientId => SSE Stream */ + private array $activeSseStreams = []; + + private bool $listening = false; + + private bool $closing = false; + + private string $ssePath; + + private string $messagePath; + + /** + * @param string $host Host to bind to (e.g., '127.0.0.1', '0.0.0.0'). + * @param int $port Port to listen on (e.g., 8080). + * @param string $mcpPathPrefix URL prefix for MCP endpoints (e.g., 'mcp'). + * @param array|null $sslContext Optional SSL context options for React SocketServer (for HTTPS). + */ + public function __construct( + private readonly string $host = '127.0.0.1', + private readonly int $port = 8080, + private readonly string $mcpPathPrefix = 'mcp', // e.g., /mcp/sse, /mcp/message + private readonly ?array $sslContext = null // For enabling HTTPS + ) { + $this->logger = new NullLogger(); + $this->loop = Loop::get(); + $this->ssePath = '/'.trim($mcpPathPrefix, '/').'/sse'; + $this->messagePath = '/'.trim($mcpPathPrefix, '/').'/message'; + } + + public function setLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + } + + public function setLoop(LoopInterface $loop): void + { + $this->loop = $loop; + } + + /** + * Starts the HTTP server listener. + * + * @throws TransportException If port binding fails. + */ + public function listen(): void + { + if ($this->listening) { + throw new TransportException('Http 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('HttpTransport: Socket server error.', ['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("SSE Endpoint: {$protocol}://{$listenAddress}{$this->ssePath}"); + $this->logger->info("Message Endpoint: {$protocol}://{$listenAddress}{$this->messagePath}"); + + $this->listening = true; + $this->closing = false; + $this->emit('ready'); + + } catch (Throwable $e) { + $this->logger->error("Failed to start listener on {$listenAddress}", ['exception' => $e]); + throw new TransportException("Failed to start HTTP listener on {$listenAddress}: {$e->getMessage()}", 0, $e); + } + } + + /** Creates the main request handling callback for ReactPHP HttpServer */ + private function createRequestHandler(): callable + { + return function (ServerRequestInterface $request) { + $path = $request->getUri()->getPath(); + $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'); + }; + } + + /** Handles a new SSE connection request */ + private function handleSseRequest(ServerRequestInterface $request): Response + { + $clientId = 'sse_'.bin2hex(random_bytes(16)); + $this->logger->info('New SSE connection', ['clientId' => $clientId]); + + $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('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']); + // Don't close the whole transport, just this stream + }); + + $this->activeSseStreams[$clientId] = $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]); + + return; + } + + try { + $baseUri = $request->getUri()->withPath($this->messagePath)->withQuery('')->withFragment(''); + $postEndpointWithId = (string) $baseUri->withQuery("clientId={$clientId}"); + $this->sendSseEvent($sseStream, 'endpoint', $postEndpointWithId, "init-{$clientId}"); + + $this->emit('client_connected', [$clientId]); + } catch (Throwable $e) { + $this->logger->error('Error sending initial endpoint event', ['clientId' => $clientId, 'exception' => $e]); + $sseStream->close(); + } + }); + + return new Response( + 200, + [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no', // Important for Nginx proxying + 'Access-Control-Allow-Origin' => '*', + ], + $sseStream + ); + } + + /** Handles incoming POST requests with messages */ + private function handleMessagePostRequest(ServerRequestInterface $request): Response + { + $queryParams = $request->getQueryParams(); + $clientId = $queryParams['clientId'] ?? null; + + if (! $clientId || ! is_string($clientId)) { + $this->logger->warning('HttpTransport: Received POST without valid clientId query parameter.'); + + return new Response(400, ['Content-Type' => 'text/plain'], 'Missing or invalid clientId query parameter'); + } + + if (! isset($this->activeSseStreams[$clientId])) { + $this->logger->warning('HttpTransport: Received POST for unknown or disconnected clientId.', ['clientId' => $clientId]); + + return new Response(404, ['Content-Type' => 'text/plain'], 'Client ID not found or disconnected'); + } + + if (! str_contains(strtolower($request->getHeaderLine('Content-Type')), 'application/json')) { + return new Response(415, ['Content-Type' => 'text/plain'], 'Content-Type must be application/json'); + } + + $body = $request->getBody()->getContents(); + + if (empty($body)) { + $this->logger->warning('HttpTransport: Received empty POST body', ['clientId' => $clientId]); + + return new Response(400, ['Content-Type' => 'text/plain'], 'Empty request body'); + } + + $this->emit('message', [$body, $clientId]); + + 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 + { + if (! isset($this->activeSseStreams[$clientId])) { + return reject(new TransportException("Cannot send message: Client '{$clientId}' not connected via SSE.")); + } + + $stream = $this->activeSseStreams[$clientId]; + if (! $stream->isWritable()) { + return reject(new TransportException("Cannot send message: SSE stream for client '{$clientId}' is not writable.")); + } + + // For SSE, the 'rawFramedMessage' should be the JSON payload. We frame it here. + $jsonData = trim($rawFramedMessage); + + if ($jsonData === '') { + return \React\Promise\resolve(null); + } + + $deferred = new Deferred(); + $written = $this->sendSseEvent($stream, 'message', $jsonData); + + if ($written) { + $this->logger->debug('HttpTransport: Message sent via SSE.', ['clientId' => $clientId, 'data' => $jsonData]); + $deferred->resolve(null); + } else { + $this->logger->debug('HttpTransport: SSE stream buffer full, waiting for drain.', ['clientId' => $clientId]); + $stream->once('drain', function () use ($deferred, $clientId) { + $this->logger->debug('HttpTransport: SSE stream drained.', ['clientId' => $clientId]); + $deferred->resolve(null); + }); + // Add a timeout? + } + + return $deferred->promise(); + } + + /** Helper to format and write an SSE event */ + private function sendSseEvent(WritableStreamInterface $stream, string $event, string $data, ?string $id = null): bool + { + if (! $stream->isWritable()) { + return false; + } + + $frame = "event: {$event}\n"; + if ($id !== null) { + $frame .= "id: {$id}\n"; + } + // Handle multi-line data + $lines = explode("\n", $data); + foreach ($lines as $line) { + $frame .= "data: {$line}\n"; + } + $frame .= "\n"; // End of event + + $this->logger->debug('Sending SSE event', ['event' => $event, 'frame' => $frame]); + + return $stream->write($frame); + } + + /** + * Stops the HTTP server and closes all connections. + */ + public function close(): void + { + if ($this->closing) { + return; + } + $this->closing = true; + $this->listening = false; + $this->logger->info('HttpTransport: Closing...'); + + if ($this->socket) { + $this->socket->close(); + $this->socket = null; + } + + $activeStreams = $this->activeSseStreams; + $this->activeSseStreams = []; + foreach ($activeStreams as $clientId => $stream) { + $this->logger->debug('HttpTransport: Closing active SSE stream', ['clientId' => $clientId]); + unset($this->activeSseStreams[$clientId]); + $stream->close(); + } + + $this->emit('close', ['HttpTransport closed.']); + $this->removeAllListeners(); + } +} diff --git a/src/Transports/HttpTransportHandler.php b/src/Transports/HttpTransportHandler.php deleted file mode 100644 index 4e09225..0000000 --- a/src/Transports/HttpTransportHandler.php +++ /dev/null @@ -1,222 +0,0 @@ -getContainer(); - $this->processor = $server->getProcessor(); - - $this->transportState = $transportState ?? new TransportState($container); - $this->logger = $container->get(LoggerInterface::class); - } - - public function start(): int - { - throw new \Exception('This method should never be called'); - } - - /** - * Processes an incoming MCP request received via HTTP POST. - * - * Parses the JSON body, processes the request(s) via Processor, - * and queues any responses in TransportState for later retrieval (e.g., via SSE). - * - * @param string $input The raw JSON request body string. - * @param string $clientId A unique identifier for the connected client (e.g., session ID). - * - * @throws JsonException If the request body is invalid JSON. - * @throws McpException If MCP processing fails validation or other MCP rules. - * @throws Throwable For other unexpected processing errors. - */ - public function handleInput(string $input, string $clientId): void - { - $this->logger->debug('MCP: Received request', ['client_id' => $clientId, 'input' => $input]); - - $this->transportState->updateClientActivity($clientId); - - $response = null; - - try { - $data = json_decode($input, true, 512, JSON_THROW_ON_ERROR); - - $id = $data['id'] ?? null; - - $message = $id === null - ? Notification::fromArray($data) - : Request::fromArray($data); - - $response = $this->processor->process($message, $clientId); - } catch (JsonException $e) { - $this->logger->error('MCP HTTP: JSON parse error', ['client_id' => $clientId, 'exception' => $e]); - - $response = $this->handleError($e); - } catch (McpException $e) { - $this->logger->error('MCP HTTP: Request processing error', ['client_id' => $clientId, 'code' => $e->getCode(), 'message' => $e->getMessage()]); - - $response = $this->handleError($e); - } catch (Throwable $e) { - $this->logger->error('MCP HTTP: Unexpected error processing message', ['client_id' => $clientId, 'exception' => $e]); - - $response = $this->handleError($e); - } - - if ($response !== null) { - $this->transportState->queueMessage($clientId, $response); - } - } - - public function sendResponse(string $data, string $clientId): void - { - echo $data; - - if (ob_get_level() > 0) { - ob_flush(); - } - flush(); - - $this->logger->debug('MCP: Sent response', ['json' => json_encode($data), 'client_id' => $clientId]); - } - - protected function sendSseEvent(string $clientId, string $event, string $data, ?string $id = null): void - { - $data = "event: {$event}\n".($id ? "id: {$id}\n" : '')."data: {$data}\n\n"; - - $this->sendResponse($data, $clientId); - } - - /** - * Manages the Server-Sent Events (SSE) message sending loop for a specific client. - * - * This method should be called within the context of an active, long-lived HTTP request - * that has the appropriate SSE headers set by the caller. - * - * @param callable $sendEventCallback A function provided by the caller responsible for sending data. - * Signature: function(string $event, mixed $data, ?string $id = null): void - * @param string $clientId The unique identifier for the client connection. - * @param string $postEndpointUri The URI the client MUST use for sending POST requests. - * @param int $loopInterval Seconds to sleep between loop iterations. - * @param int $activityUpdateInterval Seconds between updating client activity timestamp. - */ - public function handleSseConnection( - string $clientId, - string $postEndpointUri, - float $loopInterval = 0.05, // 50ms - float $activityUpdateInterval = 60.0, - ): void { - $this->logger->info('MCP: Starting SSE stream loop', ['client_id' => $clientId]); - - // disable default disconnect checks - ignore_user_abort(true); - - try { - $this->sendSseEvent($clientId, 'endpoint', $postEndpointUri); - } catch (Throwable $e) { - $this->logger->error('MCP: Failed to send initial endpoint event. Aborting stream.', ['client_id' => $clientId, 'exception' => $e->getMessage()]); - - return; - } - - $lastPing = microtime(true); - $lastActivityUpdate = microtime(true); - $eventId = 1; - - while (true) { - if (connection_aborted()) { - break; - } - - // 1. Send Queued Messages - $messages = $this->transportState->getQueuedMessages($clientId); - foreach ($messages as $message) { - try { - $messageData = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); - - $this->sendSseEvent($clientId, 'message', $messageData, (string) $eventId++); - } catch (Throwable $e) { - $this->logger->error('MCP: Error sending message event via callback', ['client_id' => $clientId, 'exception' => $e]); - break 2; // Exit loop on send error - } - } - - $now = microtime(true); - - // 2. Update Client Activity Timestamp - if (($now - $lastActivityUpdate) >= $activityUpdateInterval) { - $this->transportState->updateClientActivity($clientId); - $lastActivityUpdate = $now; - $this->logger->debug('MCP: Updated client activity timestamp', ['client_id' => $clientId]); - } - - // 3. Sleep briefly - usleep($loopInterval * 1000000); - } - - $this->logger->info('MCP: SSE stream loop ended', ['client_id' => $clientId]); - } - - /** - * Cleans up resources associated with a disconnected client. - * - * This should be called when the HTTP connection (especially SSE) is closed. - */ - public function cleanupClient(string $clientId): void - { - $this->transportState->cleanupClient($clientId); - } - - /** - * Helper to create a JSON-RPC error response structure. - * Note: The ID is usually unknown when handling errors outside the processor context. - */ - public function handleError(Throwable $error, string|int|null $id = 1): ?Response - { - $jsonRpcError = null; - if ($error instanceof JsonException) { - $jsonRpcError = McpException::parseError($error->getMessage())->toJsonRpcError(); - } elseif ($error instanceof McpException) { - $jsonRpcError = $error->toJsonRpcError(); - } else { - $jsonRpcError = McpException::internalError('Transport error: '.$error->getMessage(), $error)->toJsonRpcError(); - } - - return $jsonRpcError ? Response::error($jsonRpcError, id: $id) : null; - } - - public function stop(): void - { - $this->logger->info('MCP HTTP: Stopping HTTP Transport.'); - } - - /** - * Provides access to the underlying TransportState instance. - */ - public function getTransportState(): TransportState - { - return $this->transportState; - } -} diff --git a/src/Transports/ReactPhpHttpTransportHandler.php b/src/Transports/ReactPhpHttpTransportHandler.php deleted file mode 100644 index 5f36cdb..0000000 --- a/src/Transports/ReactPhpHttpTransportHandler.php +++ /dev/null @@ -1,210 +0,0 @@ - [timer1, timer2, ...]] - */ - private array $clientTimers = []; - - /** - * Store SSE streams per client [clientId => stream] - * - * @var array - */ - private array $clientSseStreams = []; - - public function __construct(Server $server) - { - parent::__construct($server); - - $this->loop = Loop::get(); - - $this->startGlobalCleanupTimer(); - } - - public function sendResponse(string $data, string $clientId): void - { - $stream = $this->getClientSseStream($clientId); - - $stream->write($data); - - $this->logger->debug('ReactPHP MCP: Sent response', ['data' => $data, 'client_id' => $clientId]); - } - - public function getClientSseStream(string $clientId): WritableStreamInterface - { - return $this->clientSseStreams[$clientId]; - } - - public function setClientSseStream(string $clientId, WritableStreamInterface $stream): void - { - $this->clientSseStreams[$clientId] = $stream; - } - - public function handleSseConnection( - string $clientId, - string $postEndpointUri, - float $loopInterval = 0.05, // 50ms - float $activityUpdateInterval = 60.0, - ): void { - $this->logger->info('ReactPHP MCP: Starting SSE stream', ['client_id' => $clientId]); - - $stream = $this->getClientSseStream($clientId); - if (! $stream->isWritable()) { - $this->logger->warning('ReactPHP MCP: Stream not writable on start.', ['client_id' => $clientId]); - - return; - } - - // 1. Send initial endpoint event (with a tiny delay) - $this->loop->addTimer(0.01, function () use ($clientId, $postEndpointUri) { - $stream = $this->getClientSseStream($clientId); - if (! $stream->isWritable()) { - $this->logger->warning('ReactPHP MCP: Stream closed before initial endpoint could be sent.', ['client_id' => $clientId]); - - return; - } - - $this->sendSseEvent($clientId, 'endpoint', $postEndpointUri, null); - }); - - // 2. Setup periodic timers - $this->clientTimers[$clientId] = []; - $eventId = 1; - - // Timer to pull and send queued messages - $messageTimer = $this->loop->addPeriodicTimer($loopInterval, function () use ($clientId, &$eventId) { - $stream = $this->getClientSseStream($clientId); - if (! $stream->isWritable()) { - $this->cleanupClient($clientId); - - return; - } - - try { - $messages = $this->transportState->getQueuedMessages($clientId); - - foreach ($messages as $message) { - $messageData = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); - $this->sendSseEvent($clientId, 'message', $messageData, (string) $eventId++); - } - } catch (Throwable $e) { - $this->logger->error('ReactPHP MCP: Error processing/sending message queue.', ['client_id' => $clientId, 'exception' => $e->getMessage()]); - // Decide if we should close the stream on error - $this->cleanupClient($clientId); - $stream->close(); - } - }); - $this->clientTimers[$clientId][] = $messageTimer; - - // Timer to update client activity timestamp - $activityTimer = $this->loop->addPeriodicTimer($activityUpdateInterval, function () use ($clientId) { - try { - $this->transportState->updateClientActivity($clientId); - $this->logger->debug('ReactPHP MCP: Updated client activity', ['client_id' => $clientId]); - } catch (Throwable $e) { - $this->logger->error('ReactPHP MCP: Error updating client activity.', ['client_id' => $clientId, 'exception' => $e->getMessage()]); - } - }); - $this->clientTimers[$clientId][] = $activityTimer; - - // 3. Setup stream close handler - $stream->on('close', function () use ($clientId) { - $this->logger->info('ReactPHP MCP: Stream closed by client or error.', ['client_id' => $clientId]); - $this->cleanupClient($clientId); // Ensure cleanup happens - }); - - $stream->on('error', function (Throwable $error) use ($clientId) { - $this->logger->error('ReactPHP MCP: Stream error.', ['client_id' => $clientId, 'exception' => $error->getMessage()]); - $this->cleanupClient($clientId); // Ensure cleanup happens - }); - } - - /** - * Cleans up resources for a client (cancels timers, delegates to core handler). - */ - public function cleanupClient(string $clientId): void - { - parent::cleanupClient($clientId); - - if (isset($this->clientTimers[$clientId])) { - $this->logger->debug('ReactPHP MCP: Cancelling timers', ['client_id' => $clientId]); - foreach ($this->clientTimers[$clientId] as $timer) { - $this->loop->cancelTimer($timer); - } - unset($this->clientTimers[$clientId]); - } else { - $this->logger->debug('ReactPHP MCP: No timers found to cancel for client', ['client_id' => $clientId]); - } - - if (isset($this->clientSseStreams[$clientId])) { - $this->clientSseStreams[$clientId]->close(); - unset($this->clientSseStreams[$clientId]); - } - } - - /** - * Starts a single timer that periodically checks all managed clients for inactivity. - */ - private function startGlobalCleanupTimer(): void - { - $this->loop->addPeriodicTimer(self::CLEANUP_CHECK_INTERVAL, function () { - $now = microtime(true); - $activeClientIds = array_keys($this->clientTimers); // Get IDs managed by *this* handler - $state = $this->transportState; - - if (empty($activeClientIds)) { - $this->logger->debug('ReactPHP MCP: Inactivity check running, no active clients managed by this handler.'); - - return; - } - - $this->logger->debug('ReactPHP MCP: Running inactivity check...', ['managed_clients' => count($activeClientIds)]); - - foreach ($activeClientIds as $clientId) { - $lastActivity = $state->getLastActivityTime($clientId); - - // If lastActivity is null, maybe the client never fully initialized or state was lost - // Treat it as inactive after a grace period? - // For now, only clean up if we have a timestamp and it's too old. - if ($lastActivity !== null && ($now - $lastActivity > self::INACTIVITY_TIMEOUT)) { - $this->logger->warning( - 'ReactPHP MCP: Client inactive for too long. Cleaning up.', - [ - 'client_id' => $clientId, - 'last_activity' => date('Y-m-d H:i:s', (int) $lastActivity), - 'timeout_seconds' => self::INACTIVITY_TIMEOUT, - ] - ); - // cleanupClient will cancel the timers and remove the ID from $this->clientTimers - $this->cleanupClient($clientId); - } - } - }); - } -} diff --git a/src/Transports/StdioServerTransport.php b/src/Transports/StdioServerTransport.php new file mode 100644 index 0000000..81bcae0 --- /dev/null +++ b/src/Transports/StdioServerTransport.php @@ -0,0 +1,223 @@ +inputStreamResource) || get_resource_type($this->inputStreamResource) !== 'stream') { + throw new TransportException('Invalid input stream resource provided.'); + } + + if (! is_resource($this->outputStreamResource) || get_resource_type($this->outputStreamResource) !== 'stream') { + throw new TransportException('Invalid output stream resource provided.'); + } + + $this->logger = new NullLogger(); + $this->loop = Loop::get(); + } + + public function setLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + } + + public function setLoop(LoopInterface $loop): void + { + $this->loop = $loop; + } + + /** + * Starts listening on STDIN. + * + * @throws TransportException If already listening or streams cannot be opened. + */ + public function listen(): void + { + if ($this->listening) { + throw new TransportException('Stdio transport is already listening.'); + } + + if ($this->closing) { + throw new TransportException('Cannot listen, transport is closing/closed.'); + } + + try { + $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]); + throw new TransportException("Failed to open standard streams: {$e->getMessage()}", 0, $e); + } + + $this->stdin->on('data', function ($chunk) { + $this->buffer .= $chunk; + $this->processBuffer(); + }); + + $this->stdin->on('error', function (Throwable $error) { + $this->logger->error('StdioTransport: STDIN stream error.', ['error' => $error->getMessage()]); + $this->emit('error', [new TransportException("STDIN error: {$error->getMessage()}", 0, $error), self::CLIENT_ID]); + $this->close(); + }); + + $this->stdin->on('close', function () { + $this->logger->info('STDIN stream closed.'); + $this->emit('client_disconnected', [self::CLIENT_ID, 'STDIN Closed']); + $this->close(); + }); + + $this->stdout->on('error', function (Throwable $error) { + $this->logger->error('StdioTransport: 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->close(); + }; + $this->loop->addSignal(SIGTERM, $signalHandler); + $this->loop->addSignal(SIGINT, $signalHandler); + + $this->logger->info('Server is up and listening on STDIN 🚀'); + + $this->listening = true; + $this->closing = false; + $this->emit('ready'); + $this->emit('client_connected', [self::CLIENT_ID]); + } + + /** Processes the internal buffer to find complete lines/frames. */ + private function processBuffer(): void + { + while (str_contains($this->buffer, "\n")) { + $pos = strpos($this->buffer, "\n"); + $line = substr($this->buffer, 0, $pos); + $this->buffer = substr($this->buffer, $pos + 1); + + $trimmedLine = trim($line); + if ($trimmedLine !== '') { + $this->emit('message', [$trimmedLine, self::CLIENT_ID]); + } + } + } + + /** + * Sends a raw, framed message to STDOUT. + */ + public function sendToClientAsync(string $clientId, string $rawFramedMessage): PromiseInterface + { + // For stdio, clientId is always the same, but we check anyway + if ($clientId !== self::CLIENT_ID) { + $this->logger->error("StdioTransport: 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); + + if ($written) { + $deferred->resolve(null); + } else { + // Handle backpressure: resolve the promise once the stream drains + $this->logger->debug('StdioTransport: STDOUT buffer full, waiting for drain.'); + $this->stdout->once('drain', function () use ($deferred) { + $this->logger->debug('StdioTransport: STDOUT drained.'); + $deferred->resolve(null); + }); + } + + return $deferred->promise(); + } + + /** + * Stops listening and cleans up resources. + */ + public function close(): void + { + if ($this->closing) { + return; + } + $this->closing = true; + $this->listening = false; + $this->logger->info('Closing Stdio transport...'); + + $this->stdin?->close(); + $this->stdout?->close(); + + // Remove signal handlers if possible/needed - Loop usually handles this on stop + // $this->loop->removeSignal(...) + + $this->stdin = null; + $this->stdout = null; + + $this->emit('close', ['StdioTransport closed.']); + $this->removeAllListeners(); + } +} diff --git a/src/Transports/StdioTransportHandler.php b/src/Transports/StdioTransportHandler.php deleted file mode 100644 index 2c4c3fe..0000000 --- a/src/Transports/StdioTransportHandler.php +++ /dev/null @@ -1,275 +0,0 @@ -loop = $loop ?? Loop::get(); - $this->inputStream = $inputStream ?? new ReadableResourceStream(STDIN, $this->loop); - $this->outputStream = $outputStream ?? new WritableResourceStream(STDOUT, $this->loop); - - $container = $server->getContainer(); - $this->processor = $server->getProcessor(); - $this->transportState = $transportState ?? new TransportState($container); - $this->logger = $container->get(LoggerInterface::class); - } - - /** - * Start processing messages. - * - * @return int Exit code> - */ - public function start(): int - { - try { - $this->logger->info('MCP: Starting STDIO Transport Handler.'); - fwrite(STDERR, "MCP: Starting STDIO Transport Handler...\n"); - - $this->inputStream->on('error', function (Throwable $error) { - $this->logger->error('MCP: Input stream error', ['exception' => $error]); - $this->stop(); - }); - - $this->outputStream->on('error', function (Throwable $error) { - $this->logger->error('MCP: Output stream error', ['exception' => $error]); - $this->stop(); - }); - - $this->inputStream->on('data', fn ($data) => $this->handle($data, self::CLIENT_ID)); - - $this->loop->addPeriodicTimer(0.5, fn () => $this->checkQueuedMessages()); - $this->loop->addPeriodicTimer(60, fn () => $this->transportState->updateClientActivity(self::CLIENT_ID)); - $this->loop->addSignal(SIGTERM, fn () => $this->stop()); - $this->loop->addSignal(SIGINT, fn () => $this->stop()); - - $this->logger->info('MCP: STDIO Transport ready and waiting for input...'); - fwrite(STDERR, "MCP: STDIO Transport ready and waiting for input...\n"); - - $this->loop->run(); - - return 0; - } catch (Throwable $e) { - $this->logger->critical('MCP: Fatal error in STDIO transport handler', ['exception' => $e]); - $this->handleError($e); - - return 1; - } - } - - /** - * Handle incoming data according to MCP STDIO transport. - */ - public function handle(string|array $input, string $clientId): bool - { - if (! is_string($input)) { - return false; - } - - $this->buffer .= $input; - - while (($pos = strpos($this->buffer, "\n")) !== false) { - $line = substr($this->buffer, 0, $pos); - $this->buffer = substr($this->buffer, $pos + 1); - - if (! empty($line)) { - $this->logger->debug('MCP: Received message', ['message' => $line]); - $this->handleInput(trim($line), $clientId); - } - } - - return true; - } - - /** - * Process a complete line (JSON message) - */ - public function handleInput(string $input, string $clientId): void - { - $id = null; - $response = null; - $this->transportState->updateClientActivity($clientId); - - try { - $data = json_decode($input, true, 512, JSON_THROW_ON_ERROR); - $id = $data['id'] ?? null; - - $message = $id === null - ? Notification::fromArray($data) - : Request::fromArray($data); - - $response = $this->processor->process($message, $clientId); - } catch (JsonException $e) { - $this->logger->error('MCP: Error processing message: '.$e->getMessage(), [ - 'exception' => $e, - ]); - - $response = $this->handleError($e); - } catch (McpException $e) { - $this->logger->error('MCP: Error processing message: '.$e->getMessage(), [ - 'exception' => $e, - ]); - - $response = $this->handleError($e, $id); - } catch (Throwable $e) { - $this->logger->error('MCP: Error processing message: '.$e->getMessage(), [ - 'exception' => $e, - ]); - - $response = $this->handleError($e, $id); - } - - if ($response) { - if (! $this->outputStream || ! $this->outputStream->isWritable()) { - $this->logger->error('MCP: Cannot send response, output stream is not writable.'); - - return; - } - - $jsonResponse = json_encode($response, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $this->sendResponse($jsonResponse, $clientId); - } - } - - /** - * Send a JSON-RPC response message. - * - * @param Response|Response[]|array $message The message to send - * @param string $clientId The client ID - */ - public function sendResponse(string $data, string $clientId): void - { - $this->outputStream->write($data."\n"); - $this->logger->debug('MCP: Sent response', ['json' => $data, 'client_id' => $clientId]); - } - - /** - * Handle an error that occurred in the transport. - * - * @param Throwable $error The error that occurred - * @return Response|null Error response or null if error was handled - */ - public function handleError(Throwable $error, string|int|null $id = 1): ?Response - { - $id ??= 1; - $jsonRpcError = null; - - if ($error instanceof JsonException) { - $jsonRpcError = McpException::parseError($error->getMessage())->toJsonRpcError(); - } elseif ($error instanceof McpException) { - $jsonRpcError = $error->toJsonRpcError(); - } else { - $jsonRpcError = McpException::internalError('Transport error: '.$error->getMessage())->toJsonRpcError(); - } - - $this->logger->error('MCP: Transport Error', [ - 'error_code' => $jsonRpcError?->code ?? $error->getCode(), - 'message' => $jsonRpcError?->message ?? $error->getMessage(), - ]); - - return $jsonRpcError ? Response::error($jsonRpcError, $id) : null; - } - - /** - * Close the transport connection. - */ - public function stop(): void - { - $this->logger->info('MCP: Closing STDIO Transport.'); - fwrite(STDERR, "MCP: Closing STDIO Transport...\n"); - - $this->transportState->cleanupClient(self::CLIENT_ID); - - if ($this->inputStream) { - $this->inputStream->close(); - $this->inputStream = null; - } - if ($this->outputStream) { - $this->outputStream->close(); - $this->outputStream = null; - } - $this->loop->stop(); - } - - protected function checkQueuedMessages(): void - { - try { - $messages = $this->transportState->getQueuedMessages(self::CLIENT_ID); - - foreach ($messages as $messageData) { - if (! $this->outputStream || ! $this->outputStream->isWritable()) { - $this->logger->warning('MCP: Output stream not writable, dropping queued message.', ['message' => $messageData]); - - continue; - } - $jsonResponse = json_encode($messageData, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $this->outputStream->write($jsonResponse."\n"); - $this->logger->debug('MCP: Sent message from queue', ['json' => $jsonResponse]); - } - } catch (Throwable $e) { - $this->logger->error('MCP: Error processing or sending queued messages', ['exception' => $e]); - } - } -} diff --git a/tests/JsonRpc/ResponseTest.php b/tests/JsonRpc/ResponseTest.php deleted file mode 100644 index 144b3e2..0000000 --- a/tests/JsonRpc/ResponseTest.php +++ /dev/null @@ -1,210 +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); - expect($response->error->code)->toBe(100); - expect($response->error->message)->toBe('Test error'); -}); - -test('response throws exception if both result and error are provided', 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('isSuccess returns true for success response', function () { - $result = new EmptyResult(); - $response = new Response('2.0', 1, $result); - - expect($response->isSuccess())->toBeTrue(); -}); - -test('isSuccess returns false for error response', function () { - $error = new Error(100, 'Test error'); - $response = new Response('2.0', 1, null, $error); - - expect($response->isSuccess())->toBeFalse(); -}); - -test('isError returns true for error response', function () { - $error = new Error(100, 'Test error'); - $response = new Response('2.0', 1, null, $error); - - expect($response->isError())->toBeTrue(); -}); - -test('isError returns false for success response', function () { - $result = new EmptyResult(); - $response = new Response('2.0', 1, $result); - - expect($response->isError())->toBeFalse(); -}); - -test('fromArray creates valid success response', function () { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'result' => new EmptyResult(), - ]; - - $response = Response::fromArray($data); - - expect($response->jsonrpc)->toBe('2.0'); - expect($response->id)->toBe(1); - expect($response->result)->not->toBeNull(); - expect($response->error)->toBeNull(); -}); - -test('fromArray creates valid error response', 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'); -}); - -test('fromArray throws exception for invalid jsonrpc version', function () { - $data = [ - 'jsonrpc' => '1.0', - 'id' => 1, - 'result' => [], - ]; - - expect(fn () => Response::fromArray($data))->toThrow(McpException::class); -}); - -test('fromArray throws exception for missing result and error', function () { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 1, - ]; - - expect(fn () => Response::fromArray($data))->toThrow(McpException::class); -}); - -test('fromArray throws exception for missing id', function () { - $data = [ - 'jsonrpc' => '2.0', - 'result' => [], - ]; - - expect(fn () => Response::fromArray($data))->toThrow(McpException::class); -}); - -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(McpException::class); -}); - -test('fromArray throws exception for invalid error object', function () { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'error' => [ - // Missing code and message - ], - ]; - - expect(fn () => Response::fromArray($data))->toThrow(McpException::class); -}); - -test('toArray returns correct structure for success response', function () { - $result = new EmptyResult(); - $response = new Response('2.0', 1, $result); - - $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 = new Response('2.0', 1, null, $error); - - $array = $response->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'error' => [ - 'code' => 100, - 'message' => 'Test error', - ], - ]); -}); - -test('jsonSerialize returns same result as toArray', function () { - $result = new EmptyResult(); - $response = new Response('2.0', 1, $result); - - $array = $response->toArray(); - $json = $response->jsonSerialize(); - - expect($json)->toBe($array); -}); diff --git a/tests/ProcessorTest.php b/tests/ProcessorTest.php deleted file mode 100644 index 7b24b77..0000000 --- a/tests/ProcessorTest.php +++ /dev/null @@ -1,1042 +0,0 @@ -containerMock = Mockery::mock(ContainerInterface::class); - $this->configMock = Mockery::mock(ConfigurationRepositoryInterface::class); - $this->registryMock = Mockery::mock(Registry::class); - $this->transportStateMock = Mockery::mock(TransportState::class); - $this->loggerMock = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $this->schemaValidatorMock = Mockery::mock(SchemaValidator::class); - $this->argumentPreparerMock = Mockery::mock(ArgumentPreparer::class); - - // Default config values - $this->configMock->allows('get')->with('mcp.protocol_versions', Mockery::any())->andReturn([SUPPORTED_VERSION]); - $this->configMock->allows('get')->with('mcp.protocol_version', Mockery::any())->andReturn(SUPPORTED_VERSION); - $this->configMock->allows('get')->with('mcp.pagination_limit', Mockery::any())->andReturn(50)->byDefault(); - $this->configMock->allows('get')->with('mcp.server.name', Mockery::any())->andReturn(SERVER_NAME)->byDefault(); - $this->configMock->allows('get')->with('mcp.server.version', Mockery::any())->andReturn(SERVER_VERSION)->byDefault(); - $this->configMock->allows('get')->with('mcp.capabilities.tools.enabled', false)->andReturn(true)->byDefault(); - $this->configMock->allows('get')->with('mcp.capabilities.resources.enabled', false)->andReturn(true)->byDefault(); - $this->configMock->allows('get')->with('mcp.capabilities.prompts.enabled', false)->andReturn(true)->byDefault(); - $this->configMock->allows('get')->with('mcp.capabilities.logging.enabled', false)->andReturn(true)->byDefault(); - $this->configMock->allows('get')->with('mcp.instructions')->andReturn(null)->byDefault(); // Default no instructions - $this->configMock->allows('get')->with('mcp.capabilities.tools.listChanged', false)->andReturn(true)->byDefault(); - $this->configMock->allows('get')->with('mcp.capabilities.resources.subscribe', false)->andReturn(false)->byDefault(); - $this->configMock->allows('get')->with('mcp.capabilities.resources.listChanged', false)->andReturn(false)->byDefault(); - $this->configMock->allows('get')->with('mcp.capabilities.prompts.listChanged', false)->andReturn(false)->byDefault(); - - // Default registry state (empty) - $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(); - - // Default transport state (not initialized) - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(false)->byDefault(); - - $this->containerMock->allows('get')->with(ConfigurationRepositoryInterface::class)->andReturn($this->configMock); - $this->containerMock->allows('get')->with(LoggerInterface::class)->andReturn($this->loggerMock); - - $this->processor = new Processor( - $this->containerMock, - $this->registryMock, - $this->transportStateMock, - $this->schemaValidatorMock, - $this->argumentPreparerMock - ); -}); - -function createRequest(string $method, array $params = [], string $id = 'req-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 expectMcpErrorResponse(?Response $response, int $expectedCode, ?string $id = 'req-1'): void -{ - expect($response)->toBeInstanceOf(Response::class); - expect($response->id)->toBe($id); - expect($response->result)->toBeNull(); - expect($response->error)->toBeInstanceOf(JsonRpcError::class); - expect($response->error->code)->toBe($expectedCode); -} - -// --- Tests Start Here --- - -test('constructor loads elements from registry', function () { - // Assertion is handled by the mock expectation in beforeEach - expect(true)->toBeTrue(); -}); - -// --- Initialize Tests --- - -test('handleInitialize succeeds with valid parameters', function () { - $clientInfo = ['name' => 'TestClient', 'version' => '1.2.3']; - $request = createRequest('initialize', [ - 'protocolVersion' => SUPPORTED_VERSION, - 'clientInfo' => $clientInfo, - ]); - - // Expect state update - $this->transportStateMock->shouldReceive('storeClientInfo') - ->once() - ->with($clientInfo, SUPPORTED_VERSION, CLIENT_ID); - - // 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])); - - $this->configMock->allows('get')->with('mcp.capabilities.resources.subscribe', false)->andReturn(true); // Enable subscribe for test - - $response = $this->processor->process($request, CLIENT_ID); - - 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); - expect($response->result->serverInfo['version'])->toBe(SERVER_VERSION); - expect($response->result->protocolVersion)->toBe(SUPPORTED_VERSION); - 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]); // listChanged is false by default - expect($response->result->capabilities['prompts'])->toEqual(['listChanged' => false]); - expect($response->result->capabilities['logging'])->toBeInstanceOf(stdClass::class); // Should be empty object - expect($response->result->instructions)->toBeNull(); -}); - -test('handleInitialize succeeds with instructions', function () { - $clientInfo = ['name' => 'TestClient', 'version' => '1.2.3']; - $instructionsText = 'Use the tools provided.'; - $request = createRequest('initialize', [ - 'protocolVersion' => SUPPORTED_VERSION, - 'clientInfo' => $clientInfo, - ]); - - $this->configMock->allows('get')->with('mcp.instructions')->andReturn($instructionsText); - $this->transportStateMock->shouldReceive('storeClientInfo')->once(); - - $response = $this->processor->process($request, CLIENT_ID); - - expect($response->result->instructions)->toBe($instructionsText); -}); - -test('handleInitialize logs warning for unsupported protocol version but succeeds', function () { - $clientInfo = ['name' => 'TestClient', 'version' => '1.2.3']; - $unsupportedVersion = '2023-01-01'; - $request = createRequest('initialize', [ - 'protocolVersion' => $unsupportedVersion, - 'clientInfo' => $clientInfo, - ]); - - $this->loggerMock->shouldReceive('warning') - ->once() - ->withArgs(function ($message, $context) { - return str_contains($message, 'unsupported protocol version') && $context['supportedVersions'] === [SUPPORTED_VERSION]; - }); - $this->transportStateMock->shouldReceive('storeClientInfo')->once()->with($clientInfo, SUPPORTED_VERSION, CLIENT_ID); // Stores SERVER's version - - $response = $this->processor->process($request, CLIENT_ID); - - expect($response)->toBeInstanceOf(Response::class); - expect($response->error)->toBeNull(); - expect($response->result->protocolVersion)->toBe(SUPPORTED_VERSION); // Responds with server version -}); - -test('handleInitialize fails with missing protocolVersion', function () { - $request = createRequest('initialize', ['clientInfo' => ['name' => 'TestClient']]); - $response = $this->processor->process($request, CLIENT_ID); - expectMcpErrorResponse($response, McpException::CODE_INVALID_PARAMS); - expect($response->error->message)->toContain("Missing 'protocolVersion'"); -}); - -test('handleInitialize fails with missing clientInfo', function () { - $request = createRequest('initialize', ['protocolVersion' => SUPPORTED_VERSION]); - $response = $this->processor->process($request, CLIENT_ID); - expectMcpErrorResponse($response, McpException::CODE_INVALID_PARAMS); - expect($response->error->message)->toContain("Missing or invalid 'clientInfo'"); -}); - -test('handleInitialize fails with invalid clientInfo type', function () { - $request = createRequest('initialize', [ - 'protocolVersion' => SUPPORTED_VERSION, - 'clientInfo' => 'not-an-array', - ]); - $response = $this->processor->process($request, CLIENT_ID); - expectMcpErrorResponse($response, McpException::CODE_INVALID_PARAMS); - expect($response->error->message)->toContain("Missing or invalid 'clientInfo'"); -}); - -// --- Initialized Notification --- - -test('handleNotificationInitialized marks client as initialized and returns null', function () { - $notification = createNotification('notifications/initialized'); - - $this->transportStateMock->shouldReceive('markInitialized') - ->once() - ->with(CLIENT_ID); - - $response = $this->processor->process($notification, CLIENT_ID); - - expect($response)->toBeNull(); -}); - -// --- Client State Validation --- - -test('process fails if client not initialized for non-initialize methods', function (string $method) { - // Transport state mock defaults to isInitialized = false in beforeEach - - $request = createRequest($method); // Params don't matter here - $response = $this->processor->process($request, CLIENT_ID); - - expectMcpErrorResponse($response, McpException::CODE_INVALID_REQUEST); - expect($response->error->message)->toContain('Client not initialized'); -})->with([ - 'tools/list', - 'tools/call', - 'resources/list', - 'resources/read', - 'resources/subscribe', - 'resources/unsubscribe', - 'resources/templates/list', - 'prompts/list', - 'prompts/get', - 'logging/setLevel', -]); - -// --- Capability Validation --- - -test('process fails if capability is disabled', function (string $method, string $configKey) { - // Mark client as initialized first - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - // Disable the capability - $this->configMock->allows('get')->with($configKey, false)->andReturn(false); - - $request = createRequest($method); // Params don't matter here - $response = $this->processor->process($request, CLIENT_ID); - - expectMcpErrorResponse($response, McpException::CODE_METHOD_NOT_FOUND); - expect($response->error->message)->toContain('capability'); - expect($response->error->message)->toContain('is not enabled'); -})->with([ - ['tools/list', 'mcp.capabilities.tools.enabled'], - ['tools/call', 'mcp.capabilities.tools.enabled'], - ['resources/list', 'mcp.capabilities.resources.enabled'], - ['resources/read', 'mcp.capabilities.resources.enabled'], - // subscribe/unsubscribe check capability internally - ['resources/templates/list', 'mcp.capabilities.resources.enabled'], - ['prompts/list', 'mcp.capabilities.prompts.enabled'], - ['prompts/get', 'mcp.capabilities.prompts.enabled'], - ['logging/setLevel', 'mcp.capabilities.logging.enabled'], -]); - -// --- Ping --- - -test('handlePing succeeds for initialized client', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $request = createRequest('ping'); - $response = $this->processor->process($request, CLIENT_ID); - - expect($response)->toBeInstanceOf(Response::class); - expect($response->id)->toBe($request->id); - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(EmptyResult::class); -}); - -// --- tools/list --- - -test('handleToolList returns empty list when no tools', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $this->registryMock->allows('allTools')->andReturn(new \ArrayObject); // Default, but explicit - - $request = createRequest('tools/list'); - $response = $this->processor->process($request, CLIENT_ID); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(ListToolsResult::class); - expect($response->result->tools)->toBe([]); - expect($response->result->nextCursor)->toBeNull(); -}); - -test('handleToolList returns tools without pagination', function () { - $tool1 = new ToolDefinition('DummyToolClass', 'methodA', 'tool1', 'desc1', []); - $tool2 = new ToolDefinition('DummyToolClass', 'methodB', 'tool2', 'desc2', []); - - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $this->registryMock->allows('allTools')->andReturn(new \ArrayObject([$tool1, $tool2])); - - $request = createRequest('tools/list'); - $response = $this->processor->process($request, CLIENT_ID); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(ListToolsResult::class); - expect($response->result->tools)->toEqual([$tool1, $tool2]); // Order matters - expect($response->result->nextCursor)->toBeNull(); -}); - -test('handleToolList handles pagination correctly', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $this->configMock->allows('get')->with('mcp.pagination_limit', Mockery::any())->andReturn(1); - - $tool1 = new ToolDefinition('DummyToolClass', 'methodA', 'tool1', 'desc1', []); - $tool2 = new ToolDefinition('DummyToolClass', 'methodB', 'tool2', 'desc2', []); - $this->registryMock->allows('allTools')->andReturn(new \ArrayObject([$tool1, $tool2])); - - // First page - $request1 = createRequest('tools/list', [], 'req-1'); - $response1 = $this->processor->process($request1, CLIENT_ID); - - expect($response1->error)->toBeNull(); - expect($response1->result->tools)->toEqual([$tool1]); - expect($response1->result->nextCursor)->toBeString(); // Expect a cursor for next page - $cursor = $response1->result->nextCursor; - - // Second page - $request2 = createRequest('tools/list', ['cursor' => $cursor], 'req-2'); - $response2 = $this->processor->process($request2, CLIENT_ID); - - expect($response2->error)->toBeNull(); - expect($response2->result->tools)->toEqual([$tool2]); - expect($response2->result->nextCursor)->toBeNull(); // No more pages -}); - -// --- tools/call --- - -test('handleToolCall succeeds', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $toolName = 'myTool'; - $rawArgs = ['param1' => 'value1', 'param2' => 100]; - $inputSchema = ['type' => 'object', 'properties' => ['param1' => ['type' => 'string'], 'param2' => ['type' => 'number']]]; - $preparedArgs = ['value1', 100]; // Assume ArgumentPreparer maps them - $toolResult = ['success' => true, 'data' => 'Result Data']; - $formattedResult = [new TextContent(json_encode($toolResult))]; // Assume formatter JSON encodes - - $definition = Mockery::mock(ToolDefinition::class); - $definition->allows('getClassName')->andReturn('DummyToolClass'); - $definition->allows('getMethodName')->andReturn('execute'); - $definition->allows('getInputSchema')->andReturn($inputSchema); - - $toolInstance = Mockery::mock('DummyToolClass'); - - $this->registryMock->shouldReceive('findTool')->once()->with($toolName)->andReturn($definition); - $this->schemaValidatorMock->shouldReceive('validateAgainstJsonSchema')->once()->with($rawArgs, $inputSchema)->andReturn([]); // No errors - $this->containerMock->shouldReceive('get')->once()->with('DummyToolClass')->andReturn($toolInstance); - $this->argumentPreparerMock->shouldReceive('prepareMethodArguments') - ->once() - ->with($toolInstance, 'execute', $rawArgs, $inputSchema) - ->andReturn($preparedArgs); - $toolInstance->shouldReceive('execute')->once()->with(...$preparedArgs)->andReturn($toolResult); - - // Mock the formatter call using a processor spy - /** @var MockInterface&Processor */ - $processorSpy = Mockery::mock(Processor::class, [ - $this->containerMock, - $this->registryMock, - $this->transportStateMock, - $this->schemaValidatorMock, - $this->argumentPreparerMock, - ])->makePartial(); - - // Need to mock formatToolResult to avoid calling the protected trait method - $processorSpy->shouldAllowMockingProtectedMethods(); - $processorSpy->shouldReceive('formatToolResult')->once()->with($toolResult)->andReturn($formattedResult); - - $request = createRequest('tools/call', ['name' => $toolName, 'arguments' => $rawArgs]); - $response = $processorSpy->process($request, CLIENT_ID); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(CallToolResult::class); - expect($response->result->toArray()['content'])->toEqual(array_map(fn ($item) => $item->toArray(), $formattedResult)); - expect($response->result->toArray()['isError'])->toBeFalse(); -}); - -test('handleToolCall fails if tool not found', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $toolName = 'nonExistentTool'; - $this->registryMock->shouldReceive('findTool')->once()->with($toolName)->andReturn(null); - - $request = createRequest('tools/call', ['name' => $toolName, 'arguments' => []]); - $response = $this->processor->process($request, CLIENT_ID); - - expectMcpErrorResponse($response, McpException::CODE_METHOD_NOT_FOUND); - expect($response->error->message)->toContain("Tool '{$toolName}' not found"); -}); - -test('handleToolCall fails on validation errors', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $toolName = 'myTool'; - $rawArgs = ['param1' => 123]; // Invalid type - $inputSchema = ['type' => 'object', 'properties' => ['param1' => ['type' => 'string']]]; - $validationErrors = [['property' => 'param1', 'message' => 'Must be a string']]; - - $definition = Mockery::mock(ToolDefinition::class); - $definition->allows('getInputSchema')->andReturn($inputSchema); - - $this->registryMock->shouldReceive('findTool')->once()->with($toolName)->andReturn($definition); - $this->schemaValidatorMock->shouldReceive('validateAgainstJsonSchema') - ->once() - ->with($rawArgs, $inputSchema) - ->andReturn($validationErrors); - - $request = createRequest('tools/call', ['name' => $toolName, 'arguments' => $rawArgs]); - $response = $this->processor->process($request, CLIENT_ID); - - expectMcpErrorResponse($response, McpException::CODE_INVALID_PARAMS); - expect($response->error->data['validation_errors'])->toBe($validationErrors); -}); - -test('handleToolCall handles tool execution exception', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $toolName = 'failingTool'; - $rawArgs = [new \stdClass]; - $inputSchema = []; - $preparedArgs = []; - $exceptionMessage = 'Something broke!'; - $toolException = new \RuntimeException($exceptionMessage); - // Expected formatted error from ResponseFormatter trait - $errorContent = [new TextContent('Tool execution failed: '.$exceptionMessage.' (Type: RuntimeException)')]; - - $definition = Mockery::mock(ToolDefinition::class); - $definition->allows('getClassName')->andReturn('DummyToolClass'); - $definition->allows('getMethodName')->andReturn('execute'); - $definition->allows('getInputSchema')->andReturn($inputSchema); - - $toolInstance = Mockery::mock('DummyToolClass'); - - $this->registryMock->shouldReceive('findTool')->once()->with($toolName)->andReturn($definition); - $this->schemaValidatorMock->shouldReceive('validateAgainstJsonSchema')->once()->with($rawArgs, $inputSchema)->andReturn([]); - $this->containerMock->shouldReceive('get')->once()->with('DummyToolClass')->andReturn($toolInstance); - $this->argumentPreparerMock->shouldReceive('prepareMethodArguments') - ->once() - ->with($toolInstance, 'execute', $rawArgs, $inputSchema) - ->andReturn($preparedArgs); - // Tool throws an exception - $toolInstance->shouldReceive('execute')->once()->with(...$preparedArgs)->andThrow($toolException); - - // Use spy to verify formatToolErrorResult is called - /** @var MockInterface&Processor */ - $processorSpy = Mockery::mock(Processor::class, [ - $this->containerMock, - $this->registryMock, - $this->transportStateMock, - $this->schemaValidatorMock, - $this->argumentPreparerMock, - ])->makePartial(); - - $processorSpy->shouldAllowMockingProtectedMethods(); - $processorSpy->shouldReceive('formatToolErrorResult')->once()->with($toolException)->andReturn($errorContent); - - $request = createRequest('tools/call', ['name' => $toolName, 'arguments' => $rawArgs]); - $response = $processorSpy->process($request, CLIENT_ID); - - expect($response->error)->toBeNull(); // The *JSON-RPC* error is null - expect($response->result)->toBeInstanceOf(CallToolResult::class); - expect($response->result->toArray()['content'])->toEqual(array_map(fn ($item) => $item->toArray(), $errorContent)); - expect($response->result->toArray()['isError'])->toBeTrue(); // The *MCP* result indicates an error -}); - -test('handleToolCall fails with missing name', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $request = createRequest('tools/call', ['arguments' => []]); // Missing name - $response = $this->processor->process($request, CLIENT_ID); - expectMcpErrorResponse($response, McpException::CODE_INVALID_PARAMS); - expect($response->error->message)->toContain("Missing or invalid 'name'"); -}); - -test('handleToolCall fails with invalid arguments type', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $request = createRequest('tools/call', ['name' => 'myTool', 'arguments' => 'not-an-object']); - $response = $this->processor->process($request, CLIENT_ID); - expectMcpErrorResponse($response, McpException::CODE_INVALID_PARAMS); - expect($response->error->message)->toContain("Parameter 'arguments' must be an object"); -}); - -test('process handles generic exception during processing', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $method = 'tools/list'; - $exceptionMessage = 'Completely unexpected error!'; - $exception = new \LogicException($exceptionMessage); - - // Make registry throw an unexpected error - $this->registryMock->shouldReceive('allTools')->andThrow($exception); - - $request = createRequest($method); - $response = $this->processor->process($request, CLIENT_ID); - - // Should result in a generic "Method Execution Failed" MCP error - expectMcpErrorResponse($response, McpException::CODE_INTERNAL_ERROR); - expect($response->error->message)->toContain("Execution failed for method '{$method}'"); -}); - -// --- resources/list --- - -test('handleResourcesList returns empty list when no resources', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $this->registryMock->allows('allResources')->andReturn(new \ArrayObject); - - $request = createRequest('resources/list'); - $response = $this->processor->process($request, CLIENT_ID); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(ListResourcesResult::class); - expect($response->result->resources)->toBe([]); - expect($response->result->nextCursor)->toBeNull(); -}); - -test('handleResourcesList returns resources without pagination', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $resource1 = new ResourceDefinition(\stdClass::class, 'getResource', 'file://resource1', 'resource1', null, 'text/plain', 1024); - $resource2 = new ResourceDefinition(\stdClass::class, 'getResource2', 'file://resource2', 'resource2', null, 'application/json', 2048); - - $this->registryMock->allows('allResources')->andReturn(new \ArrayObject([$resource1, $resource2])); - - $request = createRequest('resources/list'); - $response = $this->processor->process($request, CLIENT_ID); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(ListResourcesResult::class); - expect($response->result->resources)->toEqual([$resource1, $resource2]); // Order matters - expect($response->result->nextCursor)->toBeNull(); -}); - -test('handleResourcesList handles pagination correctly', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $this->configMock->allows('get')->with('mcp.pagination_limit', Mockery::any())->andReturn(1); - - $resource1 = new ResourceDefinition(\stdClass::class, 'getResource', 'file://resource1', 'resource1', null, 'text/plain', 1024); - $resource2 = new ResourceDefinition(\stdClass::class, 'getResource2', 'file://resource2', 'resource2', null, 'application/json', 2048); - - $this->registryMock->allows('allResources')->andReturn(new \ArrayObject([$resource1, $resource2])); - - // First page - $request1 = createRequest('resources/list', [], 'req-1'); - $response1 = $this->processor->process($request1, CLIENT_ID); - - expect($response1->error)->toBeNull(); - expect($response1->result->resources)->toEqual([$resource1]); - expect($response1->result->nextCursor)->toBeString(); // Expect a cursor for next page - $cursor = $response1->result->nextCursor; - - // Second page - $request2 = createRequest('resources/list', ['cursor' => $cursor], 'req-2'); - $response2 = $this->processor->process($request2, CLIENT_ID); - - expect($response2->error)->toBeNull(); - expect($response2->result->resources)->toEqual([$resource2]); - expect($response2->result->nextCursor)->toBeNull(); // No more pages -}); - -// --- resources/templates/list --- - -test('handleResourceTemplatesList returns empty list when no templates', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $this->registryMock->allows('allResourceTemplates')->andReturn(new \ArrayObject); - - $request = createRequest('resources/templates/list'); - $response = $this->processor->process($request, CLIENT_ID); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(ListResourceTemplatesResult::class); - expect($response->result->resourceTemplates)->toBe([]); - expect($response->result->nextCursor)->toBeNull(); -}); - -test('handleResourceTemplatesList returns templates without pagination', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $template1 = new ResourceTemplateDefinition(\stdClass::class, 'getTemplate', 'file://template/{id}', 'template1', null, 'text/plain'); - $template2 = new ResourceTemplateDefinition(\stdClass::class, 'getDoc', 'file://doc/{type}', 'template2', null, 'application/json'); - - $this->registryMock->allows('allResourceTemplates')->andReturn(new \ArrayObject([$template1, $template2])); - - $request = createRequest('resources/templates/list'); - $response = $this->processor->process($request, CLIENT_ID); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(ListResourceTemplatesResult::class); - expect($response->result->resourceTemplates)->toEqual([$template1, $template2]); // Order matters - expect($response->result->nextCursor)->toBeNull(); -}); - -test('handleResourceTemplatesList handles pagination correctly', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $this->configMock->allows('get')->with('mcp.pagination_limit', Mockery::any())->andReturn(1); - - $template1 = new ResourceTemplateDefinition(\stdClass::class, 'getTemplate', 'file://template/{id}', 'template1', null, 'text/plain'); - $template2 = new ResourceTemplateDefinition(\stdClass::class, 'getDoc', 'file://doc/{type}', 'template2', null, 'application/json'); - - $this->registryMock->allows('allResourceTemplates')->andReturn(new \ArrayObject([$template1, $template2])); - - // First page - $request1 = createRequest('resources/templates/list', [], 'req-1'); - $response1 = $this->processor->process($request1, CLIENT_ID); - - expect($response1->error)->toBeNull(); - expect($response1->result->resourceTemplates)->toEqual([$template1]); - expect($response1->result->nextCursor)->toBeString(); // Expect a cursor for next page - $cursor = $response1->result->nextCursor; - - // Second page - $request2 = createRequest('resources/templates/list', ['cursor' => $cursor], 'req-2'); - $response2 = $this->processor->process($request2, CLIENT_ID); - - expect($response2->error)->toBeNull(); - expect($response2->result->resourceTemplates)->toEqual([$template2]); - expect($response2->result->nextCursor)->toBeNull(); // No more pages -}); - -// --- resources/read --- - -test('handleResourceRead returns resource contents directly for exact URI match', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $uri = 'file://resource1'; - $mimeType = 'text/plain'; - $contents = 'Resource data contents'; - $expectedResult = [['type' => 'text', 'text' => $contents]]; - $formattedContents = [new TextContent($contents)]; - - $resourceDef = Mockery::mock(ResourceDefinition::class); - $resourceDef->allows('getClassName')->andReturn('DummyResourceClass'); - $resourceDef->allows('getMethodName')->andReturn('getResource'); - $resourceDef->allows('getMimeType')->andReturn($mimeType); - - $resourceInstance = Mockery::mock('DummyResourceClass'); - - $this->registryMock->shouldReceive('findResourceByUri')->once()->with($uri)->andReturn($resourceDef); - $this->containerMock->shouldReceive('get')->once()->with('DummyResourceClass')->andReturn($resourceInstance); - $this->argumentPreparerMock->shouldReceive('prepareMethodArguments') - ->once() - ->with($resourceInstance, 'getResource', ['uri' => $uri], []) - ->andReturn([]); - $resourceInstance->shouldReceive('getResource')->once()->andReturn($contents); - - $request = createRequest('resources/read', ['uri' => $uri]); - - // Use spy to verify formatResourceContents is called - /** @var MockInterface&Processor */ - $processorSpy = Mockery::mock(Processor::class, [ - $this->containerMock, - $this->registryMock, - $this->transportStateMock, - $this->schemaValidatorMock, - $this->argumentPreparerMock, - ])->makePartial(); - - $processorSpy->shouldAllowMockingProtectedMethods(); - $processorSpy->shouldReceive('formatResourceContents')->once()->with($contents, $uri, $mimeType)->andReturn($formattedContents); - - $response = $processorSpy->process($request, CLIENT_ID); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(ReadResourceResult::class); - expect($response->result->toArray()['contents'])->toBe($expectedResult); -}); - -test('handleResourceRead passes template parameters to handler method', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $templateUri = 'file://template/{id}/{type}'; - $requestedUri = 'file://template/123/json'; - $mimeType = 'application/json'; - $templateParams = ['id' => '123', 'type' => 'json']; - $contents = json_encode(['id' => 123, 'name' => 'test', 'format' => 'json']); - $expectedResult = [['type' => 'text', 'text' => $contents]]; - $formattedContents = [new TextContent($contents)]; - - $templateDef = Mockery::mock(ResourceTemplateDefinition::class); - $templateDef->allows('getClassName')->andReturn('DummyResourceClass'); - $templateDef->allows('getMethodName')->andReturn('getTemplate'); - $templateDef->allows('getMimeType')->andReturn($mimeType); - - $resourceInstance = Mockery::mock('DummyResourceClass'); - - $this->registryMock->shouldReceive('findResourceByUri')->once()->with($requestedUri)->andReturn(null); - $this->registryMock->shouldReceive('findResourceTemplateByUri')->once()->with($requestedUri)->andReturn(['definition' => $templateDef, 'variables' => $templateParams]); - $this->containerMock->shouldReceive('get')->once()->with('DummyResourceClass')->andReturn($resourceInstance); - $this->argumentPreparerMock->shouldReceive('prepareMethodArguments') - ->once() - ->with($resourceInstance, 'getTemplate', array_merge($templateParams, ['uri' => $requestedUri]), []) - ->andReturn(['123', 'json']); - $resourceInstance->shouldReceive('getTemplate')->once()->with('123', 'json')->andReturn($contents); - - $request = createRequest('resources/read', ['uri' => $requestedUri]); - - // Use spy to verify formatResourceContents is called - /** @var MockInterface&Processor */ - $processorSpy = Mockery::mock(Processor::class, [ - $this->containerMock, - $this->registryMock, - $this->transportStateMock, - $this->schemaValidatorMock, - $this->argumentPreparerMock, - ])->makePartial(); - - $processorSpy->shouldAllowMockingProtectedMethods(); - $processorSpy->shouldReceive('formatResourceContents')->once()->with($contents, $requestedUri, $mimeType)->andReturn($formattedContents); - - $response = $processorSpy->process($request, CLIENT_ID); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(ReadResourceResult::class); - expect($response->result->toArray()['contents'])->toBe($expectedResult); -}); - -test('handleResourceRead fails if resource not found', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $uri = 'file://nonexistent'; - - $this->registryMock->shouldReceive('findResourceByUri')->once()->with($uri)->andReturn(null); - $this->registryMock->shouldReceive('findResourceTemplateByUri')->once()->with($uri)->andReturn(null); - - $request = createRequest('resources/read', ['uri' => $uri]); - $response = $this->processor->process($request, CLIENT_ID); - - expectMcpErrorResponse($response, McpException::CODE_INVALID_PARAMS); - expect($response->error->message)->toContain("Resource URI '{$uri}' not found or no handler available."); -}); - -test('handleResourceRead fails with missing uri parameter', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $request = createRequest('resources/read', []); // Missing uri - $response = $this->processor->process($request, CLIENT_ID); - - expectMcpErrorResponse($response, McpException::CODE_INVALID_PARAMS); - expect($response->error->message)->toContain("Missing or invalid 'uri'"); -}); - -test('handleResourceRead handles handler execution exception', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $uri = 'file://error'; - $mimeType = 'text/plain'; - $exceptionMessage = 'Resource handler failed'; - $exception = new \RuntimeException($exceptionMessage); - - $definition = Mockery::mock(\PhpMcp\Server\Definitions\ResourceDefinition::class); - $definition->allows('getClassName')->andReturn('DummyResourceClass'); - $definition->allows('getMethodName')->andReturn('getResource'); - $definition->allows('getMimeType')->andReturn($mimeType); - - $handlerInstance = Mockery::mock('DummyResourceClass'); - - $this->registryMock->shouldReceive('findResourceByUri')->once()->with($uri)->andReturn($definition); - - $this->containerMock->shouldReceive('get')->once()->with('DummyResourceClass')->andReturn($handlerInstance); - $this->argumentPreparerMock->shouldReceive('prepareMethodArguments') - ->once() - ->with($handlerInstance, 'getResource', ['uri' => $uri], []) - ->andReturn([]); - $handlerInstance->shouldReceive('getResource')->once()->withNoArgs()->andThrow($exception); - - $request = createRequest('resources/read', ['uri' => $uri]); - $response = $this->processor->process($request, CLIENT_ID); - - expectMcpErrorResponse($response, McpException::CODE_INTERNAL_ERROR); - expect($response->error->message)->toContain($exceptionMessage); -}); - -// --- resources/subscribe --- - -test('handleResourceSubscribe subscribes to resource', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $this->configMock->allows('get')->with('mcp.capabilities.resources.subscribe', Mockery::any())->andReturn(true); - - $uri = 'file://subscribable'; - $resource = new ResourceDefinition('DummyResourceClass', 'getResource', $uri, 'testResource', null, 'text/plain', 1024); - - $this->transportStateMock->shouldReceive('addResourceSubscription')->once()->with(CLIENT_ID, $uri)->andReturn(true); - - $request = createRequest('resources/subscribe', ['uri' => $uri]); - $response = $this->processor->process($request, CLIENT_ID); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(EmptyResult::class); -}); - -test('handleResourceUnsubscribe unsubscribes from resource', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $uri = 'file://subscribable'; - $resource = new ResourceDefinition('DummyResourceClass', 'getResource', $uri, 'testResource', null, 'text/plain', 1024); - - $this->transportStateMock->shouldReceive('removeResourceSubscription')->once()->with(CLIENT_ID, $uri)->andReturn(true); - - $request = createRequest('resources/unsubscribe', ['uri' => $uri]); - $response = $this->processor->process($request, CLIENT_ID); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(EmptyResult::class); -}); - -// Keep the remaining items in TODO for reference: - -// - prompts/list (pagination) -// - prompts/get (success, not found, missing args, errors) -// - logging/setLevel (success, invalid level) -// - Result formatting errors (JsonException for tool/resource/prompt results) -// - ArgumentPreparer exceptions - -// --- prompts/list --- - -test('handlePromptsList returns empty list when no prompts', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $this->registryMock->allows('allPrompts')->andReturn(new \ArrayObject); - - $request = createRequest('prompts/list'); - $response = $this->processor->process($request, CLIENT_ID); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(ListPromptsResult::class); - expect($response->result->prompts)->toBe([]); - expect($response->result->nextCursor)->toBeNull(); -}); - -test('handlePromptsList returns prompts without pagination', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $prompt1 = new PromptDefinition(\stdClass::class, 'getPrompt1', 'prompt1', 'Prompt 1', []); - $prompt2 = new PromptDefinition(\stdClass::class, 'getPrompt2', 'prompt2', 'Prompt 2', []); - - $this->registryMock->allows('allPrompts')->andReturn(new \ArrayObject([$prompt1, $prompt2])); - - $request = createRequest('prompts/list'); - $response = $this->processor->process($request, CLIENT_ID); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(ListPromptsResult::class); - expect($response->result->prompts)->toEqual([$prompt1, $prompt2]); // Order matters - expect($response->result->nextCursor)->toBeNull(); -}); - -test('handlePromptsList handles pagination correctly', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - $this->configMock->allows('get')->with('mcp.pagination_limit', Mockery::any())->andReturn(1); - - $prompt1 = new PromptDefinition(\stdClass::class, 'getPrompt1', 'prompt1', 'Prompt 1', []); - $prompt2 = new PromptDefinition(\stdClass::class, 'getPrompt2', 'prompt2', 'Prompt 2', []); - - $this->registryMock->allows('allPrompts')->andReturn(new \ArrayObject([$prompt1, $prompt2])); - - // First page - $request1 = createRequest('prompts/list', [], 'req-1'); - $response1 = $this->processor->process($request1, CLIENT_ID); - - expect($response1->error)->toBeNull(); - expect($response1->result->prompts)->toEqual([$prompt1]); - expect($response1->result->nextCursor)->toBeString(); // Expect a cursor for next page - $cursor = $response1->result->nextCursor; - - // Second page - $request2 = createRequest('prompts/list', ['cursor' => $cursor], 'req-2'); - $response2 = $this->processor->process($request2, CLIENT_ID); - - expect($response2->error)->toBeNull(); - expect($response2->result->prompts)->toEqual([$prompt2]); - expect($response2->result->nextCursor)->toBeNull(); // No more pages -}); - -// --- prompts/get --- - -test('handlePromptGet returns formatted prompt messages', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $promptName = 'greeting'; - $promptDescription = 'A greeting prompt'; - $promptArgs = ['name' => 'User', 'language' => 'en']; - $rawResult = [ - ['role' => 'user', 'content' => 'Be polite and friendly'], - ['role' => 'user', 'content' => 'Greet User in English'], - ]; - $resultOutput = array_map(fn ($message) => ['role' => $message['role'], 'content' => ['type' => 'text', 'text' => $message['content']]], $rawResult); - $formattedMessages = array_map(fn ($message) => new PromptMessage($message['role'], new TextContent($message['content'])), $rawResult); - - $promptDef = Mockery::mock(PromptDefinition::class); - $promptDef->allows('getClassName')->andReturn('DummyPromptClass'); - $promptDef->allows('getMethodName')->andReturn('getGreetingPrompt'); - $promptDef->allows('getArguments')->andReturn([]); - $promptDef->allows('getDescription')->andReturn($promptDescription); - - $promptInstance = Mockery::mock('DummyPromptClass'); - - $this->registryMock->shouldReceive('findPrompt')->once()->with($promptName)->andReturn($promptDef); - $this->containerMock->shouldReceive('get')->once()->with('DummyPromptClass')->andReturn($promptInstance); - $this->argumentPreparerMock->shouldReceive('prepareMethodArguments') - ->once() - ->with($promptInstance, 'getGreetingPrompt', $promptArgs, []) - ->andReturn(['User', 'en']); - $promptInstance->shouldReceive('getGreetingPrompt')->once()->with('User', 'en')->andReturn($rawResult); - - $request = createRequest('prompts/get', ['name' => $promptName, 'arguments' => $promptArgs]); - - /** @var MockInterface&Processor */ - $processorSpy = Mockery::mock(Processor::class, [ - $this->containerMock, - $this->registryMock, - $this->transportStateMock, - $this->schemaValidatorMock, - $this->argumentPreparerMock, - ])->makePartial(); - - $processorSpy->shouldAllowMockingProtectedMethods(); - $processorSpy->shouldReceive('formatPromptMessages')->once()->with($rawResult)->andReturn($formattedMessages); - - $response = $processorSpy->process($request, CLIENT_ID); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(GetPromptResult::class); - expect($response->result->toArray()['messages'])->toEqual($resultOutput); - expect($response->result->toArray()['description'])->toBe($promptDescription); -}); - -test('handlePromptGet validates required arguments', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $promptName = 'greeting'; - $promptArgs = ['language' => 'en']; // Missing required 'name' argument - - $requiredArg = Mockery::mock(PromptArgumentDefinition::class); - $requiredArg->allows('getName')->andReturn('name'); - $requiredArg->allows('isRequired')->andReturn(true); - - $optionalArg = Mockery::mock(PromptArgumentDefinition::class); - $optionalArg->allows('getName')->andReturn('language'); - $optionalArg->allows('isRequired')->andReturn(false); - - $promptDef = Mockery::mock(PromptDefinition::class); - $promptDef->allows('getArguments')->andReturn([$requiredArg, $optionalArg]); - - $this->registryMock->shouldReceive('findPrompt')->once()->with($promptName)->andReturn($promptDef); - - $request = createRequest('prompts/get', ['name' => $promptName, 'arguments' => $promptArgs]); - $response = $this->processor->process($request, CLIENT_ID); - - expectMcpErrorResponse($response, McpException::CODE_INVALID_PARAMS); - expect($response->error->message)->toContain("Missing required argument 'name'"); -}); - -test('handlePromptGet fails if prompt not found', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $promptName = 'nonexistent'; - $this->registryMock->shouldReceive('findPrompt')->once()->with($promptName)->andReturn(null); - - $request = createRequest('prompts/get', ['name' => $promptName, 'arguments' => []]); - $response = $this->processor->process($request, CLIENT_ID); - - expectMcpErrorResponse($response, McpException::CODE_INVALID_PARAMS); - expect($response->error->message)->toContain("Prompt '{$promptName}' not found"); -}); - -test('handlePromptGet fails with missing name parameter', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $request = createRequest('prompts/get', ['arguments' => []]); // Missing name - $response = $this->processor->process($request, CLIENT_ID); - - expectMcpErrorResponse($response, McpException::CODE_INVALID_PARAMS); - expect($response->error->message)->toContain("Missing or invalid 'name'"); -}); - -test('handlePromptGet fails with invalid arguments type', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $request = createRequest('prompts/get', ['name' => 'promptName', 'arguments' => 'not-an-object']); - $response = $this->processor->process($request, CLIENT_ID); - - expectMcpErrorResponse($response, McpException::CODE_INVALID_PARAMS); - expect($response->error->message)->toContain("Parameter 'arguments' must be an object"); -}); - -test('handlePromptGet handles execution exception', function () { - $this->transportStateMock->allows('isInitialized')->with(CLIENT_ID)->andReturn(true); - - $promptName = 'error-prompt'; - $promptArgs = []; - $exceptionMessage = 'Failed to generate prompt'; - $exception = new \RuntimeException($exceptionMessage); - - $promptDef = Mockery::mock(PromptDefinition::class); - $promptDef->allows('getClassName')->andReturn('DummyPromptClass'); - $promptDef->allows('getMethodName')->andReturn('getErrorPrompt'); - $promptDef->allows('getArguments')->andReturn([]); - - $promptInstance = Mockery::mock('DummyPromptClass'); - - $this->registryMock->shouldReceive('findPrompt')->once()->with($promptName)->andReturn($promptDef); - $this->containerMock->shouldReceive('get')->once()->with('DummyPromptClass')->andReturn($promptInstance); - $this->argumentPreparerMock->shouldReceive('prepareMethodArguments') - ->once() - ->with($promptInstance, 'getErrorPrompt', $promptArgs, []) - ->andReturn([]); - $promptInstance->shouldReceive('getErrorPrompt')->once()->withNoArgs()->andThrow($exception); - - $this->loggerMock->shouldReceive('error') - ->once() - ->withArgs(function ($message, $context) use ($exceptionMessage) { - return str_contains($message, 'Prompt generation failed') && - $context['exception'] === $exceptionMessage; - }); - - $request = createRequest('prompts/get', ['name' => $promptName, 'arguments' => $promptArgs]); - $response = $this->processor->process($request, CLIENT_ID); - - expectMcpErrorResponse($response, McpException::CODE_INTERNAL_ERROR); - expect($response->error->message)->toContain('Failed to generate prompt'); -}); - -// Keep the remaining items in TODO for reference: - -// - logging/setLevel (success, invalid level) -// - Result formatting errors (JsonException for tool/resource/prompt results) -// - ArgumentPreparer exceptions diff --git a/tests/RegistryTest.php b/tests/RegistryTest.php deleted file mode 100644 index eb8fb25..0000000 --- a/tests/RegistryTest.php +++ /dev/null @@ -1,295 +0,0 @@ -containerMock = Mockery::mock(ContainerInterface::class); - $this->cache = Mockery::mock(CacheInterface::class); - $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $this->config = Mockery::mock(ConfigurationRepositoryInterface::class); - $this->transportState = Mockery::mock(TransportState::class)->shouldIgnoreMissing(); - - $this->config->allows('get')->with('mcp.cache.prefix', Mockery::type('string'))->andReturn(REGISTRY_CACHE_PREFIX); - $this->config->allows('get')->with('mcp.cache.ttl', Mockery::type('int'))->andReturn(3600); - - $this->cache->allows('get')->with(EXPECTED_CACHE_KEY)->andReturn(null)->byDefault(); - - $this->containerMock->shouldReceive('get')->with(CacheInterface::class)->andReturn($this->cache); - $this->containerMock->shouldReceive('get')->with(LoggerInterface::class)->andReturn($this->logger); - $this->containerMock->shouldReceive('get')->with(ConfigurationRepositoryInterface::class)->andReturn($this->config); - - $this->registry = new Registry($this->containerMock, $this->transportState); -}); - -// --- Registration and Basic Retrieval Tests --- - -test('can register and find a tool', function () { - // Arrange - $tool = createTestTool('my-tool'); - - // Act - $this->registry->registerTool($tool); - $foundTool = $this->registry->findTool('my-tool'); - $notFoundTool = $this->registry->findTool('nonexistent-tool'); - - // Assert - expect($foundTool)->toBe($tool); - expect($notFoundTool)->toBeNull(); -}); - -test('can register and find a resource by URI', function () { - // Arrange - $resource = createTestResource('file:///exact/match.txt'); - - // Act - $this->registry->registerResource($resource); - $foundResource = $this->registry->findResourceByUri('file:///exact/match.txt'); - $notFoundResource = $this->registry->findResourceByUri('file:///no-match.txt'); - - // Assert - expect($foundResource)->toBe($resource); - expect($notFoundResource)->toBeNull(); -}); - -test('can register and find a prompt', function () { - // Arrange - $prompt = createTestPrompt('my-prompt'); - - // Act - $this->registry->registerPrompt($prompt); - $foundPrompt = $this->registry->findPrompt('my-prompt'); - $notFoundPrompt = $this->registry->findPrompt('nonexistent-prompt'); - - // Assert - expect($foundPrompt)->toBe($prompt); - expect($notFoundPrompt)->toBeNull(); -}); - -test('can register and find a resource template by URI', function () { - // Arrange - $template = createTestTemplate('user://{userId}/profile'); - - // Act - $this->registry->registerResourceTemplate($template); - $match = $this->registry->findResourceTemplateByUri('user://12345/profile'); - $noMatch = $this->registry->findResourceTemplateByUri('user://12345/settings'); - $noMatchScheme = $this->registry->findResourceTemplateByUri('file://12345/profile'); - - // Assert - expect($match)->toBeArray() - ->and($match['definition'])->toBe($template) - ->and($match['variables'])->toBe(['userId' => '12345']); - expect($noMatch)->toBeNull(); - expect($noMatchScheme)->toBeNull(); -}); - -test('can retrieve all registered elements of each type', function () { - // Arrange - $tool1 = createTestTool('t1'); - $tool2 = createTestTool('t2'); - $resource1 = createTestResource('file:///valid/r1'); - $prompt1 = createTestPrompt('p1'); - $template1 = createTestTemplate('tmpl://{id}/data'); - - $this->registry->registerTool($tool1); - $this->registry->registerTool($tool2); - $this->registry->registerResource($resource1); - $this->registry->registerPrompt($prompt1); - $this->registry->registerResourceTemplate($template1); - - // Act - $allTools = $this->registry->allTools(); - $allResources = $this->registry->allResources(); - $allPrompts = $this->registry->allPrompts(); - $allTemplates = $this->registry->allResourceTemplates(); - - // Assert - expect($allTools)->toBeInstanceOf(\ArrayObject::class)->toHaveCount(2)->and($allTools->getArrayCopy())->toEqualCanonicalizing(['t1' => $tool1, 't2' => $tool2]); - expect($allResources)->toBeInstanceOf(\ArrayObject::class)->toHaveCount(1)->and($allResources->getArrayCopy())->toEqual(['file:///valid/r1' => $resource1]); - expect($allPrompts)->toBeInstanceOf(\ArrayObject::class)->toHaveCount(1)->and($allPrompts->getArrayCopy())->toEqual(['p1' => $prompt1]); - expect($allTemplates)->toBeInstanceOf(\ArrayObject::class)->toHaveCount(1)->and($allTemplates->getArrayCopy())->toEqual(['tmpl://{id}/data' => $template1]); -}); - -// --- Caching Tests --- - -test('can cache registered elements', function () { - // Arrange - $tool = createTestTool('cache-tool'); - $resource = createTestResource('cache://res'); - $prompt = createTestPrompt('cache-prompt'); - $template = createTestTemplate('cache://tmpl/{id}'); - - $this->registry->registerTool($tool); - $this->registry->registerResource($resource); - $this->registry->registerPrompt($prompt); - $this->registry->registerResourceTemplate($template); - - // Define expected structure using Mockery::on for flexibility - $this->cache->shouldReceive('set')->once() - ->with(EXPECTED_CACHE_KEY, Mockery::on(function ($data) use ($tool, $resource, $prompt, $template) { - if (! is_array($data)) { - return false; - } - if (! isset($data['tools']['cache-tool']) || $data['tools']['cache-tool'] !== $tool) { - return false; - } - if (! isset($data['resources']['cache://res']) || $data['resources']['cache://res'] !== $resource) { - return false; - } - if (! isset($data['prompts']['cache-prompt']) || $data['prompts']['cache-prompt'] !== $prompt) { - return false; - } - if (! isset($data['resourceTemplates']['cache://tmpl/{id}']) || $data['resourceTemplates']['cache://tmpl/{id}'] !== $template) { - return false; - } - - return true; // Structure and objects match - })) - ->andReturn(true); - - // Act - $result = $this->registry->saveElementsToCache(); - - // Assert - expect($result)->toBeTrue(); -}); - -test('can load elements from cache', function () { - // Arrange - $tool = createTestTool('cached-tool'); - $resource = createTestResource('cached://res'); - $prompt = createTestPrompt('cached-prompt'); - $template = createTestTemplate('cached://tmpl/{id}'); - - $cachedData = [ - 'tools' => [$tool->getName() => json_decode(json_encode($tool), true)], - 'resources' => [$resource->getUri() => json_decode(json_encode($resource), true)], - 'prompts' => [$prompt->getName() => json_decode(json_encode($prompt), true)], - 'resourceTemplates' => [$template->getUriTemplate() => json_decode(json_encode($template), true)], - ]; - - $this->cache->shouldReceive('get')->once() - ->with(EXPECTED_CACHE_KEY) - ->andReturn($cachedData); - - // Act - $this->registry->loadElementsFromCache(true); - - // Assert that loading occurred and elements are present - $foundTool = $this->registry->findTool('cached-tool'); - $foundResource = $this->registry->findResourceByUri('cached://res'); - $foundPrompt = $this->registry->findPrompt('cached-prompt'); - $foundTemplateMatch = $this->registry->findResourceTemplateByUri('cached://tmpl/123'); - - expect($foundTool)->toBeInstanceOf(ToolDefinition::class) - ->and($foundTool->getName())->toBe($tool->getName()) - ->and($foundTool->getDescription())->toBe($tool->getDescription()) - ->and($foundTool->getInputSchema())->toBe($tool->getInputSchema()); - expect($foundResource)->toBeInstanceOf(ResourceDefinition::class) - ->and($foundResource->getUri())->toBe($resource->getUri()) - ->and($foundResource->getDescription())->toBe($resource->getDescription()) - ->and($foundResource->getMimeType())->toBe($resource->getMimeType()); - expect($foundPrompt)->toBeInstanceOf(PromptDefinition::class) - ->and($foundPrompt->getName())->toBe($prompt->getName()) - ->and($foundPrompt->getDescription())->toBe($prompt->getDescription()); - expect($foundTemplateMatch)->toBeArray() - ->and($foundTemplateMatch['definition'])->toBeInstanceOf(ResourceTemplateDefinition::class) - ->and($foundTemplateMatch['variables'])->toBe(['id' => '123']); - - expect($this->registry->isLoaded())->toBeTrue(); -}); - -test('load elements ignores cache and initializes empty if cache is empty or invalid', function ($cacheReturnValue) { - // Act - $this->registry->loadElementsFromCache(); // loadElements returns void - - // Assert registry is empty and loaded flag is set - expect($this->registry->allTools()->count())->toBe(0); - expect($this->registry->allResources()->count())->toBe(0); - expect($this->registry->allPrompts()->count())->toBe(0); - expect($this->registry->allResourceTemplates()->count())->toBe(0); - expect($this->registry->isLoaded())->toBeTrue(); // Should still be marked loaded - -})->with([ - null, // Cache miss - false, // Cache driver error return value - [[]], // Empty array (Corrected dataset) - 'invalid data', // Unserializable data -]); - -test('can clear element cache', function () { - // Arrange - $this->cache->shouldReceive('delete')->once() - ->with(EXPECTED_CACHE_KEY) - ->andReturn(true); - // clearCache also calls notifiers, allow any message - $this->transportState->shouldReceive('queueMessageForAll')->atLeast()->times(1); - - // Act - $this->registry->clearCache(); // clearCache returns void - - // Assert cache was cleared (via mock expectation) and registry is reset - expect($this->registry->isLoaded())->toBeFalse(); - expect($this->registry->allTools()->count())->toBe(0); -}); - -// --- Notifier Tests --- - -test('can set and trigger notifiers', function () { - // Arrange - $toolNotifierCalled = false; - $resourceNotifierCalled = false; - $promptNotifierCalled = false; - - $this->registry->setToolsChangedNotifier(function () use (&$toolNotifierCalled) { - $toolNotifierCalled = true; - }); - $this->registry->setResourcesChangedNotifier(function () use (&$resourceNotifierCalled) { - $resourceNotifierCalled = true; - }); - $this->registry->setPromptsChangedNotifier(function () use (&$promptNotifierCalled) { - $promptNotifierCalled = true; - }); - - // Act - Register elements to trigger notifiers - $this->registry->registerTool(createTestTool()); - $this->registry->registerResource(createTestResource()); - $this->registry->registerPrompt(createTestPrompt()); - - // Assert - expect($toolNotifierCalled)->toBeTrue(); - expect($resourceNotifierCalled)->toBeTrue(); - expect($promptNotifierCalled)->toBeTrue(); -}); - -test('notifiers are not called if not set', function () { - // Arrange - // Use default null notifiers - $this->registry->setToolsChangedNotifier(null); - $this->registry->setResourcesChangedNotifier(null); - $this->registry->setPromptsChangedNotifier(null); - - // Act & Assert - Expect no exceptions when registering (which calls notify methods) - expect(fn () => $this->registry->registerTool(createTestTool()))->not->toThrow(Throwable::class); - expect(fn () => $this->registry->registerResource(createTestResource()))->not->toThrow(Throwable::class); - expect(fn () => $this->registry->registerPrompt(createTestPrompt()))->not->toThrow(Throwable::class); - // Ensure default notifiers (sending via transport) are not called - $this->transportState->shouldNotReceive('queueMessageForAll'); -}); diff --git a/tests/ServerTest.php b/tests/ServerTest.php deleted file mode 100644 index 34d623f..0000000 --- a/tests/ServerTest.php +++ /dev/null @@ -1,308 +0,0 @@ -logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $this->cache = Mockery::mock(CacheInterface::class)->shouldIgnoreMissing(); - $this->config = Mockery::mock(ConfigurationRepositoryInterface::class); - $this->container = Mockery::mock(ContainerInterface::class); - $this->registry = Mockery::mock(Registry::class); - $this->discoverer = Mockery::mock(Discoverer::class); - $this->transportState = Mockery::mock(TransportState::class); - $this->processor = Mockery::mock(Processor::class); - - $this->config->allows('get')->with('mcp.cache.prefix', Mockery::type('string'))->andReturn('mcp_'); - $this->config->allows('get')->with('mcp.cache.ttl', Mockery::type('int'))->andReturn(3600); - - $this->container->allows('get')->with(LoggerInterface::class)->andReturn($this->logger); - $this->container->allows('get')->with(CacheInterface::class)->andReturn($this->cache); - $this->container->allows('get')->with(ConfigurationRepositoryInterface::class)->andReturn($this->config); - - // Setup test path - $this->basePath = sys_get_temp_dir().'/mcp-server-test'; - if (! is_dir($this->basePath)) { - mkdir($this->basePath, 0777, true); - } -}); - -afterEach(function () { - Mockery::close(); - - // Clean up test directory - if (is_dir($this->basePath)) { - $files = glob($this->basePath.'/*'); - foreach ($files as $file) { - is_dir($file) ? rmdir($file) : unlink($file); - } - rmdir($this->basePath); - } -}); - -// --- Basic Instantiation Tests --- - -test('it can be instantiated directly', function () { - $server = new Server; - expect($server)->toBeInstanceOf(Server::class); -}); - -test('it can be instantiated using static factory method', function () { - $server = Server::make(); - expect($server)->toBeInstanceOf(Server::class); -}); - -// --- Fluent Configuration Tests --- - -test('it can be configured with a custom container', function () { - $server = new Server; - $result = $server->withContainer($this->container); - - expect($result)->toBe($server); // Fluent interface returns self - expect($server->getContainer())->toBe($this->container); -}); - -// --- Initialization Tests --- - -// test('it registers core services to BasicContainer', function () { -// $container = Mockery::mock(BasicContainer::class); -// $container->shouldReceive('set')->times(4)->withAnyArgs(); - -// $server = new Server; -// $server->withContainer($container); - -// // Force initialization -// $server->getProcessor(); - -// // With shouldReceive above we're just verifying it was called 4 times -// expect(true)->toBeTrue(); -// }); - -// --- Run Tests --- - -test('it throws exception for unsupported transport', function () { - $server = new Server; - - expect(fn () => $server->run('unsupported'))->toThrow(LogicException::class, 'Unsupported transport: unsupported'); -}); - -test('it throws exception when trying to run HTTP transport directly', function () { - $server = new Server; - - expect(fn () => $server->run('http'))->toThrow(LogicException::class, 'Cannot run HTTP transport directly'); -}); - -// --- Component Getter Tests --- - -test('it returns the processor instance', function () { - $server = new Server; - $processor = $server->getProcessor(); - expect($processor)->toBeInstanceOf(Processor::class); -}); - -test('it returns the registry instance', function () { - $server = new Server; - $registry = $server->getRegistry(); - expect($registry)->toBeInstanceOf(Registry::class); -}); - -test('it returns the container instance', function () { - $server = new Server; - $container = $server->getContainer(); - expect($container)->toBeInstanceOf(ContainerInterface::class); -}); - -// --- Manual Registration Tests --- - -test('it can manually register a tool using array handler', function () { - $server = Server::make(); - $server->withContainer($this->container); - $server->withLogger($this->logger); - - $this->registry->shouldReceive('registerTool') - ->once() - ->with(Mockery::on(function (ToolDefinition $def) { - return $def->getName() === 'customTool' - && $def->getDescription() === 'Custom Description'; - })); - - $serverReflection = new ReflectionClass($server); - $registryProperty = $serverReflection->getProperty('registry'); - $registryProperty->setAccessible(true); - $registryProperty->setValue($server, $this->registry); - - $result = $server->withTool([HandlerStub::class, 'toolHandler'], 'customTool', 'Custom Description'); - - expect($result)->toBe($server); -}); - -test('it can manually register a tool using invokable handler', function () { - $server = Server::make(); - $server->withContainer($this->container); - $server->withLogger($this->logger); - - $this->registry->shouldReceive('registerTool') - ->once() - ->with(Mockery::on(function (ToolDefinition $def) { - return $def->getName() === 'InvokableHandlerStub'; - })); - - $serverReflection = new ReflectionClass($server); - $registryProperty = $serverReflection->getProperty('registry'); - $registryProperty->setAccessible(true); - $registryProperty->setValue($server, $this->registry); - - $result = $server->withTool(InvokableHandlerStub::class); - - expect($result)->toBe($server); -}); - -test('it can manually register a resource using array handler', function () { - $server = Server::make(); - $server->withContainer($this->container); - $server->withLogger($this->logger); - - $this->registry->shouldReceive('registerResource') - ->once() - ->with(Mockery::on(function (ResourceDefinition $def) { - return $def->getName() === 'customResource' - && $def->getUri() === 'my://resource'; - })); - - $serverReflection = new ReflectionClass($server); - $registryProperty = $serverReflection->getProperty('registry'); - $registryProperty->setAccessible(true); - $registryProperty->setValue($server, $this->registry); - - $result = $server->withResource([HandlerStub::class, 'resourceHandler'], 'my://resource', 'customResource'); - - expect($result)->toBe($server); -}); - -test('it can manually register a resource using invokable handler', function () { - $server = Server::make(); - $server->withContainer($this->container); - $server->withLogger($this->logger); - - $this->registry->shouldReceive('registerResource') - ->once() - ->with(Mockery::on(function (ResourceDefinition $def) { - return $def->getName() === 'InvokableHandlerStub' - && $def->getUri() === 'invokable://resource'; - })); - - $serverReflection = new ReflectionClass($server); - $registryProperty = $serverReflection->getProperty('registry'); - $registryProperty->setAccessible(true); - $registryProperty->setValue($server, $this->registry); - - $result = $server->withResource(InvokableHandlerStub::class, 'invokable://resource'); - - expect($result)->toBe($server); -}); - -test('it can manually register a prompt using array handler', function () { - $server = Server::make(); - $server->withContainer($this->container); - $server->withLogger($this->logger); - - $this->registry->shouldReceive('registerPrompt') - ->once() - ->with(Mockery::on(function (PromptDefinition $def) { - return $def->getName() === 'customPrompt'; - })); - - $serverReflection = new ReflectionClass($server); - $registryProperty = $serverReflection->getProperty('registry'); - $registryProperty->setAccessible(true); - $registryProperty->setValue($server, $this->registry); - - $result = $server->withPrompt([HandlerStub::class, 'promptHandler'], 'customPrompt'); - - expect($result)->toBe($server); -}); - -test('it can manually register a prompt using invokable handler', function () { - $server = Server::make(); - $server->withContainer($this->container); - $server->withLogger($this->logger); - - $this->registry->shouldReceive('registerPrompt') - ->once() - ->with(Mockery::on(function (PromptDefinition $def) { - return $def->getName() === 'InvokableHandlerStub'; - })); - - $serverReflection = new ReflectionClass($server); - $registryProperty = $serverReflection->getProperty('registry'); - $registryProperty->setAccessible(true); - $registryProperty->setValue($server, $this->registry); - - $result = $server->withPrompt(InvokableHandlerStub::class); - - expect($result)->toBe($server); -}); - -test('it can manually register a resource template using array handler', function () { - $server = Server::make(); - $server->withContainer($this->container); - $server->withLogger($this->logger); - - $this->registry->shouldReceive('registerResourceTemplate') - ->once() - ->with(Mockery::on(function (ResourceTemplateDefinition $def) { - return $def->getName() === 'customTemplate' - && $def->getUriTemplate() === 'my://template/{id}'; - })); - - $serverReflection = new ReflectionClass($server); - $registryProperty = $serverReflection->getProperty('registry'); - $registryProperty->setAccessible(true); - $registryProperty->setValue($server, $this->registry); - - $result = $server->withResourceTemplate([HandlerStub::class, 'templateHandler'], 'customTemplate', null, 'my://template/{id}'); - - expect($result)->toBe($server); -}); - -test('it can manually register a resource template using invokable handler', function () { - $server = Server::make(); - $server->withContainer($this->container); - $server->withLogger($this->logger); - - $this->registry->shouldReceive('registerResourceTemplate') - ->once() - ->with(Mockery::on(function (ResourceTemplateDefinition $def) { - return $def->getName() === 'InvokableHandlerStub' - && $def->getUriTemplate() === 'invokable://template/{id}'; - })); - - $serverReflection = new ReflectionClass($server); - $registryProperty = $serverReflection->getProperty('registry'); - $registryProperty->setAccessible(true); - $registryProperty->setValue($server, $this->registry); - - $result = $server->withResourceTemplate(InvokableHandlerStub::class, null, null, 'invokable://template/{id}'); - - expect($result)->toBe($server); -}); diff --git a/tests/State/TransportStateTest.php b/tests/State/TransportStateTest.php deleted file mode 100644 index 178ea87..0000000 --- a/tests/State/TransportStateTest.php +++ /dev/null @@ -1,425 +0,0 @@ -container = Mockery::mock(ContainerInterface::class); - $this->config = Mockery::mock(ConfigurationRepositoryInterface::class); - $this->cache = Mockery::mock(CacheInterface::class); - $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - - $this->config->allows('get')->with('mcp.cache.prefix', Mockery::type('string'))->andReturn(CACHE_PREFIX); - $this->config->allows('get')->with('mcp.cache.ttl', Mockery::type('int'))->andReturn(CACHE_TTL); - - $this->container->shouldReceive('get')->with(CacheInterface::class)->andReturn($this->cache); - $this->container->shouldReceive('get')->with(LoggerInterface::class)->andReturn($this->logger); - $this->container->shouldReceive('get')->with(ConfigurationRepositoryInterface::class)->andReturn($this->config); - - $this->transportState = new TransportState($this->container); -}); - -afterEach(function () { - Mockery::close(); -}); - -// Helper to generate expected cache keys -function getCacheKey(string $key, ?string $clientId = null): string -{ - return $clientId ? CACHE_PREFIX."{$key}_{$clientId}" : CACHE_PREFIX.$key; -} - -// --- Tests --- - -test('can check if client is initialized', function () { - // Arrange - $initializedKey = getCacheKey('initialized', TEST_CLIENT_ID); - $this->cache->shouldReceive('get')->once()->with($initializedKey, false)->andReturn(false); - $this->cache->shouldReceive('get')->once()->with($initializedKey, false)->andReturn(true); - - // Act & Assert - expect($this->transportState->isInitialized(TEST_CLIENT_ID))->toBeFalse(); - expect($this->transportState->isInitialized(TEST_CLIENT_ID))->toBeTrue(); -}); - -test('can mark client as initialized', function () { - // Arrange - $initializedKey = getCacheKey('initialized', TEST_CLIENT_ID); - $activityKey = getCacheKey('active_clients'); - - $this->cache->shouldReceive('set')->once() - ->with($initializedKey, true, CACHE_TTL) - ->andReturn(true); - // Expect updateClientActivity call - $this->cache->shouldReceive('get')->once()->with($activityKey, [])->andReturn([]); - $this->cache->shouldReceive('set')->once() - ->with($activityKey, Mockery::type('array'), CACHE_TTL) // Use Mockery namespace - ->andReturnUsing(function ($key, $value) { - expect($value)->toHaveKey(TEST_CLIENT_ID); - - return true; - }); - - // Act - $this->transportState->markInitialized(TEST_CLIENT_ID); - - // Assert (implicit via mock expectations) -}); - -test('can store and retrieve client info', function () { - // Arrange - $clientInfoKey = getCacheKey('client_info', TEST_CLIENT_ID); - $protocolKey = getCacheKey('protocol_version', TEST_CLIENT_ID); - $clientInfo = ['name' => 'TestClient', 'version' => '1.1']; - $protocolVersion = '2024-11-05'; - - $this->cache->shouldReceive('set')->once() - ->with($clientInfoKey, $clientInfo, CACHE_TTL) - ->andReturn(true); - $this->cache->shouldReceive('set')->once() - ->with($protocolKey, $protocolVersion, CACHE_TTL) - ->andReturn(true); - - $this->cache->shouldReceive('get')->once() - ->with($clientInfoKey) - ->andReturn($clientInfo); - $this->cache->shouldReceive('get')->once() - ->with($protocolKey) - ->andReturn($protocolVersion); - - // Act - $this->transportState->storeClientInfo($clientInfo, $protocolVersion, TEST_CLIENT_ID); - $retrievedInfo = $this->transportState->getClientInfo(TEST_CLIENT_ID); - $retrievedVersion = $this->transportState->getProtocolVersion(TEST_CLIENT_ID); - - // Assert - expect($retrievedInfo)->toBe($clientInfo); - expect($retrievedVersion)->toBe($protocolVersion); -}); - -test('can add resource subscription', function () { - // Arrange - $clientSubKey = getCacheKey('client_subscriptions', TEST_CLIENT_ID); - $resourceSubKey = getCacheKey('resource_subscriptions', TEST_URI); - - // Mock initial gets (empty) - $this->cache->shouldReceive('get')->once()->with($clientSubKey, [])->andReturn([]); - $this->cache->shouldReceive('get')->once()->with($resourceSubKey, [])->andReturn([]); - - // Mock sets - $this->cache->shouldReceive('set')->once() - ->with($clientSubKey, [TEST_URI => true], CACHE_TTL) - ->andReturn(true); - $this->cache->shouldReceive('set')->once() - ->with($resourceSubKey, [TEST_CLIENT_ID => true], CACHE_TTL) - ->andReturn(true); - - // Act - $this->transportState->addResourceSubscription(TEST_CLIENT_ID, TEST_URI); - - // Assert (implicit via mock expectations) -}); - -test('can check resource subscription status', function () { - // Arrange - $clientSubKey = getCacheKey('client_subscriptions', TEST_CLIENT_ID); - $this->cache->shouldReceive('get') - ->with($clientSubKey, []) - ->andReturn([TEST_URI => true]); // Client is subscribed - - // Act & Assert - expect($this->transportState->isSubscribedToResource(TEST_CLIENT_ID, TEST_URI))->toBeTrue(); - expect($this->transportState->isSubscribedToResource(TEST_CLIENT_ID, TEST_URI_2))->toBeFalse(); // Check for unsubscribed -}); - -test('can get resource subscribers', function () { - // Arrange - $resourceSubKey = getCacheKey('resource_subscriptions', TEST_URI); - $resourceSubKey2 = getCacheKey('resource_subscriptions', TEST_URI_2); // Key for the second URI - - $this->cache->shouldReceive('get')->once() // Expect call for TEST_URI - ->with($resourceSubKey, []) - ->andReturn([TEST_CLIENT_ID => true, 'other-client' => true]); - $this->cache->shouldReceive('get')->once() // Expect call for TEST_URI_2 - ->with($resourceSubKey2, []) - ->andReturn([]); // Assume empty for the second URI - - // Act - $subscribers = $this->transportState->getResourceSubscribers(TEST_URI); - - // Assert - expect($subscribers)->toEqualCanonicalizing([TEST_CLIENT_ID, 'other-client']); // Use toEqualCanonicalizing for order-independent array comparison - expect($this->transportState->getResourceSubscribers(TEST_URI_2))->toBe([]); // Test non-subscribed resource -}); - -test('can remove resource subscription', function () { - // Arrange - $clientSubKey = getCacheKey('client_subscriptions', TEST_CLIENT_ID); - $resourceSubKey = getCacheKey('resource_subscriptions', TEST_URI); - $initialClientSubs = [TEST_URI => true, TEST_URI_2 => true]; - $initialResourceSubs = [TEST_CLIENT_ID => true, 'other-client' => true]; - - // Mock initial gets - $this->cache->shouldReceive('get')->once()->with($clientSubKey, [])->andReturn($initialClientSubs); - $this->cache->shouldReceive('get')->once()->with($resourceSubKey, [])->andReturn($initialResourceSubs); - - // Mock sets for removal - $this->cache->shouldReceive('set')->once() - ->with($clientSubKey, [TEST_URI_2 => true], CACHE_TTL) // TEST_URI removed - ->andReturn(true); - $this->cache->shouldReceive('set')->once() - ->with($resourceSubKey, ['other-client' => true], CACHE_TTL) // TEST_CLIENT_ID removed - ->andReturn(true); - - // Act - $this->transportState->removeResourceSubscription(TEST_CLIENT_ID, TEST_URI); - - // Assert (implicit via mock expectations) -}); - -test('can remove all resource subscriptions for a client', function () { - // Arrange - $clientSubKey = getCacheKey('client_subscriptions', TEST_CLIENT_ID); - $resourceSubKey1 = getCacheKey('resource_subscriptions', TEST_URI); - $resourceSubKey2 = getCacheKey('resource_subscriptions', TEST_URI_2); - - $initialClientSubs = [TEST_URI => true, TEST_URI_2 => true]; - $initialResourceSubs1 = [TEST_CLIENT_ID => true, 'other-client' => true]; - $initialResourceSubs2 = [TEST_CLIENT_ID => true]; // Only this client subscribed - - $this->cache->shouldReceive('get')->once()->with($clientSubKey, [])->andReturn($initialClientSubs); - $this->cache->shouldReceive('get')->once()->with($resourceSubKey1, [])->andReturn($initialResourceSubs1); - $this->cache->shouldReceive('get')->once()->with($resourceSubKey2, [])->andReturn($initialResourceSubs2); - - // Mock updates/deletes - $this->cache->shouldReceive('set')->once() // Update first resource sub list - ->with($resourceSubKey1, ['other-client' => true], CACHE_TTL) - ->andReturn(true); - $this->cache->shouldReceive('delete')->once() // Delete second resource sub list (now empty) - ->with($resourceSubKey2) - ->andReturn(true); - $this->cache->shouldReceive('delete')->once() // Delete client sub list - ->with($clientSubKey) - ->andReturn(true); - - // Act - $this->transportState->removeAllResourceSubscriptions(TEST_CLIENT_ID); - - // Assert (implicit via mock expectations) -}); - -test('can queue and retrieve messages', function () { - // Arrange - $messageKey = getCacheKey('messages', TEST_CLIENT_ID); - // Fix: Notification constructor expects ('2.0', method, params) - $notification = new Notification('2.0', 'test/event', ['data' => 1]); - $response = Response::success(new EmptyResult, id: 1); - - // Mock initial get (empty), set, get, delete - $this->cache->shouldReceive('get')->once()->ordered()->with($messageKey, [])->andReturn([]); - $this->cache->shouldReceive('set')->once()->ordered() - ->with($messageKey, [$notification->toArray()], CACHE_TTL) - ->andReturn(true); - $this->cache->shouldReceive('get')->once()->ordered()->with($messageKey, [])->andReturn([$notification->toArray()]); - $this->cache->shouldReceive('set')->once()->ordered() - ->with($messageKey, [$notification->toArray(), $response->toArray()], CACHE_TTL) - ->andReturn(true); - - // For getQueuedMessages - $this->cache->shouldReceive('get')->once()->ordered()->with($messageKey, [])->andReturn([$notification->toArray(), $response->toArray()]); - $this->cache->shouldReceive('delete')->once()->ordered()->with($messageKey)->andReturn(true); - - // Act - $this->transportState->queueMessage(TEST_CLIENT_ID, $notification); - $this->transportState->queueMessage(TEST_CLIENT_ID, $response); // Queue another - $messages = $this->transportState->getQueuedMessages(TEST_CLIENT_ID); - - // Assert - expect($messages)->toBeArray()->toHaveCount(2); - expect($messages[0])->toBe($notification->toArray()); - expect($messages[1])->toBe($response->toArray()); - - // Verify it's empty now - $this->cache->shouldReceive('get')->once()->with($messageKey, [])->andReturn([]); - expect($this->transportState->getQueuedMessages(TEST_CLIENT_ID))->toBe([]); -}); - -test('can queue message for all active clients', function () { - // Arrange - $activeKey = getCacheKey('active_clients'); - $client1 = 'client-1'; - $client2 = 'client-2'; - $inactiveClient = 'client-inactive'; - $now = time(); - $activeClientsData = [ - $client1 => $now - 10, - $client2 => $now - 20, - $inactiveClient => $now - 1000, // Should be filtered out by default threshold (300s) - ]; - $expectedCleanedList = [ - $client1 => $activeClientsData[$client1], - $client2 => $activeClientsData[$client2], - ]; - $message = new Notification('2.0', 'global/event'); - $messageKey1 = getCacheKey('messages', $client1); - $messageKey2 = getCacheKey('messages', $client2); - - $this->cache->shouldReceive('get')->once()->ordered()->with($activeKey, [])->andReturn($activeClientsData); - $this->cache->shouldReceive('set')->once()->ordered() - ->with($activeKey, $expectedCleanedList, CACHE_TTL) - ->andReturn(true); - - // Mock queuing for client1 - $this->cache->shouldReceive('get')->once()->with($messageKey1, [])->andReturn([]); - $this->cache->shouldReceive('set')->once()->with($messageKey1, [$message->toArray()], CACHE_TTL)->andReturn(true); - - // Mock queuing for client2 - $this->cache->shouldReceive('get')->once()->with($messageKey2, [])->andReturn([]); - $this->cache->shouldReceive('set')->once()->with($messageKey2, [$message->toArray()], CACHE_TTL)->andReturn(true); - - // Mock cleanupClient - $this->cache->shouldReceive('get')->once()->with(getCacheKey('client_subscriptions', $inactiveClient), [])->andReturn([]); - $this->cache->shouldReceive('deleteMultiple')->once()->with([ - getCacheKey('initialized', $inactiveClient), - getCacheKey('client_info', $inactiveClient), - getCacheKey('protocol_version', $inactiveClient), - getCacheKey('messages', $inactiveClient), - getCacheKey('client_subscriptions', $inactiveClient), - ])->andReturn(true); - - // Act - $this->transportState->queueMessageForAll($message); - - // Assert (implicit via mock expectations - inactiveClient is not called for queueing) -}); - -test('can update client activity', function () { - // Arrange - $activeKey = getCacheKey('active_clients'); - $this->cache->shouldReceive('get')->once()->with($activeKey, [])->andReturn([]); - $this->cache->shouldReceive('set')->once() - ->with($activeKey, Mockery::on(function ($arg) { // Use Mockery namespace - return is_array($arg) && isset($arg[TEST_CLIENT_ID]) && is_int($arg[TEST_CLIENT_ID]); - }), CACHE_TTL) - ->andReturn(true); - - // Act - $this->transportState->updateClientActivity(TEST_CLIENT_ID); - - // Assert (implicit) -}); - -test('can get active clients filtering inactive ones', function () { - // Arrange - $activeKey = getCacheKey('active_clients'); - $client1 = 'client-1'; - $client2 = 'client-2'; - $inactiveClient = 'client-inactive'; - $now = time(); - $activeClientsData = [ - $client1 => $now - 10, // Active - $client2 => $now - 299, // Active (just within default 300s threshold) - $inactiveClient => $now - 301, // Inactive - ]; - $expectedCleanedList = [ - $client1 => $activeClientsData[$client1], - $client2 => $activeClientsData[$client2], - ]; - - $this->cache->shouldReceive('get')->once()->with($activeKey, [])->andReturn($activeClientsData); - $this->cache->shouldReceive('set')->once() - ->with($activeKey, $expectedCleanedList, CACHE_TTL) - ->andReturn(true); - - // Mock cleanupClient - $this->cache->shouldReceive('get')->once()->with(getCacheKey('client_subscriptions', $inactiveClient), [])->andReturn([]); - $this->cache->shouldReceive('deleteMultiple')->once()->with([ - getCacheKey('initialized', $inactiveClient), - getCacheKey('client_info', $inactiveClient), - getCacheKey('protocol_version', $inactiveClient), - getCacheKey('messages', $inactiveClient), - getCacheKey('client_subscriptions', $inactiveClient), - ])->andReturn(true); - - // Act - $activeClients = $this->transportState->getActiveClients(); // Use default threshold - // Need to mock the get/set again for the second call with different threshold - $activeClientsDataLow = [ - $client1 => $now - 10, // Active - $inactiveClient => $now - 301, // Inactive - ]; - $expectedCleanedListLow = [ - $client1 => $activeClientsDataLow[$client1], - ]; - $this->cache->shouldReceive('get')->once()->with($activeKey, [])->andReturn($activeClientsDataLow); - $this->cache->shouldReceive('set')->once() - ->with($activeKey, $expectedCleanedListLow, CACHE_TTL) - ->andReturn(true); - - // Mock cleanupClient - $this->cache->shouldReceive('get')->once()->with(getCacheKey('client_subscriptions', $inactiveClient), [])->andReturn([]); - $this->cache->shouldReceive('deleteMultiple')->once()->with([ - getCacheKey('initialized', $inactiveClient), - getCacheKey('client_info', $inactiveClient), - getCacheKey('protocol_version', $inactiveClient), - getCacheKey('messages', $inactiveClient), - getCacheKey('client_subscriptions', $inactiveClient), - ])->andReturn(true); - - $activeClientsLowThreshold = $this->transportState->getActiveClients(50); // Use custom threshold - - // Assert - expect($activeClients)->toEqualCanonicalizing([$client1, $client2]); // Use toEqualCanonicalizing - expect($activeClientsLowThreshold)->toEqualCanonicalizing([$client1]); // Use toEqualCanonicalizing -}); - -test('can remove client', function () { - // Arrange - $clientId = 'client-to-remove'; - // Mock dependencies for removeAllResourceSubscriptions - $clientSubKey = getCacheKey('client_subscriptions', $clientId); - $this->cache->shouldReceive('get')->once()->with($clientSubKey, [])->andReturn([]); // Assume no subs for simplicity here - - // Mock active clients list - $activeKey = getCacheKey('active_clients'); - $initialActive = [$clientId => time(), 'other-client' => time()]; - $this->cache->shouldReceive('get')->once()->with($activeKey, [])->andReturn($initialActive); - // Use Mockery::on to be less strict about exact timestamp - $this->cache->shouldReceive('set')->once()->with($activeKey, Mockery::on(function ($arg) { - return is_array($arg) && ! isset($arg['client-to-remove']) && isset($arg['other-client']); - }), CACHE_TTL)->andReturn(true); - - // Mock deletes for other keys - $keysToDelete = [ - getCacheKey('initialized', $clientId), - getCacheKey('client_info', $clientId), - getCacheKey('protocol_version', $clientId), - getCacheKey('messages', $clientId), - $clientSubKey, // Add this key as it's deleted by deleteMultiple - ]; - // Fix: Expect deleteMultiple instead of individual deletes - $this->cache->shouldReceive('deleteMultiple')->once() - ->with(Mockery::on(function ($arg) use ($keysToDelete) { - // Check if the passed keys match the expected keys, order doesn't matter - return is_array($arg) && empty(array_diff($keysToDelete, $arg)) && empty(array_diff($arg, $keysToDelete)); - })) - ->andReturn(true); - - // Act - $this->transportState->cleanupClient($clientId); - - // Assert (implicit) -}); diff --git a/tests/Transports/HttpTransportHandlerTest.php b/tests/Transports/HttpTransportHandlerTest.php deleted file mode 100644 index 49fa8e6..0000000 --- a/tests/Transports/HttpTransportHandlerTest.php +++ /dev/null @@ -1,391 +0,0 @@ -container = Mockery::mock(ContainerInterface::class); - $this->server = Mockery::mock(Server::class); - $this->processor = Mockery::mock(Processor::class); - $this->transportState = Mockery::mock(TransportState::class); - $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - - $this->server->shouldReceive('getContainer')->andReturn($this->container); - $this->server->shouldReceive('getProcessor')->andReturn($this->processor); - - $this->container->shouldReceive('get')->with(LoggerInterface::class)->andReturn($this->logger); - - $this->handler = Mockery::mock(HttpTransportHandler::class, [ - $this->server, - $this->transportState, - ])->makePartial()->shouldAllowMockingProtectedMethods(); - - $this->clientId = 'test_client_id'; -}); - -// --- Initialization Tests --- - -test('constructs with dependencies', function () { - // Re-create without mock for this specific test - $handler = new HttpTransportHandler($this->server, $this->transportState); - - expect($handler)->toBeInstanceOf(HttpTransportHandler::class); -}); - -test('start method throws exception', function () { - expect(fn () => $this->handler->start())->toThrow(\Exception::class, 'This method should never be called'); -}); - -// --- Request Handling Tests --- - -test('handleInput processes JSON-RPC requests correctly', function () { - $input = '{"jsonrpc":"2.0","id":1,"method":"test","params":{}}'; - $expectedRequest = Request::fromArray(json_decode($input, true)); - $expectedResponse = Response::success(new EmptyResult, 1); - - $this->transportState->shouldReceive('updateClientActivity')->once()->with($this->clientId); - $this->processor->shouldReceive('process') - ->once() - ->with(Mockery::type(Request::class), $this->clientId) - ->andReturn($expectedResponse); - - $this->transportState->shouldReceive('queueMessage') - ->once() - ->with($this->clientId, $expectedResponse); - - $this->handler->handleInput($input, $this->clientId); -}); - -test('handleInput processes JSON-RPC notifications correctly', function () { - $input = '{"jsonrpc":"2.0","method":"initialized","params":{}}'; - $expectedNotification = Notification::fromArray(json_decode($input, true)); - - $this->transportState->shouldReceive('updateClientActivity')->once()->with($this->clientId); - $this->processor->shouldReceive('process') - ->once() - ->with(Mockery::type(Notification::class), $this->clientId) - ->andReturn(null); // Notifications typically return null - - // No message queuing expected for notifications - $this->transportState->shouldNotReceive('queueMessage'); - - $this->handler->handleInput($input, $this->clientId); -}); - -test('handleInput handles invalid JSON properly', function () { - $input = '{invalid json'; - - $this->transportState->shouldReceive('updateClientActivity')->once()->with($this->clientId); - $this->processor->shouldNotReceive('process'); - - $this->transportState->shouldReceive('queueMessage')->once()->with( - $this->clientId, - Mockery::on(function ($response) { - return $response instanceof Response && - $response->error->code === McpException::CODE_PARSE_ERROR; - }) - ); - - $this->logger->shouldReceive('error')->with('MCP HTTP: JSON parse error', Mockery::any()); - - $this->handler->handleInput($input, $this->clientId); -}); - -test('handleInput handles McpException properly', function () { - $input = '{"jsonrpc":"2.0","id":1,"method":"invalid_method"}'; - $exception = McpException::methodNotFound('Method not found'); - - $this->transportState->shouldReceive('updateClientActivity')->once()->with($this->clientId); - $this->processor->shouldReceive('process') - ->once() - ->with(Mockery::type(Request::class), $this->clientId) - ->andThrow($exception); - - $this->transportState->shouldReceive('queueMessage')->once()->with( - $this->clientId, - Mockery::on(function ($response) { - return $response instanceof Response && - $response->error->code === McpException::CODE_METHOD_NOT_FOUND; - }) - ); - - $this->logger->shouldReceive('error')->with('MCP HTTP: Request processing error', Mockery::any()); - - $this->handler->handleInput($input, $this->clientId); -}); - -test('handleInput handles generic exceptions properly', function () { - $input = '{"jsonrpc":"2.0","id":1,"method":"throws_error"}'; - $exception = new RuntimeException('Unexpected error'); - - $this->transportState->shouldReceive('updateClientActivity')->once()->with($this->clientId); - $this->processor->shouldReceive('process') - ->once() - ->with(Mockery::type(Request::class), $this->clientId) - ->andThrow($exception); - - $this->transportState->shouldReceive('queueMessage')->once()->with( - $this->clientId, - Mockery::on(function ($response) { - return $response instanceof Response && - $response->error->code === McpException::CODE_INTERNAL_ERROR; - }) - ); - - $this->logger->shouldReceive('error')->with('MCP HTTP: Unexpected error processing message', Mockery::any()); - - $this->handler->handleInput($input, $this->clientId); -}); - -// --- Response Handling / SSE Streaming Tests --- - -// Removed the old 'sendResponse queues message' test as it's no longer accurate. - -test('handleSseConnection attempts to send initial endpoint event', function () { - $postEndpointUri = '/mcp/post'; - - // Mock the protected sendSseEvent method to prevent actual output and check call - $this->handler->shouldReceive('sendSseEvent') - ->once() - ->with($this->clientId, 'endpoint', $postEndpointUri) - ->andReturnUsing(function () { - // Simulate successful send of initial event - }); - - $this->logger->shouldReceive('info')->with('MCP: Starting SSE stream loop', Mockery::any()); - - // Simulate loop exit after initial event - $this->transportState->shouldReceive('getQueuedMessages') - ->once() - ->andThrow(new \Exception('Force loop exit')); // Force exit - - try { - $this->handler->handleSseConnection( - $this->clientId, - $postEndpointUri - // Using default intervals - ); - } catch (\Exception $e) { - expect($e->getMessage())->toBe('Force loop exit'); - } -}); - -test('handleSseConnection attempts to send queued messages', function () { - $postEndpointUri = '/mcp/post'; - - // Mock the protected sendSseEvent method - $this->handler->shouldReceive('sendSseEvent') - ->once() // Expect initial endpoint call - ->with($this->clientId, 'endpoint', $postEndpointUri); - - // Setup three queued messages - $queuedMessages = [ - ['jsonrpc' => '2.0', 'id' => 1, 'result' => []], - ['jsonrpc' => '2.0', 'method' => 'notify', 'params' => []], - ['jsonrpc' => '2.0', 'id' => 2, 'result' => ['success' => true]], - ]; - - $this->transportState->shouldReceive('getQueuedMessages') - ->once() // First call returns messages - ->with($this->clientId) - ->andReturn($queuedMessages); - - // Expect three message events calls via sendSseEvent (with event IDs 1, 2, and 3) - $this->handler->shouldReceive('sendSseEvent') - ->once() - ->with($this->clientId, 'message', json_encode($queuedMessages[0]), '1'); - - $this->handler->shouldReceive('sendSseEvent') - ->once() - ->with($this->clientId, 'message', json_encode($queuedMessages[1]), '2'); - - $this->handler->shouldReceive('sendSseEvent') - ->once() - ->with($this->clientId, 'message', json_encode($queuedMessages[2]), '3'); - - // Simulate loop exit after processing messages - $this->transportState->shouldReceive('getQueuedMessages') - ->once() // Second call forces exit - ->andThrow(new \Exception('Force loop exit')); - - try { - $this->handler->handleSseConnection( - $this->clientId, - $postEndpointUri - // Using default intervals - ); - } catch (\Exception $e) { - expect($e->getMessage())->toBe('Force loop exit'); - } -}); - -// Removed the 'sends ping events' test as the handler no longer sends pings. - -test('handleSseConnection updates client activity based on interval', function () { - $postEndpointUri = '/mcp/post'; - $loopInterval = 0.01; // 10ms - $activityUpdateInterval = 0.02; // 20ms - should trigger on 3rd iteration - - // Mock sendSseEvent to prevent output - $this->handler->shouldReceive('sendSseEvent')->byDefault(); // Allow any calls - $this->handler->shouldReceive('sendSseEvent') - ->once() - ->with($this->clientId, 'endpoint', $postEndpointUri); - - // No messages in the queue for simplicity - $this->transportState->shouldReceive('getQueuedMessages') - ->times(3) // Expect 3 iterations before activity update - ->with($this->clientId) - ->andReturn([]); - - // Expect client activity update on the iteration where time exceeds interval - $this->transportState->shouldReceive('updateClientActivity') - ->once() - ->with($this->clientId); - - $this->logger->shouldReceive('debug')->with('MCP: Updated client activity timestamp', Mockery::any()); - - // Force exit after the 3rd iteration (where activity was updated) - $this->transportState->shouldReceive('getQueuedMessages') - ->once() - ->andThrow(new \Exception('Force loop exit')); - - try { - // We need a way to control time or iterations reliably. - // Mocking microtime is complex. Instead, we rely on the number of - // getQueuedMessages calls to simulate loop progression. - $this->handler->handleSseConnection( - $this->clientId, - $postEndpointUri, - $loopInterval, - $activityUpdateInterval - ); - } catch (\Exception $e) { - expect($e->getMessage())->toBe('Force loop exit'); - } -}); - -test('handleSseConnection handles initial sendSseEvent failure', function () { - $postEndpointUri = '/mcp/post'; - - // Mock the protected sendSseEvent method to throw on the first call - $this->handler->shouldReceive('sendSseEvent') - ->once() - ->with($this->clientId, 'endpoint', $postEndpointUri) - ->andThrow(new RuntimeException('Simulated send failure')); - - $this->logger->shouldReceive('error') - ->once() - ->with('MCP: Failed to send initial endpoint event. Aborting stream.', Mockery::any()); - - // Ensure the loop doesn't even start querying messages - $this->transportState->shouldNotReceive('getQueuedMessages'); - - // Execute - should catch the exception internally and log, not throw - $this->handler->handleSseConnection( - $this->clientId, - $postEndpointUri - ); -}); - -test('handleSseConnection handles message encoding failure', function () { - $postEndpointUri = '/mcp/post'; - - // Mock sendSseEvent for the initial endpoint call (succeeds) - $this->handler->shouldReceive('sendSseEvent') - ->once() - ->with($this->clientId, 'endpoint', $postEndpointUri); - - // Setup message that will fail json_encode (e.g., invalid UTF-8 or recursion) - // Using a resource type which cannot be directly encoded. - $badMessage = ['jsonrpc' => '2.0', 'id' => 1, 'result' => fopen('php://memory', 'r')]; - - $this->transportState->shouldReceive('getQueuedMessages') - ->once() - ->with($this->clientId) - ->andReturn([$badMessage]); - - // Expect sendSseEvent NOT to be called for the message due to encoding failure - $this->handler->shouldNotReceive('sendSseEvent') - ->with($this->clientId, 'message', Mockery::any(), '1'); - - $this->logger->shouldReceive('error') - ->once() - ->with('MCP: Error sending message event via callback', Mockery::any()); - // The error message comes from the catch block wrapping the json_encode call - - // Ensure the loop exits after the encoding error - $this->transportState->shouldNotReceive('getQueuedMessages'); - - // Execute - should catch the exception internally and log, not throw - $this->handler->handleSseConnection( - $this->clientId, - $postEndpointUri - ); -}); - -// Removed ping-related tests - -// --- Client Cleanup Tests --- - -test('cleanupClient removes client from transport state', function () { - $this->transportState->shouldReceive('cleanupClient') - ->once() - ->with($this->clientId); - - $this->logger->shouldReceive('info')->never(); // cleanupClient no longer logs directly - - // Need to use the non-mocked handler for this test - $handler = new HttpTransportHandler($this->server, $this->transportState); - $handler->cleanupClient($this->clientId); -}); - -// --- Error Handling Tests --- - -test('handleError converts JsonException to parse error', function () { - $exception = new JsonException('Invalid JSON'); - // Use the real handler instance for this utility method test - $handler = new HttpTransportHandler($this->server, $this->transportState); - - $result = $handler->handleError($exception); - - expect($result)->toBeInstanceOf(Response::class); - expect($result->error->code)->toBe(McpException::CODE_PARSE_ERROR); -}); - -test('handleError preserves McpException error codes', function () { - $exception = McpException::methodNotFound('Method not found'); - $handler = new HttpTransportHandler($this->server, $this->transportState); - - $result = $handler->handleError($exception); - - expect($result)->toBeInstanceOf(Response::class); - expect($result->error->code)->toBe(McpException::CODE_METHOD_NOT_FOUND); -}); - -test('handleError converts generic exceptions to internal error', function () { - $exception = new RuntimeException('Unexpected error'); - $handler = new HttpTransportHandler($this->server, $this->transportState); - - $result = $handler->handleError($exception); - - expect($result)->toBeInstanceOf(Response::class); - expect($result->error->code)->toBe(McpException::CODE_INTERNAL_ERROR); -}); diff --git a/tests/Transports/StdioTransportHandlerTest.php b/tests/Transports/StdioTransportHandlerTest.php deleted file mode 100644 index 06724d3..0000000 --- a/tests/Transports/StdioTransportHandlerTest.php +++ /dev/null @@ -1,304 +0,0 @@ -container = Mockery::mock(ContainerInterface::class); - $this->server = Mockery::mock(Server::class); - $this->processor = Mockery::mock(Processor::class); - $this->transportState = Mockery::mock(TransportState::class); - $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - - $this->loop = Mockery::mock(LoopInterface::class); - $this->inputStream = Mockery::mock(ReadableStreamInterface::class); - $this->outputStream = Mockery::mock(WritableStreamInterface::class); - - $this->server->shouldReceive('getContainer')->andReturn($this->container); - $this->server->shouldReceive('getProcessor')->andReturn($this->processor); - - $this->container->shouldReceive('get')->with(LoggerInterface::class)->andReturn($this->logger); - - $this->handler = new StdioTransportHandler( - $this->server, - $this->transportState, - $this->inputStream, - $this->outputStream, - $this->loop, - ); - - $this->clientId = 'stdio_client'; -}); - -// --- Initialization and Start Tests --- - -test('constructs with dependencies', function () { - expect($this->handler)->toBeInstanceOf(StdioTransportHandler::class); -}); - -test('start handles fatal errors', function () { - $exception = new RuntimeException('Fatal stream error'); - - $this->loop->shouldReceive('addPeriodicTimer')->andThrow($exception); - $this->logger->shouldReceive('critical')->once()->with('MCP: Fatal error in STDIO transport handler', Mockery::hasKey('exception')); - - $result = $this->handler->start(); - - expect($result)->toBe(1); // Error exit code -}); - -// --- Message Handling Tests --- - -test('handle adds to buffer and processes complete lines', function () { - $input = '{"jsonrpc":"2.0","method":"test"}'."\n".'{"jsonrpc":"2.0","id":1,"method":"ping"}'."\n"; - - // Create a spy to track handleInput calls - /** @var MockInterface&StdioTransportHandler */ - $handlerSpy = Mockery::mock(StdioTransportHandler::class.'[handleInput]', [ - $this->server, - $this->transportState, - $this->inputStream, - $this->outputStream, - $this->loop, - ])->makePartial(); - - // Expect two calls to handleInput for the two complete lines - $handlerSpy->shouldReceive('handleInput')->twice(); - $this->logger->shouldReceive('debug')->with('MCP: Received message', Mockery::any()); - - $result = $handlerSpy->handle($input, $this->clientId); - - expect($result)->toBeTrue(); -}); - -test('handle ignores empty lines', function () { - $input = "\n\n\n"; - - /** @var MockInterface&StdioTransportHandler */ - $handlerSpy = Mockery::mock(StdioTransportHandler::class.'[handleInput]', [ - $this->server, - $this->transportState, - $this->inputStream, - $this->outputStream, - $this->loop, - ])->makePartial(); - - // Should not call handleInput - $handlerSpy->shouldNotReceive('handleInput'); - - $result = $handlerSpy->handle($input, $this->clientId); - - expect($result)->toBeTrue(); -}); - -test('handleInput processes JSON-RPC requests correctly', function () { - $input = '{"jsonrpc":"2.0","id":1,"method":"ping"}'; - $expectedRequest = Request::fromArray(json_decode($input, true)); - // $expectedResponse = Response::result(['pong' => true], 1); - $expectedResponse = Response::success(new EmptyResult, 1); - - $this->transportState->shouldReceive('updateClientActivity')->once()->with($this->clientId); - $this->processor->shouldReceive('process') - ->once() - ->with(Mockery::type(Request::class), $this->clientId) - ->andReturn($expectedResponse); - - $this->outputStream->shouldReceive('isWritable')->andReturn(true); - $this->outputStream->shouldReceive('write')->once()->with(Mockery::pattern('/{"jsonrpc":"2.0".*}\n/')); - $this->logger->shouldReceive('debug')->with('MCP: Sent response', Mockery::any()); - - $this->handler->handleInput($input, $this->clientId); -}); - -test('handleInput processes JSON-RPC notifications correctly', function () { - $input = '{"jsonrpc":"2.0","method":"initialized","params":{}}'; - $expectedNotification = Notification::fromArray(json_decode($input, true)); - - $this->transportState->shouldReceive('updateClientActivity')->once()->with($this->clientId); - $this->processor->shouldReceive('process') - ->once() - ->with(Mockery::type(Notification::class), $this->clientId) - ->andReturn(null); // Notifications typically return null - - // No output expected for notifications - $this->outputStream->shouldNotReceive('write'); - - $this->handler->handleInput($input, $this->clientId); -}); - -test('handleInput handles invalid JSON properly', function () { - $input = '{invalid json'; - - $this->transportState->shouldReceive('updateClientActivity')->once()->with($this->clientId); - $this->processor->shouldNotReceive('process'); - - $this->outputStream->shouldReceive('isWritable')->andReturn(true); - $this->outputStream->shouldReceive('write')->once()->with( - Mockery::pattern('/{"jsonrpc":"2.0","id":1,"error":{"code":-32700.*}\n/') - ); - $this->logger->shouldReceive('error')->with('MCP: Error processing message:', Mockery::any()); - - $this->handler->handleInput($input, $this->clientId); -}); - -test('handleInput handles McpException properly', function () { - $input = '{"jsonrpc":"2.0","id":1,"method":"invalid_method"}'; - $exception = McpException::methodNotFound('Method not found'); - - $this->transportState->shouldReceive('updateClientActivity')->once()->with($this->clientId); - $this->processor->shouldReceive('process') - ->once() - ->with(Mockery::type(Request::class), $this->clientId) - ->andThrow($exception); - - $this->outputStream->shouldReceive('isWritable')->andReturn(true); - $this->outputStream->shouldReceive('write')->once()->with( - Mockery::pattern('/{"jsonrpc":"2.0","id":1,"error":{"code":-32601.*}\n/') - ); - $this->logger->shouldReceive('error')->with('MCP: Error processing message:', Mockery::any()); - - $this->handler->handleInput($input, $this->clientId); -}); - -test('handleInput handles generic exceptions properly', function () { - $input = '{"jsonrpc":"2.0","id":1,"method":"throws_error"}'; - $exception = new RuntimeException('Unexpected error'); - - $this->transportState->shouldReceive('updateClientActivity')->once()->with($this->clientId); - $this->processor->shouldReceive('process') - ->once() - ->with(Mockery::type(Request::class), $this->clientId) - ->andThrow($exception); - - $this->outputStream->shouldReceive('isWritable')->andReturn(true); - $this->outputStream->shouldReceive('write')->once()->with( - Mockery::pattern('/{"jsonrpc":"2.0","id":1,"error":{"code":-32603.*}\n/') - ); - $this->logger->shouldReceive('error')->with('MCP: Error processing message:', Mockery::any()); - - $this->handler->handleInput($input, $this->clientId); -}); - -// --- Error Handling Tests --- - -test('handleError converts JsonException to parse error', function () { - $exception = new JsonException('Invalid JSON'); - - $this->logger->shouldReceive('error')->with('MCP: Transport Error', Mockery::any()); - - $result = $this->handler->handleError($exception, 123); - - expect($result)->toBeInstanceOf(Response::class); - expect($result->error->code)->toBe(McpException::CODE_PARSE_ERROR); - expect($result->id)->toBe(123); -}); - -test('handleError preserves McpException error codes', function () { - $exception = McpException::methodNotFound('Method not found'); - - $this->logger->shouldReceive('error')->with('MCP: Transport Error', Mockery::any()); - - $result = $this->handler->handleError($exception, 456); - - expect($result)->toBeInstanceOf(Response::class); - expect($result->error->code)->toBe(McpException::CODE_METHOD_NOT_FOUND); - expect($result->id)->toBe(456); -}); - -test('handleError converts generic exceptions to internal error', function () { - $exception = new RuntimeException('Unexpected error'); - - $this->logger->shouldReceive('error')->with('MCP: Transport Error', Mockery::any()); - - $result = $this->handler->handleError($exception, 789); - - expect($result)->toBeInstanceOf(Response::class); - expect($result->error->code)->toBe(McpException::CODE_INTERNAL_ERROR); - expect($result->id)->toBe(789); -}); - -// --- Shutdown and Cleanup Tests --- - -test('stop closes streams and stops loop', function () { - $this->logger->shouldReceive('info')->with('MCP: Closing STDIO Transport.'); - $this->transportState->shouldReceive('cleanupClient')->once()->with($this->clientId); - - $this->inputStream->shouldReceive('close')->once(); - $this->outputStream->shouldReceive('close')->once(); - $this->loop->shouldReceive('stop')->once(); - - $this->handler->stop(); -}); - -// --- Queued Messages Tests --- - -test('checkQueuedMessages processes and sends queued messages', function () { - $queuedMessages = [ - ['jsonrpc' => '2.0', 'method' => 'notification1', 'params' => []], - ['jsonrpc' => '2.0', 'id' => 1, 'result' => ['success' => true]], - ]; - - $this->transportState->shouldReceive('getQueuedMessages') - ->once() - ->with($this->clientId) - ->andReturn($queuedMessages); - - $this->outputStream->shouldReceive('isWritable')->times(2)->andReturn(true); - $this->outputStream->shouldReceive('write')->twice()->with(Mockery::pattern('/{"jsonrpc":"2.0".*}\n/')); - $this->logger->shouldReceive('debug')->twice()->with('MCP: Sent message from queue', Mockery::any()); - - // Use reflection to call protected method - $reflection = new ReflectionClass($this->handler); - $method = $reflection->getMethod('checkQueuedMessages'); - $method->setAccessible(true); - $method->invoke($this->handler); -}); - -test('checkQueuedMessages handles empty queue gracefully', function () { - $this->transportState->shouldReceive('getQueuedMessages') - ->once() - ->with($this->clientId) - ->andReturn([]); - - $this->outputStream->shouldNotReceive('write'); - - $reflection = new ReflectionClass($this->handler); - $method = $reflection->getMethod('checkQueuedMessages'); - $method->setAccessible(true); - $method->invoke($this->handler); -}); - -test('checkQueuedMessages handles queue errors gracefully', function () { - $exception = new RuntimeException('Queue error'); - - $this->transportState->shouldReceive('getQueuedMessages') - ->once() - ->with($this->clientId) - ->andThrow($exception); - - $this->logger->shouldReceive('error')->once()->with('MCP: Error processing or sending queued messages', Mockery::any()); - - $reflection = new ReflectionClass($this->handler); - $method = $reflection->getMethod('checkQueuedMessages'); - $method->setAccessible(true); - $method->invoke($this->handler); -}); diff --git a/tests/Attributes/McpPromptTest.php b/tests/Unit/Attributes/McpPromptTest.php similarity index 95% rename from tests/Attributes/McpPromptTest.php rename to tests/Unit/Attributes/McpPromptTest.php index 9925639..0d6466b 100644 --- a/tests/Attributes/McpPromptTest.php +++ b/tests/Unit/Attributes/McpPromptTest.php @@ -1,6 +1,6 @@ cache = Mockery::mock(CacheInterface::class); + /** @var MockInterface&LoggerInterface */ + $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); + + // Instance WITH cache + $this->stateManager = new ClientStateManager( + $this->logger, + $this->cache, + CACHE_PREFIX_MGR, + CACHE_TTL_MGR + ); + + // Instance WITHOUT cache + $this->stateManagerNoCache = new ClientStateManager($this->logger, null); +}); + +afterEach(function () { + Mockery::close(); +}); + +// Helper to generate expected cache keys for this test file +function getMgrCacheKey(string $key, ?string $clientId = null): string +{ + return $clientId ? CACHE_PREFIX_MGR."{$key}_{$clientId}" : CACHE_PREFIX_MGR.$key; +} + +// --- Tests --- + +// --- Initialization --- +test('isInitialized returns false if cache unavailable', function () { + expect($this->stateManagerNoCache->isInitialized(TEST_CLIENT_ID_MGR))->toBeFalse(); +}); + +test('isInitialized checks cache correctly', function () { + $initializedKey = getMgrCacheKey('initialized', TEST_CLIENT_ID_MGR); + $this->cache->shouldReceive('get')->once()->with($initializedKey, false)->andReturn(false); + $this->cache->shouldReceive('get')->once()->with($initializedKey, false)->andReturn(true); + + expect($this->stateManager->isInitialized(TEST_CLIENT_ID_MGR))->toBeFalse(); + expect($this->stateManager->isInitialized(TEST_CLIENT_ID_MGR))->toBeTrue(); +}); + +test('markInitialized logs warning if cache unavailable', function () { + $this->logger->shouldReceive('warning')->once()->with(Mockery::pattern('/cache not available/'), Mockery::any()); + $this->stateManagerNoCache->markInitialized(TEST_CLIENT_ID_MGR); // No exception thrown +}); + +test('markInitialized sets cache and updates activity', function () { + $initializedKey = getMgrCacheKey('initialized', TEST_CLIENT_ID_MGR); + $activityKey = getMgrCacheKey('active_clients'); + + $this->cache->shouldReceive('set')->once()->with($initializedKey, true, CACHE_TTL_MGR)->andReturn(true); + // Expect updateClientActivity call + $this->cache->shouldReceive('get')->once()->with($activityKey, [])->andReturn([]); + $this->cache->shouldReceive('set')->once()->with($activityKey, Mockery::on(fn ($arg) => isset($arg[TEST_CLIENT_ID_MGR])), CACHE_TTL_MGR)->andReturn(true); + + $this->stateManager->markInitialized(TEST_CLIENT_ID_MGR); // No exception thrown +}); + +test('markInitialized handles cache exceptions', function () { + $initializedKey = getMgrCacheKey('initialized', TEST_CLIENT_ID_MGR); + $this->cache->shouldReceive('set')->once()->with($initializedKey, true, CACHE_TTL_MGR)->andThrow(new class () extends \Exception implements CacheInvalidArgumentException {}); + $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/Failed to mark client.*invalid key/'), Mockery::any()); + + $this->stateManager->markInitialized(TEST_CLIENT_ID_MGR); // No exception thrown outwards +}); + +// --- Client Info --- +test('storeClientInfo does nothing if cache unavailable', function () { + $this->cache->shouldNotReceive('set'); + $this->stateManagerNoCache->storeClientInfo([], 'v1', TEST_CLIENT_ID_MGR); +}); + +test('storeClientInfo sets cache keys', function () { + $clientInfoKey = getMgrCacheKey('client_info', TEST_CLIENT_ID_MGR); + $protocolKey = getMgrCacheKey('protocol_version', TEST_CLIENT_ID_MGR); + $clientInfo = ['name' => 'TestClientState', 'version' => '1.1']; + $protocolVersion = '2024-11-05'; + + $this->cache->shouldReceive('set')->once()->with($clientInfoKey, $clientInfo, CACHE_TTL_MGR)->andReturn(true); + $this->cache->shouldReceive('set')->once()->with($protocolKey, $protocolVersion, CACHE_TTL_MGR)->andReturn(true); + + $this->stateManager->storeClientInfo($clientInfo, $protocolVersion, TEST_CLIENT_ID_MGR); +}); + +test('getClientInfo returns null if cache unavailable', function () { + expect($this->stateManagerNoCache->getClientInfo(TEST_CLIENT_ID_MGR))->toBeNull(); +}); + +test('getClientInfo gets value from cache', function () { + $clientInfoKey = getMgrCacheKey('client_info', TEST_CLIENT_ID_MGR); + $clientInfo = ['name' => 'TestClientStateGet', 'version' => '1.2']; + $this->cache->shouldReceive('get')->once()->with($clientInfoKey)->andReturn($clientInfo); + + expect($this->stateManager->getClientInfo(TEST_CLIENT_ID_MGR))->toBe($clientInfo); +}); + +test('getProtocolVersion returns null if cache unavailable', function () { + expect($this->stateManagerNoCache->getProtocolVersion(TEST_CLIENT_ID_MGR))->toBeNull(); +}); + +test('getProtocolVersion gets value from cache', function () { + $protocolKey = getMgrCacheKey('protocol_version', TEST_CLIENT_ID_MGR); + $protocolVersion = '2024-11-05-test'; + $this->cache->shouldReceive('get')->once()->with($protocolKey)->andReturn($protocolVersion); + + expect($this->stateManager->getProtocolVersion(TEST_CLIENT_ID_MGR))->toBe($protocolVersion); +}); + +// --- Subscriptions --- +test('addResourceSubscription logs warning if cache unavailable', function () { + $this->logger->shouldReceive('warning')->once()->with(Mockery::pattern('/Cannot add resource subscription.*cache not available/'), Mockery::any()); + $this->stateManagerNoCache->addResourceSubscription(TEST_CLIENT_ID_MGR, TEST_URI_MGR_1); +}); + +test('addResourceSubscription sets cache keys', function () { + $clientSubKey = getMgrCacheKey('client_subscriptions', TEST_CLIENT_ID_MGR); + $resourceSubKey = getMgrCacheKey('resource_subscriptions', TEST_URI_MGR_1); + + $this->cache->shouldReceive('get')->once()->with($clientSubKey, [])->andReturn(['other/uri' => true]); // Existing data + $this->cache->shouldReceive('get')->once()->with($resourceSubKey, [])->andReturn(['other_client' => true]); + $this->cache->shouldReceive('set')->once()->with($clientSubKey, ['other/uri' => true, TEST_URI_MGR_1 => true], CACHE_TTL_MGR)->andReturn(true); + $this->cache->shouldReceive('set')->once()->with($resourceSubKey, ['other_client' => true, TEST_CLIENT_ID_MGR => true], CACHE_TTL_MGR)->andReturn(true); + + $this->stateManager->addResourceSubscription(TEST_CLIENT_ID_MGR, TEST_URI_MGR_1); +}); + +test('isSubscribedToResource returns false if cache unavailable', function () { + expect($this->stateManagerNoCache->isSubscribedToResource(TEST_CLIENT_ID_MGR, TEST_URI_MGR_1))->toBeFalse(); +}); + +test('isSubscribedToResource checks cache correctly', function () { + $clientSubKey = getMgrCacheKey('client_subscriptions', TEST_CLIENT_ID_MGR); + $this->cache->shouldReceive('get')->with($clientSubKey, [])->andReturn([TEST_URI_MGR_1 => true, 'another/one' => true]); + + expect($this->stateManager->isSubscribedToResource(TEST_CLIENT_ID_MGR, TEST_URI_MGR_1))->toBeTrue(); + expect($this->stateManager->isSubscribedToResource(TEST_CLIENT_ID_MGR, TEST_URI_MGR_2))->toBeFalse(); +}); + +test('getResourceSubscribers returns empty array if cache unavailable', function () { + expect($this->stateManagerNoCache->getResourceSubscribers(TEST_URI_MGR_1))->toBe([]); +}); + +test('getResourceSubscribers gets subscribers from cache', function () { + $resourceSubKey = getMgrCacheKey('resource_subscriptions', TEST_URI_MGR_1); + $this->cache->shouldReceive('get')->with($resourceSubKey, [])->andReturn([TEST_CLIENT_ID_MGR => true, 'client2' => true]); + + expect($this->stateManager->getResourceSubscribers(TEST_URI_MGR_1))->toEqualCanonicalizing([TEST_CLIENT_ID_MGR, 'client2']); +}); + +test('removeResourceSubscription does nothing if cache unavailable', function () { + $this->cache->shouldNotReceive('get'); + $this->cache->shouldNotReceive('set'); + $this->cache->shouldNotReceive('delete'); + $this->stateManagerNoCache->removeResourceSubscription(TEST_CLIENT_ID_MGR, TEST_URI_MGR_1); +}); + +test('removeResourceSubscription removes keys and deletes if empty', function () { + $clientSubKey = getMgrCacheKey('client_subscriptions', TEST_CLIENT_ID_MGR); + $resourceSubKey1 = getMgrCacheKey('resource_subscriptions', TEST_URI_MGR_1); + $resourceSubKey2 = getMgrCacheKey('resource_subscriptions', TEST_URI_MGR_2); + + // Initial state + $clientSubs = [TEST_URI_MGR_1 => true, TEST_URI_MGR_2 => true]; + $res1Subs = [TEST_CLIENT_ID_MGR => true, 'other' => true]; + $res2Subs = [TEST_CLIENT_ID_MGR => true]; // Only this client + + // Mocks for removing sub for TEST_URI_MGR_1 + $this->cache->shouldReceive('get')->with($clientSubKey, [])->once()->andReturn($clientSubs); + $this->cache->shouldReceive('get')->with($resourceSubKey1, [])->once()->andReturn($res1Subs); + $this->cache->shouldReceive('set')->with($clientSubKey, [TEST_URI_MGR_2 => true], CACHE_TTL_MGR)->once()->andReturn(true); // URI 1 removed + $this->cache->shouldReceive('set')->with($resourceSubKey1, ['other' => true], CACHE_TTL_MGR)->once()->andReturn(true); // Client removed + + $this->stateManager->removeResourceSubscription(TEST_CLIENT_ID_MGR, TEST_URI_MGR_1); + + // Mocks for removing sub for TEST_URI_MGR_2 (which will cause deletes) + $this->cache->shouldReceive('get')->with($clientSubKey, [])->once()->andReturn([TEST_URI_MGR_2 => true]); // State after previous call + $this->cache->shouldReceive('get')->with($resourceSubKey2, [])->once()->andReturn($res2Subs); + $this->cache->shouldReceive('delete')->with($clientSubKey)->once()->andReturn(true); // Client list now empty + $this->cache->shouldReceive('delete')->with($resourceSubKey2)->once()->andReturn(true); // Resource list now empty + + $this->stateManager->removeResourceSubscription(TEST_CLIENT_ID_MGR, TEST_URI_MGR_2); +}); + +test('removeAllResourceSubscriptions does nothing if cache unavailable', function () { + $this->cache->shouldNotReceive('get'); + $this->cache->shouldNotReceive('delete'); + $this->stateManagerNoCache->removeAllResourceSubscriptions(TEST_CLIENT_ID_MGR); +}); + +test('removeAllResourceSubscriptions clears relevant cache entries', function () { + $clientSubKey = getMgrCacheKey('client_subscriptions', TEST_CLIENT_ID_MGR); + $resourceSubKey1 = getMgrCacheKey('resource_subscriptions', TEST_URI_MGR_1); + $resourceSubKey2 = getMgrCacheKey('resource_subscriptions', TEST_URI_MGR_2); + + $initialClientSubs = [TEST_URI_MGR_1 => true, TEST_URI_MGR_2 => true]; + $initialResourceSubs1 = [TEST_CLIENT_ID_MGR => true, 'other' => true]; + $initialResourceSubs2 = [TEST_CLIENT_ID_MGR => true]; + + // Get the client's subscription list + $this->cache->shouldReceive('get')->once()->with($clientSubKey, [])->andReturn($initialClientSubs); + // Get the subscriber list for each resource the client was subscribed to + $this->cache->shouldReceive('get')->once()->with($resourceSubKey1, [])->andReturn($initialResourceSubs1); + $this->cache->shouldReceive('get')->once()->with($resourceSubKey2, [])->andReturn($initialResourceSubs2); + // Update the first resource's list + $this->cache->shouldReceive('set')->once()->with($resourceSubKey1, ['other' => true], CACHE_TTL_MGR)->andReturn(true); + // Delete the second resource's list (now empty) + $this->cache->shouldReceive('deleteMultiple')->once()->with([$resourceSubKey2])->andReturn(true); + // Delete the client's subscription list + $this->cache->shouldReceive('delete')->once()->with($clientSubKey)->andReturn(true); + + $this->stateManager->removeAllResourceSubscriptions(TEST_CLIENT_ID_MGR); +}); + +// --- Message Queue --- +test('queueMessage logs warning if cache unavailable', function () { + $this->logger->shouldReceive('warning')->once()->with(Mockery::pattern('/Cannot queue message.*cache not available/'), Mockery::any()); + $this->stateManagerNoCache->queueMessage(TEST_CLIENT_ID_MGR, new Notification('2.0', 'm')); +}); + +test('queueMessage adds single message to cache', function () { + $messageKey = getMgrCacheKey('messages', TEST_CLIENT_ID_MGR); + $notification = new Notification('2.0', 'test/event', ['data' => 1]); + + $this->cache->shouldReceive('get')->once()->with($messageKey, [])->andReturn([]); // Start empty + $this->cache->shouldReceive('set')->once()->with($messageKey, [$notification->toArray()], CACHE_TTL_MGR)->andReturn(true); + + $this->stateManager->queueMessage(TEST_CLIENT_ID_MGR, $notification); +}); + +test('queueMessage appends to existing messages in cache', function () { + $messageKey = getMgrCacheKey('messages', TEST_CLIENT_ID_MGR); + $existingMsg = ['jsonrpc' => '2.0', 'method' => 'existing']; + $notification = new Notification('2.0', 'test/event', ['data' => 2]); + + $this->cache->shouldReceive('get')->once()->with($messageKey, [])->andReturn([$existingMsg]); // Start with one message + $this->cache->shouldReceive('set')->once()->with($messageKey, [$existingMsg, $notification->toArray()], CACHE_TTL_MGR)->andReturn(true); + + $this->stateManager->queueMessage(TEST_CLIENT_ID_MGR, $notification); +}); + +test('queueMessage handles array of messages', function () { + $messageKey = getMgrCacheKey('messages', TEST_CLIENT_ID_MGR); + $notification1 = new Notification('2.0', 'msg1'); + $notification2 = new Notification('2.0', 'msg2'); + $messages = [$notification1, $notification2]; + $expectedData = [$notification1->toArray(), $notification2->toArray()]; + + $this->cache->shouldReceive('get')->once()->with($messageKey, [])->andReturn([]); + $this->cache->shouldReceive('set')->once()->with($messageKey, $expectedData, CACHE_TTL_MGR)->andReturn(true); + + $this->stateManager->queueMessage(TEST_CLIENT_ID_MGR, $messages); +}); + +test('getQueuedMessages returns empty array if cache unavailable', function () { + expect($this->stateManagerNoCache->getQueuedMessages(TEST_CLIENT_ID_MGR))->toBe([]); +}); + +test('getQueuedMessages retrieves and deletes messages from cache', function () { + $messageKey = getMgrCacheKey('messages', TEST_CLIENT_ID_MGR); + $messagesData = [['method' => 'msg1'], ['method' => 'msg2']]; + + $this->cache->shouldReceive('get')->once()->with($messageKey, [])->andReturn($messagesData); + $this->cache->shouldReceive('delete')->once()->with($messageKey)->andReturn(true); + + $retrieved = $this->stateManager->getQueuedMessages(TEST_CLIENT_ID_MGR); + expect($retrieved)->toEqual($messagesData); + + // Verify cache is now empty + $this->cache->shouldReceive('get')->once()->with($messageKey, [])->andReturn([]); + expect($this->stateManager->getQueuedMessages(TEST_CLIENT_ID_MGR))->toBe([]); +}); + +// --- Client Management --- +test('cleanupClient logs warning if cache unavailable', function () { + $this->logger->shouldReceive('warning')->once()->with(Mockery::pattern('/Cannot perform full client cleanup.*cache not available/'), Mockery::any()); + $this->stateManagerNoCache->cleanupClient(TEST_CLIENT_ID_MGR); +}); + +test('cleanupClient removes client data and optionally from active list', function ($removeFromActive) { + $clientId = 'client-mgr-remove'; + $clientSubKey = getMgrCacheKey('client_subscriptions', $clientId); + $activeKey = getMgrCacheKey('active_clients'); + $initialActive = [$clientId => time(), 'other' => time()]; + $keysToDelete = [ + getMgrCacheKey('initialized', $clientId), getMgrCacheKey('client_info', $clientId), + getMgrCacheKey('protocol_version', $clientId), getMgrCacheKey('messages', $clientId), + ]; + + // Assume no subs for simplicity + $this->cache->shouldReceive('get')->once()->with($clientSubKey, [])->andReturn([]); + + if ($removeFromActive) { + $this->cache->shouldReceive('get')->once()->with($activeKey, [])->andReturn($initialActive); + $this->cache->shouldReceive('set')->once()->with($activeKey, Mockery::on(fn ($arg) => ! isset($arg[$clientId])), CACHE_TTL_MGR)->andReturn(true); + } else { + $this->cache->shouldNotReceive('get')->with($activeKey, []); + $this->cache->shouldNotReceive('set')->with($activeKey, Mockery::any(), Mockery::any()); + } + + $this->cache->shouldReceive('deleteMultiple')->once()->with(Mockery::on(function ($arg) use ($keysToDelete) { + return is_array($arg) && empty(array_diff($keysToDelete, $arg)) && empty(array_diff($arg, $keysToDelete)); + }))->andReturn(true); + + $this->stateManager->cleanupClient($clientId, $removeFromActive); + +})->with([ + 'Remove From Active List' => [true], + 'Keep In Active List' => [false], +]); + +test('updateClientActivity does nothing if cache unavailable', function () { + $this->cache->shouldNotReceive('get'); + $this->cache->shouldNotReceive('set'); + $this->stateManagerNoCache->updateClientActivity(TEST_CLIENT_ID_MGR); +}); + +test('updateClientActivity updates timestamp in cache', function () { + $activeKey = getMgrCacheKey('active_clients'); + $startTime = time(); + + $this->cache->shouldReceive('get')->once()->with($activeKey, [])->andReturn(['other' => $startTime - 10]); + $this->cache->shouldReceive('set')->once()->with($activeKey, Mockery::on(function ($arg) use ($startTime) { + return isset($arg[TEST_CLIENT_ID_MGR]) && $arg[TEST_CLIENT_ID_MGR] >= $startTime; + }), CACHE_TTL_MGR)->andReturn(true); + + $this->stateManager->updateClientActivity(TEST_CLIENT_ID_MGR); +}); + +test('getActiveClients returns empty array if cache unavailable', function () { + expect($this->stateManagerNoCache->getActiveClients())->toBe([]); +}); + +test('getActiveClients filters inactive and cleans up', function () { + $activeKey = getMgrCacheKey('active_clients'); + $clientActive1 = 'client-mgr-active1'; + $clientActive2 = 'client-mgr-active2'; + $clientInactive = 'client-mgr-inactive'; + $clientInvalidTs = 'client-mgr-invalid-ts'; + $now = time(); + $activeClientsData = [ + $clientActive1 => $now - 10, + $clientActive2 => $now - CACHE_TTL_MGR + 10, // Still active relative to default threshold + $clientInactive => $now - CACHE_TTL_MGR - 1, // Inactive + $clientInvalidTs => 'not-a-timestamp', // Invalid data + ]; + $expectedFinalActiveList = [ + $clientActive1 => $activeClientsData[$clientActive1], + $clientActive2 => $activeClientsData[$clientActive2], + ]; + + // 1. Initial get for filtering + $this->cache->shouldReceive('get')->once()->with($activeKey, [])->andReturn($activeClientsData); + // 2. Set the filtered list back (inactive and invalid removed) + $this->cache->shouldReceive('set')->once()->with($activeKey, $expectedFinalActiveList, CACHE_TTL_MGR)->andReturn(true); + // 3. Cleanup for inactive client + $this->cache->shouldReceive('get')->once()->with(getMgrCacheKey('client_subscriptions', $clientInactive), [])->andReturn([]); + $this->cache->shouldReceive('deleteMultiple')->once()->with(Mockery::on(fn ($keys) => in_array(getMgrCacheKey('initialized', $clientInactive), $keys)))->andReturn(true); + // 4. Cleanup for client with invalid timestamp + $this->cache->shouldReceive('get')->once()->with(getMgrCacheKey('client_subscriptions', $clientInvalidTs), [])->andReturn([]); + $this->cache->shouldReceive('deleteMultiple')->once()->with(Mockery::on(fn ($keys) => in_array(getMgrCacheKey('initialized', $clientInvalidTs), $keys)))->andReturn(true); + + $active = $this->stateManager->getActiveClients(CACHE_TTL_MGR); // Use TTL as threshold for testing + + expect($active)->toEqualCanonicalizing([$clientActive1, $clientActive2]); +}); + +test('getLastActivityTime returns null if cache unavailable', function () { + expect($this->stateManagerNoCache->getLastActivityTime(TEST_CLIENT_ID_MGR))->toBeNull(); +}); + +test('getLastActivityTime returns timestamp or null from cache', function () { + $activeKey = getMgrCacheKey('active_clients'); + $now = time(); + $cacheData = [TEST_CLIENT_ID_MGR => $now - 50, 'other' => $now - 100]; + + $this->cache->shouldReceive('get')->with($activeKey, [])->times(3)->andReturn($cacheData); + + expect($this->stateManager->getLastActivityTime(TEST_CLIENT_ID_MGR))->toBe($now - 50); + expect($this->stateManager->getLastActivityTime('other'))->toBe($now - 100); + expect($this->stateManager->getLastActivityTime('nonexistent'))->toBeNull(); +}); diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php new file mode 100644 index 0000000..b6fa4c3 --- /dev/null +++ b/tests/Unit/ConfigurationTest.php @@ -0,0 +1,105 @@ +name = 'TestServer'; + $this->version = '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); + // Create a default Capabilities object for testing + $this->capabilities = Capabilities::forServer(); +}); + +afterEach(function () { + Mockery::close(); +}); + +it('constructs configuration object with all properties', function () { + $ttl = 1800; + // Pass the capabilities object to the constructor + $config = new Configuration( + serverName: $this->name, + serverVersion: $this->version, + capabilities: $this->capabilities, // Pass capabilities + logger: $this->logger, + loop: $this->loop, + cache: $this->cache, + container: $this->container, + definitionCacheTtl: $ttl + ); + + expect($config->serverName)->toBe($this->name); + expect($config->serverVersion)->toBe($this->version); + expect($config->capabilities)->toBe($this->capabilities); // Assert 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); +}); + +it('constructs configuration object with default TTL', function () { + // Pass capabilities object + $config = new Configuration( + serverName: $this->name, + serverVersion: $this->version, + capabilities: $this->capabilities, // Pass capabilities + logger: $this->logger, + loop: $this->loop, + cache: $this->cache, + container: $this->container + // No TTL provided + ); + + expect($config->definitionCacheTtl)->toBe(3600); // Default value +}); + +it('constructs configuration object with null cache', function () { + // Pass capabilities object + $config = new Configuration( + serverName: $this->name, + serverVersion: $this->version, + capabilities: $this->capabilities, // Pass capabilities + logger: $this->logger, + loop: $this->loop, + cache: null, // Explicitly null cache + container: $this->container + ); + + expect($config->cache)->toBeNull(); +}); + +it('constructs configuration object with specific capabilities', function () { + // Create specific capabilities + $customCaps = Capabilities::forServer( + resourcesSubscribe: true, + loggingEnabled: true, + instructions: 'Use wisely.' + ); + + $config = new Configuration( + serverName: $this->name, + serverVersion: $this->version, + capabilities: $customCaps, // Pass custom capabilities + logger: $this->logger, + loop: $this->loop, + cache: null, + container: $this->container + ); + + expect($config->capabilities)->toBe($customCaps); + expect($config->capabilities->resourcesSubscribe)->toBeTrue(); + expect($config->capabilities->loggingEnabled)->toBeTrue(); + expect($config->capabilities->instructions)->toBe('Use wisely.'); +}); diff --git a/tests/Definitions/PromptDefinitionTest.php b/tests/Unit/Definitions/PromptDefinitionTest.php similarity index 97% rename from tests/Definitions/PromptDefinitionTest.php rename to tests/Unit/Definitions/PromptDefinitionTest.php index fd48af5..b2d363c 100644 --- a/tests/Definitions/PromptDefinitionTest.php +++ b/tests/Unit/Definitions/PromptDefinitionTest.php @@ -1,6 +1,6 @@ getDocComment() ?: null; // Read the actual summary from the stub file - $stubContent = file_get_contents(__DIR__.'/../Mocks/DiscoveryStubs/AllElementsStub.php'); + $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; @@ -107,7 +107,7 @@ className: AllElementsStub::class, test('fromReflection handles missing docblock summary', function () { // Arrange $reflectionMethod = new ReflectionMethod(ToolOnlyStub::class, 'tool1'); - $attribute = new McpPrompt; + $attribute = new McpPrompt(); $docComment = $reflectionMethod->getDocComment() ?: null; // Mock parser diff --git a/tests/Definitions/ResourceDefinitionTest.php b/tests/Unit/Definitions/ResourceDefinitionTest.php similarity index 98% rename from tests/Definitions/ResourceDefinitionTest.php rename to tests/Unit/Definitions/ResourceDefinitionTest.php index 31f38ae..7b2275c 100644 --- a/tests/Definitions/ResourceDefinitionTest.php +++ b/tests/Unit/Definitions/ResourceDefinitionTest.php @@ -1,6 +1,6 @@ getDocComment() ?: null; // Read the actual summary from the stub file - $stubContent = file_get_contents(__DIR__.'/../Mocks/DiscoveryStubs/AllElementsStub.php'); + $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; diff --git a/tests/Definitions/ResourceTemplateDefinitionTest.php b/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php similarity index 98% rename from tests/Definitions/ResourceTemplateDefinitionTest.php rename to tests/Unit/Definitions/ResourceTemplateDefinitionTest.php index 7172bd2..d8eafaa 100644 --- a/tests/Definitions/ResourceTemplateDefinitionTest.php +++ b/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php @@ -1,6 +1,6 @@ getDocComment() ?: null; // Read the actual summary from the stub file - $stubContent = file_get_contents(__DIR__.'/../Mocks/DiscoveryStubs/AllElementsStub.php'); + $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; diff --git a/tests/Definitions/ToolDefinitionTest.php b/tests/Unit/Definitions/ToolDefinitionTest.php similarity index 97% rename from tests/Definitions/ToolDefinitionTest.php rename to tests/Unit/Definitions/ToolDefinitionTest.php index 07b754c..ff1c278 100644 --- a/tests/Definitions/ToolDefinitionTest.php +++ b/tests/Unit/Definitions/ToolDefinitionTest.php @@ -1,6 +1,6 @@ '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'); + $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 @@ -108,7 +108,7 @@ className: AllElementsStub::class, test('fromReflection handles missing docblock summary', function () { // Arrange $reflectionMethod = new ReflectionMethod(ToolOnlyStub::class, 'tool1'); - $attribute = new McpTool; + $attribute = new McpTool(); $expectedSchema = ['type' => 'object', 'properties' => []]; // tool1 has no params $docComment = $reflectionMethod->getDocComment() ?: null; // Will be null/empty diff --git a/tests/JsonRpc/BatchTest.php b/tests/Unit/JsonRpc/BatchTest.php similarity index 94% rename from tests/JsonRpc/BatchTest.php rename to tests/Unit/JsonRpc/BatchTest.php index 5ae8900..cb4c902 100644 --- a/tests/JsonRpc/BatchTest.php +++ b/tests/Unit/JsonRpc/BatchTest.php @@ -1,11 +1,11 @@ '2.0', 'id' => 1, 'method' => 'test.method1', + 'params' => [], ], [ 'jsonrpc' => '2.0', 'method' => 'test.notification', + 'params' => [], ], [ 'jsonrpc' => '2.0', @@ -121,7 +123,7 @@ }); test('fromArray throws exception for empty array', function () { - expect(fn () => Batch::fromArray([]))->toThrow(McpException::class); + expect(fn () => Batch::fromArray([]))->toThrow(ProtocolException::class); }); test('fromArray throws exception for non-array item', function () { @@ -134,7 +136,7 @@ 'not an array', ]; - expect(fn () => Batch::fromArray($data))->toThrow(McpException::class); + expect(fn () => Batch::fromArray($data))->toThrow(ProtocolException::class); }); test('toArray returns array of request representations', function () { diff --git a/tests/JsonRpc/ErrorTest.php b/tests/Unit/JsonRpc/ErrorTest.php similarity index 97% rename from tests/JsonRpc/ErrorTest.php rename to tests/Unit/JsonRpc/ErrorTest.php index e28d85b..ab297d1 100644 --- a/tests/JsonRpc/ErrorTest.php +++ b/tests/Unit/JsonRpc/ErrorTest.php @@ -1,6 +1,6 @@ 'value1']); @@ -33,7 +33,6 @@ expect($notification->params)->toBe([]); }); - test('fromArray creates valid notification from complete data', function () { $data = [ 'jsonrpc' => '2.0', @@ -59,38 +58,29 @@ expect($notification->params)->toBe([]); }); -test('fromArray throws exception for invalid jsonrpc version', function () { - $data = [ - 'jsonrpc' => '1.0', - 'method' => 'test.method', - ]; - - expect(fn () => Notification::fromArray($data))->toThrow(McpException::class); +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 exception for missing jsonrpc', function () { - $data = [ - 'method' => 'test.method', - ]; - - expect(fn () => Notification::fromArray($data))->toThrow(McpException::class); +test('fromArray throws ProtocolException for missing jsonrpc', function () { + $data = ['method' => 'test.method']; + expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); -test('fromArray throws exception for missing method', function () { - $data = [ - 'jsonrpc' => '2.0', - ]; - - expect(fn () => Notification::fromArray($data))->toThrow(McpException::class); +test('fromArray throws ProtocolException for missing method', function () { + $data = ['jsonrpc' => '2.0']; + expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); -test('fromArray throws exception for non-string method', function () { - $data = [ - 'jsonrpc' => '2.0', - 'method' => 123, - ]; +test('fromArray throws ProtocolException for non-string method', function () { + $data = ['jsonrpc' => '2.0', 'method' => 123]; + expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED +}); - expect(fn () => Notification::fromArray($data))->toThrow(McpException::class); +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 () { diff --git a/tests/JsonRpc/RequestTest.php b/tests/Unit/JsonRpc/RequestTest.php similarity index 58% rename from tests/JsonRpc/RequestTest.php rename to tests/Unit/JsonRpc/RequestTest.php index 67eb35f..9290dfc 100644 --- a/tests/JsonRpc/RequestTest.php +++ b/tests/Unit/JsonRpc/RequestTest.php @@ -1,9 +1,9 @@ 'value1']); @@ -54,62 +54,35 @@ expect($request->params)->toBe([]); }); -test('fromArray throws exception for invalid jsonrpc version', function () { - $data = [ - 'jsonrpc' => '1.0', - 'id' => 1, - 'method' => 'test.method', - ]; - - expect(fn () => Request::fromArray($data))->toThrow(McpException::class); +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 exception for missing jsonrpc', function () { - $data = [ - 'id' => 1, - 'method' => 'test.method', - ]; - - expect(fn () => Request::fromArray($data))->toThrow(McpException::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 exception for missing method', function () { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 1, - ]; - - expect(fn () => Request::fromArray($data))->toThrow(McpException::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 exception for non-string method', function () { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 123, - ]; - - expect(fn () => Request::fromArray($data))->toThrow(McpException::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 exception for missing id', function () { - $data = [ - 'jsonrpc' => '2.0', - 'method' => 'test.method', - ]; - - expect(fn () => Request::fromArray($data))->toThrow(McpException::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 exception for non-array params', function () { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'test.method', - 'params' => 'invalid', - ]; - - expect(fn () => Request::fromArray($data))->toThrow(McpException::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 () { diff --git a/tests/Unit/JsonRpc/ResponseTest.php b/tests/Unit/JsonRpc/ResponseTest.php new file mode 100644 index 0000000..f3c6dc9 --- /dev/null +++ b/tests/Unit/JsonRpc/ResponseTest.php @@ -0,0 +1,284 @@ +jsonrpc)->toBe('2.0'); + expect($response->id)->toBe(1); + expect($response->result)->toBeInstanceOf(EmptyResult::class); // Constructor stores what's passed + 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); // Pass null ID if applicable + + 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); // Null ID allowed with 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); // Factory still takes Result object + + expect($response->jsonrpc)->toBe('2.0'); + expect($response->id)->toBe(1); + expect($response->result)->toBeInstanceOf(EmptyResult::class); // Stores the Result object + expect($response->error)->toBeNull(); +}); + +test('error static method creates error response', function () { + $error = new Error(100, 'Test error'); + $response = Response::error($error, 1); // With ID + + 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); // Null ID + + expect($response->jsonrpc)->toBe('2.0'); + expect($response->id)->toBeNull(); + expect($response->result)->toBeNull(); + expect($response->error)->toBeInstanceOf(Error::class); +}); + +// --- Status Check Tests (Unchanged) --- + +test('isSuccess returns true for success response', function () { + $result = new EmptyResult(); + $response = Response::success($result, 1); // Use factory + 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]]; // Example raw result + $data = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => $rawResultData, // Use raw data here + ]; + + $response = Response::fromArray($data); + + expect($response->jsonrpc)->toBe('2.0'); + expect($response->id)->toBe(1); + // *** Assert the RAW result data is stored *** + expect($response->result)->toEqual($rawResultData); + expect($response->result)->not->toBeInstanceOf(Result::class); // It shouldn't be a Result object yet + 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, // Explicit null ID + '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]; // Missing error + 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']]; // Has result with null ID + // Need to adjust mock data to pass initial checks if both present + // Let's test the case where only result is present with null ID + $dataOnlyResult = ['jsonrpc' => '2.0', 'id' => null, 'result' => 'abc']; + expect(fn () => Response::fromArray($dataOnlyResult)) + ->toThrow(ProtocolException::class, 'must contain "error" when ID is null'); // Constructor check catches this via wrapper +}); + +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'); // Message includes details from Error::fromArray +}); + +// --- toArray / jsonSerialize Tests (Updated) --- + +test('toArray returns correct structure for success response with raw result', function () { + // Create response with raw data (as if from ::fromArray) + $rawResult = ['some' => 'data']; + $response = new Response('2.0', 1, $rawResult); // Direct construction with raw data + + $array = $response->toArray(); + + // toArray should output the raw result directly + expect($array)->toBe([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => $rawResult, // Expect raw data + ]); +}); + +test('toArray returns correct structure when using success factory (with Result obj)', function () { + // Create response using ::success factory + $resultObject = new EmptyResult(); + $response = Response::success($resultObject, 1); + + $array = $response->toArray(); + + // toArray should call toArray() on the Result object + expect($array)->toBe([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [], // Expect result of EmptyResult::toArray() + ]); +}); + +test('toArray returns correct structure for error response', function () { + $error = new Error(100, 'Test error'); + $response = Response::error($error, 1); // Use factory + + $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); // Use factory with null ID + + $array = $response->toArray(); + + expect($array)->toBe([ + 'jsonrpc' => '2.0', + 'id' => null, // ID should be 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/JsonRpc/ResultTest.php b/tests/Unit/JsonRpc/ResultTest.php similarity index 96% rename from tests/JsonRpc/ResultTest.php rename to tests/Unit/JsonRpc/ResultTest.php index 6084efa..67318da 100644 --- a/tests/JsonRpc/ResultTest.php +++ b/tests/Unit/JsonRpc/ResultTest.php @@ -1,6 +1,6 @@ expect($response)->toBeInstanceOf(Response::class); + test()->expect($response->id)->toBe($id); + test()->expect($response->result)->toBeNull(); + test()->expect($response->error)->toBeInstanceOf(JsonRpcError::class); // Use alias + 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); + + // Create a default Configuration object for tests + $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 + ); + + // Default registry state (empty) + $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(); + + // Default transport state (not initialized) + $this->clientStateManagerMock->allows('isInitialized')->with(CLIENT_ID_PROC)->andReturn(false)->byDefault(); + + $this->processor = new Processor( + $this->configuration, + $this->registryMock, + $this->clientStateManagerMock, + $this->containerMock, + $this->schemaValidatorMock, + $this->argumentPreparerMock + ); +}); + +// createRequest, createNotification, expectMcpErrorResponse helpers remain the same + +// --- Tests Start Here --- + +test('constructor receives dependencies', function () { + expect($this->processor)->toBeInstanceOf(Processor::class); +}); + +// --- Initialize Tests (Updated capabilities check) --- + +test('handleInitialize succeeds with valid parameters', 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 + ); + // Re-create processor with updated config for this test + $this->processor = new Processor($this->configuration, $this->registryMock, $this->clientStateManagerMock, $this->containerMock, $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'); +}); + +// Other initialize tests (missing params, etc.) remain largely the same logic + +test('handleNotificationInitialized marks client as initialized and returns null', 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(); +}); + +test('process 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); // Check correct code + expect($response->error->message)->toContain('Client not initialized'); +})->with([ + 'tools/list', 'tools/call', 'resources/list', // etc. +]); + +test('process 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 Processor($this->configuration, $this->registryMock, $this->clientStateManagerMock, $this->containerMock, $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]], +]); + +test('handlePing succeeds 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); +}); + +test('handleToolList returns 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 +}); + +// Other list tests (empty, pagination) remain similar logic, just limit is fixed + +// --- Action Methods (Unchanged Logic, Container Usage Verified) --- + +test('handleToolCall uses 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 Processor&MockInterface $processorSpy */ + $processorSpy = Mockery::mock(Processor::class.'[formatToolResult]', [ + $this->configuration, $this->registryMock, $this->clientStateManagerMock, $this->containerMock, + $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); +}); + +// Other action tests (call errors, read resource, get prompt, subscribe) remain similar logic, +// ensuring $this->container->get() is mocked correctly for handler resolution. + +// Test subscribe/logging capability checks (if flags were added to Configuration VO) +// test('handleResourceSubscribe fails if capability flag false', function() { ... }); +// test('handleLoggingSetLevel fails if capability flag false', function() { ... }); diff --git a/tests/Unit/ProtocolHandlerTest.php b/tests/Unit/ProtocolHandlerTest.php new file mode 100644 index 0000000..2da7642 --- /dev/null +++ b/tests/Unit/ProtocolHandlerTest.php @@ -0,0 +1,236 @@ +processor = Mockery::mock(Processor::class); + $this->clientStateManager = Mockery::mock(ClientStateManager::class); + /** @var MockInterface&LoggerInterface */ + $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); + $this->loop = Loop::get(); + $this->transport = Mockery::mock(ServerTransportInterface::class); + + $this->handler = new ProtocolHandler( + $this->processor, + $this->clientStateManager, + $this->logger, + $this->loop + ); + + $this->transport->shouldReceive('on')->withAnyArgs()->byDefault(); + $this->transport->shouldReceive('removeListener')->withAnyArgs()->byDefault(); + $this->transport->shouldReceive('sendToClientAsync') + ->withAnyArgs() + ->andReturn(resolve(null)) + ->byDefault(); + + $this->handler->bindTransport($this->transport); +}); + +afterEach(function () { + Mockery::close(); +}); + +test('handleRawMessage processes 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()); + + $this->processor->shouldReceive('process')->once()->with(Mockery::type(Request::class), $clientId)->andReturn($expectedResponse); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, $expectedResponseJson."\n")->andReturn(resolve(null)); + + $this->handler->handleRawMessage($rawJson, $clientId); + // Mockery verifies calls +}); + +test('handleRawMessage processes valid notification', function () { + $clientId = 'client-notif-1'; + $method = 'notify/event'; + $params = ['b' => 2]; + $rawJson = json_encode(['jsonrpc' => '2.0', 'method' => $method, 'params' => $params]); + + $this->processor->shouldReceive('process')->once()->with(Mockery::type(Notification::class), $clientId)->andReturn(null); + $this->transport->shouldNotReceive('sendToClientAsync'); + + $this->handler->handleRawMessage($rawJson, $clientId); +}); + +test('handleRawMessage sends parse error response', function () { + $clientId = 'client-err-parse'; + $rawJson = '{"jsonrpc":"2.0", "id":'; + + $this->processor->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->handler->handleRawMessage($rawJson, $clientId); +}); + +test('handleRawMessage sends invalid request error response', function () { + $clientId = 'client-err-invalid'; + $rawJson = '{"jsonrpc":"2.0", "id": 456}'; // Missing method + + $this->processor->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->handler->handleRawMessage($rawJson, $clientId); +}); + +test('handleRawMessage sends McpError response', function () { + $clientId = 'client-err-mcp'; + $requestId = 789; + $method = 'nonexistent/method'; + $rawJson = json_encode(['jsonrpc' => '2.0', 'id' => $requestId, 'method' => $method]); + $mcpException = McpServerException::methodNotFound($method); + + $this->processor->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->handler->handleRawMessage($rawJson, $clientId); +}); + +test('handleRawMessage sends 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'); + + $this->processor->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->handler->handleRawMessage($rawJson, $clientId); +}); + +// --- Test Event Handlers (Now call the handler directly) --- + +test('handleClientConnected logs info', function () { + $clientId = 'client-connect-test'; + $this->logger->shouldReceive('info')->once()->with('Client connected', ['clientId' => $clientId]); + $this->handler->handleClientConnected($clientId); // Call method directly +}); + +test('handleClientDisconnected cleans up state', function () { + $clientId = 'client-disconnect-test'; + $reason = 'Connection closed by peer'; + + $this->logger->shouldReceive('info')->once()->with('Client disconnected', ['clientId' => $clientId, 'reason' => $reason]); + $this->clientStateManager->shouldReceive('cleanupClient')->once()->with($clientId); + + $this->handler->handleClientDisconnected($clientId, $reason); // Call method directly +}); + +test('handleTransportError cleans up client state', function () { + $clientId = 'client-transporterror-test'; + $error = new \RuntimeException('Socket error'); + + $this->logger->shouldReceive('error')->once()->with('Transport error for client', Mockery::any()); + $this->clientStateManager->shouldReceive('cleanupClient')->once()->with($clientId); + + $this->handler->handleTransportError($error, $clientId); // Call method directly +}); + +test('handleTransportError logs general error', function () { + $error = new \RuntimeException('Listener setup failed'); + + $this->logger->shouldReceive('error')->once()->with('General transport error', Mockery::any()); + $this->clientStateManager->shouldNotReceive('cleanupClient'); + + $this->handler->handleTransportError($error, null); // Call method directly +}); + +// --- Test Binding/Unbinding (Unchanged) --- + +test('bindTransport attaches listeners', function () { + $newTransport = Mockery::mock(ServerTransportInterface::class); + $newTransport->shouldReceive('on')->times(4); + $this->handler->bindTransport($newTransport); + expect(true)->toBeTrue(); +}); + +test('unbindTransport removes listeners', function () { + $this->transport->shouldReceive('on')->times(4); + $this->handler->bindTransport($this->transport); + $this->transport->shouldReceive('removeListener')->times(4); + $this->handler->unbindTransport(); + expect(true)->toBeTrue(); +}); + +test('reBindTransport unbinds previous', function () { + $transport1 = Mockery::mock(ServerTransportInterface::class); + $transport2 = Mockery::mock(ServerTransportInterface::class); + $transport1->shouldReceive('on')->times(4); + $this->handler->bindTransport($transport1); + $transport1->shouldReceive('removeListener')->times(4); + $transport2->shouldReceive('on')->times(4); + $this->handler->bindTransport($transport2); + expect(true)->toBeTrue(); +}); + +// --- Test sendNotification (Updated to use await) --- + +test('sendNotification encodes and sends', 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"; + + $this->transport->shouldReceive('sendToClientAsync') + ->once() + ->with($clientId, $expectedFrame) + ->andReturn(resolve(null)); + + $promise = $this->handler->sendNotification($clientId, $notification); + await($promise); + + expect(true)->toBeTrue(); + +})->group('usesLoop'); + +test('sendNotification rejects on encoding error', function () { + $clientId = 'client-send-notif-err'; + $resource = fopen('php://memory', 'r'); // Unencodable resource + $notification = new Notification('2.0', 'bad/data', ['res' => $resource]); + + $this->transport->shouldNotReceive('sendToClientAsync'); + + // Act + $promise = $this->handler->sendNotification($clientId, $notification); + await($promise); + + if (is_resource($resource)) { + fclose($resource); + } + +})->group('usesLoop')->throws(McpServerException::class, 'Failed to encode notification'); + +test('sendNotification rejects if transport not bound', function () { + $this->handler->unbindTransport(); + $notification = new Notification('2.0', 'test'); + + $promise = $this->handler->sendNotification('client-id', $notification); + await($promise); + +})->throws(McpServerException::class, 'Transport not bound'); diff --git a/tests/Unit/RegistryTest.php b/tests/Unit/RegistryTest.php new file mode 100644 index 0000000..55062b3 --- /dev/null +++ b/tests/Unit/RegistryTest.php @@ -0,0 +1,408 @@ + 'object']); +} +function createTestResource(string $uri = 'test://res', string $name = 'test-res'): ResourceDefinition +{ + return new ResourceDefinition('TestClass', 'resourceMethod', $uri, $name, 'Desc '.$name, 'text/plain', 100, []); +} +function createTestPrompt(string $name = 'test-prompt'): PromptDefinition +{ + return new PromptDefinition('TestClass', 'promptMethod', $name, 'Desc '.$name, []); +} +function createTestTemplate(string $uriTemplate = 'tmpl://{id}', string $name = 'test-tmpl'): ResourceTemplateDefinition +{ + return new ResourceTemplateDefinition('TestClass', 'templateMethod', $uriTemplate, $name, 'Desc '.$name, 'application/json', []); +} + +beforeEach(function () { + /** @var MockInterface&LoggerInterface */ + $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); + /** @var MockInterface&CacheInterface */ + $this->cache = Mockery::mock(CacheInterface::class); + + $this->clientStateManager = Mockery::mock(ClientStateManager::class)->shouldIgnoreMissing(); + + // Default cache behaviors + $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(); + + $this->registry = new Registry($this->logger, $this->cache, $this->clientStateManager); + $this->registryNoCache = new Registry($this->logger, null, $this->clientStateManager); +}); + +function getRegistryProperty(Registry $reg, string $propName) +{ + $reflector = new \ReflectionClass($reg); + $prop = $reflector->getProperty($propName); + $prop->setAccessible(true); + + return $prop->getValue($reg); +} + +// --- Basic Registration & Retrieval --- + +test('registers manual tool and marks as manual', function () { + // Arrange + $tool = createTestTool('manual-tool-1'); + + // Act + $this->registry->registerTool($tool, true); // Register as manual + + // 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'); +}); + +test('registers discovered tool', function () { + // Arrange + $tool = createTestTool('discovered-tool-1'); + + // Act + $this->registry->registerTool($tool, false); // Register as discovered + + // Assert + expect($this->registry->findTool('discovered-tool-1'))->toBe($tool); + expect($this->registry->allTools())->toHaveCount(1); + expect(getRegistryProperty($this->registry, 'manualToolNames'))->toBeEmpty(); +}); + +test('registers manual resource and marks as manual', function () { + // Arrange + $res = createTestResource('manual://res/1'); + + // Act + $this->registry->registerResource($res, true); + + // Assert + expect($this->registry->findResourceByUri('manual://res/1'))->toBe($res); + expect(getRegistryProperty($this->registry, 'manualResourceUris'))->toHaveKey('manual://res/1'); +}); + +test('registers discovered resource', function () { + // Arrange + $res = createTestResource('discovered://res/1'); + + // Act + $this->registry->registerResource($res, false); + + // Assert + expect($this->registry->findResourceByUri('discovered://res/1'))->toBe($res); + expect(getRegistryProperty($this->registry, 'manualResourceUris'))->toBeEmpty(); +}); + +test('registers manual prompt and marks as manual', function () { + // Arrange + $prompt = createTestPrompt('manual-prompt'); + + // Act + $this->registry->registerPrompt($prompt, true); + + // Assert + expect($this->registry->findPrompt('manual-prompt'))->toBe($prompt); + expect(getRegistryProperty($this->registry, 'manualPromptNames'))->toHaveKey('manual-prompt'); +}); +test('registers discovered prompt', function () { + // Arrange + $prompt = createTestPrompt('discovered-prompt'); + + // Act + $this->registry->registerPrompt($prompt, false); + + // Assert + expect($this->registry->findPrompt('discovered-prompt'))->toBe($prompt); + expect(getRegistryProperty($this->registry, 'manualPromptNames'))->toBeEmpty(); +}); +test('registers manual template and marks as manual', function () { + // Arrange + $template = createTestTemplate('manual://tmpl/{id}'); + + // Act + $this->registry->registerResourceTemplate($template, true); + + // Assert + expect($this->registry->findResourceTemplateByUri('manual://tmpl/123')['definition'] ?? null)->toBe($template); + expect(getRegistryProperty($this->registry, 'manualTemplateUris'))->toHaveKey('manual://tmpl/{id}'); +}); +test('registers discovered template', function () { + // Arrange + $template = createTestTemplate('discovered://tmpl/{id}'); + + // Act + $this->registry->registerResourceTemplate($template, false); + + // Assert + expect($this->registry->findResourceTemplateByUri('discovered://tmpl/abc')['definition'] ?? null)->toBe($template); + expect(getRegistryProperty($this->registry, 'manualTemplateUris'))->toBeEmpty(); +}); + +test('hasElements returns true if manual elements exist', function () { + // Arrange + expect($this->registry->hasElements())->toBeFalse(); // Starts empty + + // Act + $this->registry->registerTool(createTestTool('manual-only'), true); + + // Assert + expect($this->registry->hasElements())->toBeTrue(); +}); + +test('hasElements returns true if discovered elements exist', function () { + // Arrange + expect($this->registry->hasElements())->toBeFalse(); + + // Act + $this->registry->registerTool(createTestTool('discovered-only'), false); + + // Assert + expect($this->registry->hasElements())->toBeTrue(); +}); + +// --- Registration Precedence --- + +test('manual registration overrides existing discovered element', function () { + // Arrange + $toolName = 'override-test'; + $discoveredTool = createTestTool($toolName); // Version 1 (Discovered) + $manualTool = createTestTool($toolName); // Version 2 (Manual) - different instance + + // Act + $this->registry->registerTool($discoveredTool, false); // Register discovered first + + // Assert + expect($this->registry->findTool($toolName))->toBe($discoveredTool); + + $this->logger->shouldReceive('warning')->with(Mockery::pattern("/Replacing existing discovered tool '{$toolName}' with manual/"))->once(); + + // Act + $this->registry->registerTool($manualTool, true); + + // 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); +}); + +test('discovered element does NOT override existing manual element', function () { + // Arrange + $toolName = 'manual-priority'; + $manualTool = createTestTool($toolName); // Version 1 (Manual) + $discoveredTool = createTestTool($toolName); // Version 2 (Discovered) + + // Act + $this->registry->registerTool($manualTool, true); // Register manual first + + // Assert + expect($this->registry->findTool($toolName))->toBe($manualTool); + + // Expect debug log when ignoring + $this->logger->shouldReceive('debug')->with(Mockery::pattern("/Ignoring discovered tool '{$toolName}' as it conflicts/"))->once(); + + // Attempt to register discovered version + $this->registry->registerTool($discoveredTool, false); + + // 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); +}); + +// --- Caching Logic --- + +test('constructor 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(); +}); + +test('constructor load 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 + + $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); + + // 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 + + // 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); + + // Assert manual version is now present and marked correctly + expect($registry->findTool($conflictName))->toBe($manualTool); + expect(getRegistryProperty($registry, 'manualToolNames'))->toHaveKey($conflictName); +}); + +test('saveDiscoveredElementsToCache only saves non-manual elements', 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); + + $this->cache->shouldReceive('set')->once() + ->with(DISCOVERED_CACHE_KEY, $expectedCachedData) // Expect EXACT filtered data + ->andReturn(true); + + $result = $this->registry->saveDiscoveredElementsToCache(); + expect($result)->toBeTrue(); +}); + +test('loadDiscoveredElementsFromCache ignores non-array cache data', function () { + // Arrange + $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY)->once()->andReturn('invalid string data'); + + // Act + $registry = new Registry($this->logger, $this->cache, $this->clientStateManager); // Load happens here + + // Assert + expect($registry->discoveryRanOrCached())->toBeFalse(); // Marked loaded + expect($registry->hasElements())->toBeFalse(); // But empty +}); + +test('loadDiscoveredElementsFromCache 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); + + // Act + $registry = new Registry($this->logger, $this->cache, $this->clientStateManager); // Load happens here + + // Assert + expect($registry->discoveryRanOrCached())->toBeFalse(); + expect($registry->hasElements())->toBeFalse(); // Hydration failed +}); + +test('clearDiscoveredElements 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 --- + +test('default notifiers send messages via ClientStateManager', function () { + // Arrange + $tool = createTestTool('notify-tool'); + $resource = createTestResource('notify://res'); + $prompt = createTestPrompt('notify-prompt'); + + // Set expectations on the ClientStateManager mock + $this->clientStateManager->shouldReceive('queueMessageForAll')->times(3)->with(Mockery::type(Notification::class)); + + // Act + $this->registry->registerTool($tool); + $this->registry->registerResource($resource); + $this->registry->registerPrompt($prompt); +}); + +test('custom notifiers can be set and are called', function () { + // Arrange + $toolNotifierCalled = false; + $this->registry->setToolsChangedNotifier(function () use (&$toolNotifierCalled) { + $toolNotifierCalled = true; + }); + + $this->clientStateManager->shouldNotReceive('queueMessageForAll'); // Default shouldn't be called + + // Act + $this->registry->registerTool(createTestTool('custom-notify')); + + // Assert + expect($toolNotifierCalled)->toBeTrue(); +}); diff --git a/tests/Unit/ServerBuilderTest.php b/tests/Unit/ServerBuilderTest.php new file mode 100644 index 0000000..ae49c54 --- /dev/null +++ b/tests/Unit/ServerBuilderTest.php @@ -0,0 +1,364 @@ +builder = new ServerBuilder(); + + $this->tempBasePath = sys_get_temp_dir().'/mcp_builder_test_'.bin2hex(random_bytes(4)); + if (! is_dir($this->tempBasePath)) { + @mkdir($this->tempBasePath, 0777, true); + } + + $this->tempCachePath = dirname(__DIR__, 3).'/cache'; + if (! is_dir($this->tempCachePath)) { + @mkdir($this->tempCachePath, 0777, true); + } +}); + +afterEach(function () { + if (! empty($this->tempBasePath) && is_dir($this->tempBasePath)) { + @rmdir($this->tempBasePath); + } + if (! empty($this->tempCachePath) && is_dir($this->tempCachePath)) { + $cacheFiles = glob($this->tempCachePath.'/mcp_server_registry*.cache'); + if ($cacheFiles) { + foreach ($cacheFiles as $file) { + @unlink($file); + } + } + } + Mockery::close(); +}); + +afterAll(function () { + if (! empty($this->tempBasePath) && is_dir($this->tempBasePath)) { + @rmdir($this->tempBasePath); + } +}); + +function getBuilderProperty(ServerBuilder $builder, string $propertyName) +{ + $reflector = new ReflectionClass($builder); + $property = $reflector->getProperty($propertyName); + $property->setAccessible(true); + + return $property->getValue($builder); +} + +// --- Configuration Method Tests --- + +it('sets server info', function () { + $this->builder->withServerInfo('MyServer', '1.2.3'); + expect(getBuilderProperty($this->builder, 'name'))->toBe('MyServer'); + expect(getBuilderProperty($this->builder, 'version'))->toBe('1.2.3'); +}); + +it('sets capabilities', function () { + $capabilities = Capabilities::forServer(); // Use static factory + $this->builder->withCapabilities($capabilities); + expect(getBuilderProperty($this->builder, 'capabilities'))->toBe($capabilities); +}); + +it('sets logger', function () { + $logger = Mockery::mock(LoggerInterface::class); + $this->builder->withLogger($logger); + expect(getBuilderProperty($this->builder, 'logger'))->toBe($logger); +}); + +it('sets cache and TTL', 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); +}); + +it('sets cache with default TTL', 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 +}); + +it('sets container', function () { + $container = Mockery::mock(ContainerInterface::class); + $this->builder->withContainer($container); + expect(getBuilderProperty($this->builder, 'container'))->toBe($container); +}); + +it('sets loop', function () { + $loop = Mockery::mock(LoopInterface::class); + $this->builder->withLoop($loop); + expect(getBuilderProperty($this->builder, 'loop'))->toBe($loop); +}); + +// --- Manual Registration Storage Tests --- + +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]); +}); + +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); +}); + +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); +}); + +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); +}); + +// --- Build Method Validation Tests --- + +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(); +})->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'], + ]); + +// --- Default Dependency Resolution Tests --- + +test('build resolves default Logger correctly', function () { + $server = $this->builder + ->withServerInfo('Test', '1.0') + ->withTool([DummyHandlerClass::class, 'handle']) + ->build(); + expect($server->getConfiguration()->logger)->toBeInstanceOf(NullLogger::class); +}); + +test('build resolves default Loop correctly', function () { + $server = $this->builder + ->withServerInfo('Test', '1.0') + ->withTool([DummyHandlerClass::class, 'handle']) + ->build(); + expect($server->getConfiguration()->loop)->toBeInstanceOf(LoopInterface::class); +}); + +test('build resolves default Container correctly', function () { + $server = $this->builder + ->withServerInfo('Test', '1.0') + ->withTool([DummyHandlerClass::class, 'handle']) + ->build(); + expect($server->getConfiguration()->container)->toBeInstanceOf(BasicContainer::class); +}); + +it('resolves Cache to null if default directory not writable', function () { + $unwritableDir = '/path/to/non/writable/dir_'.uniqid(); + // Need to ensure the internal default path logic points somewhere bad, + // or mock the is_writable checks - which is hard. + // Let's test the outcome: logger warning and null cache in config. + $logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); + $logger->shouldReceive('warning') + ->with(Mockery::pattern('/Default cache directory not found or not writable|Failed to initialize default FileCache/'), Mockery::any()) + ->once(); + // We can't easily *force* the default path to be unwritable without modifying the code under test, + // so we rely on the fact that if the `new FileCache` fails or the dir check fails, + // the builder will log a warning and proceed with null cache. + // This test mainly verifies the builder *calls* the logger on failure. + + $builder = $this->builder + ->withServerInfo('Test', '1.0') + ->withLogger($logger) // Inject mock logger + ->withTool([DummyHandlerClass::class, 'handle']); + + // Manually set the internal cache to null *before* build to simulate failed default creation path + $reflector = new ReflectionClass($builder); + $cacheProp = $reflector->getProperty('cache'); + $cacheProp->setAccessible(true); + $cacheProp->setValue($builder, null); + // Force internal logic path by temporarily making default dir unwritable if possible + $originalPerms = fileperms($this->tempCachePath); + @chmod($this->tempCachePath, 0444); // Try making read-only + + $server = $builder->build(); + + @chmod($this->tempCachePath, $originalPerms); // Restore permissions + + // We expect the logger warning was triggered and cache is null + expect($server->getConfiguration()->cache)->toBeNull(); +}); + +test('build uses provided dependencies over defaults', 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); + + $server = $this->builder + ->withServerInfo('CustomDeps', '1.0') + ->withLoop($myLoop) + ->withLogger($myLogger) + ->withContainer($myContainer) + ->withCache($myCache) + ->withCapabilities($myCaps) + ->withTool([DummyHandlerClass::class, 'handle']) // Add element + ->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); +}); + +// --- Tests for build() success and manual registration --- + +it('build successfully creates Server with defaults', function () { + $container = new BasicContainer(); + $container->set(LoggerInterface::class, new NullLogger()); + + $server = $this->builder + ->withServerInfo('BuiltServer', '1.0') + ->withContainer($container) + ->withTool([DummyHandlerClass::class, 'handle'], 'manualTool') + ->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); + +}); // REMOVED skip + +it('build 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); + + $server = $this->builder + ->withServerInfo('CustomServer', '2.0') + ->withLoop($myLoop)->withLogger($myLogger)->withContainer($myContainer) + ->withCache($myCache)->withCapabilities($myCaps) + ->withPrompt(DummyInvokableClass::class) // Add one element + ->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); + +}); // REMOVED skip + +it('build throws DefinitionException if manual tool registration fails', function () { + $container = new BasicContainer(); + $container->set(LoggerInterface::class, new NullLogger()); + + $this->builder + ->withServerInfo('FailRegServer', '1.0') + ->withContainer($container) + // Use a method that doesn't exist on the mock class + ->withTool([DummyHandlerClass::class, 'nonExistentMethod'], 'badTool') + ->build(); + +})->throws(DefinitionException::class, '1 error(s) occurred during manual element registration'); + +it('build 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 + ->build(); + +})->throws(DefinitionException::class, '1 error(s) occurred during manual element registration'); diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php new file mode 100644 index 0000000..7903ce8 --- /dev/null +++ b/tests/Unit/ServerTest.php @@ -0,0 +1,278 @@ +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(); + + $this->configuration = new Configuration( + 'TestServerInstance', + '1.0', + $capabilities, + $this->logger, + $this->loop, + $cache, + $container + ); + + $this->registry = Mockery::mock(Registry::class); + $this->processor = Mockery::mock(Processor::class); + $this->clientStateManager = Mockery::mock(ClientStateManager::class); + + // Instantiate the Server + $this->server = new Server($this->configuration, $this->registry, $this->processor, $this->clientStateManager); + + // Default registry/state manager behaviors + $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(); +}); + +test('provides getters for core components', function () { + expect($this->server->getConfiguration())->toBe($this->configuration); + expect($this->server->getRegistry())->toBe($this->registry); + expect($this->server->getProcessor())->toBe($this->processor); + expect($this->server->getClientStateManager())->toBe($this->clientStateManager); // Updated getter name +}); + +test('gets protocol handler lazily', function () { + $handler1 = $this->server->getProtocolHandler(); + $handler2 = $this->server->getProtocolHandler(); + expect($handler1)->toBeInstanceOf(ProtocolHandler::class); + expect($handler2)->toBe($handler1); +}); +test('discover() skips if already run and not forced', function () { + // Mark discoveryRan as true internally + $reflector = new \ReflectionClass($this->server); + $prop = $reflector->getProperty('discoveryRan'); + $prop->setAccessible(true); + $prop->setValue($this->server, true); + + $this->registry->shouldNotReceive('clearDiscoveredElements'); + $this->registry->shouldNotReceive('saveDiscoveredElementsToCache'); + + $this->server->discover(sys_get_temp_dir()); + + $this->logger->shouldHaveReceived('debug')->with('Discovery skipped: Already run or loaded from cache.'); +}); + +test('discover() clears discovered elements before scan', function () { + $basePath = sys_get_temp_dir(); + + $this->registry->shouldReceive('clearDiscoveredElements')->once()->with(true); // Expect clear(true) + $this->registry->shouldReceive('saveDiscoveredElementsToCache')->once()->andReturn(true); + + $this->server->discover($basePath); + + // Assert discoveryRan flag + $reflector = new \ReflectionClass($this->server); + $prop = $reflector->getProperty('discoveryRan'); + $prop->setAccessible(true); + expect($prop->getValue($this->server))->toBeTrue(); +}); + +test('discover() saves to cache when requested', function () { + // Arrange + $basePath = sys_get_temp_dir(); + + $this->registry->shouldReceive('clearDiscoveredElements')->once()->with(true); + $this->registry->shouldReceive('saveDiscoveredElementsToCache')->once()->andReturn(true); // Expect save + + // Act + $this->server->discover($basePath, saveToCache: true); +}); + +test('discover() does NOT save to cache when requested', function () { + // Arrange + $basePath = sys_get_temp_dir(); + + $this->registry->shouldReceive('clearDiscoveredElements')->once()->with(false); // saveToCache=false -> deleteCacheFile=false + $this->registry->shouldNotReceive('saveDiscoveredElementsToCache'); // Expect NOT to save + + // Act + $this->server->discover($basePath, saveToCache: false); +}); + +test('discover() throws InvalidArgumentException for bad base path', function () { + $this->server->discover('/non/existent/path/for/sure'); +})->throws(\InvalidArgumentException::class); + +test('discover() throws DiscoveryException if discoverer fails', function () { + $basePath = sys_get_temp_dir(); + $exception = new \RuntimeException('Filesystem error'); + + $this->registry->shouldReceive('clearDiscoveredElements')->once(); + $this->registry->shouldReceive('saveDiscoveredElementsToCache')->once()->andThrow($exception); + + $this->server->discover($basePath); + +})->throws(DiscoveryException::class, 'Element discovery failed: Filesystem error'); + +test('discover() 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); + + try { + $this->server->discover($basePath); + } catch (DiscoveryException $e) { + // Expected + } + + $reflector = new \ReflectionClass($this->server); + $prop = $reflector->getProperty('discoveryRan'); + $prop->setAccessible(true); + expect($prop->getValue($this->server))->toBeFalse(); // Should be reset +}); + +// --- listen() Method Tests --- + +test('listen() throws exception if already listening', function () { + $transport = Mockery::mock(ServerTransportInterface::class); + + // Simulate the first listen call succeeding and setting the flag + $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 + + // Make the first call successful + $this->server->listen($transport); + + // Reset mocks for the second call if necessary (though Mockery should handle it) + // Now, mark as listening and try again + $reflector = new \ReflectionClass($this->server); + $prop = $reflector->getProperty('isListening'); + $prop->setAccessible(true); + $prop->setValue($this->server, true); // Ensure flag is set + + // Act & Assert: Second call throws + expect(fn () => $this->server->listen($transport)) + ->toThrow(LogicException::class, 'Server is already listening'); +}); + +test('listen() warns if no elements and discovery not run', function () { + $transport = Mockery::mock(ServerTransportInterface::class); + + $this->registry->shouldReceive('hasElements')->andReturn(false); + + $this->logger->shouldReceive('warning') + ->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->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); + + $this->server->listen($transport); +}); + +test('listen() warns if no elements found AFTER discovery', function () { + $transport = Mockery::mock(ServerTransportInterface::class); + + // Setup: No elements, discoveryRan=true + $this->registry->shouldReceive('hasElements')->andReturn(false); + $reflector = new \ReflectionClass($this->server); + $prop = $reflector->getProperty('discoveryRan'); + $prop->setAccessible(true); + $prop->setValue($this->server, true); + + $this->logger->shouldReceive('warning')->once()->with(Mockery::pattern('/Starting listener, but no MCP elements were found after discovery/')); + + // --- FIX: Allow necessary mock calls --- + $transport->shouldReceive('setLogger', 'setLoop', 'on', 'once', 'removeListener', 'close')->withAnyArgs(); + $transport->shouldReceive('listen')->once(); + $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); + $this->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); + + $this->server->listen($transport); +}); + +test('listen() does not warn if elements are present', function () { + $transport = Mockery::mock(ServerTransportInterface::class); + + // Setup: HAS elements + $this->registry->shouldReceive('hasElements')->andReturn(true); + + $this->logger->shouldNotReceive('warning'); // Expect NO warning + + // --- FIX: Allow necessary mock calls --- + $transport->shouldReceive('setLogger', 'setLoop', 'on', 'once', 'removeListener', 'close')->withAnyArgs(); + $transport->shouldReceive('listen')->once(); + $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); + $this->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); + + $this->server->listen($transport); +}); + +test('listen() injects logger and loop into aware transports', function () { + // --- FIX: Allow necessary mock calls --- + $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(); // Allow emit + $this->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); + + $this->server->listen($transport); +}); + +test('listen() binds protocol handler and starts transport listen', function () { + $transport = Mockery::mock(ServerTransportInterface::class); + // Get the real handler instance but spy on it + $protocolHandlerSpy = Mockery::spy($this->server->getProtocolHandler()); + $reflector = new \ReflectionClass($this->server); + $prop = $reflector->getProperty('protocolHandler'); + $prop->setAccessible(true); + $prop->setValue($this->server, $protocolHandlerSpy); // Inject spy + + // Expectations + $protocolHandlerSpy->shouldReceive('bindTransport')->with($transport)->once(); + // --- FIX: Allow necessary mock calls --- + $transport->shouldReceive('listen')->once(); + $transport->shouldReceive('on', 'once', 'removeListener', 'close')->withAnyArgs(); // Allow listeners + $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); // Allow emit + $this->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); + $protocolHandlerSpy->shouldReceive('unbindTransport')->once(); // Expect unbind on close + + $this->server->listen($transport); + // Mockery verifies expectations +}); diff --git a/tests/Support/ArgumentPreparerTest.php b/tests/Unit/Support/ArgumentPreparerTest.php similarity index 88% rename from tests/Support/ArgumentPreparerTest.php rename to tests/Unit/Support/ArgumentPreparerTest.php index 8058d24..cbaa38f 100644 --- a/tests/Support/ArgumentPreparerTest.php +++ b/tests/Unit/Support/ArgumentPreparerTest.php @@ -1,14 +1,14 @@ '', 'p2' => 0, 'p3' => false, 'p4' => 0.0, 'p5' => [], 'p6' => new stdClass(), // Base values - $paramName => $inputVal // Use $paramName + $paramName => $inputVal, // Use $paramName ]; $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'simpleRequired', $input); @@ -106,31 +106,31 @@ function reflectMethod(string $methodName): ReflectionMethod ['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 + ['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 + $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 - ]); + ->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'); @@ -200,11 +200,11 @@ function reflectMethod(string $methodName): ReflectionMethod $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 -]); + ->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/Support/AttributeFinderTest.php b/tests/Unit/Support/AttributeFinderTest.php similarity index 97% rename from tests/Support/AttributeFinderTest.php rename to tests/Unit/Support/AttributeFinderTest.php index 1b35e45..a64e753 100644 --- a/tests/Support/AttributeFinderTest.php +++ b/tests/Unit/Support/AttributeFinderTest.php @@ -1,6 +1,6 @@ finder = new AttributeFinder(); - // No longer need temp dir setup for these tests - // setupTempDir(); }); -// REMOVED: afterEach for cleanupTempDir - -// REMOVED: getClassFromFile Tests - // --- Class Attribute Tests --- test('getFirstClassAttribute finds first matching attribute', function () { @@ -60,7 +54,6 @@ expect($instanceTwo)->toBeInstanceOf(TestClassOnlyAttribute::class); }); - // --- Method Attribute Tests --- test('getMethodAttributes finds all attributes of a type', function () { diff --git a/tests/Support/DiscovererTest.php b/tests/Unit/Support/DiscovererTest.php similarity index 96% rename from tests/Support/DiscovererTest.php rename to tests/Unit/Support/DiscovererTest.php index e31d829..09a1f50 100644 --- a/tests/Support/DiscovererTest.php +++ b/tests/Unit/Support/DiscovererTest.php @@ -1,8 +1,9 @@ container = Mockery::mock(ContainerInterface::class); $this->registry = Mockery::mock(Registry::class); + /** @var LoggerInterface&MockInterface $logger */ $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $this->container->shouldReceive('get')->with(LoggerInterface::class)->andReturn($this->logger); - - $attributeFinder = new AttributeFinder; - $docBlockParser = new DocBlockParser($this->container); + $attributeFinder = new AttributeFinder(); + $docBlockParser = new DocBlockParser($this->logger); $schemaGenerator = new SchemaGenerator($docBlockParser, $attributeFinder); $this->discoverer = new Discoverer( - $this->container, $this->registry, + $this->logger, $docBlockParser, $schemaGenerator, $attributeFinder, diff --git a/tests/Support/DocBlockParserTest.php b/tests/Unit/Support/DocBlockParserTest.php similarity index 97% rename from tests/Support/DocBlockParserTest.php rename to tests/Unit/Support/DocBlockParserTest.php index a2410bd..dd81c12 100644 --- a/tests/Support/DocBlockParserTest.php +++ b/tests/Unit/Support/DocBlockParserTest.php @@ -1,6 +1,6 @@ containerMock = Mockery::mock(ContainerInterface::class); $this->loggerMock = Mockery::mock(LoggerInterface::class); - $this->containerMock->shouldReceive('get')->with(LoggerInterface::class)->andReturn($this->loggerMock); - - $this->parser = new DocBlockParser($this->containerMock); + $this->parser = new DocBlockParser($this->loggerMock); }); // Helper function to get reflection method diff --git a/tests/Support/SchemaGeneratorTest.php b/tests/Unit/Support/SchemaGeneratorTest.php similarity index 99% rename from tests/Support/SchemaGeneratorTest.php rename to tests/Unit/Support/SchemaGeneratorTest.php index 5c492a0..b638822 100644 --- a/tests/Support/SchemaGeneratorTest.php +++ b/tests/Unit/Support/SchemaGeneratorTest.php @@ -1,6 +1,6 @@ ['type' => 'number'], 'items' => ['type' => 'array', 'items' => ['type' => 'string']], 'nullableValue' => ['type' => ['string', 'null']], - 'optionalValue' => ['type' => 'string'] // Not required + 'optionalValue' => ['type' => 'string'], // Not required ], 'required' => ['name', 'age', 'active', 'score', 'items', 'nullableValue'], 'additionalProperties' => false, @@ -41,7 +41,7 @@ function getValidData(): array 'score' => 99.5, 'items' => ['a', 'b'], 'nullableValue' => null, - 'optionalValue' => 'present' + 'optionalValue' => 'present', ]; } @@ -152,7 +152,6 @@ function getValidData(): array ->and($errors[0]['message'])->toContain('Array must have unique items'); }); - // --- Nested Structures and Pointers --- test('nested object validation error pointer', function () { $schema = [ @@ -176,7 +175,7 @@ function getValidData(): array test('array item validation error pointer', function () { $schema = [ 'type' => 'array', - 'items' => ['type' => 'integer'] + 'items' => ['type' => 'integer'], ]; $data = [1, 2, 'three', 4]; // Invalid item type diff --git a/tests/Support/UriTemplateMatcherTest.php b/tests/Unit/Support/UriTemplateMatcherTest.php similarity index 87% rename from tests/Support/UriTemplateMatcherTest.php rename to tests/Unit/Support/UriTemplateMatcherTest.php index 439c17e..d5bfd67 100644 --- a/tests/Support/UriTemplateMatcherTest.php +++ b/tests/Unit/Support/UriTemplateMatcherTest.php @@ -1,6 +1,6 @@ 'books', 'itemId' => '978-abc'] + ['category' => 'books', 'itemId' => '978-abc'], ], [ 'item/{category}/{itemId}/details', 'item/books//details', // Empty itemId segment - null // Currently matches [^/]+, so empty segment fails + null, // Currently matches [^/]+, so empty segment fails ], - [ + [ 'item/{category}/{itemId}/details', 'item/books/978-abc/summary', // Wrong literal end - null + null, ], - [ + [ 'item/{category}/{itemId}', 'item/tools/hammer', - ['category' => 'tools', 'itemId' => 'hammer'] + ['category' => 'tools', 'itemId' => 'hammer'], ], - [ + [ 'item/{category}/{itemId}', 'item/tools/hammer/extra', // Extra path segment - null + null, ], ]); @@ -61,17 +61,17 @@ [ 'user://{userId}/profile/pic_{picId}.jpg', 'user://kp/profile/pic_main.jpg', - ['userId' => 'kp', 'picId' => 'main'] + ['userId' => 'kp', 'picId' => 'main'], ], [ 'user://{userId}/profile/pic_{picId}.jpg', 'user://kp/profile/pic_main.png', // Wrong extension - null + null, ], - [ + [ 'user://{userId}/profile/img_{picId}.jpg', // Wrong literal prefix 'user://kp/profile/pic_main.jpg', - null + null, ], ]); @@ -86,10 +86,10 @@ } })->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 ], + ['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 () { diff --git a/tests/Traits/ResponseFormatterTest.php b/tests/Unit/Traits/ResponseFormatterTest.php similarity index 99% rename from tests/Traits/ResponseFormatterTest.php rename to tests/Unit/Traits/ResponseFormatterTest.php index 5e71abb..84c38fc 100644 --- a/tests/Traits/ResponseFormatterTest.php +++ b/tests/Unit/Traits/ResponseFormatterTest.php @@ -1,8 +1,9 @@ 'user', 'content' => 'First turn'], - ['role' => 'assistant', 'content' => 'Okay' ], + ['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']], diff --git a/tests/Unit/Transports/HttpServerTransportTest.php b/tests/Unit/Transports/HttpServerTransportTest.php new file mode 100644 index 0000000..cd5333d --- /dev/null +++ b/tests/Unit/Transports/HttpServerTransportTest.php @@ -0,0 +1,418 @@ +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 new file mode 100644 index 0000000..3fcd3c6 --- /dev/null +++ b/tests/Unit/Transports/StdioServerTransportTest.php @@ -0,0 +1,254 @@ +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'); From 6982781a93828f1627bcda16a81f88a916f50821 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Fri, 9 May 2025 23:21:50 +0100 Subject: [PATCH 2/9] refactor(Server): Decouple discovery from build and refine core components - ServerBuilder no longer configures discovery paths or runs discovery automatically during build; discovery is now an explicit step via `Server->discover()`. - `Server::discover()` now accepts path configurations, clears only discovered/cached elements (preserving manual ones), and saves only discovered elements to cache. - `Registry` distinguishes between manually registered and discovered elements: - Manual registrations take precedence over discovered/cached elements with the same identifier. - Caching methods (`loadDiscoveredElementsFromCache`, `saveDiscoveredElementsToCache`, `clearDiscoveredElements`) now operate only on discovered elements. - `ClientStateManager` constructor defaults to `ArrayCache` if no PSR-16 cache is provided, ensuring basic stateful functionality. - `ClientStateManager` now stores client-requested log levels. - `Processor` updated to use `ClientStateManager` for persisting client-requested log levels and relies on `Configuration` VO for server capabilities. - `JsonRpc\Response` constructor and `fromArray` updated to correctly handle `null` IDs for error responses as per JSON-RPC 2.0 spec, fixing related TypeErrors. - `StdioServerTransport` constructor now accepts optional input/output stream resources, improving testability and flexibility. - `HttpServerTransport` POST message handling reverted to synchronous body reading (`getBody()->getContents()`) for reliability with current `react/http` behavior, resolving hangs. - Corrected `Support\DocBlockParser` and `Support\Discoverer` constructors to directly accept `LoggerInterface` instead of `ContainerInterface`. - Updated unit tests for `ServerBuilder`, `Server`, `Registry`, `ClientStateManager`, `StdioServerTransport`, `HttpServerTransport`, `ProtocolHandler`, and JSON-RPC classes to reflect architectural changes and improve reliability, including fixes for Mockery state and loop interactions. - Renamed `Server::getProtocolHandler()` to `Server::getProtocol()` and updated relevant class names (`ProtocolHandler` to `Protocol`). - Renamed `Server::isLoaded()` in Registry to `discoveryRanOrCached()`. - Renamed examples and updated their internal scripts and documentation comments to reflect new server API and best practices. - Added more examples to showcase other use cases of the library --- .../McpElements.php | 0 .../01-discovery-stdio-calculator/server.php | 82 +++ .../McpElements.php | 0 .../server.php | 37 +- .../SimpleHandlers.php | 71 +++ .../03-manual-registration-stdio/server.php | 79 +++ .../DiscoveredElements.php | 32 ++ .../ManualHandlers.php | 40 ++ .../04-combined-registration-http/server.php | 93 ++++ .../05-stdio-env-variables/EnvToolHandler.php | 43 ++ examples/05-stdio-env-variables/server.php | 73 +++ .../McpTaskHandlers.php | 87 +++ .../06-custom-dependencies-stdio/Services.php | 102 ++++ .../06-custom-dependencies-stdio/server.php | 101 ++++ .../EventTypes.php | 18 + .../McpEventScheduler.php | 64 +++ .../07-complex-tool-schema-http/server.php | 94 ++++ .../standalone_stdio_calculator/server.php | 51 -- src/ClientStateManager.php | 526 ------------------ src/Contracts/ServerTransportInterface.php | 2 +- src/Defaults/ArrayCache.php | 110 ++++ src/Processor.php | 34 +- src/{ProtocolHandler.php => Protocol.php} | 6 +- src/Registry.php | 11 +- src/Server.php | 32 +- src/ServerBuilder.php | 30 +- src/State/ClientState.php | 63 +++ src/State/ClientStateManager.php | 467 ++++++++++++++++ src/Transports/StdioServerTransport.php | 6 +- tests/Mocks/DiscoveryStubs/AbstractStub.php | 10 +- .../Mocks/DiscoveryStubs/AllElementsStub.php | 26 +- .../Mocks/DiscoveryStubs/ChildInheriting.php | 10 +- .../Mocks/DiscoveryStubs/ClassUsingTrait.php | 8 +- .../Mocks/DiscoveryStubs/ConstructorStub.php | 12 +- tests/Mocks/DiscoveryStubs/EnumStub.php | 12 +- tests/Mocks/DiscoveryStubs/InterfaceStub.php | 8 +- .../DiscoveryStubs/MixedValidityStub.php | 15 +- tests/Mocks/DiscoveryStubs/ParentWithTool.php | 16 +- tests/Mocks/DiscoveryStubs/PlainPhpClass.php | 12 +- .../DiscoveryStubs/PrivateMethodStub.php | 12 +- .../DiscoveryStubs/ProtectedMethodStub.php | 12 +- .../Mocks/DiscoveryStubs/ResourceOnlyStub.php | 15 +- .../Mocks/DiscoveryStubs/StaticMethodStub.php | 12 +- tests/Mocks/DiscoveryStubs/ToolOnlyStub.php | 8 +- tests/Mocks/DiscoveryStubs/ToolTrait.php | 14 +- tests/Mocks/DiscoveryStubs/TraitStub.php | 12 +- .../Mocks/SupportStubs/AttributeTestStub.php | 25 +- tests/Mocks/SupportStubs/BackedIntEnum.php | 4 +- tests/Mocks/SupportStubs/BackedStringEnum.php | 4 +- tests/Mocks/SupportStubs/DocBlockTestStub.php | 34 +- .../SupportStubs/SchemaGeneratorTestStub.php | 51 +- tests/Mocks/SupportStubs/UnitEnum.php | 4 +- tests/Pest.php | 55 -- tests/Unit/Attributes/McpPromptTest.php | 9 +- .../Attributes/McpResourceTemplateTest.php | 7 +- tests/Unit/Attributes/McpResourceTest.php | 7 +- tests/Unit/Attributes/McpToolTest.php | 9 +- tests/Unit/ClientStateManagerTest.php | 402 ------------- tests/Unit/JsonRpc/ResponseTest.php | 78 ++- tests/Unit/ProcessorTest.php | 38 +- tests/Unit/ProtocolHandlerTest.php | 45 +- tests/Unit/RegistryTest.php | 43 +- tests/Unit/ServerBuilderTest.php | 116 +--- tests/Unit/ServerTest.php | 70 +-- tests/Unit/State/ClientStateManagerTest.php | 438 +++++++++++++++ tests/Unit/State/ClientStateTest.php | 133 +++++ 66 files changed, 2668 insertions(+), 1472 deletions(-) rename examples/{standalone_stdio_calculator => 01-discovery-stdio-calculator}/McpElements.php (100%) create mode 100644 examples/01-discovery-stdio-calculator/server.php rename examples/{standalone_http_userprofile => 02-discovery-http-userprofile}/McpElements.php (100%) rename examples/{standalone_http_userprofile => 02-discovery-http-userprofile}/server.php (50%) create mode 100644 examples/03-manual-registration-stdio/SimpleHandlers.php create mode 100644 examples/03-manual-registration-stdio/server.php create mode 100644 examples/04-combined-registration-http/DiscoveredElements.php create mode 100644 examples/04-combined-registration-http/ManualHandlers.php create mode 100644 examples/04-combined-registration-http/server.php create mode 100644 examples/05-stdio-env-variables/EnvToolHandler.php create mode 100644 examples/05-stdio-env-variables/server.php create mode 100644 examples/06-custom-dependencies-stdio/McpTaskHandlers.php create mode 100644 examples/06-custom-dependencies-stdio/Services.php create mode 100644 examples/06-custom-dependencies-stdio/server.php create mode 100644 examples/07-complex-tool-schema-http/EventTypes.php create mode 100644 examples/07-complex-tool-schema-http/McpEventScheduler.php create mode 100644 examples/07-complex-tool-schema-http/server.php delete mode 100644 examples/standalone_stdio_calculator/server.php delete mode 100644 src/ClientStateManager.php create mode 100644 src/Defaults/ArrayCache.php rename src/{ProtocolHandler.php => Protocol.php} (99%) create mode 100644 src/State/ClientState.php create mode 100644 src/State/ClientStateManager.php delete mode 100644 tests/Unit/ClientStateManagerTest.php create mode 100644 tests/Unit/State/ClientStateManagerTest.php create mode 100644 tests/Unit/State/ClientStateTest.php diff --git a/examples/standalone_stdio_calculator/McpElements.php b/examples/01-discovery-stdio-calculator/McpElements.php similarity index 100% rename from examples/standalone_stdio_calculator/McpElements.php rename to examples/01-discovery-stdio-calculator/McpElements.php diff --git a/examples/01-discovery-stdio-calculator/server.php b/examples/01-discovery-stdio-calculator/server.php new file mode 100644 index 0000000..5b96396 --- /dev/null +++ b/examples/01-discovery-stdio-calculator/server.php @@ -0,0 +1,82 @@ +#!/usr/bin/env php +discover() + | scans the current directory (specified by basePath: __DIR__, scanDirs: ['.']) + | to find and register elements before listening on STDIN/STDOUT. + | + | If you provided a `CacheInterface` implementation to the ServerBuilder, + | the discovery process will be cached, so you can comment out the + | discovery call after the first run to speed up subsequent runs. + | +*/ +declare(strict_types=1); + +chdir(__DIR__); +require_once '../../vendor/autoload.php'; +require_once 'McpElements.php'; + +use PhpMcp\Server\Server; +use PhpMcp\Server\Transports\StdioServerTransport; +use Psr\Log\AbstractLogger; + +class StderrLogger extends AbstractLogger +{ + public function log($level, \Stringable|string $message, array $context = []): void + { + fwrite(STDERR, sprintf( + "[%s] %s %s\n", + strtoupper($level), + $message, + empty($context) ? '' : json_encode($context) + )); + } +} + +try { + $logger = new StderrLogger; + $logger->info('Starting MCP Stdio Calculator Server...'); + + $server = Server::make() + ->withServerInfo('Stdio Calculator', '1.1.0') + ->withLogger($logger) + ->build(); + + $server->discover(__DIR__, ['.']); + + $transport = new StdioServerTransport; + + $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/examples/standalone_http_userprofile/McpElements.php b/examples/02-discovery-http-userprofile/McpElements.php similarity index 100% rename from examples/standalone_http_userprofile/McpElements.php rename to examples/02-discovery-http-userprofile/McpElements.php diff --git a/examples/standalone_http_userprofile/server.php b/examples/02-discovery-http-userprofile/server.php similarity index 50% rename from examples/standalone_http_userprofile/server.php rename to examples/02-discovery-http-userprofile/server.php index e09c697..1490d75 100644 --- a/examples/standalone_http_userprofile/server.php +++ b/examples/02-discovery-http-userprofile/server.php @@ -1,6 +1,39 @@ #!/usr/bin/env php discover() scans for elements, + | and then $server->listen() starts the ReactPHP HTTP server. + | + | If you provided a `CacheInterface` implementation to the ServerBuilder, + | the discovery process will be cached, so you can comment out the + | discovery call after the first run to speed up subsequent runs. + | +*/ + declare(strict_types=1); chdir(__DIR__); @@ -22,11 +55,11 @@ public function log($level, \Stringable|string $message, array $context = []): v } try { - $logger = new StderrLogger(); + $logger = new StderrLogger; $logger->info('Starting MCP HTTP User Profile Server...'); // --- Setup DI Container for DI in McpElements class --- - $container = new BasicContainer(); + $container = new BasicContainer; $container->set(LoggerInterface::class, $logger); $server = Server::make() diff --git a/examples/03-manual-registration-stdio/SimpleHandlers.php b/examples/03-manual-registration-stdio/SimpleHandlers.php new file mode 100644 index 0000000..d602ee2 --- /dev/null +++ b/examples/03-manual-registration-stdio/SimpleHandlers.php @@ -0,0 +1,71 @@ +logger = $logger; + $this->logger->info('SimpleHandlers instantiated for manual registration example.'); + } + + /** + * A manually registered tool to echo input. + * + * @param string $text The text to echo. + * @return string The echoed text. + */ + public function echoText(string $text): string + { + $this->logger->info("Manual tool 'echo_text' called.", ['text' => $text]); + + return 'Echo: '.$text; + } + + /** + * A manually registered resource providing app version. + * + * @return string The application version. + */ + public function getAppVersion(): string + { + $this->logger->info("Manual resource 'app://version' read."); + + return $this->appVersion; + } + + /** + * A manually registered prompt template. + * + * @param string $userName The name of the user. + * @return array The prompt messages. + */ + public function greetingPrompt(string $userName): array + { + $this->logger->info("Manual prompt 'personalized_greeting' called.", ['userName' => $userName]); + + return [ + ['role' => 'user', 'content' => "Craft a personalized greeting for {$userName}."], + ]; + } + + /** + * A manually registered resource template. + * + * @param string $itemId The ID of the item. + * @return array Item details. + */ + public function getItemDetails(string $itemId): array + { + $this->logger->info("Manual template 'item://{itemId}' resolved.", ['itemId' => $itemId]); + + return ['id' => $itemId, 'name' => "Item {$itemId}", 'description' => "Details for item {$itemId} from manual template."]; + } +} diff --git a/examples/03-manual-registration-stdio/server.php b/examples/03-manual-registration-stdio/server.php new file mode 100644 index 0000000..fe8477b --- /dev/null +++ b/examples/03-manual-registration-stdio/server.php @@ -0,0 +1,79 @@ +#!/usr/bin/env php +discover() method is NOT called. + | +*/ + +declare(strict_types=1); + +chdir(__DIR__); +require_once '../../vendor/autoload.php'; +require_once './SimpleHandlers.php'; + +use Mcp\ManualStdioExample\SimpleHandlers; +use PhpMcp\Server\Defaults\BasicContainer; +use PhpMcp\Server\Server; +use PhpMcp\Server\Transports\StdioServerTransport; +use Psr\Log\AbstractLogger; +use Psr\Log\LoggerInterface; + +class StderrLogger extends AbstractLogger +{ + public function log($level, \Stringable|string $message, array $context = []): void + { + fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); + } +} + +try { + $logger = new StderrLogger; + $logger->info('Starting MCP Manual Registration (Stdio) Server...'); + + $container = new BasicContainer; + $container->set(LoggerInterface::class, $logger); + + $server = Server::make() + ->withServerInfo('Manual Reg Server', '1.0.0') + ->withLogger($logger) + ->withContainer($container) + ->withTool([SimpleHandlers::class, 'echoText'], 'echo_text') + ->withResource([SimpleHandlers::class, 'getAppVersion'], 'app://version', 'application_version', mimeType: 'text/plain') + ->withPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting') + ->withResourceTemplate([SimpleHandlers::class, 'getItemDetails'], 'item://{itemId}/details', 'get_item_details', mimeType: 'application/json') + ->build(); + + $transport = new StdioServerTransport; + $server->listen($transport); + + $logger->info('Server listener stopped gracefully.'); + exit(0); + +} catch (\Throwable $e) { + fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); + exit(1); +} diff --git a/examples/04-combined-registration-http/DiscoveredElements.php b/examples/04-combined-registration-http/DiscoveredElements.php new file mode 100644 index 0000000..90192cc --- /dev/null +++ b/examples/04-combined-registration-http/DiscoveredElements.php @@ -0,0 +1,32 @@ +logger = $logger; + } + + /** + * A manually registered tool. + * + * @param string $user The user to greet. + * @return string Greeting. + */ + public function manualGreeter(string $user): string + { + $this->logger->info("Manual tool 'manual_greeter' called for {$user}"); + + return "Hello {$user}, from manual registration!"; + } + + /** + * Manually registered resource that overrides a discovered one. + * + * @return string Content. + */ + public function getPriorityConfigManual(): string + { + $this->logger->info("Manual resource 'config://priority' read."); + + return 'Manual Priority Config: HIGH (overrides discovered)'; + } +} diff --git a/examples/04-combined-registration-http/server.php b/examples/04-combined-registration-http/server.php new file mode 100644 index 0000000..7f376d2 --- /dev/null +++ b/examples/04-combined-registration-http/server.php @@ -0,0 +1,93 @@ +#!/usr/bin/env php +build(). + | Then, $server->discover() scans for attributed elements. + | +*/ + +declare(strict_types=1); + +chdir(__DIR__); +require_once '../../vendor/autoload.php'; +require_once './DiscoveredElements.php'; +require_once './ManualHandlers.php'; + +use Mcp\CombinedHttpExample\Manual\ManualHandlers; +use PhpMcp\Server\Defaults\BasicContainer; +use PhpMcp\Server\Server; +use PhpMcp\Server\Transports\HttpServerTransport; +use Psr\Log\AbstractLogger; +use Psr\Log\LoggerInterface; + +class StderrLogger extends AbstractLogger +{ + public function log($level, \Stringable|string $message, array $context = []): void + { + fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); + } +} + +try { + $logger = new StderrLogger; + $logger->info('Starting MCP Combined Registration (HTTP) Server...'); + + $container = new BasicContainer; + $container->set(LoggerInterface::class, $logger); // ManualHandlers needs LoggerInterface + + $server = Server::make() + ->withServerInfo('Combined HTTP Server', '1.0.0') + ->withLogger($logger) + ->withContainer($container) + ->withTool([ManualHandlers::class, 'manualGreeter']) + ->withResource( + [ManualHandlers::class, 'getPriorityConfigManual'], + 'config://priority', + 'priority_config_manual', + ) + ->build(); + + // Now, run discovery. Discovered elements will be added. + // If 'config://priority' was discovered, the manual one takes precedence. + $server->discover(__DIR__, scanDirs: ['.']); + + $transport = new HttpServerTransport('0.0.0.0', 8081, 'mcp_combined'); + + $server->listen($transport); + + $logger->info('Server listener stopped gracefully.'); + exit(0); + +} catch (\Throwable $e) { + fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); + exit(1); +} diff --git a/examples/05-stdio-env-variables/EnvToolHandler.php b/examples/05-stdio-env-variables/EnvToolHandler.php new file mode 100644 index 0000000..afd7af7 --- /dev/null +++ b/examples/05-stdio-env-variables/EnvToolHandler.php @@ -0,0 +1,43 @@ + 'debug', + 'processed_input' => strtoupper($input), + 'message' => 'Processed in DEBUG mode.', + ]; + } elseif ($appMode === 'production') { + return [ + 'mode' => 'production', + 'processed_input_length' => strlen($input), + 'message' => 'Processed in PRODUCTION mode (summary only).', + ]; + } else { + return [ + 'mode' => $appMode ?: 'default', + 'original_input' => $input, + 'message' => 'Processed in default mode (APP_MODE not recognized or not set).', + ]; + } + } +} diff --git a/examples/05-stdio-env-variables/server.php b/examples/05-stdio-env-variables/server.php new file mode 100644 index 0000000..2afbb57 --- /dev/null +++ b/examples/05-stdio-env-variables/server.php @@ -0,0 +1,73 @@ +#!/usr/bin/env php +info('Starting MCP Stdio Environment Variable Example Server...'); + + $server = Server::make() + ->withServerInfo('Env Var Server', '1.0.0') + ->withLogger($logger) + ->build(); + + $server->discover(__DIR__, ['.']); + + $transport = new StdioServerTransport; + $server->listen($transport); + + $logger->info('Server listener stopped gracefully.'); + exit(0); + +} catch (\Throwable $e) { + fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); + exit(1); +} diff --git a/examples/06-custom-dependencies-stdio/McpTaskHandlers.php b/examples/06-custom-dependencies-stdio/McpTaskHandlers.php new file mode 100644 index 0000000..7de78af --- /dev/null +++ b/examples/06-custom-dependencies-stdio/McpTaskHandlers.php @@ -0,0 +1,87 @@ +taskRepo = $taskRepo; + $this->statsService = $statsService; + $this->logger = $logger; + $this->logger->info('McpTaskHandlers instantiated with dependencies.'); + } + + /** + * Adds a new task for a given user. + * + * @param string $userId The ID of the user. + * @param string $description The task description. + * @return array The created task details. + */ + #[McpTool(name: 'add_task')] + public function addTask(string $userId, string $description): array + { + $this->logger->info("Tool 'add_task' invoked", ['userId' => $userId]); + + return $this->taskRepo->addTask($userId, $description); + } + + /** + * Lists pending tasks for a specific user. + * + * @param string $userId The ID of the user. + * @return array A list of tasks. + */ + #[McpTool(name: 'list_user_tasks')] + public function listUserTasks(string $userId): array + { + $this->logger->info("Tool 'list_user_tasks' invoked", ['userId' => $userId]); + + return $this->taskRepo->getTasksForUser($userId); + } + + /** + * Marks a task as complete. + * + * @param int $taskId The ID of the task to complete. + * @return array Status of the operation. + */ + #[McpTool(name: 'complete_task')] + public function completeTask(int $taskId): array + { + $this->logger->info("Tool 'complete_task' invoked", ['taskId' => $taskId]); + $success = $this->taskRepo->completeTask($taskId); + + return ['success' => $success, 'message' => $success ? "Task {$taskId} completed." : "Task {$taskId} not found."]; + } + + /** + * Provides current system statistics. + * + * @return array System statistics. + */ + #[McpResource(uri: 'stats://system/overview', name: 'system_stats', mimeType: 'application/json')] + public function getSystemStatistics(): array + { + $this->logger->info("Resource 'stats://system/overview' invoked"); + + return $this->statsService->getSystemStats(); + } +} diff --git a/examples/06-custom-dependencies-stdio/Services.php b/examples/06-custom-dependencies-stdio/Services.php new file mode 100644 index 0000000..47d7de6 --- /dev/null +++ b/examples/06-custom-dependencies-stdio/Services.php @@ -0,0 +1,102 @@ +logger = $logger; + // Add some initial tasks + $this->addTask('user1', 'Buy groceries'); + $this->addTask('user1', 'Write MCP example'); + $this->addTask('user2', 'Review PR'); + } + + public function addTask(string $userId, string $description): array + { + $task = [ + 'id' => $this->nextTaskId++, + 'userId' => $userId, + 'description' => $description, + 'completed' => false, + 'createdAt' => date('c'), + ]; + $this->tasks[$task['id']] = $task; + $this->logger->info('Task added', ['id' => $task['id'], 'user' => $userId]); + + return $task; + } + + public function getTasksForUser(string $userId): array + { + return array_values(array_filter($this->tasks, fn ($task) => $task['userId'] === $userId && ! $task['completed'])); + } + + public function getAllTasks(): array + { + return array_values($this->tasks); + } + + public function completeTask(int $taskId): bool + { + if (isset($this->tasks[$taskId])) { + $this->tasks[$taskId]['completed'] = true; + $this->logger->info('Task completed', ['id' => $taskId]); + + return true; + } + + return false; + } +} + +interface StatsServiceInterface +{ + public function getSystemStats(): array; +} + +class SystemStatsService implements StatsServiceInterface +{ + private TaskRepositoryInterface $taskRepository; + + public function __construct(TaskRepositoryInterface $taskRepository) + { + $this->taskRepository = $taskRepository; + } + + public function getSystemStats(): array + { + $allTasks = $this->taskRepository->getAllTasks(); + $completed = count(array_filter($allTasks, fn ($task) => $task['completed'])); + $pending = count($allTasks) - $completed; + + return [ + 'total_tasks' => count($allTasks), + 'completed_tasks' => $completed, + 'pending_tasks' => $pending, + 'server_uptime_seconds' => time() - $_SERVER['REQUEST_TIME_FLOAT'], // Approx uptime for CLI script + ]; + } +} diff --git a/examples/06-custom-dependencies-stdio/server.php b/examples/06-custom-dependencies-stdio/server.php new file mode 100644 index 0000000..6fa2152 --- /dev/null +++ b/examples/06-custom-dependencies-stdio/server.php @@ -0,0 +1,101 @@ +#!/usr/bin/env php +withContainer($container)`. + | - The DI container is set up with bindings for service interfaces to + | their concrete implementations (e.g., TaskRepositoryInterface -> InMemoryTaskRepository). + | - The `McpTaskHandlers` class receives its dependencies (TaskRepositoryInterface, + | StatsServiceInterface, LoggerInterface) via constructor injection, resolved by + | the DI container when the Processor needs an instance of McpTaskHandlers. + | - This example uses attribute-based discovery via `$server->discover()`. + | - It runs using the STDIO transport. + | + | To Use: + | 1. Run this script: `php server.php` (from this directory) + | 2. Configure your MCP Client (e.g., Cursor) for this server: + | + | { + | "mcpServers": { + | "php-stdio-deps-taskmgr": { + | "command": "php", + | "args": ["/full/path/to/examples/06-custom-dependencies-stdio/server.php"] + | } + | } + | } + | + | Interact with tools like 'add_task', 'list_user_tasks', 'complete_task' + | and read the resource 'stats://system/overview'. + | +*/ + +declare(strict_types=1); + +chdir(__DIR__); +require_once '../../vendor/autoload.php'; +require_once './Services.php'; +require_once './McpTaskHandlers.php'; + +use Mcp\DependenciesStdioExample\Services; +use PhpMcp\Server\Defaults\BasicContainer; +use PhpMcp\Server\Server; +use PhpMcp\Server\Transports\StdioServerTransport; +use Psr\Log\AbstractLogger; +use Psr\Log\LoggerInterface; + +class StderrLogger extends AbstractLogger +{ + public function log($level, \Stringable|string $message, array $context = []): void + { + fwrite(STDERR, sprintf("[%s][%s] %s %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message, empty($context) ? '' : json_encode($context))); + } +} + +try { + $logger = new StderrLogger; + $logger->info('Starting MCP Custom Dependencies (Stdio) Server...'); + + $container = new BasicContainer; + $container->set(LoggerInterface::class, $logger); + + $taskRepo = new Services\InMemoryTaskRepository($logger); + $container->set(Services\TaskRepositoryInterface::class, $taskRepo); + + $statsService = new Services\SystemStatsService($taskRepo); + $container->set(Services\StatsServiceInterface::class, $statsService); + + $server = Server::make() + ->withServerInfo('Task Manager Server', '1.0.0') + ->withLogger($logger) + ->withContainer($container) + ->build(); + + $server->discover(__DIR__, ['.']); + + $transport = new StdioServerTransport; + $server->listen($transport); + + $logger->info('Server listener stopped gracefully.'); + exit(0); + +} catch (\Throwable $e) { + fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); + exit(1); +} diff --git a/examples/07-complex-tool-schema-http/EventTypes.php b/examples/07-complex-tool-schema-http/EventTypes.php new file mode 100644 index 0000000..0cb4dd1 --- /dev/null +++ b/examples/07-complex-tool-schema-http/EventTypes.php @@ -0,0 +1,18 @@ +logger = $logger; + } + + /** + * Schedules a new event. + * The inputSchema for this tool will reflect all parameter types and defaults. + * + * @param string $title The title of the event. + * @param string $date The date of the event (YYYY-MM-DD). + * @param EventType $type The type of event. + * @param string|null $time The time of the event (HH:MM), optional. + * @param EventPriority $priority The priority of the event. Defaults to Normal. + * @param string[]|null $attendees An optional list of attendee email addresses. + * @param bool $sendInvites Send calendar invites to attendees? Defaults to true if attendees are provided. + * @return array Confirmation of the scheduled event. + */ + #[McpTool(name: 'schedule_event')] + public function scheduleEvent( + string $title, + string $date, + EventType $type, + ?string $time = null, // Optional, nullable + EventPriority $priority = EventPriority::Normal, // Optional with enum default + ?array $attendees = null, // Optional array of strings, nullable + bool $sendInvites = true // Optional with default + ): array { + $this->logger->info("Tool 'schedule_event' called", compact('title', 'date', 'type', 'time', 'priority', 'attendees', 'sendInvites')); + + // Simulate scheduling logic + $eventDetails = [ + 'title' => $title, + 'date' => $date, + 'type' => $type->value, // Use enum value + 'time' => $time ?? 'All day', + 'priority' => $priority->name, // Use enum name + 'attendees' => $attendees ?? [], + 'invites_will_be_sent' => ($attendees && $sendInvites), + ]; + + // In a real app, this would interact with a calendar service + $this->logger->info('Event scheduled', ['details' => $eventDetails]); + + return [ + 'success' => true, + 'message' => "Event '{$title}' scheduled successfully for {$date}.", + 'event_details' => $eventDetails, + ]; + } +} diff --git a/examples/07-complex-tool-schema-http/server.php b/examples/07-complex-tool-schema-http/server.php new file mode 100644 index 0000000..8b90528 --- /dev/null +++ b/examples/07-complex-tool-schema-http/server.php @@ -0,0 +1,94 @@ +#!/usr/bin/env php +info('Starting MCP Complex Schema HTTP Server...'); + + $container = new BasicContainer; + $container->set(LoggerInterface::class, $logger); + + $server = Server::make() + ->withServerInfo('Event Scheduler Server', '1.0.0') + ->withLogger($logger) + ->withContainer($container) + ->build(); + + $server->discover(__DIR__, ['.']); + + $transport = new HttpServerTransport('120.0.0.1', 8082, 'mcp_scheduler'); + $server->listen($transport); + + $logger->info('Server listener stopped gracefully.'); + exit(0); + +} catch (\Throwable $e) { + fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); + exit(1); +} diff --git a/examples/standalone_stdio_calculator/server.php b/examples/standalone_stdio_calculator/server.php deleted file mode 100644 index 00e8652..0000000 --- a/examples/standalone_stdio_calculator/server.php +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env php -info('Starting MCP Stdio Calculator Server...'); - - $server = Server::make() - ->withServerInfo('Stdio Calculator', '1.1.0') - ->withLogger($logger) - ->build(); - - $server->discover(__DIR__, ['.']); - - $transport = new StdioServerTransport(); - - $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/ClientStateManager.php b/src/ClientStateManager.php deleted file mode 100644 index 900a11b..0000000 --- a/src/ClientStateManager.php +++ /dev/null @@ -1,526 +0,0 @@ -logger = $logger; - $this->cache = $cache; - $this->cachePrefix = $cachePrefix; - $this->cacheTtl = max(60, $cacheTtl); // Minimum TTL of 60 seconds - } - - private function getCacheKey(string $key, ?string $clientId = null): string - { - return $clientId ? "{$this->cachePrefix}{$key}_{$clientId}" : "{$this->cachePrefix}{$key}"; - } - - // --- Initialization --- - - public function isInitialized(string $clientId): bool - { - if (! $this->cache) { - return false; - } - - return (bool) $this->cache->get($this->getCacheKey('initialized', $clientId), false); - } - - public function markInitialized(string $clientId): void - { - if (! $this->cache) { - $this->logger->warning('Cannot mark client as initialized, cache not available.', ['clientId' => $clientId]); - - return; - } - try { - $this->cache->set($this->getCacheKey('initialized', $clientId), true, $this->cacheTtl); - $this->updateClientActivity($clientId); - $this->logger->info('MCP State: Client marked initialized.', ['client_id' => $clientId]); - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP State: Failed to mark client as initialized in cache (invalid key).', ['clientId' => $clientId, 'exception' => $e]); - } catch (Throwable $e) { - $this->logger->error('MCP State: Failed to mark client as initialized in cache.', ['clientId' => $clientId, 'exception' => $e]); - } - } - - public function storeClientInfo(array $clientInfo, string $protocolVersion, string $clientId): void - { - if (! $this->cache) { - return; - } - try { - $this->cache->set($this->getCacheKey('client_info', $clientId), $clientInfo, $this->cacheTtl); - $this->cache->set($this->getCacheKey('protocol_version', $clientId), $protocolVersion, $this->cacheTtl); - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP State: Failed to store client info in cache (invalid key).', ['clientId' => $clientId, 'exception' => $e]); - } catch (Throwable $e) { - $this->logger->error('MCP State: Failed to store client info in cache.', ['clientId' => $clientId, 'exception' => $e]); - } - } - - public function getClientInfo(string $clientId): ?array - { - if (! $this->cache) { - return null; - } - try { - return $this->cache->get($this->getCacheKey('client_info', $clientId)); - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP State: Failed to get client info from cache (invalid key).', ['clientId' => $clientId, 'exception' => $e]); - - return null; - } catch (Throwable $e) { - $this->logger->error('MCP State: Failed to get client info from cache.', ['clientId' => $clientId, 'exception' => $e]); - - return null; - } - } - - public function getProtocolVersion(string $clientId): ?string - { - if (! $this->cache) { - return null; - } - try { - return $this->cache->get($this->getCacheKey('protocol_version', $clientId)); - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP State: Failed to get protocol version from cache (invalid key).', ['clientId' => $clientId, 'exception' => $e]); - - return null; - } catch (Throwable $e) { - $this->logger->error('MCP State: Failed to get protocol version from cache.', ['clientId' => $clientId, 'exception' => $e]); - - return null; - } - } - - // --- Subscriptions (methods need cache check) --- - - public function addResourceSubscription(string $clientId, string $uri): void - { - if (! $this->cache) { - $this->logger->warning('Cannot add resource subscription, cache not available.', ['clientId' => $clientId, 'uri' => $uri]); - - return; - } - try { - $clientSubKey = $this->getCacheKey('client_subscriptions', $clientId); - $resourceSubKey = $this->getCacheKey('resource_subscriptions', $uri); - - // It's safer to get existing, modify, then set, though slightly less atomic - $clientSubscriptions = $this->cache->get($clientSubKey, []); - $resourceSubscriptions = $this->cache->get($resourceSubKey, []); - - $clientSubscriptions = is_array($clientSubscriptions) ? $clientSubscriptions : []; - $resourceSubscriptions = is_array($resourceSubscriptions) ? $resourceSubscriptions : []; - - $clientSubscriptions[$uri] = true; - $resourceSubscriptions[$clientId] = true; - - $this->cache->set($clientSubKey, $clientSubscriptions, $this->cacheTtl); - $this->cache->set($resourceSubKey, $resourceSubscriptions, $this->cacheTtl); - - $this->logger->debug('MCP State: Client subscribed to resource.', ['clientId' => $clientId, 'uri' => $uri]); - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP State: Failed to add resource subscription (invalid key).', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); - } catch (Throwable $e) { - $this->logger->error('MCP State: Failed to add resource subscription.', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); - } - } - - public function removeResourceSubscription(string $clientId, string $uri): void - { - if (! $this->cache) { - return; - } - try { - $clientSubKey = $this->getCacheKey('client_subscriptions', $clientId); - $resourceSubKey = $this->getCacheKey('resource_subscriptions', $uri); - - $clientSubscriptions = $this->cache->get($clientSubKey, []); - $resourceSubscriptions = $this->cache->get($resourceSubKey, []); - $clientSubscriptions = is_array($clientSubscriptions) ? $clientSubscriptions : []; - $resourceSubscriptions = is_array($resourceSubscriptions) ? $resourceSubscriptions : []; - - $clientChanged = false; - if (isset($clientSubscriptions[$uri])) { - unset($clientSubscriptions[$uri]); - $clientChanged = true; - } - - $resourceChanged = false; - if (isset($resourceSubscriptions[$clientId])) { - unset($resourceSubscriptions[$clientId]); - $resourceChanged = true; - } - - if ($clientChanged) { - if (empty($clientSubscriptions)) { - $this->cache->delete($clientSubKey); - } else { - $this->cache->set($clientSubKey, $clientSubscriptions, $this->cacheTtl); - } - } - if ($resourceChanged) { - if (empty($resourceSubscriptions)) { - $this->cache->delete($resourceSubKey); - } else { - $this->cache->set($resourceSubKey, $resourceSubscriptions, $this->cacheTtl); - } - } - - if ($clientChanged || $resourceChanged) { - $this->logger->debug('MCP State: Client unsubscribed from resource.', ['clientId' => $clientId, 'uri' => $uri]); - } - - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP State: Failed to remove resource subscription (invalid key).', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); - } catch (Throwable $e) { - $this->logger->error('MCP State: Failed to remove resource subscription.', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); - } - } - - public function removeAllResourceSubscriptions(string $clientId): void - { - if (! $this->cache) { - return; - } - try { - $clientSubKey = $this->getCacheKey('client_subscriptions', $clientId); - $clientSubscriptions = $this->cache->get($clientSubKey, []); - $clientSubscriptions = is_array($clientSubscriptions) ? $clientSubscriptions : []; - - if (empty($clientSubscriptions)) { - return; - } - - $uris = array_keys($clientSubscriptions); - $keysToDeleteFromResources = []; - $keysToUpdateResources = []; - $updatedResourceSubs = []; - - foreach ($uris as $uri) { - $resourceSubKey = $this->getCacheKey('resource_subscriptions', $uri); - $resourceSubscriptions = $this->cache->get($resourceSubKey, []); - $resourceSubscriptions = is_array($resourceSubscriptions) ? $resourceSubscriptions : []; - - if (isset($resourceSubscriptions[$clientId])) { - unset($resourceSubscriptions[$clientId]); - if (empty($resourceSubscriptions)) { - $keysToDeleteFromResources[] = $resourceSubKey; - } else { - $keysToUpdateResources[] = $resourceSubKey; - $updatedResourceSubs[$resourceSubKey] = $resourceSubscriptions; - } - } - } - - // Perform cache operations - if (! empty($keysToDeleteFromResources)) { - $this->cache->deleteMultiple($keysToDeleteFromResources); - } - foreach ($keysToUpdateResources as $key) { - $this->cache->set($key, $updatedResourceSubs[$key], $this->cacheTtl); - } - $this->cache->delete($clientSubKey); // Remove client's master list - - $this->logger->debug('MCP State: Client removed all resource subscriptions.', ['clientId' => $clientId, 'count' => count($uris)]); - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP State: Failed to remove all resource subscriptions (invalid key).', ['clientId' => $clientId, 'exception' => $e]); - } catch (Throwable $e) { - $this->logger->error('MCP State: Failed to remove all resource subscriptions.', ['clientId' => $clientId, 'exception' => $e]); - } - } - - /** @return array */ - public function getResourceSubscribers(string $uri): array - { - if (! $this->cache) { - return []; - } - try { - $resourceSubscriptions = $this->cache->get($this->getCacheKey('resource_subscriptions', $uri), []); - - return is_array($resourceSubscriptions) ? array_keys($resourceSubscriptions) : []; - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP State: Failed to get resource subscribers (invalid key).', ['uri' => $uri, 'exception' => $e]); - - return []; - } catch (Throwable $e) { - $this->logger->error('MCP State: Failed to get resource subscribers.', ['uri' => $uri, 'exception' => $e]); - - return []; - } - } - - public function isSubscribedToResource(string $clientId, string $uri): bool - { - if (! $this->cache) { - return false; - } - try { - $clientSubscriptions = $this->cache->get($this->getCacheKey('client_subscriptions', $clientId), []); - - return is_array($clientSubscriptions) && isset($clientSubscriptions[$uri]); - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP State: Failed to check resource subscription (invalid key).', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); - - return false; - } catch (Throwable $e) { - $this->logger->error('MCP State: Failed to check resource subscription.', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); - - return false; - } - } - - // --- Message Queue (methods need cache check) --- - - public function queueMessage(string $clientId, Message|array $message): void - { - if (! $this->cache) { - $this->logger->warning('Cannot queue message, cache not available.', ['clientId' => $clientId]); - - return; - } - try { - $key = $this->getCacheKey('messages', $clientId); - $messages = $this->cache->get($key, []); - $messages = is_array($messages) ? $messages : []; - - $newMessages = []; - if (is_array($message)) { - foreach ($message as $singleMessage) { - if ($singleMessage instanceof Message) { - $newMessages[] = $singleMessage->toArray(); - } - } - } elseif ($message instanceof Message) { - $newMessages[] = $message->toArray(); - } - - if (! empty($newMessages)) { - $this->cache->set($key, array_merge($messages, $newMessages), $this->cacheTtl); - $this->logger->debug('MCP State: Queued message(s).', ['clientId' => $clientId, 'count' => count($newMessages)]); - } - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP State: Failed to queue message (invalid key).', ['clientId' => $clientId, 'exception' => $e]); - } catch (Throwable $e) { - $this->logger->error('MCP State: Failed to queue message.', ['clientId' => $clientId, 'exception' => $e]); - } - } - - public function queueMessageForAll(Message|array $message): void - { - if (! $this->cache) { - $this->logger->warning('Cannot queue message for all, cache not available.'); - - return; - } - $clients = $this->getActiveClients(); // getActiveClients handles cache check internally - if (empty($clients)) { - $this->logger->debug('MCP State: No active clients found to queue message for.'); - - return; - } - $this->logger->debug('MCP State: Queuing message for all active clients.', ['count' => count($clients)]); - foreach ($clients as $clientId) { - $this->queueMessage($clientId, $message); - } - } - - /** @return array */ - public function getQueuedMessages(string $clientId): array - { - if (! $this->cache) { - return []; - } - try { - $key = $this->getCacheKey('messages', $clientId); - $messages = $this->cache->get($key, []); - $messages = is_array($messages) ? $messages : []; - - if (! empty($messages)) { - $this->cache->delete($key); - $this->logger->debug('MCP State: Retrieved and cleared queued messages.', ['clientId' => $clientId, 'count' => count($messages)]); - } - - return $messages; - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP State: Failed to get/delete queued messages (invalid key).', ['clientId' => $clientId, 'exception' => $e]); - - return []; - } catch (Throwable $e) { - $this->logger->error('MCP State: Failed to get/delete queued messages.', ['clientId' => $clientId, 'exception' => $e]); - - return []; - } - } - - // --- Client Management --- - - public function cleanupClient(string $clientId, bool $removeFromActiveList = true): void - { - $this->removeAllResourceSubscriptions($clientId); - - if (! $this->cache) { - $this->logger->warning('Cannot perform full client cleanup, cache not available.', ['clientId' => $clientId]); - - return; - } - - try { - if ($removeFromActiveList) { - $activeClientsKey = $this->getCacheKey('active_clients'); - $activeClients = $this->cache->get($activeClientsKey, []); - $activeClients = is_array($activeClients) ? $activeClients : []; - if (isset($activeClients[$clientId])) { - unset($activeClients[$clientId]); - $this->cache->set($activeClientsKey, $activeClients, $this->cacheTtl); - } - } - - // Delete other client-specific data - $keysToDelete = [ - $this->getCacheKey('initialized', $clientId), - $this->getCacheKey('client_info', $clientId), - $this->getCacheKey('protocol_version', $clientId), - $this->getCacheKey('messages', $clientId), - // client_subscriptions key already deleted by removeAllResourceSubscriptions if needed - ]; - $this->cache->deleteMultiple($keysToDelete); - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP State: Failed to remove client data from cache (invalid key).', ['clientId' => $clientId, 'exception' => $e]); - } catch (Throwable $e) { - $this->logger->error('MCP State: Failed to remove client data from cache.', ['clientId' => $clientId, 'exception' => $e]); - } - } - - public function updateClientActivity(string $clientId): void - { - if (! $this->cache) { - return; - } - try { - $key = $this->getCacheKey('active_clients'); - $activeClients = $this->cache->get($key, []); - $activeClients = is_array($activeClients) ? $activeClients : []; - $activeClients[$clientId] = time(); // Using integer timestamp - $this->cache->set($key, $activeClients, $this->cacheTtl); - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP State: Failed to update client activity (invalid key).', ['clientId' => $clientId, 'exception' => $e]); - } catch (Throwable $e) { - $this->logger->error('MCP State: Failed to update client activity.', ['clientId' => $clientId, 'exception' => $e]); - } - } - - /** @return array Active client IDs */ - public function getActiveClients(int $inactiveThreshold = 300): array - { - if (! $this->cache) { - return []; - } - try { - $activeClientsKey = $this->getCacheKey('active_clients'); - $activeClients = $this->cache->get($activeClientsKey, []); - $activeClients = is_array($activeClients) ? $activeClients : []; - - $currentTime = time(); - $result = []; - $clientsToCleanUp = []; - $listChanged = false; - - foreach ($activeClients as $clientId => $lastSeen) { - if (! is_int($lastSeen)) { // Data sanity check - $this->logger->warning('Invalid timestamp found in active clients list, removing.', ['clientId' => $clientId, 'value' => $lastSeen]); - $clientsToCleanUp[] = $clientId; - $listChanged = true; - - continue; - } - if ($currentTime - $lastSeen < $inactiveThreshold) { - $result[] = $clientId; - } else { - $this->logger->info('MCP State: Client considered inactive, scheduling cleanup.', ['clientId' => $clientId, 'last_seen' => $lastSeen]); - $clientsToCleanUp[] = $clientId; - $listChanged = true; - } - } - - if ($listChanged) { - $updatedActiveClients = $activeClients; - foreach ($clientsToCleanUp as $idToClean) { - unset($updatedActiveClients[$idToClean]); - } - $this->cache->set($activeClientsKey, $updatedActiveClients, $this->cacheTtl); - - // Perform cleanup for inactive clients (without removing from list again) - foreach ($clientsToCleanUp as $idToClean) { - $this->cleanupClient($idToClean, false); - } - } - - return $result; - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP State: Failed to get active clients (invalid key).', ['exception' => $e]); - - return []; - } catch (Throwable $e) { - $this->logger->error('MCP State: Failed to get active clients.', ['exception' => $e]); - - return []; - } - } - - /** Retrieves the last activity timestamp for a specific client. */ - public function getLastActivityTime(string $clientId): ?int // Return int (Unix timestamp) - { - if (! $this->cache) { - return null; - } - try { - $activeClientsKey = $this->getCacheKey('active_clients'); - $activeClients = $this->cache->get($activeClientsKey, []); - $activeClients = is_array($activeClients) ? $activeClients : []; - - $lastSeen = $activeClients[$clientId] ?? null; - - return is_int($lastSeen) ? $lastSeen : null; - - } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP State: Failed to get client activity time (invalid key).', ['clientId' => $clientId, 'exception' => $e]); - - return null; - } catch (Throwable $e) { - $this->logger->error('MCP State: Failed to get client activity time.', ['clientId' => $clientId, 'exception' => $e]); - - return null; - } - } -} diff --git a/src/Contracts/ServerTransportInterface.php b/src/Contracts/ServerTransportInterface.php index f178cbd..dd9f81a 100644 --- a/src/Contracts/ServerTransportInterface.php +++ b/src/Contracts/ServerTransportInterface.php @@ -36,7 +36,7 @@ public function listen(): void; * Sends a raw, framed message to a specific connected client. * The message MUST be a complete JSON-RPC frame (typically ending in "\n" for line-based transports * or formatted as an SSE event for HTTP transports). Framing is the responsibility of the caller - * (typically the ProtocolHandler) as it depends on the transport type. + * (typically the Protocol) as it depends on the transport type. * * @param string $clientId Target client identifier ("stdio" is conventionally used for stdio transport). * @param string $rawFramedMessage Message string ready for transport. diff --git a/src/Defaults/ArrayCache.php b/src/Defaults/ArrayCache.php new file mode 100644 index 0000000..fee92a8 --- /dev/null +++ b/src/Defaults/ArrayCache.php @@ -0,0 +1,110 @@ +has($key)) { + return $default; + } + + return $this->store[$key]; + } + + public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool + { + $this->store[$key] = $value; + $this->expiries[$key] = $this->calculateExpiry($ttl); + + return true; + } + + public function delete(string $key): bool + { + unset($this->store[$key], $this->expiries[$key]); + + return true; + } + + public function clear(): bool + { + $this->store = []; + $this->expiries = []; + + return true; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + $result = []; + foreach ($keys as $key) { + $result[$key] = $this->get($key, $default); + } + + return $result; + } + + public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool + { + $expiry = $this->calculateExpiry($ttl); + foreach ($values as $key => $value) { + $this->store[$key] = $value; + $this->expiries[$key] = $expiry; + } + + return true; + } + + public function deleteMultiple(iterable $keys): bool + { + foreach ($keys as $key) { + unset($this->store[$key], $this->expiries[$key]); + } + + return true; + } + + public function has(string $key): bool + { + if (! isset($this->store[$key])) { + return false; + } + // Check expiry + if (isset($this->expiries[$key]) && $this->expiries[$key] !== null && time() >= $this->expiries[$key]) { + $this->delete($key); + + return false; + } + + return true; + } + + private function calculateExpiry(DateInterval|int|null $ttl): ?int + { + if ($ttl === null) { + return null; // No expiry + } + if (is_int($ttl)) { + return time() + $ttl; + } + if ($ttl instanceof DateInterval) { + return (new DateTime)->add($ttl)->getTimestamp(); + } + + // Invalid TTL type, treat as no expiry + return null; + } +} diff --git a/src/Processor.php b/src/Processor.php index 6c2b330..2e73cc7 100644 --- a/src/Processor.php +++ b/src/Processor.php @@ -20,11 +20,13 @@ use PhpMcp\Server\JsonRpc\Results\ListResourceTemplatesResult; use PhpMcp\Server\JsonRpc\Results\ListToolsResult; use PhpMcp\Server\JsonRpc\Results\ReadResourceResult; +use PhpMcp\Server\State\ClientStateManager; use PhpMcp\Server\Support\ArgumentPreparer; use PhpMcp\Server\Support\SchemaValidator; use PhpMcp\Server\Traits\ResponseFormatter; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; use stdClass; use Throwable; @@ -216,7 +218,7 @@ private function handleInitialize(array $params, string $clientId): InitializeRe // handlePing remains the same private function handlePing(string $clientId): EmptyResult { - return new EmptyResult(); + return new EmptyResult; } // handleNotificationInitialized remains the same (uses ClientStateManager) @@ -224,7 +226,7 @@ private function handleNotificationInitialized(array $params, string $clientId): { $this->clientStateManager->markInitialized($clientId); - return new EmptyResult(); // Return EmptyResult, Response is handled by caller + return new EmptyResult; // Return EmptyResult, Response is handled by caller } // --- List Handlers (Updated pagination limit source) --- @@ -290,7 +292,7 @@ private function handleToolCall(array $params): CallToolResult } if ($argumentsRaw === null) { - $argumentsRaw = new stdClass(); + $argumentsRaw = new stdClass; } elseif (! is_array($argumentsRaw) && ! $argumentsRaw instanceof stdClass) { throw McpServerException::invalidParams("Parameter 'arguments' must be an object/array for tools/call."); } @@ -403,7 +405,7 @@ private function handleResourceSubscribe(array $params, string $clientId): Empty $this->clientStateManager->addResourceSubscription($clientId, $uri); - return new EmptyResult(); + return new EmptyResult; } private function handleResourceUnsubscribe(array $params, string $clientId): EmptyResult @@ -417,7 +419,7 @@ private function handleResourceUnsubscribe(array $params, string $clientId): Emp $this->clientStateManager->removeResourceSubscription($clientId, $uri); - return new EmptyResult(); + return new EmptyResult; } private function handlePromptGet(array $params): GetPromptResult @@ -473,27 +475,29 @@ private function handlePromptGet(array $params): GetPromptResult } } - // handleLoggingSetLevel needs a way to persist the setting. - // Using ClientStateManager might be okay, or a dedicated config service. - // For Phase 1, let's just log it. - private function handleLoggingSetLevel(array $params): EmptyResult + private function handleLoggingSetLevel(array $params, string $clientId): EmptyResult { $level = $params['level'] ?? null; - $validLevels = ['emergency', 'alert', 'critical', 'error', 'warning', 'notice', 'info', 'debug']; + $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->logger->info('MCP logging level set request received', ['level' => $level]); - // In a real implementation, update the logger's minimum level or store this preference. - // $this->clientStateManager->setClientLogLevel($clientId, strtolower($level)); // Example state storage + $this->clientStateManager->setClientRequestedLogLevel($clientId, strtolower($level)); + + $this->logger->info("Processor: Client '{$clientId}' requested log level set to '{$level}'."); - return new EmptyResult(); + return new EmptyResult; } - // --- Pagination Helpers (Unchanged) --- + // --- Pagination Helpers --- private function decodeCursor(?string $cursor): int { diff --git a/src/ProtocolHandler.php b/src/Protocol.php similarity index 99% rename from src/ProtocolHandler.php rename to src/Protocol.php index 843756c..6d7d35c 100644 --- a/src/ProtocolHandler.php +++ b/src/Protocol.php @@ -12,6 +12,7 @@ use PhpMcp\Server\JsonRpc\Notification; use PhpMcp\Server\JsonRpc\Request; use PhpMcp\Server\JsonRpc\Response; +use PhpMcp\Server\State\ClientStateManager; use Psr\Log\LoggerInterface; use React\EventLoop\LoopInterface; use React\Promise\PromiseInterface; @@ -26,7 +27,7 @@ * This handler manages the JSON-RPC parsing, processing delegation, and response sending * based on events received from the transport layer. */ -class ProtocolHandler +class Protocol { protected ?ServerTransportInterface $transport = null; @@ -38,8 +39,7 @@ public function __construct( protected readonly ClientStateManager $clientStateManager, protected readonly LoggerInterface $logger, protected readonly LoopInterface $loop - ) { - } + ) {} /** * Binds this handler to a transport instance by attaching event listeners. diff --git a/src/Registry.php b/src/Registry.php index 70aae1e..a65a581 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -11,6 +11,7 @@ use PhpMcp\Server\Definitions\ToolDefinition; 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; @@ -81,7 +82,7 @@ public function __construct( $this->loadDiscoveredElementsFromCache(); } else { $this->discoveredElementsLoaded = true; - $this->logger->debug('MCP Registry: Cache not provided, skipping initial cache load.'); + $this->logger->debug('No cache provided to registry, skipping initial cache load.'); } } @@ -106,10 +107,10 @@ public function hasElements(): bool private function initializeCollections(): void { - $this->tools = new ArrayObject(); - $this->resources = new ArrayObject(); - $this->prompts = new ArrayObject(); - $this->resourceTemplates = new ArrayObject(); + $this->tools = new ArrayObject; + $this->resources = new ArrayObject; + $this->prompts = new ArrayObject; + $this->resourceTemplates = new ArrayObject; $this->manualToolNames = []; $this->manualResourceUris = []; diff --git a/src/Server.php b/src/Server.php index 5b9d32b..8506471 100644 --- a/src/Server.php +++ b/src/Server.php @@ -10,6 +10,7 @@ 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 Throwable; @@ -24,7 +25,7 @@ */ class Server { - protected ?ProtocolHandler $protocolHandler = null; + protected ?Protocol $protocol = null; protected bool $discoveryRan = false; @@ -44,12 +45,11 @@ public function __construct( protected readonly Registry $registry, protected readonly Processor $processor, protected readonly ClientStateManager $clientStateManager, - ) { - } + ) {} public static function make(): ServerBuilder { - return new ServerBuilder(); + return new ServerBuilder; } /** @@ -115,7 +115,7 @@ public function discover( * Binds the server's MCP logic to the provided transport and starts the transport's listener, * then runs the event loop, making this a BLOCKING call suitable for standalone servers. * - * For framework integration where the loop is managed externally, use `getProtocolHandler()` + * For framework integration where the loop is managed externally, use `getProtocol()` * and bind it to your framework's transport mechanism manually. * * @param ServerTransportInterface $transport The transport to listen with. @@ -138,18 +138,18 @@ public function listen(ServerTransportInterface $transport): void $transport->setLoop($this->configuration->loop); } - $protocolHandler = $this->getProtocolHandler(); + $protocol = $this->getProtocol(); - $closeHandlerCallback = function (?string $reason = null) use ($protocolHandler) { + $closeHandlerCallback = function (?string $reason = null) use ($protocol) { $this->isListening = false; $this->configuration->logger->info('Transport closed.', ['reason' => $reason ?? 'N/A']); - $protocolHandler->unbindTransport(); + $protocol->unbindTransport(); $this->configuration->loop->stop(); }; $transport->once('close', $closeHandlerCallback); - $protocolHandler->bindTransport($transport); + $protocol->bindTransport($transport); try { $transport->listen(); @@ -161,7 +161,7 @@ public function listen(ServerTransportInterface $transport): void } catch (Throwable $e) { $this->configuration->logger->critical('Failed to start listening or event loop crashed.', ['exception' => $e]); if ($this->isListening) { - $protocolHandler->unbindTransport(); + $protocol->unbindTransport(); $transport->removeListener('close', $closeHandlerCallback); // Remove listener $transport->close(); } @@ -169,7 +169,7 @@ public function listen(ServerTransportInterface $transport): void throw $e; } finally { if ($this->isListening) { - $protocolHandler->unbindTransport(); + $protocol->unbindTransport(); $transport->removeListener('close', $closeHandlerCallback); $transport->close(); } @@ -193,15 +193,15 @@ protected function warnIfNoElements(): void } /** - * Gets the ProtocolHandler instance associated with this server. + * Gets the Protocol instance associated with this server. * * Useful for framework integrations where the event loop and transport * communication are managed externally. */ - public function getProtocolHandler(): ProtocolHandler + public function getProtocol(): Protocol { - if ($this->protocolHandler === null) { - $this->protocolHandler = new ProtocolHandler( + if ($this->protocol === null) { + $this->protocol = new Protocol( $this->processor, $this->clientStateManager, $this->configuration->logger, @@ -209,7 +209,7 @@ public function getProtocolHandler(): ProtocolHandler ); } - return $this->protocolHandler; + return $this->protocol; } // --- Getters for Core Components --- diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index 080260e..5ab2a85 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -9,6 +9,7 @@ use PhpMcp\Server\Exception\ConfigurationException; use PhpMcp\Server\Exception\DefinitionException; use PhpMcp\Server\Model\Capabilities; +use PhpMcp\Server\State\ClientStateManager; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -44,9 +45,7 @@ final class ServerBuilder private array $manualPrompts = []; - public function __construct() - { - } + public function __construct() {} /** Sets the server's identity. Required. */ public function withServerInfo(string $name, string $version): self @@ -148,30 +147,9 @@ public function build(): Server } $loop = $this->loop ?? Loop::get(); - $logger = $this->logger ?? new NullLogger(); - $container = $this->container ?? new BasicContainer(); - $cache = $this->cache; - if ($cache === null) { - $defaultCacheDir = dirname(__DIR__, 2).'/cache'; - if (! is_dir($defaultCacheDir)) { - @mkdir($defaultCacheDir, 0775, true); - } - - $cacheFile = $defaultCacheDir.'/mcp_server_registry.cache'; - if (is_dir($defaultCacheDir) && (is_writable($defaultCacheDir) || is_writable($cacheFile))) { - try { - $cache = new FileCache($cacheFile); - } catch (\InvalidArgumentException $e) { - $logger->warning('Failed to initialize default FileCache, cache disabled.', ['path' => $cacheFile, 'error' => $e->getMessage()]); - $cache = null; - } - } else { - $logger->warning('Default cache directory not found or not writable, cache disabled.', ['path' => $defaultCacheDir]); - $cache = null; - } - } - + $logger = $this->logger ?? new NullLogger; + $container = $this->container ?? new BasicContainer; $capabilities = $this->capabilities ?? Capabilities::forServer(); $configuration = new Configuration( diff --git a/src/State/ClientState.php b/src/State/ClientState.php new file mode 100644 index 0000000..05f2ca6 --- /dev/null +++ b/src/State/ClientState.php @@ -0,0 +1,63 @@ + URIs this client is subscribed to. Key is URI, value is true. */ + public array $subscriptions = []; // This is the client's *view* of its subscriptions + + /** @var array Queued outgoing messages for this client. */ + public array $messageQueue = []; + + public int $lastActivityTimestamp; + + public ?string $requestedLogLevel = null; + + public function __construct(string $clientId) // clientId not stored here, used as cache key + { + $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(array $messageData): void + { + $this->messageQueue[] = $messageData; + } + + /** @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 new file mode 100644 index 0000000..2a40359 --- /dev/null +++ b/src/State/ClientStateManager.php @@ -0,0 +1,467 @@ +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; + } + } + + // --- Initialization --- + + public function isInitialized(string $clientId): bool + { + $state = $this->getClientState($clientId); + + return $state !== null && $state->isInitialized; + } + + 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]); + } + } + + 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); + } + } + + public function getClientInfo(string $clientId): ?array + { + return $this->getClientState($clientId)?->clientInfo; + } + + public function getProtocolVersion(string $clientId): ?string + { + return $this->getClientState($clientId)?->protocolVersion; + } + + // --- Subscriptions --- + + 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]); + } + } + + 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]); + } + } + + 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]); + } + } + + /** @return array 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 []; + } + } + + 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; + } + } + + // --- Message Queue --- + + public function queueMessage(string $clientId, Message|array $message): void + { + $state = $this->getClientState($clientId, true); + if (! $state) { + return; + } + + $newMessages = []; + if (is_array($message)) { + foreach ($message as $singleMessage) { + if ($singleMessage instanceof Message) { + $newMessages[] = $singleMessage->toArray(); + } + } + } elseif ($message instanceof Message) { + $newMessages[] = $message->toArray(); + } + + if (! empty($newMessages)) { + foreach ($newMessages as $msgData) { + $state->addMessageToQueue($msgData); + } + + $this->saveClientState($clientId, $state); + } + } + + public function queueMessageForAll(Message|array $message): void + { + $clients = $this->getActiveClients(); + + foreach ($clients as $clientId) { + $this->queueMessage($clientId, $message); + } + } + + /** @return array */ + 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; + } + + // --- Log Level Management --- + + /** + * 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; + } + + // --- Client Management --- + + 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); + } + + /** @return array 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) { /* log error */ return null; + } + } +} diff --git a/src/Transports/StdioServerTransport.php b/src/Transports/StdioServerTransport.php index 81bcae0..965129f 100644 --- a/src/Transports/StdioServerTransport.php +++ b/src/Transports/StdioServerTransport.php @@ -26,7 +26,7 @@ /** * Implementation of the STDIO server transport using ReactPHP Process and Streams. - * Listens on STDIN, writes to STDOUT, and emits events for the ProtocolHandler. + * Listens on STDIN, writes to STDOUT, and emits events for the Protocol. */ class StdioServerTransport implements LoggerAwareInterface, LoopAwareInterface, ServerTransportInterface { @@ -72,7 +72,7 @@ public function __construct( throw new TransportException('Invalid output stream resource provided.'); } - $this->logger = new NullLogger(); + $this->logger = new NullLogger; $this->loop = Loop::get(); } @@ -179,7 +179,7 @@ public function sendToClientAsync(string $clientId, string $rawFramedMessage): P return reject(new TransportException('Stdio transport is closed or STDOUT is not writable.')); } - $deferred = new Deferred(); + $deferred = new Deferred; $written = $this->stdout->write($rawFramedMessage); if ($written) { diff --git a/tests/Mocks/DiscoveryStubs/AbstractStub.php b/tests/Mocks/DiscoveryStubs/AbstractStub.php index ff4fb44..c28acb4 100644 --- a/tests/Mocks/DiscoveryStubs/AbstractStub.php +++ b/tests/Mocks/DiscoveryStubs/AbstractStub.php @@ -1,9 +1,11 @@ - $param5 Array description. * @param \stdClass $param6 Object param. */ - public function methodWithParams(string $param1, ?int $param2, bool $param3, $param4, array $param5, \stdClass $param6): void {} + public function methodWithParams(string $param1, ?int $param2, bool $param3, $param4, array $param5, \stdClass $param6): void + { + } /** * Method with return tag. * * @return string The result of the operation. */ - public function methodWithReturn(): string { return ''; } + public function methodWithReturn(): string + { + return ''; + } /** * Method with multiple tags. @@ -51,15 +60,24 @@ public function methodWithReturn(): string { return ''; } * @deprecated Use newMethod() instead. * @see \PhpMcp\Server\Tests\Mocks\SupportStubs\DocBlockTestStub::newMethod() */ - public function methodWithMultipleTags(float $value): bool { return true; } + public function methodWithMultipleTags(float $value): bool + { + return true; + } /** * Malformed docblock - missing closing */ - public function methodWithMalformedDocBlock(): void {} + public function methodWithMalformedDocBlock(): void + { + } - public function methodWithNoDocBlock(): void {} + public function methodWithNoDocBlock(): void + { + } // Some other method needed for a @see tag perhaps - public function newMethod(): void {} + public function newMethod(): void + { + } } diff --git a/tests/Mocks/SupportStubs/SchemaGeneratorTestStub.php b/tests/Mocks/SupportStubs/SchemaGeneratorTestStub.php index 334e1bd..00860bf 100644 --- a/tests/Mocks/SupportStubs/SchemaGeneratorTestStub.php +++ b/tests/Mocks/SupportStubs/SchemaGeneratorTestStub.php @@ -9,7 +9,9 @@ */ class SchemaGeneratorTestStub { - public function noParams(): void {} + public function noParams(): void + { + } /** * Method with simple required types. @@ -20,7 +22,9 @@ public function noParams(): void {} * @param array $p5 Array param * @param stdClass $p6 Object param */ - public function simpleRequired(string $p1, int $p2, bool $p3, float $p4, array $p5, stdClass $p6): void {} + public function simpleRequired(string $p1, int $p2, bool $p3, float $p4, array $p5, stdClass $p6): void + { + } /** * Method with simple optional types with default values. @@ -38,7 +42,8 @@ public function simpleOptionalDefaults( float $p4 = 1.23, array $p5 = ['a', 'b'], ?stdClass $p6 = null - ): void {} + ): void { + } /** * Method with nullable types without explicit defaults. @@ -46,21 +51,27 @@ public function simpleOptionalDefaults( * @param ?int $p2 Nullable int shorthand * @param ?bool $p3 Nullable bool */ - public function nullableWithoutDefault(?string $p1, ?int $p2, ?bool $p3): void {} + public function nullableWithoutDefault(?string $p1, ?int $p2, ?bool $p3): void + { + } /** * Method with nullable types WITH explicit null defaults. * @param string|null $p1 Nullable string with default * @param ?int $p2 Nullable int shorthand with default */ - public function nullableWithNullDefault(?string $p1 = null, ?int $p2 = null): void {} + public function nullableWithNullDefault(?string $p1 = null, ?int $p2 = null): void + { + } /** * Method with union types. * @param string|int $p1 String or Int * @param bool|string|null $p2 Bool, String or Null */ - public function unionTypes(string|int $p1, bool|null|string $p2): void {} + public function unionTypes(string|int $p1, bool|null|string $p2): void + { + } /** * Method with various array types. @@ -78,7 +89,8 @@ public function arrayTypes( array $p4, array $p5, array $p6 - ): void {} + ): void { + } /** * Method with various enum types (requires PHP 8.1+). @@ -96,33 +108,42 @@ public function enumTypes( ?BackedStringEnum $p4, BackedIntEnum $p5 = BackedIntEnum::First, ?UnitEnum $p6 = null - ): void {} + ): void { + } /** * Method with variadic parameters. * @param string ...$items Variadic strings */ - public function variadicParam(string ...$items): void {} + 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 {} + 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 {} + 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 {} + public function docBlockOverrides(int $p1): void + { + } /** * Method with parameters implying formats. @@ -130,8 +151,10 @@ public function docBlockOverrides(int $p1): void {} * @param string $url URL string * @param string $dateTime ISO Date time string */ - public function formatParams(string $email, string $url, string $dateTime): void {} + 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 {} -} \ No newline at end of file +} diff --git a/tests/Mocks/SupportStubs/UnitEnum.php b/tests/Mocks/SupportStubs/UnitEnum.php index efe1e54..a7dd657 100644 --- a/tests/Mocks/SupportStubs/UnitEnum.php +++ b/tests/Mocks/SupportStubs/UnitEnum.php @@ -1,9 +1,9 @@ 'object', 'properties' => ['arg1' => ['type' => 'string']]] - ); -} - -function createTestResource(string $uri = 'file:///test.res', string $name = 'test-resource'): \PhpMcp\Server\Definitions\ResourceDefinition -{ - return new \PhpMcp\Server\Definitions\ResourceDefinition( - className: 'Test\\ResourceClass', - methodName: 'readResource', - uri: $uri, - name: $name, - description: 'A test resource', - mimeType: 'text/plain', - size: null, - annotations: [] - ); -} - -function createTestPrompt(string $name = 'test-prompt'): \PhpMcp\Server\Definitions\PromptDefinition -{ - return new \PhpMcp\Server\Definitions\PromptDefinition( - className: 'Test\\PromptClass', - methodName: 'getPrompt', - promptName: $name, - description: 'A test prompt', - arguments: [] - ); -} - -function createTestTemplate(string $uriTemplate = 'tmpl://{id}/data', string $name = 'test-template'): \PhpMcp\Server\Definitions\ResourceTemplateDefinition -{ - return new \PhpMcp\Server\Definitions\ResourceTemplateDefinition( - className: 'Test\\TemplateClass', - methodName: 'readTemplate', - uriTemplate: $uriTemplate, - name: $name, - description: 'A test template', - mimeType: 'application/json', - annotations: [] - ); -} diff --git a/tests/Unit/Attributes/McpPromptTest.php b/tests/Unit/Attributes/McpPromptTest.php index 0d6466b..8d21568 100644 --- a/tests/Unit/Attributes/McpPromptTest.php +++ b/tests/Unit/Attributes/McpPromptTest.php @@ -4,7 +4,7 @@ use PhpMcp\Server\Attributes\McpPrompt; -test('constructor assigns properties correctly for McpPrompt', function () { +it('instantiates with name and description', function () { // Arrange $name = 'test-prompt-name'; $description = 'This is a test prompt description.'; @@ -17,7 +17,7 @@ expect($attribute->description)->toBe($description); }); -test('constructor handles null values for McpPrompt', function () { +it('instantiates with null values for name and description', function () { // Arrange & Act $attribute = new McpPrompt(name: null, description: null); @@ -26,12 +26,11 @@ expect($attribute->description)->toBeNull(); }); -test('constructor handles missing optional arguments for McpPrompt', function () { +it('instantiates with missing optional arguments', function () { // Arrange & Act - $attribute = new McpPrompt(); // Use default constructor values + $attribute = new McpPrompt; // Use default constructor values // Assert - // Check default values (assuming they are null) expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); }); diff --git a/tests/Unit/Attributes/McpResourceTemplateTest.php b/tests/Unit/Attributes/McpResourceTemplateTest.php index 70e4931..99ba685 100644 --- a/tests/Unit/Attributes/McpResourceTemplateTest.php +++ b/tests/Unit/Attributes/McpResourceTemplateTest.php @@ -4,7 +4,7 @@ use PhpMcp\Server\Attributes\McpResourceTemplate; -test('constructor assigns properties correctly for McpResourceTemplate', function () { +it('instantiates with correct properties', function () { // Arrange $uriTemplate = 'file:///{path}/data'; $name = 'test-template-name'; @@ -29,7 +29,7 @@ expect($attribute->annotations)->toBe($annotations); }); -test('constructor handles null values for McpResourceTemplate', function () { +it('instantiates with null values for name and description', function () { // Arrange & Act $attribute = new McpResourceTemplate( uriTemplate: 'test://{id}', // uriTemplate is required @@ -47,14 +47,13 @@ expect($attribute->annotations)->toBe([]); }); -test('constructor handles missing optional arguments for McpResourceTemplate', function () { +it('instantiates with missing optional arguments', function () { // Arrange & Act $uriTemplate = 'tmpl://{key}'; $attribute = new McpResourceTemplate(uriTemplate: $uriTemplate); // Assert expect($attribute->uriTemplate)->toBe($uriTemplate); - // Check default values (assuming they are null or empty array) expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); diff --git a/tests/Unit/Attributes/McpResourceTest.php b/tests/Unit/Attributes/McpResourceTest.php index fa4d7ef..b541ddb 100644 --- a/tests/Unit/Attributes/McpResourceTest.php +++ b/tests/Unit/Attributes/McpResourceTest.php @@ -4,7 +4,7 @@ use PhpMcp\Server\Attributes\McpResource; -test('constructor assigns properties correctly for McpResource', function () { +it('instantiates with correct properties', function () { // Arrange $uri = 'file:///test/resource'; $name = 'test-resource-name'; @@ -32,7 +32,7 @@ expect($attribute->annotations)->toBe($annotations); }); -test('constructor handles null values for McpResource', function () { +it('instantiates with null values for name and description', function () { // Arrange & Act $attribute = new McpResource( uri: 'file:///test', // URI is required @@ -52,14 +52,13 @@ expect($attribute->annotations)->toBe([]); }); -test('constructor handles missing optional arguments for McpResource', function () { +it('instantiates with missing optional arguments', function () { // Arrange & Act $uri = 'file:///only-uri'; $attribute = new McpResource(uri: $uri); // Assert expect($attribute->uri)->toBe($uri); - // Check default values (assuming they are null or empty array) expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); diff --git a/tests/Unit/Attributes/McpToolTest.php b/tests/Unit/Attributes/McpToolTest.php index c37268e..2e408b8 100644 --- a/tests/Unit/Attributes/McpToolTest.php +++ b/tests/Unit/Attributes/McpToolTest.php @@ -4,7 +4,7 @@ use PhpMcp\Server\Attributes\McpTool; -test('constructor assigns properties correctly for McpTool', function () { +it('instantiates with correct properties', function () { // Arrange $name = 'test-tool-name'; $description = 'This is a test description.'; @@ -17,7 +17,7 @@ expect($attribute->description)->toBe($description); }); -test('constructor handles null values for McpTool', function () { +it('instantiates with null values for name and description', function () { // Arrange & Act $attribute = new McpTool(name: null, description: null); @@ -26,12 +26,11 @@ expect($attribute->description)->toBeNull(); }); -test('constructor handles missing optional arguments for McpTool', function () { +it('instantiates with missing optional arguments', function () { // Arrange & Act - $attribute = new McpTool(); // Use default constructor values + $attribute = new McpTool; // Use default constructor values // Assert - // Check default values (assuming they are null) expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); }); diff --git a/tests/Unit/ClientStateManagerTest.php b/tests/Unit/ClientStateManagerTest.php deleted file mode 100644 index e508e54..0000000 --- a/tests/Unit/ClientStateManagerTest.php +++ /dev/null @@ -1,402 +0,0 @@ -cache = Mockery::mock(CacheInterface::class); - /** @var MockInterface&LoggerInterface */ - $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - - // Instance WITH cache - $this->stateManager = new ClientStateManager( - $this->logger, - $this->cache, - CACHE_PREFIX_MGR, - CACHE_TTL_MGR - ); - - // Instance WITHOUT cache - $this->stateManagerNoCache = new ClientStateManager($this->logger, null); -}); - -afterEach(function () { - Mockery::close(); -}); - -// Helper to generate expected cache keys for this test file -function getMgrCacheKey(string $key, ?string $clientId = null): string -{ - return $clientId ? CACHE_PREFIX_MGR."{$key}_{$clientId}" : CACHE_PREFIX_MGR.$key; -} - -// --- Tests --- - -// --- Initialization --- -test('isInitialized returns false if cache unavailable', function () { - expect($this->stateManagerNoCache->isInitialized(TEST_CLIENT_ID_MGR))->toBeFalse(); -}); - -test('isInitialized checks cache correctly', function () { - $initializedKey = getMgrCacheKey('initialized', TEST_CLIENT_ID_MGR); - $this->cache->shouldReceive('get')->once()->with($initializedKey, false)->andReturn(false); - $this->cache->shouldReceive('get')->once()->with($initializedKey, false)->andReturn(true); - - expect($this->stateManager->isInitialized(TEST_CLIENT_ID_MGR))->toBeFalse(); - expect($this->stateManager->isInitialized(TEST_CLIENT_ID_MGR))->toBeTrue(); -}); - -test('markInitialized logs warning if cache unavailable', function () { - $this->logger->shouldReceive('warning')->once()->with(Mockery::pattern('/cache not available/'), Mockery::any()); - $this->stateManagerNoCache->markInitialized(TEST_CLIENT_ID_MGR); // No exception thrown -}); - -test('markInitialized sets cache and updates activity', function () { - $initializedKey = getMgrCacheKey('initialized', TEST_CLIENT_ID_MGR); - $activityKey = getMgrCacheKey('active_clients'); - - $this->cache->shouldReceive('set')->once()->with($initializedKey, true, CACHE_TTL_MGR)->andReturn(true); - // Expect updateClientActivity call - $this->cache->shouldReceive('get')->once()->with($activityKey, [])->andReturn([]); - $this->cache->shouldReceive('set')->once()->with($activityKey, Mockery::on(fn ($arg) => isset($arg[TEST_CLIENT_ID_MGR])), CACHE_TTL_MGR)->andReturn(true); - - $this->stateManager->markInitialized(TEST_CLIENT_ID_MGR); // No exception thrown -}); - -test('markInitialized handles cache exceptions', function () { - $initializedKey = getMgrCacheKey('initialized', TEST_CLIENT_ID_MGR); - $this->cache->shouldReceive('set')->once()->with($initializedKey, true, CACHE_TTL_MGR)->andThrow(new class () extends \Exception implements CacheInvalidArgumentException {}); - $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/Failed to mark client.*invalid key/'), Mockery::any()); - - $this->stateManager->markInitialized(TEST_CLIENT_ID_MGR); // No exception thrown outwards -}); - -// --- Client Info --- -test('storeClientInfo does nothing if cache unavailable', function () { - $this->cache->shouldNotReceive('set'); - $this->stateManagerNoCache->storeClientInfo([], 'v1', TEST_CLIENT_ID_MGR); -}); - -test('storeClientInfo sets cache keys', function () { - $clientInfoKey = getMgrCacheKey('client_info', TEST_CLIENT_ID_MGR); - $protocolKey = getMgrCacheKey('protocol_version', TEST_CLIENT_ID_MGR); - $clientInfo = ['name' => 'TestClientState', 'version' => '1.1']; - $protocolVersion = '2024-11-05'; - - $this->cache->shouldReceive('set')->once()->with($clientInfoKey, $clientInfo, CACHE_TTL_MGR)->andReturn(true); - $this->cache->shouldReceive('set')->once()->with($protocolKey, $protocolVersion, CACHE_TTL_MGR)->andReturn(true); - - $this->stateManager->storeClientInfo($clientInfo, $protocolVersion, TEST_CLIENT_ID_MGR); -}); - -test('getClientInfo returns null if cache unavailable', function () { - expect($this->stateManagerNoCache->getClientInfo(TEST_CLIENT_ID_MGR))->toBeNull(); -}); - -test('getClientInfo gets value from cache', function () { - $clientInfoKey = getMgrCacheKey('client_info', TEST_CLIENT_ID_MGR); - $clientInfo = ['name' => 'TestClientStateGet', 'version' => '1.2']; - $this->cache->shouldReceive('get')->once()->with($clientInfoKey)->andReturn($clientInfo); - - expect($this->stateManager->getClientInfo(TEST_CLIENT_ID_MGR))->toBe($clientInfo); -}); - -test('getProtocolVersion returns null if cache unavailable', function () { - expect($this->stateManagerNoCache->getProtocolVersion(TEST_CLIENT_ID_MGR))->toBeNull(); -}); - -test('getProtocolVersion gets value from cache', function () { - $protocolKey = getMgrCacheKey('protocol_version', TEST_CLIENT_ID_MGR); - $protocolVersion = '2024-11-05-test'; - $this->cache->shouldReceive('get')->once()->with($protocolKey)->andReturn($protocolVersion); - - expect($this->stateManager->getProtocolVersion(TEST_CLIENT_ID_MGR))->toBe($protocolVersion); -}); - -// --- Subscriptions --- -test('addResourceSubscription logs warning if cache unavailable', function () { - $this->logger->shouldReceive('warning')->once()->with(Mockery::pattern('/Cannot add resource subscription.*cache not available/'), Mockery::any()); - $this->stateManagerNoCache->addResourceSubscription(TEST_CLIENT_ID_MGR, TEST_URI_MGR_1); -}); - -test('addResourceSubscription sets cache keys', function () { - $clientSubKey = getMgrCacheKey('client_subscriptions', TEST_CLIENT_ID_MGR); - $resourceSubKey = getMgrCacheKey('resource_subscriptions', TEST_URI_MGR_1); - - $this->cache->shouldReceive('get')->once()->with($clientSubKey, [])->andReturn(['other/uri' => true]); // Existing data - $this->cache->shouldReceive('get')->once()->with($resourceSubKey, [])->andReturn(['other_client' => true]); - $this->cache->shouldReceive('set')->once()->with($clientSubKey, ['other/uri' => true, TEST_URI_MGR_1 => true], CACHE_TTL_MGR)->andReturn(true); - $this->cache->shouldReceive('set')->once()->with($resourceSubKey, ['other_client' => true, TEST_CLIENT_ID_MGR => true], CACHE_TTL_MGR)->andReturn(true); - - $this->stateManager->addResourceSubscription(TEST_CLIENT_ID_MGR, TEST_URI_MGR_1); -}); - -test('isSubscribedToResource returns false if cache unavailable', function () { - expect($this->stateManagerNoCache->isSubscribedToResource(TEST_CLIENT_ID_MGR, TEST_URI_MGR_1))->toBeFalse(); -}); - -test('isSubscribedToResource checks cache correctly', function () { - $clientSubKey = getMgrCacheKey('client_subscriptions', TEST_CLIENT_ID_MGR); - $this->cache->shouldReceive('get')->with($clientSubKey, [])->andReturn([TEST_URI_MGR_1 => true, 'another/one' => true]); - - expect($this->stateManager->isSubscribedToResource(TEST_CLIENT_ID_MGR, TEST_URI_MGR_1))->toBeTrue(); - expect($this->stateManager->isSubscribedToResource(TEST_CLIENT_ID_MGR, TEST_URI_MGR_2))->toBeFalse(); -}); - -test('getResourceSubscribers returns empty array if cache unavailable', function () { - expect($this->stateManagerNoCache->getResourceSubscribers(TEST_URI_MGR_1))->toBe([]); -}); - -test('getResourceSubscribers gets subscribers from cache', function () { - $resourceSubKey = getMgrCacheKey('resource_subscriptions', TEST_URI_MGR_1); - $this->cache->shouldReceive('get')->with($resourceSubKey, [])->andReturn([TEST_CLIENT_ID_MGR => true, 'client2' => true]); - - expect($this->stateManager->getResourceSubscribers(TEST_URI_MGR_1))->toEqualCanonicalizing([TEST_CLIENT_ID_MGR, 'client2']); -}); - -test('removeResourceSubscription does nothing if cache unavailable', function () { - $this->cache->shouldNotReceive('get'); - $this->cache->shouldNotReceive('set'); - $this->cache->shouldNotReceive('delete'); - $this->stateManagerNoCache->removeResourceSubscription(TEST_CLIENT_ID_MGR, TEST_URI_MGR_1); -}); - -test('removeResourceSubscription removes keys and deletes if empty', function () { - $clientSubKey = getMgrCacheKey('client_subscriptions', TEST_CLIENT_ID_MGR); - $resourceSubKey1 = getMgrCacheKey('resource_subscriptions', TEST_URI_MGR_1); - $resourceSubKey2 = getMgrCacheKey('resource_subscriptions', TEST_URI_MGR_2); - - // Initial state - $clientSubs = [TEST_URI_MGR_1 => true, TEST_URI_MGR_2 => true]; - $res1Subs = [TEST_CLIENT_ID_MGR => true, 'other' => true]; - $res2Subs = [TEST_CLIENT_ID_MGR => true]; // Only this client - - // Mocks for removing sub for TEST_URI_MGR_1 - $this->cache->shouldReceive('get')->with($clientSubKey, [])->once()->andReturn($clientSubs); - $this->cache->shouldReceive('get')->with($resourceSubKey1, [])->once()->andReturn($res1Subs); - $this->cache->shouldReceive('set')->with($clientSubKey, [TEST_URI_MGR_2 => true], CACHE_TTL_MGR)->once()->andReturn(true); // URI 1 removed - $this->cache->shouldReceive('set')->with($resourceSubKey1, ['other' => true], CACHE_TTL_MGR)->once()->andReturn(true); // Client removed - - $this->stateManager->removeResourceSubscription(TEST_CLIENT_ID_MGR, TEST_URI_MGR_1); - - // Mocks for removing sub for TEST_URI_MGR_2 (which will cause deletes) - $this->cache->shouldReceive('get')->with($clientSubKey, [])->once()->andReturn([TEST_URI_MGR_2 => true]); // State after previous call - $this->cache->shouldReceive('get')->with($resourceSubKey2, [])->once()->andReturn($res2Subs); - $this->cache->shouldReceive('delete')->with($clientSubKey)->once()->andReturn(true); // Client list now empty - $this->cache->shouldReceive('delete')->with($resourceSubKey2)->once()->andReturn(true); // Resource list now empty - - $this->stateManager->removeResourceSubscription(TEST_CLIENT_ID_MGR, TEST_URI_MGR_2); -}); - -test('removeAllResourceSubscriptions does nothing if cache unavailable', function () { - $this->cache->shouldNotReceive('get'); - $this->cache->shouldNotReceive('delete'); - $this->stateManagerNoCache->removeAllResourceSubscriptions(TEST_CLIENT_ID_MGR); -}); - -test('removeAllResourceSubscriptions clears relevant cache entries', function () { - $clientSubKey = getMgrCacheKey('client_subscriptions', TEST_CLIENT_ID_MGR); - $resourceSubKey1 = getMgrCacheKey('resource_subscriptions', TEST_URI_MGR_1); - $resourceSubKey2 = getMgrCacheKey('resource_subscriptions', TEST_URI_MGR_2); - - $initialClientSubs = [TEST_URI_MGR_1 => true, TEST_URI_MGR_2 => true]; - $initialResourceSubs1 = [TEST_CLIENT_ID_MGR => true, 'other' => true]; - $initialResourceSubs2 = [TEST_CLIENT_ID_MGR => true]; - - // Get the client's subscription list - $this->cache->shouldReceive('get')->once()->with($clientSubKey, [])->andReturn($initialClientSubs); - // Get the subscriber list for each resource the client was subscribed to - $this->cache->shouldReceive('get')->once()->with($resourceSubKey1, [])->andReturn($initialResourceSubs1); - $this->cache->shouldReceive('get')->once()->with($resourceSubKey2, [])->andReturn($initialResourceSubs2); - // Update the first resource's list - $this->cache->shouldReceive('set')->once()->with($resourceSubKey1, ['other' => true], CACHE_TTL_MGR)->andReturn(true); - // Delete the second resource's list (now empty) - $this->cache->shouldReceive('deleteMultiple')->once()->with([$resourceSubKey2])->andReturn(true); - // Delete the client's subscription list - $this->cache->shouldReceive('delete')->once()->with($clientSubKey)->andReturn(true); - - $this->stateManager->removeAllResourceSubscriptions(TEST_CLIENT_ID_MGR); -}); - -// --- Message Queue --- -test('queueMessage logs warning if cache unavailable', function () { - $this->logger->shouldReceive('warning')->once()->with(Mockery::pattern('/Cannot queue message.*cache not available/'), Mockery::any()); - $this->stateManagerNoCache->queueMessage(TEST_CLIENT_ID_MGR, new Notification('2.0', 'm')); -}); - -test('queueMessage adds single message to cache', function () { - $messageKey = getMgrCacheKey('messages', TEST_CLIENT_ID_MGR); - $notification = new Notification('2.0', 'test/event', ['data' => 1]); - - $this->cache->shouldReceive('get')->once()->with($messageKey, [])->andReturn([]); // Start empty - $this->cache->shouldReceive('set')->once()->with($messageKey, [$notification->toArray()], CACHE_TTL_MGR)->andReturn(true); - - $this->stateManager->queueMessage(TEST_CLIENT_ID_MGR, $notification); -}); - -test('queueMessage appends to existing messages in cache', function () { - $messageKey = getMgrCacheKey('messages', TEST_CLIENT_ID_MGR); - $existingMsg = ['jsonrpc' => '2.0', 'method' => 'existing']; - $notification = new Notification('2.0', 'test/event', ['data' => 2]); - - $this->cache->shouldReceive('get')->once()->with($messageKey, [])->andReturn([$existingMsg]); // Start with one message - $this->cache->shouldReceive('set')->once()->with($messageKey, [$existingMsg, $notification->toArray()], CACHE_TTL_MGR)->andReturn(true); - - $this->stateManager->queueMessage(TEST_CLIENT_ID_MGR, $notification); -}); - -test('queueMessage handles array of messages', function () { - $messageKey = getMgrCacheKey('messages', TEST_CLIENT_ID_MGR); - $notification1 = new Notification('2.0', 'msg1'); - $notification2 = new Notification('2.0', 'msg2'); - $messages = [$notification1, $notification2]; - $expectedData = [$notification1->toArray(), $notification2->toArray()]; - - $this->cache->shouldReceive('get')->once()->with($messageKey, [])->andReturn([]); - $this->cache->shouldReceive('set')->once()->with($messageKey, $expectedData, CACHE_TTL_MGR)->andReturn(true); - - $this->stateManager->queueMessage(TEST_CLIENT_ID_MGR, $messages); -}); - -test('getQueuedMessages returns empty array if cache unavailable', function () { - expect($this->stateManagerNoCache->getQueuedMessages(TEST_CLIENT_ID_MGR))->toBe([]); -}); - -test('getQueuedMessages retrieves and deletes messages from cache', function () { - $messageKey = getMgrCacheKey('messages', TEST_CLIENT_ID_MGR); - $messagesData = [['method' => 'msg1'], ['method' => 'msg2']]; - - $this->cache->shouldReceive('get')->once()->with($messageKey, [])->andReturn($messagesData); - $this->cache->shouldReceive('delete')->once()->with($messageKey)->andReturn(true); - - $retrieved = $this->stateManager->getQueuedMessages(TEST_CLIENT_ID_MGR); - expect($retrieved)->toEqual($messagesData); - - // Verify cache is now empty - $this->cache->shouldReceive('get')->once()->with($messageKey, [])->andReturn([]); - expect($this->stateManager->getQueuedMessages(TEST_CLIENT_ID_MGR))->toBe([]); -}); - -// --- Client Management --- -test('cleanupClient logs warning if cache unavailable', function () { - $this->logger->shouldReceive('warning')->once()->with(Mockery::pattern('/Cannot perform full client cleanup.*cache not available/'), Mockery::any()); - $this->stateManagerNoCache->cleanupClient(TEST_CLIENT_ID_MGR); -}); - -test('cleanupClient removes client data and optionally from active list', function ($removeFromActive) { - $clientId = 'client-mgr-remove'; - $clientSubKey = getMgrCacheKey('client_subscriptions', $clientId); - $activeKey = getMgrCacheKey('active_clients'); - $initialActive = [$clientId => time(), 'other' => time()]; - $keysToDelete = [ - getMgrCacheKey('initialized', $clientId), getMgrCacheKey('client_info', $clientId), - getMgrCacheKey('protocol_version', $clientId), getMgrCacheKey('messages', $clientId), - ]; - - // Assume no subs for simplicity - $this->cache->shouldReceive('get')->once()->with($clientSubKey, [])->andReturn([]); - - if ($removeFromActive) { - $this->cache->shouldReceive('get')->once()->with($activeKey, [])->andReturn($initialActive); - $this->cache->shouldReceive('set')->once()->with($activeKey, Mockery::on(fn ($arg) => ! isset($arg[$clientId])), CACHE_TTL_MGR)->andReturn(true); - } else { - $this->cache->shouldNotReceive('get')->with($activeKey, []); - $this->cache->shouldNotReceive('set')->with($activeKey, Mockery::any(), Mockery::any()); - } - - $this->cache->shouldReceive('deleteMultiple')->once()->with(Mockery::on(function ($arg) use ($keysToDelete) { - return is_array($arg) && empty(array_diff($keysToDelete, $arg)) && empty(array_diff($arg, $keysToDelete)); - }))->andReturn(true); - - $this->stateManager->cleanupClient($clientId, $removeFromActive); - -})->with([ - 'Remove From Active List' => [true], - 'Keep In Active List' => [false], -]); - -test('updateClientActivity does nothing if cache unavailable', function () { - $this->cache->shouldNotReceive('get'); - $this->cache->shouldNotReceive('set'); - $this->stateManagerNoCache->updateClientActivity(TEST_CLIENT_ID_MGR); -}); - -test('updateClientActivity updates timestamp in cache', function () { - $activeKey = getMgrCacheKey('active_clients'); - $startTime = time(); - - $this->cache->shouldReceive('get')->once()->with($activeKey, [])->andReturn(['other' => $startTime - 10]); - $this->cache->shouldReceive('set')->once()->with($activeKey, Mockery::on(function ($arg) use ($startTime) { - return isset($arg[TEST_CLIENT_ID_MGR]) && $arg[TEST_CLIENT_ID_MGR] >= $startTime; - }), CACHE_TTL_MGR)->andReturn(true); - - $this->stateManager->updateClientActivity(TEST_CLIENT_ID_MGR); -}); - -test('getActiveClients returns empty array if cache unavailable', function () { - expect($this->stateManagerNoCache->getActiveClients())->toBe([]); -}); - -test('getActiveClients filters inactive and cleans up', function () { - $activeKey = getMgrCacheKey('active_clients'); - $clientActive1 = 'client-mgr-active1'; - $clientActive2 = 'client-mgr-active2'; - $clientInactive = 'client-mgr-inactive'; - $clientInvalidTs = 'client-mgr-invalid-ts'; - $now = time(); - $activeClientsData = [ - $clientActive1 => $now - 10, - $clientActive2 => $now - CACHE_TTL_MGR + 10, // Still active relative to default threshold - $clientInactive => $now - CACHE_TTL_MGR - 1, // Inactive - $clientInvalidTs => 'not-a-timestamp', // Invalid data - ]; - $expectedFinalActiveList = [ - $clientActive1 => $activeClientsData[$clientActive1], - $clientActive2 => $activeClientsData[$clientActive2], - ]; - - // 1. Initial get for filtering - $this->cache->shouldReceive('get')->once()->with($activeKey, [])->andReturn($activeClientsData); - // 2. Set the filtered list back (inactive and invalid removed) - $this->cache->shouldReceive('set')->once()->with($activeKey, $expectedFinalActiveList, CACHE_TTL_MGR)->andReturn(true); - // 3. Cleanup for inactive client - $this->cache->shouldReceive('get')->once()->with(getMgrCacheKey('client_subscriptions', $clientInactive), [])->andReturn([]); - $this->cache->shouldReceive('deleteMultiple')->once()->with(Mockery::on(fn ($keys) => in_array(getMgrCacheKey('initialized', $clientInactive), $keys)))->andReturn(true); - // 4. Cleanup for client with invalid timestamp - $this->cache->shouldReceive('get')->once()->with(getMgrCacheKey('client_subscriptions', $clientInvalidTs), [])->andReturn([]); - $this->cache->shouldReceive('deleteMultiple')->once()->with(Mockery::on(fn ($keys) => in_array(getMgrCacheKey('initialized', $clientInvalidTs), $keys)))->andReturn(true); - - $active = $this->stateManager->getActiveClients(CACHE_TTL_MGR); // Use TTL as threshold for testing - - expect($active)->toEqualCanonicalizing([$clientActive1, $clientActive2]); -}); - -test('getLastActivityTime returns null if cache unavailable', function () { - expect($this->stateManagerNoCache->getLastActivityTime(TEST_CLIENT_ID_MGR))->toBeNull(); -}); - -test('getLastActivityTime returns timestamp or null from cache', function () { - $activeKey = getMgrCacheKey('active_clients'); - $now = time(); - $cacheData = [TEST_CLIENT_ID_MGR => $now - 50, 'other' => $now - 100]; - - $this->cache->shouldReceive('get')->with($activeKey, [])->times(3)->andReturn($cacheData); - - expect($this->stateManager->getLastActivityTime(TEST_CLIENT_ID_MGR))->toBe($now - 50); - expect($this->stateManager->getLastActivityTime('other'))->toBe($now - 100); - expect($this->stateManager->getLastActivityTime('nonexistent'))->toBeNull(); -}); diff --git a/tests/Unit/JsonRpc/ResponseTest.php b/tests/Unit/JsonRpc/ResponseTest.php index f3c6dc9..2422d73 100644 --- a/tests/Unit/JsonRpc/ResponseTest.php +++ b/tests/Unit/JsonRpc/ResponseTest.php @@ -3,28 +3,27 @@ namespace PhpMcp\Server\Tests\Unit\JsonRpc; use InvalidArgumentException; -// Use base exception for factory methods maybe -use PhpMcp\Server\Exception\ProtocolException; // Use this for fromArray errors +use PhpMcp\Server\Exception\ProtocolException; use PhpMcp\Server\JsonRpc\Error; use PhpMcp\Server\JsonRpc\Response; -use PhpMcp\Server\JsonRpc\Result; // Keep for testing ::success factory -use PhpMcp\Server\JsonRpc\Results\EmptyResult; // Needed for type hints in factories +use PhpMcp\Server\JsonRpc\Result; +use PhpMcp\Server\JsonRpc\Results\EmptyResult; -// --- Construction and Factory Tests (Mostly Unchanged) --- +// --- Construction and Factory Tests --- test('response construction sets all properties for success response', function () { - $resultObject = new EmptyResult(); // Use Result object for constructor test consistency + $resultObject = new EmptyResult; $response = new Response('2.0', 1, $resultObject, null); expect($response->jsonrpc)->toBe('2.0'); expect($response->id)->toBe(1); - expect($response->result)->toBeInstanceOf(EmptyResult::class); // Constructor stores what's passed + 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); // Pass null ID if applicable + $response = new Response('2.0', 1, null, $error); expect($response->jsonrpc)->toBe('2.0'); expect($response->id)->toBe(1); @@ -34,7 +33,7 @@ test('response construction allows null ID for error response', function () { $error = new Error(100, 'Test error'); - $response = new Response('2.0', null, null, $error); // Null ID allowed with error + $response = new Response('2.0', null, null, $error); expect($response->id)->toBeNull(); expect($response->error)->toBe($error); @@ -57,24 +56,24 @@ }); test('response throws exception if both result and error are provided with ID', function () { - $result = new EmptyResult(); + $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); // Factory still takes Result object + $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); // Stores the Result object + 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); // With ID + $response = Response::error($error, 1); expect($response->jsonrpc)->toBe('2.0'); expect($response->id)->toBe(1); @@ -84,7 +83,7 @@ test('error static method creates error response with null ID', function () { $error = new Error(100, 'Parse error'); - $response = Response::error($error, null); // Null ID + $response = Response::error($error, null); expect($response->jsonrpc)->toBe('2.0'); expect($response->id)->toBeNull(); @@ -92,11 +91,11 @@ expect($response->error)->toBeInstanceOf(Error::class); }); -// --- Status Check Tests (Unchanged) --- +// --- Status Check Tests --- test('isSuccess returns true for success response', function () { - $result = new EmptyResult(); - $response = Response::success($result, 1); // Use factory + $result = new EmptyResult; + $response = Response::success($result, 1); expect($response->isSuccess())->toBeTrue(); }); @@ -113,7 +112,7 @@ }); test('isError returns false for success response', function () { - $result = new EmptyResult(); + $result = new EmptyResult; $response = Response::success($result, 1); expect($response->isError())->toBeFalse(); }); @@ -121,20 +120,19 @@ // --- fromArray Tests (Updated) --- test('fromArray creates valid success response with RAW result data', function () { - $rawResultData = ['key' => 'value', 'items' => [1, 2]]; // Example raw result + $rawResultData = ['key' => 'value', 'items' => [1, 2]]; $data = [ 'jsonrpc' => '2.0', 'id' => 1, - 'result' => $rawResultData, // Use raw data here + 'result' => $rawResultData, ]; $response = Response::fromArray($data); expect($response->jsonrpc)->toBe('2.0'); expect($response->id)->toBe(1); - // *** Assert the RAW result data is stored *** expect($response->result)->toEqual($rawResultData); - expect($response->result)->not->toBeInstanceOf(Result::class); // It shouldn't be a Result object yet + expect($response->result)->not->toBeInstanceOf(Result::class); expect($response->error)->toBeNull(); expect($response->isSuccess())->toBeTrue(); }); @@ -160,7 +158,7 @@ test('fromArray creates valid error response with null ID', function () { $data = [ 'jsonrpc' => '2.0', - 'id' => null, // Explicit null ID + 'id' => null, 'error' => ['code' => -32700, 'message' => 'Parse error'], ]; @@ -186,17 +184,15 @@ }); test('fromArray throws exception for response with null ID but missing error', function () { - $data = ['jsonrpc' => '2.0', 'id' => null]; // Missing error + $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']]; // Has result with null ID - // Need to adjust mock data to pass initial checks if both present - // Let's test the case where only result is present with null ID + $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'); // Constructor check catches this via wrapper + ->toThrow(ProtocolException::class, 'must contain "error" when ID is null'); }); test('fromArray throws exception for invalid ID type', function () { @@ -212,44 +208,40 @@ 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'); // Message includes details from Error::fromArray + ->toThrow(ProtocolException::class, 'Invalid "error" object structure'); }); -// --- toArray / jsonSerialize Tests (Updated) --- +// --- toArray / jsonSerialize Tests --- test('toArray returns correct structure for success response with raw result', function () { - // Create response with raw data (as if from ::fromArray) $rawResult = ['some' => 'data']; - $response = new Response('2.0', 1, $rawResult); // Direct construction with raw data + $response = new Response('2.0', 1, $rawResult); $array = $response->toArray(); - // toArray should output the raw result directly expect($array)->toBe([ 'jsonrpc' => '2.0', 'id' => 1, - 'result' => $rawResult, // Expect raw data + 'result' => $rawResult, ]); }); test('toArray returns correct structure when using success factory (with Result obj)', function () { - // Create response using ::success factory - $resultObject = new EmptyResult(); + $resultObject = new EmptyResult; $response = Response::success($resultObject, 1); $array = $response->toArray(); - // toArray should call toArray() on the Result object expect($array)->toBe([ 'jsonrpc' => '2.0', 'id' => 1, - 'result' => [], // Expect result of EmptyResult::toArray() + 'result' => [], ]); }); test('toArray returns correct structure for error response', function () { $error = new Error(100, 'Test error'); - $response = Response::error($error, 1); // Use factory + $response = Response::error($error, 1); $array = $response->toArray(); @@ -262,19 +254,19 @@ test('toArray returns correct structure for error response with null ID', function () { $error = new Error(-32700, 'Parse error'); - $response = Response::error($error, null); // Use factory with null ID + $response = Response::error($error, null); $array = $response->toArray(); expect($array)->toBe([ 'jsonrpc' => '2.0', - 'id' => null, // ID should be null + 'id' => null, 'error' => ['code' => -32700, 'message' => 'Parse error'], ]); }); test('jsonSerialize returns same result as toArray', function () { - $result = new EmptyResult(); + $result = new EmptyResult; $response = Response::success($result, 1); $array = $response->toArray(); diff --git a/tests/Unit/ProcessorTest.php b/tests/Unit/ProcessorTest.php index 6fb5ad4..ada2491 100644 --- a/tests/Unit/ProcessorTest.php +++ b/tests/Unit/ProcessorTest.php @@ -4,7 +4,6 @@ use Mockery; use Mockery\MockInterface; -use PhpMcp\Server\ClientStateManager; use PhpMcp\Server\Configuration; use PhpMcp\Server\Definitions\ToolDefinition; use PhpMcp\Server\Exception\McpServerException; @@ -20,6 +19,7 @@ use PhpMcp\Server\Model\Capabilities; use PhpMcp\Server\Processor; use PhpMcp\Server\Registry; +use PhpMcp\Server\State\ClientStateManager; use PhpMcp\Server\Support\ArgumentPreparer; use PhpMcp\Server\Support\SchemaValidator; use Psr\Container\ContainerInterface; @@ -51,7 +51,7 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string test()->expect($response)->toBeInstanceOf(Response::class); test()->expect($response->id)->toBe($id); test()->expect($response->result)->toBeNull(); - test()->expect($response->error)->toBeInstanceOf(JsonRpcError::class); // Use alias + test()->expect($response->error)->toBeInstanceOf(JsonRpcError::class); test()->expect($response->error->code)->toBe($expectedCode); } @@ -78,10 +78,10 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string ); // Default registry state (empty) - $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->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(); // Default transport state (not initialized) $this->clientStateManagerMock->allows('isInitialized')->with(CLIENT_ID_PROC)->andReturn(false)->byDefault(); @@ -100,13 +100,13 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string // --- Tests Start Here --- -test('constructor receives dependencies', function () { +it('can be instantiated', function () { expect($this->processor)->toBeInstanceOf(Processor::class); }); // --- Initialize Tests (Updated capabilities check) --- -test('handleInitialize succeeds with valid parameters', function () { +it('can handle an initialize request', function () { $clientInfo = ['name' => 'TestClientProc', 'version' => '1.3.0']; $request = createRequest('initialize', [ 'protocolVersion' => SUPPORTED_VERSION_PROC, @@ -116,9 +116,9 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string $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()])); + $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( @@ -164,23 +164,23 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string // Other initialize tests (missing params, etc.) remain largely the same logic -test('handleNotificationInitialized marks client as initialized and returns null', function () { +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(); }); -test('process fails if client not initialized for non-initialize methods', function (string $method) { +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); // Check correct code + expectMcpErrorResponse($response, McpServerException::CODE_INVALID_REQUEST); expect($response->error->message)->toContain('Client not initialized'); })->with([ 'tools/list', 'tools/call', 'resources/list', // etc. ]); -test('process fails if capability is disabled', function (string $method, array $params, array $enabledCaps) { +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); @@ -212,7 +212,7 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string 'logging/setLevel' => ['logging/setLevel', [], ['loggingEnabled' => false]], ]); -test('handlePing succeeds for initialized client', function () { +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); @@ -220,7 +220,7 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string expect($response->result)->toBeInstanceOf(EmptyResult::class); }); -test('handleToolList returns tools using hardcoded limit', function () { +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', []); @@ -236,9 +236,9 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string // Other list tests (empty, pagination) remain similar logic, just limit is fixed -// --- Action Methods (Unchanged Logic, Container Usage Verified) --- +// --- Action Methods --- -test('handleToolCall uses container to get handler', function () { +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'; diff --git a/tests/Unit/ProtocolHandlerTest.php b/tests/Unit/ProtocolHandlerTest.php index 2da7642..f6ae1da 100644 --- a/tests/Unit/ProtocolHandlerTest.php +++ b/tests/Unit/ProtocolHandlerTest.php @@ -5,7 +5,6 @@ use Mockery; // Use Mockery integration trait use Mockery\MockInterface; -use PhpMcp\Server\ClientStateManager; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Server\JsonRpc\Notification; @@ -13,14 +12,14 @@ use PhpMcp\Server\JsonRpc\Response; use PhpMcp\Server\JsonRpc\Results\EmptyResult; use PhpMcp\Server\Processor; -use PhpMcp\Server\ProtocolHandler; +use PhpMcp\Server\Protocol; +use PhpMcp\Server\State\ClientStateManager; use Psr\Log\LoggerInterface; use React\EventLoop\Loop; use function React\Async\await; use function React\Promise\resolve; -// --- Setup --- beforeEach(function () { $this->processor = Mockery::mock(Processor::class); $this->clientStateManager = Mockery::mock(ClientStateManager::class); @@ -29,7 +28,7 @@ $this->loop = Loop::get(); $this->transport = Mockery::mock(ServerTransportInterface::class); - $this->handler = new ProtocolHandler( + $this->handler = new Protocol( $this->processor, $this->clientStateManager, $this->logger, @@ -50,13 +49,13 @@ Mockery::close(); }); -test('handleRawMessage processes valid request', function () { +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); + $expectedResponse = Response::success(new EmptyResult, $requestId); $expectedResponseJson = json_encode($expectedResponse->toArray()); $this->processor->shouldReceive('process')->once()->with(Mockery::type(Request::class), $clientId)->andReturn($expectedResponse); @@ -66,7 +65,7 @@ // Mockery verifies calls }); -test('handleRawMessage processes valid notification', function () { +it('can handle a valid notification', function () { $clientId = 'client-notif-1'; $method = 'notify/event'; $params = ['b' => 2]; @@ -78,7 +77,7 @@ $this->handler->handleRawMessage($rawJson, $clientId); }); -test('handleRawMessage sends parse error response', function () { +it('sends a parse error response for invalid JSON', function () { $clientId = 'client-err-parse'; $rawJson = '{"jsonrpc":"2.0", "id":'; @@ -88,7 +87,7 @@ $this->handler->handleRawMessage($rawJson, $clientId); }); -test('handleRawMessage sends invalid request error response', function () { +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 @@ -98,7 +97,7 @@ $this->handler->handleRawMessage($rawJson, $clientId); }); -test('handleRawMessage sends McpError response', function () { +it('sends a mcp error response for a method not found', function () { $clientId = 'client-err-mcp'; $requestId = 789; $method = 'nonexistent/method'; @@ -111,7 +110,7 @@ $this->handler->handleRawMessage($rawJson, $clientId); }); -test('handleRawMessage sends internal error response on processor exception', function () { +it('sends an internal error response on processor exception', function () { $clientId = 'client-err-internal'; $requestId = 101; $method = 'explode/now'; @@ -126,13 +125,13 @@ // --- Test Event Handlers (Now call the handler directly) --- -test('handleClientConnected logs info', function () { +it('logs info when a client connects', function () { $clientId = 'client-connect-test'; $this->logger->shouldReceive('info')->once()->with('Client connected', ['clientId' => $clientId]); $this->handler->handleClientConnected($clientId); // Call method directly }); -test('handleClientDisconnected cleans up state', function () { +it('cleans up state when a client disconnects', function () { $clientId = 'client-disconnect-test'; $reason = 'Connection closed by peer'; @@ -142,7 +141,7 @@ $this->handler->handleClientDisconnected($clientId, $reason); // Call method directly }); -test('handleTransportError cleans up client state', function () { +it('cleans up client state when a transport error occurs', function () { $clientId = 'client-transporterror-test'; $error = new \RuntimeException('Socket error'); @@ -152,7 +151,7 @@ $this->handler->handleTransportError($error, $clientId); // Call method directly }); -test('handleTransportError logs general error', function () { +it('logs a general error when a transport error occurs', function () { $error = new \RuntimeException('Listener setup failed'); $this->logger->shouldReceive('error')->once()->with('General transport error', Mockery::any()); @@ -161,16 +160,16 @@ $this->handler->handleTransportError($error, null); // Call method directly }); -// --- Test Binding/Unbinding (Unchanged) --- +// --- Test Binding/Unbinding --- -test('bindTransport attaches listeners', function () { +it('attaches listeners when binding a new transport', function () { $newTransport = Mockery::mock(ServerTransportInterface::class); $newTransport->shouldReceive('on')->times(4); $this->handler->bindTransport($newTransport); expect(true)->toBeTrue(); }); -test('unbindTransport removes listeners', function () { +it('removes listeners when unbinding a transport', function () { $this->transport->shouldReceive('on')->times(4); $this->handler->bindTransport($this->transport); $this->transport->shouldReceive('removeListener')->times(4); @@ -178,7 +177,7 @@ expect(true)->toBeTrue(); }); -test('reBindTransport unbinds previous', function () { +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); @@ -189,9 +188,7 @@ expect(true)->toBeTrue(); }); -// --- Test sendNotification (Updated to use await) --- - -test('sendNotification encodes and sends', function () { +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); @@ -209,7 +206,7 @@ })->group('usesLoop'); -test('sendNotification rejects on encoding error', function () { +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]); @@ -226,7 +223,7 @@ })->group('usesLoop')->throws(McpServerException::class, 'Failed to encode notification'); -test('sendNotification rejects if transport not bound', function () { +it('rejects if transport not bound when sending a notification', function () { $this->handler->unbindTransport(); $notification = new Notification('2.0', 'test'); diff --git a/tests/Unit/RegistryTest.php b/tests/Unit/RegistryTest.php index 55062b3..0b04264 100644 --- a/tests/Unit/RegistryTest.php +++ b/tests/Unit/RegistryTest.php @@ -4,13 +4,13 @@ use Mockery; use Mockery\MockInterface; -use PhpMcp\Server\ClientStateManager; 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\Server\Registry; +use PhpMcp\Server\State\ClientStateManager; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; @@ -61,7 +61,7 @@ function getRegistryProperty(Registry $reg, string $propName) // --- Basic Registration & Retrieval --- -test('registers manual tool and marks as manual', function () { +it('registers manual tool and marks as manual', function () { // Arrange $tool = createTestTool('manual-tool-1'); @@ -74,7 +74,7 @@ function getRegistryProperty(Registry $reg, string $propName) expect(getRegistryProperty($this->registry, 'manualToolNames'))->toHaveKey('manual-tool-1'); }); -test('registers discovered tool', function () { +it('registers discovered tool', function () { // Arrange $tool = createTestTool('discovered-tool-1'); @@ -87,7 +87,7 @@ function getRegistryProperty(Registry $reg, string $propName) expect(getRegistryProperty($this->registry, 'manualToolNames'))->toBeEmpty(); }); -test('registers manual resource and marks as manual', function () { +it('registers manual resource and marks as manual', function () { // Arrange $res = createTestResource('manual://res/1'); @@ -99,7 +99,7 @@ function getRegistryProperty(Registry $reg, string $propName) expect(getRegistryProperty($this->registry, 'manualResourceUris'))->toHaveKey('manual://res/1'); }); -test('registers discovered resource', function () { +it('registers discovered resource', function () { // Arrange $res = createTestResource('discovered://res/1'); @@ -111,7 +111,7 @@ function getRegistryProperty(Registry $reg, string $propName) expect(getRegistryProperty($this->registry, 'manualResourceUris'))->toBeEmpty(); }); -test('registers manual prompt and marks as manual', function () { +it('registers manual prompt and marks as manual', function () { // Arrange $prompt = createTestPrompt('manual-prompt'); @@ -122,7 +122,8 @@ function getRegistryProperty(Registry $reg, string $propName) expect($this->registry->findPrompt('manual-prompt'))->toBe($prompt); expect(getRegistryProperty($this->registry, 'manualPromptNames'))->toHaveKey('manual-prompt'); }); -test('registers discovered prompt', function () { + +it('registers discovered prompt', function () { // Arrange $prompt = createTestPrompt('discovered-prompt'); @@ -133,7 +134,8 @@ function getRegistryProperty(Registry $reg, string $propName) expect($this->registry->findPrompt('discovered-prompt'))->toBe($prompt); expect(getRegistryProperty($this->registry, 'manualPromptNames'))->toBeEmpty(); }); -test('registers manual template and marks as manual', function () { + +it('registers manual template and marks as manual', function () { // Arrange $template = createTestTemplate('manual://tmpl/{id}'); @@ -144,7 +146,8 @@ function getRegistryProperty(Registry $reg, string $propName) expect($this->registry->findResourceTemplateByUri('manual://tmpl/123')['definition'] ?? null)->toBe($template); expect(getRegistryProperty($this->registry, 'manualTemplateUris'))->toHaveKey('manual://tmpl/{id}'); }); -test('registers discovered template', function () { + +it('registers discovered template', function () { // Arrange $template = createTestTemplate('discovered://tmpl/{id}'); @@ -180,7 +183,7 @@ function getRegistryProperty(Registry $reg, string $propName) // --- Registration Precedence --- -test('manual registration overrides existing discovered element', function () { +it('overrides existing discovered element with manual registration', function () { // Arrange $toolName = 'override-test'; $discoveredTool = createTestTool($toolName); // Version 1 (Discovered) @@ -206,7 +209,7 @@ function getRegistryProperty(Registry $reg, string $propName) expect($manualNamesProp->getValue($this->registry))->toHaveKey($toolName); }); -test('discovered element does NOT override existing manual element', function () { +it('does not override existing manual element with discovered registration', function () { // Arrange $toolName = 'manual-priority'; $manualTool = createTestTool($toolName); // Version 1 (Manual) @@ -235,7 +238,7 @@ function getRegistryProperty(Registry $reg, string $propName) // --- Caching Logic --- -test('constructor loads discovered elements from cache correctly', function () { +it('loads discovered elements from cache correctly', function () { // Arrange $cachedTool = createTestTool('cached-tool-constructor'); $cachedResource = createTestResource('cached://res-constructor'); @@ -259,7 +262,7 @@ function getRegistryProperty(Registry $reg, string $propName) expect(getRegistryProperty($registry, 'manualResourceUris'))->toBeEmpty(); }); -test('constructor load skips cache items conflicting with LATER manual registration', function () { +it('skips cache items conflicting with LATER manual registration', function () { // Arrange $conflictName = 'conflict-tool'; $manualTool = createTestTool($conflictName); @@ -285,7 +288,7 @@ function getRegistryProperty(Registry $reg, string $propName) expect(getRegistryProperty($registry, 'manualToolNames'))->toHaveKey($conflictName); }); -test('saveDiscoveredElementsToCache only saves non-manual elements', function () { +it('saves only non-manual elements to cache', function () { // Arrange $manualTool = createTestTool('manual-save'); $discoveredTool = createTestTool('discovered-save'); @@ -308,7 +311,7 @@ function getRegistryProperty(Registry $reg, string $propName) expect($result)->toBeTrue(); }); -test('loadDiscoveredElementsFromCache ignores non-array cache data', function () { +it('ignores non-array cache data', function () { // Arrange $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY)->once()->andReturn('invalid string data'); @@ -320,7 +323,7 @@ function getRegistryProperty(Registry $reg, string $propName) expect($registry->hasElements())->toBeFalse(); // But empty }); -test('loadDiscoveredElementsFromCache ignores cache on hydration error', function () { +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]]; @@ -334,7 +337,7 @@ function getRegistryProperty(Registry $reg, string $propName) expect($registry->hasElements())->toBeFalse(); // Hydration failed }); -test('clearDiscoveredElements removes only non-manual elements and optionally clears cache', function ($deleteCacheFile) { +it('removes only non-manual elements and optionally clears cache', function ($deleteCacheFile) { // Arrange $manualTool = createTestTool('manual-clear'); $discoveredTool = createTestTool('discovered-clear'); @@ -376,7 +379,7 @@ function getRegistryProperty(Registry $reg, string $propName) // --- Notifier Tests --- -test('default notifiers send messages via ClientStateManager', function () { +it('default notifiers send messages via ClientStateManager', function () { // Arrange $tool = createTestTool('notify-tool'); $resource = createTestResource('notify://res'); @@ -391,14 +394,14 @@ function getRegistryProperty(Registry $reg, string $propName) $this->registry->registerPrompt($prompt); }); -test('custom notifiers can be set and are called', function () { +it('custom notifiers can be set and are called', function () { // Arrange $toolNotifierCalled = false; $this->registry->setToolsChangedNotifier(function () use (&$toolNotifierCalled) { $toolNotifierCalled = true; }); - $this->clientStateManager->shouldNotReceive('queueMessageForAll'); // Default shouldn't be called + $this->clientStateManager->shouldNotReceive('queueMessageForAll'); // Act $this->registry->registerTool(createTestTool('custom-notify')); diff --git a/tests/Unit/ServerBuilderTest.php b/tests/Unit/ServerBuilderTest.php index ae49c54..798aaf5 100644 --- a/tests/Unit/ServerBuilderTest.php +++ b/tests/Unit/ServerBuilderTest.php @@ -6,7 +6,6 @@ use PhpMcp\Server\Attributes\McpTool; use PhpMcp\Server\Configuration; use PhpMcp\Server\Defaults\BasicContainer; -use PhpMcp\Server\Defaults\FileCache; use PhpMcp\Server\Exception\ConfigurationException; use PhpMcp\Server\Exception\DefinitionException; use PhpMcp\Server\Model\Capabilities; @@ -21,61 +20,22 @@ class DummyHandlerClass { - public function handle() - { - } + public function handle() {} } class DummyInvokableClass { - public function __invoke() - { - } + public function __invoke() {} } class HandlerWithDeps { - public function __construct(public LoggerInterface $log) - { - } + public function __construct(public LoggerInterface $log) {} #[McpTool(name: 'depTool')] - public function run() - { - } + public function run() {} } beforeEach(function () { - $this->builder = new ServerBuilder(); - - $this->tempBasePath = sys_get_temp_dir().'/mcp_builder_test_'.bin2hex(random_bytes(4)); - if (! is_dir($this->tempBasePath)) { - @mkdir($this->tempBasePath, 0777, true); - } - - $this->tempCachePath = dirname(__DIR__, 3).'/cache'; - if (! is_dir($this->tempCachePath)) { - @mkdir($this->tempCachePath, 0777, true); - } -}); - -afterEach(function () { - if (! empty($this->tempBasePath) && is_dir($this->tempBasePath)) { - @rmdir($this->tempBasePath); - } - if (! empty($this->tempCachePath) && is_dir($this->tempCachePath)) { - $cacheFiles = glob($this->tempCachePath.'/mcp_server_registry*.cache'); - if ($cacheFiles) { - foreach ($cacheFiles as $file) { - @unlink($file); - } - } - } - Mockery::close(); -}); - -afterAll(function () { - if (! empty($this->tempBasePath) && is_dir($this->tempBasePath)) { - @rmdir($this->tempBasePath); - } + $this->builder = new ServerBuilder; }); function getBuilderProperty(ServerBuilder $builder, string $propertyName) @@ -204,7 +164,7 @@ function getBuilderProperty(ServerBuilder $builder, string $propertyName) // --- Default Dependency Resolution Tests --- -test('build resolves default Logger correctly', function () { +it('resolves default Logger correctly when building', function () { $server = $this->builder ->withServerInfo('Test', '1.0') ->withTool([DummyHandlerClass::class, 'handle']) @@ -212,7 +172,7 @@ function getBuilderProperty(ServerBuilder $builder, string $propertyName) expect($server->getConfiguration()->logger)->toBeInstanceOf(NullLogger::class); }); -test('build resolves default Loop correctly', function () { +it('resolves default Loop correctly when building', function () { $server = $this->builder ->withServerInfo('Test', '1.0') ->withTool([DummyHandlerClass::class, 'handle']) @@ -220,7 +180,7 @@ function getBuilderProperty(ServerBuilder $builder, string $propertyName) expect($server->getConfiguration()->loop)->toBeInstanceOf(LoopInterface::class); }); -test('build resolves default Container correctly', function () { +it('resolves default Container correctly when building', function () { $server = $this->builder ->withServerInfo('Test', '1.0') ->withTool([DummyHandlerClass::class, 'handle']) @@ -228,43 +188,7 @@ function getBuilderProperty(ServerBuilder $builder, string $propertyName) expect($server->getConfiguration()->container)->toBeInstanceOf(BasicContainer::class); }); -it('resolves Cache to null if default directory not writable', function () { - $unwritableDir = '/path/to/non/writable/dir_'.uniqid(); - // Need to ensure the internal default path logic points somewhere bad, - // or mock the is_writable checks - which is hard. - // Let's test the outcome: logger warning and null cache in config. - $logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $logger->shouldReceive('warning') - ->with(Mockery::pattern('/Default cache directory not found or not writable|Failed to initialize default FileCache/'), Mockery::any()) - ->once(); - // We can't easily *force* the default path to be unwritable without modifying the code under test, - // so we rely on the fact that if the `new FileCache` fails or the dir check fails, - // the builder will log a warning and proceed with null cache. - // This test mainly verifies the builder *calls* the logger on failure. - - $builder = $this->builder - ->withServerInfo('Test', '1.0') - ->withLogger($logger) // Inject mock logger - ->withTool([DummyHandlerClass::class, 'handle']); - - // Manually set the internal cache to null *before* build to simulate failed default creation path - $reflector = new ReflectionClass($builder); - $cacheProp = $reflector->getProperty('cache'); - $cacheProp->setAccessible(true); - $cacheProp->setValue($builder, null); - // Force internal logic path by temporarily making default dir unwritable if possible - $originalPerms = fileperms($this->tempCachePath); - @chmod($this->tempCachePath, 0444); // Try making read-only - - $server = $builder->build(); - - @chmod($this->tempCachePath, $originalPerms); // Restore permissions - - // We expect the logger warning was triggered and cache is null - expect($server->getConfiguration()->cache)->toBeNull(); -}); - -test('build uses provided dependencies over defaults', function () { +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); @@ -289,11 +213,9 @@ function getBuilderProperty(ServerBuilder $builder, string $propertyName) expect($config->capabilities)->toBe($myCaps); }); -// --- Tests for build() success and manual registration --- - -it('build successfully creates Server with defaults', function () { - $container = new BasicContainer(); - $container->set(LoggerInterface::class, new NullLogger()); +it('successfully creates Server with defaults', function () { + $container = new BasicContainer; + $container->set(LoggerInterface::class, new NullLogger); $server = $this->builder ->withServerInfo('BuiltServer', '1.0') @@ -312,7 +234,7 @@ function getBuilderProperty(ServerBuilder $builder, string $propertyName) }); // REMOVED skip -it('build successfully creates Server with custom dependencies', function () { +it('successfully creates Server with custom dependencies', function () { $myLoop = Mockery::mock(LoopInterface::class); $myLogger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); $myContainer = Mockery::mock(ContainerInterface::class); @@ -338,9 +260,9 @@ function getBuilderProperty(ServerBuilder $builder, string $propertyName) }); // REMOVED skip -it('build throws DefinitionException if manual tool registration fails', function () { - $container = new BasicContainer(); - $container->set(LoggerInterface::class, new NullLogger()); +it('throws DefinitionException if manual tool registration fails', function () { + $container = new BasicContainer; + $container->set(LoggerInterface::class, new NullLogger); $this->builder ->withServerInfo('FailRegServer', '1.0') @@ -351,9 +273,9 @@ function getBuilderProperty(ServerBuilder $builder, string $propertyName) })->throws(DefinitionException::class, '1 error(s) occurred during manual element registration'); -it('build throws DefinitionException if manual resource registration fails', function () { - $container = new BasicContainer(); - $container->set(LoggerInterface::class, new NullLogger()); +it('throws DefinitionException if manual resource registration fails', function () { + $container = new BasicContainer; + $container->set(LoggerInterface::class, new NullLogger); $this->builder ->withServerInfo('FailRegServer', '1.0') diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index 7903ce8..5f7d908 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -5,25 +5,23 @@ use LogicException; use Mockery; use Mockery\MockInterface; -use PhpMcp\Server\ClientStateManager; use PhpMcp\Server\Configuration; use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\DiscoveryException; -// Correct namespace use PhpMcp\Server\Model\Capabilities; use PhpMcp\Server\Processor; -use PhpMcp\Server\ProtocolHandler; +use PhpMcp\Server\Protocol; use PhpMcp\Server\Registry; use PhpMcp\Server\Server; +use PhpMcp\Server\State\ClientStateManager; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use React\EventLoop\Loop; use React\EventLoop\LoopInterface; -// --- Setup --- beforeEach(function () { /** @var MockInterface&LoggerInterface */ $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); @@ -47,10 +45,8 @@ $this->processor = Mockery::mock(Processor::class); $this->clientStateManager = Mockery::mock(ClientStateManager::class); - // Instantiate the Server $this->server = new Server($this->configuration, $this->registry, $this->processor, $this->clientStateManager); - // Default registry/state manager behaviors $this->registry->allows('hasElements')->withNoArgs()->andReturn(false)->byDefault(); $this->registry->allows('discoveryRanOrCached')->withNoArgs()->andReturn(false)->byDefault(); $this->registry->allows('clearDiscoveredElements')->withAnyArgs()->andReturnNull()->byDefault(); @@ -62,21 +58,21 @@ $this->registry->allows('allPrompts->count')->withNoArgs()->andReturn(0)->byDefault(); }); -test('provides getters for core components', function () { +it('provides getters for core components', function () { expect($this->server->getConfiguration())->toBe($this->configuration); expect($this->server->getRegistry())->toBe($this->registry); expect($this->server->getProcessor())->toBe($this->processor); - expect($this->server->getClientStateManager())->toBe($this->clientStateManager); // Updated getter name + expect($this->server->getClientStateManager())->toBe($this->clientStateManager); }); -test('gets protocol handler lazily', function () { - $handler1 = $this->server->getProtocolHandler(); - $handler2 = $this->server->getProtocolHandler(); - expect($handler1)->toBeInstanceOf(ProtocolHandler::class); +it('gets protocol handler lazily', function () { + $handler1 = $this->server->getProtocol(); + $handler2 = $this->server->getProtocol(); + expect($handler1)->toBeInstanceOf(Protocol::class); expect($handler2)->toBe($handler1); }); -test('discover() skips if already run and not forced', function () { - // Mark discoveryRan as true internally + +it('skips discovery if already run and not forced', function () { $reflector = new \ReflectionClass($this->server); $prop = $reflector->getProperty('discoveryRan'); $prop->setAccessible(true); @@ -90,33 +86,32 @@ $this->logger->shouldHaveReceived('debug')->with('Discovery skipped: Already run or loaded from cache.'); }); -test('discover() clears discovered elements before scan', function () { +it('clears discovered elements before scanning', function () { $basePath = sys_get_temp_dir(); - $this->registry->shouldReceive('clearDiscoveredElements')->once()->with(true); // Expect clear(true) + $this->registry->shouldReceive('clearDiscoveredElements')->once()->with(true); $this->registry->shouldReceive('saveDiscoveredElementsToCache')->once()->andReturn(true); $this->server->discover($basePath); - // Assert discoveryRan flag $reflector = new \ReflectionClass($this->server); $prop = $reflector->getProperty('discoveryRan'); $prop->setAccessible(true); expect($prop->getValue($this->server))->toBeTrue(); }); -test('discover() saves to cache when requested', function () { +it('saves to cache after discovery when requested', function () { // Arrange $basePath = sys_get_temp_dir(); $this->registry->shouldReceive('clearDiscoveredElements')->once()->with(true); - $this->registry->shouldReceive('saveDiscoveredElementsToCache')->once()->andReturn(true); // Expect save + $this->registry->shouldReceive('saveDiscoveredElementsToCache')->once()->andReturn(true); // Act $this->server->discover($basePath, saveToCache: true); }); -test('discover() does NOT save to cache when requested', function () { +it('does NOT save to cache after discovery when requested', function () { // Arrange $basePath = sys_get_temp_dir(); @@ -127,11 +122,11 @@ $this->server->discover($basePath, saveToCache: false); }); -test('discover() throws InvalidArgumentException for bad base path', function () { +it('throws InvalidArgumentException for bad base path', function () { $this->server->discover('/non/existent/path/for/sure'); })->throws(\InvalidArgumentException::class); -test('discover() throws DiscoveryException if discoverer fails', function () { +it('throws DiscoveryException if discoverer fails', function () { $basePath = sys_get_temp_dir(); $exception = new \RuntimeException('Filesystem error'); @@ -142,7 +137,7 @@ })->throws(DiscoveryException::class, 'Element discovery failed: Filesystem error'); -test('discover() resets discoveryRan flag on failure', function () { +it('resets discoveryRan flag on failure', function () { $basePath = sys_get_temp_dir(); $exception = new \RuntimeException('Filesystem error'); @@ -158,12 +153,10 @@ $reflector = new \ReflectionClass($this->server); $prop = $reflector->getProperty('discoveryRan'); $prop->setAccessible(true); - expect($prop->getValue($this->server))->toBeFalse(); // Should be reset + expect($prop->getValue($this->server))->toBeFalse(); }); -// --- listen() Method Tests --- - -test('listen() throws exception if already listening', function () { +it('throws exception if already listening', function () { $transport = Mockery::mock(ServerTransportInterface::class); // Simulate the first listen call succeeding and setting the flag @@ -187,7 +180,7 @@ ->toThrow(LogicException::class, 'Server is already listening'); }); -test('listen() warns if no elements and discovery not run', function () { +it('warns if no elements and discovery not run when trying to listen', function () { $transport = Mockery::mock(ServerTransportInterface::class); $this->registry->shouldReceive('hasElements')->andReturn(false); @@ -204,7 +197,7 @@ $this->server->listen($transport); }); -test('listen() warns if no elements found AFTER discovery', function () { +it('warns if no elements found AFTER discovery when trying to listen', function () { $transport = Mockery::mock(ServerTransportInterface::class); // Setup: No elements, discoveryRan=true @@ -216,7 +209,6 @@ $this->logger->shouldReceive('warning')->once()->with(Mockery::pattern('/Starting listener, but no MCP elements were found after discovery/')); - // --- FIX: Allow necessary mock calls --- $transport->shouldReceive('setLogger', 'setLoop', 'on', 'once', 'removeListener', 'close')->withAnyArgs(); $transport->shouldReceive('listen')->once(); $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); @@ -225,7 +217,7 @@ $this->server->listen($transport); }); -test('listen() does not warn if elements are present', function () { +it('does not warn if elements are present when trying to listen', function () { $transport = Mockery::mock(ServerTransportInterface::class); // Setup: HAS elements @@ -242,8 +234,7 @@ $this->server->listen($transport); }); -test('listen() injects logger and loop into aware transports', function () { - // --- FIX: Allow necessary mock calls --- +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(); @@ -255,23 +246,22 @@ $this->server->listen($transport); }); -test('listen() binds protocol handler and starts transport listen', function () { +it('binds protocol handler and starts transport listen', function () { $transport = Mockery::mock(ServerTransportInterface::class); // Get the real handler instance but spy on it - $protocolHandlerSpy = Mockery::spy($this->server->getProtocolHandler()); + $protocolSpy = Mockery::spy($this->server->getProtocol()); $reflector = new \ReflectionClass($this->server); - $prop = $reflector->getProperty('protocolHandler'); + $prop = $reflector->getProperty('protocol'); $prop->setAccessible(true); - $prop->setValue($this->server, $protocolHandlerSpy); // Inject spy + $prop->setValue($this->server, $protocolSpy); // Inject spy // Expectations - $protocolHandlerSpy->shouldReceive('bindTransport')->with($transport)->once(); - // --- FIX: Allow necessary mock calls --- + $protocolSpy->shouldReceive('bindTransport')->with($transport)->once(); $transport->shouldReceive('listen')->once(); $transport->shouldReceive('on', 'once', 'removeListener', 'close')->withAnyArgs(); // Allow listeners $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); // Allow emit $this->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); - $protocolHandlerSpy->shouldReceive('unbindTransport')->once(); // Expect unbind on close + $protocolSpy->shouldReceive('unbindTransport')->once(); // Expect unbind on close $this->server->listen($transport); // Mockery verifies expectations diff --git a/tests/Unit/State/ClientStateManagerTest.php b/tests/Unit/State/ClientStateManagerTest.php new file mode 100644 index 0000000..b7f7a3b --- /dev/null +++ b/tests/Unit/State/ClientStateManagerTest.php @@ -0,0 +1,438 @@ +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 = new Notification('2.0', 'event'); + $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->toArray(); + }), 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 = [['method' => 'm1'], ['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 new file mode 100644 index 0000000..01d65a4 --- /dev/null +++ b/tests/Unit/State/ClientStateTest.php @@ -0,0 +1,133 @@ +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 = ['jsonrpc' => '2.0', 'method' => 'notify1']; + $message2 = ['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 = ['method' => 'msg1']; + $message2 = ['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); +}); From 41733af9ad50af14a2183defd5c39b51407d6791 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 10 May 2025 00:52:40 +0100 Subject: [PATCH 3/9] chore(docs): Update README with comprehensive v2.0 architecture changes --- CONTRIBUTING.md | 59 ++++ README.md | 699 +++++++++++++++++------------------------------- 2 files changed, 311 insertions(+), 447 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f913471 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing to php-mcp/server + +First off, thank you for considering contributing to `php-mcp/server`! We appreciate your time and effort. This project aims to provide a robust and easy-to-use PHP server for the Model Context Protocol. + +Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open-source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests. + +## How Can I Contribute? + +There are several ways you can contribute: + +* **Reporting Bugs:** If you find a bug, please open an issue on the GitHub repository. Include steps to reproduce, expected behavior, and actual behavior. Specify your PHP version, operating system, and relevant package versions. +* **Suggesting Enhancements:** Open an issue to suggest new features or improvements to existing functionality. Explain the use case and why the enhancement would be valuable. +* **Improving Documentation:** If you find errors, omissions, or areas that could be clearer in the README or code comments, please submit a pull request or open an issue. +* **Writing Code:** Submit pull requests to fix bugs or add new features. + +## Development Setup + +1. **Fork the repository:** Click the "Fork" button on the [php-mcp/server GitHub page](https://github.com/php-mcp/server). +2. **Clone your fork:** `git clone git@github.com:YOUR_USERNAME/server.git` +3. **Navigate into the directory:** `cd server` +4. **Install dependencies:** `composer install` (This installs runtime and development dependencies). + +## Submitting Changes (Pull Requests) + +1. **Create a new branch:** `git checkout -b feature/your-feature-name` or `git checkout -b fix/issue-number`. +2. **Make your changes:** Write your code and accompanying tests. +3. **Ensure Code Style:** Run the code style fixer (if configured, e.g., PHP CS Fixer): + ```bash + composer lint # Or ./vendor/bin/php-cs-fixer fix + ``` + Adhere to PSR-12 coding standards. +4. **Run Tests:** Ensure all tests pass: + ```bash + composer test # Or ./vendor/bin/pest + ``` + Consider adding new tests for your changes. Aim for good test coverage. +5. **Update Documentation:** If your changes affect the public API or usage, update the `README.md` and relevant PHPDoc blocks. +6. **Commit your changes:** Use clear and descriptive commit messages. `git commit -m "feat: Add support for resource subscriptions"` or `git commit -m "fix: Correct handling of transport errors"` +7. **Push to your fork:** `git push origin feature/your-feature-name` +8. **Open a Pull Request:** Go to the original `php-mcp/server` repository on GitHub and open a pull request from your branch to the `main` branch (or the appropriate development branch). +9. **Describe your changes:** Provide a clear description of the problem and solution in the pull request. Link to any relevant issues (`Closes #123`). + +## Coding Standards + +* Follow **PSR-12** coding standards. +* Use **strict types:** `declare(strict_types=1);` at the top of PHP files. +* Use **PHP 8.1+ features** where appropriate (readonly properties, enums, etc.). +* Add **PHPDoc blocks** for all public classes, methods, and properties. +* Write clear and concise code. Add comments only where necessary to explain complex logic. + +## Reporting Issues + +* Use the GitHub issue tracker. +* Check if the issue already exists. +* Provide a clear title and description. +* Include steps to reproduce the issue, code examples, error messages, and stack traces if applicable. +* Specify relevant environment details (PHP version, OS, package version). + +Thank you for contributing! \ No newline at end of file diff --git a/README.md b/README.md index 5b8bea6..48fd840 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,66 @@ # PHP MCP Server -Core PHP implementation of the **Model Context Protocol (MCP)** server. - [![Latest Version on Packagist](https://img.shields.io/packagist/v/php-mcp/server.svg?style=flat-square)](https://packagist.org/packages/php-mcp/server) [![Total Downloads](https://img.shields.io/packagist/dt/php-mcp/server.svg?style=flat-square)](https://packagist.org/packages/php-mcp/server) -[![Tests](https://github.com/php-mcp/server/actions/workflows/tests.yml/badge.svg)](https://github.com/php-mcp/server/actions/workflows/tests.yml) +[![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) -## Introduction - -The [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) is an open standard, initially developed by Anthropic, designed to standardize how AI assistants and tools connect to external data sources, APIs, and other systems. Think of it like USB-C for AI – a single, consistent way to provide context. +**PHP MCP Server provides a robust and flexible server-side implementation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) for PHP applications.** -`php-mcp/server` is a PHP library that makes it incredibly easy to build MCP-compliant servers. Its core goal is to allow you to expose parts of your existing PHP application – specific methods – as MCP **Tools**, **Resources**, or **Prompts** with minimal effort, primarily using PHP 8 Attributes. +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. -This package currently supports the `2024-11-05` version of the Model Context Protocol and is compatible with various MCP clients like Claude Desktop, Cursor, Windsurf, and others that adhere to this protocol version. +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 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). -* **Attribute-Based Definition:** Define MCP elements (Tools, Resources, Prompts, Templates) using simple PHP 8 Attributes (`#[McpTool]`, `#[McpResource]`, `#[McpPrompt]`, `#[McpResourceTemplate]`, `#[McpTemplate]`) on your methods or **directly on invokable classes**. -* **Manual Registration:** Programmatically register MCP elements using fluent methods on the `Server` instance (e.g., `->withTool()`, `->withResource()`). -* **Automatic Metadata Inference:** Leverages method names, parameter names, PHP type hints (for schema), and DocBlocks (for schema and descriptions) to automatically generate MCP definitions, minimizing boilerplate code. -* **PSR Compliant:** Integrates seamlessly with standard PHP interfaces: - * `PSR-3` (LoggerInterface): Bring your own logger (e.g., Monolog). - * `PSR-11` (ContainerInterface): Use your favorite DI container (e.g., Laravel, Symfony, PHP-DI) for resolving your application classes and their dependencies when MCP elements are invoked. - * `PSR-16` (SimpleCacheInterface): Provide a cache implementation (e.g., Symfony Cache, Laravel Cache) for discovered elements and transport state. -* **Flexible Configuration:** Starts with sensible defaults but allows providing your own implementations for logging, caching, DI container, and detailed MCP configuration (`ConfigurationRepositoryInterface`). -* **Multiple Transports:** Supports `stdio` (for command-line clients) and includes components for building `http+sse` (HTTP + Server-Sent Events) transports out-of-the-box (requires integration with a HTTP server). -* **Automatic Discovery:** Scans specified directories within your project to find classes and methods annotated with MCP attributes. -* **Framework Agnostic:** Designed to work equally well in vanilla PHP projects or integrated into any PHP framework. +This package currently supports the `2024-11-05` version of the Model Context Protocol. ## 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). ## Installation -You can install the package via Composer: - ```bash composer require php-mcp/server ``` -> **Note:** For Laravel applications, consider using the dedicated [`php-mcp/laravel`](https://github.com/php-mcp/laravel) package. It builds upon this core library, providing helpful integrations, configuration options, and Artisan commands specifically tailored for the Laravel framework. +> **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. -## Getting Started: A Simple `stdio` Server +## Quick Start: Standalone `stdio` Server with Discovery -Here's a minimal example demonstrating how to expose a simple PHP class method as an MCP Tool using the `stdio` transport. +This example creates a server using **attribute discovery** to find elements and runs via the `stdio` transport. -**1. Create your MCP Element Class:** +**1. Define Your MCP Element:** -Create a file, for example, `src/MyMcpStuff.php`: +Create `src/MyMcpElements.php`: ```php withServerInfo('My Discovery Server', '1.0.2') + ->build(); -// Optional: Configure logging (defaults to STDERR) -// $logger = new MyPsrLoggerImplementation(...); + // 2. **Explicitly run discovery** + $server->discover( + basePath: __DIR__, + scanDirs: ['src'], + ); -$server = Server::make() - // Optional: ->withLogger($logger) - // Optional: ->withCache(new MyPsrCacheImplementation(...)) - // Optional: ->withContainer(new MyPsrContainerImplementation(...)) - ->withBasePath(__DIR__) // Directory to start scanning for Attributes - ->withScanDirectories(['src']) // Specific subdirectories to scan (relative to basePath) - ->discover(); // Find all #[Mcp*] attributes + // 3. Create the Stdio Transport + $transport = new StdioServerTransport(); + + // 4. Start Listening (BLOCKING call) + $server->listen($transport); -// Run the server using the stdio transport -$exitCode = $server->run('stdio'); + exit(0); -exit($exitCode); +} catch (\Throwable $e) { + fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n" . $e . "\n"); + exit(1); +} ``` -**3. Configure your MCP Client:** +**3. Configure Your MCP Client:** -Configure your MCP client (like Cursor, Claude Desktop, etc.) to connect using the `stdio` transport. This typically involves specifying the command to run the server script. For example, in Cursor's `.cursor/mcp.json`: +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**: ```json +// Example: .cursor/mcp.json { "mcpServers": { - "my-php-server": { + "my-php-stdio": { "command": "php", - "args": [ - "/path/to/your/project/mcp-server.php" - ] + "args": ["/full/path/to/your/project/mcp-server.php"] } } } ``` -Replace `/path/to/your/project/mcp-server.php` with the actual absolute path to your script. +**Flow:** + +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. + +## Core Architecture + +The server uses a decoupled architecture: + +* **`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`, `Processor`, `ClientStateManager`, `Configuration`). It's transport-agnostic. Provides methods to `discover()` elements and `listen()` via a specific 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. +* **`Processor`:** Processes parsed JSON-RPC requests/notifications, executes handlers (via DI Container), formats results, handles errors. +* **`ClientStateManager`:** Manages client runtime state (initialization, subscriptions, activity) using the configured cache. +* **`ServerTransportInterface`:** Event-driven interface for server-side transports (`StdioServerTransport`, `HttpServerTransport`). Handles communication, emits events. +* **`Protocol`:** Internal bridge listening to transport events, interacting with `Processor` and `ClientStateManager`. +* **`discover()` Method:** An **explicit** method on the `Server` instance to trigger attribute discovery. Takes path configurations as arguments. By default, it clears previously discovered/cached elements before scanning and saves the new results to cache (if enabled). +* **`listen()` Method:** Starts the server using a specific transport. Binds the `Protocol`, starts the transport listener, and **runs the event loop (blocking)**. Performs a pre-check and warns if no elements are registered and discovery hasn't run. +* **`getProtocol()` Method:** For framework integration (see below). + +## Defining MCP Elements + +Expose your application's functionality as MCP Tools, Resources, or Prompts using attributes or manual registration. + +### 1. Using Attributes (`#[Mcp*]`) + +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. + +```php +$server = ServerBuilder::make()->...->build(); +// Scan 'src/Handlers' relative to the project root +$server->discover(basePath: __DIR__, scanDirs: ['src/Handlers']); +``` + +Attributes: + +* **`#[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. + +*(See [Attribute Details](#attribute-details-and-return-formatting) below for more on parameters and return value formatting)* + +### 2. Manual Registration (`ServerBuilder->with*`) + +Use `withTool`, `withResource`, `withResourceTemplate`, `withPrompt` on the `ServerBuilder` *before* calling `build()`. + +```php +use App\Handlers\MyToolHandler; +use App\Handlers\MyResourceHandler; + +$server = Server::make() + ->withServerInfo(...) + ->withTool( + [MyToolHandler::class, 'processData'], // Handler: [class, method] + 'data_processor' // MCP Name (Optional) + ) + ->withResource( + MyResourceHandler::class, // Handler: Invokable class + 'config://app/name' // URI (Required) + ) + // ->withResourceTemplate(...) + // ->withPrompt(...) + ->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()`). -Now, when you connect your client, it should discover the `adder` tool. +### Precedence: Manual vs. Discovered/Cached -## Core Concepts +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): -The primary ways to expose functionality through `php-mcp/server` are: +* **The manually registered element always takes precedence.** +* The discovered/cached version will be ignored, and a debug message will be logged. -1. **Attribute Discovery:** Decorating your PHP methods or invokable classes with specific Attributes (`#[McpTool]`, `#[McpResource]`, etc.). The server automatically discovers these during the `->discover()` process. -2. **Manual Registration:** Using fluent methods (`->withTool()`, `->withResource()`, etc.) on the `Server` instance before running it. +This ensures explicit manual configuration overrides any potentially outdated discovered or cached definitions. + +## Discovery and Caching + +Attribute discovery is an **explicit step** performed on a built `Server` instance. + +* **`$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: + +* **`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(...)`. +* `withTool(...)`, `withResource(...)`, etc.: Optional manual registration. + +## 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)`. + +### Stdio Transport + +Handles communication over Standard Input/Output. Ideal for servers launched directly by an MCP client (like Cursor). + +```php +use PhpMcp\Server\Transports\StdioServerTransport; + +// ... build $server ... + +$transport = new StdioServerTransport(); + +// This blocks until the transport is closed (e.g., SIGINT/SIGTERM) +$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]); +> ``` + +### HTTP Transport (HTTP+SSE) + +Listens for HTTP connections, handling client messages via POST and sending server messages/notifications via Server-Sent Events (SSE). + +```php +use PhpMcp\Server\Transports\HttpServerTransport; + +// ... build $server ... + +$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 +); + +// 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.). + +**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. + +### Custom / Framework Integration + +Integrate into frameworks without the blocking `listen()` call: + +1. **Build Server:** `$server = Server::make()->...->build();`, likely as a service container singleton. +2. **(Optional) Discover:** `$server->discover(...);` (Perhaps in a cache warmup command). +3. **Get Protocol:** Obtain the protocol from the server: `$protocol = $server->getProtocol();` +4. **Bridge Transport Events:** In your framework's request handling (HTTP controller, WebSocket): + * When a new connection is established: `$protocol->handleClientConnected($clientId);` + * When a raw JSON-RPC message frame is received, call: `$protocol->handleRawMessage($rawJsonFrame, $clientId);` + * When a connection closes, call: `$protocol->handleClientDisconnected($clientId, $reason);` + * Handle transport-level errors by potentially calling: `$protocol->handleTransportError($exception, $clientId);` +5. **Sending Responses:** You'll need framework-specific logic to send responses/notifications back to the correct client connection identified by `$clientId`. + +This approach allows the framework to manage the event loop, sockets, and request/response objects, while leveraging the `php-mcp/server` core for MCP processing logic. + +## Connecting MCP Clients -### Attributes for Discovery +Instruct clients how to connect to your server: + +* **`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. + +Refer to specific client documentation (Cursor, Claude Desktop, etc.) for their configuration format. + +## Attribute Details & Return Formatting {#attribute-details-and-return-formatting} These attributes mark classes or methods to be found by the `->discover()` process. @@ -379,401 +550,35 @@ class SummarizePrompt { } ``` -### The `Server` Fluent Interface - -The `PhpMcp\Server\Server` class is the main entry point for configuring and running your MCP server. It provides a fluent interface (method chaining) to set up dependencies, parameters, and manually register elements. - -* **`Server::make(): self`**: Static factory method to create a new server instance. It initializes the server with default implementations for core services (Logger, Cache, Config, Container). -* **`->withLogger(LoggerInterface $logger): self`**: Provide a PSR-3 compliant logger implementation. By default, logging is disabled unless a logger is explicitly provided here (when using the default container) or registered in a custom container. - * **If using the default `BasicContainer`:** This method replaces the default no-op logger and updates the registration within the `BasicContainer`, enabling logging. - * **If using a custom container:** This method *only* sets an internal property on the `Server` instance. It **does not** affect the custom container. You should register your desired `LoggerInterface` directly within your container setup to enable logging. -* **`->withCache(CacheInterface $cache): self`**: Provide a PSR-16 compliant cache implementation. - * **If using the default `BasicContainer`:** This method replaces the default `FileCache` instance and updates the registration within the `BasicContainer`. - * **If using a custom container:** This method *only* sets an internal property on the `Server` instance. It **does not** affect the custom container. You should register your desired `CacheInterface` directly within your container setup. -* **`->withContainer(ContainerInterface $container): self`**: Provide a PSR-11 compliant DI container. - * When called, the server will **use this container** for all dependency resolution, including its internal needs and instantiating your handler classes. - * **Crucially, you MUST ensure this container is configured to provide implementations for `LoggerInterface`, `CacheInterface`, and `ConfigurationRepositoryInterface`**, as the server relies on these. - * If not called, the server uses its internal `BasicContainer` with built-in defaults. -* **`->withConfig(ConfigurationRepositoryInterface $config): self`**: Provide a custom configuration repository. - * **If using the default `BasicContainer`:** This method replaces the default `ArrayConfigurationRepository` instance and updates the registration within the `BasicContainer`. - * **If using a custom container:** This method *only* sets an internal property on the `Server` instance. It **does not** affect the custom container. You should register your desired `ConfigurationRepositoryInterface` directly within your container setup. -* **`->withBasePath(string $path): self`**: Set the absolute base path for directory scanning during discovery. Defaults to the parent directory of `vendor/php-mcp/server`. -* **`->withScanDirectories(array $dirs): self`**: Specify an array of directory paths *relative* to the `basePath` where the server should look for annotated classes/methods during discovery. Defaults to `['.', 'src/MCP']`. -* **`->withExcludeDirectories(array $dirs): self`**: Specify an array of directory paths *relative* to the `basePath` to *exclude* from scanning during discovery. Defaults to common directories like `['vendor', 'tests', 'storage', 'cache', 'node_modules']`. Added directories are merged with defaults. -* **`->withTool(array|string $handler, ?string $name = null, ?string $description = null): self`**: Manually registers a tool. -* **`->withResource(array|string $handler, string $uri, ?string $name = null, ...): self`**: Manually registers a resource. -* **`->withPrompt(array|string $handler, ?string $name = null, ?string $description = null): self`**: Manually registers a prompt. -* **`->withResourceTemplate(array|string $handler, ?string $name = null, ..., string $uriTemplate, ...): self`**: Manually registers a resource template. -* **`->discover(bool $cache = true): self`**: Initiates the discovery process. Scans the configured directories for attributes, builds the internal registry of MCP elements, and caches them using the provided cache implementation (unless `$cache` is false). **Note:** Manually registered elements are always added to the registry, regardless of discovery or caching. -* **`->run(?string $transport = null): int`**: Starts the server's main processing loop using the specified transport. - * If `$transport` is `'stdio'` (or `null` when running in CLI), it uses the `StdioTransportHandler` to communicate over standard input/output. - * If `$transport` is `'http'` or `'reactphp'`, it throws an exception, as these transports needs to be integrated into an existing HTTP server loop (see Transports section). - * Returns the exit code (relevant for `stdio`). - -### Dependency Injection - -The `Server` relies on a PSR-11 `ContainerInterface` for two main purposes: - -1. **Resolving Server Dependencies:** The server itself needs instances of `LoggerInterface`, `CacheInterface`, and `ConfigurationRepositoryInterface` to function (e.g., for logging internal operations, caching discovered elements, reading configuration values). -2. **Resolving Handler Dependencies:** When an MCP client calls a tool or reads a resource/prompt that maps to one of your attributed methods or a manually registered handler, the server uses the container to get an instance of the handler's class (e.g., `$container->get(MyHandlerClass::class)`). This allows your handler classes to use constructor injection for their own dependencies (like database connections, application services, etc.). - -**Default Behavior (No `withContainer` Call):** - -If you *do not* call `->withContainer()`, the server uses its internal `PhpMcp\Server\Defaults\BasicContainer`. This basic container comes pre-configured with default implementations: -* `LoggerInterface` -> `Psr\Log\NullLogger` (Logging is effectively disabled) -* `CacheInterface` -> `PhpMcp\Server\Defaults\FileCache` (writes to `../cache/mcp_cache` relative to the package directory) -* `ConfigurationRepositoryInterface` -> `PhpMcp\Server\Defaults\ArrayConfigurationRepository` (uses built-in default configuration values) - -In this default mode, you *can* use the `->withLogger()`, `->withCache()`, and `->withConfig()` methods to replace these defaults. These methods update the instance used by the server and also update the registration within the internal `BasicContainer`. - -**Using a Custom Container (`->withContainer(MyContainer $c)`):** - -If you provide your own PSR-11 container instance using `->withContainer()`, the responsibility shifts entirely to you: - -* **You MUST ensure your container is configured to provide implementations for `LoggerInterface`, `CacheInterface`, and `ConfigurationRepositoryInterface`.** The server will attempt to fetch these using `$container->get(...)` and will fail if they are not available. Providing a `NullLogger` for `LoggerInterface` will keep logging disabled. -* Your container will also be used to instantiate your handler classes, so ensure all their dependencies are also properly configured within your container. -* When using a custom container, the `->withLogger()`, `->withCache()`, and `->withConfig()` methods on the `Server` instance become largely ineffective for modifying the dependencies the server *actually uses* during request processing, as the server will always defer to retrieving these services from *your provided container*. Configure these services directly in your container's setup. - -Using the default `BasicContainer` is suitable for simple cases. For most applications, providing your own pre-configured PSR-11 container (from your framework or a library like PHP-DI) via `->withContainer()` is the recommended approach for proper dependency management. - -### Configuration - -The server's behavior can be customized through a configuration repository implementing `PhpMcp\Server\Contracts\ConfigurationRepositoryInterface`. You provide this using `->withConfig()`. If not provided, a default `PhpMcp\Server\Defaults\ArrayConfigurationRepository` is used. - -Key configuration values (using dot notation) include: - -* `mcp.server.name`: (string) Server name for handshake. -* `mcp.server.version`: (string) Server version for handshake. -* `mcp.protocol_versions`: (array) Supported protocol versions (e.g., `['2024-11-05']`). -* `mcp.pagination_limit`: (int) Default limit for listing elements. -* `mcp.capabilities.tools.enabled`: (bool) Enable/disable the tools capability. -* `mcp.capabilities.resources.enabled`: (bool) Enable/disable the resources capability. -* `mcp.capabilities.resources.subscribe`: (bool) Enable/disable resource subscriptions. -* `mcp.capabilities.prompts.enabled`: (bool) Enable/disable the prompts capability. -* `mcp.capabilities.logging.enabled`: (bool) Enable/disable the `logging/setLevel` method. -* `mcp.cache.ttl`: (int) Cache time-to-live in seconds. -* `mcp.cache.prefix`: (string) Prefix for cache related to mcp. -* `mcp.runtime.log_level`: (string) Default log level (used by default logger). - -You can create your own implementation of the interface or pass an instance of `ArrayConfigurationRepository` populated with your overrides to `->withConfig()`. If a capability flag (e.g., `mcp.capabilities.tools.enabled`) is set to `false`, attempts by a client to use methods related to that capability (e.g., `tools/list`, `tools/call`) will result in a "Method not found" error. - -### Transports - -MCP defines how clients and servers exchange JSON-RPC messages. This package provides handlers for common transport mechanisms and allows for custom implementations. - -#### Standard I/O (`stdio`) - -The `PhpMcp\Server\Transports\StdioTransportHandler` handles communication over Standard Input (`STDIN`) and Standard Output (`STDOUT`). It uses `react/event-loop` and `react/stream` internally for non-blocking I/O, making it suitable for direct integration with clients that manage the server process lifecycle, like Cursor or Claude Desktop when configured to run a command. You activate it by calling `$server->run('stdio')` or simply `$server->run()` when executed in a CLI environment. - -#### HTTP + Server-Sent Events (`http`) - -The `PhpMcp\Server\Transports\HttpTransportHandler` implements the standard MCP HTTP binding. It uses Server-Sent Events (SSE) for server-to-client communication and standard HTTP POST requests for client-to-server messages. This handler is *not* run directly via `$server->run('http')`. Instead, you must integrate its logic into your own HTTP server built with a framework like Symfony, Laravel, or an asynchronous framework like ReactPHP. - -> [!WARNING] -> **Server Environment Warning:** Standard synchronous PHP web server setups (like PHP's built-in server, Apache/Nginx without concurrent FPM processes, or `php artisan serve`) typically run **one PHP process per request**. This model **cannot reliably handle** the concurrent nature of HTTP+SSE, where one long-running GET request handles the SSE stream while other POST requests arrive to send messages. This will likely cause hangs or failed requests. - -To use HTTP+SSE reliably, your PHP application **must** be served by an environment capable of handling multiple requests concurrently, such as: -* Nginx + PHP-FPM or Apache + PHP-FPM (with multiple worker processes configured). -* Asynchronous PHP Runtimes like ReactPHP, Amp, Swoole (e.g., via Laravel Octane), RoadRunner (e.g., via Laravel Octane), or FrankenPHP. - -Additionally, ensure your web server and PHP-FPM (if used) configurations allow long-running scripts (`set_time_limit(0)` is recommended in your SSE handler) and do not buffer the `text/event-stream` response. - -**Client ID Handling:** The server needs a reliable way to associate incoming POST requests with the correct persistent SSE connection state. Relying solely on session cookies can be problematic. -* **Recommended Approach (Query Parameter):** - 1. When the SSE connection is established, determine a unique `clientId` (e.g., session ID or generated UUID). - 2. Generate the URL for the POST endpoint (where the client sends messages). - 3. Append the `clientId` as a query parameter to this URL (e.g., `/mcp/message?clientId=UNIQUE_ID`). - 4. Send this *complete URL* (including the query parameter) to the client via the initial `endpoint` SSE event. - 5. In your HTTP controller handling the POST requests, retrieve the `clientId` directly from the query parameter (`$request->query('clientId')`). - 6. Pass this explicit `clientId` to `$httpHandler->handleInput(...)`. - -**Integration Steps (General):** -1. **Configure Server:** Create and configure your `PhpMcp\Server\Server` instance (e.g., `$server = Server::make()->withLogger(...)->discover();`). -2. **Instantiate Handler:** Get an instance of `HttpTransportHandler`, passing the configured `$server` instance to its constructor: `$httpHandler = new HttpTransportHandler($server);` (or use dependency injection configured to do this). -3. **SSE Endpoint:** Create an endpoint (e.g., `/mcp/sse`) for GET requests. Set `Content-Type: text/event-stream` and keep the connection open. -4. **POST Endpoint:** Create an endpoint (e.g., `/mcp/message`) for POST requests with `Content-Type: application/json`. -5. **SSE Handler Logic:** Determine the `clientId`, use the `$httpHandler`, generate the POST URI *with* the `clientId` query parameter, call `$httpHandler->handleSseConnection(...)`, and ensure `$httpHandler->cleanupClient(...)` is called when the connection closes. -6. **POST Handler Logic:** Retrieve the `clientId` from the query parameter, get the raw JSON request body, use the `$httpHandler`, call `$httpHandler->handleInput(...)` with the body and `clientId`, and return an appropriate HTTP response (e.g., 202 Accepted). - -#### ReactPHP HTTP Transport (`reactphp`) - -This package includes `PhpMcp\Server\Transports\ReactPhpHttpTransportHandler`, a concrete transport handler that integrates the core MCP HTTP+SSE logic with the ReactPHP ecosystem. It replaces potentially synchronous or blocking loops (often found in basic integrations of `HttpTransportHandler`) with ReactPHP's fully asynchronous, non-blocking event loop and stream primitives. Instantiate it by passing your configured `Server` instance: `$reactHandler = new ReactPhpHttpTransportHandler($server);`. This enables efficient handling of concurrent SSE connections within a ReactPHP-based application server. See the `samples/reactphp_http/server.php` example for a practical implementation. +## Error Handling -#### Custom Transports - -You can create your own transport handlers if `stdio` or `http` don't fit your specific needs (e.g., WebSockets, custom RPC mechanisms). Two main approaches exist: - -1. **Implement the Interface:** Create a class that implements `PhpMcp\Server\Contracts\TransportHandlerInterface`. This gives you complete control over the communication lifecycle. -2. **Extend Existing Handlers:** Inherit from `PhpMcp\Server\Transports\StdioTransportHandler`, `PhpMcp\Server\Transports\HttpTransportHandler`, or `PhpMcp\Server\Transports\ReactPhpHttpTransportHandler`. Override specific methods to adapt the behavior (e.g., `sendResponse`, `handleSseConnection`, `cleanupClient`). Remember to call the parent constructor correctly if extending HTTP handlers: `parent::__construct($server)`. The `ReactPhpHttpTransportHandler` serves as a good example of extending `HttpTransportHandler`. - -Examine the source code of the provided handlers to understand the interaction with the `Processor` and how to manage the request/response flow and client state. - -## Advanced Usage & Recipes - -Here are some examples of how to integrate `php-mcp/server` with common libraries and frameworks. - -### Using Custom PSR Implementations - -* **Monolog (PSR-3 Logger):** - ```php - use Monolog\Logger; - use Monolog\Handler\StreamHandler; - use PhpMcp\Server\Server; - - // composer require monolog/monolog - $log = new Logger('mcp-server'); - $log->pushHandler(new StreamHandler(__DIR__.'/mcp.log', Logger::DEBUG)); - - $server = Server::make() - ->withLogger($log) - // ... other configurations - ->discover() - ->run(); - ``` - -* **PSR-11 Container (Example with PHP-DI):** - ```php - use DI\ContainerBuilder; - use PhpMcp\Server\Server; - - // composer require php-di/php-di - $containerBuilder = new ContainerBuilder(); - // $containerBuilder->addDefinitions(...); // Add your app definitions - $container = $containerBuilder->build(); - - $server = Server::make() - ->withContainer($container) - // ... other configurations - ->discover() - ->run(); - ``` - -* **Fine-grained Configuration:** - Override default settings by providing a pre-configured `ArrayConfigurationRepository`: - ```php - use PhpMcp\Server\Defaults\ArrayConfigurationRepository; - use PhpMcp\Server\Server; - - $configOverrides = [ - 'mcp.server.name' => 'My Custom PHP Server', - 'mcp.capabilities.prompts.enabled' => false, // Disable prompts - 'mcp.discovery.directories' => ['src/Api/McpHandlers'], // Scan only specific dir - ]; - - $configRepo = new ArrayConfigurationRepository($configOverrides); - // Note: This replaces ALL defaults. Merge manually if needed: - // $defaultConfig = new ArrayConfigurationRepository(); // Get defaults - // $mergedConfigData = array_merge_recursive($defaultConfig->all(), $configOverrides); - // $configRepo = new ArrayConfigurationRepository($mergedConfigData); - - $server = Server::make() - ->withConfig($configRepo) - // Ensure other PSR dependencies are provided if not using defaults - // ->withLogger(...)->withCache(...)->withContainer(...) - ->withBasePath(__DIR__) - ->discover() - ->run(); - ``` - -### HTTP+SSE Integration (Framework Examples) - -* **Symfony Controller Skeleton:** - ```php - query('clientId'); - if (! $clientId) { - // Or: $session = $request->getSession(); $session->start(); $clientId = $session->getId(); - return new Response('Missing clientId', 400); - } - - if (! $request->isJson()) { - return new Response('Content-Type must be application/json', 415); - } - $content = $request->getContent(); - if (empty($content)) { - return new Response('Empty request body', 400); - } - - // Ensure session is started if using session ID - $session = $request->getSession(); - $session->start(); // Make sure session exists - $clientId = $session->getId(); - - try { - $this->mcpHandler->handleInput($content, $clientId); - return new Response(null, 202); // Accepted - } catch (\JsonException $e) { - return new Response('Invalid JSON: '.$e->getMessage(), 400); - } catch (\Throwable $e) { - $this->logger->error('MCP POST error', ['exception' => $e]); - return new Response('Internal Server Error', 500); - } - } - - #[Route('/mcp/sse', name: 'mcp_sse', methods: ['GET'])] - public function handleSse(Request $request): StreamedResponse - { - // Retrieve/generate clientId (e.g., from session or generate new one) - $session = $request->getSession(); - $session->start(); - $clientId = $session->getId(); - // Or: $clientId = 'client_'.bin2hex(random_bytes(16)); - - $this->logger->info('MCP SSE connection opening', ['client_id' => $clientId]); - - $response = new StreamedResponse(function () use ($clientId, $request) { - try { - $postEndpointUri = $this->generateUrl('mcp_post', ['clientId' => $clientId], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL); - - // Use the handler's method to manage the SSE loop - $this->mcpHandler->handleSseConnection($clientId, $postEndpointUri); - } catch (\Throwable $e) { - if (! ($e instanceof RuntimeException && str_contains($e->getMessage(), 'disconnected'))) { - $this->logger->error('SSE stream loop terminated unexpectedly', ['exception' => $e, 'clientId' => $clientId]); - } - } finally { - // Ensure cleanup happens when the loop exits - $this->mcpHandler->cleanupClient($clientId); - $this->logger->info('SSE connection closed', ['client_id' => $clientId]); - } - }); - - // Set headers for SSE - $response->headers->set('Content-Type', 'text/event-stream'); - $response->headers->set('Cache-Control', 'no-cache'); - $response->headers->set('Connection', 'keep-alive'); - $response->headers->set('X-Accel-Buffering', 'no'); // Important for Nginx - return $response; - } - } - ``` - -### Resource, Tool, and Prompt Change Notifications - -Clients may need to be notified if the available resources, tools, or prompts change *after* the initial connection and `initialize` handshake (e.g., due to dynamic configuration changes, file watching, etc.). - -When your application detects such a change, retrieve the `Registry` instance (e.g., via `$server->getRegistry()` or DI) and call the appropriate method: - -* `$registry->notifyResourceChanged(string $uri)`: If a specific resource's content changed. -* `$registry->notifyResourcesListChanged()`: If the list of available resources changed. -* `$registry->notifyToolsListChanged()`: If the list of available tools changed. -* `$registry->notifyPromptsListChanged()`: If the list of available prompts changed. - -These methods trigger internal notifiers (configurable via `set*ChangedNotifier` methods on the registry). The active transport handler (especially `HttpTransportHandler` in its SSE loop) listens for these notifications and sends the corresponding MCP notification (`resources/didChange`, `resources/listChanged`, `tools/listChanged`, `prompts/listChanged`) to connected clients. - -## Connecting MCP Clients - -You can connect various MCP-compatible clients to servers built with this library. The connection method depends on the transport you are using (`stdio` or `http`). - -**General Principles:** - -* **`stdio` Transport:** You typically provide the client with the command needed to execute your server script (e.g., `php /path/to/mcp-server.php`). The client manages the server process lifecycle. -* **`http` Transport:** You provide the client with the URL of your SSE endpoint (e.g., `http://localhost:8080/mcp/sse`). The client connects to this URL, and the server (via the initial `endpoint` event) tells the client where to send POST requests. - -**Client-Specific Instructions:** - -* **Cursor:** - * Open your User Settings (`Cmd/Ctrl + ,`), navigate to the `MCP` section, or directly edit your `.cursor/mcp.json` file. - * Add an entry under `mcpServers`: - * **For `stdio`:** - ```json - { - "mcpServers": { - "my-php-server-stdio": { // Choose a unique name - "command": "php", - "args": [ - "/full/path/to/your/project/mcp-server.php" // Use absolute path - ] - } - } - } - ``` - * **For `http`:** (Check Cursor's documentation for the exact format, likely involves a `url` field) - ```json - { - "mcpServers": { - "my-php-server-http": { // Choose a unique name - "url": "http://localhost:8080/mcp/sse" // Your SSE endpoint URL - } - } - } - ``` - -* **Claude Desktop:** - * Go to Settings -> Connected Apps -> MCP Servers -> Add Server. - * **For `stdio`:** Select "Command" type, enter `php` in the command field, and the absolute path to your `mcp-server.php` script in the arguments field. - * **For `http`:** Select "URL" type and enter the full URL of your SSE endpoint (e.g., `http://localhost:8080/mcp/sse`). - * *(Refer to official Claude Desktop documentation for the most up-to-date instructions.)* - -* **Windsurf:** - * Connection settings are typically managed through its configuration. - * **For `stdio`:** Look for options to define a server using a command and arguments, similar to Cursor. - * **For `http+sse`:** Look for options to connect to an MCP server via a URL, providing your SSE endpoint. - * *(Refer to official Windsurf documentation for specific details.)* +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. ## Examples -Working examples demonstrating different setups can be found in the [`samples/`](./samples/) directory: +See the [`examples/`](./examples/) directory: -* [`samples/php_stdio/`](./samples/php_stdio/): Demonstrates a basic server using the `stdio` transport, suitable for direct command-line execution by clients. -* [`samples/php_http/`](./samples/php_http/): Provides a basic example of integrating with a synchronous PHP HTTP server (e.g., using PHP's built-in server or Apache/Nginx with PHP-FPM). *Note: Requires careful handling of request lifecycles and SSE for full functionality.* -* [`samples/reactphp_http/`](./samples/reactphp_http/): Shows how to integrate the `ReactPhpHttpTransportHandler` with [ReactPHP](https://reactphp.org/) to create an asynchronous HTTP+SSE server. +* **`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). +* *(Conceptual framework example to be added)* ## Testing -This package uses [Pest](https://pestphp.com/) for testing. - -1. Install development dependencies: - ```bash - composer install --dev - ``` -2. Run the test suite: - ```bash - composer test - ``` -3. Run tests with code coverage reporting (requires Xdebug): - ```bash - composer test:coverage - ``` +```bash +composer install --dev +composer test +composer test:coverage # Requires Xdebug +``` ## Contributing -Please see CONTRIBUTING.md for details (if it exists), but generally: - -* Report bugs or suggest features via GitHub Issues. -* Submit pull requests for improvements. Please ensure tests pass and code style is maintained. +Please see [CONTRIBUTING.md](CONTRIBUTING.md). ## License -The MIT License (MIT). Please see [License File](LICENSE) for more information. - -## Support & Feedback - -Please open an issue on the [GitHub repository](https://github.com/php-mcp/server) for bugs, questions, or feedback. +The MIT License (MIT). See [LICENSE](LICENSE). \ No newline at end of file From 8285b48e7e958e5c366365f91fc7b7309433e05b Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 10 May 2025 00:52:59 +0100 Subject: [PATCH 4/9] chore(docs): Update README with comprehensive v2.0 architecture changes [skip ci] --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 48fd840..9ffb7fd 100644 --- a/README.md +++ b/README.md @@ -581,4 +581,5 @@ Please see [CONTRIBUTING.md](CONTRIBUTING.md). ## License -The MIT License (MIT). See [LICENSE](LICENSE). \ No newline at end of file +The MIT License (MIT). See [LICENSE](LICENSE). + From bee773e378a2c2cb58cdb0a4b99dc065049c7e00 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 10 May 2025 00:56:24 +0100 Subject: [PATCH 5/9] fix: warn and prevent usage of STDIN and STDOUT on Windows due to PHP limitations --- src/Transports/StdioServerTransport.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Transports/StdioServerTransport.php b/src/Transports/StdioServerTransport.php index 965129f..8ada006 100644 --- a/src/Transports/StdioServerTransport.php +++ b/src/Transports/StdioServerTransport.php @@ -64,6 +64,19 @@ public function __construct( protected $inputStreamResource = STDIN, protected $outputStreamResource = STDOUT ) { + if (str_contains(PHP_OS, 'WIN') && ($this->inputStreamResource === STDIN && $this->outputStreamResource === STDOUT)) { + $message = 'STDIN and STDOUT are not supported as input and output stream resources'. + 'on Windows due to PHP\'s limitations with non blocking pipes.'. + 'Please use WSL or HttpServerTransport, or if you are advanced, provide your own stream resources.'; + + throw new TransportException($message); + } + + // if (str_contains(PHP_OS, 'WIN')) { + // $this->inputStreamResource = pclose(popen('winpty -c "'.$this->inputStreamResource.'"', 'r')); + // $this->outputStreamResource = pclose(popen('winpty -c "'.$this->outputStreamResource.'"', 'w')); + // } + if (! is_resource($this->inputStreamResource) || get_resource_type($this->inputStreamResource) !== 'stream') { throw new TransportException('Invalid input stream resource provided.'); } From ca6d25a46d5eebe0add32e610a442dd95060cbd0 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 10 May 2025 12:18:57 +0100 Subject: [PATCH 6/9] feat: provide more informative validate error messages for tool calls - Enhanced error handling in Processor to provide more informative validation error messages. - Changed server listening address from '0.0.0.0' to '127.0.0.1' in example server scripts for improved clarity and compatibility. - Refined logging messages in HttpServerTransport for consistency and clarity. --- .../04-combined-registration-http/server.php | 4 ++-- .../07-complex-tool-schema-http/server.php | 2 +- src/Processor.php | 21 +++++++++++++--- src/Support/SchemaValidator.php | 24 +++++++++++++++---- src/Transports/HttpServerTransport.php | 23 +++++++++--------- 5 files changed, 51 insertions(+), 23 deletions(-) diff --git a/examples/04-combined-registration-http/server.php b/examples/04-combined-registration-http/server.php index 7f376d2..dcb7f5f 100644 --- a/examples/04-combined-registration-http/server.php +++ b/examples/04-combined-registration-http/server.php @@ -19,7 +19,7 @@ | | To Use: | 1. Run this script from your CLI: `php server.php` - | The server will listen on http://0.0.0.0:8081 by default. + | The server will listen on http://127.0.0.1:8081 by default. | 2. Configure your MCP Client (e.g., Cursor): | | { @@ -80,7 +80,7 @@ public function log($level, \Stringable|string $message, array $context = []): v // If 'config://priority' was discovered, the manual one takes precedence. $server->discover(__DIR__, scanDirs: ['.']); - $transport = new HttpServerTransport('0.0.0.0', 8081, 'mcp_combined'); + $transport = new HttpServerTransport('127.0.0.1', 8081, 'mcp_combined'); $server->listen($transport); diff --git a/examples/07-complex-tool-schema-http/server.php b/examples/07-complex-tool-schema-http/server.php index 8b90528..cacd57a 100644 --- a/examples/07-complex-tool-schema-http/server.php +++ b/examples/07-complex-tool-schema-http/server.php @@ -82,7 +82,7 @@ public function log($level, \Stringable|string $message, array $context = []): v $server->discover(__DIR__, ['.']); - $transport = new HttpServerTransport('120.0.0.1', 8082, 'mcp_scheduler'); + $transport = new HttpServerTransport('127.0.0.1', 8082, 'mcp_scheduler'); $server->listen($transport); $logger->info('Server listener stopped gracefully.'); diff --git a/src/Processor.php b/src/Processor.php index 2e73cc7..6a611e4 100644 --- a/src/Processor.php +++ b/src/Processor.php @@ -299,18 +299,33 @@ private function handleToolCall(array $params): CallToolResult $definition = $this->registry->findTool($toolName); if (! $definition) { - throw McpServerException::methodNotFound("Tool '{$toolName}' not found."); // Method not found seems appropriate + throw McpServerException::methodNotFound("Tool '{$toolName}' not found."); } $inputSchema = $definition->getInputSchema(); $argumentsForValidation = is_object($argumentsRaw) ? (array) $argumentsRaw : $argumentsRaw; $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($argumentsForValidation, $inputSchema); + if (! empty($validationErrors)) { - throw McpServerException::invalidParams(data: ['validation_errors' => $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) $argumentsRaw; // Need array for ArgumentPreparer + $argumentsForPhpCall = (array) $argumentsRaw; try { $instance = $this->container->get($definition->getClassName()); diff --git a/src/Support/SchemaValidator.php b/src/Support/SchemaValidator.php index 8655059..3fb0231 100644 --- a/src/Support/SchemaValidator.php +++ b/src/Support/SchemaValidator.php @@ -2,12 +2,12 @@ namespace PhpMcp\Server\Support; +use InvalidArgumentException; use JsonException; use Opis\JsonSchema\Errors\ValidationError; use Opis\JsonSchema\Validator; use Psr\Log\LoggerInterface; use Throwable; -use InvalidArgumentException; /** * Validates data against JSON Schema definitions using opis/json-schema. @@ -15,6 +15,7 @@ class SchemaValidator { private ?Validator $jsonSchemaValidator = null; + private LoggerInterface $logger; public function __construct(LoggerInterface $logger) @@ -50,12 +51,15 @@ public function validateAgainstJsonSchema(mixed $data, array|object $schema): ar } catch (JsonException $e) { $this->logger->error('MCP SDK: Invalid schema structure provided for validation (JSON conversion failed).', ['exception' => $e]); + return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Invalid schema definition provided (JSON error).']]; } catch (InvalidArgumentException $e) { $this->logger->error('MCP SDK: Invalid schema structure provided for validation.', ['exception' => $e]); + return [['pointer' => '', 'keyword' => 'internal', 'message' => $e->getMessage()]]; } catch (Throwable $e) { $this->logger->error('MCP SDK: Error preparing data/schema for validation.', ['exception' => $e]); + return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Internal validation preparation error.']]; } @@ -70,6 +74,7 @@ public function validateAgainstJsonSchema(mixed $data, array|object $schema): ar 'data' => json_encode($dataToValidate), 'schema' => json_encode($schemaObject), ]); + return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Schema validation process failed: '.$e->getMessage()]]; } @@ -101,9 +106,10 @@ public function validateAgainstJsonSchema(mixed $data, array|object $schema): ar private function getJsonSchemaValidator(): Validator { if ($this->jsonSchemaValidator === null) { - $this->jsonSchemaValidator = new Validator(); + $this->jsonSchemaValidator = new Validator; // Potentially configure resolver here if needed later } + return $this->jsonSchemaValidator; } @@ -114,11 +120,12 @@ private function convertDataForValidator(mixed $data): mixed { if (is_array($data)) { // Check if it's an associative array (keys are not sequential numbers 0..N-1) - if (!empty($data) && array_keys($data) !== range(0, count($data) - 1)) { - $obj = new \stdClass(); + if (! empty($data) && array_keys($data) !== range(0, count($data) - 1)) { + $obj = new \stdClass; foreach ($data as $key => $value) { $obj->{$key} = $this->convertDataForValidator($value); } + return $obj; } else { // It's a list (sequential array), convert items recursively @@ -126,12 +133,14 @@ private function convertDataForValidator(mixed $data): mixed } } elseif (is_object($data) && $data instanceof \stdClass) { // Deep copy/convert stdClass objects as well - $obj = new \stdClass(); + $obj = new \stdClass; foreach (get_object_vars($data) as $key => $value) { $obj->{$key} = $this->convertDataForValidator($value); } + return $obj; } + // Leave other objects and scalar types as they are return $data; } @@ -165,8 +174,10 @@ private function formatJsonPointerPath(?array $pathComponents): string } $escapedComponents = array_map(function ($component) { $componentStr = (string) $component; + return str_replace(['~', '/'], ['~0', '~1'], $componentStr); }, $pathComponents); + return '/'.implode('/', $escapedComponents); } @@ -213,6 +224,7 @@ private function formatValidationError(ValidationError $error): string if ($v === null) { return 'null'; } + return (string) $v; }, $allowedValues); $message = 'Value must be one of the allowed values: '.implode(', ', $formattedAllowed).'.'; @@ -289,12 +301,14 @@ private function formatValidationError(ValidationError $error): string $builtInMessage = preg_replace_callback('/\{(\w+)\}/', function ($match) use ($placeholders) { $key = $match[1]; $value = $placeholders[$key] ?? '{'.$key.'}'; + return is_array($value) ? json_encode($value) : (string) $value; }, $builtInMessage); $message = $builtInMessage; } break; } + return $message; } } diff --git a/src/Transports/HttpServerTransport.php b/src/Transports/HttpServerTransport.php index b001ee3..258de93 100644 --- a/src/Transports/HttpServerTransport.php +++ b/src/Transports/HttpServerTransport.php @@ -65,7 +65,7 @@ public function __construct( private readonly string $mcpPathPrefix = 'mcp', // e.g., /mcp/sse, /mcp/message private readonly ?array $sslContext = null // For enabling HTTPS ) { - $this->logger = new NullLogger(); + $this->logger = new NullLogger; $this->loop = Loop::get(); $this->ssePath = '/'.trim($mcpPathPrefix, '/').'/sse'; $this->messagePath = '/'.trim($mcpPathPrefix, '/').'/message'; @@ -109,7 +109,7 @@ public function listen(): void $this->http->listen($this->socket); $this->socket->on('error', function (Throwable $error) { - $this->logger->error('HttpTransport: Socket server error.', ['error' => $error->getMessage()]); + $this->logger->error('Socket server error.', ['error' => $error->getMessage()]); $this->emit('error', [new TransportException("Socket server error: {$error->getMessage()}", 0, $error)]); $this->close(); }); @@ -159,7 +159,7 @@ private function handleSseRequest(ServerRequestInterface $request): Response $clientId = 'sse_'.bin2hex(random_bytes(16)); $this->logger->info('New SSE connection', ['clientId' => $clientId]); - $sseStream = new ThroughStream(); + $sseStream = new ThroughStream; $sseStream->on('close', function () use ($clientId) { $this->logger->info('SSE stream closed', ['clientId' => $clientId]); @@ -216,13 +216,13 @@ private function handleMessagePostRequest(ServerRequestInterface $request): Resp $clientId = $queryParams['clientId'] ?? null; if (! $clientId || ! is_string($clientId)) { - $this->logger->warning('HttpTransport: Received POST without valid clientId query parameter.'); + $this->logger->warning('Received POST without valid clientId query parameter.'); return new Response(400, ['Content-Type' => 'text/plain'], 'Missing or invalid clientId query parameter'); } if (! isset($this->activeSseStreams[$clientId])) { - $this->logger->warning('HttpTransport: Received POST for unknown or disconnected clientId.', ['clientId' => $clientId]); + $this->logger->warning('Received POST for unknown or disconnected clientId.', ['clientId' => $clientId]); return new Response(404, ['Content-Type' => 'text/plain'], 'Client ID not found or disconnected'); } @@ -234,7 +234,7 @@ private function handleMessagePostRequest(ServerRequestInterface $request): Resp $body = $request->getBody()->getContents(); if (empty($body)) { - $this->logger->warning('HttpTransport: Received empty POST body', ['clientId' => $clientId]); + $this->logger->warning('Received empty POST body', ['clientId' => $clientId]); return new Response(400, ['Content-Type' => 'text/plain'], 'Empty request body'); } @@ -265,16 +265,15 @@ public function sendToClientAsync(string $clientId, string $rawFramedMessage): P return \React\Promise\resolve(null); } - $deferred = new Deferred(); + $deferred = new Deferred; $written = $this->sendSseEvent($stream, 'message', $jsonData); if ($written) { - $this->logger->debug('HttpTransport: Message sent via SSE.', ['clientId' => $clientId, 'data' => $jsonData]); $deferred->resolve(null); } else { - $this->logger->debug('HttpTransport: SSE stream buffer full, waiting for drain.', ['clientId' => $clientId]); + $this->logger->debug('SSE stream buffer full, waiting for drain.', ['clientId' => $clientId]); $stream->once('drain', function () use ($deferred, $clientId) { - $this->logger->debug('HttpTransport: SSE stream drained.', ['clientId' => $clientId]); + $this->logger->debug('SSE stream drained.', ['clientId' => $clientId]); $deferred->resolve(null); }); // Add a timeout? @@ -316,7 +315,7 @@ public function close(): void } $this->closing = true; $this->listening = false; - $this->logger->info('HttpTransport: Closing...'); + $this->logger->info('Closing transport...'); if ($this->socket) { $this->socket->close(); @@ -326,7 +325,7 @@ public function close(): void $activeStreams = $this->activeSseStreams; $this->activeSseStreams = []; foreach ($activeStreams as $clientId => $stream) { - $this->logger->debug('HttpTransport: Closing active SSE stream', ['clientId' => $clientId]); + $this->logger->debug('Closing active SSE stream', ['clientId' => $clientId]); unset($this->activeSseStreams[$clientId]); $stream->close(); } From d3a0fe6e7e813e46a07de3d6236cde1b9a4b76d0 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 10 May 2025 12:28:30 +0100 Subject: [PATCH 7/9] refactor(Schema): Remove optimistic string format inference from SchemaGenerator --- src/Support/SchemaGenerator.php | 20 +++++--------------- tests/Unit/Support/SchemaGeneratorTest.php | 14 +------------- 2 files changed, 6 insertions(+), 28 deletions(-) diff --git a/src/Support/SchemaGenerator.php b/src/Support/SchemaGenerator.php index ae216c2..32408e6 100644 --- a/src/Support/SchemaGenerator.php +++ b/src/Support/SchemaGenerator.php @@ -131,20 +131,10 @@ public function fromMethodParameters(ReflectionMethod $method): array } } - // Add format for specific string types (basic inference) - if (isset($paramSchema['type'])) { - $schemaType = is_array($paramSchema['type']) ? (in_array('string', $paramSchema['type']) ? 'string' : null) : $paramSchema['type']; - if ($schemaType === 'string') { - if (stripos($name, 'email') !== false || stripos($typeString, 'email') !== false) { - $paramSchema['format'] = 'email'; - } elseif (stripos($name, 'date') !== false || stripos($typeString, 'date') !== false) { - $paramSchema['format'] = 'date-time'; // Or 'date' depending on convention - } elseif (stripos($name, 'uri') !== false || stripos($name, 'url') !== false || stripos($typeString, 'uri') !== false || stripos($typeString, 'url') !== false) { - $paramSchema['format'] = 'uri'; - } - // Add more format detections if needed - } - } + // 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'])) { @@ -176,7 +166,7 @@ public function fromMethodParameters(ReflectionMethod $method): array if (empty($schema['properties'])) { // Keep properties object even if empty, per spec - $schema['properties'] = new stdClass(); + $schema['properties'] = new stdClass; } if (empty($schema['required'])) { unset($schema['required']); diff --git a/tests/Unit/Support/SchemaGeneratorTest.php b/tests/Unit/Support/SchemaGeneratorTest.php index b638822..4a917bb 100644 --- a/tests/Unit/Support/SchemaGeneratorTest.php +++ b/tests/Unit/Support/SchemaGeneratorTest.php @@ -56,7 +56,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection $schema = $this->schemaGenerator->fromMethodParameters($method); - expect($schema)->toEqual(['type' => 'object', 'properties' => new \stdClass()]); + expect($schema)->toEqual(['type' => 'object', 'properties' => new \stdClass]); expect($schema)->not->toHaveKey('required'); }); @@ -216,15 +216,3 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection expect($schema['properties']['p1'])->toEqual(['type' => 'string', 'description' => 'Docblock overrides int']); expect($schema['required'])->toEqualCanonicalizing(['p1']); }); - -test('generates schema infers format from parameter name', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'formatParams'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['email'])->toEqual(['type' => 'string', 'description' => 'Email address', 'format' => 'email']); - expect($schema['properties']['url'])->toEqual(['type' => 'string', 'description' => 'URL string', 'format' => 'uri']); - expect($schema['properties']['dateTime'])->toEqual(['type' => 'string', 'description' => 'ISO Date time string', 'format' => 'date-time']); - expect($schema['required'])->toEqualCanonicalizing(['email', 'url', 'dateTime']); -}); From 505f8b7ef345c9d5ec1e39fefce9f1e3d6f5c08f Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 10 May 2025 13:25:42 +0100 Subject: [PATCH 8/9] fix: json schema validator error being thrown for tools with no argument --- src/Processor.php | 13 ++++++------- tests/Unit/Support/SchemaGeneratorTest.php | 7 ------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/Processor.php b/src/Processor.php index 6a611e4..04c7c8c 100644 --- a/src/Processor.php +++ b/src/Processor.php @@ -285,15 +285,15 @@ private function handlePromptsList(array $params): ListPromptsResult private function handleToolCall(array $params): CallToolResult { $toolName = $params['name'] ?? null; - $argumentsRaw = $params['arguments'] ?? null; + $arguments = $params['arguments'] ?? null; if (! is_string($toolName) || empty($toolName)) { throw McpServerException::invalidParams("Missing or invalid 'name' parameter for tools/call."); } - if ($argumentsRaw === null) { - $argumentsRaw = new stdClass; - } elseif (! is_array($argumentsRaw) && ! $argumentsRaw instanceof stdClass) { + 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."); } @@ -303,9 +303,8 @@ private function handleToolCall(array $params): CallToolResult } $inputSchema = $definition->getInputSchema(); - $argumentsForValidation = is_object($argumentsRaw) ? (array) $argumentsRaw : $argumentsRaw; - $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($argumentsForValidation, $inputSchema); + $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($arguments, $inputSchema); if (! empty($validationErrors)) { $errorMessages = []; @@ -325,7 +324,7 @@ private function handleToolCall(array $params): CallToolResult throw McpServerException::invalidParams($summaryMessage, data: ['validation_errors' => $validationErrors]); } - $argumentsForPhpCall = (array) $argumentsRaw; + $argumentsForPhpCall = (array) $arguments; try { $instance = $this->container->get($definition->getClassName()); diff --git a/tests/Unit/Support/SchemaGeneratorTest.php b/tests/Unit/Support/SchemaGeneratorTest.php index 4a917bb..3e49e7c 100644 --- a/tests/Unit/Support/SchemaGeneratorTest.php +++ b/tests/Unit/Support/SchemaGeneratorTest.php @@ -143,13 +143,6 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema for enum types', function () { - // Skip test if PHP version is less than 8.1 - if (version_compare(PHP_VERSION, '8.1', '<')) { - expect(true)->toBeTrue(); // Placeholder assertion - - return; // Skip test - } - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'enumTypes'); setupDocBlockExpectations($this->docBlockParserMock, $method); From 7fafdd69649905c751c5cc1f5490213288233b70 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 11 May 2025 01:29:51 +0100 Subject: [PATCH 9/9] chore(docs): Remove outdated custom integration section from README [skip ci] --- README.md | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/README.md b/README.md index 9ffb7fd..a39342a 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,6 @@ The server uses a decoupled architecture: * **`Protocol`:** Internal bridge listening to transport events, interacting with `Processor` and `ClientStateManager`. * **`discover()` Method:** An **explicit** method on the `Server` instance to trigger attribute discovery. Takes path configurations as arguments. By default, it clears previously discovered/cached elements before scanning and saves the new results to cache (if enabled). * **`listen()` Method:** Starts the server using a specific transport. Binds the `Protocol`, starts the transport listener, and **runs the event loop (blocking)**. Performs a pre-check and warns if no elements are registered and discovery hasn't run. -* **`getProtocol()` Method:** For framework integration (see below). ## Defining MCP Elements @@ -282,22 +281,6 @@ $server->listen($transport); * **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. -### Custom / Framework Integration - -Integrate into frameworks without the blocking `listen()` call: - -1. **Build Server:** `$server = Server::make()->...->build();`, likely as a service container singleton. -2. **(Optional) Discover:** `$server->discover(...);` (Perhaps in a cache warmup command). -3. **Get Protocol:** Obtain the protocol from the server: `$protocol = $server->getProtocol();` -4. **Bridge Transport Events:** In your framework's request handling (HTTP controller, WebSocket): - * When a new connection is established: `$protocol->handleClientConnected($clientId);` - * When a raw JSON-RPC message frame is received, call: `$protocol->handleRawMessage($rawJsonFrame, $clientId);` - * When a connection closes, call: `$protocol->handleClientDisconnected($clientId, $reason);` - * Handle transport-level errors by potentially calling: `$protocol->handleTransportError($exception, $clientId);` -5. **Sending Responses:** You'll need framework-specific logic to send responses/notifications back to the correct client connection identified by `$clientId`. - -This approach allows the framework to manage the event loop, sockets, and request/response objects, while leveraging the `php-mcp/server` core for MCP processing logic. - ## Connecting MCP Clients Instruct clients how to connect to your server: @@ -565,7 +548,6 @@ See the [`examples/`](./examples/) directory: * **`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). -* *(Conceptual framework example to be added)* ## Testing