From 8a834c7496505f4d9412eb568a9d208fb8c6f17e Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Thu, 5 Jun 2025 18:27:51 +0100 Subject: [PATCH 01/27] refactor: New SessionManager and Session to replace ClientManager and state - Updated Protocol, Registry, Server, ServerBuilder, and RequestProcessor to use SessionManager for session handling. - Adjusted methods to reflect the new session management approach, including cleanup and message queuing. - Enhanced error handling in AudioContent for better file reading feedback. --- src/Contracts/SessionInterface.php | 87 +++++++ src/JsonRpc/Contents/AudioContent.php | 11 +- src/Protocol.php | 10 +- src/Registry.php | 85 +++---- src/Server.php | 8 +- src/ServerBuilder.php | 10 +- src/Session/Session.php | 189 ++++++++++++++ src/Session/SessionManager.php | 338 ++++++++++++++++++++++++++ src/Support/RequestProcessor.php | 31 +-- 9 files changed, 680 insertions(+), 89 deletions(-) create mode 100644 src/Contracts/SessionInterface.php create mode 100644 src/Session/Session.php create mode 100644 src/Session/SessionManager.php diff --git a/src/Contracts/SessionInterface.php b/src/Contracts/SessionInterface.php new file mode 100644 index 0000000..20d78d3 --- /dev/null +++ b/src/Contracts/SessionInterface.php @@ -0,0 +1,87 @@ + + */ + public function dequeueMessages(): array; + + /** + * Check if there are any messages in the queue. + */ + public function hasQueuedMessages(): bool; +} diff --git a/src/JsonRpc/Contents/AudioContent.php b/src/JsonRpc/Contents/AudioContent.php index cb065ad..be186a4 100644 --- a/src/JsonRpc/Contents/AudioContent.php +++ b/src/JsonRpc/Contents/AudioContent.php @@ -16,8 +16,7 @@ class AudioContent extends Content public function __construct( protected string $data, protected string $mimeType - ) { - } + ) {} /** * Get the audio data. @@ -71,8 +70,12 @@ public static function fromFile(string $path, ?string $mimeType = null): static throw new \InvalidArgumentException("Audio file not found: {$path}"); } - $data = base64_encode(file_get_contents($path)); - $detectedMime = $mimeType ?? mime_content_type($path) ?? 'audio/mpeg'; + $content = file_get_contents($path); + if ($content === false) { + throw new \RuntimeException("Could not read audio file: {$path}"); + } + $data = base64_encode($content); + $detectedMime = $mimeType ?? mime_content_type($path) ?: 'application/octet-stream'; return new static($data, $detectedMime); } diff --git a/src/Protocol.php b/src/Protocol.php index 643f8f0..a0fdd76 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -11,7 +11,7 @@ use PhpMcp\Server\JsonRpc\Notification; use PhpMcp\Server\JsonRpc\Request; use PhpMcp\Server\JsonRpc\Response; -use PhpMcp\Server\State\ClientStateManager; +use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Support\RequestProcessor; use Psr\Log\LoggerInterface; use React\Promise\PromiseInterface; @@ -37,13 +37,13 @@ class Protocol public function __construct( protected readonly Configuration $configuration, protected readonly Registry $registry, - protected readonly ClientStateManager $clientStateManager, + protected readonly SessionManager $sessionManager, protected ?RequestProcessor $requestProcessor = null, ) { $this->requestProcessor ??= new RequestProcessor( $configuration, $registry, - $clientStateManager, + $sessionManager, ); $this->logger = $configuration->logger; @@ -229,7 +229,7 @@ public function handleClientConnected(string $clientId): void public function handleClientDisconnected(string $clientId, ?string $reason = null): void { $this->logger->info('Client disconnected', ['clientId' => $clientId, 'reason' => $reason ?? 'N/A']); - $this->clientStateManager->cleanupClient($clientId); + $this->sessionManager->deleteSession($clientId); } /** @@ -242,7 +242,7 @@ public function handleTransportError(Throwable $error, ?string $clientId = null) if ($clientId) { $context['clientId'] = $clientId; $this->logger->error('Transport error for client', $context); - $this->clientStateManager->cleanupClient($clientId); + $this->sessionManager->deleteSession($clientId); } else { $this->logger->error('General transport error', $context); } diff --git a/src/Registry.php b/src/Registry.php index 281bf00..667a0bd 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -11,7 +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\Session\SessionManager; use PhpMcp\Server\Support\UriTemplateMatcher; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; @@ -22,12 +22,6 @@ class Registry { private const DISCOVERED_ELEMENTS_CACHE_KEY = 'mcp_server_discovered_elements'; - private ?CacheInterface $cache; - - private LoggerInterface $logger; - - private ?ClientStateManager $clientStateManager = null; - /** @var ArrayObject */ private ArrayObject $tools; @@ -58,14 +52,10 @@ class Registry public function __construct( - LoggerInterface $logger, - ?CacheInterface $cache = null, - ?ClientStateManager $clientStateManager = null + protected LoggerInterface $logger, + protected ?CacheInterface $cache = null, + protected ?SessionManager $sessionManager = null ) { - $this->logger = $logger; - $this->cache = $cache; - $this->clientStateManager = $clientStateManager; - $this->initializeCollections(); if ($this->cache) { @@ -110,45 +100,38 @@ private function initializeCollections(): void public function enableNotifications(): void { - $this->notificationsEnabled = true; - } - - public function disableNotifications(): void - { - $this->notificationsEnabled = false; - } - - public function notifyToolsListChanged(): void - { - if (!$this->notificationsEnabled || !$this->clientStateManager) { - return; - } - $notification = Notification::make('notifications/tools/list_changed'); - - $framedMessage = json_encode($notification, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; - if ($framedMessage === false || $framedMessage === "\n") { - $this->logger->error('Failed to encode notification for queuing.', ['method' => $notification->method]); - return; - } - $this->clientStateManager->queueMessageForAll($framedMessage); - } - - public function notifyResourcesListChanged(): void - { - if (!$this->notificationsEnabled || !$this->clientStateManager) { - return; - } - $notification = Notification::make('notifications/resources/list_changed'); - - $framedMessage = json_encode($notification, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; - if ($framedMessage === false || $framedMessage === "\n") { - $this->logger->error('Failed to encode notification for queuing.', ['method' => $notification->method]); - return; - } - $this->clientStateManager->queueMessageForAll($framedMessage); + $this->notifyToolsChanged = function () { + if ($this->sessionManager) { + $notification = Notification::make('notifications/tools/list_changed'); + $framedMessage = json_encode($notification->toArray(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; + if ($framedMessage !== false) { + $this->sessionManager->queueMessageForAll($framedMessage); + } + } + }; + + $this->notifyResourcesChanged = function () { + if ($this->sessionManager) { + $notification = Notification::make('notifications/resources/list_changed'); + $framedMessage = json_encode($notification->toArray(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; + if ($framedMessage !== false) { + $this->sessionManager->queueMessageForAll($framedMessage); + } + } + }; + + $this->notifyPromptsChanged = function () { + if ($this->sessionManager) { + $notification = Notification::make('notifications/prompts/list_changed'); + $framedMessage = json_encode($notification->toArray(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; + if ($framedMessage !== false) { + $this->sessionManager->queueMessageForAll($framedMessage); + } + } + }; } - public function notifyPromptsListChanged(): void + public function setToolsChangedNotifier(?callable $notifier): void { if (!$this->notificationsEnabled || !$this->clientStateManager) { return; diff --git a/src/Server.php b/src/Server.php index 540e7b9..0da4213 100644 --- a/src/Server.php +++ b/src/Server.php @@ -10,7 +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\Session\SessionManager; use PhpMcp\Server\Support\Discoverer; use Throwable; @@ -40,7 +40,7 @@ public function __construct( protected readonly Configuration $configuration, protected readonly Registry $registry, protected readonly Protocol $protocol, - protected readonly ClientStateManager $clientStateManager, + protected readonly SessionManager $sessionManager, ) {} public static function make(): ServerBuilder @@ -221,8 +221,8 @@ public function getProtocol(): Protocol return $this->protocol; } - public function getClientStateManager(): ClientStateManager + public function getSessionManager(): SessionManager { - return $this->clientStateManager; + return $this->sessionManager; } } diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index fc15a4d..8b8cf7a 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -8,7 +8,7 @@ use PhpMcp\Server\Exception\ConfigurationException; use PhpMcp\Server\Exception\DefinitionException; use PhpMcp\Server\Model\Capabilities; -use PhpMcp\Server\State\ClientStateManager; +use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Support\HandlerResolver; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; @@ -192,9 +192,9 @@ public function build(): Server paginationLimit: $this->paginationLimit ?? 50 ); - $clientStateManager = new ClientStateManager($configuration->logger, $configuration->cache, 'mcp_state_', $configuration->definitionCacheTtl); - $registry = new Registry($configuration->logger, $configuration->cache, $clientStateManager); - $protocol = new Protocol($configuration, $registry, $clientStateManager); + $sessionManager = new SessionManager($configuration->logger, $configuration->cache, $configuration->definitionCacheTtl); + $registry = new Registry($configuration->logger, $configuration->cache, $sessionManager); + $protocol = new Protocol($configuration, $registry, $sessionManager); $registry->disableNotifications(); @@ -202,7 +202,7 @@ public function build(): Server $registry->enableNotifications(); - $server = new Server($configuration, $registry, $protocol, $clientStateManager); + $server = new Server($configuration, $registry, $protocol, $sessionManager); return $server; } diff --git a/src/Session/Session.php b/src/Session/Session.php new file mode 100644 index 0000000..bf09ef5 --- /dev/null +++ b/src/Session/Session.php @@ -0,0 +1,189 @@ + Stores all session data. + * Keys are snake_case by convention for MCP-specific data. + */ + protected array $data = [ + 'initialized' => false, + 'client_info' => null, + 'protocol_version' => null, + 'subscriptions' => [], // [uri => true] + 'message_queue' => [], // string[] (raw JSON-RPC frames) + 'requested_log_level' => null, + 'last_activity_timestamp' => 0, + ]; + + public function __construct(string $sessionId) + { + $this->id = $sessionId; + $this->touch(); + } + + public function getId(): string + { + return $this->id; + } + + public function initialize(): void + { + $this->setAttribute('initialized', true); + } + + public function isInitialized(): bool + { + return (bool) $this->getAttribute('initialized', false); + } + + public function getAttribute(string $key, mixed $default = null): mixed + { + $key = explode('.', $key); + $data = $this->data; + + foreach ($key as $segment) { + if (is_array($data) && array_key_exists($segment, $data)) { + $data = $data[$segment]; + } else { + return $default; + } + } + + return $data; + } + + public function setAttribute(string $key, mixed $value, bool $overwrite = true): void + { + $segments = explode('.', $key); + $data = &$this->data; + + while (count($segments) > 1) { + $segment = array_shift($segments); + if (!isset($data[$segment]) || !is_array($data[$segment])) { + $data[$segment] = []; + } + $data = &$data[$segment]; + } + + $lastKey = array_shift($segments); + if ($overwrite || !isset($data[$lastKey])) { + $data[$lastKey] = $value; + } + $this->touch(); + } + + public function hasAttribute(string $key): bool + { + $key = explode('.', $key); + $data = $this->data; + + foreach ($key as $segment) { + if (is_array($data) && array_key_exists($segment, $data)) { + $data = $data[$segment]; + } elseif (is_object($data) && isset($data->{$segment})) { + $data = $data->{$segment}; + } else { + return false; + } + } + + return true; + } + + public function forgetAttribute(string $key): void + { + $segments = explode('.', $key); + $data = &$this->data; + + while (count($segments) > 1) { + $segment = array_shift($segments); + if (!isset($data[$segment]) || !is_array($data[$segment])) { + $data[$segment] = []; + } + $data = &$data[$segment]; + } + + $lastKey = array_shift($segments); + if (isset($data[$lastKey])) { + unset($data[$lastKey]); + } + + $this->touch(); + } + + public function pullAttribute(string $key, mixed $default = null): mixed + { + $value = $this->getAttribute($key, $default); + $this->forgetAttribute($key); + return $value; + } + + public function getAttributes(): array + { + return $this->data; + } + + public function setAttributes(array $attributes): void + { + $this->data = array_merge( + [ + 'initialized' => false, + 'client_info' => null, + 'protocol_version' => null, + 'subscriptions' => [], + 'message_queue' => [], + 'requested_log_level' => null, + 'last_activity_timestamp' => 0, + ], + $attributes + ); + unset($this->data['id']); + + if (!isset($attributes['last_activity_timestamp'])) { + $this->touch(); + } else { + $this->data['last_activity_timestamp'] = (int) $attributes['last_activity_timestamp']; + } + } + + public function touch(): void + { + $this->data['last_activity_timestamp'] = time(); + } + + public function queueMessage(string $rawFramedMessage): void + { + $this->data['message_queue'][] = $rawFramedMessage; + } + + public function dequeueMessages(): array + { + $messages = $this->data['message_queue'] ?? []; + $this->data['message_queue'] = []; + + if (!empty($messages)) { + $this->touch(); + } + + return $messages; + } + + public function hasQueuedMessages(): bool + { + return !empty($this->data['message_queue']); + } + + public function jsonSerialize(): array + { + return $this->getAttributes(); + } +} diff --git a/src/Session/SessionManager.php b/src/Session/SessionManager.php new file mode 100644 index 0000000..17cdd82 --- /dev/null +++ b/src/Session/SessionManager.php @@ -0,0 +1,338 @@ +cache ??= new ArrayCache(); + } + + protected function getActiveSessionsCacheKey(): string + { + return $this->cachePrefix . self::GLOBAL_ACTIVE_SESSIONS_KEY; + } + + protected function getResourceSubscribersCacheKey(string $uri): string + { + return self::GLOBAL_RESOURCE_SUBSCRIBERS_KEY_PREFIX . sha1($uri); + } + + public function getSession(string $sessionId, bool $createIfNotFound = false): ?SessionInterface + { + $key = $this->cachePrefix . $sessionId; + $json = $this->cache->get($key); + + if ($json === null) { + return $createIfNotFound ? new Session($sessionId) : null; + } + + try { + $attributes = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + $session = new Session($sessionId); + $session->setAttributes($attributes); + return $session; + } catch (Throwable $e) { + $this->logger->warning('Failed to decode session data from cache.', ['sessionId' => $sessionId, 'key' => $key, 'exception' => $e]); + $this->cache->delete($key); + return $createIfNotFound ? new Session($sessionId) : null; + } + } + + public function isSessionInitialized(string $sessionId): bool + { + $session = $this->getSession($sessionId); + return $session !== null && $session->isInitialized(); + } + + public function initializeSession(string $sessionId): void + { + $session = $this->getSession($sessionId, true); + $session->initialize(); + + if ($this->saveSession($session)) { + $this->activeSessions[] = $sessionId; + } + } + + public function saveSession(SessionInterface $session): bool + { + try { + $key = $this->cachePrefix . $session->getId(); + $json = json_encode($session, JSON_THROW_ON_ERROR); + return $this->cache->set($key, $json, $this->cacheTtl); + } catch (Throwable $e) { + $this->logger->warning('Failed to save session data to cache.', ['sessionId' => $session->getId(), 'exception' => $e->getMessage()]); + return false; + } + } + + public function deleteSession(string $sessionId, bool $updateCache = true): bool + { + $this->removeAllResourceSubscriptions($sessionId); + + $key = $this->cachePrefix . $sessionId; + + try { + $this->cache->delete($key); + } catch (Throwable $e) { + $this->logger->error('Failed to delete session.', ['sessionId' => $sessionId, 'exception' => $e]); + } + + if ($updateCache) { + $activeSessionsKey = $this->getActiveSessionsCacheKey(); + try { + $activeSessions = $this->cache->get($activeSessionsKey, []); + + if (isset($activeSessions[$sessionId])) { + unset($activeSessions[$sessionId]); + $this->cache->set($activeSessionsKey, $activeSessions, $this->cacheTtl); + } + } catch (Throwable $e) { + $this->logger->error('Failed to update global active sessions list during cleanup.', ['sessionId' => $sessionId, 'exception' => $e]); + } + } + + $this->logger->info('Session deleted.', ['sessionId' => $sessionId]); + + return true; + } + + public function touchSession(string $sessionId): void + { + $session = $this->getSession($sessionId, true); + if ($session === null) return; + $session->touch(); + $this->saveSession($session); + } + + public function getActiveSessions(int $inactiveThreshold = 300): array + { + try { + $activeSessionsKey = $this->getActiveSessionsCacheKey(); + $activeSessions = $this->cache->get($activeSessionsKey, []); + + $currentTimeStamp = time(); + $sessionsToCleanUp = []; + + foreach ($activeSessions as $sessionId) { + $session = $this->getSession($sessionId, false); + if (!$session) { + $sessionsToCleanUp[] = $sessionId; + continue; + } + + $lastActivityTimestamp = $session->getAttribute('last_activity_timestamp'); + if ($currentTimeStamp - $lastActivityTimestamp > $inactiveThreshold) { + $sessionsToCleanUp[] = $sessionId; + } + } + + foreach ($sessionsToCleanUp as $sessionIdToClean) { + unset($activeSessions[$sessionIdToClean]); + $this->deleteSession($sessionIdToClean, false); + } + + $this->cache->set($activeSessionsKey, $activeSessions, $this->cacheTtl); + + return $activeSessions; + } catch (Throwable $e) { + $this->logger->error('Failed to get active sessions.', ['exception' => $e]); + + return []; + } + } + + public function storeClientInfo(string $sessionId, array $clientInfo): void + { + $session = $this->getSession($sessionId, true); + $session->setAttribute('client_info', $clientInfo); + $this->saveSession($session); + } + + public function addResourceSubscription(string $sessionId, string $uri): void + { + $session = $this->getSession($sessionId, true); + if ($session === null) return; + + $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); + + try { + $subscriptions = $session->getAttribute('subscriptions', []); + $subscriptions[$uri] = true; + $session->setAttribute('subscriptions', $subscriptions); + $this->saveSession($session); + + $subscribers = $this->cache->get($resourceSubKey, []); + $subscribers = is_array($subscribers) ? $subscribers : []; + $subscribers[$sessionId] = true; + $this->cache->set($resourceSubKey, $subscribers, $this->cacheTtl); + } catch (Throwable $e) { + $this->logger->warning('Failed to add resource subscription to session.', ['sessionId' => $sessionId, 'uri' => $uri, 'exception' => $e->getMessage()]); + } + } + + public function removeResourceSubscription(string $sessionId, string $uri): void + { + $session = $this->getSession($sessionId, true); + $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); + + try { + if ($session) { + $subscriptions = $session->getAttribute('subscriptions', []); + unset($subscriptions[$uri]); + $session->setAttribute('subscriptions', $subscriptions); + $this->saveSession($session); + } + + $subscribers = $this->cache->get($resourceSubKey, []); + $subscribers = is_array($subscribers) ? $subscribers : []; + $changed = false; + + if (isset($subscribers[$sessionId])) { + unset($subscribers[$sessionId]); + $changed = true; + } + + if ($changed) { + if (empty($subscribers)) { + $this->cache->delete($resourceSubKey); + } else { + $this->cache->set($resourceSubKey, $subscribers, $this->cacheTtl); + } + $this->logger->debug('Session unsubscribed from resource.', ['sessionId' => $sessionId, 'uri' => $uri]); + } + } catch (Throwable $e) { + $this->logger->warning('Failed to remove resource subscription from session.', ['sessionId' => $sessionId, 'uri' => $uri, 'exception' => $e->getMessage()]); + } + } + + public function removeAllResourceSubscriptions(string $sessionId): void + { + $session = $this->getSession($sessionId, true); + if ($session === null || empty($session->getAttribute('subscriptions'))) return; + + $urisSessionWasSubscribedTo = array_keys($session->getAttribute('subscriptions')); + + try { + $session->forgetAttribute('subscriptions'); + $this->saveSession($session); + + foreach ($urisSessionWasSubscribedTo as $uri) { + $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); + $subscribers = $this->cache->get($resourceSubKey, []); + $subscribers = is_array($subscribers) ? $subscribers : []; + if (isset($subscribers[$sessionId])) { + unset($subscribers[$sessionId]); + if (empty($subscribers)) { + $this->cache->delete($resourceSubKey); + } else { + $this->cache->set($resourceSubKey, $subscribers, $this->cacheTtl); + } + } + } + $this->logger->debug('Removed all resource subscriptions for session.', ['sessionId' => $sessionId, 'count' => count($urisSessionWasSubscribedTo)]); + } catch (Throwable $e) { + $this->logger->error('Failed to remove all resource subscriptions.', ['sessionId' => $sessionId, 'exception' => $e]); + } + } + + 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 $sessionId, string $uri): bool + { + $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); + + try { + $subscribers = $this->cache->get($resourceSubKey, []); + + return is_array($subscribers) && isset($subscribers[$sessionId]); + } catch (Throwable $e) { + $this->logger->error('Failed to check resource subscription.', ['sessionId' => $sessionId, 'uri' => $uri, 'exception' => $e]); + + return false; + } + } + + public function queueMessage(string $sessionId, string $message): void + { + $session = $this->getSession($sessionId, true); + if ($session === null) return; + + $session->queueMessage($message); + $this->saveSession($session); + } + + public function dequeueMessages(string $sessionId): array + { + $session = $this->getSession($sessionId, true); + if ($session === null) return []; + + $messages = $session->dequeueMessages(); + $this->saveSession($session); + return $messages; + } + + public function hasQueuedMessages(string $sessionId): bool + { + $session = $this->getSession($sessionId, true); + if ($session === null) return false; + + return $session->hasQueuedMessages(); + } + + public function queueMessageForAll(string $message): void + { + $activeSessions = $this->getActiveSessions(); + + foreach ($activeSessions as $sessionId) { + $this->queueMessage($sessionId, $message); + } + } + + public function setLogLevel(string $sessionId, string $level): void + { + $session = $this->getSession($sessionId, true); + if ($session === null) return; + + $session->setAttribute('log_level', $level); + $this->saveSession($session); + } + + public function getLogLevel(string $sessionId): ?string + { + $session = $this->getSession($sessionId, true); + if ($session === null) return null; + + return $session->getAttribute('log_level'); + } +} diff --git a/src/Support/RequestProcessor.php b/src/Support/RequestProcessor.php index bf17ee2..a2f2b6c 100644 --- a/src/Support/RequestProcessor.php +++ b/src/Support/RequestProcessor.php @@ -22,7 +22,7 @@ use PhpMcp\Server\JsonRpc\Results\ListToolsResult; use PhpMcp\Server\JsonRpc\Results\ReadResourceResult; use PhpMcp\Server\Registry; -use PhpMcp\Server\State\ClientStateManager; +use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Support\ArgumentPreparer; use PhpMcp\Server\Support\SchemaValidator; use PhpMcp\Server\Traits\ResponseFormatter; @@ -39,13 +39,7 @@ class RequestProcessor { use ResponseFormatter; - protected const SUPPORTED_PROTOCOL_VERSIONS = ['2024-11-05']; - - protected Configuration $configuration; - - protected Registry $registry; - - protected ClientStateManager $clientStateManager; + protected const SUPPORTED_PROTOCOL_VERSIONS = ['2025-03-26', '2024-11-05']; protected LoggerInterface $logger; @@ -56,15 +50,12 @@ class RequestProcessor protected ArgumentPreparer $argumentPreparer; public function __construct( - Configuration $configuration, - Registry $registry, - ClientStateManager $clientStateManager, + protected Configuration $configuration, + protected Registry $registry, + protected SessionManager $sessionManager, ?SchemaValidator $schemaValidator = null, ?ArgumentPreparer $argumentPreparer = null ) { - $this->configuration = $configuration; - $this->registry = $registry; - $this->clientStateManager = $clientStateManager; $this->container = $configuration->container; $this->logger = $configuration->logger; @@ -154,7 +145,7 @@ private function parseMethod(string $method): array private function validateClientInitialized(string $clientId): void { - if (! $this->clientStateManager->isInitialized($clientId)) { + if (! $this->sessionManager->isSessionInitialized($clientId)) { throw McpServerException::invalidRequest('Client not initialized.'); } } @@ -198,7 +189,7 @@ private function handleInitialize(array $params, string $clientId): InitializeRe throw McpServerException::invalidParams("Missing or invalid 'clientInfo' parameter."); } - $this->clientStateManager->storeClientInfo($clientInfo, $serverProtocolVersion, $clientId); + $this->sessionManager->storeClientInfo($clientId, $clientInfo); $serverInfo = [ 'name' => $this->configuration->serverName, @@ -220,7 +211,7 @@ private function handlePing(string $clientId): EmptyResult private function handleNotificationInitialized(array $params, string $clientId): EmptyResult { - $this->clientStateManager->markInitialized($clientId); + $this->sessionManager->initializeSession($clientId); return new EmptyResult(); } @@ -405,7 +396,7 @@ private function handleResourceSubscribe(array $params, string $clientId): Empty $this->validateCapabilityEnabled('resources/subscribe'); - $this->clientStateManager->addResourceSubscription($clientId, $uri); + $this->sessionManager->addResourceSubscription($clientId, $uri); return new EmptyResult(); } @@ -419,7 +410,7 @@ private function handleResourceUnsubscribe(array $params, string $clientId): Emp $this->validateCapabilityEnabled('resources/unsubscribe'); - $this->clientStateManager->removeResourceSubscription($clientId, $uri); + $this->sessionManager->removeResourceSubscription($clientId, $uri); return new EmptyResult(); } @@ -496,7 +487,7 @@ private function handleLoggingSetLevel(array $params, string $clientId): EmptyRe $this->validateCapabilityEnabled('logging'); - $this->clientStateManager->setClientRequestedLogLevel($clientId, strtolower($level)); + $this->sessionManager->setLogLevel($clientId, strtolower($level)); $this->logger->info("Processor: Client '{$clientId}' requested log level set to '{$level}'."); From ad0fe21c8783b25e5bb995d73c02d51759ca39c8 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Thu, 5 Jun 2025 21:13:10 +0100 Subject: [PATCH 02/27] feat: add support for batch request processing - Introduced batch processing and better error handling. - Refactored Protocol class to use sessionId instead of clientId for message handling. - Updated ServerBuilder to initialize Protocol with RequestProcessor, streamlining session management. - Cleaned up unused code and improved error logging for better maintainability. --- src/Attributes/McpTool.php | 3 +- src/Contracts/ServerTransportInterface.php | 12 +- src/Protocol.php | 304 +++++++++++++-------- src/ServerBuilder.php | 14 +- src/Support/RequestProcessor.php | 56 ++-- 5 files changed, 226 insertions(+), 163 deletions(-) diff --git a/src/Attributes/McpTool.php b/src/Attributes/McpTool.php index 3298aa0..378ddd4 100644 --- a/src/Attributes/McpTool.php +++ b/src/Attributes/McpTool.php @@ -14,6 +14,5 @@ class McpTool public function __construct( public ?string $name = null, public ?string $description = null, - ) { - } + ) {} } diff --git a/src/Contracts/ServerTransportInterface.php b/src/Contracts/ServerTransportInterface.php index dd9f81a..8c039be 100644 --- a/src/Contracts/ServerTransportInterface.php +++ b/src/Contracts/ServerTransportInterface.php @@ -16,10 +16,10 @@ * * --- Expected Emitted Events --- * 'ready': () - Optional: Fired when listening starts successfully. - * 'client_connected': (string $clientId) - New client connection (e.g., SSE). - * 'message': (string $rawJsonRpcFrame, string $clientId) - Complete message received from a client. - * 'client_disconnected': (string $clientId, ?string $reason) - Client connection closed. - * 'error': (Throwable $error, ?string $clientId) - Error occurred (general transport error if clientId is null). + * 'client_connected': (string $sessionId) - New client connection (e.g., SSE). + * 'message': (string $rawJsonRpcFrame, string $sessionId) - Complete message received from a client. + * 'client_disconnected': (string $sessionId, ?string $reason) - Client connection closed. + * 'error': (Throwable $error, ?string $sessionId) - Error occurred (general transport error if sessionId is null). * 'close': (?string $reason) - Transport listener stopped completely. */ interface ServerTransportInterface extends EventEmitterInterface @@ -38,11 +38,11 @@ public function listen(): void; * or formatted as an SSE event for HTTP transports). Framing is the responsibility of the caller * (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 $sessionId Target session identifier ("stdio" is conventionally used for stdio transport). * @param string $rawFramedMessage Message string ready for transport. * @return PromiseInterface Resolves on successful send/queue, rejects on specific send error. */ - public function sendToClientAsync(string $clientId, string $rawFramedMessage): PromiseInterface; + public function sendToClientAsync(string $sessionId, string $rawFramedMessage): PromiseInterface; /** * Stops the transport listener gracefully and closes all active connections. diff --git a/src/Protocol.php b/src/Protocol.php index a0fdd76..d31e934 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -8,19 +8,16 @@ use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Server\Exception\ProtocolException; +use PhpMcp\Server\JsonRpc\Batch; use PhpMcp\Server\JsonRpc\Notification; use PhpMcp\Server\JsonRpc\Request; use PhpMcp\Server\JsonRpc\Response; -use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Support\RequestProcessor; use Psr\Log\LoggerInterface; -use React\Promise\PromiseInterface; use Throwable; -use function React\Promise\reject; - /** - * Bridges the core MCP Processor/Registry/State logic with a ServerTransportInterface + * Bridges the core MCP Processor logic with a ServerTransportInterface * by listening to transport events and processing incoming messages. * * This handler manages the JSON-RPC parsing, processing delegation, and response sending @@ -29,25 +26,14 @@ class Protocol { protected ?ServerTransportInterface $transport = null; - protected LoggerInterface $logger; /** Stores listener references for proper removal */ protected array $listeners = []; public function __construct( - protected readonly Configuration $configuration, - protected readonly Registry $registry, - protected readonly SessionManager $sessionManager, - protected ?RequestProcessor $requestProcessor = null, - ) { - $this->requestProcessor ??= new RequestProcessor( - $configuration, - $registry, - $sessionManager, - ); - - $this->logger = $configuration->logger; - } + protected readonly LoggerInterface $logger, + protected readonly RequestProcessor $requestProcessor, + ) {} /** * Binds this handler to a transport instance by attaching event listeners. @@ -62,7 +48,7 @@ public function bindTransport(ServerTransportInterface $transport): void $this->transport = $transport; $this->listeners = [ - 'message' => [$this, 'handleRawMessage'], + 'message' => [$this, 'handleMessage'], 'client_connected' => [$this, 'handleClientConnected'], 'client_disconnected' => [$this, 'handleClientDisconnected'], 'error' => [$this, 'handleTransportError'], @@ -95,124 +81,224 @@ public function unbindTransport(): void * * Parses JSON, validates structure, processes via Processor, sends Response/Error. */ - public function handleRawMessage(string $rawJsonRpcFrame, string $clientId): void + public function handleMessage(string $rawJsonRpcFrame, string $sessionId): void { - $this->logger->debug('Received message', ['clientId' => $clientId, 'frame' => $rawJsonRpcFrame]); - $responseToSend = null; - $parsedMessage = null; - $messageData = null; + $this->logger->debug('Received message', ['sessionId' => $sessionId, 'frame' => $rawJsonRpcFrame]); 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).'); - } + $messageData = $this->decodeJsonMessage($rawJsonRpcFrame); - $parsedMessage = $this->parseMessageData($messageData); + $message = $this->parseMessage($messageData); - if ($parsedMessage === null) { - throw McpServerException::invalidRequest('Invalid MCP/JSON-RPC message structure.'); - } + $response = $this->processMessage($message, $sessionId); - $responseToSend = $this->requestProcessor->process($parsedMessage, $clientId); + if ($response) { + $this->sendResponse($sessionId, $response); + } } catch (JsonException $e) { - $this->logger->error("JSON Parse Error for client {$clientId}", ['error' => $e->getMessage()]); - $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); + $this->handleJsonParseError($e, $sessionId); + } catch (ProtocolException $e) { + $this->handleProtocolError($e, $sessionId); } catch (Throwable $e) { - $this->logger->error("Unexpected processing error for client {$clientId}", ['exception' => $e]); - $id = $this->getRequestId($parsedMessage, $messageData); - $responseToSend = Response::error(McpServerException::internalError()->toJsonRpcError(), $id); + $this->handleUnexpectedError($e, $sessionId); } + } + + /** + * Decodes a raw JSON message string into an array + */ + private function decodeJsonMessage(string $rawJsonRpcFrame): array + { + $messageData = json_decode($rawJsonRpcFrame, true, 512, JSON_THROW_ON_ERROR); - if ($responseToSend instanceof Response) { - $this->sendResponse($clientId, $responseToSend); - } elseif ($parsedMessage instanceof Request && $responseToSend === null) { - $this->logger->error('Processor failed to return a Response for a Request', ['clientId' => $clientId, 'method' => $parsedMessage->method, 'id' => $parsedMessage->id]); - $responseToSend = Response::error(McpServerException::internalError('Processing failed to generate a response.')->toJsonRpcError(), $parsedMessage->id); - $this->sendResponse($clientId, $responseToSend); + if (!is_array($messageData)) { + throw ProtocolException::invalidRequest('Invalid JSON-RPC message: payload is not a JSON object or array.'); } + + return $messageData; } /** - * Safely gets the request ID from potentially parsed or raw message data + * Parses message data into Request, Notification, Batch, or throws an exception */ - private function getRequestId(Request|Notification|null $parsed, ?array $rawData): string|int|null + private function parseMessage(array $messageData): Request|Notification|Batch { - if ($parsed instanceof Request) { - return $parsed->id; - } - if (is_array($rawData) && isset($rawData['id']) && (is_string($rawData['id']) || is_int($rawData['id']))) { - return $rawData['id']; + $isBatch = array_is_list($messageData) && count($messageData) > 0 && is_array($messageData[0] ?? null); + + if ($isBatch) { + return Batch::fromArray($messageData); + } elseif (isset($messageData['method'])) { + if (isset($messageData['id']) && $messageData['id'] !== null) { + return Request::fromArray($messageData); + } else { + return Notification::fromArray($messageData); + } } - return null; + throw McpServerException::invalidRequest("Message must contain a 'method' field."); } /** - * Sends a Response object via the transport + * Process a message based on its type */ - private function sendResponse(string $clientId, Response $response): void + private function processMessage(Request|Notification|Batch $message, string $sessionId): ?string { - if ($this->transport === null) { - $this->logger->error('Cannot send response, transport is not bound.', ['clientId' => $clientId]); + return match (true) { + $message instanceof Batch => $this->processBatchMessage($message, $sessionId), + $message instanceof Request => $this->processRequestMessage($message, $sessionId), + $message instanceof Notification => $this->processNotificationMessage($message, $sessionId), + }; + } - return; + /** + * Process a batch message + */ + private function processBatchMessage(Batch $batch, string $sessionId): ?string + { + $responsesToSend = []; + + foreach ($batch->getRequests() as $item) { + try { + if ($item instanceof Request) { + $itemResponse = $this->requestProcessor->process($item, $sessionId); + + if ($itemResponse instanceof Response) { + $responsesToSend[] = $itemResponse; + } elseif ($itemResponse === null) { + $this->logger->error( + 'Processor failed to return a Response for a Request in batch', + ['sessionId' => $sessionId, 'method' => $item->method, 'id' => $item->id] + ); + $responsesToSend[] = Response::error( + McpServerException::internalError('Processing failed to generate a response for batch item.')->toJsonRpcError(), + $item->id + ); + } + } elseif ($item instanceof Notification) { + $this->requestProcessor->process($item, $sessionId); + } + } catch (McpServerException $e) { + $itemId = $item instanceof Request ? $item->id : null; + $responsesToSend[] = Response::error($e->toJsonRpcError(), $itemId); + } catch (Throwable $e) { + $this->logger->error("Unexpected processing error for batch item", ['sessionId' => $sessionId, 'exception' => $e]); + $itemId = $item instanceof Request ? $item->id : null; + $responsesToSend[] = Response::error( + McpServerException::internalError('Internal error processing batch item.')->toJsonRpcError(), + $itemId + ); + } } + if (!empty($responsesToSend)) { + $batchResponseArray = array_map(fn(Response $r) => $r->toArray(), $responsesToSend); + return json_encode($batchResponseArray, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + return null; + } + + /** + * Process a request message + */ + private function processRequestMessage(Request $request, string $sessionId): string + { try { - $responseData = $response->toArray(); - $jsonResponse = json_encode($responseData, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - - $framedMessage = $jsonResponse . "\n"; - - $this->transport->sendToClientAsync($clientId, $framedMessage) - ->catch( - function (Throwable $e) use ($clientId, $response) { - $this->logger->error('Transport failed to send response.', [ - 'clientId' => $clientId, - 'responseId' => $response->id, - 'error' => $e->getMessage(), - ]); - } + $response = $this->requestProcessor->process($request, $sessionId); + + if ($response instanceof Response) { + return json_encode($response->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } else { + $this->logger->error( + 'Processor failed to return a Response for a Request', + ['sessionId' => $sessionId, 'method' => $request->method, 'id' => $request->id] + ); + $errorResponse = Response::error( + McpServerException::internalError('Processing failed to generate a response.')->toJsonRpcError(), + $request->id ); + return json_encode($errorResponse->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + } catch (McpServerException $e) { + $errorResponse = Response::error($e->toJsonRpcError(), $request->id); + return json_encode($errorResponse->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } catch (Throwable $e) { + $this->logger->error("Unexpected error processing request", ['sessionId' => $sessionId, 'exception' => $e]); + $errorResponse = Response::error( + McpServerException::internalError('Internal error processing request.')->toJsonRpcError(), + $request->id + ); + return json_encode($errorResponse->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + } - $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()]); + /** + * Process a notification message + */ + private function processNotificationMessage(Notification $notification, string $sessionId): ?string + { + try { + $this->requestProcessor->process($notification, $sessionId); + return null; } catch (Throwable $e) { - $this->logger->error('Unexpected error during response preparation/sending.', ['clientId' => $clientId, 'responseId' => $response->id, 'exception' => $e]); + $this->logger->error( + "Error processing notification", + ['sessionId' => $sessionId, 'method' => $notification->method, 'exception' => $e->getMessage()] + ); + return null; } } /** - * Sends a Notification object via the transport to a specific client. + * Handle a JSON parse error */ - public function sendNotification(string $clientId, Notification $notification): PromiseInterface + private function handleJsonParseError(JsonException $e, string $sessionId): void { - if ($this->transport === null) { - $this->logger->error('Cannot send notification, transport not bound.', ['clientId' => $clientId]); + $this->logger->error("JSON Parse Error", ['sessionId' => $sessionId, 'error' => $e->getMessage()]); + $responseToSend = Response::error(McpServerException::parseError($e->getMessage())->toJsonRpcError(), null); + $responseJson = json_encode($responseToSend->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $this->sendResponse($sessionId, $responseJson); + } - 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"; - $this->logger->debug('Sending notification', ['clientId' => $clientId, 'method' => $notification->method]); + /** + * Handle a protocol error + */ + private function handleProtocolError(ProtocolException $e, string $sessionId): void + { + $this->logger->error("Invalid JSON-RPC structure", ['sessionId' => $sessionId, 'error' => $e->getMessage()]); + $responseToSend = Response::error($e->toJsonRpcError(), null); + $responseJson = json_encode($responseToSend->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $this->sendResponse($sessionId, $responseJson); + } - 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()]); + /** + * Handle an unexpected error + */ + private function handleUnexpectedError(Throwable $e, string $sessionId): void + { + $this->logger->error("Unexpected error", ['sessionId' => $sessionId, 'exception' => $e]); + $responseToSend = Response::error(McpServerException::internalError('Internal server error.')->toJsonRpcError(), null); + $responseJson = json_encode($responseToSend->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $this->sendResponse($sessionId, $responseJson); + } - 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)); + private function sendResponse(string $sessionId, string $response): void + { + if ($this->transport === null) { + $this->logger->error('Cannot send response, there is no transport bound.', ['sessionId' => $sessionId]); + return; } + + $framedMessage = $response . "\n"; + + $this->transport->sendToClientAsync($sessionId, $framedMessage) + ->then(function () use ($sessionId, $framedMessage) { + $this->logger->debug('Sent response', ['sessionId' => $sessionId, 'frame' => $framedMessage]); + }) + ->catch(function (Throwable $e) use ($sessionId) { + $this->logger->error('Transport failed to send response.', ['sessionId' => $sessionId, 'error' => $e->getMessage()]); + }); } /** @@ -229,7 +315,6 @@ public function handleClientConnected(string $clientId): void public function handleClientDisconnected(string $clientId, ?string $reason = null): void { $this->logger->info('Client disconnected', ['clientId' => $clientId, 'reason' => $reason ?? 'N/A']); - $this->sessionManager->deleteSession($clientId); } /** @@ -242,31 +327,8 @@ public function handleTransportError(Throwable $error, ?string $clientId = null) if ($clientId) { $context['clientId'] = $clientId; $this->logger->error('Transport error for client', $context); - $this->sessionManager->deleteSession($clientId); } else { $this->logger->error('General transport error', $context); } } - - /** - * Parses raw array into Request or Notification - */ - private function parseMessageData(array $data): Request|Notification|null - { - try { - if (isset($data['method'])) { - if (isset($data['id']) && $data['id'] !== null) { - return Request::fromArray($data); - } else { - return Notification::fromArray($data); - } - } - } catch (ProtocolException $e) { - throw McpServerException::invalidRequest('Invalid JSON-RPC structure: ' . $e->getMessage(), $e); - } catch (Throwable $e) { - throw new ProtocolException('Unexpected error parsing message structure: ' . $e->getMessage(), McpServerException::CODE_PARSE_ERROR, null, $e); - } - - throw McpServerException::invalidRequest("Message must contain a 'method' field."); - } } diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index 8b8cf7a..b5b3edf 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -10,6 +10,7 @@ use PhpMcp\Server\Model\Capabilities; use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Support\HandlerResolver; +use PhpMcp\Server\Support\RequestProcessor; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -192,14 +193,15 @@ public function build(): Server paginationLimit: $this->paginationLimit ?? 50 ); - $sessionManager = new SessionManager($configuration->logger, $configuration->cache, $configuration->definitionCacheTtl); - $registry = new Registry($configuration->logger, $configuration->cache, $sessionManager); - $protocol = new Protocol($configuration, $registry, $sessionManager); + $sessionManager = new SessionManager($logger, $cache, $configuration->definitionCacheTtl); + $registry = new Registry($logger, $cache, $sessionManager); + $requestProcessor = new RequestProcessor($configuration, $registry, $sessionManager); + $protocol = new Protocol($logger, $requestProcessor); $registry->disableNotifications(); - - $this->performManualRegistrations($registry, $configuration->logger); - + + $this->performManualRegistrations($registry, $logger); + $registry->enableNotifications(); $server = new Server($configuration, $registry, $protocol, $sessionManager); diff --git a/src/Support/RequestProcessor.php b/src/Support/RequestProcessor.php index a2f2b6c..6a1df1c 100644 --- a/src/Support/RequestProcessor.php +++ b/src/Support/RequestProcessor.php @@ -39,7 +39,7 @@ class RequestProcessor { use ResponseFormatter; - protected const SUPPORTED_PROTOCOL_VERSIONS = ['2025-03-26', '2024-11-05']; + protected const SUPPORTED_PROTOCOL_VERSIONS = ['2024-11-05', '2025-03-26']; protected LoggerInterface $logger; @@ -63,7 +63,7 @@ public function __construct( $this->argumentPreparer = $argumentPreparer ?? new ArgumentPreparer($this->configuration->logger); } - public function process(Request|Notification $message, string $clientId): ?Response + public function process(Request|Notification $message, string $sessionId): ?Response { $method = $message->method; $params = $message->params; @@ -74,15 +74,15 @@ public function process(Request|Notification $message, string $clientId): ?Respo $result = null; if ($method === 'initialize') { - $result = $this->handleInitialize($params, $clientId); + $result = $this->handleInitialize($params, $sessionId); } elseif ($method === 'ping') { - $result = $this->handlePing($clientId); + $result = $this->handlePing($sessionId); } elseif ($method === 'notifications/initialized') { - $this->handleNotificationInitialized($params, $clientId); + $this->handleNotificationInitialized($params, $sessionId); return null; } else { - $this->validateClientInitialized($clientId); + $this->validateSessionInitialized($sessionId); [$type, $action] = $this->parseMethod($method); $this->validateCapabilityEnabled($type); @@ -95,8 +95,8 @@ public function process(Request|Notification $message, string $clientId): ?Respo 'resources' => match ($action) { 'list' => $this->handleResourcesList($params), 'read' => $this->handleResourceRead($params), - 'subscribe' => $this->handleResourceSubscribe($params, $clientId), - 'unsubscribe' => $this->handleResourceUnsubscribe($params, $clientId), + 'subscribe' => $this->handleResourceSubscribe($params, $sessionId), + 'unsubscribe' => $this->handleResourceUnsubscribe($params, $sessionId), 'templates/list' => $this->handleResourceTemplateList($params), default => throw McpServerException::methodNotFound($method), }, @@ -106,7 +106,7 @@ public function process(Request|Notification $message, string $clientId): ?Respo default => throw McpServerException::methodNotFound($method), }, 'logging' => match ($action) { - 'setLevel' => $this->handleLoggingSetLevel($params, $clientId), + 'setLevel' => $this->handleLoggingSetLevel($params, $sessionId), default => throw McpServerException::methodNotFound($method), }, default => throw McpServerException::methodNotFound($method), @@ -143,10 +143,10 @@ private function parseMethod(string $method): array return [$method, '']; } - private function validateClientInitialized(string $clientId): void + private function validateSessionInitialized(string $sessionId): void { - if (! $this->sessionManager->isSessionInitialized($clientId)) { - throw McpServerException::invalidRequest('Client not initialized.'); + if (! $this->sessionManager->isSessionInitialized($sessionId)) { + throw McpServerException::invalidRequest('Client session not initialized.'); } } @@ -169,15 +169,15 @@ private function validateCapabilityEnabled(string $type): void } } - private function handleInitialize(array $params, string $clientId): InitializeResult + private function handleInitialize(array $params, string $sessionId): InitializeResult { - $clientProtocolVersion = $params['protocolVersion'] ?? null; - if (! $clientProtocolVersion) { + $protocolVersion = $params['protocolVersion'] ?? null; + if (! $protocolVersion) { throw McpServerException::invalidParams("Missing 'protocolVersion' parameter."); } - if (! in_array($clientProtocolVersion, self::SUPPORTED_PROTOCOL_VERSIONS)) { - $this->logger->warning("Client requested unsupported protocol version: {$clientProtocolVersion}", [ + if (! in_array($protocolVersion, self::SUPPORTED_PROTOCOL_VERSIONS)) { + $this->logger->warning("Unsupported protocol version: {$protocolVersion}", [ 'supportedVersions' => self::SUPPORTED_PROTOCOL_VERSIONS, ]); } @@ -189,7 +189,7 @@ private function handleInitialize(array $params, string $clientId): InitializeRe throw McpServerException::invalidParams("Missing or invalid 'clientInfo' parameter."); } - $this->sessionManager->storeClientInfo($clientId, $clientInfo); + $this->sessionManager->storeClientInfo($sessionId, $clientInfo); $serverInfo = [ 'name' => $this->configuration->serverName, @@ -204,14 +204,14 @@ private function handleInitialize(array $params, string $clientId): InitializeRe return new InitializeResult($serverInfo, $serverProtocolVersion, $responseCapabilities, $instructions); } - private function handlePing(string $clientId): EmptyResult + private function handlePing(string $sessionId): EmptyResult { return new EmptyResult(); } - private function handleNotificationInitialized(array $params, string $clientId): EmptyResult + private function handleNotificationInitialized(array $params, string $sessionId): EmptyResult { - $this->sessionManager->initializeSession($clientId); + $this->sessionManager->initializeSession($sessionId); return new EmptyResult(); } @@ -387,7 +387,7 @@ private function handleResourceRead(array $params): ReadResourceResult } } - private function handleResourceSubscribe(array $params, string $clientId): EmptyResult + private function handleResourceSubscribe(array $params, string $sessionId): EmptyResult { $uri = $params['uri'] ?? null; if (! is_string($uri) || empty($uri)) { @@ -396,12 +396,12 @@ private function handleResourceSubscribe(array $params, string $clientId): Empty $this->validateCapabilityEnabled('resources/subscribe'); - $this->sessionManager->addResourceSubscription($clientId, $uri); + $this->sessionManager->addResourceSubscription($sessionId, $uri); return new EmptyResult(); } - private function handleResourceUnsubscribe(array $params, string $clientId): EmptyResult + private function handleResourceUnsubscribe(array $params, string $sessionId): EmptyResult { $uri = $params['uri'] ?? null; if (! is_string($uri) || empty($uri)) { @@ -410,7 +410,7 @@ private function handleResourceUnsubscribe(array $params, string $clientId): Emp $this->validateCapabilityEnabled('resources/unsubscribe'); - $this->sessionManager->removeResourceSubscription($clientId, $uri); + $this->sessionManager->removeResourceSubscription($sessionId, $uri); return new EmptyResult(); } @@ -467,7 +467,7 @@ private function handlePromptGet(array $params): GetPromptResult } } - private function handleLoggingSetLevel(array $params, string $clientId): EmptyResult + private function handleLoggingSetLevel(array $params, string $sessionId): EmptyResult { $level = $params['level'] ?? null; $validLevels = [ @@ -487,9 +487,9 @@ private function handleLoggingSetLevel(array $params, string $clientId): EmptyRe $this->validateCapabilityEnabled('logging'); - $this->sessionManager->setLogLevel($clientId, strtolower($level)); + $this->sessionManager->setLogLevel($sessionId, strtolower($level)); - $this->logger->info("Processor: Client '{$clientId}' requested log level set to '{$level}'."); + $this->logger->info("Log level set to '{$level}'.", ['sessionId' => $sessionId]); return new EmptyResult(); } From bb32c09914284f9aec000706ede739e1e404ac04 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Thu, 5 Jun 2025 22:36:39 +0100 Subject: [PATCH 03/27] refactor: Align MCP element definitions and content types with 2025-03-26 schema Refactors core MCP element definitions (Tool, Resource, ResourceTemplate, Prompt) and content types (Text, Audio, EmbeddedResource, ResourceContent) to enhance type safety and schema compliance with the MCP 2025-03-26 specification. Key changes include: - Introduced `Model\Annotations` and `Model\ToolAnnotations` classes for structured annotation handling, replacing generic arrays. - Updated `McpTool`, `McpResource`, and `McpResourceTemplate` attributes and their corresponding Definition classes to use these new Annotation types. - Restructured `JsonRpc\Contents\EmbeddedResource` to be a wrapper for `ResourceContent`, correctly reflecting the protocol's embedded resource structure. `ResourceContent` now directly holds URI, MIME, and data. - Added optional `annotations` property to `TextContent` and `AudioContent`. - Removed individual getter methods from Definition classes, promoting reliance on public readonly properties or `toArray()` for MCP output. - Updated `toArray()`, `fromArray()`, and `fromReflection()` methods across all affected Definition and Content classes to support these changes. --- src/Attributes/McpResource.php | 8 +- src/Attributes/McpResourceTemplate.php | 8 +- src/Attributes/McpTool.php | 3 + src/Definitions/PromptArgumentDefinition.php | 24 +--- src/Definitions/PromptDefinition.php | 41 +----- src/Definitions/ResourceDefinition.php | 63 ++------- .../ResourceTemplateDefinition.php | 56 ++------ src/Definitions/ToolDefinition.php | 44 ++----- src/JsonRpc/Contents/AudioContent.php | 34 ++++- src/JsonRpc/Contents/Content.php | 5 - src/JsonRpc/Contents/EmbeddedResource.php | 120 ++---------------- src/JsonRpc/Contents/PromptMessage.php | 45 ++----- src/JsonRpc/Contents/ResourceContent.php | 110 +++++++++------- src/JsonRpc/Contents/TextContent.php | 37 ++++-- src/JsonRpc/Results/ReadResourceResult.php | 11 +- src/Model/Annotations.php | 54 ++++++++ src/Model/Capabilities.php | 3 +- src/Model/Role.php | 14 ++ src/Model/ToolAnnotations.php | 66 ++++++++++ src/ServerBuilder.php | 13 +- src/Support/Discoverer.php | 29 +++-- src/Support/RequestProcessor.php | 3 +- src/Traits/ResponseFormatter.php | 102 ++++++--------- .../Attributes/McpResourceTemplateTest.php | 6 - tests/Unit/Attributes/McpResourceTest.php | 6 - .../Definitions/ResourceDefinitionTest.php | 22 +--- .../ResourceTemplateDefinitionTest.php | 22 +--- 27 files changed, 401 insertions(+), 548 deletions(-) create mode 100644 src/Model/Annotations.php create mode 100644 src/Model/Role.php create mode 100644 src/Model/ToolAnnotations.php diff --git a/src/Attributes/McpResource.php b/src/Attributes/McpResource.php index f4b0774..43462f1 100644 --- a/src/Attributes/McpResource.php +++ b/src/Attributes/McpResource.php @@ -3,6 +3,7 @@ namespace PhpMcp\Server\Attributes; use Attribute; +use PhpMcp\Server\Model\Annotations; /** * Marks a PHP class as representing or handling a specific MCP Resource instance. @@ -17,7 +18,7 @@ final class McpResource * @param ?string $description An optional description of the resource. Defaults to class DocBlock summary. * @param ?string $mimeType The MIME type, if known and constant for this resource. * @param ?int $size The size in bytes, if known and constant. - * @param array $annotations Optional annotations following the MCP spec (e.g., ['audience' => ['user'], 'priority' => 0.5]). + * @param Annotations|null $annotations Optional annotations describing the resource. */ public function __construct( public string $uri, @@ -25,7 +26,6 @@ public function __construct( public ?string $description = null, public ?string $mimeType = null, public ?int $size = null, - public array $annotations = [], - ) { - } + public ?Annotations $annotations = null, + ) {} } diff --git a/src/Attributes/McpResourceTemplate.php b/src/Attributes/McpResourceTemplate.php index 95a7c88..5873cea 100644 --- a/src/Attributes/McpResourceTemplate.php +++ b/src/Attributes/McpResourceTemplate.php @@ -3,6 +3,7 @@ namespace PhpMcp\Server\Attributes; use Attribute; +use PhpMcp\Server\Model\Annotations; /** * Marks a PHP class definition as representing an MCP Resource Template. @@ -16,14 +17,13 @@ final class McpResourceTemplate * @param ?string $name A human-readable name for the template type. If null, a default might be generated from the method name. * @param ?string $description Optional description. Defaults to class DocBlock summary. * @param ?string $mimeType Optional default MIME type for matching resources. - * @param array $annotations Optional annotations following the MCP spec. + * @param ?Annotations $annotations Optional annotations describing the resource template. */ public function __construct( public string $uriTemplate, public ?string $name = null, public ?string $description = null, public ?string $mimeType = null, - public array $annotations = [], - ) { - } + public ?Annotations $annotations = null, + ) {} } diff --git a/src/Attributes/McpTool.php b/src/Attributes/McpTool.php index 378ddd4..2542fd2 100644 --- a/src/Attributes/McpTool.php +++ b/src/Attributes/McpTool.php @@ -3,6 +3,7 @@ namespace PhpMcp\Server\Attributes; use Attribute; +use PhpMcp\Server\Model\ToolAnnotations; #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class McpTool @@ -10,9 +11,11 @@ class McpTool /** * @param string|null $name The name of the tool (defaults to the method name) * @param string|null $description The description of the tool (defaults to the DocBlock/inferred) + * @param ToolAnnotations|null $annotations Optional annotations describing tool behavior */ public function __construct( public ?string $name = null, public ?string $description = null, + public ?ToolAnnotations $annotations = null, ) {} } diff --git a/src/Definitions/PromptArgumentDefinition.php b/src/Definitions/PromptArgumentDefinition.php index 0acc773..7f0c1f1 100644 --- a/src/Definitions/PromptArgumentDefinition.php +++ b/src/Definitions/PromptArgumentDefinition.php @@ -6,8 +6,7 @@ use ReflectionParameter; /** - * Represents a defined argument for an MCP Prompt template. - * Compliant with MCP 'PromptArgument'. + * Describes an argument that a prompt can accept. */ class PromptArgumentDefinition { @@ -20,23 +19,7 @@ public function __construct( public readonly string $name, public readonly ?string $description, public readonly bool $required = false - ) { - } - - public function getName(): string - { - return $this->name; - } - - public function getDescription(): ?string - { - return $this->description; - } - - public function isRequired(): bool - { - return $this->required; - } + ) {} /** * Formats the definition into the structure expected by MCP's 'Prompt.arguments'. @@ -48,10 +31,11 @@ public function toArray(): array $data = [ 'name' => $this->name, ]; + if ($this->description !== null) { $data['description'] = $this->description; } - // 'required' defaults to false, only include if true for brevity? Schema doesn't specify default. Let's include it. + $data['required'] = $this->required; return $data; diff --git a/src/Definitions/PromptDefinition.php b/src/Definitions/PromptDefinition.php index 61317c8..0c390ec 100644 --- a/src/Definitions/PromptDefinition.php +++ b/src/Definitions/PromptDefinition.php @@ -5,8 +5,7 @@ use PhpMcp\Server\Support\DocBlockParser; /** - * Represents a discovered MCP Prompt or Prompt Template. - * Aligns with MCP 'Prompt' structure for listing and 'GetPromptResult' for getting. + * Describes a prompt or prompt template. */ class PromptDefinition { @@ -19,9 +18,9 @@ class PromptDefinition /** * @param class-string $className The fully qualified class name containing the prompt generation logic. * @param string $methodName The name of the PHP method implementing the prompt generation. - * @param string $promptName The designated name of the MCP prompt (used in 'prompts/get'). - * @param string|null $description A description of what this prompt provides. - * @param PromptArgumentDefinition[] $arguments Definitions of arguments used for templating. Empty if not a template. + * @param string $promptName The name of the prompt or prompt template. + * @param string|null $description An optional description of what this prompt provides + * @param PromptArgumentDefinition[] $arguments A list of arguments to use for templating the prompt. Empty if not a template. * * @throws \InvalidArgumentException If the prompt name doesn't match the required pattern. */ @@ -50,34 +49,6 @@ private function validate(): void } } - public function getClassName(): string - { - return $this->className; - } - - public function getMethodName(): string - { - return $this->methodName; - } - - public function getName(): string - { - return $this->promptName; - } - - public function getDescription(): ?string - { - return $this->description; - } - - /** - * @return list - */ - public function getArguments(): array - { - return $this->arguments; - } - public function isTemplate(): bool { return ! empty($this->arguments); @@ -146,16 +117,14 @@ public static function fromReflection( $description = $overrideDescription ?? $docBlockParser->getSummary($docBlock) ?? null; $arguments = []; - $paramTags = $docBlockParser->getParamTags($docBlock); // Get all param tags first + $paramTags = $docBlockParser->getParamTags($docBlock); foreach ($method->getParameters() as $param) { $reflectionType = $param->getType(); - // Basic DI check (heuristic) if ($reflectionType instanceof \ReflectionNamedType && ! $reflectionType->isBuiltin()) { continue; } - // Correctly get the specific Param tag using the '$' prefix $paramTag = $paramTags['$' . $param->getName()] ?? null; $arguments[] = PromptArgumentDefinition::fromReflection($param, $paramTag); } diff --git a/src/Definitions/ResourceDefinition.php b/src/Definitions/ResourceDefinition.php index 4f1aee7..03ba3c8 100644 --- a/src/Definitions/ResourceDefinition.php +++ b/src/Definitions/ResourceDefinition.php @@ -3,6 +3,7 @@ namespace PhpMcp\Server\Definitions; use PhpMcp\Server\Attributes\McpResource; +use PhpMcp\Server\Model\Annotations; use PhpMcp\Server\Support\DocBlockParser; use ReflectionMethod; @@ -31,8 +32,8 @@ class ResourceDefinition * @param string $name A human-readable name for this resource. * @param string|null $description A description of what this resource represents. * @param string|null $mimeType The MIME type of this resource, if known. - * @param int|null $size The size of the resource content in bytes, if known. - * @param array $annotations Optional annotations (audience, priority). + * @param ?Annotations $annotations Optional annotations describing the resource. + * @param int|null $size The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known * * @throws \InvalidArgumentException If the URI doesn't match the required pattern. */ @@ -43,8 +44,8 @@ public function __construct( public readonly string $name, public readonly ?string $description, public readonly ?string $mimeType, + public readonly ?Annotations $annotations, public readonly ?int $size, - public readonly array $annotations = [] // Follows Annotated base type ) { $this->validate(); } @@ -71,50 +72,10 @@ private function validate(): void } } - public function getClassName(): string - { - return $this->className; - } - - public function getMethodName(): string - { - return $this->methodName; - } - - public function getUri(): string - { - return $this->uri; - } - - public function getName(): string - { - return $this->name; - } - - public function getDescription(): ?string - { - return $this->description; - } - - public function getMimeType(): ?string - { - return $this->mimeType; - } - - public function getSize(): ?int - { - return $this->size; - } - - public function getAnnotations(): array - { - return $this->annotations; - } - /** * Formats the definition into the structure expected by MCP's 'resources/list'. * - * @return array{uri: string, name: string, description?: string, mimeType?: string, size?: int, annotations?: array} + * @return array{uri: string, name: string, description?: string, mimeType?: string, size?: int, annotations?: array} */ public function toArray(): array { @@ -128,12 +89,12 @@ public function toArray(): array if ($this->mimeType !== null) { $data['mimeType'] = $this->mimeType; } + if ($this->annotations !== null) { + $data['annotations'] = $this->annotations->toArray(); + } if ($this->size !== null) { $data['size'] = $this->size; } - if (! empty($this->annotations)) { - $data['annotations'] = $this->annotations; - } return $data; } @@ -146,6 +107,8 @@ public function toArray(): array */ public static function fromArray(array $data): static { + $annotations = isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null; + return new self( className: $data['className'], methodName: $data['methodName'], @@ -153,8 +116,8 @@ className: $data['className'], name: $data['name'], description: $data['description'], mimeType: $data['mimeType'], + annotations: $annotations, size: $data['size'], - annotations: $data['annotations'] ?? [] ); } @@ -171,8 +134,8 @@ public static function fromReflection( ?string $overrideDescription, string $uri, ?string $mimeType, + ?Annotations $annotations, ?int $size, - ?array $annotations, DocBlockParser $docBlockParser ): self { $docBlock = $docBlockParser->parseDocBlock($method->getDocComment() ?: null); @@ -189,8 +152,8 @@ className: $method->getDeclaringClass()->getName(), name: $name, description: $description, mimeType: $mimeType, + annotations: $annotations, size: $size, - annotations: $annotations ); } } diff --git a/src/Definitions/ResourceTemplateDefinition.php b/src/Definitions/ResourceTemplateDefinition.php index f617293..545e319 100644 --- a/src/Definitions/ResourceTemplateDefinition.php +++ b/src/Definitions/ResourceTemplateDefinition.php @@ -2,7 +2,7 @@ namespace PhpMcp\Server\Definitions; -use PhpMcp\Server\Attributes\McpResourceTemplate; +use PhpMcp\Server\Model\Annotations; use PhpMcp\Server\Support\DocBlockParser; use ReflectionMethod; @@ -30,7 +30,7 @@ class ResourceTemplateDefinition * @param string $name A human-readable name for the template type. * @param string|null $description A description of what this template is for. * @param string|null $mimeType Optional default MIME type for resources matching this template. - * @param array $annotations Optional annotations (audience, priority). + * @param ?Annotations $annotations Optional annotations describing the resource template. * * @throws \InvalidArgumentException If the URI template doesn't match the required pattern. */ @@ -41,7 +41,7 @@ public function __construct( public readonly string $name, public readonly ?string $description, public readonly ?string $mimeType, - public readonly array $annotations = [] + public readonly ?Annotations $annotations, ) { $this->validate(); } @@ -68,45 +68,10 @@ private function validate(): void } } - public function getClassName(): string - { - return $this->className; - } - - public function getMethodName(): string - { - return $this->methodName; - } - - public function getUriTemplate(): string - { - return $this->uriTemplate; - } - - public function getName(): string - { - return $this->name; - } - - public function getDescription(): ?string - { - return $this->description; - } - - public function getMimeType(): ?string - { - return $this->mimeType; - } - - public function getAnnotations(): array - { - return $this->annotations; - } - /** * Formats the definition into the structure expected by MCP's 'resources/templates/list'. * - * @return array{uriTemplate: string, name: string, description?: string, mimeType?: string, annotations?: array} + * @return array{uriTemplate: string, name: string, description?: string, mimeType?: string, annotations?: array} */ public function toArray(): array { @@ -120,10 +85,9 @@ public function toArray(): array if ($this->mimeType !== null) { $data['mimeType'] = $this->mimeType; } - if (! empty($this->annotations)) { - $data['annotations'] = $this->annotations; + if ($this->annotations !== null) { + $data['annotations'] = $this->annotations->toArray(); } - return $data; } @@ -135,6 +99,7 @@ public function toArray(): array */ public static function fromArray(array $data): static { + $annotations = isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null; return new self( className: $data['className'], methodName: $data['methodName'], @@ -142,7 +107,7 @@ className: $data['className'], name: $data['name'], description: $data['description'] ?? null, mimeType: $data['mimeType'] ?? null, - annotations: $data['annotations'] ?? [] + annotations: $annotations, ); } @@ -154,7 +119,6 @@ className: $data['className'], * @param string|null $overrideDescription The description for the resource. * @param string $uriTemplate The URI template for the resource. * @param string|null $mimeType The MIME type for the resource. - * @param array|null $annotations The annotations for the resource. * @param DocBlockParser $docBlockParser Utility to parse docblocks. */ public static function fromReflection( @@ -163,7 +127,7 @@ public static function fromReflection( ?string $overrideDescription, string $uriTemplate, ?string $mimeType, - ?array $annotations, + ?Annotations $annotations, DocBlockParser $docBlockParser ): self { $docBlock = $docBlockParser->parseDocBlock($method->getDocComment() ?: null); @@ -180,7 +144,7 @@ className: $method->getDeclaringClass()->getName(), name: $name, description: $description, mimeType: $mimeType, - annotations: $annotations + annotations: $annotations, ); } } diff --git a/src/Definitions/ToolDefinition.php b/src/Definitions/ToolDefinition.php index 3a574a3..41ee213 100644 --- a/src/Definitions/ToolDefinition.php +++ b/src/Definitions/ToolDefinition.php @@ -3,6 +3,7 @@ namespace PhpMcp\Server\Definitions; use PhpMcp\Server\Attributes\McpTool; +use PhpMcp\Server\Model\ToolAnnotations; use PhpMcp\Server\Support\DocBlockParser; use PhpMcp\Server\Support\SchemaGenerator; use ReflectionMethod; @@ -32,6 +33,7 @@ public function __construct( public readonly string $toolName, public readonly ?string $description, public readonly array $inputSchema, + public readonly ?ToolAnnotations $annotations ) { $this->validate(); } @@ -51,36 +53,6 @@ private function validate(): void } } - public function getClassName(): string - { - return $this->className; - } - - public function getMethodName(): string - { - return $this->methodName; - } - - public function getName(): string - { - return $this->toolName; - } - - public function getDescription(): ?string - { - return $this->description; - } - - /** - * Gets the JSON schema defining the tool's input arguments. - * - * @return array - */ - public function getInputSchema(): array - { - return $this->inputSchema; - } - /** * Convert the tool definition to MCP format. */ @@ -98,6 +70,10 @@ public function toArray(): array $result['inputSchema'] = $this->inputSchema; } + if ($this->annotations !== null) { + $result['annotations'] = $this->annotations->toArray(); + } + return $result; } @@ -113,8 +89,9 @@ public static function fromArray(array $data): static className: $data['className'], methodName: $data['methodName'], toolName: $data['toolName'], - description: $data['description'], - inputSchema: $data['inputSchema'], + description: $data['description'] ?? null, + inputSchema: $data['inputSchema'] ?? [], + annotations: $data['annotations'] ?? null, ); } @@ -130,13 +107,13 @@ public static function fromReflection( ReflectionMethod $method, ?string $overrideName, ?string $overrideDescription, + ?ToolAnnotations $annotations, DocBlockParser $docBlockParser, SchemaGenerator $schemaGenerator ): self { $docBlock = $docBlockParser->parseDocBlock($method->getDocComment() ?? null); $description = $overrideDescription ?? $docBlockParser->getSummary($docBlock) ?? null; $inputSchema = $schemaGenerator->fromMethodParameters($method); - $toolName = $overrideName ?? ($method->getName() === '__invoke' ? $method->getDeclaringClass()->getShortName() : $method->getName()); @@ -147,6 +124,7 @@ className: $method->getDeclaringClass()->getName(), toolName: $toolName, description: $description, inputSchema: $inputSchema, + annotations: $annotations, ); } } diff --git a/src/JsonRpc/Contents/AudioContent.php b/src/JsonRpc/Contents/AudioContent.php index be186a4..8730503 100644 --- a/src/JsonRpc/Contents/AudioContent.php +++ b/src/JsonRpc/Contents/AudioContent.php @@ -2,6 +2,8 @@ namespace PhpMcp\Server\JsonRpc\Contents; +use PhpMcp\Server\Model\Annotations; + /** * Represents audio content in MCP. */ @@ -12,10 +14,12 @@ class AudioContent extends Content * * @param string $data Base64-encoded audio data * @param string $mimeType The MIME type of the audio + * @param ?Annotations $annotations Optional annotations describing the content */ public function __construct( protected string $data, - protected string $mimeType + protected string $mimeType, + protected ?Annotations $annotations = null ) {} /** @@ -34,6 +38,14 @@ public function getMimeType(): string return $this->mimeType; } + /** + * Get the annotations. + */ + public function getAnnotations(): ?Annotations + { + return $this->annotations; + } + /** * Get the content type. */ @@ -45,15 +57,21 @@ public function getType(): string /** * Convert the content to an array. * - * @return array{type: string, data: string, mimeType: string} + * @return array{type: string, data: string, mimeType: string, annotations?: array} */ public function toArray(): array { - return [ + $result = [ 'type' => 'audio', 'data' => $this->data, 'mimeType' => $this->mimeType, ]; + + if ($this->annotations !== null) { + $result['annotations'] = $this->annotations->toArray(); + } + + return $result; } /** @@ -61,10 +79,11 @@ public function toArray(): array * * @param string $path Path to the audio file * @param string|null $mimeType Optional MIME type override + * @param ?Annotations $annotations Optional annotations describing the content * * @throws \InvalidArgumentException If the file doesn't exist */ - public static function fromFile(string $path, ?string $mimeType = null): static + public static function fromFile(string $path, ?string $mimeType = null, ?Annotations $annotations = null): static { if (! file_exists($path)) { throw new \InvalidArgumentException("Audio file not found: {$path}"); @@ -77,7 +96,7 @@ public static function fromFile(string $path, ?string $mimeType = null): static $data = base64_encode($content); $detectedMime = $mimeType ?? mime_content_type($path) ?: 'application/octet-stream'; - return new static($data, $detectedMime); + return new static($data, $detectedMime, $annotations); } /** @@ -85,9 +104,10 @@ public static function fromFile(string $path, ?string $mimeType = null): static * * @param string $binaryData Raw binary audio data * @param string $mimeType MIME type of the audio + * @param ?Annotations $annotations Optional annotations describing the content */ - public static function fromBinary(string $binaryData, string $mimeType): static + public static function fromBinary(string $binaryData, string $mimeType, ?Annotations $annotations = null): static { - return new static(base64_encode($binaryData), $mimeType); + return new static(base64_encode($binaryData), $mimeType, $annotations); } } diff --git a/src/JsonRpc/Contents/Content.php b/src/JsonRpc/Contents/Content.php index dc60d3c..bc0a6a5 100644 --- a/src/JsonRpc/Contents/Content.php +++ b/src/JsonRpc/Contents/Content.php @@ -9,11 +9,6 @@ */ abstract class Content implements JsonSerializable { - /** - * Get the content type. - */ - abstract public function getType(): string; - /** * Convert the content to an array. */ diff --git a/src/JsonRpc/Contents/EmbeddedResource.php b/src/JsonRpc/Contents/EmbeddedResource.php index fca392f..32f3139 100644 --- a/src/JsonRpc/Contents/EmbeddedResource.php +++ b/src/JsonRpc/Contents/EmbeddedResource.php @@ -16,93 +16,20 @@ class EmbeddedResource * @param string|null $blob Base64-encoded binary data if available */ public function __construct( - protected string $uri, - protected string $mimeType, - protected ?string $text = null, - protected ?string $blob = null - ) { - // Validate that either text or blob is provided, but not both - if (($text === null && $blob === null) || ($text !== null && $blob !== null)) { - throw new \InvalidArgumentException('Either text OR blob must be provided for a resource.'); - } - } - - /** - * Get the URI. - */ - public function getUri(): string - { - return $this->uri; - } - - /** - * Get the MIME type. - */ - public function getMimeType(): string - { - return $this->mimeType; - } - - /** - * Get the text content. - */ - public function getText(): ?string - { - return $this->text; - } - - /** - * Get the binary data. - */ - public function getBlob(): ?string - { - return $this->blob; - } - - /** - * Check if the resource has text content. - */ - public function hasText(): bool - { - return $this->text !== null; - } - - /** - * Check if the resource has binary content. - */ - public function hasBlob(): bool - { - return $this->blob !== null; - } + public readonly ResourceContent $resource + ) {} /** * Convert the resource to an array. */ public function toArray(): array { - $resource = [ - 'uri' => $this->uri, - 'mimeType' => $this->mimeType, - ]; - - if ($this->text !== null) { - $resource['text'] = $this->text; - } elseif ($this->blob !== null) { - $resource['blob'] = $this->blob; - } - - return $resource; - } + $resource = $this->resource->toArray(); - /** - * Determines if the given MIME type is likely to be text-based. - * - * @param string $mimeType The MIME type to check - */ - private static function isTextMimeType(string $mimeType): bool - { - return str_starts_with($mimeType, 'text/') || - in_array($mimeType, ['application/json', 'application/xml', 'application/javascript', 'application/yaml']); + return [ + 'type' => 'resource', + 'resource' => $resource, + ]; } /** @@ -116,19 +43,7 @@ private static function isTextMimeType(string $mimeType): bool */ public static function fromFile(string $uri, string $path, ?string $mimeType = null): static { - if (! file_exists($path)) { - throw new \InvalidArgumentException("File not found: {$path}"); - } - - $detectedMime = $mimeType ?? mime_content_type($path) ?? 'application/octet-stream'; - $content = file_get_contents($path); - - // Decide if we should use text or blob based on the mime type - if (self::isTextMimeType($detectedMime)) { - return new static($uri, $detectedMime, $content); - } else { - return new static($uri, $detectedMime, null, base64_encode($content)); - } + return new static(ResourceContent::fromFile($uri, $path, $mimeType)); } /** @@ -142,18 +57,7 @@ public static function fromFile(string $uri, string $path, ?string $mimeType = n */ public static function fromStream(string $uri, $stream, string $mimeType): static { - if (! is_resource($stream) || get_resource_type($stream) !== 'stream') { - throw new \InvalidArgumentException('Expected a stream resource'); - } - - $content = stream_get_contents($stream); - - // Determine if this is text based on mime type - if (self::isTextMimeType($mimeType)) { - return new static($uri, $mimeType, $content); - } else { - return new static($uri, $mimeType, null, base64_encode($content)); - } + return new static(ResourceContent::fromStream($uri, $stream, $mimeType)); } /** @@ -167,10 +71,6 @@ public static function fromStream(string $uri, $stream, string $mimeType): stati */ public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string $mimeType = null): static { - if (! $file->isReadable()) { - throw new \InvalidArgumentException("File is not readable: {$file->getPathname()}"); - } - - return self::fromFile($uri, $file->getPathname(), $mimeType); + return new static(ResourceContent::fromSplFileInfo($uri, $file, $mimeType)); } } diff --git a/src/JsonRpc/Contents/PromptMessage.php b/src/JsonRpc/Contents/PromptMessage.php index e4e3608..6ecb514 100644 --- a/src/JsonRpc/Contents/PromptMessage.php +++ b/src/JsonRpc/Contents/PromptMessage.php @@ -2,47 +2,28 @@ namespace PhpMcp\Server\JsonRpc\Contents; +use PhpMcp\Server\Model\Role; + /** - * Represents a message in an MCP prompt. + * Describes a message returned as part of a prompt. */ class PromptMessage { /** * Create a new PromptMessage instance. * - * @param string $role Either "user" or "assistant" - * @param Content $content The content of the message + * @param Role $role Either "user" or "assistant" + * @param TextContent|ImageContent|AudioContent|EmbeddedResource $content The content of the message */ public function __construct( - protected string $role, - protected Content $content - ) { - // Validate role - if (! in_array($role, ['user', 'assistant'])) { - throw new \InvalidArgumentException('Role must be either "user" or "assistant".'); - } - } - - /** - * Get the role. - */ - public function getRole(): string - { - return $this->role; - } - - /** - * Get the content. - */ - public function getContent(): Content - { - return $this->content; - } + public readonly Role $role, + public readonly TextContent|ImageContent|AudioContent|EmbeddedResource $content + ) {} /** * Convert the message to an array. * - * @return array{role: string, content: array} + * @return array{role: Role, content: array} */ public function toArray(): array { @@ -59,7 +40,7 @@ public function toArray(): array */ public static function user(string $text): static { - return new static('user', new TextContent($text)); + return new static(Role::User, new TextContent($text)); } /** @@ -69,7 +50,7 @@ public static function user(string $text): static */ public static function assistant(string $text): static { - return new static('assistant', new TextContent($text)); + return new static(Role::Assistant, new TextContent($text)); } /** @@ -79,7 +60,7 @@ public static function assistant(string $text): static */ public static function userWithContent(Content $content): static { - return new static('user', $content); + return new static(Role::User, $content); } /** @@ -89,6 +70,6 @@ public static function userWithContent(Content $content): static */ public static function assistantWithContent(Content $content): static { - return new static('assistant', $content); + return new static(Role::Assistant, $content); } } diff --git a/src/JsonRpc/Contents/ResourceContent.php b/src/JsonRpc/Contents/ResourceContent.php index 60ef418..38318f2 100644 --- a/src/JsonRpc/Contents/ResourceContent.php +++ b/src/JsonRpc/Contents/ResourceContent.php @@ -7,47 +7,62 @@ */ class ResourceContent extends Content { + /** - * Create a new ResourceContent instance. + * Create a new Resource instance. * - * @param EmbeddedResource $resource The embedded resource + * @param string $uri The URI of the resource + * @param string $mimeType The MIME type of the resource + * @param string|null $text The text content if available + * @param string|null $blob Base64-encoded binary data if available */ public function __construct( - protected EmbeddedResource $resource + public readonly string $uri, + public readonly string $mimeType, + public readonly ?string $text = null, + public readonly ?string $blob = null ) { + // Validate that either text or blob is provided, but not both + if (($text === null && $blob === null) || ($text !== null && $blob !== null)) { + throw new \InvalidArgumentException('Either text OR blob must be provided for a resource.'); + } } - /** - * Get the resource. - */ - public function getResource(): EmbeddedResource - { - return $this->resource; - } /** - * Get the content type. + * Convert the content to an array. + * + * @return array{type: string, resource: array} */ - public function getType(): string + public function toArray(): array { - return 'resource'; + $resource = [ + 'uri' => $this->uri, + 'mimeType' => $this->mimeType, + ]; + + if ($this->text !== null) { + $resource['text'] = $this->text; + } elseif ($this->blob !== null) { + $resource['blob'] = $this->blob; + } + + return $resource; } /** - * Convert the content to an array. + * Determines if the given MIME type is likely to be text-based. * - * @return array{type: string, resource: array} + * @param string $mimeType The MIME type to check */ - public function toArray(): array + private static function isTextMimeType(string $mimeType): bool { - return [ - 'type' => 'resource', - 'resource' => $this->resource->toArray(), - ]; + return str_starts_with($mimeType, 'text/') || + in_array($mimeType, ['application/json', 'application/xml', 'application/javascript', 'application/yaml']); } /** - * Create a new ResourceContent from a file path. + * Create a new EmbeddedResource from a file path. * * @param string $uri The URI for the resource * @param string $path Path to the file @@ -57,7 +72,18 @@ public function toArray(): array */ public static function fromFile(string $uri, string $path, ?string $mimeType = null): static { - return new static(EmbeddedResource::fromFile($uri, $path, $mimeType)); + if (! file_exists($path)) { + throw new \InvalidArgumentException("File not found: {$path}"); + } + + $detectedMime = $mimeType ?? mime_content_type($path) ?? 'application/octet-stream'; + $content = file_get_contents($path); + + if (self::isTextMimeType($detectedMime)) { + return new static($uri, $detectedMime, $content); + } else { + return new static($uri, $detectedMime, null, base64_encode($content)); + } } /** @@ -71,7 +97,17 @@ public static function fromFile(string $uri, string $path, ?string $mimeType = n */ public static function fromStream(string $uri, $stream, string $mimeType): static { - return new static(EmbeddedResource::fromStream($uri, $stream, $mimeType)); + if (! is_resource($stream) || get_resource_type($stream) !== 'stream') { + throw new \InvalidArgumentException('Expected a stream resource'); + } + + $content = stream_get_contents($stream); + + if (self::isTextMimeType($mimeType)) { + return new static($uri, $mimeType, $content); + } else { + return new static($uri, $mimeType, null, base64_encode($content)); + } } /** @@ -85,30 +121,10 @@ public static function fromStream(string $uri, $stream, string $mimeType): stati */ public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string $mimeType = null): static { - return new static(EmbeddedResource::fromSplFileInfo($uri, $file, $mimeType)); - } + if (! $file->isReadable()) { + throw new \InvalidArgumentException("File is not readable: {$file->getPathname()}"); + } - /** - * Create a text resource content. - * - * @param string $uri The URI for the resource - * @param string $text The text content - * @param string $mimeType MIME type of the content - */ - public static function text(string $uri, string $text, string $mimeType = 'text/plain'): static - { - return new static(new EmbeddedResource($uri, $mimeType, $text)); - } - - /** - * Create a binary resource content. - * - * @param string $uri The URI for the resource - * @param string $binaryData The binary data (will be base64 encoded) - * @param string $mimeType MIME type of the content - */ - public static function binary(string $uri, string $binaryData, string $mimeType): static - { - return new static(new EmbeddedResource($uri, $mimeType, null, base64_encode($binaryData))); + return self::fromFile($uri, $file->getPathname(), $mimeType); } } diff --git a/src/JsonRpc/Contents/TextContent.php b/src/JsonRpc/Contents/TextContent.php index bf3a2d2..ad651c8 100644 --- a/src/JsonRpc/Contents/TextContent.php +++ b/src/JsonRpc/Contents/TextContent.php @@ -2,6 +2,8 @@ namespace PhpMcp\Server\JsonRpc\Contents; +use PhpMcp\Server\Model\Annotations; + /** * Represents text content in MCP. */ @@ -11,11 +13,12 @@ class TextContent extends Content * Create a new TextContent instance. * * @param string $text The text content + * @param ?Annotations $annotations Optional annotations describing the content */ public function __construct( - protected string $text - ) { - } + protected string $text, + protected ?Annotations $annotations = null + ) {} /** * Get the text content. @@ -25,6 +28,14 @@ public function getText(): string return $this->text; } + /** + * Get the annotations. + */ + public function getAnnotations(): ?Annotations + { + return $this->annotations; + } + /** * Get the content type. */ @@ -36,14 +47,20 @@ public function getType(): string /** * Convert the content to an array. * - * @return array{type: string, text: string} + * @return array{type: string, text: string, annotations?: array} */ public function toArray(): array { - return [ + $result = [ 'type' => 'text', 'text' => $this->text, ]; + + if ($this->annotations !== null) { + $result['annotations'] = $this->annotations->toArray(); + } + + return $result; } /** @@ -51,15 +68,15 @@ public function toArray(): array * * @param mixed $value The value to convert to text */ - public static function make(mixed $value): static + public static function make(mixed $value, ?Annotations $annotations = null): static { if (is_array($value) || is_object($value)) { $text = json_encode($value, JSON_PRETTY_PRINT); - return new static($text); + return new static($text, $annotations); } - return new static((string) $value); + return new static((string) $value, $annotations); } /** @@ -68,8 +85,8 @@ public static function make(mixed $value): static * @param string $code The code to format * @param string $language The language for syntax highlighting */ - public static function code(string $code, string $language = ''): static + public static function code(string $code, string $language = '', ?Annotations $annotations = null): static { - return new static("```{$language}\n{$code}\n```"); + return new static("```{$language}\n{$code}\n```", $annotations); } } diff --git a/src/JsonRpc/Results/ReadResourceResult.php b/src/JsonRpc/Results/ReadResourceResult.php index 495fe33..7aebe01 100644 --- a/src/JsonRpc/Results/ReadResourceResult.php +++ b/src/JsonRpc/Results/ReadResourceResult.php @@ -2,7 +2,7 @@ namespace PhpMcp\Server\JsonRpc\Results; -use Codewithkyrian\LaravelMcp\JsonRpc\Types\EmbeddedResource; +use PhpMcp\Server\JsonRpc\Contents\ResourceContent; use PhpMcp\Server\JsonRpc\Result; class ReadResourceResult extends Result @@ -10,17 +10,16 @@ class ReadResourceResult extends Result /** * Create a new ReadResourceResult. * - * @param EmbeddedResource[] $contents The contents of the resource + * @param ResourceContent[] $contents The contents of the resource */ public function __construct( protected array $contents - ) { - } + ) {} /** * Get the contents of the resource. * - * @return EmbeddedResource[] + * @return ResourceContent[] */ public function getContents(): array { @@ -33,7 +32,7 @@ public function getContents(): array public function toArray(): array { return [ - 'contents' => array_map(fn ($resource) => $resource->toArray(), $this->contents), + 'contents' => array_map(fn($resource) => $resource->toArray(), $this->contents), ]; } } diff --git a/src/Model/Annotations.php b/src/Model/Annotations.php new file mode 100644 index 0000000..8df1768 --- /dev/null +++ b/src/Model/Annotations.php @@ -0,0 +1,54 @@ +priority !== null && ($this->priority < 0 || $this->priority > 1)) { + throw new \InvalidArgumentException('Priority must be between 0 and 1.'); + } + } + + public static function default(): self + { + return new self(null, null); + } + + public static function fromArray(array $data): self + { + return new self($data['audience'] ?? null, $data['priority'] ?? null); + } + + public function toArray(): array + { + $result = []; + + if ($this->audience !== null) { + $audience = []; + + foreach ($this->audience as $role) { + $audience[] = $role instanceof Role ? $role->value : $role; + } + + $result['audience'] = $audience; + } + if ($this->priority !== null) { + $result['priority'] = $this->priority; + } + + return $result; + } +} diff --git a/src/Model/Capabilities.php b/src/Model/Capabilities.php index 1b8583f..4451332 100644 --- a/src/Model/Capabilities.php +++ b/src/Model/Capabilities.php @@ -27,8 +27,7 @@ private function __construct( public readonly bool $loggingEnabled = false, public readonly ?string $instructions = null, public readonly ?array $experimental = null - ) { - } + ) {} /** * Factory method to create a Capabilities instance for the server. diff --git a/src/Model/Role.php b/src/Model/Role.php new file mode 100644 index 0000000..4c6c599 --- /dev/null +++ b/src/Model/Role.php @@ -0,0 +1,14 @@ +title !== null) { + $result['title'] = $this->title; + } + if ($this->readOnlyHint !== null) { + $result['readOnlyHint'] = $this->readOnlyHint; + } + if ($this->destructiveHint !== null) { + $result['destructiveHint'] = $this->destructiveHint; + } + if ($this->idempotentHint !== null) { + $result['idempotentHint'] = $this->idempotentHint; + } + if ($this->openWorldHint !== null) { + $result['openWorldHint'] = $this->openWorldHint; + } + + return $result; + } +} diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index b5b3edf..7bbbb9f 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -127,9 +127,9 @@ public function withLoop(LoopInterface $loop): self /** * Manually registers a tool handler. */ - public function withTool(array|string $handler, ?string $name = null, ?string $description = null): self + public function withTool(array|string $handler, ?string $name = null, ?string $description = null, ?array $annotations = null): self { - $this->manualTools[] = compact('handler', 'name', 'description'); + $this->manualTools[] = compact('handler', 'name', 'description', 'annotations'); return $this; } @@ -137,9 +137,9 @@ public function withTool(array|string $handler, ?string $name = null, ?string $d /** * Manually registers a resource handler. */ - public function withResource(array|string $handler, string $uri, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?int $size = null, array $annotations = []): self + public function withResource(array|string $handler, string $uri, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?int $size = null): self { - $this->manualResources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations'); + $this->manualResources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size'); return $this; } @@ -147,9 +147,9 @@ public function withResource(array|string $handler, string $uri, ?string $name = /** * Manually registers a resource template handler. */ - public function withResourceTemplate(array|string $handler, string $uriTemplate, ?string $name = null, ?string $description = null, ?string $mimeType = null, array $annotations = []): self + public function withResourceTemplate(array|string $handler, string $uriTemplate, ?string $name = null, ?string $description = null, ?string $mimeType = null): self { - $this->manualResourceTemplates[] = compact('handler', 'uriTemplate', 'name', 'description', 'mimeType', 'annotations'); + $this->manualResourceTemplates[] = compact('handler', 'uriTemplate', 'name', 'description', 'mimeType'); return $this; } @@ -231,6 +231,7 @@ private function performManualRegistrations(Registry $registry, LoggerInterface $resolvedHandler['reflectionMethod'], $data['name'], $data['description'], + $data['annotations'], $docBlockParser, $schemaGenerator ); diff --git a/src/Support/Discoverer.php b/src/Support/Discoverer.php index 0f96a62..d34b60e 100644 --- a/src/Support/Discoverer.php +++ b/src/Support/Discoverer.php @@ -185,7 +185,7 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void * * @param ReflectionMethod $method The target method (e.g., regular method or __invoke). * @param array $discoveredCount Pass by reference to update counts. - * @param ReflectionAttribute $attribute The ReflectionAttribute instance found (on method or class). + * @param ReflectionAttribute $attribute The ReflectionAttribute instance found (on method or class). */ private function processMethod(ReflectionMethod $method, array &$discoveredCount, ReflectionAttribute $attribute): void { @@ -200,8 +200,9 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount case McpTool::class: $definition = ToolDefinition::fromReflection( $method, - $instance->name ?? null, - $instance->description ?? null, + $instance->name, + $instance->description, + $instance->annotations, $this->docBlockParser, $this->schemaGenerator ); @@ -215,12 +216,12 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount } $definition = ResourceDefinition::fromReflection( $method, - $instance->name ?? null, - $instance->description ?? null, + $instance->name, + $instance->description, $instance->uri, - $instance->mimeType ?? null, - $instance->size ?? null, - $instance->annotations ?? [], + $instance->mimeType, + $instance->annotations, + $instance->size, $this->docBlockParser ); $this->registry->registerResource($definition); @@ -230,8 +231,8 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount case McpPrompt::class: $definition = PromptDefinition::fromReflection( $method, - $instance->name ?? null, - $instance->description ?? null, + $instance->name, + $instance->description, $this->docBlockParser ); $this->registry->registerPrompt($definition); @@ -244,11 +245,11 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount } $definition = ResourceTemplateDefinition::fromReflection( $method, - $instance->name ?? null, - $instance->description ?? null, + $instance->name, + $instance->description, $instance->uriTemplate, - $instance->mimeType ?? null, - $instance->annotations ?? [], + $instance->mimeType, + $instance->annotations, $this->docBlockParser ); $this->registry->registerResourceTemplate($definition); diff --git a/src/Support/RequestProcessor.php b/src/Support/RequestProcessor.php index 6a1df1c..aca7b6f 100644 --- a/src/Support/RequestProcessor.php +++ b/src/Support/RequestProcessor.php @@ -444,12 +444,11 @@ private function handlePromptGet(array $params): GetPromptResult $instance = $this->container->get($definition->getClassName()); $methodName = $definition->getMethodName(); - // Prepare arguments for the prompt generator method $args = $this->argumentPreparer->prepareMethodArguments( $instance, $methodName, $arguments, - [] // No input schema for prompts + [] ); $promptGenerationResult = $instance->{$methodName}(...$args); diff --git a/src/Traits/ResponseFormatter.php b/src/Traits/ResponseFormatter.php index 735e253..3bd98d5 100644 --- a/src/Traits/ResponseFormatter.php +++ b/src/Traits/ResponseFormatter.php @@ -10,6 +10,7 @@ use PhpMcp\Server\JsonRpc\Contents\PromptMessage; use PhpMcp\Server\JsonRpc\Contents\ResourceContent; use PhpMcp\Server\JsonRpc\Contents\TextContent; +use PhpMcp\Server\Model\Role; use Throwable; /** @@ -27,17 +28,14 @@ trait ResponseFormatter */ protected function formatToolResult(mixed $toolExecutionResult): array { - // If already an array of Content objects, use as is if (is_array($toolExecutionResult) && ! empty($toolExecutionResult) && $toolExecutionResult[array_key_first($toolExecutionResult)] instanceof Content) { return $toolExecutionResult; } - // If a single Content object, wrap in array if ($toolExecutionResult instanceof Content) { return [$toolExecutionResult]; } - // Null or "void" function if ($toolExecutionResult === null) { if (($outputSchema['type'] ?? 'mixed') !== 'void') { return [TextContent::make('(null)')]; @@ -45,7 +43,6 @@ protected function formatToolResult(mixed $toolExecutionResult): array return []; } - // Handle booleans explicitly if (is_bool($toolExecutionResult)) { return [TextContent::make($toolExecutionResult ? 'true' : 'false')]; } @@ -54,7 +51,6 @@ protected function formatToolResult(mixed $toolExecutionResult): array return [TextContent::make($toolExecutionResult)]; } - // Default: JSON encode complex structures - let exceptions bubble up $jsonResult = json_encode( $toolExecutionResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE @@ -71,10 +67,8 @@ protected function formatToolResult(mixed $toolExecutionResult): array */ protected function formatToolErrorResult(Throwable $toolError): array { - // Provide a user/LLM-friendly error message. Avoid stack traces. - $errorMessage = 'Tool execution failed: '.$toolError->getMessage(); - // Include exception type name for context, might help debugging/LLM understanding. - $errorMessage .= ' (Type: '.get_class($toolError).')'; + $errorMessage = 'Tool execution failed: ' . $toolError->getMessage(); + $errorMessage .= ' (Type: ' . get_class($toolError) . ')'; return [ new TextContent($errorMessage), @@ -82,7 +76,7 @@ protected function formatToolErrorResult(Throwable $toolError): array } /** - * Formats the raw result of a resource read operation into MCP ResourceContents items. + * Formats the raw result of a resource read operation into MCP ResourceContent items. * * @param mixed $readResult The raw result from the resource handler method. * @param string $uri The URI of the resource that was read. @@ -104,43 +98,35 @@ protected function formatToolErrorResult(Throwable $toolError): array */ protected function formatResourceContents(mixed $readResult, string $uri, ?string $defaultMimeType): array { - // If already an EmbeddedResource, just wrap it - if ($readResult instanceof EmbeddedResource) { + if ($readResult instanceof ResourceContent) { return [$readResult]; } - // If it's a ResourceContent, extract the embedded resource - if ($readResult instanceof ResourceContent) { - return [$readResult->getResource()]; + if ($readResult instanceof EmbeddedResource) { + return [$readResult->resource]; } - // Handle array of EmbeddedResource objects - if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof EmbeddedResource) { + if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof ResourceContent) { return $readResult; } - // Handle array of ResourceContent objects - if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof ResourceContent) { - return array_map(fn ($item) => $item->getResource(), $readResult); + if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof EmbeddedResource) { + return array_map(fn($item) => $item->resource, $readResult); } - // Handle string (text content) if (is_string($readResult)) { $mimeType = $defaultMimeType ?? $this->guessMimeTypeFromString($readResult); - return [new EmbeddedResource($uri, $mimeType, $readResult)]; + return [new ResourceContent($uri, $mimeType, $readResult)]; } - // Handle stream resources if (is_resource($readResult) && get_resource_type($readResult) === 'stream') { - // Let exceptions bubble up - $result = EmbeddedResource::fromStream( + $result = ResourceContent::fromStream( $uri, $readResult, $defaultMimeType ?? 'application/octet-stream' ); - // Ensure stream is closed if we opened/read it if (is_resource($readResult)) { @fclose($readResult); } @@ -148,34 +134,29 @@ protected function formatResourceContents(mixed $readResult, string $uri, ?strin return [$result]; } - // Handle pre-formatted array structure if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) { $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'application/octet-stream'; - return [new EmbeddedResource($uri, $mimeType, null, $readResult['blob'])]; + return [new ResourceContent($uri, $mimeType, null, $readResult['blob'])]; } if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) { $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'text/plain'; - return [new EmbeddedResource($uri, $mimeType, $readResult['text'])]; + return [new ResourceContent($uri, $mimeType, $readResult['text'])]; } - // Handle SplFileInfo if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { - // Let exceptions bubble up - return [EmbeddedResource::fromSplFileInfo($uri, $readResult, $defaultMimeType)]; + return [ResourceContent::fromSplFileInfo($uri, $readResult, $defaultMimeType)]; } - // Handle arrays for JSON MIME types - convert to JSON string if (is_array($readResult)) { - // If default MIME type is JSON or contains 'json', encode the array to JSON if ($defaultMimeType && (str_contains(strtolower($defaultMimeType), 'json') || - $defaultMimeType === 'application/json')) { + $defaultMimeType === 'application/json')) { try { $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); - return [new EmbeddedResource($uri, $defaultMimeType, $jsonString)]; + return [new ResourceContent($uri, $defaultMimeType, $jsonString)]; } catch (\JsonException $e) { $this->logger->warning('MCP SDK: Failed to JSON encode array resource result', [ 'uri' => $uri, @@ -185,19 +166,17 @@ protected function formatResourceContents(mixed $readResult, string $uri, ?strin } } - // For non-JSON mime types, we could still try to encode the array, but with a warning try { $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); - $mimeType = 'application/json'; // Override to JSON mime type + $mimeType = 'application/json'; $this->logger->warning('MCP SDK: Automatically converted array to JSON for resource', [ 'uri' => $uri, 'requestedMimeType' => $defaultMimeType, 'usedMimeType' => $mimeType, ]); - return [new EmbeddedResource($uri, $mimeType, $jsonString)]; + return [new ResourceContent($uri, $mimeType, $jsonString)]; } catch (\JsonException $e) { - // If JSON encoding fails, log error and continue to the error handling below $this->logger->error('MCP SDK: Failed to encode array resource result as JSON', [ 'uri' => $uri, 'exception' => $e->getMessage(), @@ -206,7 +185,7 @@ protected function formatResourceContents(mixed $readResult, string $uri, ?strin } $this->logger->error('MCP SDK: Unformattable resource read result type.', ['type' => gettype($readResult), 'uri' => $uri]); - throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: ".gettype($readResult)); + throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: " . gettype($readResult)); } /** Guesses MIME type from string content (very basic) */ @@ -245,15 +224,17 @@ private function guessMimeTypeFromString(string $content): string */ protected function formatPromptMessages(mixed $promptGenerationResult): array { - // If already an array of PromptMessage objects, use as is - if (is_array($promptGenerationResult) && ! empty($promptGenerationResult) - && $promptGenerationResult[array_key_first($promptGenerationResult)] instanceof PromptMessage) { + if ( + is_array($promptGenerationResult) && ! empty($promptGenerationResult) + && $promptGenerationResult[array_key_first($promptGenerationResult)] instanceof PromptMessage + ) { return $promptGenerationResult; } - // Handle simple role => text pairs array - if (is_array($promptGenerationResult) && ! array_is_list($promptGenerationResult) - && (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant']))) { + if ( + is_array($promptGenerationResult) && ! array_is_list($promptGenerationResult) + && (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant'])) + ) { $result = []; if (isset($promptGenerationResult['user'])) { @@ -272,51 +253,44 @@ protected function formatPromptMessages(mixed $promptGenerationResult): array throw new \RuntimeException('Prompt generator method must return an array of messages.'); } - // Ensure it's a list of messages if (! array_is_list($promptGenerationResult)) { throw new \RuntimeException('Prompt generator method must return a list (sequential array) of messages, not an associative array.'); } $formattedMessages = []; foreach ($promptGenerationResult as $index => $message) { - // If it's already a PromptMessage, use it directly if ($message instanceof PromptMessage) { $formattedMessages[] = $message; continue; } - // Handle simple role => content object - if (is_array($message) && isset($message['role']) && isset($message['content']) && count($message) === 2) { - $role = $message['role']; + if (is_array($message) && isset($message['role']) && isset($message['content'])) { + $role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']); $content = $message['content']; - if (! in_array($role, ['user', 'assistant'])) { - throw new \RuntimeException("Invalid role '{$role}' in prompt message at index {$index}. Only 'user' or 'assistant' are supported."); + if ($role === null) { + throw new \RuntimeException("Invalid role '{$message['role']}' in prompt message at index {$index}. Only 'user' or 'assistant' are supported."); } - // If content is already a Content object if ($content instanceof Content) { $formattedMessages[] = new PromptMessage($role, $content); continue; } - // If content is a string, convert to TextContent if (is_string($content)) { $formattedMessages[] = new PromptMessage($role, new TextContent($content)); continue; } - // Handle content array with type field if (is_array($content) && isset($content['type'])) { $type = $content['type']; if (! in_array($type, ['text', 'image', 'audio', 'resource'])) { throw new \RuntimeException("Invalid content type '{$type}' in prompt message at index {$index}."); } - // Convert to appropriate Content object $contentObj = null; switch ($type) { case 'text': @@ -356,15 +330,11 @@ protected function formatPromptMessages(mixed $promptGenerationResult): array throw new \RuntimeException("Invalid resource: Missing or invalid 'uri'."); } - $embeddedResource = null; + $resourceObj = null; if (isset($resource['text']) && is_string($resource['text'])) { - $embeddedResource = new EmbeddedResource( - $resource['uri'], - $resource['mimeType'] ?? 'text/plain', - $resource['text'] - ); + $resourceObj = new ResourceContent($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); } elseif (isset($resource['blob']) && is_string($resource['blob'])) { - $embeddedResource = new EmbeddedResource( + $resourceObj = new ResourceContent( $resource['uri'], $resource['mimeType'] ?? 'application/octet-stream', null, @@ -374,7 +344,7 @@ protected function formatPromptMessages(mixed $promptGenerationResult): array throw new \RuntimeException("Invalid resource: Must contain 'text' or 'blob'."); } - $contentObj = new ResourceContent($embeddedResource); + $contentObj = new EmbeddedResource($resourceObj); break; } diff --git a/tests/Unit/Attributes/McpResourceTemplateTest.php b/tests/Unit/Attributes/McpResourceTemplateTest.php index 99ba685..37e70f9 100644 --- a/tests/Unit/Attributes/McpResourceTemplateTest.php +++ b/tests/Unit/Attributes/McpResourceTemplateTest.php @@ -10,7 +10,6 @@ $name = 'test-template-name'; $description = 'This is a test template description.'; $mimeType = 'application/json'; - $annotations = ['group' => 'files']; // Act $attribute = new McpResourceTemplate( @@ -18,7 +17,6 @@ name: $name, description: $description, mimeType: $mimeType, - annotations: $annotations ); // Assert @@ -26,7 +24,6 @@ expect($attribute->name)->toBe($name); expect($attribute->description)->toBe($description); expect($attribute->mimeType)->toBe($mimeType); - expect($attribute->annotations)->toBe($annotations); }); it('instantiates with null values for name and description', function () { @@ -36,7 +33,6 @@ name: null, description: null, mimeType: null, - annotations: [] ); // Assert @@ -44,7 +40,6 @@ expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); - expect($attribute->annotations)->toBe([]); }); it('instantiates with missing optional arguments', function () { @@ -57,5 +52,4 @@ expect($attribute->name)->toBeNull(); expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); - expect($attribute->annotations)->toBe([]); }); diff --git a/tests/Unit/Attributes/McpResourceTest.php b/tests/Unit/Attributes/McpResourceTest.php index b541ddb..be025fe 100644 --- a/tests/Unit/Attributes/McpResourceTest.php +++ b/tests/Unit/Attributes/McpResourceTest.php @@ -11,7 +11,6 @@ $description = 'This is a test resource description.'; $mimeType = 'text/plain'; $size = 1024; - $annotations = ['priority' => 5]; // Act $attribute = new McpResource( @@ -20,7 +19,6 @@ description: $description, mimeType: $mimeType, size: $size, - annotations: $annotations ); // Assert @@ -29,7 +27,6 @@ expect($attribute->description)->toBe($description); expect($attribute->mimeType)->toBe($mimeType); expect($attribute->size)->toBe($size); - expect($attribute->annotations)->toBe($annotations); }); it('instantiates with null values for name and description', function () { @@ -40,7 +37,6 @@ description: null, mimeType: null, size: null, - annotations: [] ); // Assert @@ -49,7 +45,6 @@ expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); expect($attribute->size)->toBeNull(); - expect($attribute->annotations)->toBe([]); }); it('instantiates with missing optional arguments', function () { @@ -63,5 +58,4 @@ expect($attribute->description)->toBeNull(); expect($attribute->mimeType)->toBeNull(); expect($attribute->size)->toBeNull(); - expect($attribute->annotations)->toBe([]); }); diff --git a/tests/Unit/Definitions/ResourceDefinitionTest.php b/tests/Unit/Definitions/ResourceDefinitionTest.php index 7b2275c..abd5e11 100644 --- a/tests/Unit/Definitions/ResourceDefinitionTest.php +++ b/tests/Unit/Definitions/ResourceDefinitionTest.php @@ -13,7 +13,7 @@ // --- Constructor Validation Tests --- test('constructor validates resource name pattern', function (string $resourceName, bool $shouldFail) { - $action = fn () => new ResourceDefinition( + $action = fn() => new ResourceDefinition( className: AllElementsStub::class, methodName: 'resourceMethod', uri: 'file:///valid/uri', @@ -21,7 +21,6 @@ className: AllElementsStub::class, description: 'Desc', mimeType: 'text/plain', size: 100, - annotations: [] ); if ($shouldFail) { @@ -38,7 +37,7 @@ className: AllElementsStub::class, ]); test('constructor validates URI pattern', function (string $uri, bool $shouldFail) { - $action = fn () => new ResourceDefinition( + $action = fn() => new ResourceDefinition( className: AllElementsStub::class, methodName: 'resourceMethod', uri: $uri, @@ -46,7 +45,6 @@ className: AllElementsStub::class, description: 'Desc', mimeType: 'text/plain', size: 100, - annotations: [] ); if ($shouldFail) { @@ -81,7 +79,6 @@ className: AllElementsStub::class, description: 'Explicit Description', mimeType: 'application/json', size: 1234, - annotations: ['audience' => 'model'] ); $docComment = $reflectionMethod->getDocComment() ?: null; @@ -95,7 +92,6 @@ className: AllElementsStub::class, $attribute->uri, $attribute->mimeType, $attribute->size, - $attribute->annotations, $this->docBlockParser ); @@ -107,7 +103,6 @@ className: AllElementsStub::class, expect($definition->getMethodName())->toBe('resourceMethod'); expect($definition->getMimeType())->toBe('application/json'); expect($definition->getSize())->toBe(1234); - expect($definition->getAnnotations())->toBe(['audience' => 'model']); }); test('fromReflection uses method name and docblock summary as defaults', function () { @@ -117,7 +112,7 @@ className: AllElementsStub::class, $docComment = $reflectionMethod->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; @@ -133,7 +128,6 @@ className: AllElementsStub::class, $attribute->uri, $attribute->mimeType, $attribute->size, - $attribute->annotations, $this->docBlockParser ); @@ -145,7 +139,6 @@ className: AllElementsStub::class, expect($definition->getMethodName())->toBe('resourceMethod'); expect($definition->getMimeType())->toBeNull(); expect($definition->getSize())->toBeNull(); - expect($definition->getAnnotations())->toBe([]); }); test('fromReflection handles missing docblock summary', function () { @@ -165,7 +158,6 @@ className: AllElementsStub::class, $attribute->uri, $attribute->mimeType, $attribute->size, - $attribute->annotations, $this->docBlockParser ); @@ -188,7 +180,6 @@ className: AllElementsStub::class, description: 'Testing serialization', mimeType: 'image/jpeg', size: 9876, - annotations: ['p' => 1] ); // Act @@ -201,14 +192,12 @@ className: AllElementsStub::class, 'description' => $original->getDescription(), 'mimeType' => $original->getMimeType(), 'size' => $original->getSize(), - 'annotations' => $original->getAnnotations(), ]; $reconstructed = ResourceDefinition::fromArray($internalArray); // Assert expect($reconstructed)->toEqual($original); expect($reconstructed->getSize())->toBe($original->getSize()); - expect($reconstructed->getAnnotations())->toBe($original->getAnnotations()); }); test('toArray produces correct MCP format', function () { @@ -221,7 +210,6 @@ className: AllElementsStub::class, description: 'MCP Description', mimeType: 'text/markdown', size: 555, - annotations: ['a' => 'b'] ); $definitionMinimal = new ResourceDefinition( className: ResourceOnlyStub::class, @@ -231,7 +219,6 @@ className: ResourceOnlyStub::class, description: null, mimeType: null, size: null, - annotations: [] ); // Act @@ -245,11 +232,10 @@ className: ResourceOnlyStub::class, 'description' => 'MCP Description', 'mimeType' => 'text/markdown', 'size' => 555, - 'annotations' => ['a' => 'b'], ]); expect($arrayMinimal)->toBe([ 'uri' => 'mcp://minimal', 'name' => 'mcp-minimal', ]); - expect($arrayMinimal)->not->toHaveKeys(['description', 'mimeType', 'size', 'annotations']); + expect($arrayMinimal)->not->toHaveKeys(['description', 'mimeType', 'size']); }); diff --git a/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php b/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php index d8eafaa..539d13c 100644 --- a/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php +++ b/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php @@ -12,14 +12,13 @@ // --- Constructor Validation Tests --- test('constructor validates template name pattern', function (string $templateName, bool $shouldFail) { - $action = fn () => new ResourceTemplateDefinition( + $action = fn() => new ResourceTemplateDefinition( className: AllElementsStub::class, methodName: 'templateMethod', uriTemplate: 'user://{userId}/profile', name: $templateName, description: 'Desc', mimeType: 'application/json', - annotations: [] ); if ($shouldFail) { @@ -36,14 +35,13 @@ className: AllElementsStub::class, ]); test('constructor validates URI template pattern', function (string $uriTemplate, bool $shouldFail) { - $action = fn () => new ResourceTemplateDefinition( + $action = fn() => new ResourceTemplateDefinition( className: AllElementsStub::class, methodName: 'templateMethod', uriTemplate: $uriTemplate, name: 'valid-name', description: 'Desc', mimeType: 'application/json', - annotations: [] ); if ($shouldFail) { @@ -78,7 +76,6 @@ className: AllElementsStub::class, name: 'explicit-tmpl-name', description: 'Explicit Description', mimeType: 'application/xml', - annotations: ['priority' => 10] ); $docComment = $reflectionMethod->getDocComment() ?: null; @@ -92,7 +89,6 @@ className: AllElementsStub::class, $attribute->description, $attribute->uriTemplate, $attribute->mimeType, - $attribute->annotations, $this->docBlockParser ); @@ -103,7 +99,6 @@ className: AllElementsStub::class, expect($definition->getClassName())->toBe(AllElementsStub::class); expect($definition->getMethodName())->toBe('templateMethod'); expect($definition->getMimeType())->toBe('application/xml'); - expect($definition->getAnnotations())->toBe(['priority' => 10]); }); test('fromReflection uses method name and docblock summary as defaults', function () { @@ -113,7 +108,7 @@ className: AllElementsStub::class, $docComment = $reflectionMethod->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; @@ -129,7 +124,6 @@ className: AllElementsStub::class, $attribute->description, $attribute->uriTemplate, $attribute->mimeType, - $attribute->annotations, $this->docBlockParser ); @@ -140,7 +134,6 @@ className: AllElementsStub::class, expect($definition->getClassName())->toBe(AllElementsStub::class); expect($definition->getMethodName())->toBe('templateMethod'); expect($definition->getMimeType())->toBeNull(); - expect($definition->getAnnotations())->toBe([]); }); test('fromReflection handles missing docblock summary', function () { @@ -161,7 +154,6 @@ className: AllElementsStub::class, $attribute->description, $attribute->uriTemplate, $attribute->mimeType, - $attribute->annotations, $this->docBlockParser ); @@ -183,7 +175,6 @@ className: AllElementsStub::class, name: 'serial-tmpl', description: 'Testing serialization', mimeType: 'text/csv', - annotations: ['test' => true] ); // Act @@ -195,13 +186,11 @@ className: AllElementsStub::class, 'name' => $original->getName(), 'description' => $original->getDescription(), 'mimeType' => $original->getMimeType(), - 'annotations' => $original->getAnnotations(), ]; $reconstructed = ResourceTemplateDefinition::fromArray($internalArray); // Assert expect($reconstructed)->toEqual($original); - expect($reconstructed->getAnnotations())->toBe($original->getAnnotations()); }); test('toArray produces correct MCP format', function () { @@ -213,7 +202,6 @@ className: AllElementsStub::class, name: 'mcp-tmpl', description: 'MCP Description', mimeType: 'application/vnd.api+json', - annotations: ['version' => '1.0'] ); $definitionMinimal = new ResourceTemplateDefinition( className: AllElementsStub::class, @@ -222,7 +210,6 @@ className: AllElementsStub::class, name: 'mcp-minimal', description: null, mimeType: null, - annotations: [] ); // Act @@ -235,11 +222,10 @@ className: AllElementsStub::class, 'name' => 'mcp-tmpl', 'description' => 'MCP Description', 'mimeType' => 'application/vnd.api+json', - 'annotations' => ['version' => '1.0'], ]); expect($arrayMinimal)->toBe([ 'uriTemplate' => 'mcp://minimal/{key}', 'name' => 'mcp-minimal', ]); - expect($arrayMinimal)->not->toHaveKeys(['description', 'mimeType', 'annotations']); + expect($arrayMinimal)->not->toHaveKeys(['description', 'mimeType']); }); From edd7deec76940bf0c390726bf3052ee0a02f8558 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 7 Jun 2025 00:24:11 +0100 Subject: [PATCH 04/27] feat: added new StreamableHttpTransport with resumability - New StreamableHttpTransport to handle post, get and delete requests and resumability - updated JSON RPC objects to comply more closely with the specification - added Eventstore interface and implementation for storing stream events to allow resumability - new SessionIdGenerator Interface and implementation --- .../02-discovery-http-userprofile/server.php | 17 +- src/Contracts/EventStoreInterface.php | 29 + src/Contracts/ServerTransportInterface.php | 15 +- src/Contracts/SessionIdGeneratorInterface.php | 21 + .../DefaultUuidSessionIdGenerator.php | 20 + src/Defaults/InMemoryEventStore.php | 78 +++ src/Exception/McpServerException.php | 49 +- src/Exception/ProtocolException.php | 17 +- src/Exception/TransportException.php | 16 +- src/JsonRpc/Contents/AudioContent.php | 46 +- src/JsonRpc/Contents/Content.php | 21 - src/JsonRpc/Contents/ImageContent.php | 39 +- src/JsonRpc/Contents/PromptMessage.php | 6 +- src/JsonRpc/Contents/ResourceContent.php | 10 +- src/JsonRpc/Contents/TextContent.php | 36 +- src/JsonRpc/Contracts/ContentInterface.php | 12 + src/JsonRpc/Contracts/MessageInterface.php | 14 + src/JsonRpc/Contracts/ResultInterface.php | 12 + src/JsonRpc/Error.php | 57 -- src/JsonRpc/Message.php | 25 - .../{Batch.php => Messages/BatchRequest.php} | 56 +- src/JsonRpc/Messages/BatchResponse.php | 109 ++++ src/JsonRpc/Messages/Error.php | 104 ++++ src/JsonRpc/Messages/Message.php | 60 ++ src/JsonRpc/{ => Messages}/Notification.php | 11 +- src/JsonRpc/{ => Messages}/Request.php | 16 +- src/JsonRpc/Messages/Response.php | 85 +++ src/JsonRpc/Response.php | 175 ------ src/JsonRpc/Result.php | 26 - src/JsonRpc/Results/CallToolResult.php | 37 +- src/JsonRpc/Results/EmptyResult.php | 13 +- src/JsonRpc/Results/GetPromptResult.php | 36 +- src/JsonRpc/Results/InitializeResult.php | 12 +- src/JsonRpc/Results/ListPromptsResult.php | 14 +- .../Results/ListResourceTemplatesResult.php | 14 +- src/JsonRpc/Results/ListResourcesResult.php | 14 +- src/JsonRpc/Results/ListToolsResult.php | 14 +- src/JsonRpc/Results/ReadResourceResult.php | 20 +- src/Protocol.php | 238 ++----- src/Registry.php | 32 +- src/ServerBuilder.php | 8 +- src/Support/RequestProcessor.php | 61 +- src/Transports/HttpServerTransport.php | 39 +- src/Transports/StdioServerTransport.php | 13 +- .../StreamableHttpServerTransport.php | 582 ++++++++++++++++++ .../Unit/Definitions/PromptDefinitionTest.php | 46 +- .../Definitions/ResourceDefinitionTest.php | 52 +- .../ResourceTemplateDefinitionTest.php | 44 +- tests/Unit/Definitions/ToolDefinitionTest.php | 50 +- tests/Unit/JsonRpc/NotificationTest.php | 12 +- tests/Unit/ProtocolTest.php | 16 +- tests/Unit/RegistryTest.php | 5 +- tests/Unit/Support/DiscovererTest.php | 39 +- tests/Unit/Support/RequestProcessorTest.php | 17 +- tests/Unit/Traits/ResponseFormatterTest.php | 34 +- 55 files changed, 1648 insertions(+), 996 deletions(-) create mode 100644 src/Contracts/EventStoreInterface.php create mode 100644 src/Contracts/SessionIdGeneratorInterface.php create mode 100644 src/Defaults/DefaultUuidSessionIdGenerator.php create mode 100644 src/Defaults/InMemoryEventStore.php delete mode 100644 src/JsonRpc/Contents/Content.php create mode 100644 src/JsonRpc/Contracts/ContentInterface.php create mode 100644 src/JsonRpc/Contracts/MessageInterface.php create mode 100644 src/JsonRpc/Contracts/ResultInterface.php delete mode 100644 src/JsonRpc/Error.php delete mode 100644 src/JsonRpc/Message.php rename src/JsonRpc/{Batch.php => Messages/BatchRequest.php} (62%) create mode 100644 src/JsonRpc/Messages/BatchResponse.php create mode 100644 src/JsonRpc/Messages/Error.php create mode 100644 src/JsonRpc/Messages/Message.php rename src/JsonRpc/{ => Messages}/Notification.php (92%) rename src/JsonRpc/{ => Messages}/Request.php (90%) create mode 100644 src/JsonRpc/Messages/Response.php delete mode 100644 src/JsonRpc/Response.php delete mode 100644 src/JsonRpc/Result.php create mode 100644 src/Transports/StreamableHttpServerTransport.php diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php index 35dc495..93abc03 100644 --- a/examples/02-discovery-http-userprofile/server.php +++ b/examples/02-discovery-http-userprofile/server.php @@ -43,6 +43,7 @@ use PhpMcp\Server\Defaults\BasicContainer; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\HttpServerTransport; +use PhpMcp\Server\Transports\StreamableHttpServerTransport; use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; @@ -70,21 +71,25 @@ public function log($level, \Stringable|string $message, array $context = []): v $server->discover(__DIR__, ['.']); - $transport = new HttpServerTransport( + // $transport = new HttpServerTransport( + // host: '127.0.0.1', + // port: 8080, + // mcpPathPrefix: 'mcp' + // ); + $transport = new StreamableHttpServerTransport( host: '127.0.0.1', port: 8080, - mcpPathPrefix: 'mcp' + mcpPath: 'mcp' ); $server->listen($transport); $logger->info('Server listener stopped gracefully.'); exit(0); - } catch (\Throwable $e) { fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); - fwrite(STDERR, 'Error: '.$e->getMessage()."\n"); - fwrite(STDERR, 'File: '.$e->getFile().':'.$e->getLine()."\n"); - fwrite(STDERR, $e->getTraceAsString()."\n"); + fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); + fwrite(STDERR, 'File: ' . $e->getFile() . ':' . $e->getLine() . "\n"); + fwrite(STDERR, $e->getTraceAsString() . "\n"); exit(1); } diff --git a/src/Contracts/EventStoreInterface.php b/src/Contracts/EventStoreInterface.php new file mode 100644 index 0000000..efa13b6 --- /dev/null +++ b/src/Contracts/EventStoreInterface.php @@ -0,0 +1,29 @@ + Resolves on successful send/queue, rejects on specific send error. */ - public function sendToClientAsync(string $sessionId, string $rawFramedMessage): PromiseInterface; + public function sendMessage(Response|Error|BatchResponse|null $message, string $sessionId, array $context = []): PromiseInterface; /** * Stops the transport listener gracefully and closes all active connections. diff --git a/src/Contracts/SessionIdGeneratorInterface.php b/src/Contracts/SessionIdGeneratorInterface.php new file mode 100644 index 0000000..76f71dc --- /dev/null +++ b/src/Contracts/SessionIdGeneratorInterface.php @@ -0,0 +1,21 @@ + + * Example: [eventId1 => ['streamId' => 'abc', 'message' => '...']] + */ + private array $events = []; + + private function generateEventId(string $streamId): string + { + return $streamId . '_' . (int)(microtime(true) * 1000) . '_' . bin2hex(random_bytes(4)); + } + + private function getStreamIdFromEventId(string $eventId): ?string + { + $parts = explode('_', $eventId); + return $parts[0] ?? null; + } + + public function storeEvent(string $streamId, string $message): string + { + $eventId = $this->generateEventId($streamId); + + $this->events[$eventId] = [ + 'streamId' => $streamId, + 'message' => $message, + ]; + + return $eventId; + } + + public function replayEventsAfter(string $lastEventId, callable $sendCallback): void + { + if (!isset($this->events[$lastEventId])) { + return; + } + + $streamId = $this->getStreamIdFromEventId($lastEventId); + if ($streamId === null) { + return; + } + + $foundLastEvent = false; + + // Sort by eventId for deterministic ordering + ksort($this->events); + + foreach ($this->events as $eventId => ['streamId' => $eventStreamId, 'message' => $message]) { + if ($eventStreamId !== $streamId) { + continue; + } + + if ($eventId === $lastEventId) { + $foundLastEvent = true; + continue; + } + + if ($foundLastEvent) { + $sendCallback($eventId, $message); + } + } + } +} diff --git a/src/Exception/McpServerException.php b/src/Exception/McpServerException.php index 9bdfbaa..caddca9 100644 --- a/src/Exception/McpServerException.php +++ b/src/Exception/McpServerException.php @@ -5,7 +5,7 @@ namespace PhpMcp\Server\Exception; use Exception; -use PhpMcp\Server\JsonRpc\Error as JsonRpcError; +use PhpMcp\Server\JsonRpc\Messages\Error as JsonRpcError; use Throwable; /** @@ -13,16 +13,7 @@ */ class McpServerException extends Exception { - // Standard JSON-RPC 2.0 Error Codes (retained for convenience) - public const CODE_PARSE_ERROR = -32700; - public const CODE_INVALID_REQUEST = -32600; - - public const CODE_METHOD_NOT_FOUND = -32601; - - public const CODE_INVALID_PARAMS = -32602; - - public const CODE_INTERNAL_ERROR = -32603; // MCP reserved range: -32000 to -32099 (Server error) // Add specific server-side codes if needed later, e.g.: @@ -66,75 +57,81 @@ public function getData(): mixed * 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 + public function toJsonRpcError(string|int $id): JsonRpcError { - $code = ($this->code >= -32768 && $this->code <= -32000) ? $this->code : self::CODE_INTERNAL_ERROR; - - return new JsonRpcError($code, $this->getMessage(), $this->getData()); + $code = ($this->code >= -32768 && $this->code <= -32000) ? $this->code : JsonRpcError::CODE_INTERNAL_ERROR; + + return new JsonRpcError( + jsonrpc: '2.0', + id: $id, + code: $code, + message: $this->getMessage(), + data: $this->getData() + ); } // --- Static Factory Methods for Common JSON-RPC Errors --- public static function parseError(string $details, ?Throwable $previous = null): self { - return new ProtocolException('Parse error: '.$details, self::CODE_PARSE_ERROR, null, $previous); + return new ProtocolException('Parse error: ' . $details, JsonRpcError::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); + return new ProtocolException($details, JsonRpcError::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); + return new ProtocolException("Method not found: {$methodName}", JsonRpcError::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); + return new ProtocolException($message, JsonRpcError::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; + $message .= ': ' . $details; } elseif ($previous && $details === null) { $message .= ' (See server logs)'; } - return new McpServerException($message, self::CODE_INTERNAL_ERROR, null, $previous); + return new McpServerException($message, JsonRpcError::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(); + $message .= ': ' . $previous->getMessage(); } - return new McpServerException($message, self::CODE_INTERNAL_ERROR, null, $previous); + return new McpServerException($message, JsonRpcError::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(); + $message .= ': ' . $previous->getMessage(); } - return new McpServerException($message, self::CODE_INTERNAL_ERROR, null, $previous); + return new McpServerException($message, JsonRpcError::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(); + $message .= ': ' . $previous->getMessage(); } - return new McpServerException($message, self::CODE_INTERNAL_ERROR, null, $previous); + return new McpServerException($message, JsonRpcError::CODE_INTERNAL_ERROR, null, $previous); } } diff --git a/src/Exception/ProtocolException.php b/src/Exception/ProtocolException.php index 95f76cc..baf6e72 100644 --- a/src/Exception/ProtocolException.php +++ b/src/Exception/ProtocolException.php @@ -4,6 +4,8 @@ namespace PhpMcp\Server\Exception; +use PhpMcp\Server\JsonRpc\Messages\Error as JsonRpcError; + /** * Exception related to violations of the JSON-RPC 2.0 or MCP structure * in incoming messages or outgoing responses (e.g., missing required fields, @@ -11,17 +13,16 @@ */ class ProtocolException extends McpServerException { - // This exception often corresponds directly to JSON-RPC error codes. - // The factory methods in McpServerException can assign appropriate codes. - - public function toJsonRpcError(): \PhpMcp\Server\JsonRpc\Error + public function toJsonRpcError(string|int $id): JsonRpcError { $code = ($this->code >= -32700 && $this->code <= -32600) ? $this->code : self::CODE_INVALID_REQUEST; - return new \PhpMcp\Server\JsonRpc\Error( - $code, - $this->getMessage(), - $this->getData() + return new JsonRpcError( + jsonrpc: '2.0', + id: $id, + code: $code, + message: $this->getMessage(), + data: $this->getData() ); } } diff --git a/src/Exception/TransportException.php b/src/Exception/TransportException.php index 695bc7a..eaeecc2 100644 --- a/src/Exception/TransportException.php +++ b/src/Exception/TransportException.php @@ -4,20 +4,22 @@ namespace PhpMcp\Server\Exception; +use PhpMcp\Server\JsonRpc\Messages\Error as JsonRpcError; + /** * Exception related to errors in the underlying transport layer * (e.g., socket errors, process management issues, SSE stream errors). */ class TransportException extends McpServerException { - // Usually indicates an internal server error if it prevents request processing. - public function toJsonRpcError(): \PhpMcp\Server\JsonRpc\Error + public function toJsonRpcError(string|int $id): JsonRpcError { - // Override to ensure it maps to internal error for JSON-RPC responses - return new \PhpMcp\Server\JsonRpc\Error( - self::CODE_INTERNAL_ERROR, - 'Transport layer error: '.$this->getMessage(), - null + return new JsonRpcError( + jsonrpc: '2.0', + id: $id, + code: JsonRpcError::CODE_INTERNAL_ERROR, + message: 'Transport layer error: ' . $this->getMessage(), + data: null ); } } diff --git a/src/JsonRpc/Contents/AudioContent.php b/src/JsonRpc/Contents/AudioContent.php index 8730503..d925624 100644 --- a/src/JsonRpc/Contents/AudioContent.php +++ b/src/JsonRpc/Contents/AudioContent.php @@ -2,12 +2,13 @@ namespace PhpMcp\Server\JsonRpc\Contents; +use PhpMcp\Server\JsonRpc\Contracts\ContentInterface; use PhpMcp\Server\Model\Annotations; /** * Represents audio content in MCP. */ -class AudioContent extends Content +class AudioContent implements ContentInterface { /** * Create a new AudioContent instance. @@ -17,43 +18,11 @@ class AudioContent extends Content * @param ?Annotations $annotations Optional annotations describing the content */ public function __construct( - protected string $data, - protected string $mimeType, - protected ?Annotations $annotations = null + public readonly string $data, + public readonly string $mimeType, + public readonly ?Annotations $annotations = null ) {} - /** - * Get the audio data. - */ - public function getData(): string - { - return $this->data; - } - - /** - * Get the MIME type. - */ - public function getMimeType(): string - { - return $this->mimeType; - } - - /** - * Get the annotations. - */ - public function getAnnotations(): ?Annotations - { - return $this->annotations; - } - - /** - * Get the content type. - */ - public function getType(): string - { - return 'audio'; - } - /** * Convert the content to an array. * @@ -74,6 +43,11 @@ public function toArray(): array return $result; } + public function jsonSerialize(): array + { + return $this->toArray(); + } + /** * Create a new AudioContent from a file path. * diff --git a/src/JsonRpc/Contents/Content.php b/src/JsonRpc/Contents/Content.php deleted file mode 100644 index bc0a6a5..0000000 --- a/src/JsonRpc/Contents/Content.php +++ /dev/null @@ -1,21 +0,0 @@ -toArray(); - } -} diff --git a/src/JsonRpc/Contents/ImageContent.php b/src/JsonRpc/Contents/ImageContent.php index 2059c99..3cd612a 100644 --- a/src/JsonRpc/Contents/ImageContent.php +++ b/src/JsonRpc/Contents/ImageContent.php @@ -2,10 +2,12 @@ namespace PhpMcp\Server\JsonRpc\Contents; +use PhpMcp\Server\JsonRpc\Contracts\ContentInterface; + /** * Represents image content in MCP. */ -class ImageContent extends Content +class ImageContent implements ContentInterface { /** * Create a new ImageContent instance. @@ -14,34 +16,10 @@ class ImageContent extends Content * @param string $mimeType The MIME type of the image */ public function __construct( - protected string $data, - protected string $mimeType - ) { - } + public readonly string $data, + public readonly string $mimeType + ) {} - /** - * Get the image data. - */ - public function getData(): string - { - return $this->data; - } - - /** - * Get the MIME type. - */ - public function getMimeType(): string - { - return $this->mimeType; - } - - /** - * Get the content type. - */ - public function getType(): string - { - return 'image'; - } /** * Convert the content to an array. @@ -57,6 +35,11 @@ public function toArray(): array ]; } + public function jsonSerialize(): array + { + return $this->toArray(); + } + /** * Create a new ImageContent from a file path. * diff --git a/src/JsonRpc/Contents/PromptMessage.php b/src/JsonRpc/Contents/PromptMessage.php index 6ecb514..946f67a 100644 --- a/src/JsonRpc/Contents/PromptMessage.php +++ b/src/JsonRpc/Contents/PromptMessage.php @@ -28,7 +28,7 @@ public function __construct( public function toArray(): array { return [ - 'role' => $this->role, + 'role' => $this->role->value, 'content' => $this->content->toArray(), ]; } @@ -58,7 +58,7 @@ public static function assistant(string $text): static * * @param Content $content The message content */ - public static function userWithContent(Content $content): static + public static function userWithContent(TextContent|ImageContent|AudioContent|EmbeddedResource $content): static { return new static(Role::User, $content); } @@ -68,7 +68,7 @@ public static function userWithContent(Content $content): static * * @param Content $content The message content */ - public static function assistantWithContent(Content $content): static + public static function assistantWithContent(TextContent|ImageContent|AudioContent|EmbeddedResource $content): static { return new static(Role::Assistant, $content); } diff --git a/src/JsonRpc/Contents/ResourceContent.php b/src/JsonRpc/Contents/ResourceContent.php index 38318f2..05746d1 100644 --- a/src/JsonRpc/Contents/ResourceContent.php +++ b/src/JsonRpc/Contents/ResourceContent.php @@ -2,10 +2,12 @@ namespace PhpMcp\Server\JsonRpc\Contents; +use PhpMcp\Server\JsonRpc\Contracts\ContentInterface; + /** * Represents embedded resource content in MCP. */ -class ResourceContent extends Content +class ResourceContent implements ContentInterface { /** @@ -22,7 +24,6 @@ public function __construct( public readonly ?string $text = null, public readonly ?string $blob = null ) { - // Validate that either text or blob is provided, but not both if (($text === null && $blob === null) || ($text !== null && $blob !== null)) { throw new \InvalidArgumentException('Either text OR blob must be provided for a resource.'); } @@ -50,6 +51,11 @@ public function toArray(): array return $resource; } + public function jsonSerialize(): array + { + return $this->toArray(); + } + /** * Determines if the given MIME type is likely to be text-based. * diff --git a/src/JsonRpc/Contents/TextContent.php b/src/JsonRpc/Contents/TextContent.php index ad651c8..3d33515 100644 --- a/src/JsonRpc/Contents/TextContent.php +++ b/src/JsonRpc/Contents/TextContent.php @@ -2,12 +2,13 @@ namespace PhpMcp\Server\JsonRpc\Contents; +use PhpMcp\Server\JsonRpc\Contracts\ContentInterface; use PhpMcp\Server\Model\Annotations; /** * Represents text content in MCP. */ -class TextContent extends Content +class TextContent implements ContentInterface { /** * Create a new TextContent instance. @@ -16,34 +17,10 @@ class TextContent extends Content * @param ?Annotations $annotations Optional annotations describing the content */ public function __construct( - protected string $text, - protected ?Annotations $annotations = null + public readonly string $text, + public readonly ?Annotations $annotations = null ) {} - /** - * Get the text content. - */ - public function getText(): string - { - return $this->text; - } - - /** - * Get the annotations. - */ - public function getAnnotations(): ?Annotations - { - return $this->annotations; - } - - /** - * Get the content type. - */ - public function getType(): string - { - return 'text'; - } - /** * Convert the content to an array. * @@ -63,6 +40,11 @@ public function toArray(): array return $result; } + public function jsonSerialize(): array + { + return $this->toArray(); + } + /** * Create a new TextContent from any simple value. * diff --git a/src/JsonRpc/Contracts/ContentInterface.php b/src/JsonRpc/Contracts/ContentInterface.php new file mode 100644 index 0000000..a2187d3 --- /dev/null +++ b/src/JsonRpc/Contracts/ContentInterface.php @@ -0,0 +1,12 @@ + $this->code, - 'message' => $this->message, - ]; - - if ($this->data !== null) { - $result['data'] = $this->data; - } - - return $result; - } -} diff --git a/src/JsonRpc/Message.php b/src/JsonRpc/Message.php deleted file mode 100644 index 70e75f2..0000000 --- a/src/JsonRpc/Message.php +++ /dev/null @@ -1,25 +0,0 @@ - $this->jsonrpc, - ]; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } -} diff --git a/src/JsonRpc/Batch.php b/src/JsonRpc/Messages/BatchRequest.php similarity index 62% rename from src/JsonRpc/Batch.php rename to src/JsonRpc/Messages/BatchRequest.php index 988bb4d..86ea4a1 100644 --- a/src/JsonRpc/Batch.php +++ b/src/JsonRpc/Messages/BatchRequest.php @@ -1,10 +1,13 @@ addRequest($request); + $this->add($request); } } + public function getId(): string|int|null + { + return null; + } + /** * Create a Batch object from an array representation. * @@ -45,11 +53,10 @@ public static function fromArray(array $data): self throw ProtocolException::invalidRequest('Each item in a batch must be a valid JSON-RPC object.'); } - // Determine if the item is a notification (no id) or a request if (! isset($item['id'])) { - $batch->addRequest(Notification::fromArray($item)); + $batch->add(Notification::fromArray($item)); } else { - $batch->addRequest(Request::fromArray($item)); + $batch->add(Request::fromArray($item)); } } @@ -61,7 +68,7 @@ public static function fromArray(array $data): self * * @param Request|Notification $request The request to add */ - public function addRequest(Request|Notification $request): self + public function add(Request|Notification $request): self { $this->requests[] = $request; @@ -73,7 +80,7 @@ public function addRequest(Request|Notification $request): self * * @return array */ - public function getRequests(): array + public function all(): array { return $this->requests; } @@ -83,9 +90,9 @@ public function getRequests(): array * * @return array */ - public function getRequestsWithIds(): array + public function getRequests(): array { - return array_filter($this->requests, fn ($r) => ! $r instanceof Notification); + return array_filter($this->requests, fn($r) => ! $r instanceof Notification); } /** @@ -95,7 +102,32 @@ public function getRequestsWithIds(): array */ public function getNotifications(): array { - return array_filter($this->requests, fn ($r) => $r instanceof Notification); + return array_filter($this->requests, fn($r) => $r instanceof Notification); + } + + public function hasRequests(): bool + { + $hasRequests = false; + foreach ($this->requests as $request) { + if ($request instanceof Request) { + $hasRequests = true; + break; + } + } + + return $hasRequests; + } + + public function hasNotifications(): bool + { + $hasNotifications = false; + foreach ($this->requests as $request) { + if ($request instanceof Notification) { + $hasNotifications = true; + break; + } + } + return $hasNotifications; } /** @@ -111,6 +143,6 @@ public function count(): int */ public function toArray(): array { - return array_map(fn ($r) => $r->toArray(), $this->requests); + return array_map(fn($r) => $r->toArray(), $this->requests); } } diff --git a/src/JsonRpc/Messages/BatchResponse.php b/src/JsonRpc/Messages/BatchResponse.php new file mode 100644 index 0000000..8897c8b --- /dev/null +++ b/src/JsonRpc/Messages/BatchResponse.php @@ -0,0 +1,109 @@ + + */ + private array $responses = []; + + /** + * Create a new JSON-RPC 2.0 batch of requests/notifications. + * + * @param array $responses Optional array of responses to initialize with + */ + public function __construct(array $responses = []) + { + foreach ($responses as $response) { + $this->add($response); + } + } + + public function getId(): string|int|null + { + return null; + } + + public static function fromArray(array $data): self + { + $batch = new self(); + + foreach ($data as $response) { + $batch->add(Message::parseResponse($response)); + } + + return $batch; + } + + /** + * Add a response to the batch. + * + * @param Response|Error $response The response to add + */ + public function add(Response|Error $response): self + { + $this->responses[] = $response; + + return $this; + } + + /** + * Get all requests in this batch. + * + * @return array + */ + public function all(): array + { + return $this->responses; + } + + /** + * Get only the requests with IDs (excludes notifications). + * + * @return array + */ + public function getResponses(): array + { + return array_filter($this->responses, fn($r) => $r instanceof Response); + } + + /** + * Get only the notifications (requests without IDs). + * + * @return array + */ + public function getErrors(): array + { + return array_filter($this->responses, fn($r) => $r instanceof Error); + } + + public function isEmpty(): bool + { + return empty($this->responses); + } + + /** + * Count the total number of requests in this batch. + */ + public function count(): int + { + return count($this->responses); + } + + /** + * Convert the batch to an array. + */ + public function toArray(): array + { + return array_map(fn($r) => $r->toArray(), $this->responses); + } +} diff --git a/src/JsonRpc/Messages/Error.php b/src/JsonRpc/Messages/Error.php new file mode 100644 index 0000000..7a1cdd5 --- /dev/null +++ b/src/JsonRpc/Messages/Error.php @@ -0,0 +1,104 @@ +id; + } + + public static function parseError(string $message): self + { + return new self( + jsonrpc: '2.0', + id: '0', + code: self::CODE_PARSE_ERROR, + message: $message, + data: null, + ); + } + + public static function invalidRequest(string $message): self + { + return new self( + jsonrpc: '2.0', + id: '0', + code: self::CODE_INVALID_REQUEST, + message: $message, + data: null, + ); + } + + /** + * Create an Error object from an array representation. + * + * @param array $data Raw decoded JSON-RPC error data + */ + public static function fromArray(array $data): self + { + if (! isset($data['error']) || ! is_array($data['error'])) { + throw ProtocolException::invalidRequest('Invalid or missing "error" field.'); + } + + return new self( + jsonrpc: '2.0', + id: $data['id'] ?? null, + code: $data['error']['code'], + message: $data['error']['message'], + data: $data['error']['data'] ?? null + ); + } + + /** + * Convert the error to an array. + */ + public function toArray(): array + { + $error = [ + 'code' => $this->code, + 'message' => $this->message, + ]; + + if ($this->data !== null) { + $error['data'] = $this->data; + } + + return [ + 'jsonrpc' => $this->jsonrpc, + 'id' => $this->id, + 'error' => $error, + ]; + } +} diff --git a/src/JsonRpc/Messages/Message.php b/src/JsonRpc/Messages/Message.php new file mode 100644 index 0000000..697e06c --- /dev/null +++ b/src/JsonRpc/Messages/Message.php @@ -0,0 +1,60 @@ +toArray(); + } + + public static function parseRequest(string $message): Request|Notification|BatchRequest + { + $messageData = json_decode($message, true, 512, JSON_THROW_ON_ERROR); + + $isBatch = array_is_list($messageData) && count($messageData) > 0 && is_array($messageData[0] ?? null); + + if ($isBatch) { + return BatchRequest::fromArray($messageData); + } elseif (isset($messageData['method'])) { + if (isset($messageData['id']) && $messageData['id'] !== null) { + return Request::fromArray($messageData); + } else { + return Notification::fromArray($messageData); + } + } + + throw new McpServerException('Invalid JSON-RPC message'); + } + + public static function parseResponse(string $message): Response|Error|BatchResponse + { + $messageData = json_decode($message, true, 512, JSON_THROW_ON_ERROR); + + $isBatch = array_is_list($messageData) && count($messageData) > 0 && is_array($messageData[0] ?? null); + + if ($isBatch) { + return BatchResponse::fromArray($messageData); + } elseif (isset($messageData['id']) && $messageData['id'] !== null) { + return Response::fromArray($messageData); + } else { + return Error::fromArray($messageData); + } + } +} diff --git a/src/JsonRpc/Notification.php b/src/JsonRpc/Messages/Notification.php similarity index 92% rename from src/JsonRpc/Notification.php rename to src/JsonRpc/Messages/Notification.php index cea9ceb..840e4fb 100644 --- a/src/JsonRpc/Notification.php +++ b/src/JsonRpc/Messages/Notification.php @@ -1,6 +1,6 @@ id; } /** @@ -31,22 +39,18 @@ public function __construct( */ public static function fromArray(array $data): self { - // Validate JSON-RPC 2.0 if (! isset($data['jsonrpc']) || $data['jsonrpc'] !== '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 ProtocolException::invalidRequest('Invalid or missing "method" field.'); } - // Validate ID if (! isset($data['id'])) { throw ProtocolException::invalidRequest('Invalid or missing "id" field.'); } - // Check params if present (optional) $params = []; if (isset($data['params'])) { if (! is_array($data['params'])) { diff --git a/src/JsonRpc/Messages/Response.php b/src/JsonRpc/Messages/Response.php new file mode 100644 index 0000000..5f1b9a9 --- /dev/null +++ b/src/JsonRpc/Messages/Response.php @@ -0,0 +1,85 @@ +result === null) { + throw new \InvalidArgumentException('A JSON-RPC response with an ID must have a valid result.'); + } + } + + public function getId(): string|int + { + return $this->id; + } + + /** + * Create a Response object from an array representation. + * + * @param array $data Raw decoded JSON-RPC response data + * + * @throws ProtocolException If the data doesn't conform to JSON-RPC 2.0 structure + */ + public static function fromArray(array $data): self + { + if (! isset($data['jsonrpc']) || $data['jsonrpc'] !== '2.0') { + throw new ProtocolException('Invalid or missing "jsonrpc" version. Must be "2.0".'); + } + + $id = $data['id'] ?? null; + if (! (is_string($id) || is_int($id) || $id === null)) { + throw new ProtocolException('Invalid "id" field type in response.'); + } + + $result = $data['result']; + + try { + return new self('2.0', $id, $result); + } catch (\InvalidArgumentException $e) { + throw new ProtocolException('Invalid response structure: ' . $e->getMessage()); + } + } + + /** + * Create a successful response. + * + * @param T $result Method result + * @param mixed $id Request ID + */ + public static function make(mixed $result, string|int $id): self + { + return new self(jsonrpc: '2.0', result: $result, id: $id); + } + + /** + * Convert the response to an array. + */ + public function toArray(): array + { + return [ + 'jsonrpc' => $this->jsonrpc, + 'id' => $this->id, + 'result' => is_array($this->result) ? $this->result : $this->result->toArray(), + ]; + } +} diff --git a/src/JsonRpc/Response.php b/src/JsonRpc/Response.php deleted file mode 100644 index 6cea195..0000000 --- a/src/JsonRpc/Response.php +++ /dev/null @@ -1,175 +0,0 @@ -id !== null && $this->result !== null && $this->error !== null) { - throw new \InvalidArgumentException('A JSON-RPC response with an ID cannot have both result and error.'); - } - - // A response with an ID MUST have either result or error - if ($this->id !== null && $this->result === null && $this->error === null) { - throw new \InvalidArgumentException('A JSON-RPC response with an ID must have either result or error.'); - } - - // A response with null ID MUST have an error and MUST NOT have result - if ($this->id === null && $this->error === null) { - throw new \InvalidArgumentException('A JSON-RPC response with null ID must have an error object.'); - } - - if ($this->id === null && $this->result !== null) { - throw new \InvalidArgumentException('A JSON-RPC response with null ID cannot have a result field.'); - } - } - - /** - * Create a Response object from an array representation. - * - * @param array $data Raw decoded JSON-RPC response data - * - * @throws ProtocolException If the data doesn't conform to JSON-RPC 2.0 structure - */ - public static function fromArray(array $data): self - { - if (! isset($data['jsonrpc']) || $data['jsonrpc'] !== '2.0') { - throw new ProtocolException('Invalid or missing "jsonrpc" version. Must be "2.0".'); - } - - // ID must exist for valid responses, but can be null for specific error cases - // We rely on the constructor validation logic for the result/error/id combinations - $id = $data['id'] ?? null; // Default to null if missing - if (! (is_string($id) || is_int($id) || $id === null)) { - throw new ProtocolException('Invalid "id" field type in response.'); - } - - $hasResult = array_key_exists('result', $data); - $hasError = array_key_exists('error', $data); - - if ($id !== null) { // If ID is present, standard validation applies - if ($hasResult && $hasError) { - throw new ProtocolException('Invalid response: contains both "result" and "error".'); - } - if (! $hasResult && ! $hasError) { - throw new ProtocolException('Invalid response: must contain either "result" or "error" when ID is present.'); - } - } else { // If ID is null, error MUST be present, result MUST NOT - if (! $hasError) { - throw new ProtocolException('Invalid response: must contain "error" when ID is null.'); - } - if ($hasResult) { - throw new ProtocolException('Invalid response: must not contain "result" when ID is null.'); - } - } - - $error = null; - $result = null; // Keep result structure flexible (any JSON type) - - if ($hasError) { - if (! is_array($data['error'])) { // Error MUST be an object - throw new ProtocolException('Invalid "error" field in response: must be an object.'); - } - try { - $error = Error::fromArray($data['error']); - } catch (ProtocolException $e) { - // Wrap error from Error::fromArray for context - throw new ProtocolException('Invalid "error" object structure in response: '.$e->getMessage(), 0, $e); - } - } elseif ($hasResult) { - $result = $data['result']; // Result can be anything - } - - try { - // The constructor now handles the final validation of id/result/error combinations - return new self('2.0', $id, $result, $error); - } catch (\InvalidArgumentException $e) { - // Convert constructor validation error to ProtocolException - throw new ProtocolException('Invalid response structure: '.$e->getMessage()); - } - } - - /** - * Create a successful response. - * - * @param Result $result Method result - can be a Result object or array - * @param mixed $id Request ID - */ - public static function success(Result $result, mixed $id): self - { - return new self(jsonrpc: '2.0', result: $result, id: $id); - } - - /** - * Create an error response. - * - * @param Error $error Error object - * @param string|int|null $id Request ID (can be null for parse errors) - */ - public static function error(Error $error, string|int|null $id): self - { - return new self(jsonrpc: '2.0', error: $error, id: $id); - } - - /** - * Check if this response is a success response. - */ - public function isSuccess(): bool - { - return $this->error === null; - } - - /** - * Check if this response is an error response. - */ - public function isError(): bool - { - return $this->error !== null; - } - - /** - * Convert the response to an array. - */ - public function toArray(): array - { - $result = [ - 'jsonrpc' => $this->jsonrpc, - 'id' => $this->id, - ]; - - if ($this->isSuccess()) { - $result['result'] = is_array($this->result) ? $this->result : $this->result->toArray(); - } else { - $result['error'] = $this->error->toArray(); - } - - return $result; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } -} diff --git a/src/JsonRpc/Result.php b/src/JsonRpc/Result.php deleted file mode 100644 index bb1effd..0000000 --- a/src/JsonRpc/Result.php +++ /dev/null @@ -1,26 +0,0 @@ -toArray(); - } - - /** - * Convert the result object to an array. - * - * @return array - */ - abstract public function toArray(): array; -} diff --git a/src/JsonRpc/Results/CallToolResult.php b/src/JsonRpc/Results/CallToolResult.php index 37f1fe3..9b50a01 100644 --- a/src/JsonRpc/Results/CallToolResult.php +++ b/src/JsonRpc/Results/CallToolResult.php @@ -2,10 +2,9 @@ namespace PhpMcp\Server\JsonRpc\Results; -use PhpMcp\Server\JsonRpc\Contents\Content; -use PhpMcp\Server\JsonRpc\Result; +use PhpMcp\Server\JsonRpc\Contracts\ResultInterface; -class CallToolResult extends Result +class CallToolResult implements ResultInterface { /** * Create a new CallToolResult. @@ -14,28 +13,9 @@ class CallToolResult extends Result * @param bool $isError Whether the tool execution resulted in an error */ public function __construct( - protected array $content, - protected bool $isError = false - ) { - } - - /** - * Get the content of the tool result. - * - * @return Content[] - */ - public function getContent(): array - { - return $this->content; - } - - /** - * Check if the tool execution resulted in an error. - */ - public function isError(): bool - { - return $this->isError; - } + public readonly array $content, + public readonly bool $isError = false + ) {} /** * Convert the result to an array. @@ -43,8 +23,13 @@ public function isError(): bool public function toArray(): array { return [ - 'content' => array_map(fn ($item) => $item->toArray(), $this->content), + 'content' => array_map(fn($item) => $item->toArray(), $this->content), 'isError' => $this->isError, ]; } + + public function jsonSerialize(): array + { + return $this->toArray(); + } } diff --git a/src/JsonRpc/Results/EmptyResult.php b/src/JsonRpc/Results/EmptyResult.php index 0d650ae..2e6aed6 100644 --- a/src/JsonRpc/Results/EmptyResult.php +++ b/src/JsonRpc/Results/EmptyResult.php @@ -2,19 +2,17 @@ namespace PhpMcp\Server\JsonRpc\Results; -use PhpMcp\Server\JsonRpc\Result; +use PhpMcp\Server\JsonRpc\Contracts\ResultInterface; /** * A generic empty result for methods that return an empty object */ -class EmptyResult extends Result +class EmptyResult implements ResultInterface { /** * Create a new EmptyResult. */ - public function __construct() - { - } + public function __construct() {} /** * Convert the result to an array. @@ -23,4 +21,9 @@ public function toArray(): array { return []; // Empty result object } + + public function jsonSerialize(): array + { + return $this->toArray(); + } } diff --git a/src/JsonRpc/Results/GetPromptResult.php b/src/JsonRpc/Results/GetPromptResult.php index a966842..39e5db7 100644 --- a/src/JsonRpc/Results/GetPromptResult.php +++ b/src/JsonRpc/Results/GetPromptResult.php @@ -3,9 +3,9 @@ namespace PhpMcp\Server\JsonRpc\Results; use PhpMcp\Server\JsonRpc\Contents\PromptMessage; -use PhpMcp\Server\JsonRpc\Result; +use PhpMcp\Server\JsonRpc\Contracts\ResultInterface; -class GetPromptResult extends Result +class GetPromptResult implements ResultInterface { /** * Create a new GetPromptResult. @@ -14,28 +14,9 @@ class GetPromptResult extends Result * @param string|null $description Optional description of the prompt */ public function __construct( - protected array $messages, - protected ?string $description = null - ) { - } - - /** - * Get the messages in the prompt. - * - * @return PromptMessage[] - */ - public function getMessages(): array - { - return $this->messages; - } - - /** - * Get the description of the prompt. - */ - public function getDescription(): ?string - { - return $this->description; - } + public readonly array $messages, + public readonly ?string $description = null + ) {} /** * Convert the result to an array. @@ -43,7 +24,7 @@ public function getDescription(): ?string public function toArray(): array { $result = [ - 'messages' => array_map(fn ($message) => $message->toArray(), $this->messages), + 'messages' => array_map(fn($message) => $message->toArray(), $this->messages), ]; if ($this->description !== null) { @@ -52,4 +33,9 @@ public function toArray(): array return $result; } + + public function jsonSerialize(): array + { + return $this->toArray(); + } } diff --git a/src/JsonRpc/Results/InitializeResult.php b/src/JsonRpc/Results/InitializeResult.php index 39d08fe..a865c4c 100644 --- a/src/JsonRpc/Results/InitializeResult.php +++ b/src/JsonRpc/Results/InitializeResult.php @@ -2,9 +2,9 @@ namespace PhpMcp\Server\JsonRpc\Results; -use PhpMcp\Server\JsonRpc\Result; +use PhpMcp\Server\JsonRpc\Contracts\ResultInterface; -class InitializeResult extends Result +class InitializeResult implements ResultInterface { /** * Create a new InitializeResult. @@ -19,8 +19,7 @@ public function __construct( public readonly string $protocolVersion, public readonly array $capabilities, public readonly ?string $instructions = null - ) { - } + ) {} /** * Convert the result to an array. @@ -39,4 +38,9 @@ public function toArray(): array return $result; } + + public function jsonSerialize(): array + { + return $this->toArray(); + } } diff --git a/src/JsonRpc/Results/ListPromptsResult.php b/src/JsonRpc/Results/ListPromptsResult.php index 212354c..22ab879 100644 --- a/src/JsonRpc/Results/ListPromptsResult.php +++ b/src/JsonRpc/Results/ListPromptsResult.php @@ -3,9 +3,9 @@ namespace PhpMcp\Server\JsonRpc\Results; use PhpMcp\Server\Definitions\PromptDefinition; -use PhpMcp\Server\JsonRpc\Result; +use PhpMcp\Server\JsonRpc\Contracts\ResultInterface; -class ListPromptsResult extends Result +class ListPromptsResult implements ResultInterface { /** * @param array $prompts The list of prompt definitions. @@ -14,13 +14,12 @@ class ListPromptsResult extends Result public function __construct( public readonly array $prompts, public readonly ?string $nextCursor = null - ) { - } + ) {} public function toArray(): array { $result = [ - 'prompts' => array_map(fn (PromptDefinition $p) => $p->toArray(), $this->prompts), + 'prompts' => array_map(fn(PromptDefinition $p) => $p->toArray(), $this->prompts), ]; if ($this->nextCursor) { @@ -29,4 +28,9 @@ public function toArray(): array return $result; } + + public function jsonSerialize(): array + { + return $this->toArray(); + } } diff --git a/src/JsonRpc/Results/ListResourceTemplatesResult.php b/src/JsonRpc/Results/ListResourceTemplatesResult.php index 3494c62..1d3c04b 100644 --- a/src/JsonRpc/Results/ListResourceTemplatesResult.php +++ b/src/JsonRpc/Results/ListResourceTemplatesResult.php @@ -3,9 +3,9 @@ namespace PhpMcp\Server\JsonRpc\Results; use PhpMcp\Server\Definitions\ResourceTemplateDefinition; -use PhpMcp\Server\JsonRpc\Result; +use PhpMcp\Server\JsonRpc\Contracts\ResultInterface; -class ListResourceTemplatesResult extends Result +class ListResourceTemplatesResult implements ResultInterface { /** * @param array $resourceTemplates The list of resource template definitions. @@ -14,8 +14,7 @@ class ListResourceTemplatesResult extends Result public function __construct( public readonly array $resourceTemplates, public readonly ?string $nextCursor = null - ) { - } + ) {} /** * Convert the result to an array. @@ -23,7 +22,7 @@ public function __construct( public function toArray(): array { $result = [ - 'resourceTemplates' => array_map(fn (ResourceTemplateDefinition $t) => $t->toArray(), $this->resourceTemplates), + 'resourceTemplates' => array_map(fn(ResourceTemplateDefinition $t) => $t->toArray(), $this->resourceTemplates), ]; if ($this->nextCursor) { @@ -32,4 +31,9 @@ public function toArray(): array return $result; } + + public function jsonSerialize(): array + { + return $this->toArray(); + } } diff --git a/src/JsonRpc/Results/ListResourcesResult.php b/src/JsonRpc/Results/ListResourcesResult.php index 4e29368..cb140ec 100644 --- a/src/JsonRpc/Results/ListResourcesResult.php +++ b/src/JsonRpc/Results/ListResourcesResult.php @@ -3,9 +3,9 @@ namespace PhpMcp\Server\JsonRpc\Results; use PhpMcp\Server\Definitions\ResourceDefinition; -use PhpMcp\Server\JsonRpc\Result; +use PhpMcp\Server\JsonRpc\Contracts\ResultInterface; -class ListResourcesResult extends Result +class ListResourcesResult implements ResultInterface { /** * @param array $resources The list of resource definitions. @@ -14,8 +14,7 @@ class ListResourcesResult extends Result public function __construct( public readonly array $resources, public readonly ?string $nextCursor = null - ) { - } + ) {} /** * Convert the result to an array. @@ -23,7 +22,7 @@ public function __construct( public function toArray(): array { $result = [ - 'resources' => array_map(fn (ResourceDefinition $r) => $r->toArray(), $this->resources), + 'resources' => array_map(fn(ResourceDefinition $r) => $r->toArray(), $this->resources), ]; if ($this->nextCursor !== null) { @@ -32,4 +31,9 @@ public function toArray(): array return $result; } + + public function jsonSerialize(): array + { + return $this->toArray(); + } } diff --git a/src/JsonRpc/Results/ListToolsResult.php b/src/JsonRpc/Results/ListToolsResult.php index 90404ee..0ef51ce 100644 --- a/src/JsonRpc/Results/ListToolsResult.php +++ b/src/JsonRpc/Results/ListToolsResult.php @@ -3,9 +3,9 @@ namespace PhpMcp\Server\JsonRpc\Results; use PhpMcp\Server\Definitions\ToolDefinition; -use PhpMcp\Server\JsonRpc\Result; +use PhpMcp\Server\JsonRpc\Contracts\ResultInterface; -class ListToolsResult extends Result +class ListToolsResult implements ResultInterface { /** * @param array $tools The list of tool definitions. @@ -14,13 +14,12 @@ class ListToolsResult extends Result public function __construct( public readonly array $tools, public readonly ?string $nextCursor = null - ) { - } + ) {} public function toArray(): array { $result = [ - 'tools' => array_map(fn (ToolDefinition $t) => $t->toArray(), $this->tools), + 'tools' => array_map(fn(ToolDefinition $t) => $t->toArray(), $this->tools), ]; if ($this->nextCursor) { @@ -29,4 +28,9 @@ public function toArray(): array return $result; } + + public function jsonSerialize(): array + { + return $this->toArray(); + } } diff --git a/src/JsonRpc/Results/ReadResourceResult.php b/src/JsonRpc/Results/ReadResourceResult.php index 7aebe01..8ae92d5 100644 --- a/src/JsonRpc/Results/ReadResourceResult.php +++ b/src/JsonRpc/Results/ReadResourceResult.php @@ -3,9 +3,9 @@ namespace PhpMcp\Server\JsonRpc\Results; use PhpMcp\Server\JsonRpc\Contents\ResourceContent; -use PhpMcp\Server\JsonRpc\Result; +use PhpMcp\Server\JsonRpc\Contracts\ResultInterface; -class ReadResourceResult extends Result +class ReadResourceResult implements ResultInterface { /** * Create a new ReadResourceResult. @@ -13,18 +13,9 @@ class ReadResourceResult extends Result * @param ResourceContent[] $contents The contents of the resource */ public function __construct( - protected array $contents + public readonly array $contents ) {} - /** - * Get the contents of the resource. - * - * @return ResourceContent[] - */ - public function getContents(): array - { - return $this->contents; - } /** * Convert the result to an array. @@ -35,4 +26,9 @@ public function toArray(): array 'contents' => array_map(fn($resource) => $resource->toArray(), $this->contents), ]; } + + public function jsonSerialize(): array + { + return $this->toArray(); + } } diff --git a/src/Protocol.php b/src/Protocol.php index d31e934..e45453f 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -6,12 +6,12 @@ use JsonException; use PhpMcp\Server\Contracts\ServerTransportInterface; -use PhpMcp\Server\Exception\McpServerException; -use PhpMcp\Server\Exception\ProtocolException; -use PhpMcp\Server\JsonRpc\Batch; -use PhpMcp\Server\JsonRpc\Notification; -use PhpMcp\Server\JsonRpc\Request; -use PhpMcp\Server\JsonRpc\Response; +use PhpMcp\Server\JsonRpc\Messages\BatchRequest; +use PhpMcp\Server\JsonRpc\Messages\BatchResponse; +use PhpMcp\Server\JsonRpc\Messages\Error; +use PhpMcp\Server\JsonRpc\Messages\Notification; +use PhpMcp\Server\JsonRpc\Messages\Request; +use PhpMcp\Server\JsonRpc\Messages\Response; use PhpMcp\Server\Support\RequestProcessor; use Psr\Log\LoggerInterface; use Throwable; @@ -48,7 +48,7 @@ public function bindTransport(ServerTransportInterface $transport): void $this->transport = $transport; $this->listeners = [ - 'message' => [$this, 'handleMessage'], + 'message' => [$this, 'processMessage'], 'client_connected' => [$this, 'handleClientConnected'], 'client_disconnected' => [$this, 'handleClientDisconnected'], 'error' => [$this, 'handleTransportError'], @@ -77,228 +77,66 @@ public function unbindTransport(): void } /** - * Handles a raw message frame received from the transport. + * Handles a message received from the transport. * - * Parses JSON, validates structure, processes via Processor, sends Response/Error. + * Processes via Processor, sends Response/Error. */ - public function handleMessage(string $rawJsonRpcFrame, string $sessionId): void + public function processMessage(Request|Notification|BatchRequest $message, string $sessionId, array $context = []): void { - $this->logger->debug('Received message', ['sessionId' => $sessionId, 'frame' => $rawJsonRpcFrame]); + $this->logger->debug('Message received.', ['sessionId' => $sessionId, 'message' => $message]); + $processedPayload = null; - try { - $messageData = $this->decodeJsonMessage($rawJsonRpcFrame); - - $message = $this->parseMessage($messageData); - - $response = $this->processMessage($message, $sessionId); - - if ($response) { - $this->sendResponse($sessionId, $response); - } - } catch (JsonException $e) { - $this->handleJsonParseError($e, $sessionId); - } catch (ProtocolException $e) { - $this->handleProtocolError($e, $sessionId); - } catch (Throwable $e) { - $this->handleUnexpectedError($e, $sessionId); - } - } - - /** - * Decodes a raw JSON message string into an array - */ - private function decodeJsonMessage(string $rawJsonRpcFrame): array - { - $messageData = json_decode($rawJsonRpcFrame, true, 512, JSON_THROW_ON_ERROR); - - if (!is_array($messageData)) { - throw ProtocolException::invalidRequest('Invalid JSON-RPC message: payload is not a JSON object or array.'); + if ($message instanceof BatchRequest) { + $processedPayload = $this->processBatchRequest($message, $sessionId); + } elseif ($message instanceof Request) { + $processedPayload = $this->processRequest($message, $sessionId); + } elseif ($message instanceof Notification) { + $this->processNotification($message, $sessionId); } - return $messageData; - } - - /** - * Parses message data into Request, Notification, Batch, or throws an exception - */ - private function parseMessage(array $messageData): Request|Notification|Batch - { - $isBatch = array_is_list($messageData) && count($messageData) > 0 && is_array($messageData[0] ?? null); - - if ($isBatch) { - return Batch::fromArray($messageData); - } elseif (isset($messageData['method'])) { - if (isset($messageData['id']) && $messageData['id'] !== null) { - return Request::fromArray($messageData); - } else { - return Notification::fromArray($messageData); - } - } - - throw McpServerException::invalidRequest("Message must contain a 'method' field."); - } - - /** - * Process a message based on its type - */ - private function processMessage(Request|Notification|Batch $message, string $sessionId): ?string - { - return match (true) { - $message instanceof Batch => $this->processBatchMessage($message, $sessionId), - $message instanceof Request => $this->processRequestMessage($message, $sessionId), - $message instanceof Notification => $this->processNotificationMessage($message, $sessionId), - }; + $this->transport->sendMessage($processedPayload, $sessionId, $context) + ->then(function () use ($sessionId, $processedPayload) { + $this->logger->debug('Message sent.', ['sessionId' => $sessionId, 'payload' => $processedPayload]); + }) + ->catch(function (Throwable $e) use ($sessionId, $processedPayload) { + $this->logger->error('Message send failed.', ['sessionId' => $sessionId, 'error' => $e->getMessage()]); + }); } /** * Process a batch message */ - private function processBatchMessage(Batch $batch, string $sessionId): ?string + private function processBatchRequest(BatchRequest $batch, string $sessionId): BatchResponse { - $responsesToSend = []; - - foreach ($batch->getRequests() as $item) { - try { - if ($item instanceof Request) { - $itemResponse = $this->requestProcessor->process($item, $sessionId); + $batchResponse = new BatchResponse(); - if ($itemResponse instanceof Response) { - $responsesToSend[] = $itemResponse; - } elseif ($itemResponse === null) { - $this->logger->error( - 'Processor failed to return a Response for a Request in batch', - ['sessionId' => $sessionId, 'method' => $item->method, 'id' => $item->id] - ); - $responsesToSend[] = Response::error( - McpServerException::internalError('Processing failed to generate a response for batch item.')->toJsonRpcError(), - $item->id - ); - } - } elseif ($item instanceof Notification) { - $this->requestProcessor->process($item, $sessionId); - } - } catch (McpServerException $e) { - $itemId = $item instanceof Request ? $item->id : null; - $responsesToSend[] = Response::error($e->toJsonRpcError(), $itemId); - } catch (Throwable $e) { - $this->logger->error("Unexpected processing error for batch item", ['sessionId' => $sessionId, 'exception' => $e]); - $itemId = $item instanceof Request ? $item->id : null; - $responsesToSend[] = Response::error( - McpServerException::internalError('Internal error processing batch item.')->toJsonRpcError(), - $itemId - ); - } + foreach ($batch->getNotifications() as $notification) { + $this->processNotification($notification, $sessionId); } - if (!empty($responsesToSend)) { - $batchResponseArray = array_map(fn(Response $r) => $r->toArray(), $responsesToSend); - return json_encode($batchResponseArray, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + foreach ($batch->getRequests() as $request) { + $response = $this->processRequest($request, $sessionId); + + $batchResponse->add($response); } - return null; + return $batchResponse; } /** * Process a request message */ - private function processRequestMessage(Request $request, string $sessionId): string + private function processRequest(Request $request, string $sessionId): Response|Error { - try { - $response = $this->requestProcessor->process($request, $sessionId); - - if ($response instanceof Response) { - return json_encode($response->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - } else { - $this->logger->error( - 'Processor failed to return a Response for a Request', - ['sessionId' => $sessionId, 'method' => $request->method, 'id' => $request->id] - ); - $errorResponse = Response::error( - McpServerException::internalError('Processing failed to generate a response.')->toJsonRpcError(), - $request->id - ); - return json_encode($errorResponse->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - } - } catch (McpServerException $e) { - $errorResponse = Response::error($e->toJsonRpcError(), $request->id); - return json_encode($errorResponse->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - } catch (Throwable $e) { - $this->logger->error("Unexpected error processing request", ['sessionId' => $sessionId, 'exception' => $e]); - $errorResponse = Response::error( - McpServerException::internalError('Internal error processing request.')->toJsonRpcError(), - $request->id - ); - return json_encode($errorResponse->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - } + return $this->requestProcessor->processRequest($request, $sessionId); } /** * Process a notification message */ - private function processNotificationMessage(Notification $notification, string $sessionId): ?string - { - try { - $this->requestProcessor->process($notification, $sessionId); - return null; - } catch (Throwable $e) { - $this->logger->error( - "Error processing notification", - ['sessionId' => $sessionId, 'method' => $notification->method, 'exception' => $e->getMessage()] - ); - return null; - } - } - - /** - * Handle a JSON parse error - */ - private function handleJsonParseError(JsonException $e, string $sessionId): void + private function processNotification(Notification $notification, string $sessionId): void { - $this->logger->error("JSON Parse Error", ['sessionId' => $sessionId, 'error' => $e->getMessage()]); - $responseToSend = Response::error(McpServerException::parseError($e->getMessage())->toJsonRpcError(), null); - $responseJson = json_encode($responseToSend->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $this->sendResponse($sessionId, $responseJson); - } - - /** - * Handle a protocol error - */ - private function handleProtocolError(ProtocolException $e, string $sessionId): void - { - $this->logger->error("Invalid JSON-RPC structure", ['sessionId' => $sessionId, 'error' => $e->getMessage()]); - $responseToSend = Response::error($e->toJsonRpcError(), null); - $responseJson = json_encode($responseToSend->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $this->sendResponse($sessionId, $responseJson); - } - - /** - * Handle an unexpected error - */ - private function handleUnexpectedError(Throwable $e, string $sessionId): void - { - $this->logger->error("Unexpected error", ['sessionId' => $sessionId, 'exception' => $e]); - $responseToSend = Response::error(McpServerException::internalError('Internal server error.')->toJsonRpcError(), null); - $responseJson = json_encode($responseToSend->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $this->sendResponse($sessionId, $responseJson); - } - - - private function sendResponse(string $sessionId, string $response): void - { - if ($this->transport === null) { - $this->logger->error('Cannot send response, there is no transport bound.', ['sessionId' => $sessionId]); - return; - } - - $framedMessage = $response . "\n"; - - $this->transport->sendToClientAsync($sessionId, $framedMessage) - ->then(function () use ($sessionId, $framedMessage) { - $this->logger->debug('Sent response', ['sessionId' => $sessionId, 'frame' => $framedMessage]); - }) - ->catch(function (Throwable $e) use ($sessionId) { - $this->logger->error('Transport failed to send response.', ['sessionId' => $sessionId, 'error' => $e->getMessage()]); - }); + $this->requestProcessor->processNotification($notification, $sessionId); } /** diff --git a/src/Registry.php b/src/Registry.php index 667a0bd..7534406 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -10,7 +10,7 @@ use PhpMcp\Server\Definitions\ResourceTemplateDefinition; use PhpMcp\Server\Definitions\ToolDefinition; use PhpMcp\Server\Exception\DefinitionException; -use PhpMcp\Server\JsonRpc\Notification; +use PhpMcp\Server\JsonRpc\Messages\Notification; use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Support\UriTemplateMatcher; use Psr\Log\LoggerInterface; @@ -79,10 +79,10 @@ public function discoveryRanOrCached(): bool /** 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; + return ! empty($this->tools->getArrayCopy()) + || ! empty($this->resources->getArrayCopy()) + || ! empty($this->prompts->getArrayCopy()) + || ! empty($this->resourceTemplates->getArrayCopy()); } private function initializeCollections(): void @@ -180,7 +180,7 @@ public function setPromptsChangedNotifier(?callable $notifier): void {} public function registerTool(ToolDefinition $tool, bool $isManual = false): void { - $toolName = $tool->getName(); + $toolName = $tool->toolName; $exists = $this->tools->offsetExists($toolName); $wasManual = isset($this->manualToolNames[$toolName]); @@ -209,7 +209,7 @@ public function registerTool(ToolDefinition $tool, bool $isManual = false): void public function registerResource(ResourceDefinition $resource, bool $isManual = false): void { - $uri = $resource->getUri(); + $uri = $resource->uri; $exists = $this->resources->offsetExists($uri); $wasManual = isset($this->manualResourceUris[$uri]); @@ -236,7 +236,7 @@ public function registerResource(ResourceDefinition $resource, bool $isManual = public function registerResourceTemplate(ResourceTemplateDefinition $template, bool $isManual = false): void { - $uriTemplate = $template->getUriTemplate(); + $uriTemplate = $template->uriTemplate; $exists = $this->resourceTemplates->offsetExists($uriTemplate); $wasManual = isset($this->manualTemplateUris[$uriTemplate]); @@ -260,7 +260,7 @@ public function registerResourceTemplate(ResourceTemplateDefinition $template, b public function registerPrompt(PromptDefinition $prompt, bool $isManual = false): void { - $promptName = $prompt->getName(); + $promptName = $prompt->promptName; $exists = $this->prompts->offsetExists($promptName); $wasManual = isset($this->manualPromptNames[$promptName]); @@ -309,7 +309,7 @@ public function loadDiscoveredElementsFromCache(bool $force = false): void foreach ($cached['tools'] ?? [] as $toolData) { $toolDefinition = $toolData instanceof ToolDefinition ? $toolData : ToolDefinition::fromArray($toolData); - $toolName = $toolDefinition->getName(); + $toolName = $toolDefinition->toolName; if (! isset($this->manualToolNames[$toolName])) { $this->tools[$toolName] = $toolDefinition; $loadCount++; @@ -320,7 +320,7 @@ public function loadDiscoveredElementsFromCache(bool $force = false): void foreach ($cached['resources'] ?? [] as $resourceData) { $resourceDefinition = $resourceData instanceof ResourceDefinition ? $resourceData : ResourceDefinition::fromArray($resourceData); - $uri = $resourceDefinition->getUri(); + $uri = $resourceDefinition->uri; if (! isset($this->manualResourceUris[$uri])) { $this->resources[$uri] = $resourceDefinition; $loadCount++; @@ -331,7 +331,7 @@ public function loadDiscoveredElementsFromCache(bool $force = false): void foreach ($cached['prompts'] ?? [] as $promptData) { $promptDefinition = $promptData instanceof PromptDefinition ? $promptData : PromptDefinition::fromArray($promptData); - $promptName = $promptDefinition->getName(); + $promptName = $promptDefinition->promptName; if (! isset($this->manualPromptNames[$promptName])) { $this->prompts[$promptName] = $promptDefinition; $loadCount++; @@ -342,7 +342,7 @@ public function loadDiscoveredElementsFromCache(bool $force = false): void foreach ($cached['resourceTemplates'] ?? [] as $templateData) { $templateDefinition = $templateData instanceof ResourceTemplateDefinition ? $templateData : ResourceTemplateDefinition::fromArray($templateData); - $uriTemplate = $templateDefinition->getUriTemplate(); + $uriTemplate = $templateDefinition->uriTemplate; if (! isset($this->manualTemplateUris[$uriTemplate])) { $this->resourceTemplates[$uriTemplate] = $templateDefinition; $loadCount++; @@ -493,17 +493,17 @@ public function findResourceTemplateByUri(string $uri): ?array { foreach ($this->resourceTemplates as $templateDefinition) { try { - $matcher = new UriTemplateMatcher($templateDefinition->getUriTemplate()); + $matcher = new UriTemplateMatcher($templateDefinition->uriTemplate); $variables = $matcher->match($uri); if ($variables !== null) { - $this->logger->debug('MCP Registry: Matched URI to template.', ['uri' => $uri, 'template' => $templateDefinition->getUriTemplate()]); + $this->logger->debug('MCP Registry: Matched URI to template.', ['uri' => $uri, 'template' => $templateDefinition->uriTemplate]); return ['definition' => $templateDefinition, 'variables' => $variables]; } } catch (\InvalidArgumentException $e) { $this->logger->warning('Invalid resource template encountered during matching', [ - 'template' => $templateDefinition->getUriTemplate(), + 'template' => $templateDefinition->uriTemplate, 'error' => $e->getMessage(), ]); diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index 7bbbb9f..8ead57b 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -236,7 +236,7 @@ private function performManualRegistrations(Registry $registry, LoggerInterface $schemaGenerator ); $registry->registerTool($def, true); - $logger->debug("Registered manual tool '{$def->getName()}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); + $logger->debug("Registered manual tool '{$def->toolName}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); } catch (Throwable $e) { $errorCount++; $logger->error('Failed to register manual tool', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); @@ -258,7 +258,7 @@ private function performManualRegistrations(Registry $registry, LoggerInterface $docBlockParser ); $registry->registerResource($def, true); - $logger->debug("Registered manual resource '{$def->getUri()}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); + $logger->debug("Registered manual resource '{$def->uri}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); } catch (Throwable $e) { $errorCount++; $logger->error('Failed to register manual resource', ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e]); @@ -279,7 +279,7 @@ private function performManualRegistrations(Registry $registry, LoggerInterface $docBlockParser ); $registry->registerResourceTemplate($def, true); - $logger->debug("Registered manual template '{$def->getUriTemplate()}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); + $logger->debug("Registered manual template '{$def->uriTemplate}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); } catch (Throwable $e) { $errorCount++; $logger->error('Failed to register manual template', ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e]); @@ -297,7 +297,7 @@ private function performManualRegistrations(Registry $registry, LoggerInterface $docBlockParser ); $registry->registerPrompt($def, true); - $logger->debug("Registered manual prompt '{$def->getName()}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); + $logger->debug("Registered manual prompt '{$def->promptName}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); } catch (Throwable $e) { $errorCount++; $logger->error('Failed to register manual prompt', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); diff --git a/src/Support/RequestProcessor.php b/src/Support/RequestProcessor.php index aca7b6f..956e447 100644 --- a/src/Support/RequestProcessor.php +++ b/src/Support/RequestProcessor.php @@ -8,9 +8,10 @@ use PhpMcp\Server\Configuration; use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Server\JsonRpc\Contents\TextContent; -use PhpMcp\Server\JsonRpc\Notification; -use PhpMcp\Server\JsonRpc\Request; -use PhpMcp\Server\JsonRpc\Response; +use PhpMcp\Server\JsonRpc\Messages\Notification; +use PhpMcp\Server\JsonRpc\Messages\Request; +use PhpMcp\Server\JsonRpc\Messages\Response; +use PhpMcp\Server\JsonRpc\Messages\Error; use PhpMcp\Server\JsonRpc\Result; use PhpMcp\Server\JsonRpc\Results\CallToolResult; use PhpMcp\Server\JsonRpc\Results\EmptyResult; @@ -63,11 +64,20 @@ public function __construct( $this->argumentPreparer = $argumentPreparer ?? new ArgumentPreparer($this->configuration->logger); } - public function process(Request|Notification $message, string $sessionId): ?Response + public function processNotification(Notification $message, string $sessionId): void + { + $method = $message->method; + $params = $message->params; + + if ($method === 'notifications/initialized') { + $this->handleNotificationInitialized($params, $sessionId); + } + } + + public function processRequest(Request $message, string $sessionId): Response|Error { $method = $message->method; $params = $message->params; - $id = $message instanceof Notification ? null : $message->id; try { /** @var Result|null $result */ @@ -77,10 +87,6 @@ public function process(Request|Notification $message, string $sessionId): ?Resp $result = $this->handleInitialize($params, $sessionId); } elseif ($method === 'ping') { $result = $this->handlePing($sessionId); - } elseif ($method === 'notifications/initialized') { - $this->handleNotificationInitialized($params, $sessionId); - - return null; } else { $this->validateSessionInitialized($sessionId); [$type, $action] = $this->parseMethod($method); @@ -118,16 +124,21 @@ public function process(Request|Notification $message, string $sessionId): ?Resp throw McpServerException::internalError("Processing method '{$method}' failed to return a result."); } - return isset($id) ? Response::success($result, id: $id) : null; + return Response::make($result, $message->id); } 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; + return $e->toJsonRpcError($message->id); } catch (Throwable $e) { $this->logger->error('MCP Processor caught unexpected error', ['method' => $method, 'exception' => $e]); - $mcpError = McpServerException::internalError("Internal error processing method '{$method}'", $e); // Use internalError factory - return isset($id) ? Response::error($mcpError->toJsonRpcError(), id: $id) : null; + return new Error( + jsonrpc: '2.0', + id: $message->id, + code: Error::CODE_INTERNAL_ERROR, + message: 'Internal error processing method ' . $method, + data: $e->getMessage() + ); } } @@ -285,7 +296,7 @@ private function handleToolCall(array $params): CallToolResult throw McpServerException::methodNotFound("Tool '{$toolName}' not found."); } - $inputSchema = $definition->getInputSchema(); + $inputSchema = $definition->inputSchema; $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($arguments, $inputSchema); @@ -310,8 +321,8 @@ private function handleToolCall(array $params): CallToolResult $argumentsForPhpCall = (array) $arguments; try { - $instance = $this->container->get($definition->getClassName()); - $methodName = $definition->getMethodName(); + $instance = $this->container->get($definition->className); + $methodName = $definition->methodName; $args = $this->argumentPreparer->prepareMethodArguments( $instance, @@ -360,8 +371,8 @@ private function handleResourceRead(array $params): ReadResourceResult } try { - $instance = $this->container->get($definition->getClassName()); - $methodName = $definition->getMethodName(); + $instance = $this->container->get($definition->className); + $methodName = $definition->methodName; $methodParams = array_merge($uriVariables, ['uri' => $uri]); @@ -373,7 +384,7 @@ private function handleResourceRead(array $params): ReadResourceResult ); $readResult = $instance->{$methodName}(...$args); - $contents = $this->formatResourceContents($readResult, $uri, $definition->getMimeType()); + $contents = $this->formatResourceContents($readResult, $uri, $definition->mimeType); return new ReadResourceResult($contents); } catch (JsonException $e) { @@ -434,15 +445,15 @@ private function handlePromptGet(array $params): GetPromptResult $arguments = (array) $arguments; - foreach ($definition->getArguments() as $argDef) { - if ($argDef->isRequired() && ! array_key_exists($argDef->getName(), $arguments)) { - throw McpServerException::invalidParams("Missing required argument '{$argDef->getName()}' for prompt '{$promptName}'."); + foreach ($definition->arguments as $argDef) { + if ($argDef->required && ! array_key_exists($argDef->name, $arguments)) { + throw McpServerException::invalidParams("Missing required argument '{$argDef->name}' for prompt '{$promptName}'."); } } try { - $instance = $this->container->get($definition->getClassName()); - $methodName = $definition->getMethodName(); + $instance = $this->container->get($definition->className); + $methodName = $definition->methodName; $args = $this->argumentPreparer->prepareMethodArguments( $instance, @@ -454,7 +465,7 @@ private function handlePromptGet(array $params): GetPromptResult $promptGenerationResult = $instance->{$methodName}(...$args); $messages = $this->formatPromptMessages($promptGenerationResult); - return new GetPromptResult($messages, $definition->getDescription()); + return new GetPromptResult($messages, $definition->description); } catch (JsonException $e) { $this->logger->warning('MCP SDK: Failed to JSON encode prompt messages.', ['exception' => $e, 'promptName' => $promptName]); throw McpServerException::internalError("Failed to serialize prompt messages for '{$promptName}'.", $e); diff --git a/src/Transports/HttpServerTransport.php b/src/Transports/HttpServerTransport.php index 773b009..bd14451 100644 --- a/src/Transports/HttpServerTransport.php +++ b/src/Transports/HttpServerTransport.php @@ -9,6 +9,7 @@ use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\TransportException; +use PhpMcp\Server\JsonRpc\Messages\Message as JsonRpcMessage; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -31,7 +32,7 @@ * * Listens for HTTP connections, manages SSE streams, and emits events. */ -class HttpServerTransport implements LoggerAwareInterface, LoopAwareInterface, ServerTransportInterface +class HttpServerTransport implements ServerTransportInterface, LoggerAwareInterface, LoopAwareInterface { use EventEmitterTrait; @@ -136,17 +137,14 @@ protected function createRequestHandler(): callable $method = $request->getMethod(); $this->logger->debug('Received request', ['method' => $method, 'path' => $path]); - // --- SSE Connection Handling --- if ($method === 'GET' && $path === $this->ssePath) { return $this->handleSseRequest($request); } - // --- Message POST Handling --- if ($method === 'POST' && $path === $this->messagePath) { return $this->handleMessagePostRequest($request); } - // --- Not Found --- $this->logger->debug('404 Not Found', ['method' => $method, 'path' => $path]); return new Response(404, ['Content-Type' => 'text/plain'], 'Not Found'); @@ -237,40 +235,49 @@ protected function handleMessagePostRequest(ServerRequestInterface $request): Re return new Response(400, ['Content-Type' => 'text/plain'], 'Empty request body'); } - $this->emit('message', [$body, $clientId]); + try { + $message = JsonRpcMessage::parseRequest($body); + } catch (Throwable $e) { + $this->logger->error('Error parsing message', ['clientId' => $clientId, 'exception' => $e]); + return new Response(400, ['Content-Type' => 'text/plain'], 'Invalid JSON-RPC message: ' . $e->getMessage()); + } + + $this->emit('message', [$message, $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 + public function sendMessage(JsonRpcMessage|null $message, string $sessionId, array $context = []): PromiseInterface { - if (! isset($this->activeSseStreams[$clientId])) { - return reject(new TransportException("Cannot send message: Client '{$clientId}' not connected via SSE.")); + if (! isset($this->activeSseStreams[$sessionId])) { + return reject(new TransportException("Cannot send message: Client '{$sessionId}' not connected via SSE.")); } - $stream = $this->activeSseStreams[$clientId]; + $stream = $this->activeSseStreams[$sessionId]; if (! $stream->isWritable()) { - return reject(new TransportException("Cannot send message: SSE stream for client '{$clientId}' is not writable.")); + return reject(new TransportException("Cannot send message: SSE stream for client '{$sessionId}' is not writable.")); } - $jsonData = trim($rawFramedMessage); + $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - if ($jsonData === '') { + if ($json === '') { return resolve(null); } $deferred = new Deferred(); - $written = $this->sendSseEvent($stream, 'message', $jsonData); + $written = $this->sendSseEvent($stream, 'message', $json); if ($written) { $deferred->resolve(null); } else { - $this->logger->debug('SSE stream buffer full, waiting for drain.', ['clientId' => $clientId]); - $stream->once('drain', function () use ($deferred, $clientId) { - $this->logger->debug('SSE stream drained.', ['clientId' => $clientId]); + $this->logger->debug('SSE stream buffer full, waiting for drain.', ['sessionId' => $sessionId]); + $stream->once('drain', function () use ($deferred, $sessionId) { + $this->logger->debug('SSE stream drained.', ['sessionId' => $sessionId]); $deferred->resolve(null); }); } diff --git a/src/Transports/StdioServerTransport.php b/src/Transports/StdioServerTransport.php index 68bb65d..b4e46ca 100644 --- a/src/Transports/StdioServerTransport.php +++ b/src/Transports/StdioServerTransport.php @@ -9,6 +9,7 @@ use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\TransportException; +use PhpMcp\Server\JsonRpc\Messages\Message as JsonRpcMessage; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use React\ChildProcess\Process; @@ -179,25 +180,19 @@ private function processBuffer(): void /** * Sends a raw, framed message to STDOUT. */ - public function sendToClientAsync(string $clientId, string $rawFramedMessage): PromiseInterface + public function sendMessage(JsonRpcMessage|null $message, string $sessionId, array $context = []): PromiseInterface { - if ($clientId !== self::CLIENT_ID) { - $this->logger->error("Attempted to send message to invalid clientId '{$clientId}'."); - - return reject(new TransportException("Invalid clientId '{$clientId}' for Stdio transport.")); - } - if ($this->closing || ! $this->stdout || ! $this->stdout->isWritable()) { return reject(new TransportException('Stdio transport is closed or STDOUT is not writable.')); } $deferred = new Deferred(); - $written = $this->stdout->write($rawFramedMessage); + $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $written = $this->stdout->write($json); if ($written) { $deferred->resolve(null); } else { - // Handle backpressure: resolve the promise once the stream drains $this->logger->debug('STDOUT buffer full, waiting for drain.'); $this->stdout->once('drain', function () use ($deferred) { $this->logger->debug('STDOUT drained.'); diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php new file mode 100644 index 0000000..65e5b86 --- /dev/null +++ b/src/Transports/StreamableHttpServerTransport.php @@ -0,0 +1,582 @@ + + */ + private array $pendingDirectPostResponses = []; + + /** + * Stores context for active SSE streams initiated by a POST request. + * Helps manage when to close these streams. + * Key: streamId + * Value: ['expectedResponses' => int, 'receivedResponses' => int] + * @var array + */ + private array $postSseStreamContexts = []; + + /** + * Stores active SSE streams. + * Key: streamId + * Value: ['stream' => ThroughStream, 'sessionId' => string, 'type' => 'get' | 'post' + * 'post_init' for SSE stream established for an InitializeRequest + * 'post_data' for SSE stream established for other data requests + * @var array + */ + private array $activeSseStreams = []; + + public function __construct( + private readonly string $host = '127.0.0.1', + private readonly int $port = 8080, + private string $mcpPath = '/mcp', + private ?array $sslContext = null, + ?SessionIdGeneratorInterface $sessionIdGenerator = null, + private readonly bool $preferDirectJsonResponse = true, + ?EventStoreInterface $eventStore = null + ) { + $this->logger = new NullLogger(); + $this->loop = Loop::get(); + $this->mcpPath = '/' . trim($mcpPath, '/'); + $this->sessionIdGenerator = $sessionIdGenerator ?? new DefaultUuidSessionIdGenerator(); + $this->eventStore = $eventStore; + } + + public function setLogger(LoggerInterface $logger): void + { + $this->logger = $logger; + } + + public function setLoop(LoopInterface $loop): void + { + $this->loop = $loop; + } + + private function generateStreamId(): string + { + return bin2hex(random_bytes(16)); + } + + public function listen(): void + { + if ($this->listening) { + throw new TransportException('StreamableHttp transport is already listening.'); + } + + if ($this->closing) { + throw new TransportException('Cannot listen, transport is closing/closed.'); + } + + $listenAddress = "{$this->host}:{$this->port}"; + $protocol = $this->sslContext ? 'https' : 'http'; + + try { + $this->socket = new SocketServer( + $listenAddress, + $this->sslContext ?? [], + $this->loop + ); + + $this->http = new HttpServer($this->loop, $this->createRequestHandler()); + $this->http->listen($this->socket); + + $this->socket->on('error', function (Throwable $error) { + $this->logger->error('Socket server error (StreamableHttp).', ['error' => $error->getMessage()]); + $this->emit('error', [new TransportException("Socket server error: {$error->getMessage()}", 0, $error)]); + $this->close(); + }); + + $this->logger->info("Server is up and listening on {$protocol}://{$listenAddress} ๐Ÿš€"); + $this->logger->info("MCP Endpoint: {$protocol}://{$listenAddress}{$this->mcpPath}"); + + $this->listening = true; + $this->closing = false; + $this->emit('ready'); + } catch (Throwable $e) { + $this->logger->error("Failed to start StreamableHttp listener on {$listenAddress}", ['exception' => $e]); + throw new TransportException("Failed to start StreamableHttp listener on {$listenAddress}: {$e->getMessage()}", 0, $e); + } + } + + private function createRequestHandler(): callable + { + return function (ServerRequestInterface $request) { + $path = $request->getUri()->getPath(); + $method = $request->getMethod(); + + $this->logger->debug("Request received", ['method' => $method, 'path' => $path, 'target' => $this->mcpPath]); + + if ($path !== $this->mcpPath) { + return new HttpResponse(404, ['Content-Type' => 'text/plain'], 'Not Found'); + } + + $corsHeaders = [ + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers' => 'Content-Type, Mcp-Session-Id, Last-Event-ID, Authorization', + ]; + + if ($method === 'OPTIONS') { + return new HttpResponse(204, $corsHeaders); + } + + $addCors = function (HttpResponse $r) use ($corsHeaders) { + foreach ($corsHeaders as $key => $value) { + $r = $r->withAddedHeader($key, $value); + } + return $r; + }; + + try { + return match ($method) { + 'GET' => $this->handleGetRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), + 'POST' => $this->handlePostRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), + 'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), + default => $addCors(new HttpResponse(405, ['Content-Type' => 'text/plain', 'Allow' => 'GET, POST, DELETE, OPTIONS'], 'Method Not Allowed')), + }; + } catch (Throwable $e) { + return $addCors($this->handleRequestError($e, $request)); + } + }; + } + + private function handleGetRequest(ServerRequestInterface $request): PromiseInterface + { + $acceptHeader = $request->getHeaderLine('Accept'); + if (!str_contains($acceptHeader, 'text/event-stream')) { + return resolve(new HttpResponse(406, ['Content-Type' => 'text/plain'], 'Not Acceptable: Client must accept text/event-stream for GET requests.')); + } + + $sessionId = $request->getHeaderLine('Mcp-Session-Id'); + if (empty($sessionId)) { + $this->logger->warning("GET request without Mcp-Session-Id."); + $error = JsonRpcError::invalidRequest("Mcp-Session-Id header required for GET requests."); + return resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); + } + + $streamId = $this->generateStreamId(); + $sseStream = new ThroughStream(); + + $this->activeSseStreams[$streamId] = ['stream' => $sseStream, 'sessionId' => $sessionId, 'type' => 'get']; + + $sseStream->on('close', function () use ($streamId, $sessionId) { + $this->logger->info("StreamableHttp: GET SSE stream closed.", ['streamId' => $streamId, 'sessionId' => $sessionId]); + unset($this->activeSseStreams[$streamId]); + }); + + $headers = [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no', + ]; + + $response = new HttpResponse(200, $headers, $sseStream); + + if ($this->eventStore) { + $lastEventId = $request->getHeaderLine('Last-Event-ID'); + if (!empty($lastEventId)) { + try { + $this->eventStore->replayEventsAfter( + $lastEventId, + function (string $replayedEventId, string $json) use ($sseStream, $streamId) { + $this->logger->debug("Replaying event", ['targetstreamId' => $streamId, 'replayedEventId' => $replayedEventId]); + $this->sendSseEventToStream($sseStream, $json, $replayedEventId); + } + ); + } catch (Throwable $e) { + $this->logger->error("Error during event replay.", ['streamId' => $streamId, 'sessionId' => $sessionId, 'exception' => $e]); + } + } + } + + $this->emit('client_connected', [$sessionId, $streamId]); + + return resolve($response); + } + + private function handlePostRequest(ServerRequestInterface $request): PromiseInterface + { + $deferred = new Deferred(); + + $acceptHeader = $request->getHeaderLine('Accept'); + if (!str_contains($acceptHeader, 'application/json') && !str_contains($acceptHeader, 'text/event-stream')) { + $deferred->resolve(new HttpResponse(406, ['Content-Type' => 'text/plain'], 'Not Acceptable: Client must accept application/json or text/event-stream')); + return $deferred->promise(); + } + + if (!str_contains($request->getHeaderLine('Content-Type'), 'application/json')) { + $deferred->resolve(new HttpResponse(415, ['Content-Type' => 'text/plain'], 'Unsupported Media Type: Content-Type must be application/json')); + return $deferred->promise(); + } + + $bodyContents = $request->getBody()->getContents(); + + if ($bodyContents === '') { + $this->logger->warning("Received empty POST body"); + $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'text/plain'], 'Empty request body.')); + return $deferred->promise(); + } + + try { + $message = JsonRpcMessage::parseRequest($bodyContents); + } catch (Throwable $e) { + $this->logger->error("Failed to parse MCP message from POST body", ['error' => $e->getMessage()]); + $error = JsonRpcError::parseError("Invalid JSON: " . $e->getMessage()); + $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); + return $deferred->promise(); + } + + $isInitializeRequest = ($message instanceof JsonRpcRequest && $message->method === 'initialize'); + $sessionId = null; + if ($isInitializeRequest) { + if ($request->hasHeader('Mcp-Session-Id')) { + $this->logger->warning("Client sent Mcp-Session-Id with InitializeRequest. Ignoring.", ['clientSentId' => $request->getHeaderLine('Mcp-Session-Id')]); + } + $sessionId = $this->sessionIdGenerator->generateId(); + } else { + $sessionId = $request->getHeaderLine('Mcp-Session-Id'); + + if (empty($sessionId)) { + $this->logger->warning("POST request without Mcp-Session-Id."); + $error = JsonRpcError::invalidRequest("Mcp-Session-Id header required for POST requests."); + $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); + return $deferred->promise(); + } + } + + $context = [ + 'is_initialize_request' => $isInitializeRequest, + ]; + + $hasRequests = false; + $nRequests = 0; + if ($message instanceof JsonRpcRequest) { + $hasRequests = true; + $nRequests = 1; + } elseif ($message instanceof BatchRequest) { + $hasRequests = $message->hasRequests(); + $nRequests = count($message->getRequests()); + } + + if (!$hasRequests) { + $deferred->resolve(new HttpResponse(202)); + $context['type'] = 'post_202_sent'; + } else { + $clientPrefersSse = str_contains($acceptHeader, 'text/event-stream'); + $clientAcceptsJson = str_contains($acceptHeader, 'application/json'); + $useSse = $clientPrefersSse && !($this->preferDirectJsonResponse && $clientAcceptsJson); + + if ($useSse) { + $streamId = $this->generateStreamId(); + $sseStream = new ThroughStream(); + $this->activeSseStreams[$streamId] = ['stream' => $sseStream, 'sessionId' => $sessionId, 'type' => 'post']; + $this->postSseStreamContexts[$streamId] = [ + 'nRequests' => $nRequests, + 'nResponses' => 0, + 'sessionId' => $sessionId + ]; + + $sseStream->on('close', function () use ($streamId) { + $this->logger->info("POST SSE stream closed by client/server.", ['streamId' => $streamId, 'sessionId' => $this->postSseStreamContexts[$streamId]['sessionId'] ?? 'unknown']); + unset($this->activeSseStreams[$streamId]); + unset($this->postSseStreamContexts[$streamId]); + }); + $sseStream->on('error', function (Throwable $e) use ($streamId) { + $this->logger->error("POST SSE stream error.", ['streamId' => $streamId, 'sessionId' => $this->postSseStreamContexts[$streamId]['sessionId'] ?? 'unknown', 'error' => $e->getMessage()]); + unset($this->activeSseStreams[$streamId]); + unset($this->postSseStreamContexts[$streamId]); + }); + + $headers = [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no', + ]; + + if ($this->sessionIdGenerator !== null && $sessionId && $message->method !== 'initialize') { + if ($request->hasHeader('Mcp-Session-Id')) { + $headers['Mcp-Session-Id'] = $sessionId; + } + } + + $deferred->resolve(new HttpResponse(200, $headers, $sseStream)); + $context['type'] = 'post_sse'; + $context['streamId'] = $streamId; + $context['nRequests'] = $nRequests; + } else { + $pendingRequestId = $this->generateStreamId(); + $this->pendingDirectPostResponses[$pendingRequestId] = $deferred; + + $timeoutTimer = $this->loop->addTimer(30, function () use ($pendingRequestId, $sessionId) { + if (isset($this->pendingDirectPostResponses[$pendingRequestId])) { + $deferred = $this->pendingDirectPostResponses[$pendingRequestId]; + unset($this->pendingDirectPostResponses[$pendingRequestId]); + $this->logger->warning("Timeout waiting for direct JSON response processing.", ['pending_request_id' => $pendingRequestId, 'session_id' => $sessionId]); + $errorResponse = McpServerException::internalError("Request processing timed out.")->toJsonRpcError($pendingRequestId); + $deferred->resolve(new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($errorResponse->toArray()))); + } + }); + + $this->pendingDirectPostResponses[$pendingRequestId]->promise()->finally(function () use ($timeoutTimer) { + $this->loop->cancelTimer($timeoutTimer); + }); + + $context['type'] = 'post_json'; + $context['pending_request_id'] = $pendingRequestId; + } + } + + $this->emit('message', [$message, $sessionId, $context]); + + return $deferred->promise(); + } + + private function handleDeleteRequest(ServerRequestInterface $request): PromiseInterface + { + $sessionId = $request->getHeaderLine('Mcp-Session-Id'); + if (empty($sessionId)) { + $this->logger->warning("DELETE request without Mcp-Session-Id."); + $error = JsonRpcError::invalidRequest("Mcp-Session-Id header required for DELETE."); + return resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); + } + + // TODO: Use session manager to handle this? + + // TODO: Close all associated HTTP streams for this session + + // TODO: Clean up session tracking in this transport + + // TODO: Remove any mappings for requests belonging to this session + + foreach ($this->activeSseStreams as $streamId => $streamInfo) { + if ($streamInfo['sessionId'] === $sessionId) { + $streamInfo['stream']->end(); + } + } + + $this->emit('client_disconnected', [$sessionId, null, 'Session terminated by DELETE request']); // No specific streamId, signals whole session. + + return resolve(new HttpResponse(204)); + } + + private function handleRequestError(Throwable $e, ServerRequestInterface $request): HttpResponse + { + $this->logger->error("Error processing HTTP request", [ + 'method' => $request->getMethod(), + 'path' => $request->getUri()->getPath(), + 'exception' => $e + ]); + + if ($e instanceof TransportException) { + return new HttpResponse(500, ['Content-Type' => 'text/plain'], 'Transport Error: ' . $e->getMessage()); + } + + return new HttpResponse(500, ['Content-Type' => 'text/plain'], 'Internal Server Error during HTTP request processing.'); + } + + public function sendMessage(JsonRpcMessage|null $message, string $sessionId, array $context = []): PromiseInterface + { + if ($this->closing) { + return reject(new TransportException('Transport is closing.')); + } + + $isInitializeResponse = ($context['is_initialize_request'] ?? false) && ($message instanceof JsonRpcResponse); + + switch ($context['type'] ?? null) { + case 'post_202_sent': + return resolve(null); + + case 'post_sse': + $streamId = $context['streamId']; + if (!isset($this->activeSseStreams[$streamId])) { + $this->logger->error("SSE stream for POST not found.", ['streamId' => $streamId, 'sessionId' => $sessionId]); + return reject(new TransportException("SSE stream {$streamId} not found for POST response.")); + } + + $stream = $this->activeSseStreams[$streamId]['stream']; + if (!$stream->isWritable()) { + $this->logger->warning("SSE stream for POST is not writable.", ['streamId' => $streamId, 'sessionId' => $sessionId]); + return reject(new TransportException("SSE stream {$streamId} for POST is not writable.")); + } + + $sentCountThisCall = 0; + + if ($message instanceof JsonRpcResponse || $message instanceof JsonRpcError) { + $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $eventId = $this->eventStore ? $this->eventStore->storeEvent($streamId, $json) : null; + $this->sendSseEventToStream($stream, $json, $eventId); + $sentCountThisCall = 1; + } elseif ($message instanceof BatchResponse) { + foreach ($message->all() as $singleResponse) { + $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $eventId = $this->eventStore ? $this->eventStore->storeEvent($streamId, $json) : null; + $this->sendSseEventToStream($stream, $json, $eventId); + $sentCountThisCall++; + } + } + + if (isset($this->postSseStreamContexts[$streamId])) { + $this->postSseStreamContexts[$streamId]['nResponses'] += $sentCountThisCall; + $sCtx = $this->postSseStreamContexts[$streamId]; + if ($sCtx['nResponses'] >= $sCtx['nRequests']) { + $this->logger->info("All expected responses sent for POST SSE stream. Closing.", ['streamId' => $streamId, 'sessionId' => $sessionId]); + $stream->end(); // Will trigger 'close' event. + } + } + return resolve(null); + + case 'post_json': + $pendingRequestId = $context['pending_request_id']; + if (!isset($this->pendingDirectPostResponses[$pendingRequestId])) { + $this->logger->error("Pending direct JSON request not found.", ['pending_request_id' => $pendingRequestId, 'session_id' => $sessionId]); + return reject(new TransportException("Pending request {$pendingRequestId} not found.")); + } + + $deferred = $this->pendingDirectPostResponses[$pendingRequestId]; + unset($this->pendingDirectPostResponses[$pendingRequestId]); + + $responseBody = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $headers = ['Content-Type' => 'application/json']; + if ($isInitializeResponse) { + $headers['Mcp-Session-Id'] = $sessionId; + } + + $deferred->resolve(new HttpResponse(200, $headers, $responseBody)); + return resolve(null); + + case 'get_sse': + $streamId = $context['streamId']; + if (!isset($this->activeSseStreams[$streamId])) { + $this->logger->error("GET SSE stream not found.", ['streamId' => $streamId, 'sessionId' => $sessionId]); + return reject(new TransportException("GET SSE stream {$streamId} not found.")); + } + + $stream = $this->activeSseStreams[$streamId]['stream']; + if (!$stream->isWritable()) { + $this->logger->warning("GET SSE stream is not writable.", ['streamId' => $streamId, 'sessionId' => $sessionId]); + return reject(new TransportException("GET SSE stream {$streamId} not writable.")); + } + if ($message instanceof JsonRpcResponse || $message instanceof JsonRpcError) { + $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $eventId = $this->eventStore ? $this->eventStore->storeEvent($streamId, $json) : null; + $this->sendSseEventToStream($stream, $json, $eventId); + } elseif ($message instanceof BatchResponse) { + foreach ($message->all() as $singleResponse) { + $json = json_encode($singleResponse, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $eventId = $this->eventStore ? $this->eventStore->storeEvent($streamId, $json) : null; + $this->sendSseEventToStream($stream, $json, $eventId); + } + } + return resolve(null); + + default: + $this->logger->error("Unknown sendMessage context type.", ['context' => $context, 'sessionId' => $sessionId]); + return reject(new TransportException("Unknown sendMessage context type: " . ($context['type'] ?? 'null'))); + } + } + + private function sendSseEventToStream(ThroughStream $stream, string $data, ?string $eventId = null): bool + { + if (! $stream->isWritable()) { + return false; + } + + $frame = "event: message\n"; + if ($eventId !== null) { + $frame .= "id: {$eventId}\n"; + } + + $lines = explode("\n", $data); + foreach ($lines as $line) { + $frame .= "data: {$line}\n"; + } + $frame .= "\n"; + + return $stream->write($frame); + } + + public function close(): void + { + if ($this->closing) { + return; + } + + $this->closing = true; + $this->listening = false; + $this->logger->info('Closing transport...'); + + if ($this->socket) { + $this->socket->close(); + $this->socket = null; + } + + foreach ($this->activeSseStreams as $streamId => $streamInfo) { + if ($streamInfo['stream']->isWritable()) { + $streamInfo['stream']->end(); + } + } + + foreach ($this->pendingDirectPostResponses as $pendingRequestId => $deferred) { + $deferred->reject(new TransportException('Transport is closing.')); + } + + $this->activeSseStreams = []; + $this->postSseStreamContexts = []; + $this->pendingDirectPostResponses = []; + + $this->emit('close', ['Transport closed.']); + $this->removeAllListeners(); + } +} diff --git a/tests/Unit/Definitions/PromptDefinitionTest.php b/tests/Unit/Definitions/PromptDefinitionTest.php index b2d363c..f1b6da6 100644 --- a/tests/Unit/Definitions/PromptDefinitionTest.php +++ b/tests/Unit/Definitions/PromptDefinitionTest.php @@ -15,7 +15,7 @@ // --- Constructor Validation Tests --- test('constructor validates prompt name pattern', function (string $promptName, bool $shouldFail) { - $action = fn () => new PromptDefinition( + $action = fn() => new PromptDefinition( className: AllElementsStub::class, methodName: 'templateMethod', promptName: $promptName, @@ -62,13 +62,13 @@ className: AllElementsStub::class, ); // Assert - expect($definition->getName())->toBe('explicit-prompt-name'); - expect($definition->getDescription())->toBe('Explicit Description'); - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('templateMethod'); + expect($definition->promptName)->toBe('explicit-prompt-name'); + expect($definition->description)->toBe('Explicit Description'); + expect($definition->className)->toBe(AllElementsStub::class); + expect($definition->methodName)->toBe('templateMethod'); // Assert arguments based on reflection (templateMethod has 1 param: $id) - expect($definition->getArguments())->toBeArray()->toHaveCount(1); - expect($definition->getArguments()[0]->getName())->toBe('id'); + expect($definition->arguments)->toBeArray()->toHaveCount(1); + expect($definition->arguments[0]->name)->toBe('id'); }); test('fromReflection uses method name and docblock summary as defaults', function () { @@ -78,7 +78,7 @@ className: AllElementsStub::class, $docComment = $reflectionMethod->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; @@ -97,11 +97,11 @@ className: AllElementsStub::class, ); // Assert - expect($definition->getName())->toBe('templateMethod'); // Default to method name - expect($definition->getDescription())->toBe($expectedSummary); // Default to summary - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('templateMethod'); - expect($definition->getArguments())->toBeArray()->toHaveCount(1); // templateMethod has 1 param + expect($definition->promptName)->toBe('templateMethod'); // Default to method name + expect($definition->description)->toBe($expectedSummary); // Default to summary + expect($definition->className)->toBe(AllElementsStub::class); + expect($definition->methodName)->toBe('templateMethod'); + expect($definition->arguments)->toBeArray()->toHaveCount(1); // templateMethod has 1 param }); test('fromReflection handles missing docblock summary', function () { @@ -124,11 +124,11 @@ className: AllElementsStub::class, ); // Assert - expect($definition->getName())->toBe('tool1'); - expect($definition->getDescription())->toBeNull(); - expect($definition->getClassName())->toBe(ToolOnlyStub::class); - expect($definition->getMethodName())->toBe('tool1'); - expect($definition->getArguments())->toBeArray()->toBeEmpty(); // tool1 has no params + expect($definition->promptName)->toBe('tool1'); + expect($definition->description)->toBeNull(); + expect($definition->className)->toBe(ToolOnlyStub::class); + expect($definition->methodName)->toBe('tool1'); + expect($definition->arguments)->toBeArray()->toBeEmpty(); // tool1 has no params }); // --- Serialization Tests --- @@ -150,10 +150,10 @@ className: AllElementsStub::class, // Act $mcpArray = $original->toArray(); $internalArray = [ - 'className' => $original->getClassName(), - 'methodName' => $original->getMethodName(), - 'promptName' => $original->getName(), - 'description' => $original->getDescription(), + 'className' => $original->className, + 'methodName' => $original->methodName, + 'promptName' => $original->promptName, + 'description' => $original->description, 'arguments' => $mcpArray['arguments'], // Use the toArray version of arguments ]; @@ -161,7 +161,7 @@ className: AllElementsStub::class, // Assert expect($reconstructed)->toEqual($original); // Should work with real argument object - expect($reconstructed->getArguments()[0]->getName())->toBe('id'); + expect($reconstructed->arguments[0]->name)->toBe('id'); }); test('toArray produces correct MCP format', function () { diff --git a/tests/Unit/Definitions/ResourceDefinitionTest.php b/tests/Unit/Definitions/ResourceDefinitionTest.php index abd5e11..cb38c16 100644 --- a/tests/Unit/Definitions/ResourceDefinitionTest.php +++ b/tests/Unit/Definitions/ResourceDefinitionTest.php @@ -96,13 +96,13 @@ className: AllElementsStub::class, ); // Assert - expect($definition->getUri())->toBe('test://explicit/uri'); - expect($definition->getName())->toBe('explicit-res-name'); - expect($definition->getDescription())->toBe('Explicit Description'); - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('resourceMethod'); - expect($definition->getMimeType())->toBe('application/json'); - expect($definition->getSize())->toBe(1234); + expect($definition->uri)->toBe('test://explicit/uri'); + expect($definition->name)->toBe('explicit-res-name'); + expect($definition->description)->toBe('Explicit Description'); + expect($definition->className)->toBe(AllElementsStub::class); + expect($definition->methodName)->toBe('resourceMethod'); + expect($definition->mimeType)->toBe('application/json'); + expect($definition->size)->toBe(1234); }); test('fromReflection uses method name and docblock summary as defaults', function () { @@ -132,13 +132,13 @@ className: AllElementsStub::class, ); // Assert - expect($definition->getUri())->toBe('test://default/uri'); - expect($definition->getName())->toBe('resourceMethod'); // Default to method name - expect($definition->getDescription())->toBe($expectedSummary); // Default to summary - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('resourceMethod'); - expect($definition->getMimeType())->toBeNull(); - expect($definition->getSize())->toBeNull(); + expect($definition->uri)->toBe('test://default/uri'); + expect($definition->name)->toBe('resourceMethod'); // Default to method name + expect($definition->description)->toBe($expectedSummary); // Default to summary + expect($definition->className)->toBe(AllElementsStub::class); + expect($definition->methodName)->toBe('resourceMethod'); + expect($definition->mimeType)->toBeNull(); + expect($definition->size)->toBeNull(); }); test('fromReflection handles missing docblock summary', function () { @@ -162,10 +162,10 @@ className: AllElementsStub::class, ); // Assert - expect($definition->getName())->toBe('resource2'); - expect($definition->getDescription())->toBeNull(); - expect($definition->getClassName())->toBe(ResourceOnlyStub::class); - expect($definition->getMethodName())->toBe('resource2'); + expect($definition->name)->toBe('resource2'); + expect($definition->description)->toBeNull(); + expect($definition->className)->toBe(ResourceOnlyStub::class); + expect($definition->methodName)->toBe('resource2'); }); // --- Serialization Tests --- @@ -185,19 +185,19 @@ className: AllElementsStub::class, // Act $mcpArray = $original->toArray(); $internalArray = [ - 'className' => $original->getClassName(), - 'methodName' => $original->getMethodName(), - 'uri' => $original->getUri(), - 'name' => $original->getName(), - 'description' => $original->getDescription(), - 'mimeType' => $original->getMimeType(), - 'size' => $original->getSize(), + 'className' => $original->className, + 'methodName' => $original->methodName, + 'uri' => $original->uri, + 'name' => $original->name, + 'description' => $original->description, + 'mimeType' => $original->mimeType, + 'size' => $original->size, ]; $reconstructed = ResourceDefinition::fromArray($internalArray); // Assert expect($reconstructed)->toEqual($original); - expect($reconstructed->getSize())->toBe($original->getSize()); + expect($reconstructed->size)->toBe($original->size); }); test('toArray produces correct MCP format', function () { diff --git a/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php b/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php index 539d13c..8a59ba0 100644 --- a/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php +++ b/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php @@ -93,12 +93,12 @@ className: AllElementsStub::class, ); // Assert - expect($definition->getUriTemplate())->toBe('test://explicit/{id}/uri'); - expect($definition->getName())->toBe('explicit-tmpl-name'); - expect($definition->getDescription())->toBe('Explicit Description'); - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('templateMethod'); - expect($definition->getMimeType())->toBe('application/xml'); + expect($definition->uriTemplate)->toBe('test://explicit/{id}/uri'); + expect($definition->name)->toBe('explicit-tmpl-name'); + expect($definition->description)->toBe('Explicit Description'); + expect($definition->className)->toBe(AllElementsStub::class); + expect($definition->methodName)->toBe('templateMethod'); + expect($definition->mimeType)->toBe('application/xml'); }); test('fromReflection uses method name and docblock summary as defaults', function () { @@ -128,12 +128,12 @@ className: AllElementsStub::class, ); // Assert - expect($definition->getUriTemplate())->toBe('test://default/{tmplId}'); - expect($definition->getName())->toBe('templateMethod'); // Default to method name - expect($definition->getDescription())->toBe($expectedSummary); // Default to summary - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('templateMethod'); - expect($definition->getMimeType())->toBeNull(); + expect($definition->uriTemplate)->toBe('test://default/{tmplId}'); + expect($definition->name)->toBe('templateMethod'); // Default to method name + expect($definition->description)->toBe($expectedSummary); // Default to summary + expect($definition->className)->toBe(AllElementsStub::class); + expect($definition->methodName)->toBe('templateMethod'); + expect($definition->mimeType)->toBeNull(); }); test('fromReflection handles missing docblock summary', function () { @@ -158,10 +158,10 @@ className: AllElementsStub::class, ); // Assert - expect($definition->getName())->toBe('templateMethod'); // Still defaults to method name - expect($definition->getDescription())->toBeNull(); // No description available - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('templateMethod'); + expect($definition->name)->toBe('templateMethod'); // Still defaults to method name + expect($definition->description)->toBeNull(); // No description available + expect($definition->className)->toBe(AllElementsStub::class); + expect($definition->methodName)->toBe('templateMethod'); }); // --- Serialization Tests --- @@ -180,12 +180,12 @@ className: AllElementsStub::class, // Act $mcpArray = $original->toArray(); $internalArray = [ - 'className' => $original->getClassName(), - 'methodName' => $original->getMethodName(), - 'uriTemplate' => $original->getUriTemplate(), - 'name' => $original->getName(), - 'description' => $original->getDescription(), - 'mimeType' => $original->getMimeType(), + 'className' => $original->className, + 'methodName' => $original->methodName, + 'uriTemplate' => $original->uriTemplate, + 'name' => $original->name, + 'description' => $original->description, + 'mimeType' => $original->mimeType, ]; $reconstructed = ResourceTemplateDefinition::fromArray($internalArray); diff --git a/tests/Unit/Definitions/ToolDefinitionTest.php b/tests/Unit/Definitions/ToolDefinitionTest.php index 0d467a7..9a3dbaa 100644 --- a/tests/Unit/Definitions/ToolDefinitionTest.php +++ b/tests/Unit/Definitions/ToolDefinitionTest.php @@ -60,11 +60,11 @@ className: AllElementsStub::class, ); // Assert - expect($definition->getName())->toBe('explicit-tool-name'); - expect($definition->getDescription())->toBe('Explicit Description'); - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('templateMethod'); - expect($definition->getInputSchema())->toBe($expectedSchema); + expect($definition->toolName)->toBe('explicit-tool-name'); + expect($definition->description)->toBe('Explicit Description'); + expect($definition->className)->toBe(AllElementsStub::class); + expect($definition->methodName)->toBe('templateMethod'); + expect($definition->inputSchema)->toBe($expectedSchema); }); test('fromReflection uses method name and docblock summary as defaults', function () { @@ -95,11 +95,11 @@ className: AllElementsStub::class, ); // Assert - expect($definition->getName())->toBe('templateMethod'); // Default to method name - expect($definition->getDescription())->toBe($expectedSummary); // Default to actual summary - expect($definition->getClassName())->toBe(AllElementsStub::class); - expect($definition->getMethodName())->toBe('templateMethod'); - expect($definition->getInputSchema())->toBe($expectedSchema); + expect($definition->toolName)->toBe('templateMethod'); // Default to method name + expect($definition->description)->toBe($expectedSummary); // Default to actual summary + expect($definition->className)->toBe(AllElementsStub::class); + expect($definition->methodName)->toBe('templateMethod'); + expect($definition->inputSchema)->toBe($expectedSchema); }); test('fromReflection uses class short name as default tool name for invokable classes', function () { @@ -118,10 +118,10 @@ className: AllElementsStub::class, $this->schemaGenerator ); - expect($definition->getName())->toBe('ToolOnlyStub'); - expect($definition->getClassName())->toBe(ToolOnlyStub::class); - expect($definition->getMethodName())->toBe('__invoke'); - expect($definition->getInputSchema())->toBe(['type' => 'object']); + expect($definition->toolName)->toBe('ToolOnlyStub'); + expect($definition->className)->toBe(ToolOnlyStub::class); + expect($definition->methodName)->toBe('__invoke'); + expect($definition->inputSchema)->toBe(['type' => 'object']); }); test('fromReflection handles missing docblock summary', function () { @@ -145,11 +145,11 @@ className: AllElementsStub::class, ); // Assert - expect($definition->getName())->toBe('tool1'); - expect($definition->getDescription())->toBeNull(); // No description available - expect($definition->getClassName())->toBe(ToolOnlyStub::class); - expect($definition->getMethodName())->toBe('tool1'); - expect($definition->getInputSchema())->toBe($expectedSchema); + expect($definition->toolName)->toBe('tool1'); + expect($definition->description)->toBeNull(); // No description available + expect($definition->className)->toBe(ToolOnlyStub::class); + expect($definition->methodName)->toBe('tool1'); + expect($definition->inputSchema)->toBe($expectedSchema); }); // --- Serialization Tests --- @@ -167,17 +167,17 @@ className: AllElementsStub::class, // Act $mcpArray = $original->toArray(); $internalArray = [ - 'className' => $original->getClassName(), - 'methodName' => $original->getMethodName(), - 'toolName' => $original->getName(), - 'description' => $original->getDescription(), - 'inputSchema' => $original->getInputSchema(), + 'className' => $original->className, + 'methodName' => $original->methodName, + 'toolName' => $original->toolName, + 'description' => $original->description, + 'inputSchema' => $original->inputSchema, ]; $reconstructed = ToolDefinition::fromArray($internalArray); // Assert expect($reconstructed)->toEqual($original); - expect($reconstructed->getInputSchema())->toBe($original->getInputSchema()); + expect($reconstructed->inputSchema)->toBe($original->inputSchema); }); test('toArray produces correct MCP format', function () { diff --git a/tests/Unit/JsonRpc/NotificationTest.php b/tests/Unit/JsonRpc/NotificationTest.php index ecc2549..12786b0 100644 --- a/tests/Unit/JsonRpc/NotificationTest.php +++ b/tests/Unit/JsonRpc/NotificationTest.php @@ -3,7 +3,7 @@ namespace PhpMcp\Server\Tests\Unit\JsonRpc; use PhpMcp\Server\Exception\ProtocolException; -use PhpMcp\Server\JsonRpc\Notification; +use PhpMcp\Server\JsonRpc\Messages\Notification; test('notification construction sets properties correctly', function () { $notification = new Notification('2.0', 'test.method', ['param1' => 'value1']); @@ -60,27 +60,27 @@ 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 + expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException for missing jsonrpc', function () { $data = ['method' => 'test.method']; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED + expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException for missing method', function () { $data = ['jsonrpc' => '2.0']; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED + expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException for non-string method', function () { $data = ['jsonrpc' => '2.0', 'method' => 123]; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED + expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException if params is not an array/object', function () { $data = ['jsonrpc' => '2.0', 'method' => 'test', 'params' => 'string']; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); + expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); }); test('toArray returns correct structure with params', function () { diff --git a/tests/Unit/ProtocolTest.php b/tests/Unit/ProtocolTest.php index 17f320e..094347f 100644 --- a/tests/Unit/ProtocolTest.php +++ b/tests/Unit/ProtocolTest.php @@ -7,9 +7,9 @@ use PhpMcp\Server\Configuration; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\McpServerException; -use PhpMcp\Server\JsonRpc\Notification; -use PhpMcp\Server\JsonRpc\Request; -use PhpMcp\Server\JsonRpc\Response; +use PhpMcp\Server\JsonRpc\Messages\Notification; +use PhpMcp\Server\JsonRpc\Messages\Request; +use PhpMcp\Server\JsonRpc\Messages\Response; use PhpMcp\Server\JsonRpc\Results\EmptyResult; use PhpMcp\Server\Model\Capabilities; use PhpMcp\Server\Protocol; @@ -75,7 +75,7 @@ $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::make(new EmptyResult(), $requestId); $expectedResponseJson = json_encode($expectedResponse->toArray()); $this->requestProcessor->shouldReceive('process')->once()->with(Mockery::type(Request::class), $clientId)->andReturn($expectedResponse); @@ -102,7 +102,7 @@ $rawJson = '{"jsonrpc":"2.0", "id":'; $this->requestProcessor->shouldNotReceive('process'); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32700') && str_contains($json, '"id":null')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32700') && str_contains($json, '"id":null')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); @@ -112,7 +112,7 @@ $rawJson = '{"jsonrpc":"2.0", "id": 456}'; // Missing method $this->requestProcessor->shouldNotReceive('process'); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32600') && str_contains($json, '"id":456')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32600') && str_contains($json, '"id":456')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); @@ -125,7 +125,7 @@ $mcpException = McpServerException::methodNotFound($method); $this->requestProcessor->shouldReceive('process')->once()->andThrow($mcpException); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32601') && str_contains($json, '"id":789')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32601') && str_contains($json, '"id":789')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); @@ -138,7 +138,7 @@ $internalException = new \RuntimeException('Borked'); $this->requestProcessor->shouldReceive('process')->once()->andThrow($internalException); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32603') && str_contains($json, '"id":101')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32603') && str_contains($json, '"id":101')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); diff --git a/tests/Unit/RegistryTest.php b/tests/Unit/RegistryTest.php index 9e1b6eb..00c23d0 100644 --- a/tests/Unit/RegistryTest.php +++ b/tests/Unit/RegistryTest.php @@ -8,7 +8,6 @@ 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; @@ -242,8 +241,8 @@ function getRegistryProperty(Registry $reg, string $propName) $cachedTool = createTestTool('cached-tool-constructor'); $cachedResource = createTestResource('cached://res-constructor'); $cachedData = [ - 'tools' => [$cachedTool->getName() => $cachedTool], - 'resources' => [$cachedResource->getUri() => $cachedResource], + 'tools' => [$cachedTool->toolName => $cachedTool], + 'resources' => [$cachedResource->uri => $cachedResource], 'prompts' => [], 'resourceTemplates' => [], ]; diff --git a/tests/Unit/Support/DiscovererTest.php b/tests/Unit/Support/DiscovererTest.php index 09a1f50..d7c2971 100644 --- a/tests/Unit/Support/DiscovererTest.php +++ b/tests/Unit/Support/DiscovererTest.php @@ -44,19 +44,19 @@ // Assert registry interactions $this->registry->shouldReceive('registerTool')->once()->with(Mockery::on(function ($arg) { - return $arg instanceof ToolDefinition && $arg->getName() === 'discovered-tool'; + return $arg instanceof ToolDefinition && $arg->toolName === 'discovered-tool'; })); $this->registry->shouldReceive('registerResource')->once()->with(Mockery::on(function ($arg) { - return $arg instanceof ResourceDefinition && $arg->getUri() === 'discovered://resource'; + return $arg instanceof ResourceDefinition && $arg->uri === 'discovered://resource'; })); $this->registry->shouldReceive('registerPrompt')->once()->with(Mockery::on(function ($arg) { - return $arg instanceof PromptDefinition && $arg->getName() === 'discovered-prompt'; + return $arg instanceof PromptDefinition && $arg->promptName === 'discovered-prompt'; })); $this->registry->shouldReceive('registerResourceTemplate')->once()->with(Mockery::on(function ($arg) { - return $arg instanceof ResourceTemplateDefinition && $arg->getUriTemplate() === 'discovered://template/{id}'; + return $arg instanceof ResourceTemplateDefinition && $arg->uriTemplate === 'discovered://template/{id}'; })); - $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); + $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); // Act $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); @@ -68,13 +68,13 @@ $file2Path = createDiscoveryTestFile('ResourceOnlyStub'); // Assert registry interactions - $this->registry->shouldReceive('registerTool')->once()->with(Mockery::on(fn ($arg) => $arg->getName() === 'tool-from-file1')); + $this->registry->shouldReceive('registerTool')->once()->with(Mockery::on(fn($arg) => $arg->toolName === 'tool-from-file1')); $this->registry->shouldNotReceive('registerResource'); $this->registry->shouldNotReceive('registerPrompt'); $this->registry->shouldNotReceive('registerResourceTemplate'); // Ensure no errors during processing of these files - $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && ($ctx['file'] === $file1Path || $ctx['file'] === $file2Path))); + $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn($ctx) => isset($ctx['file']) && ($ctx['file'] === $file1Path || $ctx['file'] === $file2Path))); // Act $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); @@ -96,7 +96,7 @@ test('handles non-existent directory gracefully', function () { // Arrange - $nonExistentDir = TEST_DISCOVERY_DIR.'/nonexistent'; + $nonExistentDir = TEST_DISCOVERY_DIR . '/nonexistent'; // Assert registry interactions $this->registry->shouldNotReceive('registerTool'); @@ -130,11 +130,10 @@ } // Ensure no processing errors for this file - $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); + $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); // Act $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); - })->with([ 'Abstract class' => ['AbstractStub', 0], 'Interface' => ['InterfaceStub', 0], @@ -155,16 +154,16 @@ // Assert registry interactions $this->registry->shouldReceive('registerTool') - ->with(Mockery::on(fn ($arg) => $arg instanceof ToolDefinition && $arg->getName() === 'valid-tool')) + ->with(Mockery::on(fn($arg) => $arg instanceof ToolDefinition && $arg->toolName === 'valid-tool')) ->once(); $this->registry->shouldReceive('registerTool') - ->with(Mockery::on(fn ($arg) => $arg instanceof ToolDefinition && $arg->getName() === 'another-valid-tool')) + ->with(Mockery::on(fn($arg) => $arg instanceof ToolDefinition && $arg->toolName === 'another-valid-tool')) ->once(); $this->registry->shouldNotReceive('registerResource'); // Ensure no *other* unexpected errors related to this class/methods $this->logger->shouldNotReceive('error') - ->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); + ->with(Mockery::any(), Mockery::on(fn($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); // Act $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); @@ -172,7 +171,7 @@ test('handles file read error gracefully', function () { // Arrange - $invalidFile = TEST_DISCOVERY_DIR.'/invalid.php'; + $invalidFile = TEST_DISCOVERY_DIR . '/invalid.php'; touch($invalidFile); // Create the file chmod($invalidFile, 0000); // Make it unreadable @@ -197,13 +196,13 @@ ->once() ->with(Mockery::on(function ($arg) use ($expectedNameOrUri, $stubName) { // Check if it's the correct definition type and name/uri - return ($arg instanceof ToolDefinition && $arg->getName() === $expectedNameOrUri) - || ($arg instanceof ResourceDefinition && $arg->getUri() === $expectedNameOrUri) - || ($arg instanceof PromptDefinition && $arg->getName() === $expectedNameOrUri) - || ($arg instanceof ResourceTemplateDefinition && $arg->getUriTemplate() === $expectedNameOrUri) + return ($arg instanceof ToolDefinition && $arg->toolName === $expectedNameOrUri) + || ($arg instanceof ResourceDefinition && $arg->uri === $expectedNameOrUri) + || ($arg instanceof PromptDefinition && $arg->promptName === $expectedNameOrUri) + || ($arg instanceof ResourceTemplateDefinition && $arg->uriTemplate === $expectedNameOrUri) // Verify the definition points to the __invoke method - && $arg->getMethodName() === '__invoke' - && str_ends_with($arg->getClassName(), $stubName); + && $arg->methodName === '__invoke' + && str_ends_with($arg->className, $stubName); })); // Act diff --git a/tests/Unit/Support/RequestProcessorTest.php b/tests/Unit/Support/RequestProcessorTest.php index 792d13b..e937899 100644 --- a/tests/Unit/Support/RequestProcessorTest.php +++ b/tests/Unit/Support/RequestProcessorTest.php @@ -9,9 +9,9 @@ use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Server\JsonRpc\Contents\TextContent; use PhpMcp\Server\JsonRpc\Error as JsonRpcError; -use PhpMcp\Server\JsonRpc\Notification; -use PhpMcp\Server\JsonRpc\Request; -use PhpMcp\Server\JsonRpc\Response; +use PhpMcp\Server\JsonRpc\Messages\Notification; +use PhpMcp\Server\JsonRpc\Messages\Request; +use PhpMcp\Server\JsonRpc\Messages\Response; use PhpMcp\Server\JsonRpc\Results\CallToolResult; use PhpMcp\Server\JsonRpc\Results\EmptyResult; use PhpMcp\Server\JsonRpc\Results\InitializeResult; @@ -226,12 +226,15 @@ function expectMcpErrorResponse(?Response $response, int $expectedCode, ?string $handlerMethod = 'execute'; $rawArgs = ['p' => 'v']; $toolResult = 'Success'; - $definition = Mockery::mock(ToolDefinition::class); + $definition = new ToolDefinition( + className: $handlerClass, + methodName: $handlerMethod, + toolName: $toolName, + description: 'd1', + inputSchema: [], + ); $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([]); diff --git a/tests/Unit/Traits/ResponseFormatterTest.php b/tests/Unit/Traits/ResponseFormatterTest.php index 84c38fc..4942bfd 100644 --- a/tests/Unit/Traits/ResponseFormatterTest.php +++ b/tests/Unit/Traits/ResponseFormatterTest.php @@ -53,7 +53,7 @@ class TestFormatterClass expect($result)->toBeArray()->toHaveCount(1) ->and($result[0])->toBeInstanceOf(TextContent::class) - ->and($result[0]->getText())->toBe($expectedText); + ->and($result[0]->text)->toBe($expectedText); })->with([ ['hello world', 'hello world'], [12345, '12345'], @@ -67,7 +67,7 @@ class TestFormatterClass expect($result)->toBeArray()->toHaveCount(1) ->and($result[0])->toBeInstanceOf(TextContent::class) - ->and($result[0]->getText())->toBe('(null)'); + ->and($result[0]->text)->toBe('(null)'); }); test('formatToolResult handles array (JSON encodes)', function () { @@ -77,7 +77,7 @@ class TestFormatterClass expect($result)->toBeArray()->toHaveCount(1) ->and($result[0])->toBeInstanceOf(TextContent::class) - ->and($result[0]->getText())->toBe($expectedJson); + ->and($result[0]->text)->toBe($expectedJson); }); test('formatToolResult handles object (JSON encodes)', function () { @@ -88,7 +88,7 @@ class TestFormatterClass $result = $this->formatter->formatToolResult($data); expect($result)->toBeArray()->toHaveCount(1) ->and($result[0])->toBeInstanceOf(TextContent::class) - ->and($result[0]->getText())->toBe($expectedJson); + ->and($result[0]->text)->toBe($expectedJson); }); test('formatToolResult handles single Content object', function () { @@ -122,7 +122,7 @@ class TestFormatterClass expect($result)->toBeArray()->toHaveCount(1) ->and($result[0])->toBeInstanceOf(TextContent::class) - ->and($result[0]->getText())->toBe('Tool execution failed: Something went wrong (Type: RuntimeException)'); + ->and($result[0]->text)->toBe('Tool execution failed: Something went wrong (Type: RuntimeException)'); }); // --- formatResourceContents Tests --- @@ -196,10 +196,10 @@ class TestFormatterClass // expect(is_resource($stream))->toBeFalse(); expect($result)->toBeArray()->toHaveCount(1) ->and($result[0])->toBeInstanceOf(EmbeddedResource::class) - ->and($result[0]->getUri())->toBe('test/uri') - ->and($result[0]->getMimeType())->toBe('application/pdf') - ->and($result[0]->getText())->toBeNull() - ->and($result[0]->getBlob())->toBe(base64_encode('stream content')); + ->and($result[0]->uri)->toBe('test/uri') + ->and($result[0]->mimeType)->toBe('application/pdf') + ->and($result[0]->text)->toBeNull() + ->and($result[0]->blob)->toBe(base64_encode('stream content')); }); test('formatResourceContents handles array blob input', function () { @@ -223,17 +223,17 @@ class TestFormatterClass expect($result)->toBeArray()->toHaveCount(1) ->and($result[0])->toBeInstanceOf(EmbeddedResource::class) - ->and($result[0]->getUri())->toBe('test/uri') - ->and($result[0]->getMimeType())->toBe('text/vnd.test') - ->and($result[0]->getText())->toBe('splfile test content') - ->and($result[0]->getBlob())->toBeNull(); + ->and($result[0]->uri)->toBe('test/uri') + ->and($result[0]->mimeType)->toBe('text/vnd.test') + ->and($result[0]->text)->toBe('splfile test content') + ->and($result[0]->blob)->toBeNull(); expect($result2)->toBeArray()->toHaveCount(1) ->and($result2[0])->toBeInstanceOf(EmbeddedResource::class) - ->and($result2[0]->getUri())->toBe('test/uri') - ->and($result2[0]->getMimeType())->toBe('image/png') - ->and($result2[0]->getText())->toBeNull() - ->and($result2[0]->getBlob())->toBe(base64_encode('splfile test content')); + ->and($result2[0]->uri)->toBe('test/uri') + ->and($result2[0]->mimeType)->toBe('image/png') + ->and($result2[0]->text)->toBeNull() + ->and($result2[0]->blob)->toBe(base64_encode('splfile test content')); }); test('formatResourceContents handles array input (json mime)', function () { From 771ac3c57bbe061cc63f4022aa109e8cdd950b1a Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 7 Jun 2025 23:49:55 +0100 Subject: [PATCH 05/27] refactor: Enhance session management and error handling - Updated Protocol class to utilize SessionInterface for session handling, improving type safety and consistency. - Refactored message processing methods to accept SessionInterface instead of sessionId directly. - Improved error handling in StdioServerTransport and StreamableHttpServerTransport for better logging and response management. - Introduced new methods in SessionInterface for better session data management. --- .../02-discovery-http-userprofile/server.php | 12 +- .../06-custom-dependencies-stdio/server.php | 6 +- src/Contracts/IdGeneratorInterface.php | 10 + src/Contracts/ServerTransportInterface.php | 12 +- src/Contracts/SessionIdGeneratorInterface.php | 21 - src/Contracts/SessionInterface.php | 37 +- src/JsonRpc/Messages/Error.php | 22 +- src/JsonRpc/Messages/Message.php | 2 +- src/JsonRpc/Messages/Response.php | 16 +- src/JsonRpc/Results/EmptyResult.php | 6 +- src/JsonRpc/Results/InitializeResult.php | 2 +- src/Protocol.php | 184 ++++++-- src/Registry.php | 6 +- src/Server.php | 2 +- src/ServerBuilder.php | 39 +- src/Session/ArraySessionHandler.php | 78 ++++ src/Session/CacheSessionHandler.php | 43 ++ src/Session/Session.php | 92 ++-- src/Session/SessionManager.php | 344 ++++----------- src/Support/RandomIdGenerator.php | 15 + src/Support/RequestHandler.php | 401 ++++++++++++++++++ src/Transports/HttpServerTransport.php | 82 ++-- src/Transports/StdioServerTransport.php | 22 +- .../StreamableHttpServerTransport.php | 118 ++++-- 24 files changed, 1058 insertions(+), 514 deletions(-) create mode 100644 src/Contracts/IdGeneratorInterface.php delete mode 100644 src/Contracts/SessionIdGeneratorInterface.php create mode 100644 src/Session/ArraySessionHandler.php create mode 100644 src/Session/CacheSessionHandler.php create mode 100644 src/Support/RandomIdGenerator.php create mode 100644 src/Support/RequestHandler.php diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php index 93abc03..4b8bf33 100644 --- a/examples/02-discovery-http-userprofile/server.php +++ b/examples/02-discovery-http-userprofile/server.php @@ -71,16 +71,8 @@ public function log($level, \Stringable|string $message, array $context = []): v $server->discover(__DIR__, ['.']); - // $transport = new HttpServerTransport( - // host: '127.0.0.1', - // port: 8080, - // mcpPathPrefix: 'mcp' - // ); - $transport = new StreamableHttpServerTransport( - host: '127.0.0.1', - port: 8080, - mcpPath: 'mcp' - ); + // $transport = new HttpServerTransport('127.0.0.1', 8080, 'mcp'); + $transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'); $server->listen($transport); diff --git a/examples/06-custom-dependencies-stdio/server.php b/examples/06-custom-dependencies-stdio/server.php index 4348920..f8a630f 100644 --- a/examples/06-custom-dependencies-stdio/server.php +++ b/examples/06-custom-dependencies-stdio/server.php @@ -94,8 +94,10 @@ public function log($level, \Stringable|string $message, array $context = []): v $logger->info('Server listener stopped gracefully.'); exit(0); - } catch (\Throwable $e) { - fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n".$e."\n"); + fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); + fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); + fwrite(STDERR, 'File: ' . $e->getFile() . ':' . $e->getLine() . "\n"); + fwrite(STDERR, $e->getTraceAsString() . "\n"); exit(1); } diff --git a/src/Contracts/IdGeneratorInterface.php b/src/Contracts/IdGeneratorInterface.php new file mode 100644 index 0000000..90f0431 --- /dev/null +++ b/src/Contracts/IdGeneratorInterface.php @@ -0,0 +1,10 @@ + Resolves on successful send/queue, rejects on specific send error. */ - public function sendMessage(Response|Error|BatchResponse|null $message, string $sessionId, array $context = []): PromiseInterface; + public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface; /** * Stops the transport listener gracefully and closes all active connections. diff --git a/src/Contracts/SessionIdGeneratorInterface.php b/src/Contracts/SessionIdGeneratorInterface.php deleted file mode 100644 index 76f71dc..0000000 --- a/src/Contracts/SessionIdGeneratorInterface.php +++ /dev/null @@ -1,21 +0,0 @@ -toArray(); } diff --git a/src/JsonRpc/Messages/Response.php b/src/JsonRpc/Messages/Response.php index 5f1b9a9..3b62a59 100644 --- a/src/JsonRpc/Messages/Response.php +++ b/src/JsonRpc/Messages/Response.php @@ -3,11 +3,12 @@ namespace PhpMcp\Server\JsonRpc\Messages; use PhpMcp\Server\Exception\ProtocolException; +use PhpMcp\Server\JsonRpc\Contracts\ResultInterface; /** * A successful (non-error) response to a request. * - * @template T + * @template T of ResultInterface */ class Response extends Message { @@ -16,7 +17,7 @@ class Response extends Message * * @param string $jsonrpc JSON-RPC version (always "2.0") * @param string|int $id Request ID this response is for (must match the request) - * @param T|null $result Method result (can be a Result object or array) + * @param T $result Method result */ public function __construct( public readonly string $jsonrpc, @@ -79,7 +80,16 @@ public function toArray(): array return [ 'jsonrpc' => $this->jsonrpc, 'id' => $this->id, - 'result' => is_array($this->result) ? $this->result : $this->result->toArray(), + 'result' => $this->result->toArray(), + ]; + } + + public function jsonSerialize(): mixed + { + return [ + 'jsonrpc' => $this->jsonrpc, + 'id' => $this->id, + 'result' => $this->result->jsonSerialize(), ]; } } diff --git a/src/JsonRpc/Results/EmptyResult.php b/src/JsonRpc/Results/EmptyResult.php index 2e6aed6..c59f359 100644 --- a/src/JsonRpc/Results/EmptyResult.php +++ b/src/JsonRpc/Results/EmptyResult.php @@ -5,7 +5,7 @@ use PhpMcp\Server\JsonRpc\Contracts\ResultInterface; /** - * A generic empty result for methods that return an empty object + * A generic empty result that indicates success but carries no data. */ class EmptyResult implements ResultInterface { @@ -22,8 +22,8 @@ public function toArray(): array return []; // Empty result object } - public function jsonSerialize(): array + public function jsonSerialize(): mixed { - return $this->toArray(); + return new \stdClass(); } } diff --git a/src/JsonRpc/Results/InitializeResult.php b/src/JsonRpc/Results/InitializeResult.php index a865c4c..e0d4c8b 100644 --- a/src/JsonRpc/Results/InitializeResult.php +++ b/src/JsonRpc/Results/InitializeResult.php @@ -12,7 +12,7 @@ class InitializeResult implements ResultInterface * @param array $serverInfo Server information * @param string $protocolVersion Protocol version * @param array $capabilities Server capabilities - * @param string|null $instructions Optional instructions text + * @param string|null $instructions Instructions describing how to use the server and its features. */ public function __construct( public readonly array $serverInfo, diff --git a/src/Protocol.php b/src/Protocol.php index e45453f..6822933 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -4,15 +4,17 @@ namespace PhpMcp\Server; -use JsonException; use PhpMcp\Server\Contracts\ServerTransportInterface; +use PhpMcp\Server\Contracts\SessionInterface; +use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Server\JsonRpc\Messages\BatchRequest; use PhpMcp\Server\JsonRpc\Messages\BatchResponse; use PhpMcp\Server\JsonRpc\Messages\Error; use PhpMcp\Server\JsonRpc\Messages\Notification; use PhpMcp\Server\JsonRpc\Messages\Request; use PhpMcp\Server\JsonRpc\Messages\Response; -use PhpMcp\Server\Support\RequestProcessor; +use PhpMcp\Server\Session\SessionManager; +use PhpMcp\Server\Support\RequestHandler; use Psr\Log\LoggerInterface; use Throwable; @@ -25,15 +27,24 @@ */ class Protocol { + public const SUPPORTED_PROTOCOL_VERSIONS = ['2024-11-05', '2025-03-26']; + protected ?ServerTransportInterface $transport = null; + protected LoggerInterface $logger; + /** Stores listener references for proper removal */ protected array $listeners = []; public function __construct( - protected readonly LoggerInterface $logger, - protected readonly RequestProcessor $requestProcessor, - ) {} + protected Configuration $configuration, + protected Registry $registry, + protected SessionManager $sessionManager, + protected ?RequestHandler $requestHandler = null, + ) { + $this->logger = $this->configuration->logger; + $this->requestHandler ??= new RequestHandler($this->configuration, $this->registry, $this->sessionManager); + } /** * Binds this handler to a transport instance by attaching event listeners. @@ -84,38 +95,47 @@ public function unbindTransport(): void public function processMessage(Request|Notification|BatchRequest $message, string $sessionId, array $context = []): void { $this->logger->debug('Message received.', ['sessionId' => $sessionId, 'message' => $message]); - $processedPayload = null; + + $session = $this->sessionManager->getSession($sessionId); + + $response = null; if ($message instanceof BatchRequest) { - $processedPayload = $this->processBatchRequest($message, $sessionId); + $response = $this->processBatchRequest($message, $session); } elseif ($message instanceof Request) { - $processedPayload = $this->processRequest($message, $sessionId); + $response = $this->processRequest($message, $session); } elseif ($message instanceof Notification) { - $this->processNotification($message, $sessionId); + $this->processNotification($message, $session); } - $this->transport->sendMessage($processedPayload, $sessionId, $context) - ->then(function () use ($sessionId, $processedPayload) { - $this->logger->debug('Message sent.', ['sessionId' => $sessionId, 'payload' => $processedPayload]); + $session->save(); + + if ($response === null) { + return; + } + + $this->transport->sendMessage($response, $sessionId, $context) + ->then(function () use ($sessionId, $response) { + $this->logger->debug('Response sent.', ['sessionId' => $sessionId, 'payload' => $response]); }) - ->catch(function (Throwable $e) use ($sessionId, $processedPayload) { - $this->logger->error('Message send failed.', ['sessionId' => $sessionId, 'error' => $e->getMessage()]); + ->catch(function (Throwable $e) use ($sessionId) { + $this->logger->error('Failed to send response.', ['sessionId' => $sessionId, 'error' => $e->getMessage()]); }); } /** * Process a batch message */ - private function processBatchRequest(BatchRequest $batch, string $sessionId): BatchResponse + private function processBatchRequest(BatchRequest $batch, SessionInterface $session): BatchResponse { $batchResponse = new BatchResponse(); foreach ($batch->getNotifications() as $notification) { - $this->processNotification($notification, $sessionId); + $this->processNotification($notification, $session); } foreach ($batch->getRequests() as $request) { - $response = $this->processRequest($request, $sessionId); + $response = $this->processRequest($request, $session); $batchResponse->add($response); } @@ -126,33 +146,147 @@ private function processBatchRequest(BatchRequest $batch, string $sessionId): Ba /** * Process a request message */ - private function processRequest(Request $request, string $sessionId): Response|Error + private function processRequest(Request $request, SessionInterface $session): Response|Error { - return $this->requestProcessor->processRequest($request, $sessionId); + $method = $request->method; + $params = $request->params; + + try { + /** @var Result|null $result */ + $result = null; + + if ($method === 'initialize') { + $result = $this->requestHandler->handleInitialize($params, $session); + } elseif ($method === 'ping') { + $result = $this->requestHandler->handlePing($session); + } else { + $this->validateSessionInitialized($session); + [$type, $action] = $this->parseMethod($method); + $this->validateCapabilityEnabled($type); + + $result = match ($type) { + 'tools' => match ($action) { + 'list' => $this->requestHandler->handleToolList($params), + 'call' => $this->requestHandler->handleToolCall($params), + default => throw McpServerException::methodNotFound($method), + }, + 'resources' => match ($action) { + 'list' => $this->requestHandler->handleResourcesList($params), + 'read' => $this->requestHandler->handleResourceRead($params), + 'subscribe' => $this->requestHandler->handleResourceSubscribe($params, $session), + 'unsubscribe' => $this->requestHandler->handleResourceUnsubscribe($params, $session), + 'templates/list' => $this->requestHandler->handleResourceTemplateList($params), + default => throw McpServerException::methodNotFound($method), + }, + 'prompts' => match ($action) { + 'list' => $this->requestHandler->handlePromptsList($params), + 'get' => $this->requestHandler->handlePromptGet($params), + default => throw McpServerException::methodNotFound($method), + }, + 'logging' => match ($action) { + 'setLevel' => $this->requestHandler->handleLoggingSetLevel($params, $session), + default => throw McpServerException::methodNotFound($method), + }, + default => throw McpServerException::methodNotFound($method), + }; + } + + return Response::make($result, $request->id); + } catch (McpServerException $e) { + $this->logger->debug('MCP Processor caught McpServerException', ['method' => $method, 'code' => $e->getCode(), 'message' => $e->getMessage(), 'data' => $e->getData()]); + + return $e->toJsonRpcError($request->id); + } catch (Throwable $e) { + $this->logger->error('MCP Processor caught unexpected error', ['method' => $method, 'exception' => $e]); + + return new Error( + jsonrpc: '2.0', + id: $request->id, + code: Error::CODE_INTERNAL_ERROR, + message: 'Internal error processing method ' . $method, + data: $e->getMessage() + ); + } } /** * Process a notification message */ - private function processNotification(Notification $notification, string $sessionId): void + private function processNotification(Notification $notification, SessionInterface $session): void + { + $method = $notification->method; + $params = $notification->params; + + if ($method === 'notifications/initialized') { + $this->requestHandler->handleNotificationInitialized($params, $session); + } + } + + /** + * Validate that a session is initialized + */ + private function validateSessionInitialized(SessionInterface $session): void + { + if (!$session->get('initialized', false)) { + throw McpServerException::invalidRequest('Client session not initialized.'); + } + } + + /** + * Validate that a capability is enabled + */ + private function validateCapabilityEnabled(string $type): void + { + $caps = $this->configuration->capabilities; + + $enabled = match ($type) { + 'tools' => $caps->toolsEnabled, + 'resources', 'resources/templates' => $caps->resourcesEnabled, + 'resources/subscribe', 'resources/unsubscribe' => $caps->resourcesEnabled && $caps->resourcesSubscribe, + 'prompts' => $caps->promptsEnabled, + 'logging' => $caps->loggingEnabled, + default => false, + }; + + if (!$enabled) { + $methodSegment = explode('/', $type)[0]; + throw McpServerException::methodNotFound("MCP capability '{$methodSegment}' is not enabled on this server."); + } + } + + /** + * Parse a method string into type and action + */ + private function parseMethod(string $method): array { - $this->requestProcessor->processNotification($notification, $sessionId); + if (str_contains($method, '/')) { + $parts = explode('/', $method, 2); + if (count($parts) === 2) { + return [$parts[0], $parts[1]]; + } + } + + return [$method, '']; } /** * Handles 'client_connected' event from the transport */ - public function handleClientConnected(string $clientId): void + public function handleClientConnected(string $sessionId): void { - $this->logger->info('Client connected', ['clientId' => $clientId]); + $this->sessionManager->createSession($sessionId); + + $this->logger->info('Client connected', ['sessionId' => $sessionId]); } /** * Handles 'client_disconnected' event from the transport */ - public function handleClientDisconnected(string $clientId, ?string $reason = null): void + public function handleClientDisconnected(string $sessionId, ?string $reason = null): void { - $this->logger->info('Client disconnected', ['clientId' => $clientId, 'reason' => $reason ?? 'N/A']); + $this->sessionManager->deleteSession($sessionId); + + $this->logger->info('Client disconnected', ['clientId' => $sessionId, 'reason' => $reason ?? 'N/A']); } /** diff --git a/src/Registry.php b/src/Registry.php index 7534406..1da879a 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -105,7 +105,7 @@ public function enableNotifications(): void $notification = Notification::make('notifications/tools/list_changed'); $framedMessage = json_encode($notification->toArray(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; if ($framedMessage !== false) { - $this->sessionManager->queueMessageForAll($framedMessage); + // $this->sessionManager->queueMessageForAll($framedMessage); } } }; @@ -115,7 +115,7 @@ public function enableNotifications(): void $notification = Notification::make('notifications/resources/list_changed'); $framedMessage = json_encode($notification->toArray(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; if ($framedMessage !== false) { - $this->sessionManager->queueMessageForAll($framedMessage); + // $this->sessionManager->queueMessageForAll($framedMessage); } } }; @@ -125,7 +125,7 @@ public function enableNotifications(): void $notification = Notification::make('notifications/prompts/list_changed'); $framedMessage = json_encode($notification->toArray(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; if ($framedMessage !== false) { - $this->sessionManager->queueMessageForAll($framedMessage); + // $this->sessionManager->queueMessageForAll($framedMessage); } } }; diff --git a/src/Server.php b/src/Server.php index 0da4213..6a2ca92 100644 --- a/src/Server.php +++ b/src/Server.php @@ -160,7 +160,7 @@ public function listen(ServerTransportInterface $transport, bool $runLoop = true $this->endListen($transport); // If the loop ends, we need to clean up } } catch (Throwable $e) { - $this->configuration->logger->critical('Failed to start listening or event loop crashed.', ['exception' => $e]); + $this->configuration->logger->critical('Failed to start listening or event loop crashed.', ['exception' => $e->getMessage()]); $this->endListen($transport); throw $e; } diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index 8ead57b..8047353 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -8,15 +8,18 @@ use PhpMcp\Server\Exception\ConfigurationException; use PhpMcp\Server\Exception\DefinitionException; use PhpMcp\Server\Model\Capabilities; +use PhpMcp\Server\Session\ArraySessionHandler; +use PhpMcp\Server\Session\CacheSessionHandler; use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Support\HandlerResolver; -use PhpMcp\Server\Support\RequestProcessor; +use PhpMcp\Server\Support\RequestHandler; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Psr\SimpleCache\CacheInterface; use React\EventLoop\Loop; use React\EventLoop\LoopInterface; +use SessionHandlerInterface; use Throwable; final class ServerBuilder @@ -35,6 +38,10 @@ final class ServerBuilder private ?LoopInterface $loop = null; + private ?SessionHandlerInterface $sessionHandler = null; + + private ?int $sessionTtl = 3600; + private ?int $definitionCacheTtl = 3600; private ?int $paginationLimit = 50; @@ -103,6 +110,30 @@ public function withCache(CacheInterface $cache, int $definitionCacheTtl = 3600) return $this; } + public function withSessionHandler(SessionHandlerInterface $sessionHandler, int $sessionTtl = 3600): self + { + $this->sessionHandler = $sessionHandler; + $this->sessionTtl = $sessionTtl; + + return $this; + } + + public function withArraySessionHandler(int $sessionTtl = 3600): self + { + $this->sessionHandler = new ArraySessionHandler($sessionTtl); + $this->sessionTtl = $sessionTtl; + + return $this; + } + + public function withCacheSessionHandler(CacheInterface $cache, int $sessionTtl = 3600): self + { + $this->sessionHandler = new CacheSessionHandler($cache, $sessionTtl); + $this->sessionTtl = $sessionTtl; + + return $this; + } + /** * Provides a PSR-11 DI container, primarily for resolving user-defined handler classes. * Defaults to a basic internal container. @@ -193,10 +224,10 @@ public function build(): Server paginationLimit: $this->paginationLimit ?? 50 ); - $sessionManager = new SessionManager($logger, $cache, $configuration->definitionCacheTtl); + $sessionHandler = $this->sessionHandler ?? new ArraySessionHandler(3600); + $sessionManager = new SessionManager($sessionHandler, $logger, $loop, $this->sessionTtl); $registry = new Registry($logger, $cache, $sessionManager); - $requestProcessor = new RequestProcessor($configuration, $registry, $sessionManager); - $protocol = new Protocol($logger, $requestProcessor); + $protocol = new Protocol($configuration, $registry, $sessionManager); $registry->disableNotifications(); diff --git a/src/Session/ArraySessionHandler.php b/src/Session/ArraySessionHandler.php new file mode 100644 index 0000000..d4d8c33 --- /dev/null +++ b/src/Session/ArraySessionHandler.php @@ -0,0 +1,78 @@ + + */ + protected array $store = []; + + public function __construct(public readonly int $ttl = 3600) {} + + public function open(string $savePath, string $sessionName): bool + { + return true; + } + + public function close(): bool + { + return true; + } + + public function read(string $sessionId): string|false + { + $session = $this->store[$sessionId] ?? ''; + if ($session === '') { + return false; + } + + $currentTimestamp = time(); + + if ($currentTimestamp - $session['timestamp'] > $this->ttl) { + unset($this->store[$sessionId]); + return false; + } + + return $session['data']; + } + + public function write(string $sessionId, string $data): bool + { + $this->store[$sessionId] = [ + 'data' => $data, + 'timestamp' => time(), + ]; + + return true; + } + + public function destroy(string $sessionId): bool + { + if (isset($this->store[$sessionId])) { + unset($this->store[$sessionId]); + } + + return true; + } + + public function gc(int $maxLifetime): int|false + { + $currentTimestamp = time(); + + $deletedSessions = 0; + foreach ($this->store as $sessionId => $session) { + if ($currentTimestamp - $session['timestamp'] > $maxLifetime) { + unset($this->store[$sessionId]); + $deletedSessions++; + } + } + + return $deletedSessions; + } +} diff --git a/src/Session/CacheSessionHandler.php b/src/Session/CacheSessionHandler.php new file mode 100644 index 0000000..5391e8c --- /dev/null +++ b/src/Session/CacheSessionHandler.php @@ -0,0 +1,43 @@ +cache->get($sessionId, false); + } + + public function write(string $sessionId, string $data): bool + { + return $this->cache->set($sessionId, $data, $this->ttl); + } + + public function destroy(string $sessionId): bool + { + return $this->cache->delete($sessionId); + } + + public function gc(int $maxLifetime): int|false + { + return 0; + } +} diff --git a/src/Session/Session.php b/src/Session/Session.php index bf09ef5..38f95fd 100644 --- a/src/Session/Session.php +++ b/src/Session/Session.php @@ -5,29 +5,35 @@ namespace PhpMcp\Server\Session; use PhpMcp\Server\Contracts\SessionInterface; +use SessionHandlerInterface; class Session implements SessionInterface { - protected string $id; - /** * @var array Stores all session data. * Keys are snake_case by convention for MCP-specific data. + * + * Official keys are: + * - initialized: bool + * - client_info: array|null + * - protocol_version: string|null + * - subscriptions: array + * - message_queue: array + * - log_level: string|null */ - protected array $data = [ - 'initialized' => false, - 'client_info' => null, - 'protocol_version' => null, - 'subscriptions' => [], // [uri => true] - 'message_queue' => [], // string[] (raw JSON-RPC frames) - 'requested_log_level' => null, - 'last_activity_timestamp' => 0, - ]; - - public function __construct(string $sessionId) - { - $this->id = $sessionId; - $this->touch(); + protected array $data = []; + + public function __construct( + protected SessionHandlerInterface $handler, + protected string $id = '' + ) { + if (empty($this->id)) { + $this->id = $this->generateId(); + } + + if ($data = $this->handler->read($this->id)) { + $this->data = json_decode($data, true) ?? []; + } } public function getId(): string @@ -35,17 +41,17 @@ public function getId(): string return $this->id; } - public function initialize(): void + public function generateId(): string { - $this->setAttribute('initialized', true); + return bin2hex(random_bytes(16)); } - public function isInitialized(): bool + public function save(): void { - return (bool) $this->getAttribute('initialized', false); + $this->handler->write($this->id, json_encode($this->data)); } - public function getAttribute(string $key, mixed $default = null): mixed + public function get(string $key, mixed $default = null): mixed { $key = explode('.', $key); $data = $this->data; @@ -61,7 +67,7 @@ public function getAttribute(string $key, mixed $default = null): mixed return $data; } - public function setAttribute(string $key, mixed $value, bool $overwrite = true): void + public function set(string $key, mixed $value, bool $overwrite = true): void { $segments = explode('.', $key); $data = &$this->data; @@ -78,10 +84,9 @@ public function setAttribute(string $key, mixed $value, bool $overwrite = true): if ($overwrite || !isset($data[$lastKey])) { $data[$lastKey] = $value; } - $this->touch(); } - public function hasAttribute(string $key): bool + public function has(string $key): bool { $key = explode('.', $key); $data = $this->data; @@ -99,7 +104,7 @@ public function hasAttribute(string $key): bool return true; } - public function forgetAttribute(string $key): void + public function forget(string $key): void { $segments = explode('.', $key); $data = &$this->data; @@ -116,23 +121,26 @@ public function forgetAttribute(string $key): void if (isset($data[$lastKey])) { unset($data[$lastKey]); } + } - $this->touch(); + public function clear(): void + { + $this->data = []; } - public function pullAttribute(string $key, mixed $default = null): mixed + public function pull(string $key, mixed $default = null): mixed { - $value = $this->getAttribute($key, $default); - $this->forgetAttribute($key); + $value = $this->get($key, $default); + $this->forget($key); return $value; } - public function getAttributes(): array + public function all(): array { return $this->data; } - public function setAttributes(array $attributes): void + public function hydrate(array $attributes): void { $this->data = array_merge( [ @@ -141,23 +149,12 @@ public function setAttributes(array $attributes): void 'protocol_version' => null, 'subscriptions' => [], 'message_queue' => [], - 'requested_log_level' => null, - 'last_activity_timestamp' => 0, + 'log_level' => null, + 'timestamp' => 0, ], $attributes ); unset($this->data['id']); - - if (!isset($attributes['last_activity_timestamp'])) { - $this->touch(); - } else { - $this->data['last_activity_timestamp'] = (int) $attributes['last_activity_timestamp']; - } - } - - public function touch(): void - { - $this->data['last_activity_timestamp'] = time(); } public function queueMessage(string $rawFramedMessage): void @@ -169,11 +166,6 @@ public function dequeueMessages(): array { $messages = $this->data['message_queue'] ?? []; $this->data['message_queue'] = []; - - if (!empty($messages)) { - $this->touch(); - } - return $messages; } @@ -184,6 +176,6 @@ public function hasQueuedMessages(): bool public function jsonSerialize(): array { - return $this->getAttributes(); + return $this->all(); } } diff --git a/src/Session/SessionManager.php b/src/Session/SessionManager.php index 17cdd82..26957f2 100644 --- a/src/Session/SessionManager.php +++ b/src/Session/SessionManager.php @@ -4,301 +4,129 @@ namespace PhpMcp\Server\Session; +use Evenement\EventEmitterInterface; +use Evenement\EventEmitterTrait; use PhpMcp\Server\Contracts\SessionInterface; -use PhpMcp\Server\Defaults\ArrayCache; use Psr\Log\LoggerInterface; -use Psr\SimpleCache\CacheInterface; -use Throwable; +use React\EventLoop\Loop; +use React\EventLoop\LoopInterface; +use React\EventLoop\TimerInterface; +use SessionHandlerInterface; -class SessionManager +class SessionManager implements EventEmitterInterface { - protected string $cachePrefix = 'mcp_session_'; - public const GLOBAL_ACTIVE_SESSIONS_KEY = 'mcp_active_sessions'; - public const GLOBAL_RESOURCE_SUBSCRIBERS_KEY_PREFIX = 'mcp_res_subs_'; - protected array $activeSessions = []; + use EventEmitterTrait; + + protected ?TimerInterface $gcTimer = null; + protected int $gcInterval = 300; // 5 minutes public function __construct( + protected SessionHandlerInterface $handler, protected LoggerInterface $logger, - protected ?CacheInterface $cache = null, - protected int $cacheTtl = 3600 + protected ?LoopInterface $loop = null, + protected int $ttl = 3600 ) { - $this->cache ??= new ArrayCache(); - } - - protected function getActiveSessionsCacheKey(): string - { - return $this->cachePrefix . self::GLOBAL_ACTIVE_SESSIONS_KEY; - } - - protected function getResourceSubscribersCacheKey(string $uri): string - { - return self::GLOBAL_RESOURCE_SUBSCRIBERS_KEY_PREFIX . sha1($uri); + $this->loop ??= Loop::get(); } - public function getSession(string $sessionId, bool $createIfNotFound = false): ?SessionInterface + /** + * Start the garbage collection timer + */ + public function startGcTimer(): void { - $key = $this->cachePrefix . $sessionId; - $json = $this->cache->get($key); - - if ($json === null) { - return $createIfNotFound ? new Session($sessionId) : null; + if ($this->gcTimer !== null) { + return; } - try { - $attributes = json_decode($json, true, 512, JSON_THROW_ON_ERROR); - $session = new Session($sessionId); - $session->setAttributes($attributes); - return $session; - } catch (Throwable $e) { - $this->logger->warning('Failed to decode session data from cache.', ['sessionId' => $sessionId, 'key' => $key, 'exception' => $e]); - $this->cache->delete($key); - return $createIfNotFound ? new Session($sessionId) : null; - } + $this->gcTimer = $this->loop->addPeriodicTimer($this->gcInterval, function () { + $count = $this->handler->gc($this->ttl); + $this->logger->debug('Session garbage collection complete', ['purged_sessions' => $count]); + }); } - public function isSessionInitialized(string $sessionId): bool + /** + * Stop the garbage collection timer + */ + public function stopGcTimer(): void { - $session = $this->getSession($sessionId); - return $session !== null && $session->isInitialized(); - } - - public function initializeSession(string $sessionId): void - { - $session = $this->getSession($sessionId, true); - $session->initialize(); - - if ($this->saveSession($session)) { - $this->activeSessions[] = $sessionId; + if ($this->gcTimer !== null && $this->loop !== null) { + $this->loop->cancelTimer($this->gcTimer); + $this->gcTimer = null; } } - public function saveSession(SessionInterface $session): bool + /** + * Create a new session + */ + public function createSession(string $sessionId): SessionInterface { - try { - $key = $this->cachePrefix . $session->getId(); - $json = json_encode($session, JSON_THROW_ON_ERROR); - return $this->cache->set($key, $json, $this->cacheTtl); - } catch (Throwable $e) { - $this->logger->warning('Failed to save session data to cache.', ['sessionId' => $session->getId(), 'exception' => $e->getMessage()]); - return false; - } - } - - public function deleteSession(string $sessionId, bool $updateCache = true): bool - { - $this->removeAllResourceSubscriptions($sessionId); - - $key = $this->cachePrefix . $sessionId; - - try { - $this->cache->delete($key); - } catch (Throwable $e) { - $this->logger->error('Failed to delete session.', ['sessionId' => $sessionId, 'exception' => $e]); - } - - if ($updateCache) { - $activeSessionsKey = $this->getActiveSessionsCacheKey(); - try { - $activeSessions = $this->cache->get($activeSessionsKey, []); + $session = new Session($this->handler, $sessionId); - if (isset($activeSessions[$sessionId])) { - unset($activeSessions[$sessionId]); - $this->cache->set($activeSessionsKey, $activeSessions, $this->cacheTtl); - } - } catch (Throwable $e) { - $this->logger->error('Failed to update global active sessions list during cleanup.', ['sessionId' => $sessionId, 'exception' => $e]); - } - } + $session->hydrate([ + 'initialized' => false, + 'client_info' => null, + 'protocol_version' => null, + 'subscriptions' => [], // [uri => true] + 'message_queue' => [], // string[] (raw JSON-RPC frames) + 'log_level' => null, + ]); - $this->logger->info('Session deleted.', ['sessionId' => $sessionId]); + $session->save(); - return true; - } + $this->logger->info('Session created', ['sessionId' => $sessionId]); + $this->emit('session_created', [$sessionId, $session]); - public function touchSession(string $sessionId): void - { - $session = $this->getSession($sessionId, true); - if ($session === null) return; - $session->touch(); - $this->saveSession($session); + return $session; } - public function getActiveSessions(int $inactiveThreshold = 300): array + /** + * Get an existing session + */ + public function getSession(string $sessionId): ?SessionInterface { - try { - $activeSessionsKey = $this->getActiveSessionsCacheKey(); - $activeSessions = $this->cache->get($activeSessionsKey, []); - - $currentTimeStamp = time(); - $sessionsToCleanUp = []; + $session = new Session($this->handler, $sessionId); - foreach ($activeSessions as $sessionId) { - $session = $this->getSession($sessionId, false); - if (!$session) { - $sessionsToCleanUp[] = $sessionId; - continue; - } - - $lastActivityTimestamp = $session->getAttribute('last_activity_timestamp'); - if ($currentTimeStamp - $lastActivityTimestamp > $inactiveThreshold) { - $sessionsToCleanUp[] = $sessionId; - } - } - - foreach ($sessionsToCleanUp as $sessionIdToClean) { - unset($activeSessions[$sessionIdToClean]); - $this->deleteSession($sessionIdToClean, false); - } - - $this->cache->set($activeSessionsKey, $activeSessions, $this->cacheTtl); - - return $activeSessions; - } catch (Throwable $e) { - $this->logger->error('Failed to get active sessions.', ['exception' => $e]); - - return []; + if (empty($session->all())) { + return null; } - } - public function storeClientInfo(string $sessionId, array $clientInfo): void - { - $session = $this->getSession($sessionId, true); - $session->setAttribute('client_info', $clientInfo); - $this->saveSession($session); + return $session; } - public function addResourceSubscription(string $sessionId, string $uri): void + /** + * Delete a session completely + */ + public function deleteSession(string $sessionId): bool { - $session = $this->getSession($sessionId, true); - if ($session === null) return; - - $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); - - try { - $subscriptions = $session->getAttribute('subscriptions', []); - $subscriptions[$uri] = true; - $session->setAttribute('subscriptions', $subscriptions); - $this->saveSession($session); + $success = $this->handler->destroy($sessionId); - $subscribers = $this->cache->get($resourceSubKey, []); - $subscribers = is_array($subscribers) ? $subscribers : []; - $subscribers[$sessionId] = true; - $this->cache->set($resourceSubKey, $subscribers, $this->cacheTtl); - } catch (Throwable $e) { - $this->logger->warning('Failed to add resource subscription to session.', ['sessionId' => $sessionId, 'uri' => $uri, 'exception' => $e->getMessage()]); + if ($success) { + $this->emit('session_deleted', [$sessionId]); + $this->logger->info('Session deleted', ['sessionId' => $sessionId]); + } else { + $this->logger->warning('Failed to delete session', ['sessionId' => $sessionId]); } - } - - public function removeResourceSubscription(string $sessionId, string $uri): void - { - $session = $this->getSession($sessionId, true); - $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); - - try { - if ($session) { - $subscriptions = $session->getAttribute('subscriptions', []); - unset($subscriptions[$uri]); - $session->setAttribute('subscriptions', $subscriptions); - $this->saveSession($session); - } - $subscribers = $this->cache->get($resourceSubKey, []); - $subscribers = is_array($subscribers) ? $subscribers : []; - $changed = false; - - if (isset($subscribers[$sessionId])) { - unset($subscribers[$sessionId]); - $changed = true; - } - - if ($changed) { - if (empty($subscribers)) { - $this->cache->delete($resourceSubKey); - } else { - $this->cache->set($resourceSubKey, $subscribers, $this->cacheTtl); - } - $this->logger->debug('Session unsubscribed from resource.', ['sessionId' => $sessionId, 'uri' => $uri]); - } - } catch (Throwable $e) { - $this->logger->warning('Failed to remove resource subscription from session.', ['sessionId' => $sessionId, 'uri' => $uri, 'exception' => $e->getMessage()]); - } - } - - public function removeAllResourceSubscriptions(string $sessionId): void - { - $session = $this->getSession($sessionId, true); - if ($session === null || empty($session->getAttribute('subscriptions'))) return; - - $urisSessionWasSubscribedTo = array_keys($session->getAttribute('subscriptions')); - - try { - $session->forgetAttribute('subscriptions'); - $this->saveSession($session); - - foreach ($urisSessionWasSubscribedTo as $uri) { - $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); - $subscribers = $this->cache->get($resourceSubKey, []); - $subscribers = is_array($subscribers) ? $subscribers : []; - if (isset($subscribers[$sessionId])) { - unset($subscribers[$sessionId]); - if (empty($subscribers)) { - $this->cache->delete($resourceSubKey); - } else { - $this->cache->set($resourceSubKey, $subscribers, $this->cacheTtl); - } - } - } - $this->logger->debug('Removed all resource subscriptions for session.', ['sessionId' => $sessionId, 'count' => count($urisSessionWasSubscribedTo)]); - } catch (Throwable $e) { - $this->logger->error('Failed to remove all resource subscriptions.', ['sessionId' => $sessionId, 'exception' => $e]); - } - } - - 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 $sessionId, string $uri): bool - { - $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); - - try { - $subscribers = $this->cache->get($resourceSubKey, []); - - return is_array($subscribers) && isset($subscribers[$sessionId]); - } catch (Throwable $e) { - $this->logger->error('Failed to check resource subscription.', ['sessionId' => $sessionId, 'uri' => $uri, 'exception' => $e]); - - return false; - } + return $success; } public function queueMessage(string $sessionId, string $message): void { - $session = $this->getSession($sessionId, true); + $session = $this->getSession($sessionId); if ($session === null) return; $session->queueMessage($message); - $this->saveSession($session); + $session->save(); } public function dequeueMessages(string $sessionId): array { - $session = $this->getSession($sessionId, true); + $session = $this->getSession($sessionId); if ($session === null) return []; $messages = $session->dequeueMessages(); - $this->saveSession($session); + $session->save(); + return $messages; } @@ -309,30 +137,4 @@ public function hasQueuedMessages(string $sessionId): bool return $session->hasQueuedMessages(); } - - public function queueMessageForAll(string $message): void - { - $activeSessions = $this->getActiveSessions(); - - foreach ($activeSessions as $sessionId) { - $this->queueMessage($sessionId, $message); - } - } - - public function setLogLevel(string $sessionId, string $level): void - { - $session = $this->getSession($sessionId, true); - if ($session === null) return; - - $session->setAttribute('log_level', $level); - $this->saveSession($session); - } - - public function getLogLevel(string $sessionId): ?string - { - $session = $this->getSession($sessionId, true); - if ($session === null) return null; - - return $session->getAttribute('log_level'); - } } diff --git a/src/Support/RandomIdGenerator.php b/src/Support/RandomIdGenerator.php new file mode 100644 index 0000000..96eea5c --- /dev/null +++ b/src/Support/RandomIdGenerator.php @@ -0,0 +1,15 @@ +container = $this->configuration->container; + $this->logger = $this->configuration->logger; + + $this->schemaValidator ??= new SchemaValidator($this->logger); + $this->argumentPreparer ??= new ArgumentPreparer($this->logger); + } + + public function handleInitialize(array $params, SessionInterface $session): InitializeResult + { + $protocolVersion = $params['protocolVersion'] ?? null; + if (! $protocolVersion) { + throw McpServerException::invalidParams("Missing 'protocolVersion' parameter."); + } + + if (! in_array($protocolVersion, Protocol::SUPPORTED_PROTOCOL_VERSIONS)) { + $this->logger->warning("Unsupported protocol version: {$protocolVersion}", [ + 'supportedVersions' => Protocol::SUPPORTED_PROTOCOL_VERSIONS, + ]); + } + + $serverProtocolVersion = Protocol::SUPPORTED_PROTOCOL_VERSIONS[count(Protocol::SUPPORTED_PROTOCOL_VERSIONS) - 1]; + + $clientInfo = $params['clientInfo'] ?? null; + if (! is_array($clientInfo)) { + throw McpServerException::invalidParams("Missing or invalid 'clientInfo' parameter."); + } + + $session->set('client_info', $clientInfo); + + $serverInfo = [ + 'name' => $this->configuration->serverName, + 'version' => $this->configuration->serverVersion, + ]; + + $serverCapabilities = $this->configuration->capabilities; + $responseCapabilities = $serverCapabilities->toInitializeResponseArray(); + + $instructions = $serverCapabilities->instructions; + + return new InitializeResult($serverInfo, $serverProtocolVersion, $responseCapabilities, $instructions); + } + + public function handlePing(): EmptyResult + { + return new EmptyResult(); + } + + public function handleToolList(array $params): ListToolsResult + { + $cursor = $params['cursor'] ?? null; + $limit = $this->configuration->paginationLimit; + $offset = $this->decodeCursor($cursor); + $allItems = $this->registry->allTools()->getArrayCopy(); + $pagedItems = array_slice($allItems, $offset, $limit); + $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); + + return new ListToolsResult(array_values($pagedItems), $nextCursor); + } + + public function handleResourcesList(array $params): ListResourcesResult + { + $cursor = $params['cursor'] ?? null; + $limit = $this->configuration->paginationLimit; + $offset = $this->decodeCursor($cursor); + $allItems = $this->registry->allResources()->getArrayCopy(); + $pagedItems = array_slice($allItems, $offset, $limit); + $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); + + return new ListResourcesResult(array_values($pagedItems), $nextCursor); + } + + public function handleResourceTemplateList(array $params): ListResourceTemplatesResult + { + $cursor = $params['cursor'] ?? null; + $limit = $this->configuration->paginationLimit; + $offset = $this->decodeCursor($cursor); + $allItems = $this->registry->allResourceTemplates()->getArrayCopy(); + $pagedItems = array_slice($allItems, $offset, $limit); + $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); + + return new ListResourceTemplatesResult(array_values($pagedItems), $nextCursor); + } + + public function handlePromptsList(array $params): ListPromptsResult + { + $cursor = $params['cursor'] ?? null; + $limit = $this->configuration->paginationLimit; + $offset = $this->decodeCursor($cursor); + $allItems = $this->registry->allPrompts()->getArrayCopy(); + $pagedItems = array_slice($allItems, $offset, $limit); + $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); + + return new ListPromptsResult(array_values($pagedItems), $nextCursor); + } + + public function handleToolCall(array $params): CallToolResult + { + $toolName = $params['name'] ?? null; + $arguments = $params['arguments'] ?? null; + + if (! is_string($toolName) || empty($toolName)) { + throw McpServerException::invalidParams("Missing or invalid 'name' parameter for tools/call."); + } + + if ($arguments === null || $arguments === []) { + $arguments = new stdClass(); + } elseif (! is_array($arguments) && ! $arguments instanceof stdClass) { + throw McpServerException::invalidParams("Parameter 'arguments' must be an object/array for tools/call."); + } + + $definition = $this->registry->findTool($toolName); + if (! $definition) { + throw McpServerException::methodNotFound("Tool '{$toolName}' not found."); + } + + $inputSchema = $definition->inputSchema; + + $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($arguments, $inputSchema); + + if (! empty($validationErrors)) { + $errorMessages = []; + + foreach ($validationErrors as $errorDetail) { + $pointer = $errorDetail['pointer'] ?? ''; + $message = $errorDetail['message'] ?? 'Unknown validation error'; + $errorMessages[] = ($pointer !== '/' && $pointer !== '' ? "Property '{$pointer}': " : '') . $message; + } + + $summaryMessage = "Invalid parameters for tool '{$toolName}': " . implode('; ', array_slice($errorMessages, 0, 3)); + + if (count($errorMessages) > 3) { + $summaryMessage .= '; ...and more errors.'; + } + + throw McpServerException::invalidParams($summaryMessage, data: ['validation_errors' => $validationErrors]); + } + + $argumentsForPhpCall = (array) $arguments; + + try { + $instance = $this->container->get($definition->className); + $methodName = $definition->methodName; + + $args = $this->argumentPreparer->prepareMethodArguments( + $instance, + $methodName, + $argumentsForPhpCall, + $inputSchema + ); + + $toolExecutionResult = $instance->{$methodName}(...$args); + $formattedResult = $this->formatToolResult($toolExecutionResult); + + return new CallToolResult($formattedResult, false); + } catch (JsonException $e) { + $this->logger->warning('MCP SDK: Failed to JSON encode tool result.', ['tool' => $toolName, 'exception' => $e]); + $errorMessage = "Failed to serialize tool result: {$e->getMessage()}"; + + return new CallToolResult([new TextContent($errorMessage)], true); + } catch (Throwable $toolError) { + $this->logger->error('MCP SDK: Tool execution failed.', ['tool' => $toolName, 'exception' => $toolError]); + $errorContent = $this->formatToolErrorResult($toolError); + + return new CallToolResult($errorContent, true); + } + } + + public function handleResourceRead(array $params): ReadResourceResult + { + $uri = $params['uri'] ?? null; + if (! is_string($uri) || empty($uri)) { + throw McpServerException::invalidParams("Missing or invalid 'uri' parameter for resources/read."); + } + + $definition = null; + $uriVariables = []; + + $definition = $this->registry->findResourceByUri($uri); + + if (! $definition) { + $templateResult = $this->registry->findResourceTemplateByUri($uri); + if ($templateResult) { + $definition = $templateResult['definition']; + $uriVariables = $templateResult['variables']; + } else { + throw McpServerException::invalidParams("Resource URI '{$uri}' not found or no handler available."); + } + } + + try { + $instance = $this->container->get($definition->className); + $methodName = $definition->methodName; + + $methodParams = array_merge($uriVariables, ['uri' => $uri]); + + $args = $this->argumentPreparer->prepareMethodArguments( + $instance, + $methodName, + $methodParams, + [] + ); + + $readResult = $instance->{$methodName}(...$args); + $contents = $this->formatResourceContents($readResult, $uri, $definition->mimeType); + + return new ReadResourceResult($contents); + } catch (JsonException $e) { + $this->logger->warning('MCP SDK: Failed to JSON encode resource content.', ['exception' => $e, 'uri' => $uri]); + throw McpServerException::internalError("Failed to serialize resource content for '{$uri}'.", $e); + } catch (McpServerException $e) { + throw $e; + } catch (Throwable $e) { + $this->logger->error('MCP SDK: Resource read failed.', ['uri' => $uri, 'exception' => $e]); + throw McpServerException::resourceReadFailed($uri, $e); + } + } + + public function handleResourceSubscribe(array $params, SessionInterface $session): EmptyResult + { + $uri = $params['uri'] ?? null; + if (! is_string($uri) || empty($uri)) { + throw McpServerException::invalidParams("Missing or invalid 'uri' parameter for resources/subscribe."); + } + + $subscriptions = $session->get('subscriptions', []); + $subscriptions[$uri] = true; + $session->set('subscriptions', $subscriptions); + + return new EmptyResult(); + } + + public function handleResourceUnsubscribe(array $params, SessionInterface $session): EmptyResult + { + $uri = $params['uri'] ?? null; + if (! is_string($uri) || empty($uri)) { + throw McpServerException::invalidParams("Missing or invalid 'uri' parameter for resources/unsubscribe."); + } + + $subscriptions = $session->get('subscriptions', []); + unset($subscriptions[$uri]); + $session->set('subscriptions', $subscriptions); + + return new EmptyResult(); + } + + public function handlePromptGet(array $params): GetPromptResult + { + $promptName = $params['name'] ?? null; + $arguments = $params['arguments'] ?? []; + + if (! is_string($promptName) || empty($promptName)) { + throw McpServerException::invalidParams("Missing or invalid 'name' parameter for prompts/get."); + } + if (! is_array($arguments) && ! $arguments instanceof stdClass) { + throw McpServerException::invalidParams("Parameter 'arguments' must be an object/array for prompts/get."); + } + + $definition = $this->registry->findPrompt($promptName); + if (! $definition) { + throw McpServerException::invalidParams("Prompt '{$promptName}' not found."); + } + + $arguments = (array) $arguments; + + foreach ($definition->arguments as $argDef) { + if ($argDef->required && ! array_key_exists($argDef->name, $arguments)) { + throw McpServerException::invalidParams("Missing required argument '{$argDef->name}' for prompt '{$promptName}'."); + } + } + + try { + $instance = $this->container->get($definition->className); + $methodName = $definition->methodName; + + $args = $this->argumentPreparer->prepareMethodArguments( + $instance, + $methodName, + $arguments, + [] + ); + + $promptGenerationResult = $instance->{$methodName}(...$args); + $messages = $this->formatPromptMessages($promptGenerationResult); + + return new GetPromptResult($messages, $definition->description); + } catch (JsonException $e) { + $this->logger->warning('MCP SDK: Failed to JSON encode prompt messages.', ['exception' => $e, 'promptName' => $promptName]); + throw McpServerException::internalError("Failed to serialize prompt messages for '{$promptName}'.", $e); + } catch (McpServerException $e) { + throw $e; // Re-throw known MCP errors + } catch (Throwable $e) { + $this->logger->error('MCP SDK: Prompt generation failed.', ['promptName' => $promptName, 'exception' => $e]); + throw McpServerException::promptGenerationFailed($promptName, $e); // Use specific factory + } + } + + public function handleLoggingSetLevel(array $params, SessionInterface $session): EmptyResult + { + $level = $params['level'] ?? null; + $validLevels = [ + LogLevel::EMERGENCY, + LogLevel::ALERT, + LogLevel::CRITICAL, + LogLevel::ERROR, + LogLevel::WARNING, + LogLevel::NOTICE, + LogLevel::INFO, + LogLevel::DEBUG, + ]; + + if (! is_string($level) || ! in_array(strtolower($level), $validLevels)) { + throw McpServerException::invalidParams("Invalid or missing 'level'. Must be one of: " . implode(', ', $validLevels)); + } + + $session->set('log_level', strtolower($level)); + + $this->logger->info("Log level set to '{$level}'.", ['sessionId' => $session->getId()]); + + return new EmptyResult(); + } + + public function handleNotificationInitialized(array $params, SessionInterface $session): EmptyResult + { + $session->set('initialized', true); + + return new EmptyResult(); + } + + private function decodeCursor(?string $cursor): int + { + if ($cursor === null) { + return 0; + } + $decoded = base64_decode($cursor, true); + if ($decoded === false) { + $this->logger->warning('Received invalid pagination cursor (not base64)', ['cursor' => $cursor]); + + return 0; + } + if (preg_match('/^offset=(\d+)$/', $decoded, $matches)) { + return (int) $matches[1]; + } + $this->logger->warning('Received invalid pagination cursor format', ['cursor' => $decoded]); + + return 0; + } + + private function encodeNextCursor(int $currentOffset, int $returnedCount, int $totalCount, int $limit): ?string + { + $nextOffset = $currentOffset + $returnedCount; + if ($returnedCount > 0 && $nextOffset < $totalCount) { + return base64_encode("offset={$nextOffset}"); + } + + return null; + } +} diff --git a/src/Transports/HttpServerTransport.php b/src/Transports/HttpServerTransport.php index bd14451..caea6e6 100644 --- a/src/Transports/HttpServerTransport.php +++ b/src/Transports/HttpServerTransport.php @@ -5,11 +5,13 @@ namespace PhpMcp\Server\Transports; use Evenement\EventEmitterTrait; +use PhpMcp\Server\Contracts\IdGeneratorInterface; use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\TransportException; use PhpMcp\Server\JsonRpc\Messages\Message as JsonRpcMessage; +use PhpMcp\Server\Support\RandomIdGenerator; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -44,8 +46,10 @@ class HttpServerTransport implements ServerTransportInterface, LoggerAwareInterf protected ?HttpServer $http = null; - /** @var array clientId => SSE Stream */ - protected array $activeSseStreams = []; + private IdGeneratorInterface $idGenerator; + + /** @var array sessionId => SSE Stream */ + private array $activeSseStreams = []; protected bool $listening = false; @@ -62,15 +66,17 @@ class HttpServerTransport implements ServerTransportInterface, LoggerAwareInterf * @param array|null $sslContext Optional SSL context options for React SocketServer (for HTTPS). */ public function __construct( - protected readonly string $host = '127.0.0.1', - protected readonly int $port = 8080, - protected readonly string $mcpPathPrefix = 'mcp', // e.g., /mcp/sse, /mcp/message - protected readonly ?array $sslContext = null // For enabling HTTPS + private readonly string $host = '127.0.0.1', + private readonly int $port = 8080, + private readonly string $mcpPathPrefix = 'mcp', + private readonly ?array $sslContext = null, + ?IdGeneratorInterface $idGenerator = null ) { $this->logger = new NullLogger(); $this->loop = Loop::get(); $this->ssePath = '/' . trim($mcpPathPrefix, '/') . '/sse'; $this->messagePath = '/' . trim($mcpPathPrefix, '/') . '/message'; + $this->idGenerator = $idGenerator ?? new RandomIdGenerator; } public function setLogger(LoggerInterface $logger): void @@ -154,40 +160,41 @@ protected function createRequestHandler(): callable /** Handles a new SSE connection request */ protected function handleSseRequest(ServerRequestInterface $request): Response { - $clientId = 'sse_' . bin2hex(random_bytes(16)); - $this->logger->info('New SSE connection', ['clientId' => $clientId]); + $sessionId = $this->idGenerator->generateId(); + $this->logger->info('New SSE connection', ['sessionId' => $sessionId]); $sseStream = new ThroughStream(); - $sseStream->on('close', function () use ($clientId) { - $this->logger->info('SSE stream closed', ['clientId' => $clientId]); - unset($this->activeSseStreams[$clientId]); - $this->emit('client_disconnected', [$clientId, 'SSE stream closed']); + $sseStream->on('close', function () use ($sessionId) { + $this->logger->info('SSE stream closed', ['sessionId' => $sessionId]); + unset($this->activeSseStreams[$sessionId]); + $this->emit('client_disconnected', [$sessionId, 'SSE stream closed']); }); - $sseStream->on('error', function (Throwable $error) use ($clientId) { - $this->logger->warning('SSE stream error', ['clientId' => $clientId, 'error' => $error->getMessage()]); - unset($this->activeSseStreams[$clientId]); - $this->emit('error', [new TransportException("SSE Stream Error: {$error->getMessage()}", 0, $error), $clientId]); - $this->emit('client_disconnected', [$clientId, 'SSE stream error']); + $sseStream->on('error', function (Throwable $error) use ($sessionId) { + $this->logger->warning('SSE stream error', ['sessionId' => $sessionId, 'error' => $error->getMessage()]); + unset($this->activeSseStreams[$sessionId]); + $this->emit('error', [new TransportException("SSE Stream Error: {$error->getMessage()}", 0, $error), $sessionId]); + $this->emit('client_disconnected', [$sessionId, 'SSE stream error']); }); - $this->activeSseStreams[$clientId] = $sseStream; + $this->activeSseStreams[$sessionId] = $sseStream; - $this->loop->futureTick(function () use ($clientId, $request, $sseStream) { - if (! isset($this->activeSseStreams[$clientId]) || ! $sseStream->isWritable()) { - $this->logger->warning('Cannot send initial endpoint event, stream closed/invalid early.', ['clientId' => $clientId]); + $this->loop->futureTick(function () use ($sessionId, $request, $sseStream) { + if (! isset($this->activeSseStreams[$sessionId]) || ! $sseStream->isWritable()) { + $this->logger->warning('Cannot send initial endpoint event, stream closed/invalid early.', ['sessionId' => $sessionId]); return; } try { - $postEndpoint = $this->messagePath . "?clientId={$clientId}"; - $this->sendSseEvent($sseStream, 'endpoint', $postEndpoint, "init-{$clientId}"); + $baseUri = $request->getUri()->withPath($this->messagePath)->withQuery('')->withFragment(''); + $postEndpointWithId = (string) $baseUri->withQuery("clientId={$sessionId}"); + $this->sendSseEvent($sseStream, 'endpoint', $postEndpointWithId, "init-{$sessionId}"); - $this->emit('client_connected', [$clientId]); + $this->emit('client_connected', [$sessionId]); } catch (Throwable $e) { - $this->logger->error('Error sending initial endpoint event', ['clientId' => $clientId, 'exception' => $e]); + $this->logger->error('Error sending initial endpoint event', ['sessionId' => $sessionId, 'exception' => $e]); $sseStream->close(); } }); @@ -209,18 +216,18 @@ protected function handleSseRequest(ServerRequestInterface $request): Response protected function handleMessagePostRequest(ServerRequestInterface $request): Response { $queryParams = $request->getQueryParams(); - $clientId = $queryParams['clientId'] ?? null; + $sessionId = $queryParams['clientId'] ?? null; - if (! $clientId || ! is_string($clientId)) { + if (! $sessionId || ! is_string($sessionId)) { $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('Received POST for unknown or disconnected clientId.', ['clientId' => $clientId]); + if (! isset($this->activeSseStreams[$sessionId])) { + $this->logger->warning('Received POST for unknown or disconnected sessionId.', ['sessionId' => $sessionId]); - return new Response(404, ['Content-Type' => 'text/plain'], 'Client ID not found or disconnected'); + return new Response(404, ['Content-Type' => 'text/plain'], 'Session ID not found or disconnected'); } if (! str_contains(strtolower($request->getHeaderLine('Content-Type')), 'application/json')) { @@ -230,7 +237,7 @@ protected function handleMessagePostRequest(ServerRequestInterface $request): Re $body = $request->getBody()->getContents(); if (empty($body)) { - $this->logger->warning('Received empty POST body', ['clientId' => $clientId]); + $this->logger->warning('Received empty POST body', ['sessionId' => $sessionId]); return new Response(400, ['Content-Type' => 'text/plain'], 'Empty request body'); } @@ -238,12 +245,11 @@ protected function handleMessagePostRequest(ServerRequestInterface $request): Re try { $message = JsonRpcMessage::parseRequest($body); } catch (Throwable $e) { - $this->logger->error('Error parsing message', ['clientId' => $clientId, 'exception' => $e]); + $this->logger->error('Error parsing message', ['sessionId' => $sessionId, 'exception' => $e]); return new Response(400, ['Content-Type' => 'text/plain'], 'Invalid JSON-RPC message: ' . $e->getMessage()); } - $this->emit('message', [$message, $clientId]); - + $this->emit('message', [$message, $sessionId]); return new Response(202, ['Content-Type' => 'text/plain'], 'Accepted'); } @@ -252,7 +258,7 @@ protected function handleMessagePostRequest(ServerRequestInterface $request): Re /** * Sends a raw JSON-RPC message frame to a specific client via SSE. */ - public function sendMessage(JsonRpcMessage|null $message, string $sessionId, array $context = []): PromiseInterface + public function sendMessage(JsonRpcMessage $message, string $sessionId, array $context = []): PromiseInterface { if (! isset($this->activeSseStreams[$sessionId])) { return reject(new TransportException("Cannot send message: Client '{$sessionId}' not connected via SSE.")); @@ -327,9 +333,9 @@ public function close(): void $activeStreams = $this->activeSseStreams; $this->activeSseStreams = []; - foreach ($activeStreams as $clientId => $stream) { - $this->logger->debug('Closing active SSE stream', ['clientId' => $clientId]); - unset($this->activeSseStreams[$clientId]); + foreach ($activeStreams as $sessionId => $stream) { + $this->logger->debug('Closing active SSE stream', ['sessionId' => $sessionId]); + unset($this->activeSseStreams[$sessionId]); $stream->close(); } diff --git a/src/Transports/StdioServerTransport.php b/src/Transports/StdioServerTransport.php index b4e46ca..107f2cb 100644 --- a/src/Transports/StdioServerTransport.php +++ b/src/Transports/StdioServerTransport.php @@ -10,6 +10,7 @@ use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\TransportException; use PhpMcp\Server\JsonRpc\Messages\Message as JsonRpcMessage; +use PhpMcp\Server\JsonRpc\Messages\Error as JsonRpcError; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use React\ChildProcess\Process; @@ -29,7 +30,7 @@ * Implementation of the STDIO server transport using ReactPHP Process and Streams. * Listens on STDIN, writes to STDOUT, and emits events for the Protocol. */ -class StdioServerTransport implements LoggerAwareInterface, LoopAwareInterface, ServerTransportInterface +class StdioServerTransport implements ServerTransportInterface, LoggerAwareInterface, LoopAwareInterface { use EventEmitterTrait; @@ -171,16 +172,27 @@ private function processBuffer(): void $this->buffer = substr($this->buffer, $pos + 1); $trimmedLine = trim($line); - if ($trimmedLine !== '') { - $this->emit('message', [$trimmedLine, self::CLIENT_ID]); + if (empty($trimmedLine)) { + continue; } + + try { + $message = JsonRpcMessage::parseRequest($trimmedLine); + } catch (Throwable $e) { + $this->logger->error('Error parsing message', ['exception' => $e]); + $error = JsonRpcError::parseError("Invalid JSON: " . $e->getMessage()); + $this->sendMessage($error, self::CLIENT_ID); + continue; + } + + $this->emit('message', [$message, self::CLIENT_ID]); } } /** * Sends a raw, framed message to STDOUT. */ - public function sendMessage(JsonRpcMessage|null $message, string $sessionId, array $context = []): PromiseInterface + public function sendMessage(JsonRpcMessage $message, string $sessionId, array $context = []): PromiseInterface { if ($this->closing || ! $this->stdout || ! $this->stdout->isWritable()) { return reject(new TransportException('Stdio transport is closed or STDOUT is not writable.')); @@ -188,7 +200,7 @@ public function sendMessage(JsonRpcMessage|null $message, string $sessionId, arr $deferred = new Deferred(); $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $written = $this->stdout->write($json); + $written = $this->stdout->write($json . "\n"); if ($written) { $deferred->resolve(null); diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php index 65e5b86..639df16 100644 --- a/src/Transports/StreamableHttpServerTransport.php +++ b/src/Transports/StreamableHttpServerTransport.php @@ -6,6 +6,7 @@ use Evenement\EventEmitterTrait; use PhpMcp\Server\Contracts\EventStoreInterface; +use PhpMcp\Server\Contracts\IdGeneratorInterface; use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; @@ -19,6 +20,7 @@ use PhpMcp\Server\JsonRpc\Messages\Error as JsonRpcError; use PhpMcp\Server\JsonRpc\Messages\Request as JsonRpcRequest; use PhpMcp\Server\JsonRpc\Messages\Response as JsonRpcResponse; +use PhpMcp\Server\Support\RandomIdGenerator; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -47,7 +49,7 @@ class StreamableHttpServerTransport implements ServerTransportInterface, LoggerA private bool $listening = false; private bool $closing = false; - private SessionIdGeneratorInterface $sessionIdGenerator; + private IdGeneratorInterface $idGenerator; private ?EventStoreInterface $eventStore; /** @@ -81,14 +83,14 @@ public function __construct( private readonly int $port = 8080, private string $mcpPath = '/mcp', private ?array $sslContext = null, - ?SessionIdGeneratorInterface $sessionIdGenerator = null, private readonly bool $preferDirectJsonResponse = true, + ?IdGeneratorInterface $idGenerator = null, ?EventStoreInterface $eventStore = null ) { $this->logger = new NullLogger(); $this->loop = Loop::get(); $this->mcpPath = '/' . trim($mcpPath, '/'); - $this->sessionIdGenerator = $sessionIdGenerator ?? new DefaultUuidSessionIdGenerator(); + $this->idGenerator = $idGenerator ?? new RandomIdGenerator(); $this->eventStore = $eventStore; } @@ -102,11 +104,6 @@ public function setLoop(LoopInterface $loop): void $this->loop = $loop; } - private function generateStreamId(): string - { - return bin2hex(random_bytes(16)); - } - public function listen(): void { if ($this->listening) { @@ -157,7 +154,8 @@ private function createRequestHandler(): callable $this->logger->debug("Request received", ['method' => $method, 'path' => $path, 'target' => $this->mcpPath]); if ($path !== $this->mcpPath) { - return new HttpResponse(404, ['Content-Type' => 'text/plain'], 'Not Found'); + $error = JsonRpcError::invalidRequest("Not found: {$path}"); + return new HttpResponse(404, ['Content-Type' => 'application/json'], json_encode($error)); } $corsHeaders = [ @@ -182,7 +180,7 @@ private function createRequestHandler(): callable 'GET' => $this->handleGetRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), 'POST' => $this->handlePostRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), 'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), - default => $addCors(new HttpResponse(405, ['Content-Type' => 'text/plain', 'Allow' => 'GET, POST, DELETE, OPTIONS'], 'Method Not Allowed')), + default => $addCors($this->handleUnsupportedRequest($request)), }; } catch (Throwable $e) { return $addCors($this->handleRequestError($e, $request)); @@ -194,7 +192,8 @@ private function handleGetRequest(ServerRequestInterface $request): PromiseInter { $acceptHeader = $request->getHeaderLine('Accept'); if (!str_contains($acceptHeader, 'text/event-stream')) { - return resolve(new HttpResponse(406, ['Content-Type' => 'text/plain'], 'Not Acceptable: Client must accept text/event-stream for GET requests.')); + $error = JsonRpcError::connectionAborted("Not Acceptable: Client must accept text/event-stream for GET requests."); + return resolve(new HttpResponse(406, ['Content-Type' => 'application/json'], json_encode($error))); } $sessionId = $request->getHeaderLine('Mcp-Session-Id'); @@ -204,13 +203,18 @@ private function handleGetRequest(ServerRequestInterface $request): PromiseInter return resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); } - $streamId = $this->generateStreamId(); + $streamId = $this->idGenerator->generateId(); $sseStream = new ThroughStream(); $this->activeSseStreams[$streamId] = ['stream' => $sseStream, 'sessionId' => $sessionId, 'type' => 'get']; $sseStream->on('close', function () use ($streamId, $sessionId) { - $this->logger->info("StreamableHttp: GET SSE stream closed.", ['streamId' => $streamId, 'sessionId' => $sessionId]); + $this->logger->info("GET SSE stream closed.", ['streamId' => $streamId, 'sessionId' => $sessionId]); + unset($this->activeSseStreams[$streamId]); + }); + + $sseStream->on('error', function (Throwable $e) use ($streamId, $sessionId) { + $this->logger->error("GET SSE stream error.", ['streamId' => $streamId, 'sessionId' => $sessionId, 'error' => $e->getMessage()]); unset($this->activeSseStreams[$streamId]); }); @@ -225,23 +229,9 @@ private function handleGetRequest(ServerRequestInterface $request): PromiseInter if ($this->eventStore) { $lastEventId = $request->getHeaderLine('Last-Event-ID'); - if (!empty($lastEventId)) { - try { - $this->eventStore->replayEventsAfter( - $lastEventId, - function (string $replayedEventId, string $json) use ($sseStream, $streamId) { - $this->logger->debug("Replaying event", ['targetstreamId' => $streamId, 'replayedEventId' => $replayedEventId]); - $this->sendSseEventToStream($sseStream, $json, $replayedEventId); - } - ); - } catch (Throwable $e) { - $this->logger->error("Error during event replay.", ['streamId' => $streamId, 'sessionId' => $sessionId, 'exception' => $e]); - } - } + $this->replayEvents($lastEventId, $sseStream, $sessionId); } - $this->emit('client_connected', [$sessionId, $streamId]); - return resolve($response); } @@ -251,25 +241,28 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte $acceptHeader = $request->getHeaderLine('Accept'); if (!str_contains($acceptHeader, 'application/json') && !str_contains($acceptHeader, 'text/event-stream')) { - $deferred->resolve(new HttpResponse(406, ['Content-Type' => 'text/plain'], 'Not Acceptable: Client must accept application/json or text/event-stream')); + $error = JsonRpcError::connectionAborted("Not Acceptable: Client must accept application/json or text/event-stream"); + $deferred->resolve(new HttpResponse(406, ['Content-Type' => 'application/json'], json_encode($error))); return $deferred->promise(); } if (!str_contains($request->getHeaderLine('Content-Type'), 'application/json')) { - $deferred->resolve(new HttpResponse(415, ['Content-Type' => 'text/plain'], 'Unsupported Media Type: Content-Type must be application/json')); + $error = JsonRpcError::connectionAborted("Unsupported Media Type: Content-Type must be application/json"); + $deferred->resolve(new HttpResponse(415, ['Content-Type' => 'application/json'], json_encode($error))); return $deferred->promise(); } - $bodyContents = $request->getBody()->getContents(); + $body = $request->getBody()->getContents(); - if ($bodyContents === '') { + if (empty($body)) { $this->logger->warning("Received empty POST body"); - $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'text/plain'], 'Empty request body.')); + $error = JsonRpcError::invalidRequest("Empty request body."); + $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); return $deferred->promise(); } try { - $message = JsonRpcMessage::parseRequest($bodyContents); + $message = JsonRpcMessage::parseRequest($body); } catch (Throwable $e) { $this->logger->error("Failed to parse MCP message from POST body", ['error' => $e->getMessage()]); $error = JsonRpcError::parseError("Invalid JSON: " . $e->getMessage()); @@ -279,11 +272,17 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte $isInitializeRequest = ($message instanceof JsonRpcRequest && $message->method === 'initialize'); $sessionId = null; + if ($isInitializeRequest) { if ($request->hasHeader('Mcp-Session-Id')) { $this->logger->warning("Client sent Mcp-Session-Id with InitializeRequest. Ignoring.", ['clientSentId' => $request->getHeaderLine('Mcp-Session-Id')]); + $error = JsonRpcError::invalidRequest("Invalid request: Session already initialized. Mcp-Session-Id header not allowed with InitializeRequest."); + $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); + return $deferred->promise(); } - $sessionId = $this->sessionIdGenerator->generateId(); + + $sessionId = $this->idGenerator->generateId(); + $this->emit('client_connected', [$sessionId]); } else { $sessionId = $request->getHeaderLine('Mcp-Session-Id'); @@ -318,7 +317,7 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte $useSse = $clientPrefersSse && !($this->preferDirectJsonResponse && $clientAcceptsJson); if ($useSse) { - $streamId = $this->generateStreamId(); + $streamId = $this->idGenerator->generateId(); $sseStream = new ThroughStream(); $this->activeSseStreams[$streamId] = ['stream' => $sseStream, 'sessionId' => $sessionId, 'type' => 'post']; $this->postSseStreamContexts[$streamId] = [ @@ -345,10 +344,8 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte 'X-Accel-Buffering' => 'no', ]; - if ($this->sessionIdGenerator !== null && $sessionId && $message->method !== 'initialize') { - if ($request->hasHeader('Mcp-Session-Id')) { - $headers['Mcp-Session-Id'] = $sessionId; - } + if (!empty($sessionId)) { + $headers['Mcp-Session-Id'] = $sessionId; } $deferred->resolve(new HttpResponse(200, $headers, $sseStream)); @@ -356,7 +353,7 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte $context['streamId'] = $streamId; $context['nRequests'] = $nRequests; } else { - $pendingRequestId = $this->generateStreamId(); + $pendingRequestId = $this->idGenerator->generateId(); $this->pendingDirectPostResponses[$pendingRequestId] = $deferred; $timeoutTimer = $this->loop->addTimer(30, function () use ($pendingRequestId, $sessionId) { @@ -411,22 +408,34 @@ private function handleDeleteRequest(ServerRequestInterface $request): PromiseIn return resolve(new HttpResponse(204)); } + private function handleUnsupportedRequest(ServerRequestInterface $request): HttpResponse + { + $error = JsonRpcError::connectionAborted("Method not allowed: {$request->getMethod()}"); + $headers = [ + 'Content-Type' => 'application/json', + 'Allow' => 'GET, POST, DELETE, OPTIONS', + ]; + return new HttpResponse(405, $headers, json_encode($error)); + } + private function handleRequestError(Throwable $e, ServerRequestInterface $request): HttpResponse { $this->logger->error("Error processing HTTP request", [ 'method' => $request->getMethod(), 'path' => $request->getUri()->getPath(), - 'exception' => $e + 'exception' => $e->getMessage() ]); if ($e instanceof TransportException) { - return new HttpResponse(500, ['Content-Type' => 'text/plain'], 'Transport Error: ' . $e->getMessage()); + $error = JsonRpcError::connectionAborted("Transport Error: " . $e->getMessage()); + return new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($error)); } - return new HttpResponse(500, ['Content-Type' => 'text/plain'], 'Internal Server Error during HTTP request processing.'); + $error = JsonRpcError::connectionAborted("Internal Server Error during HTTP request processing."); + return new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($error)); } - public function sendMessage(JsonRpcMessage|null $message, string $sessionId, array $context = []): PromiseInterface + public function sendMessage(JsonRpcMessage $message, string $sessionId, array $context = []): PromiseInterface { if ($this->closing) { return reject(new TransportException('Transport is closing.')); @@ -527,6 +536,25 @@ public function sendMessage(JsonRpcMessage|null $message, string $sessionId, arr } } + private function replayEvents(string $lastEventId, ThroughStream $sseStream, string $sessionId): void + { + if (empty($lastEventId)) { + return; + } + + try { + $this->eventStore->replayEventsAfter( + $lastEventId, + function (string $replayedEventId, string $json) use ($sseStream) { + $this->logger->debug("Replaying event", ['replayedEventId' => $replayedEventId]); + $this->sendSseEventToStream($sseStream, $json, $replayedEventId); + } + ); + } catch (Throwable $e) { + $this->logger->error("Error during event replay.", ['sessionId' => $sessionId, 'exception' => $e]); + } + } + private function sendSseEventToStream(ThroughStream $stream, string $data, ?string $eventId = null): bool { if (! $stream->isWritable()) { From 5fb3036ed8522adb3bcd254e7d700bfc2d24e1aa Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 8 Jun 2025 08:03:28 +0100 Subject: [PATCH 06/27] refactor: simplify stream management in StreamableHttpServerTransport - Updated Protocol class to ensure session creation and deletion are logged correctly during client connection and disconnection. - Enhanced StreamableHttpServerTransport by consolidating session context management and improving SSE stream handling. - Removed unnecessary session context variables to streamline the code and improve clarity. --- src/Contracts/ServerTransportInterface.php | 4 +- src/Protocol.php | 8 +-- src/Transports/StdioServerTransport.php | 9 ++- .../StreamableHttpServerTransport.php | 62 +++++++------------ 4 files changed, 32 insertions(+), 51 deletions(-) diff --git a/src/Contracts/ServerTransportInterface.php b/src/Contracts/ServerTransportInterface.php index 298953a..a5b0037 100644 --- a/src/Contracts/ServerTransportInterface.php +++ b/src/Contracts/ServerTransportInterface.php @@ -5,9 +5,9 @@ namespace PhpMcp\Server\Contracts; use Evenement\EventEmitterInterface; +use PhpMcp\Server\Exception\TransportException; use PhpMcp\Server\JsonRpc\Messages\Message; use React\Promise\PromiseInterface; -use Throwable; /** * Interface for server-side MCP transports. @@ -29,7 +29,7 @@ interface ServerTransportInterface extends EventEmitterInterface * Starts the transport listener (e.g., listens on STDIN, starts HTTP server). * Does NOT run the event loop itself. Prepares transport to emit events when loop runs. * - * @throws \PhpMcp\Server\Exception\TransportException on immediate setup failure (e.g., port binding). + * @throws TransportException on immediate setup failure (e.g., port binding). */ public function listen(): void; diff --git a/src/Protocol.php b/src/Protocol.php index 6822933..3f85ec5 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -274,9 +274,9 @@ private function parseMethod(string $method): array */ public function handleClientConnected(string $sessionId): void { - $this->sessionManager->createSession($sessionId); - $this->logger->info('Client connected', ['sessionId' => $sessionId]); + + $this->sessionManager->createSession($sessionId); } /** @@ -284,9 +284,9 @@ public function handleClientConnected(string $sessionId): void */ public function handleClientDisconnected(string $sessionId, ?string $reason = null): void { - $this->sessionManager->deleteSession($sessionId); - $this->logger->info('Client disconnected', ['clientId' => $sessionId, 'reason' => $reason ?? 'N/A']); + + $this->sessionManager->deleteSession($sessionId); } /** diff --git a/src/Transports/StdioServerTransport.php b/src/Transports/StdioServerTransport.php index 107f2cb..1ae4b47 100644 --- a/src/Transports/StdioServerTransport.php +++ b/src/Transports/StdioServerTransport.php @@ -120,7 +120,7 @@ public function listen(): void $this->stdin = new ReadableResourceStream($this->inputStreamResource, $this->loop); $this->stdout = new WritableResourceStream($this->outputStreamResource, $this->loop); } catch (Throwable $e) { - $this->logger->error('StdioTransport: Failed to open STDIN/STDOUT streams.', ['exception' => $e]); + $this->logger->error('Failed to open STDIN/STDOUT streams.', ['exception' => $e]); throw new TransportException("Failed to open standard streams: {$e->getMessage()}", 0, $e); } @@ -130,7 +130,7 @@ public function listen(): void }); $this->stdin->on('error', function (Throwable $error) { - $this->logger->error('StdioTransport: STDIN stream error.', ['error' => $error->getMessage()]); + $this->logger->error('STDIN stream error.', ['error' => $error->getMessage()]); $this->emit('error', [new TransportException("STDIN error: {$error->getMessage()}", 0, $error), self::CLIENT_ID]); $this->close(); }); @@ -142,14 +142,13 @@ public function listen(): void }); $this->stdout->on('error', function (Throwable $error) { - $this->logger->error('StdioTransport: STDOUT stream error.', ['error' => $error->getMessage()]); + $this->logger->error('STDOUT stream error.', ['error' => $error->getMessage()]); $this->emit('error', [new TransportException("STDOUT error: {$error->getMessage()}", 0, $error), self::CLIENT_ID]); $this->close(); }); $signalHandler = function (int $signal) { - $this->logger->info("StdioTransport: Received signal {$signal}, shutting down."); - // $this->emit('client_disconnected', [self::CLIENT_ID, 'SIGTERM/SIGINT']); + $this->logger->info("Received signal {$signal}, shutting down."); $this->close(); }; $this->loop->addSignal(SIGTERM, $signalHandler); diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php index 639df16..a847508 100644 --- a/src/Transports/StreamableHttpServerTransport.php +++ b/src/Transports/StreamableHttpServerTransport.php @@ -10,8 +10,6 @@ use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; -use PhpMcp\Server\Contracts\SessionIdGeneratorInterface; -use PhpMcp\Server\Defaults\DefaultUuidSessionIdGenerator; use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Server\Exception\TransportException; use PhpMcp\Server\JsonRpc\Messages\Message as JsonRpcMessage; @@ -59,22 +57,11 @@ class StreamableHttpServerTransport implements ServerTransportInterface, LoggerA */ private array $pendingDirectPostResponses = []; - /** - * Stores context for active SSE streams initiated by a POST request. - * Helps manage when to close these streams. - * Key: streamId - * Value: ['expectedResponses' => int, 'receivedResponses' => int] - * @var array - */ - private array $postSseStreamContexts = []; - /** * Stores active SSE streams. * Key: streamId - * Value: ['stream' => ThroughStream, 'sessionId' => string, 'type' => 'get' | 'post' - * 'post_init' for SSE stream established for an InitializeRequest - * 'post_data' for SSE stream established for other data requests - * @var array + * Value: ['stream' => ThroughStream, 'sessionId' => string, 'type' => 'get' | 'post', 'context' => array] + * @var array */ private array $activeSseStreams = []; @@ -209,7 +196,7 @@ private function handleGetRequest(ServerRequestInterface $request): PromiseInter $this->activeSseStreams[$streamId] = ['stream' => $sseStream, 'sessionId' => $sessionId, 'type' => 'get']; $sseStream->on('close', function () use ($streamId, $sessionId) { - $this->logger->info("GET SSE stream closed.", ['streamId' => $streamId, 'sessionId' => $sessionId]); + $this->logger->debug("GET SSE stream closed.", ['streamId' => $streamId, 'sessionId' => $sessionId]); unset($this->activeSseStreams[$streamId]); }); @@ -319,22 +306,20 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte if ($useSse) { $streamId = $this->idGenerator->generateId(); $sseStream = new ThroughStream(); - $this->activeSseStreams[$streamId] = ['stream' => $sseStream, 'sessionId' => $sessionId, 'type' => 'post']; - $this->postSseStreamContexts[$streamId] = [ - 'nRequests' => $nRequests, - 'nResponses' => 0, - 'sessionId' => $sessionId + $this->activeSseStreams[$streamId] = [ + 'stream' => $sseStream, + 'sessionId' => $sessionId, + 'type' => 'post', + 'context' => ['nRequests' => $nRequests, 'nResponses' => 0] ]; $sseStream->on('close', function () use ($streamId) { - $this->logger->info("POST SSE stream closed by client/server.", ['streamId' => $streamId, 'sessionId' => $this->postSseStreamContexts[$streamId]['sessionId'] ?? 'unknown']); + $this->logger->info("POST SSE stream closed by client/server.", ['streamId' => $streamId, 'sessionId' => $this->activeSseStreams[$streamId]['sessionId']]); unset($this->activeSseStreams[$streamId]); - unset($this->postSseStreamContexts[$streamId]); }); $sseStream->on('error', function (Throwable $e) use ($streamId) { - $this->logger->error("POST SSE stream error.", ['streamId' => $streamId, 'sessionId' => $this->postSseStreamContexts[$streamId]['sessionId'] ?? 'unknown', 'error' => $e->getMessage()]); + $this->logger->error("POST SSE stream error.", ['streamId' => $streamId, 'sessionId' => $this->activeSseStreams[$streamId]['sessionId'], 'error' => $e->getMessage()]); unset($this->activeSseStreams[$streamId]); - unset($this->postSseStreamContexts[$streamId]); }); $headers = [ @@ -389,21 +374,19 @@ private function handleDeleteRequest(ServerRequestInterface $request): PromiseIn return resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); } - // TODO: Use session manager to handle this? - - // TODO: Close all associated HTTP streams for this session - - // TODO: Clean up session tracking in this transport - - // TODO: Remove any mappings for requests belonging to this session - + $streamsToClose = []; foreach ($this->activeSseStreams as $streamId => $streamInfo) { if ($streamInfo['sessionId'] === $sessionId) { - $streamInfo['stream']->end(); + $streamsToClose[] = $streamId; } } - $this->emit('client_disconnected', [$sessionId, null, 'Session terminated by DELETE request']); // No specific streamId, signals whole session. + foreach ($streamsToClose as $streamId) { + $this->activeSseStreams[$streamId]['stream']->end(); + unset($this->activeSseStreams[$streamId]); + } + + $this->emit('client_disconnected', [$sessionId, 'Session terminated by DELETE request']); return resolve(new HttpResponse(204)); } @@ -476,14 +459,14 @@ public function sendMessage(JsonRpcMessage $message, string $sessionId, array $c } } - if (isset($this->postSseStreamContexts[$streamId])) { - $this->postSseStreamContexts[$streamId]['nResponses'] += $sentCountThisCall; - $sCtx = $this->postSseStreamContexts[$streamId]; - if ($sCtx['nResponses'] >= $sCtx['nRequests']) { + if (isset($this->activeSseStreams[$streamId]['context'])) { + $this->activeSseStreams[$streamId]['context']['nResponses'] += $sentCountThisCall; + if ($this->activeSseStreams[$streamId]['context']['nResponses'] >= $this->activeSseStreams[$streamId]['context']['nRequests']) { $this->logger->info("All expected responses sent for POST SSE stream. Closing.", ['streamId' => $streamId, 'sessionId' => $sessionId]); $stream->end(); // Will trigger 'close' event. } } + return resolve(null); case 'post_json': @@ -601,7 +584,6 @@ public function close(): void } $this->activeSseStreams = []; - $this->postSseStreamContexts = []; $this->pendingDirectPostResponses = []; $this->emit('close', ['Transport closed.']); From c274420dabfe543dcd412808c41a3cda93838f85 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 8 Jun 2025 16:06:40 +0100 Subject: [PATCH 07/27] lint: Format project --- src/Attributes/McpResource.php | 3 +- src/Attributes/McpResourceTemplate.php | 3 +- src/Attributes/McpTool.php | 3 +- src/Configuration.php | 3 +- src/Definitions/PromptArgumentDefinition.php | 3 +- src/Definitions/PromptDefinition.php | 2 +- src/Exception/McpServerException.php | 6 +- src/JsonRpc/Contents/AudioContent.php | 3 +- src/JsonRpc/Contents/EmbeddedResource.php | 3 +- src/JsonRpc/Contents/ImageContent.php | 3 +- src/JsonRpc/Contents/PromptMessage.php | 3 +- src/JsonRpc/Contents/ResourceContent.php | 1 - src/JsonRpc/Contents/TextContent.php | 3 +- src/JsonRpc/Messages/BatchRequest.php | 6 +- src/JsonRpc/Messages/BatchResponse.php | 6 +- src/JsonRpc/Messages/Error.php | 3 +- src/JsonRpc/Messages/Notification.php | 3 +- src/JsonRpc/Messages/Request.php | 3 +- src/JsonRpc/Results/CallToolResult.php | 5 +- src/JsonRpc/Results/EmptyResult.php | 4 +- src/JsonRpc/Results/GetPromptResult.php | 5 +- src/JsonRpc/Results/InitializeResult.php | 3 +- src/JsonRpc/Results/ListPromptsResult.php | 5 +- .../Results/ListResourceTemplatesResult.php | 5 +- src/JsonRpc/Results/ListResourcesResult.php | 5 +- src/JsonRpc/Results/ListToolsResult.php | 5 +- src/JsonRpc/Results/ReadResourceResult.php | 5 +- src/Model/ToolAnnotations.php | 3 +- src/Protocol.php | 180 ++++++++++++------ src/Server.php | 3 +- src/ServerBuilder.php | 4 +- src/Session/ArraySessionHandler.php | 4 +- src/Session/CacheSessionHandler.php | 4 +- src/Session/SessionManager.php | 18 +- src/Support/RequestHandler.php | 3 + src/Traits/ResponseFormatter.php | 2 +- src/Transports/HttpServerTransport.php | 2 +- .../StreamableHttpServerTransport.php | 6 +- .../Unit/Definitions/PromptDefinitionTest.php | 2 +- .../Definitions/ResourceDefinitionTest.php | 4 +- .../ResourceTemplateDefinitionTest.php | 4 +- tests/Unit/Definitions/ToolDefinitionTest.php | 2 +- tests/Unit/JsonRpc/NotificationTest.php | 10 +- tests/Unit/ProtocolTest.php | 8 +- tests/Unit/ServerBuilderTest.php | 16 +- tests/Unit/ServerTest.php | 12 +- tests/Unit/State/ClientStateManagerTest.php | 22 +-- tests/Unit/Support/DiscovererTest.php | 14 +- tests/Unit/Support/HandlerResolverTest.php | 32 +++- 49 files changed, 294 insertions(+), 163 deletions(-) diff --git a/src/Attributes/McpResource.php b/src/Attributes/McpResource.php index 43462f1..8cb61e6 100644 --- a/src/Attributes/McpResource.php +++ b/src/Attributes/McpResource.php @@ -27,5 +27,6 @@ public function __construct( public ?string $mimeType = null, public ?int $size = null, public ?Annotations $annotations = null, - ) {} + ) { + } } diff --git a/src/Attributes/McpResourceTemplate.php b/src/Attributes/McpResourceTemplate.php index 5873cea..1364ec0 100644 --- a/src/Attributes/McpResourceTemplate.php +++ b/src/Attributes/McpResourceTemplate.php @@ -25,5 +25,6 @@ public function __construct( public ?string $description = null, public ?string $mimeType = null, public ?Annotations $annotations = null, - ) {} + ) { + } } diff --git a/src/Attributes/McpTool.php b/src/Attributes/McpTool.php index 2542fd2..38294db 100644 --- a/src/Attributes/McpTool.php +++ b/src/Attributes/McpTool.php @@ -17,5 +17,6 @@ public function __construct( public ?string $name = null, public ?string $description = null, public ?ToolAnnotations $annotations = null, - ) {} + ) { + } } diff --git a/src/Configuration.php b/src/Configuration.php index ed94af3..5e5bb69 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -38,5 +38,6 @@ public function __construct( public readonly ContainerInterface $container, public readonly int $definitionCacheTtl = 3600, public readonly int $paginationLimit = 50, - ) {} + ) { + } } diff --git a/src/Definitions/PromptArgumentDefinition.php b/src/Definitions/PromptArgumentDefinition.php index 7f0c1f1..a77ca2f 100644 --- a/src/Definitions/PromptArgumentDefinition.php +++ b/src/Definitions/PromptArgumentDefinition.php @@ -19,7 +19,8 @@ public function __construct( public readonly string $name, public readonly ?string $description, public readonly bool $required = false - ) {} + ) { + } /** * Formats the definition into the structure expected by MCP's 'Prompt.arguments'. diff --git a/src/Definitions/PromptDefinition.php b/src/Definitions/PromptDefinition.php index 0c390ec..94b610c 100644 --- a/src/Definitions/PromptDefinition.php +++ b/src/Definitions/PromptDefinition.php @@ -69,7 +69,7 @@ public function toArray(): array } if (! empty($this->arguments)) { $data['arguments'] = array_map( - fn(PromptArgumentDefinition $arg) => $arg->toArray(), + fn (PromptArgumentDefinition $arg) => $arg->toArray(), $this->arguments ); } diff --git a/src/Exception/McpServerException.php b/src/Exception/McpServerException.php index caddca9..2aeadb4 100644 --- a/src/Exception/McpServerException.php +++ b/src/Exception/McpServerException.php @@ -13,8 +13,6 @@ */ class McpServerException extends Exception { - - // MCP reserved range: -32000 to -32099 (Server error) // Add specific server-side codes if needed later, e.g.: // public const CODE_RESOURCE_ACTION_FAILED = -32000; @@ -82,9 +80,9 @@ public static function invalidRequest(?string $details = 'Invalid Request', ?Thr return new ProtocolException($details, JsonRpcError::CODE_INVALID_REQUEST, null, $previous); } - public static function methodNotFound(string $methodName, ?Throwable $previous = null): self + public static function methodNotFound(string $methodName, ?string $message = null, ?Throwable $previous = null): self { - return new ProtocolException("Method not found: {$methodName}", JsonRpcError::CODE_METHOD_NOT_FOUND, null, $previous); + return new ProtocolException($message ?? "Method not found: {$methodName}", JsonRpcError::CODE_METHOD_NOT_FOUND, null, $previous); } public static function invalidParams(string $message = 'Invalid params', $data = null, ?Throwable $previous = null): self diff --git a/src/JsonRpc/Contents/AudioContent.php b/src/JsonRpc/Contents/AudioContent.php index d925624..6a4b271 100644 --- a/src/JsonRpc/Contents/AudioContent.php +++ b/src/JsonRpc/Contents/AudioContent.php @@ -21,7 +21,8 @@ public function __construct( public readonly string $data, public readonly string $mimeType, public readonly ?Annotations $annotations = null - ) {} + ) { + } /** * Convert the content to an array. diff --git a/src/JsonRpc/Contents/EmbeddedResource.php b/src/JsonRpc/Contents/EmbeddedResource.php index 32f3139..55fc997 100644 --- a/src/JsonRpc/Contents/EmbeddedResource.php +++ b/src/JsonRpc/Contents/EmbeddedResource.php @@ -17,7 +17,8 @@ class EmbeddedResource */ public function __construct( public readonly ResourceContent $resource - ) {} + ) { + } /** * Convert the resource to an array. diff --git a/src/JsonRpc/Contents/ImageContent.php b/src/JsonRpc/Contents/ImageContent.php index 3cd612a..624221e 100644 --- a/src/JsonRpc/Contents/ImageContent.php +++ b/src/JsonRpc/Contents/ImageContent.php @@ -18,7 +18,8 @@ class ImageContent implements ContentInterface public function __construct( public readonly string $data, public readonly string $mimeType - ) {} + ) { + } /** diff --git a/src/JsonRpc/Contents/PromptMessage.php b/src/JsonRpc/Contents/PromptMessage.php index 946f67a..62ba405 100644 --- a/src/JsonRpc/Contents/PromptMessage.php +++ b/src/JsonRpc/Contents/PromptMessage.php @@ -18,7 +18,8 @@ class PromptMessage public function __construct( public readonly Role $role, public readonly TextContent|ImageContent|AudioContent|EmbeddedResource $content - ) {} + ) { + } /** * Convert the message to an array. diff --git a/src/JsonRpc/Contents/ResourceContent.php b/src/JsonRpc/Contents/ResourceContent.php index 05746d1..ea67457 100644 --- a/src/JsonRpc/Contents/ResourceContent.php +++ b/src/JsonRpc/Contents/ResourceContent.php @@ -9,7 +9,6 @@ */ class ResourceContent implements ContentInterface { - /** * Create a new Resource instance. * diff --git a/src/JsonRpc/Contents/TextContent.php b/src/JsonRpc/Contents/TextContent.php index 3d33515..0bf90e2 100644 --- a/src/JsonRpc/Contents/TextContent.php +++ b/src/JsonRpc/Contents/TextContent.php @@ -19,7 +19,8 @@ class TextContent implements ContentInterface public function __construct( public readonly string $text, public readonly ?Annotations $annotations = null - ) {} + ) { + } /** * Convert the content to an array. diff --git a/src/JsonRpc/Messages/BatchRequest.php b/src/JsonRpc/Messages/BatchRequest.php index 86ea4a1..e252905 100644 --- a/src/JsonRpc/Messages/BatchRequest.php +++ b/src/JsonRpc/Messages/BatchRequest.php @@ -92,7 +92,7 @@ public function all(): array */ public function getRequests(): array { - return array_filter($this->requests, fn($r) => ! $r instanceof Notification); + return array_filter($this->requests, fn ($r) => ! $r instanceof Notification); } /** @@ -102,7 +102,7 @@ public function getRequests(): array */ public function getNotifications(): array { - return array_filter($this->requests, fn($r) => $r instanceof Notification); + return array_filter($this->requests, fn ($r) => $r instanceof Notification); } public function hasRequests(): bool @@ -143,6 +143,6 @@ public function count(): int */ public function toArray(): array { - return array_map(fn($r) => $r->toArray(), $this->requests); + return array_map(fn ($r) => $r->toArray(), $this->requests); } } diff --git a/src/JsonRpc/Messages/BatchResponse.php b/src/JsonRpc/Messages/BatchResponse.php index 8897c8b..fe78807 100644 --- a/src/JsonRpc/Messages/BatchResponse.php +++ b/src/JsonRpc/Messages/BatchResponse.php @@ -73,7 +73,7 @@ public function all(): array */ public function getResponses(): array { - return array_filter($this->responses, fn($r) => $r instanceof Response); + return array_filter($this->responses, fn ($r) => $r instanceof Response); } /** @@ -83,7 +83,7 @@ public function getResponses(): array */ public function getErrors(): array { - return array_filter($this->responses, fn($r) => $r instanceof Error); + return array_filter($this->responses, fn ($r) => $r instanceof Error); } public function isEmpty(): bool @@ -104,6 +104,6 @@ public function count(): int */ public function toArray(): array { - return array_map(fn($r) => $r->toArray(), $this->responses); + return array_map(fn ($r) => $r->toArray(), $this->responses); } } diff --git a/src/JsonRpc/Messages/Error.php b/src/JsonRpc/Messages/Error.php index 17122be..9f7f1cf 100644 --- a/src/JsonRpc/Messages/Error.php +++ b/src/JsonRpc/Messages/Error.php @@ -37,7 +37,8 @@ public function __construct( public readonly int $code, public readonly string $message, public readonly mixed $data = null - ) {} + ) { + } public function getId(): string|int { diff --git a/src/JsonRpc/Messages/Notification.php b/src/JsonRpc/Messages/Notification.php index 840e4fb..f359730 100644 --- a/src/JsonRpc/Messages/Notification.php +++ b/src/JsonRpc/Messages/Notification.php @@ -17,7 +17,8 @@ public function __construct( public readonly string $jsonrpc, public readonly string $method, public readonly array $params = [], - ) {} + ) { + } public function getId(): null { diff --git a/src/JsonRpc/Messages/Request.php b/src/JsonRpc/Messages/Request.php index 28a8c41..7654cde 100644 --- a/src/JsonRpc/Messages/Request.php +++ b/src/JsonRpc/Messages/Request.php @@ -23,7 +23,8 @@ public function __construct( public readonly string|int $id, public readonly string $method, public readonly array $params = [], - ) {} + ) { + } public function getId(): string|int { diff --git a/src/JsonRpc/Results/CallToolResult.php b/src/JsonRpc/Results/CallToolResult.php index 9b50a01..09c688f 100644 --- a/src/JsonRpc/Results/CallToolResult.php +++ b/src/JsonRpc/Results/CallToolResult.php @@ -15,7 +15,8 @@ class CallToolResult implements ResultInterface public function __construct( public readonly array $content, public readonly bool $isError = false - ) {} + ) { + } /** * Convert the result to an array. @@ -23,7 +24,7 @@ public function __construct( public function toArray(): array { return [ - 'content' => array_map(fn($item) => $item->toArray(), $this->content), + 'content' => array_map(fn ($item) => $item->toArray(), $this->content), 'isError' => $this->isError, ]; } diff --git a/src/JsonRpc/Results/EmptyResult.php b/src/JsonRpc/Results/EmptyResult.php index c59f359..57634a8 100644 --- a/src/JsonRpc/Results/EmptyResult.php +++ b/src/JsonRpc/Results/EmptyResult.php @@ -12,7 +12,9 @@ class EmptyResult implements ResultInterface /** * Create a new EmptyResult. */ - public function __construct() {} + public function __construct() + { + } /** * Convert the result to an array. diff --git a/src/JsonRpc/Results/GetPromptResult.php b/src/JsonRpc/Results/GetPromptResult.php index 39e5db7..ae201bc 100644 --- a/src/JsonRpc/Results/GetPromptResult.php +++ b/src/JsonRpc/Results/GetPromptResult.php @@ -16,7 +16,8 @@ class GetPromptResult implements ResultInterface public function __construct( public readonly array $messages, public readonly ?string $description = null - ) {} + ) { + } /** * Convert the result to an array. @@ -24,7 +25,7 @@ public function __construct( public function toArray(): array { $result = [ - 'messages' => array_map(fn($message) => $message->toArray(), $this->messages), + 'messages' => array_map(fn ($message) => $message->toArray(), $this->messages), ]; if ($this->description !== null) { diff --git a/src/JsonRpc/Results/InitializeResult.php b/src/JsonRpc/Results/InitializeResult.php index e0d4c8b..d5b0113 100644 --- a/src/JsonRpc/Results/InitializeResult.php +++ b/src/JsonRpc/Results/InitializeResult.php @@ -19,7 +19,8 @@ public function __construct( public readonly string $protocolVersion, public readonly array $capabilities, public readonly ?string $instructions = null - ) {} + ) { + } /** * Convert the result to an array. diff --git a/src/JsonRpc/Results/ListPromptsResult.php b/src/JsonRpc/Results/ListPromptsResult.php index 22ab879..0de446c 100644 --- a/src/JsonRpc/Results/ListPromptsResult.php +++ b/src/JsonRpc/Results/ListPromptsResult.php @@ -14,12 +14,13 @@ class ListPromptsResult implements ResultInterface public function __construct( public readonly array $prompts, public readonly ?string $nextCursor = null - ) {} + ) { + } public function toArray(): array { $result = [ - 'prompts' => array_map(fn(PromptDefinition $p) => $p->toArray(), $this->prompts), + 'prompts' => array_map(fn (PromptDefinition $p) => $p->toArray(), $this->prompts), ]; if ($this->nextCursor) { diff --git a/src/JsonRpc/Results/ListResourceTemplatesResult.php b/src/JsonRpc/Results/ListResourceTemplatesResult.php index 1d3c04b..7e02f22 100644 --- a/src/JsonRpc/Results/ListResourceTemplatesResult.php +++ b/src/JsonRpc/Results/ListResourceTemplatesResult.php @@ -14,7 +14,8 @@ class ListResourceTemplatesResult implements ResultInterface public function __construct( public readonly array $resourceTemplates, public readonly ?string $nextCursor = null - ) {} + ) { + } /** * Convert the result to an array. @@ -22,7 +23,7 @@ public function __construct( public function toArray(): array { $result = [ - 'resourceTemplates' => array_map(fn(ResourceTemplateDefinition $t) => $t->toArray(), $this->resourceTemplates), + 'resourceTemplates' => array_map(fn (ResourceTemplateDefinition $t) => $t->toArray(), $this->resourceTemplates), ]; if ($this->nextCursor) { diff --git a/src/JsonRpc/Results/ListResourcesResult.php b/src/JsonRpc/Results/ListResourcesResult.php index cb140ec..92298b3 100644 --- a/src/JsonRpc/Results/ListResourcesResult.php +++ b/src/JsonRpc/Results/ListResourcesResult.php @@ -14,7 +14,8 @@ class ListResourcesResult implements ResultInterface public function __construct( public readonly array $resources, public readonly ?string $nextCursor = null - ) {} + ) { + } /** * Convert the result to an array. @@ -22,7 +23,7 @@ public function __construct( public function toArray(): array { $result = [ - 'resources' => array_map(fn(ResourceDefinition $r) => $r->toArray(), $this->resources), + 'resources' => array_map(fn (ResourceDefinition $r) => $r->toArray(), $this->resources), ]; if ($this->nextCursor !== null) { diff --git a/src/JsonRpc/Results/ListToolsResult.php b/src/JsonRpc/Results/ListToolsResult.php index 0ef51ce..3f216ea 100644 --- a/src/JsonRpc/Results/ListToolsResult.php +++ b/src/JsonRpc/Results/ListToolsResult.php @@ -14,12 +14,13 @@ class ListToolsResult implements ResultInterface public function __construct( public readonly array $tools, public readonly ?string $nextCursor = null - ) {} + ) { + } public function toArray(): array { $result = [ - 'tools' => array_map(fn(ToolDefinition $t) => $t->toArray(), $this->tools), + 'tools' => array_map(fn (ToolDefinition $t) => $t->toArray(), $this->tools), ]; if ($this->nextCursor) { diff --git a/src/JsonRpc/Results/ReadResourceResult.php b/src/JsonRpc/Results/ReadResourceResult.php index 8ae92d5..193b39f 100644 --- a/src/JsonRpc/Results/ReadResourceResult.php +++ b/src/JsonRpc/Results/ReadResourceResult.php @@ -14,7 +14,8 @@ class ReadResourceResult implements ResultInterface */ public function __construct( public readonly array $contents - ) {} + ) { + } /** @@ -23,7 +24,7 @@ public function __construct( public function toArray(): array { return [ - 'contents' => array_map(fn($resource) => $resource->toArray(), $this->contents), + 'contents' => array_map(fn ($resource) => $resource->toArray(), $this->contents), ]; } diff --git a/src/Model/ToolAnnotations.php b/src/Model/ToolAnnotations.php index b3cea03..27b5f0e 100644 --- a/src/Model/ToolAnnotations.php +++ b/src/Model/ToolAnnotations.php @@ -29,7 +29,8 @@ public function __construct( public readonly ?bool $destructiveHint = null, public readonly ?bool $idempotentHint = null, public readonly ?bool $openWorldHint = null, - ) {} + ) { + } public static function default(): self { diff --git a/src/Protocol.php b/src/Protocol.php index 3f85ec5..6f7f5df 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -37,10 +37,10 @@ class Protocol protected array $listeners = []; public function __construct( - protected Configuration $configuration, - protected Registry $registry, - protected SessionManager $sessionManager, - protected ?RequestHandler $requestHandler = null, + protected Configuration $configuration, + protected Registry $registry, + protected SessionManager $sessionManager, + protected ?RequestHandler $requestHandler = null, ) { $this->logger = $this->configuration->logger; $this->requestHandler ??= new RequestHandler($this->configuration, $this->registry, $this->sessionManager); @@ -152,45 +152,28 @@ private function processRequest(Request $request, SessionInterface $session): Re $params = $request->params; try { - /** @var Result|null $result */ - $result = null; - - if ($method === 'initialize') { - $result = $this->requestHandler->handleInitialize($params, $session); - } elseif ($method === 'ping') { - $result = $this->requestHandler->handlePing($session); - } else { - $this->validateSessionInitialized($session); - [$type, $action] = $this->parseMethod($method); - $this->validateCapabilityEnabled($type); - - $result = match ($type) { - 'tools' => match ($action) { - 'list' => $this->requestHandler->handleToolList($params), - 'call' => $this->requestHandler->handleToolCall($params), - default => throw McpServerException::methodNotFound($method), - }, - 'resources' => match ($action) { - 'list' => $this->requestHandler->handleResourcesList($params), - 'read' => $this->requestHandler->handleResourceRead($params), - 'subscribe' => $this->requestHandler->handleResourceSubscribe($params, $session), - 'unsubscribe' => $this->requestHandler->handleResourceUnsubscribe($params, $session), - 'templates/list' => $this->requestHandler->handleResourceTemplateList($params), - default => throw McpServerException::methodNotFound($method), - }, - 'prompts' => match ($action) { - 'list' => $this->requestHandler->handlePromptsList($params), - 'get' => $this->requestHandler->handlePromptGet($params), - default => throw McpServerException::methodNotFound($method), - }, - 'logging' => match ($action) { - 'setLevel' => $this->requestHandler->handleLoggingSetLevel($params, $session), - default => throw McpServerException::methodNotFound($method), - }, - default => throw McpServerException::methodNotFound($method), - }; + if ($method !== 'initialize') { + $this->assertSessionInitialized($session); } + $this->assertRequestCapability($method); + + $result = match ($method) { + 'initialize' => $this->requestHandler->handleInitialize($params, $session), + 'ping' => $this->requestHandler->handlePing($session), + 'tools/list' => $this->requestHandler->handleToolList($params), + 'tools/call' => $this->requestHandler->handleToolCall($params), + 'resources/list' => $this->requestHandler->handleResourcesList($params), + 'resources/read' => $this->requestHandler->handleResourceRead($params), + 'resources/subscribe' => $this->requestHandler->handleResourceSubscribe($params, $session), + 'resources/unsubscribe' => $this->requestHandler->handleResourceUnsubscribe($params, $session), + 'resources/templates/list' => $this->requestHandler->handleResourceTemplateList($params), + 'prompts/list' => $this->requestHandler->handlePromptsList($params), + 'prompts/get' => $this->requestHandler->handlePromptGet($params), + 'logging/setLevel' => $this->requestHandler->handleLoggingSetLevel($params, $session), + default => throw McpServerException::methodNotFound($method), + }; + return Response::make($result, $request->id); } catch (McpServerException $e) { $this->logger->debug('MCP Processor caught McpServerException', ['method' => $method, 'code' => $e->getCode(), 'message' => $e->getMessage(), 'data' => $e->getData()]); @@ -225,7 +208,7 @@ private function processNotification(Notification $notification, SessionInterfac /** * Validate that a session is initialized */ - private function validateSessionInitialized(SessionInterface $session): void + private function assertSessionInitialized(SessionInterface $session): void { if (!$session->get('initialized', false)) { throw McpServerException::invalidRequest('Client session not initialized.'); @@ -233,24 +216,105 @@ private function validateSessionInitialized(SessionInterface $session): void } /** - * Validate that a capability is enabled + * Assert that a request method is enabled */ - private function validateCapabilityEnabled(string $type): void + private function assertRequestCapability(string $method): void + { + $capabilities = $this->configuration->capabilities; + + switch ($method) { + case "ping": + case "initialize": + // No specific capability required for these methods + break; + + case 'tools/list': + case 'tools/call': + if (!$capabilities->toolsEnabled) { + throw McpServerException::methodNotFound($method, 'Tools are not enabled on this server.'); + } + break; + + case 'resources/list': + case 'resources/templates/list': + case 'resources/read': + if (!$capabilities->resourcesEnabled) { + throw McpServerException::methodNotFound($method, 'Resources are not enabled on this server.'); + } + break; + + case 'resources/subscribe': + case 'resources/unsubscribe': + if (!$capabilities->resourcesEnabled) { + throw McpServerException::methodNotFound($method, 'Resources are not enabled on this server.'); + } + if (!$capabilities->resourcesSubscribe) { + throw McpServerException::methodNotFound($method, 'Resources subscription is not enabled on this server.'); + } + break; + + case 'prompts/list': + case 'prompts/get': + if (!$capabilities->promptsEnabled) { + throw McpServerException::methodNotFound($method, 'Prompts are not enabled on this server.'); + } + break; + + case 'logging/setLevel': + if (!$capabilities->loggingEnabled) { + throw McpServerException::methodNotFound($method, 'Logging is not enabled on this server.'); + } + break; + + default: + break; + } + } + + private function assertNotificationCapability(string $method): void { - $caps = $this->configuration->capabilities; - - $enabled = match ($type) { - 'tools' => $caps->toolsEnabled, - 'resources', 'resources/templates' => $caps->resourcesEnabled, - 'resources/subscribe', 'resources/unsubscribe' => $caps->resourcesEnabled && $caps->resourcesSubscribe, - 'prompts' => $caps->promptsEnabled, - 'logging' => $caps->loggingEnabled, - default => false, - }; - - if (!$enabled) { - $methodSegment = explode('/', $type)[0]; - throw McpServerException::methodNotFound("MCP capability '{$methodSegment}' is not enabled on this server."); + $capabilities = $this->configuration->capabilities; + + switch ($method) { + case 'notifications/message': + if (!$capabilities->loggingEnabled) { + throw McpServerException::methodNotFound($method, 'Logging is not enabled on this server.'); + } + break; + + case "notifications/initialized": + // Initialized notifications are always allowed + break; + + case "notifications/resources/updated": + case "notifications/resources/list_changed": + if (!$capabilities->resourcesListChanged) { + throw McpServerException::methodNotFound($method, 'Resources list changed notifications are not enabled on this server.'); + } + break; + + case "notifications/tools/list_changed": + if (!$capabilities->toolsListChanged) { + throw McpServerException::methodNotFound($method, 'Tools list changed notifications are not enabled on this server.'); + } + break; + + case "notifications/prompts/list_changed": + if (!$capabilities->promptsListChanged) { + throw McpServerException::methodNotFound($method, 'Prompts list changed notifications are not enabled on this server.'); + } + break; + + case "notifications/cancelled": + // Cancellation notifications are always allowed + break; + + case "notifications/progress": + // Progress notifications are always allowed + break; + + default: + break; } } diff --git a/src/Server.php b/src/Server.php index 6a2ca92..c812bd2 100644 --- a/src/Server.php +++ b/src/Server.php @@ -41,7 +41,8 @@ public function __construct( protected readonly Registry $registry, protected readonly Protocol $protocol, protected readonly SessionManager $sessionManager, - ) {} + ) { + } public static function make(): ServerBuilder { diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index 8047353..c0dcd42 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -55,7 +55,9 @@ final class ServerBuilder private array $manualPrompts = []; - public function __construct() {} + public function __construct() + { + } /** * Sets the server's identity. Required. diff --git a/src/Session/ArraySessionHandler.php b/src/Session/ArraySessionHandler.php index d4d8c33..b184428 100644 --- a/src/Session/ArraySessionHandler.php +++ b/src/Session/ArraySessionHandler.php @@ -13,7 +13,9 @@ class ArraySessionHandler implements SessionHandlerInterface */ protected array $store = []; - public function __construct(public readonly int $ttl = 3600) {} + public function __construct(public readonly int $ttl = 3600) + { + } public function open(string $savePath, string $sessionName): bool { diff --git a/src/Session/CacheSessionHandler.php b/src/Session/CacheSessionHandler.php index 5391e8c..6c0bc0f 100644 --- a/src/Session/CacheSessionHandler.php +++ b/src/Session/CacheSessionHandler.php @@ -9,7 +9,9 @@ class CacheSessionHandler implements SessionHandlerInterface { - public function __construct(public readonly CacheInterface $cache, public readonly int $ttl = 3600) {} + public function __construct(public readonly CacheInterface $cache, public readonly int $ttl = 3600) + { + } public function open(string $savePath, string $sessionName): bool { diff --git a/src/Session/SessionManager.php b/src/Session/SessionManager.php index 26957f2..c9c153e 100644 --- a/src/Session/SessionManager.php +++ b/src/Session/SessionManager.php @@ -27,6 +27,7 @@ public function __construct( protected int $ttl = 3600 ) { $this->loop ??= Loop::get(); + $this->startGcTimer(); } /** @@ -113,7 +114,9 @@ public function deleteSession(string $sessionId): bool public function queueMessage(string $sessionId, string $message): void { $session = $this->getSession($sessionId); - if ($session === null) return; + if ($session === null) { + return; + } $session->queueMessage($message); $session->save(); @@ -122,7 +125,9 @@ public function queueMessage(string $sessionId, string $message): void public function dequeueMessages(string $sessionId): array { $session = $this->getSession($sessionId); - if ($session === null) return []; + if ($session === null) { + return []; + } $messages = $session->dequeueMessages(); $session->save(); @@ -133,8 +138,15 @@ public function dequeueMessages(string $sessionId): array public function hasQueuedMessages(string $sessionId): bool { $session = $this->getSession($sessionId, true); - if ($session === null) return false; + if ($session === null) { + return false; + } return $session->hasQueuedMessages(); } + + public function __destruct() + { + $this->stopGcTimer(); + } } diff --git a/src/Support/RequestHandler.php b/src/Support/RequestHandler.php index aaeb54c..7fe67d4 100644 --- a/src/Support/RequestHandler.php +++ b/src/Support/RequestHandler.php @@ -375,15 +375,18 @@ private function decodeCursor(?string $cursor): int if ($cursor === null) { return 0; } + $decoded = base64_decode($cursor, true); if ($decoded === false) { $this->logger->warning('Received invalid pagination cursor (not base64)', ['cursor' => $cursor]); return 0; } + if (preg_match('/^offset=(\d+)$/', $decoded, $matches)) { return (int) $matches[1]; } + $this->logger->warning('Received invalid pagination cursor format', ['cursor' => $decoded]); return 0; diff --git a/src/Traits/ResponseFormatter.php b/src/Traits/ResponseFormatter.php index 3bd98d5..64d5cbd 100644 --- a/src/Traits/ResponseFormatter.php +++ b/src/Traits/ResponseFormatter.php @@ -111,7 +111,7 @@ protected function formatResourceContents(mixed $readResult, string $uri, ?strin } if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof EmbeddedResource) { - return array_map(fn($item) => $item->resource, $readResult); + return array_map(fn ($item) => $item->resource, $readResult); } if (is_string($readResult)) { diff --git a/src/Transports/HttpServerTransport.php b/src/Transports/HttpServerTransport.php index caea6e6..87b460f 100644 --- a/src/Transports/HttpServerTransport.php +++ b/src/Transports/HttpServerTransport.php @@ -76,7 +76,7 @@ public function __construct( $this->loop = Loop::get(); $this->ssePath = '/' . trim($mcpPathPrefix, '/') . '/sse'; $this->messagePath = '/' . trim($mcpPathPrefix, '/') . '/message'; - $this->idGenerator = $idGenerator ?? new RandomIdGenerator; + $this->idGenerator = $idGenerator ?? new RandomIdGenerator(); } public function setLogger(LoggerInterface $logger): void diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php index a847508..7618c77 100644 --- a/src/Transports/StreamableHttpServerTransport.php +++ b/src/Transports/StreamableHttpServerTransport.php @@ -164,9 +164,9 @@ private function createRequestHandler(): callable try { return match ($method) { - 'GET' => $this->handleGetRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), - 'POST' => $this->handlePostRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), - 'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), + 'GET' => $this->handleGetRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), + 'POST' => $this->handlePostRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), + 'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), default => $addCors($this->handleUnsupportedRequest($request)), }; } catch (Throwable $e) { diff --git a/tests/Unit/Definitions/PromptDefinitionTest.php b/tests/Unit/Definitions/PromptDefinitionTest.php index f1b6da6..76576ae 100644 --- a/tests/Unit/Definitions/PromptDefinitionTest.php +++ b/tests/Unit/Definitions/PromptDefinitionTest.php @@ -15,7 +15,7 @@ // --- Constructor Validation Tests --- test('constructor validates prompt name pattern', function (string $promptName, bool $shouldFail) { - $action = fn() => new PromptDefinition( + $action = fn () => new PromptDefinition( className: AllElementsStub::class, methodName: 'templateMethod', promptName: $promptName, diff --git a/tests/Unit/Definitions/ResourceDefinitionTest.php b/tests/Unit/Definitions/ResourceDefinitionTest.php index cb38c16..82536ad 100644 --- a/tests/Unit/Definitions/ResourceDefinitionTest.php +++ b/tests/Unit/Definitions/ResourceDefinitionTest.php @@ -13,7 +13,7 @@ // --- Constructor Validation Tests --- test('constructor validates resource name pattern', function (string $resourceName, bool $shouldFail) { - $action = fn() => new ResourceDefinition( + $action = fn () => new ResourceDefinition( className: AllElementsStub::class, methodName: 'resourceMethod', uri: 'file:///valid/uri', @@ -37,7 +37,7 @@ className: AllElementsStub::class, ]); test('constructor validates URI pattern', function (string $uri, bool $shouldFail) { - $action = fn() => new ResourceDefinition( + $action = fn () => new ResourceDefinition( className: AllElementsStub::class, methodName: 'resourceMethod', uri: $uri, diff --git a/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php b/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php index 8a59ba0..30c3f87 100644 --- a/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php +++ b/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php @@ -12,7 +12,7 @@ // --- Constructor Validation Tests --- test('constructor validates template name pattern', function (string $templateName, bool $shouldFail) { - $action = fn() => new ResourceTemplateDefinition( + $action = fn () => new ResourceTemplateDefinition( className: AllElementsStub::class, methodName: 'templateMethod', uriTemplate: 'user://{userId}/profile', @@ -35,7 +35,7 @@ className: AllElementsStub::class, ]); test('constructor validates URI template pattern', function (string $uriTemplate, bool $shouldFail) { - $action = fn() => new ResourceTemplateDefinition( + $action = fn () => new ResourceTemplateDefinition( className: AllElementsStub::class, methodName: 'templateMethod', uriTemplate: $uriTemplate, diff --git a/tests/Unit/Definitions/ToolDefinitionTest.php b/tests/Unit/Definitions/ToolDefinitionTest.php index 9a3dbaa..5dee833 100644 --- a/tests/Unit/Definitions/ToolDefinitionTest.php +++ b/tests/Unit/Definitions/ToolDefinitionTest.php @@ -12,7 +12,7 @@ use ReflectionMethod; test('constructor validates tool name pattern', function (string $toolName, bool $shouldFail) { - $action = fn() => new ToolDefinition( + $action = fn () => new ToolDefinition( className: AllElementsStub::class, methodName: 'templateMethod', toolName: $toolName, diff --git a/tests/Unit/JsonRpc/NotificationTest.php b/tests/Unit/JsonRpc/NotificationTest.php index 12786b0..2788ce2 100644 --- a/tests/Unit/JsonRpc/NotificationTest.php +++ b/tests/Unit/JsonRpc/NotificationTest.php @@ -60,27 +60,27 @@ 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 + expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException for missing jsonrpc', function () { $data = ['method' => 'test.method']; - expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED + expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException for missing method', function () { $data = ['jsonrpc' => '2.0']; - expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED + expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException for non-string method', function () { $data = ['jsonrpc' => '2.0', 'method' => 123]; - expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED + expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException if params is not an array/object', function () { $data = ['jsonrpc' => '2.0', 'method' => 'test', 'params' => 'string']; - expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); + expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); }); test('toArray returns correct structure with params', function () { diff --git a/tests/Unit/ProtocolTest.php b/tests/Unit/ProtocolTest.php index 094347f..c0371b4 100644 --- a/tests/Unit/ProtocolTest.php +++ b/tests/Unit/ProtocolTest.php @@ -102,7 +102,7 @@ $rawJson = '{"jsonrpc":"2.0", "id":'; $this->requestProcessor->shouldNotReceive('process'); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32700') && str_contains($json, '"id":null')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32700') && str_contains($json, '"id":null')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); @@ -112,7 +112,7 @@ $rawJson = '{"jsonrpc":"2.0", "id": 456}'; // Missing method $this->requestProcessor->shouldNotReceive('process'); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32600') && str_contains($json, '"id":456')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32600') && str_contains($json, '"id":456')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); @@ -125,7 +125,7 @@ $mcpException = McpServerException::methodNotFound($method); $this->requestProcessor->shouldReceive('process')->once()->andThrow($mcpException); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32601') && str_contains($json, '"id":789')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32601') && str_contains($json, '"id":789')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); @@ -138,7 +138,7 @@ $internalException = new \RuntimeException('Borked'); $this->requestProcessor->shouldReceive('process')->once()->andThrow($internalException); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32603') && str_contains($json, '"id":101')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32603') && str_contains($json, '"id":101')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); diff --git a/tests/Unit/ServerBuilderTest.php b/tests/Unit/ServerBuilderTest.php index 2649fa5..477817e 100644 --- a/tests/Unit/ServerBuilderTest.php +++ b/tests/Unit/ServerBuilderTest.php @@ -20,18 +20,26 @@ 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 () { diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index 4851e06..7c0f288 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -152,7 +152,7 @@ $transport->shouldReceive('setLogger', 'setLoop', 'on', 'once', 'removeListener', 'close')->withAnyArgs()->byDefault(); $transport->shouldReceive('listen')->once(); // Expect listen on first call $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); // Allow emit - $this->loop->shouldReceive('run')->once()->andReturnUsing(fn() => $transport->emit('close')); // Simulate loop run for first call + $this->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); // Simulate loop run for first call $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->once(); $transport->shouldReceive('removeAllListeners')->once(); @@ -164,7 +164,7 @@ $prop->setValue($this->server, true); // Act & Assert: Second call throws - expect(fn() => $this->server->listen($transport)) + expect(fn () => $this->server->listen($transport)) ->toThrow(LogicException::class, 'Server is already listening'); }); @@ -182,7 +182,7 @@ $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); // Allow emit $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->once(); $transport->shouldReceive('removeAllListeners')->once(); - $this->loop->shouldReceive('run')->once()->andReturnUsing(fn() => $transport->emit('close')); + $this->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); $this->server->listen($transport); }); @@ -203,7 +203,7 @@ $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->once(); $transport->shouldReceive('removeAllListeners')->once(); - $this->loop->shouldReceive('run')->once()->andReturnUsing(fn() => $transport->emit('close')); + $this->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); $this->server->listen($transport); }); @@ -220,7 +220,7 @@ $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->once(); $transport->shouldReceive('removeAllListeners')->once(); - $this->loop->shouldReceive('run')->once()->andReturnUsing(fn() => $transport->emit('close')); + $this->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); $this->server->listen($transport); }); @@ -234,7 +234,7 @@ $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->once(); $transport->shouldReceive('removeAllListeners')->once(); - $this->loop->shouldReceive('run')->once()->andReturnUsing(fn() => $transport->emit('close')); + $this->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); $this->server->listen($transport); }); diff --git a/tests/Unit/State/ClientStateManagerTest.php b/tests/Unit/State/ClientStateManagerTest.php index cb6c72c..68cc757 100644 --- a/tests/Unit/State/ClientStateManagerTest.php +++ b/tests/Unit/State/ClientStateManagerTest.php @@ -172,7 +172,7 @@ function getActiveClientsKey(): string $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) + ->with($clientStateKey, Mockery::on(fn (ClientState $s) => $s->isInitialized === true), CACHE_TTL_CSM) ->andReturn(true); // updateGlobalActiveClientTimestamp $this->cache->shouldReceive('get')->once()->with($activeClientsKey, [])->andReturn([]); @@ -220,7 +220,7 @@ function getActiveClientsKey(): string $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) + ->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([]); @@ -241,7 +241,7 @@ function getActiveClientsKey(): string $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) + ->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]); @@ -263,7 +263,7 @@ function getActiveClientsKey(): string $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) + ->with($clientStateKey, Mockery::on(fn (ClientState $s) => empty($s->subscriptions)), CACHE_TTL_CSM) ->andReturn(true); // Interaction with global resource sub list for URI 1 @@ -314,7 +314,7 @@ function getActiveClientsKey(): string $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) + ->with($clientStateKey, Mockery::on(fn (ClientState $s) => empty($s->messageQueue)), CACHE_TTL_CSM) ->andReturn(true); $retrieved = $this->stateManagerWithCache->getQueuedMessages(TEST_CLIENT_ID_CSM); @@ -328,7 +328,7 @@ function getActiveClientsKey(): string $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) + ->with($clientStateKey, Mockery::on(fn (ClientState $s) => $s->requestedLogLevel === $level), CACHE_TTL_CSM) ->andReturn(true); $this->stateManagerWithCache->setClientRequestedLogLevel(TEST_CLIENT_ID_CSM, $level); @@ -356,7 +356,7 @@ function getActiveClientsKey(): string $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... + $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 @@ -365,7 +365,7 @@ function getActiveClientsKey(): string 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); + $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 } @@ -384,10 +384,10 @@ function getActiveClientsKey(): string $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) + ->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->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); }); @@ -430,7 +430,7 @@ function getActiveClientsKey(): string 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 {}); + ->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/Support/DiscovererTest.php b/tests/Unit/Support/DiscovererTest.php index d7c2971..3d9f446 100644 --- a/tests/Unit/Support/DiscovererTest.php +++ b/tests/Unit/Support/DiscovererTest.php @@ -56,7 +56,7 @@ return $arg instanceof ResourceTemplateDefinition && $arg->uriTemplate === 'discovered://template/{id}'; })); - $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); + $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); // Act $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); @@ -68,13 +68,13 @@ $file2Path = createDiscoveryTestFile('ResourceOnlyStub'); // Assert registry interactions - $this->registry->shouldReceive('registerTool')->once()->with(Mockery::on(fn($arg) => $arg->toolName === 'tool-from-file1')); + $this->registry->shouldReceive('registerTool')->once()->with(Mockery::on(fn ($arg) => $arg->toolName === 'tool-from-file1')); $this->registry->shouldNotReceive('registerResource'); $this->registry->shouldNotReceive('registerPrompt'); $this->registry->shouldNotReceive('registerResourceTemplate'); // Ensure no errors during processing of these files - $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn($ctx) => isset($ctx['file']) && ($ctx['file'] === $file1Path || $ctx['file'] === $file2Path))); + $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && ($ctx['file'] === $file1Path || $ctx['file'] === $file2Path))); // Act $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); @@ -130,7 +130,7 @@ } // Ensure no processing errors for this file - $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); + $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); // Act $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); @@ -154,16 +154,16 @@ // Assert registry interactions $this->registry->shouldReceive('registerTool') - ->with(Mockery::on(fn($arg) => $arg instanceof ToolDefinition && $arg->toolName === 'valid-tool')) + ->with(Mockery::on(fn ($arg) => $arg instanceof ToolDefinition && $arg->toolName === 'valid-tool')) ->once(); $this->registry->shouldReceive('registerTool') - ->with(Mockery::on(fn($arg) => $arg instanceof ToolDefinition && $arg->toolName === 'another-valid-tool')) + ->with(Mockery::on(fn ($arg) => $arg instanceof ToolDefinition && $arg->toolName === 'another-valid-tool')) ->once(); $this->registry->shouldNotReceive('registerResource'); // Ensure no *other* unexpected errors related to this class/methods $this->logger->shouldNotReceive('error') - ->with(Mockery::any(), Mockery::on(fn($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); + ->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); // Act $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); diff --git a/tests/Unit/Support/HandlerResolverTest.php b/tests/Unit/Support/HandlerResolverTest.php index ad080c5..4e3324e 100644 --- a/tests/Unit/Support/HandlerResolverTest.php +++ b/tests/Unit/Support/HandlerResolverTest.php @@ -8,20 +8,36 @@ class ValidHandlerClass { - public function publicMethod() {} - protected function protectedMethod() {} - private function privateMethod() {} - public static function staticMethod() {} - public function __construct() {} - public function __destruct() {} + public function publicMethod() + { + } + protected function protectedMethod() + { + } + private function privateMethod() + { + } + public static function staticMethod() + { + } + public function __construct() + { + } + public function __destruct() + { + } } class ValidInvokableClass { - public function __invoke() {} + public function __invoke() + { + } } -class NonInvokableClass {} +class NonInvokableClass +{ +} abstract class AbstractHandlerClass { From 2f660d20acf9dce5df0805fe7b9f999055fe256d Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 8 Jun 2025 23:09:40 +0100 Subject: [PATCH 08/27] feat: Improve session garbage collection and new registry event system --- src/Contracts/SessionHandlerInterface.php | 38 ++ .../Results/CompletionCompleteResult.php | 37 ++ src/Model/Capabilities.php | 14 +- src/Protocol.php | 153 +++++-- src/Registry.php | 379 +++++++----------- src/Server.php | 4 +- src/Session/ArraySessionHandler.php | 18 +- src/Session/CacheSessionHandler.php | 43 +- src/Session/Session.php | 4 +- src/Session/SessionManager.php | 15 +- src/Session/SubscriptionManager.php | 96 +++++ src/Support/RequestHandler.php | 73 +++- .../StreamableHttpServerTransport.php | 61 ++- 13 files changed, 588 insertions(+), 347 deletions(-) create mode 100644 src/Contracts/SessionHandlerInterface.php create mode 100644 src/JsonRpc/Results/CompletionCompleteResult.php create mode 100644 src/Session/SubscriptionManager.php diff --git a/src/Contracts/SessionHandlerInterface.php b/src/Contracts/SessionHandlerInterface.php new file mode 100644 index 0000000..e6fe8dc --- /dev/null +++ b/src/Contracts/SessionHandlerInterface.php @@ -0,0 +1,38 @@ + ['values' => $this->values]]; + if ($this->total !== null) { + $result['completion']['total'] = $this->total; + } + if ($this->hasMore !== null) { + $result['completion']['hasMore'] = $this->hasMore; + } + return $result; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Model/Capabilities.php b/src/Model/Capabilities.php index 4451332..00036b3 100644 --- a/src/Model/Capabilities.php +++ b/src/Model/Capabilities.php @@ -25,9 +25,11 @@ private function __construct( public readonly bool $promptsEnabled = true, public readonly bool $promptsListChanged = false, public readonly bool $loggingEnabled = false, + public readonly bool $completionsEnabled = false, public readonly ?string $instructions = null, public readonly ?array $experimental = null - ) {} + ) { + } /** * Factory method to create a Capabilities instance for the server. @@ -40,6 +42,7 @@ private function __construct( * @param bool $promptsEnabled Whether the prompts capability is generally enabled. * @param bool $promptsListChanged Whether the server supports 'prompts/listChanged' notifications. * @param bool $loggingEnabled Whether the server supports 'logging/setLevel'. + * @param bool $completionsEnabled Whether the server supports 'completions/get'. * @param string|null $instructions Optional static instructions text provided during initialization. * @param array|null $experimental Optional experimental capabilities declared by the server. */ @@ -52,6 +55,7 @@ public static function forServer( bool $promptsEnabled = true, bool $promptsListChanged = false, bool $loggingEnabled = false, + bool $completionsEnabled = false, ?string $instructions = null, ?array $experimental = null ): self { @@ -97,15 +101,13 @@ public function toInitializeResponseArray(): array|stdClass if ($this->loggingEnabled) { $data['logging'] = new stdClass(); } + if ($this->completionsEnabled) { + $data['completions'] = new stdClass(); + } if ($this->experimental !== null && ! empty($this->experimental)) { $data['experimental'] = $this->experimental; } - // Return empty object if no capabilities are effectively enabled/declared - // This might deviate slightly from spec if e.g. only 'tools' is true but listChanged is false, - // spec implies {'tools': {}} should still be sent. Let's keep it simple for now. - // Correction: Spec implies the key should exist if the capability is enabled. - // Let's ensure keys are present if the *Enabled flag is true. return empty($data) ? new stdClass() : $data; } } diff --git a/src/Protocol.php b/src/Protocol.php index 6f7f5df..9b8eca4 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -14,10 +14,14 @@ use PhpMcp\Server\JsonRpc\Messages\Request; use PhpMcp\Server\JsonRpc\Messages\Response; use PhpMcp\Server\Session\SessionManager; +use PhpMcp\Server\Session\SubscriptionManager; use PhpMcp\Server\Support\RequestHandler; use Psr\Log\LoggerInterface; +use React\Promise\PromiseInterface; use Throwable; +use function React\Promise\reject; + /** * Bridges the core MCP Processor logic with a ServerTransportInterface * by listening to transport events and processing incoming messages. @@ -27,7 +31,8 @@ */ class Protocol { - public const SUPPORTED_PROTOCOL_VERSIONS = ['2024-11-05', '2025-03-26']; + public const LATEST_PROTOCOL_VERSION = '2025-03-26'; + public const SUPPORTED_PROTOCOL_VERSIONS = [self::LATEST_PROTOCOL_VERSION, '2024-11-05']; protected ?ServerTransportInterface $transport = null; @@ -41,9 +46,19 @@ public function __construct( protected Registry $registry, protected SessionManager $sessionManager, protected ?RequestHandler $requestHandler = null, + protected ?SubscriptionManager $subscriptionManager = null, ) { $this->logger = $this->configuration->logger; - $this->requestHandler ??= new RequestHandler($this->configuration, $this->registry, $this->sessionManager); + $this->subscriptionManager ??= new SubscriptionManager($this->logger); + $this->requestHandler ??= new RequestHandler($this->configuration, $this->registry, $this->subscriptionManager); + + $this->sessionManager->on('session_deleted', function (string $sessionId) { + $this->subscriptionManager->cleanupSession($sessionId); + }); + + $this->registry->on('list_changed', function (string $listType) { + $this->handleListChanged($listType); + }); } /** @@ -171,6 +186,7 @@ private function processRequest(Request $request, SessionInterface $session): Re 'prompts/list' => $this->requestHandler->handlePromptsList($params), 'prompts/get' => $this->requestHandler->handlePromptGet($params), 'logging/setLevel' => $this->requestHandler->handleLoggingSetLevel($params, $session), + 'completion/complete' => $this->requestHandler->handleCompletionComplete($params, $session), default => throw McpServerException::methodNotFound($method), }; @@ -180,7 +196,7 @@ private function processRequest(Request $request, SessionInterface $session): Re return $e->toJsonRpcError($request->id); } catch (Throwable $e) { - $this->logger->error('MCP Processor caught unexpected error', ['method' => $method, 'exception' => $e]); + $this->logger->error('MCP Processor caught unexpected error', ['method' => $method, 'exception' => $e->getMessage()]); return new Error( jsonrpc: '2.0', @@ -200,11 +216,67 @@ private function processNotification(Notification $notification, SessionInterfac $method = $notification->method; $params = $notification->params; - if ($method === 'notifications/initialized') { - $this->requestHandler->handleNotificationInitialized($params, $session); + try { + if ($method === 'notifications/initialized') { + $this->requestHandler->handleNotificationInitialized($params, $session); + } + } catch (Throwable $e) { + $this->logger->error('Error while processing notification', ['method' => $method, 'exception' => $e->getMessage()]); + return; + } + } + + /** + * Send a notification to a session + */ + public function sendNotification(string $sessionId, Notification $notification): PromiseInterface + { + if ($this->transport === null) { + $this->logger->error('Cannot send notification, transport not bound', [ + 'sessionId' => $sessionId, + 'method' => $notification->method + ]); + return reject(new McpServerException('Transport not bound')); + } + + try { + return $this->transport->sendMessage($notification, $sessionId, []); + } catch (Throwable $e) { + $this->logger->error('Failed to send notification', [ + 'sessionId' => $sessionId, + 'method' => $notification->method, + 'error' => $e->getMessage() + ]); + return reject(new McpServerException('Failed to send notification: ' . $e->getMessage())); } } + /** + * Notify subscribers about resource content change + */ + public function notifyResourceChanged(string $uri): void + { + $subscribers = $this->subscriptionManager->getSubscribers($uri); + + if (empty($subscribers)) { + return; + } + + $notification = Notification::make( + 'notifications/resources/updated', + ['uri' => $uri] + ); + + foreach ($subscribers as $sessionId) { + $this->sendNotification($sessionId, $notification); + } + + $this->logger->debug("Sent resource change notification", [ + 'uri' => $uri, + 'subscriber_count' => count($subscribers) + ]); + } + /** * Validate that a session is initialized */ @@ -266,42 +338,50 @@ private function assertRequestCapability(string $method): void } break; + case 'completion/complete': + if (!$capabilities->completionsEnabled) { + throw McpServerException::methodNotFound($method, 'Completions are not enabled on this server.'); + } + break; + default: break; } } - private function assertNotificationCapability(string $method): void + private function canSendNotification(string $method): bool { $capabilities = $this->configuration->capabilities; + $valid = true; + switch ($method) { case 'notifications/message': if (!$capabilities->loggingEnabled) { - throw McpServerException::methodNotFound($method, 'Logging is not enabled on this server.'); + $this->logger->warning('Logging is not enabled on this server. Notifications/message will not be sent.'); + $valid = false; } break; - case "notifications/initialized": - // Initialized notifications are always allowed - break; - case "notifications/resources/updated": case "notifications/resources/list_changed": if (!$capabilities->resourcesListChanged) { - throw McpServerException::methodNotFound($method, 'Resources list changed notifications are not enabled on this server.'); + $this->logger->warning('Resources list changed notifications are not enabled on this server. Notifications/resources/list_changed will not be sent.'); + $valid = false; } break; case "notifications/tools/list_changed": if (!$capabilities->toolsListChanged) { - throw McpServerException::methodNotFound($method, 'Tools list changed notifications are not enabled on this server.'); + $this->logger->warning('Tools list changed notifications are not enabled on this server. Notifications/tools/list_changed will not be sent.'); + $valid = false; } break; case "notifications/prompts/list_changed": if (!$capabilities->promptsListChanged) { - throw McpServerException::methodNotFound($method, 'Prompts list changed notifications are not enabled on this server.'); + $this->logger->warning('Prompts list changed notifications are not enabled on this server. Notifications/prompts/list_changed will not be sent.'); + $valid = false; } break; @@ -316,21 +396,8 @@ private function assertNotificationCapability(string $method): void default: break; } - } - /** - * Parse a method string into type and action - */ - private function parseMethod(string $method): array - { - if (str_contains($method, '/')) { - $parts = explode('/', $method, 2); - if (count($parts) === 2) { - return [$parts[0], $parts[1]]; - } - } - - return [$method, '']; + return $valid; } /** @@ -353,6 +420,36 @@ public function handleClientDisconnected(string $sessionId, ?string $reason = nu $this->sessionManager->deleteSession($sessionId); } + /** + * Handle list changed event from registry + */ + private function handleListChanged(string $listType): void + { + $listChangeUri = "mcp://changes/{$listType}"; + + $subscribers = $this->subscriptionManager->getSubscribers($listChangeUri); + if (empty($subscribers)) { + return; + } + + $method = "notifications/{$listType}/list_changed"; + + if (!$this->canSendNotification($method)) { + return; + } + + $notification = Notification::make($method); + + foreach ($subscribers as $sessionId) { + $this->sendNotification($sessionId, $notification); + } + + $this->logger->debug("Sent list change notification", [ + 'list_type' => $listType, + 'subscriber_count' => count($subscribers) + ]); + } + /** * Handles 'error' event from the transport */ diff --git a/src/Registry.php b/src/Registry.php index 1da879a..1bccbfa 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -4,35 +4,36 @@ namespace PhpMcp\Server; -use ArrayObject; +use Evenement\EventEmitterInterface; +use Evenement\EventEmitterTrait; 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\Messages\Notification; -use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Support\UriTemplateMatcher; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use Psr\SimpleCache\InvalidArgumentException as CacheInvalidArgumentException; use Throwable; -class Registry +class Registry implements EventEmitterInterface { + use EventEmitterTrait; + private const DISCOVERED_ELEMENTS_CACHE_KEY = 'mcp_server_discovered_elements'; - /** @var ArrayObject */ - private ArrayObject $tools; + /** @var array */ + private array $tools = []; - /** @var ArrayObject */ - private ArrayObject $resources; + /** @var array */ + private array $resources = []; - /** @var ArrayObject */ - private ArrayObject $prompts; + /** @var array */ + private array $prompts = []; - /** @var ArrayObject */ - private ArrayObject $resourceTemplates; + /** @var array */ + private array $resourceTemplates = []; /** @var array */ private array $manualToolNames = []; @@ -46,142 +47,124 @@ class Registry /** @var array */ private array $manualTemplateUris = []; - private bool $discoveredElementsLoaded = false; + private array $listHashes = [ + 'tools' => '', + 'resources' => '', + 'resource_templates' => '', + 'prompts' => '', + ]; private bool $notificationsEnabled = true; - public function __construct( protected LoggerInterface $logger, protected ?CacheInterface $cache = null, - protected ?SessionManager $sessionManager = null ) { - $this->initializeCollections(); - - if ($this->cache) { - $this->loadDiscoveredElementsFromCache(); - } else { - $this->discoveredElementsLoaded = true; - $this->logger->debug('No cache provided to registry, skipping initial cache load.'); - } + $this->load(); + $this->computeAllHashes(); } /** - * Checks if discovery has been run OR elements loaded from cache. - * - * Note: Manual elements can exist even if this is false initially. + * Compute hashes for all lists for change detection */ - public function discoveryRanOrCached(): bool + private function computeAllHashes(): void { - return $this->discoveredElementsLoaded; + $this->listHashes['tools'] = $this->computeHash($this->tools); + $this->listHashes['resources'] = $this->computeHash($this->resources); + $this->listHashes['resource_templates'] = $this->computeHash($this->resourceTemplates); + $this->listHashes['prompts'] = $this->computeHash($this->prompts); } - /** Checks if any elements (manual or discovered) are currently registered. */ - public function hasElements(): bool - { - return ! empty($this->tools->getArrayCopy()) - || ! empty($this->resources->getArrayCopy()) - || ! empty($this->prompts->getArrayCopy()) - || ! empty($this->resourceTemplates->getArrayCopy()); - } - - private function initializeCollections(): void + /** + * Compute a stable hash for a collection + */ + private function computeHash(array $collection): string { - $this->tools = new ArrayObject(); - $this->resources = new ArrayObject(); - $this->prompts = new ArrayObject(); - $this->resourceTemplates = new ArrayObject(); - - $this->manualToolNames = []; - $this->manualResourceUris = []; - $this->manualPromptNames = []; - $this->manualTemplateUris = []; - } + if (empty($collection)) { + return ''; + } - public function enableNotifications(): void - { - $this->notifyToolsChanged = function () { - if ($this->sessionManager) { - $notification = Notification::make('notifications/tools/list_changed'); - $framedMessage = json_encode($notification->toArray(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; - if ($framedMessage !== false) { - // $this->sessionManager->queueMessageForAll($framedMessage); - } - } - }; - - $this->notifyResourcesChanged = function () { - if ($this->sessionManager) { - $notification = Notification::make('notifications/resources/list_changed'); - $framedMessage = json_encode($notification->toArray(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; - if ($framedMessage !== false) { - // $this->sessionManager->queueMessageForAll($framedMessage); - } - } - }; - - $this->notifyPromptsChanged = function () { - if ($this->sessionManager) { - $notification = Notification::make('notifications/prompts/list_changed'); - $framedMessage = json_encode($notification->toArray(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; - if ($framedMessage !== false) { - // $this->sessionManager->queueMessageForAll($framedMessage); - } - } - }; + ksort($collection); + return md5(json_encode($collection)); } - public function setToolsChangedNotifier(?callable $notifier): void + public function load(): void { - if (!$this->notificationsEnabled || !$this->clientStateManager) { + if ($this->cache === null) { return; } - $notification = Notification::make('notifications/prompts/list_changed'); - $framedMessage = json_encode($notification, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; - if ($framedMessage === false || $framedMessage === "\n") { - $this->logger->error('Failed to encode notification for queuing.', ['method' => $notification->method]); - return; - } - $this->clientStateManager->queueMessageForAll($framedMessage); - } + $this->clear(); - public function notifyResourceUpdated(string $uri): void - { - if (!$this->notificationsEnabled || !$this->clientStateManager) { - return; - } + try { + $cached = $this->cache->get(self::DISCOVERED_ELEMENTS_CACHE_KEY); - $subscribers = $this->clientStateManager->getResourceSubscribers($uri); - if (empty($subscribers)) { - return; - } - $notification = Notification::make('notifications/resources/updated', ['uri' => $uri]); + if (is_array($cached)) { + $loadCount = 0; - $framedMessage = json_encode($notification, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; - if ($framedMessage === false || $framedMessage === "\n") { - $this->logger->error('Failed to encode resource/updated notification.', ['uri' => $uri]); - return; - } + foreach ($cached['tools'] ?? [] as $toolData) { + $toolDefinition = $toolData instanceof ToolDefinition ? $toolData : ToolDefinition::fromArray($toolData); + $toolName = $toolDefinition->toolName; + if (! isset($this->manualToolNames[$toolName])) { + $this->tools[$toolName] = $toolDefinition; + $loadCount++; + } else { + $this->logger->debug("Skipping cached tool '{$toolName}' as manual version exists."); + } + } - foreach ($subscribers as $clientId) { - $this->clientStateManager->queueMessage($clientId, $framedMessage); - } - } + foreach ($cached['resources'] ?? [] as $resourceData) { + $resourceDefinition = $resourceData instanceof ResourceDefinition ? $resourceData : ResourceDefinition::fromArray($resourceData); + $uri = $resourceDefinition->uri; + if (! isset($this->manualResourceUris[$uri])) { + $this->resources[$uri] = $resourceDefinition; + $loadCount++; + } else { + $this->logger->debug("Skipping cached resource '{$uri}' as manual version exists."); + } + } - /** @deprecated */ - public function setToolsChangedNotifier(?callable $notifier): void {} + foreach ($cached['prompts'] ?? [] as $promptData) { + $promptDefinition = $promptData instanceof PromptDefinition ? $promptData : PromptDefinition::fromArray($promptData); + $promptName = $promptDefinition->promptName; + if (! isset($this->manualPromptNames[$promptName])) { + $this->prompts[$promptName] = $promptDefinition; + $loadCount++; + } else { + $this->logger->debug("Skipping cached prompt '{$promptName}' as manual version exists."); + } + } - /** @deprecated */ - public function setResourcesChangedNotifier(?callable $notifier): void {} + foreach ($cached['resourceTemplates'] ?? [] as $templateData) { + $templateDefinition = $templateData instanceof ResourceTemplateDefinition ? $templateData : ResourceTemplateDefinition::fromArray($templateData); + $uriTemplate = $templateDefinition->uriTemplate; + if (! isset($this->manualTemplateUris[$uriTemplate])) { + $this->resourceTemplates[$uriTemplate] = $templateDefinition; + $loadCount++; + } else { + $this->logger->debug("Skipping cached template '{$uriTemplate}' as manual version exists."); + } + } - /** @deprecated */ - public function setPromptsChangedNotifier(?callable $notifier): void {} + $this->logger->debug("Loaded {$loadCount} elements from cache."); + } elseif ($cached !== null) { + $this->logger->warning('Invalid data type found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); + } else { + $this->logger->debug('Cache miss or empty.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); + } + } catch (CacheInvalidArgumentException $e) { + $this->logger->error('Invalid registry cache key used.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); + } catch (DefinitionException $e) { + $this->logger->error('Error hydrating definition from cache.', ['exception' => $e]); + } catch (Throwable $e) { + $this->logger->error('Unexpected error loading from cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); + } + } public function registerTool(ToolDefinition $tool, bool $isManual = false): void { $toolName = $tool->toolName; - $exists = $this->tools->offsetExists($toolName); + $exists = isset($this->tools[$toolName]); $wasManual = isset($this->manualToolNames[$toolName]); if ($exists && ! $isManual && $wasManual) { @@ -202,42 +185,39 @@ public function registerTool(ToolDefinition $tool, bool $isManual = false): void unset($this->manualToolNames[$toolName]); } - if (! $exists) { - $this->notifyToolsListChanged(); - } + $this->checkAndEmitChange('tools', $this->tools); } public function registerResource(ResourceDefinition $resource, bool $isManual = false): void { $uri = $resource->uri; - $exists = $this->resources->offsetExists($uri); + $exists = isset($this->resources[$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."); + $this->logger->debug("Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one."); return; } if ($exists) { - $this->logger->warning('MCP Registry: Replacing existing ' . ($wasManual ? 'manual' : 'discovered') . " resource '{$uri}' with " . ($isManual ? 'manual' : 'discovered') . ' definition.'); + $this->logger->warning('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 (! $exists) { - $this->notifyResourcesListChanged(); - } + $this->checkAndEmitChange('resources', $this->resources); } public function registerResourceTemplate(ResourceTemplateDefinition $template, bool $isManual = false): void { $uriTemplate = $template->uriTemplate; - $exists = $this->resourceTemplates->offsetExists($uriTemplate); + $exists = isset($this->resourceTemplates[$uriTemplate]); $wasManual = isset($this->manualTemplateUris[$uriTemplate]); if ($exists && ! $isManual && $wasManual) { @@ -250,18 +230,20 @@ public function registerResourceTemplate(ResourceTemplateDefinition $template, b } $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, bool $isManual = false): void { $promptName = $prompt->promptName; - $exists = $this->prompts->offsetExists($promptName); + $exists = isset($this->prompts[$promptName]); $wasManual = isset($this->manualPromptNames[$promptName]); if ($exists && ! $isManual && $wasManual) { @@ -274,107 +256,46 @@ public function registerPrompt(PromptDefinition $prompt, bool $isManual = false) } $this->prompts[$promptName] = $prompt; + if ($isManual) { $this->manualPromptNames[$promptName] = true; } elseif ($wasManual) { unset($this->manualPromptNames[$promptName]); } - if (! $exists) { - $this->notifyPromptsListChanged(); - } + $this->checkAndEmitChange('prompts', $this->prompts); } - public function loadDiscoveredElementsFromCache(bool $force = false): void + public function enableNotifications(): void { - if ($this->cache === null) { - $this->logger->debug('MCP Registry: Cache load skipped, cache not available.'); - $this->discoveredElementsLoaded = true; + $this->notificationsEnabled = true; + } - return; - } + public function disableNotifications(): void + { + $this->notificationsEnabled = false; + } - if ($this->discoveredElementsLoaded && ! $force) { - return; // Already loaded or ran discovery this session + /** + * Check if a list has changed and emit event if needed + */ + private function checkAndEmitChange(string $listType, array $collection): void + { + if (! $this->notificationsEnabled) { + return; } - $this->clearDiscoveredElements(false); // Don't delete cache, just clear internal collections - - 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->toolName; - 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->uri; - 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->promptName; - if (! isset($this->manualPromptNames[$promptName])) { - $this->prompts[$promptName] = $promptDefinition; - $loadCount++; - } else { - $this->logger->debug("Skipping cached prompt '{$promptName}' as manual version exists."); - } - } + $newHash = $this->computeHash($collection); - foreach ($cached['resourceTemplates'] ?? [] as $templateData) { - $templateDefinition = $templateData instanceof ResourceTemplateDefinition ? $templateData : ResourceTemplateDefinition::fromArray($templateData); - $uriTemplate = $templateDefinition->uriTemplate; - 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]); + if ($newHash !== $this->listHashes[$listType]) { + $this->listHashes[$listType] = $newHash; + $this->emit('list_changed', [$listType]); } } - public function saveDiscoveredElementsToCache(): bool + public function save(): bool { if ($this->cache === null) { - $this->logger->debug('MCP Registry: Cache save skipped, cache not available.'); - return false; } @@ -413,33 +334,40 @@ public function saveDiscoveredElementsToCache(): bool $success = $this->cache->set(self::DISCOVERED_ELEMENTS_CACHE_KEY, $discoveredData); if ($success) { - $this->logger->debug('MCP Registry: Elements saved to cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); + $this->logger->debug('Registry elements saved to cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); } else { - $this->logger->warning('MCP Registry: Cache set operation returned false.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); + $this->logger->warning('Registry cache set operation returned false.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); } return $success; } catch (CacheInvalidArgumentException $e) { - $this->logger->error('MCP Registry: Invalid cache key or value during save.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); + $this->logger->error('Invalid cache key or value during save.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); return false; } catch (Throwable $e) { - $this->logger->error('MCP Registry: Unexpected error saving to cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); + $this->logger->error('Unexpected error saving to cache.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); return false; } } - public function clearDiscoveredElements(bool $deleteFromCache = true): void + /** Checks if any elements (manual or discovered) are currently registered. */ + public function hasElements(): bool { - $this->logger->debug('Clearing discovered elements...', ['deleteCacheFile' => $deleteFromCache]); + return ! empty($this->tools) + || ! empty($this->resources) + || ! empty($this->prompts) + || ! empty($this->resourceTemplates); + } - if ($deleteFromCache && $this->cache !== null) { + public function clear(): void + { + if ($this->cache !== null) { try { $this->cache->delete(self::DISCOVERED_ELEMENTS_CACHE_KEY); - $this->logger->info('MCP Registry: Discovered elements cache cleared.'); + $this->logger->debug('Registry cache cleared.'); } catch (Throwable $e) { - $this->logger->error('MCP Registry: Error clearing discovered elements cache.', ['exception' => $e]); + $this->logger->error('Error clearing registry cache.', ['exception' => $e]); } } @@ -470,7 +398,6 @@ public function clearDiscoveredElements(bool $deleteFromCache = true): void } } - $this->discoveredElementsLoaded = false; $this->logger->debug("Removed {$clearCount} discovered elements from internal registry."); } @@ -515,26 +442,26 @@ public function findResourceTemplateByUri(string $uri): ?array return null; } - /** @return ArrayObject */ - public function allTools(): ArrayObject + /** @return array */ + public function getTools(): array { return $this->tools; } - /** @return ArrayObject */ - public function allResources(): ArrayObject + /** @return array */ + public function getResources(): array { return $this->resources; } - /** @return ArrayObject */ - public function allPrompts(): ArrayObject + /** @return array */ + public function getPrompts(): array { return $this->prompts; } - /** @return ArrayObject */ - public function allResourceTemplates(): ArrayObject + /** @return array */ + public function getResourceTemplates(): array { return $this->resourceTemplates; } diff --git a/src/Server.php b/src/Server.php index c812bd2..2d76649 100644 --- a/src/Server.php +++ b/src/Server.php @@ -90,7 +90,7 @@ public function discover( 'saveToCache' => $shouldSaveCache, ]); - $this->registry->clearDiscoveredElements($shouldSaveCache); + $this->registry->clear(); try { $discoverer = new Discoverer($this->registry, $this->configuration->logger); @@ -101,7 +101,7 @@ public function discover( $this->configuration->logger->info('Element discovery process finished.'); if ($shouldSaveCache) { - $this->registry->saveDiscoveredElementsToCache(); + $this->registry->save(); } } catch (Throwable $e) { $this->discoveryRan = false; diff --git a/src/Session/ArraySessionHandler.php b/src/Session/ArraySessionHandler.php index b184428..afe0b75 100644 --- a/src/Session/ArraySessionHandler.php +++ b/src/Session/ArraySessionHandler.php @@ -4,7 +4,7 @@ namespace PhpMcp\Server\Session; -use SessionHandlerInterface; +use PhpMcp\Server\Contracts\SessionHandlerInterface; class ArraySessionHandler implements SessionHandlerInterface { @@ -17,16 +17,6 @@ public function __construct(public readonly int $ttl = 3600) { } - public function open(string $savePath, string $sessionName): bool - { - return true; - } - - public function close(): bool - { - return true; - } - public function read(string $sessionId): string|false { $session = $this->store[$sessionId] ?? ''; @@ -63,15 +53,15 @@ public function destroy(string $sessionId): bool return true; } - public function gc(int $maxLifetime): int|false + public function gc(int $maxLifetime): array { $currentTimestamp = time(); + $deletedSessions = []; - $deletedSessions = 0; foreach ($this->store as $sessionId => $session) { if ($currentTimestamp - $session['timestamp'] > $maxLifetime) { unset($this->store[$sessionId]); - $deletedSessions++; + $deletedSessions[] = $sessionId; } } diff --git a/src/Session/CacheSessionHandler.php b/src/Session/CacheSessionHandler.php index 6c0bc0f..1925e6f 100644 --- a/src/Session/CacheSessionHandler.php +++ b/src/Session/CacheSessionHandler.php @@ -4,23 +4,19 @@ namespace PhpMcp\Server\Session; +use PhpMcp\Server\Contracts\SessionHandlerInterface; use Psr\SimpleCache\CacheInterface; -use SessionHandlerInterface; class CacheSessionHandler implements SessionHandlerInterface { - public function __construct(public readonly CacheInterface $cache, public readonly int $ttl = 3600) - { - } - - public function open(string $savePath, string $sessionName): bool - { - return true; - } - - public function close(): bool - { - return true; + private const SESSION_INDEX_KEY = 'mcp_session_index'; + private array $sessionIndex = []; + + public function __construct( + public readonly CacheInterface $cache, + public readonly int $ttl = 3600 + ) { + $this->sessionIndex = $this->cache->get(self::SESSION_INDEX_KEY, []); } public function read(string $sessionId): string|false @@ -30,16 +26,33 @@ public function read(string $sessionId): string|false public function write(string $sessionId, string $data): bool { + $this->sessionIndex[$sessionId] = time(); + $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); return $this->cache->set($sessionId, $data, $this->ttl); } public function destroy(string $sessionId): bool { + unset($this->sessionIndex[$sessionId]); + $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); return $this->cache->delete($sessionId); } - public function gc(int $maxLifetime): int|false + public function gc(int $maxLifetime): array { - return 0; + $currentTime = time(); + $deletedSessions = []; + + foreach ($this->sessionIndex as $sessionId => $timestamp) { + if ($currentTime - $timestamp > $maxLifetime) { + $this->cache->delete($sessionId); + unset($this->sessionIndex[$sessionId]); + $deletedSessions[] = $sessionId; + } + } + + $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); + + return $deletedSessions; } } diff --git a/src/Session/Session.php b/src/Session/Session.php index 38f95fd..d7f1285 100644 --- a/src/Session/Session.php +++ b/src/Session/Session.php @@ -4,8 +4,8 @@ namespace PhpMcp\Server\Session; +use PhpMcp\Server\Contracts\SessionHandlerInterface; use PhpMcp\Server\Contracts\SessionInterface; -use SessionHandlerInterface; class Session implements SessionInterface { @@ -147,10 +147,8 @@ public function hydrate(array $attributes): void 'initialized' => false, 'client_info' => null, 'protocol_version' => null, - 'subscriptions' => [], 'message_queue' => [], 'log_level' => null, - 'timestamp' => 0, ], $attributes ); diff --git a/src/Session/SessionManager.php b/src/Session/SessionManager.php index c9c153e..7a1f076 100644 --- a/src/Session/SessionManager.php +++ b/src/Session/SessionManager.php @@ -6,12 +6,12 @@ use Evenement\EventEmitterInterface; use Evenement\EventEmitterTrait; +use PhpMcp\Server\Contracts\SessionHandlerInterface; use PhpMcp\Server\Contracts\SessionInterface; use Psr\Log\LoggerInterface; use React\EventLoop\Loop; use React\EventLoop\LoopInterface; use React\EventLoop\TimerInterface; -use SessionHandlerInterface; class SessionManager implements EventEmitterInterface { @@ -40,8 +40,17 @@ public function startGcTimer(): void } $this->gcTimer = $this->loop->addPeriodicTimer($this->gcInterval, function () { - $count = $this->handler->gc($this->ttl); - $this->logger->debug('Session garbage collection complete', ['purged_sessions' => $count]); + $deletedSessions = $this->handler->gc($this->ttl); + + foreach ($deletedSessions as $sessionId) { + $this->emit('session_deleted', [$sessionId]); + } + + if (count($deletedSessions) > 0) { + $this->logger->debug('Session garbage collection complete', [ + 'purged_sessions' => count($deletedSessions), + ]); + } }); } diff --git a/src/Session/SubscriptionManager.php b/src/Session/SubscriptionManager.php new file mode 100644 index 0000000..e7fc733 --- /dev/null +++ b/src/Session/SubscriptionManager.php @@ -0,0 +1,96 @@ +> Key: URI, Value: array of session IDs */ + private array $resourceSubscribers = []; + + /** @var array> Key: Session ID, Value: array of URIs */ + private array $sessionSubscriptions = []; + + public function __construct( + private readonly LoggerInterface $logger + ) { + } + + /** + * Subscribe a session to a resource + */ + public function subscribe(string $sessionId, string $uri): void + { + // Add to both mappings for efficient lookup + $this->resourceSubscribers[$uri][$sessionId] = true; + $this->sessionSubscriptions[$sessionId][$uri] = true; + + $this->logger->debug('Session subscribed to resource', [ + 'sessionId' => $sessionId, + 'uri' => $uri + ]); + } + + /** + * Unsubscribe a session from a resource + */ + public function unsubscribe(string $sessionId, string $uri): void + { + unset($this->resourceSubscribers[$uri][$sessionId]); + unset($this->sessionSubscriptions[$sessionId][$uri]); + + // Clean up empty arrays + if (empty($this->resourceSubscribers[$uri])) { + unset($this->resourceSubscribers[$uri]); + } + + $this->logger->debug('Session unsubscribed from resource', [ + 'sessionId' => $sessionId, + 'uri' => $uri + ]); + } + + /** + * Get all sessions subscribed to a resource + */ + public function getSubscribers(string $uri): array + { + return array_keys($this->resourceSubscribers[$uri] ?? []); + } + + /** + * Check if a session is subscribed to a resource + */ + public function isSubscribed(string $sessionId, string $uri): bool + { + return isset($this->sessionSubscriptions[$sessionId][$uri]); + } + + /** + * Clean up all subscriptions for a session + */ + public function cleanupSession(string $sessionId): void + { + if (!isset($this->sessionSubscriptions[$sessionId])) { + return; + } + + $uris = array_keys($this->sessionSubscriptions[$sessionId]); + foreach ($uris as $uri) { + unset($this->resourceSubscribers[$uri][$sessionId]); + + // Clean up empty arrays + if (empty($this->resourceSubscribers[$uri])) { + unset($this->resourceSubscribers[$uri]); + } + } + + unset($this->sessionSubscriptions[$sessionId]); + + $this->logger->debug('Cleaned up all subscriptions for session', [ + 'sessionId' => $sessionId, + 'count' => count($uris) + ]); + } +} diff --git a/src/Support/RequestHandler.php b/src/Support/RequestHandler.php index 7fe67d4..5b74459 100644 --- a/src/Support/RequestHandler.php +++ b/src/Support/RequestHandler.php @@ -10,6 +10,7 @@ use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Server\JsonRpc\Contents\TextContent; use PhpMcp\Server\JsonRpc\Results\CallToolResult; +use PhpMcp\Server\JsonRpc\Results\CompletionCompleteResult; use PhpMcp\Server\JsonRpc\Results\EmptyResult; use PhpMcp\Server\JsonRpc\Results\GetPromptResult; use PhpMcp\Server\JsonRpc\Results\InitializeResult; @@ -21,6 +22,7 @@ use PhpMcp\Server\Protocol; use PhpMcp\Server\Registry; use PhpMcp\Server\Session\SessionManager; +use PhpMcp\Server\Session\SubscriptionManager; use PhpMcp\Server\Traits\ResponseFormatter; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; @@ -38,7 +40,7 @@ class RequestHandler public function __construct( protected Configuration $configuration, protected Registry $registry, - protected SessionManager $sessionManager, + protected SubscriptionManager $subscriptionManager, protected ?SchemaValidator $schemaValidator = null, protected ?ArgumentPreparer $argumentPreparer = null, ) { @@ -62,7 +64,7 @@ public function handleInitialize(array $params, SessionInterface $session): Init ]); } - $serverProtocolVersion = Protocol::SUPPORTED_PROTOCOL_VERSIONS[count(Protocol::SUPPORTED_PROTOCOL_VERSIONS) - 1]; + $serverProtocolVersion = Protocol::LATEST_PROTOCOL_VERSION; $clientInfo = $params['clientInfo'] ?? null; if (! is_array($clientInfo)) { @@ -94,7 +96,7 @@ public function handleToolList(array $params): ListToolsResult $cursor = $params['cursor'] ?? null; $limit = $this->configuration->paginationLimit; $offset = $this->decodeCursor($cursor); - $allItems = $this->registry->allTools()->getArrayCopy(); + $allItems = $this->registry->getTools(); $pagedItems = array_slice($allItems, $offset, $limit); $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); @@ -106,7 +108,7 @@ public function handleResourcesList(array $params): ListResourcesResult $cursor = $params['cursor'] ?? null; $limit = $this->configuration->paginationLimit; $offset = $this->decodeCursor($cursor); - $allItems = $this->registry->allResources()->getArrayCopy(); + $allItems = $this->registry->getResources(); $pagedItems = array_slice($allItems, $offset, $limit); $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); @@ -118,7 +120,7 @@ public function handleResourceTemplateList(array $params): ListResourceTemplates $cursor = $params['cursor'] ?? null; $limit = $this->configuration->paginationLimit; $offset = $this->decodeCursor($cursor); - $allItems = $this->registry->allResourceTemplates()->getArrayCopy(); + $allItems = $this->registry->getResourceTemplates(); $pagedItems = array_slice($allItems, $offset, $limit); $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); @@ -130,7 +132,7 @@ public function handlePromptsList(array $params): ListPromptsResult $cursor = $params['cursor'] ?? null; $limit = $this->configuration->paginationLimit; $offset = $this->decodeCursor($cursor); - $allItems = $this->registry->allPrompts()->getArrayCopy(); + $allItems = $this->registry->getPrompts(); $pagedItems = array_slice($allItems, $offset, $limit); $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); @@ -259,31 +261,27 @@ public function handleResourceRead(array $params): ReadResourceResult } } + public function handleResourceSubscribe(array $params, SessionInterface $session): EmptyResult { $uri = $params['uri'] ?? null; - if (! is_string($uri) || empty($uri)) { - throw McpServerException::invalidParams("Missing or invalid 'uri' parameter for resources/subscribe."); + if (!is_string($uri) || empty($uri)) { + throw McpServerException::invalidParams("Missing or invalid 'uri' parameter"); } - $subscriptions = $session->get('subscriptions', []); - $subscriptions[$uri] = true; - $session->set('subscriptions', $subscriptions); - + $this->subscriptionManager->subscribe($session->getId(), $uri); return new EmptyResult(); } + public function handleResourceUnsubscribe(array $params, SessionInterface $session): EmptyResult { $uri = $params['uri'] ?? null; - if (! is_string($uri) || empty($uri)) { - throw McpServerException::invalidParams("Missing or invalid 'uri' parameter for resources/unsubscribe."); + if (!is_string($uri) || empty($uri)) { + throw McpServerException::invalidParams("Missing or invalid 'uri' parameter"); } - $subscriptions = $session->get('subscriptions', []); - unset($subscriptions[$uri]); - $session->set('subscriptions', $subscriptions); - + $this->subscriptionManager->unsubscribe($session->getId(), $uri); return new EmptyResult(); } @@ -363,6 +361,45 @@ public function handleLoggingSetLevel(array $params, SessionInterface $session): return new EmptyResult(); } + public function handleCompletionComplete(array $params, SessionInterface $session): CompletionCompleteResult + { + $ref = $params['ref'] ?? null; + $argumentContext = $params['argument'] ?? null; + + if ( + !is_array($ref) + || !isset($ref['type']) + || !is_array($argumentContext) + || !isset($argumentContext['name']) + || !array_key_exists('value', $argumentContext) + ) { + throw McpServerException::invalidParams("Missing or invalid 'ref' or 'argument' parameters for completion/complete."); + } + + $type = $ref['type']; + $name = $argumentContext['name']; + $value = $argumentContext['value']; + + $completionValues = []; + $total = null; + $hasMore = null; + + // TODO: Implement actual completion logic here. + // This requires a way to: + // 1. Find the target prompt or resource template definition. + // 2. Determine if that definition has a completion provider for the given $argName. + // 3. Invoke that provider with $currentValue and $session (for context). + + // --- Placeholder/Example Logic --- + if ($name === 'userId') { + $completionValues = ['101', '102', '103']; + $total = 3; + } + // --- End Placeholder --- + + return new CompletionCompleteResult($completionValues, $total, $hasMore); + } + public function handleNotificationInitialized(array $params, SessionInterface $session): EmptyResult { $session->set('initialized', true); diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php index 7618c77..1be9c4f 100644 --- a/src/Transports/StreamableHttpServerTransport.php +++ b/src/Transports/StreamableHttpServerTransport.php @@ -60,11 +60,13 @@ class StreamableHttpServerTransport implements ServerTransportInterface, LoggerA /** * Stores active SSE streams. * Key: streamId - * Value: ['stream' => ThroughStream, 'sessionId' => string, 'type' => 'get' | 'post', 'context' => array] - * @var array + * Value: ['stream' => ThroughStream, 'sessionId' => string, 'context' => array] + * @var array */ private array $activeSseStreams = []; + private ?ThroughStream $getStream = null; + public function __construct( private readonly string $host = '127.0.0.1', private readonly int $port = 8080, @@ -190,19 +192,16 @@ private function handleGetRequest(ServerRequestInterface $request): PromiseInter return resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); } - $streamId = $this->idGenerator->generateId(); - $sseStream = new ThroughStream(); - - $this->activeSseStreams[$streamId] = ['stream' => $sseStream, 'sessionId' => $sessionId, 'type' => 'get']; + $this->getStream = new ThroughStream(); - $sseStream->on('close', function () use ($streamId, $sessionId) { - $this->logger->debug("GET SSE stream closed.", ['streamId' => $streamId, 'sessionId' => $sessionId]); - unset($this->activeSseStreams[$streamId]); + $this->getStream->on('close', function () use ($sessionId) { + $this->logger->debug("GET SSE stream closed.", ['sessionId' => $sessionId]); + $this->getStream = null; }); - $sseStream->on('error', function (Throwable $e) use ($streamId, $sessionId) { - $this->logger->error("GET SSE stream error.", ['streamId' => $streamId, 'sessionId' => $sessionId, 'error' => $e->getMessage()]); - unset($this->activeSseStreams[$streamId]); + $this->getStream->on('error', function (Throwable $e) use ($sessionId) { + $this->logger->error("GET SSE stream error.", ['sessionId' => $sessionId, 'error' => $e->getMessage()]); + $this->getStream = null; }); $headers = [ @@ -212,11 +211,11 @@ private function handleGetRequest(ServerRequestInterface $request): PromiseInter 'X-Accel-Buffering' => 'no', ]; - $response = new HttpResponse(200, $headers, $sseStream); + $response = new HttpResponse(200, $headers, $this->getStream); if ($this->eventStore) { $lastEventId = $request->getHeaderLine('Last-Event-ID'); - $this->replayEvents($lastEventId, $sseStream, $sessionId); + $this->replayEvents($lastEventId, $this->getStream, $sessionId); } return resolve($response); @@ -309,7 +308,6 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte $this->activeSseStreams[$streamId] = [ 'stream' => $sseStream, 'sessionId' => $sessionId, - 'type' => 'post', 'context' => ['nRequests' => $nRequests, 'nResponses' => 0] ]; @@ -488,34 +486,28 @@ public function sendMessage(JsonRpcMessage $message, string $sessionId, array $c $deferred->resolve(new HttpResponse(200, $headers, $responseBody)); return resolve(null); - case 'get_sse': - $streamId = $context['streamId']; - if (!isset($this->activeSseStreams[$streamId])) { - $this->logger->error("GET SSE stream not found.", ['streamId' => $streamId, 'sessionId' => $sessionId]); - return reject(new TransportException("GET SSE stream {$streamId} not found.")); + default: + if ($this->getStream === null) { + $this->logger->error("GET SSE stream not found.", ['sessionId' => $sessionId]); + return reject(new TransportException("GET SSE stream not found.")); } - $stream = $this->activeSseStreams[$streamId]['stream']; - if (!$stream->isWritable()) { - $this->logger->warning("GET SSE stream is not writable.", ['streamId' => $streamId, 'sessionId' => $sessionId]); - return reject(new TransportException("GET SSE stream {$streamId} not writable.")); + if (!$this->getStream->isWritable()) { + $this->logger->warning("GET SSE stream is not writable.", ['sessionId' => $sessionId]); + return reject(new TransportException("GET SSE stream not writable.")); } if ($message instanceof JsonRpcResponse || $message instanceof JsonRpcError) { $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $eventId = $this->eventStore ? $this->eventStore->storeEvent($streamId, $json) : null; - $this->sendSseEventToStream($stream, $json, $eventId); + $eventId = $this->eventStore ? $this->eventStore->storeEvent('GET_STREAM', $json) : null; + $this->sendSseEventToStream($this->getStream, $json, $eventId); } elseif ($message instanceof BatchResponse) { foreach ($message->all() as $singleResponse) { $json = json_encode($singleResponse, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $eventId = $this->eventStore ? $this->eventStore->storeEvent($streamId, $json) : null; - $this->sendSseEventToStream($stream, $json, $eventId); + $eventId = $this->eventStore ? $this->eventStore->storeEvent('GET_STREAM', $json) : null; + $this->sendSseEventToStream($this->getStream, $json, $eventId); } } return resolve(null); - - default: - $this->logger->error("Unknown sendMessage context type.", ['context' => $context, 'sessionId' => $sessionId]); - return reject(new TransportException("Unknown sendMessage context type: " . ($context['type'] ?? 'null'))); } } @@ -579,6 +571,11 @@ public function close(): void } } + if ($this->getStream !== null) { + $this->getStream->end(); + $this->getStream = null; + } + foreach ($this->pendingDirectPostResponses as $pendingRequestId => $deferred) { $deferred->reject(new TransportException('Transport is closing.')); } From dc05ca65d8bdb74783d4b9022c6e946cc5689c8c Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Tue, 10 Jun 2025 01:07:12 +0100 Subject: [PATCH 09/27] feat(core): Integrate php-mcp/schema package for core DTOs and messages Replaces internal MCP data structures (Requests, Responses, Results, Content types, Enums, and model definitions like Tool, Resource) with their counterparts from the new `php-mcp/schema` package, enhancing type safety and compliance with the MCP specification. --- composer.json | 9 +- .../02-discovery-http-userprofile/server.php | 4 +- src/Attributes/McpResource.php | 5 +- src/Attributes/McpResourceTemplate.php | 5 +- src/Attributes/McpTool.php | 5 +- src/Configuration.php | 16 +- src/Contracts/ServerTransportInterface.php | 2 +- src/Definitions/PromptArgumentDefinition.php | 77 --- src/Definitions/PromptDefinition.php | 140 ----- src/Definitions/ResourceDefinition.php | 159 ------ .../ResourceTemplateDefinition.php | 150 ----- src/Definitions/ToolDefinition.php | 130 ----- src/Exception/McpServerException.php | 23 +- src/Exception/ProtocolException.php | 2 +- src/Exception/TransportException.php | 2 +- src/JsonRpc/Contents/AudioContent.php | 88 --- src/JsonRpc/Contents/EmbeddedResource.php | 77 --- src/JsonRpc/Contents/ImageContent.php | 74 --- src/JsonRpc/Contents/PromptMessage.php | 76 --- src/JsonRpc/Contents/ResourceContent.php | 135 ----- src/JsonRpc/Contents/TextContent.php | 75 --- src/JsonRpc/Contracts/ContentInterface.php | 12 - src/JsonRpc/Contracts/MessageInterface.php | 14 - src/JsonRpc/Contracts/ResultInterface.php | 12 - src/JsonRpc/Messages/BatchRequest.php | 148 ----- src/JsonRpc/Messages/BatchResponse.php | 109 ---- src/JsonRpc/Messages/Error.php | 121 ---- src/JsonRpc/Messages/Message.php | 60 -- src/JsonRpc/Messages/Notification.php | 78 --- src/JsonRpc/Messages/Request.php | 88 --- src/JsonRpc/Messages/Response.php | 95 ---- src/JsonRpc/Results/CallToolResult.php | 36 -- .../Results/CompletionCompleteResult.php | 37 -- src/JsonRpc/Results/EmptyResult.php | 31 - src/JsonRpc/Results/GetPromptResult.php | 42 -- src/JsonRpc/Results/InitializeResult.php | 47 -- src/JsonRpc/Results/ListPromptsResult.php | 37 -- .../Results/ListResourceTemplatesResult.php | 40 -- src/JsonRpc/Results/ListResourcesResult.php | 40 -- src/JsonRpc/Results/ListToolsResult.php | 37 -- src/JsonRpc/Results/ReadResourceResult.php | 35 -- src/Model/Annotations.php | 54 -- src/Model/Capabilities.php | 113 ---- src/Model/Role.php | 14 - src/Model/ToolAnnotations.php | 67 --- src/Protocol.php | 115 ++-- src/Registry.php | 197 ++++--- src/Server.php | 4 +- src/ServerBuilder.php | 199 ++++--- src/State/ClientState.php | 63 --- src/State/ClientStateManager.php | 501 ---------------- src/Support/ArgumentPreparer.php | 201 ------- src/Support/AttributeFinder.php | 139 ----- src/Support/Discoverer.php | 106 ++-- src/Support/HandlerResolver.php | 11 +- src/Support/MethodInvoker.php | 194 +++++++ src/Support/RequestHandler.php | 378 ++++++------- src/Support/RequestProcessor.php | 535 ------------------ src/Traits/ResponseFormatter.php | 64 +-- src/Transports/HttpServerTransport.php | 52 +- src/Transports/StdioServerTransport.php | 34 +- .../StreamableHttpServerTransport.php | 82 ++- tests/Unit/JsonRpc/NotificationTest.php | 12 +- tests/Unit/JsonRpc/ResponseTest.php | 24 +- tests/Unit/JsonRpc/ResultTest.php | 2 +- .../Unit/JsonRpc/Results/EmptyResultTest.php | 2 +- tests/Unit/ProtocolTest.php | 16 +- tests/Unit/Support/RequestProcessorTest.php | 14 +- 68 files changed, 952 insertions(+), 4614 deletions(-) delete mode 100644 src/Definitions/PromptArgumentDefinition.php delete mode 100644 src/Definitions/PromptDefinition.php delete mode 100644 src/Definitions/ResourceDefinition.php delete mode 100644 src/Definitions/ResourceTemplateDefinition.php delete mode 100644 src/Definitions/ToolDefinition.php delete mode 100644 src/JsonRpc/Contents/AudioContent.php delete mode 100644 src/JsonRpc/Contents/EmbeddedResource.php delete mode 100644 src/JsonRpc/Contents/ImageContent.php delete mode 100644 src/JsonRpc/Contents/PromptMessage.php delete mode 100644 src/JsonRpc/Contents/ResourceContent.php delete mode 100644 src/JsonRpc/Contents/TextContent.php delete mode 100644 src/JsonRpc/Contracts/ContentInterface.php delete mode 100644 src/JsonRpc/Contracts/MessageInterface.php delete mode 100644 src/JsonRpc/Contracts/ResultInterface.php delete mode 100644 src/JsonRpc/Messages/BatchRequest.php delete mode 100644 src/JsonRpc/Messages/BatchResponse.php delete mode 100644 src/JsonRpc/Messages/Error.php delete mode 100644 src/JsonRpc/Messages/Message.php delete mode 100644 src/JsonRpc/Messages/Notification.php delete mode 100644 src/JsonRpc/Messages/Request.php delete mode 100644 src/JsonRpc/Messages/Response.php delete mode 100644 src/JsonRpc/Results/CallToolResult.php delete mode 100644 src/JsonRpc/Results/CompletionCompleteResult.php delete mode 100644 src/JsonRpc/Results/EmptyResult.php delete mode 100644 src/JsonRpc/Results/GetPromptResult.php delete mode 100644 src/JsonRpc/Results/InitializeResult.php delete mode 100644 src/JsonRpc/Results/ListPromptsResult.php delete mode 100644 src/JsonRpc/Results/ListResourceTemplatesResult.php delete mode 100644 src/JsonRpc/Results/ListResourcesResult.php delete mode 100644 src/JsonRpc/Results/ListToolsResult.php delete mode 100644 src/JsonRpc/Results/ReadResourceResult.php delete mode 100644 src/Model/Annotations.php delete mode 100644 src/Model/Capabilities.php delete mode 100644 src/Model/Role.php delete mode 100644 src/Model/ToolAnnotations.php delete mode 100644 src/State/ClientState.php delete mode 100644 src/State/ClientStateManager.php delete mode 100644 src/Support/ArgumentPreparer.php delete mode 100644 src/Support/AttributeFinder.php create mode 100644 src/Support/MethodInvoker.php delete mode 100644 src/Support/RequestProcessor.php diff --git a/composer.json b/composer.json index 3040a55..b2140c7 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "require": { "php": ">=8.1", "opis/json-schema": "^2.4", + "php-mcp/schema": "dev-main", "phpdocumentor/reflection-docblock": "^5.6", "psr/container": "^1.0 || ^2.0", "psr/event-dispatcher": "^1.0", @@ -57,5 +58,11 @@ } }, "minimum-stability": "dev", - "prefer-stable": true + "prefer-stable": true, + "repositories": [ + { + "type": "path", + "url": "../schema" + } + ] } diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php index 4b8bf33..1b18423 100644 --- a/examples/02-discovery-http-userprofile/server.php +++ b/examples/02-discovery-http-userprofile/server.php @@ -71,8 +71,8 @@ public function log($level, \Stringable|string $message, array $context = []): v $server->discover(__DIR__, ['.']); - // $transport = new HttpServerTransport('127.0.0.1', 8080, 'mcp'); - $transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'); + $transport = new HttpServerTransport('127.0.0.1', 8080, 'mcp'); + // $transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'); $server->listen($transport); diff --git a/src/Attributes/McpResource.php b/src/Attributes/McpResource.php index 8cb61e6..709f6fd 100644 --- a/src/Attributes/McpResource.php +++ b/src/Attributes/McpResource.php @@ -3,7 +3,7 @@ namespace PhpMcp\Server\Attributes; use Attribute; -use PhpMcp\Server\Model\Annotations; +use PhpMcp\Schema\Annotations; /** * Marks a PHP class as representing or handling a specific MCP Resource instance. @@ -27,6 +27,5 @@ public function __construct( public ?string $mimeType = null, public ?int $size = null, public ?Annotations $annotations = null, - ) { - } + ) {} } diff --git a/src/Attributes/McpResourceTemplate.php b/src/Attributes/McpResourceTemplate.php index 1364ec0..e007ce5 100644 --- a/src/Attributes/McpResourceTemplate.php +++ b/src/Attributes/McpResourceTemplate.php @@ -3,7 +3,7 @@ namespace PhpMcp\Server\Attributes; use Attribute; -use PhpMcp\Server\Model\Annotations; +use PhpMcp\Schema\Annotations; /** * Marks a PHP class definition as representing an MCP Resource Template. @@ -25,6 +25,5 @@ public function __construct( public ?string $description = null, public ?string $mimeType = null, public ?Annotations $annotations = null, - ) { - } + ) {} } diff --git a/src/Attributes/McpTool.php b/src/Attributes/McpTool.php index 38294db..4d82435 100644 --- a/src/Attributes/McpTool.php +++ b/src/Attributes/McpTool.php @@ -3,7 +3,7 @@ namespace PhpMcp\Server\Attributes; use Attribute; -use PhpMcp\Server\Model\ToolAnnotations; +use PhpMcp\Schema\ToolAnnotations; #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] class McpTool @@ -17,6 +17,5 @@ public function __construct( public ?string $name = null, public ?string $description = null, public ?ToolAnnotations $annotations = null, - ) { - } + ) {} } diff --git a/src/Configuration.php b/src/Configuration.php index 5e5bb69..1cab84e 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -4,7 +4,8 @@ namespace PhpMcp\Server; -use PhpMcp\Server\Model\Capabilities; +use PhpMcp\Schema\Implementation; +use PhpMcp\Schema\ServerCapabilities; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; @@ -18,9 +19,8 @@ class Configuration { /** - * @param string $serverName Name of this MCP server application. - * @param string $serverVersion Version of this MCP server application. - * @param Capabilities $capabilities Capabilities of this MCP server application. + * @param Implementation $serverInfo Info about this MCP server application. + * @param ServerCapabilities $capabilities Capabilities of this MCP server application. * @param LoggerInterface $logger PSR-3 Logger instance. * @param LoopInterface $loop ReactPHP Event Loop instance. * @param CacheInterface|null $cache Optional PSR-16 Cache instance for registry/state. @@ -29,15 +29,13 @@ class Configuration * @param int $paginationLimit Maximum number of items to return for list methods. */ public function __construct( - public readonly string $serverName, - public readonly string $serverVersion, - public readonly Capabilities $capabilities, + public readonly Implementation $serverInfo, + public readonly ServerCapabilities $capabilities, public readonly LoggerInterface $logger, public readonly LoopInterface $loop, public readonly ?CacheInterface $cache, public readonly ContainerInterface $container, public readonly int $definitionCacheTtl = 3600, public readonly int $paginationLimit = 50, - ) { - } + ) {} } diff --git a/src/Contracts/ServerTransportInterface.php b/src/Contracts/ServerTransportInterface.php index a5b0037..665d9ef 100644 --- a/src/Contracts/ServerTransportInterface.php +++ b/src/Contracts/ServerTransportInterface.php @@ -6,7 +6,7 @@ use Evenement\EventEmitterInterface; use PhpMcp\Server\Exception\TransportException; -use PhpMcp\Server\JsonRpc\Messages\Message; +use PhpMcp\Schema\JsonRpc\Message; use React\Promise\PromiseInterface; /** diff --git a/src/Definitions/PromptArgumentDefinition.php b/src/Definitions/PromptArgumentDefinition.php deleted file mode 100644 index a77ca2f..0000000 --- a/src/Definitions/PromptArgumentDefinition.php +++ /dev/null @@ -1,77 +0,0 @@ - $this->name, - ]; - - if ($this->description !== null) { - $data['description'] = $this->description; - } - - $data['required'] = $this->required; - - return $data; - } - - /** - * Reconstruct a PromptArgumentDefinition from its array representation. - * - * @param array $data The array representation of a PromptArgumentDefinition - * @return static The reconstructed PromptArgumentDefinition - */ - public static function fromArray(array $data): static - { - return new self( - name: $data['name'], - description: $data['description'] ?? null, - required: $data['required'] ?? false - ); - } - - /** - * Create a PromptArgumentDefinition from reflection data. - * - * @param \ReflectionParameter $parameter The reflection parameter. - * @param \phpDocumentor\Reflection\DocBlock\Tags\Param|null $paramTag The corresponding parsed @param tag, or null. - */ - public static function fromReflection(ReflectionParameter $parameter, ?Param $paramTag = null): self - { - $name = $parameter->getName(); - $description = $paramTag ? trim((string) $paramTag->getDescription()) : null; - - return new self( - name: $name, - description: $description, - required: ! $parameter->isOptional() && ! $parameter->isDefaultValueAvailable() - ); - } -} diff --git a/src/Definitions/PromptDefinition.php b/src/Definitions/PromptDefinition.php deleted file mode 100644 index 94b610c..0000000 --- a/src/Definitions/PromptDefinition.php +++ /dev/null @@ -1,140 +0,0 @@ -validate(); - } - - /** - * Validates the definition parameters - * - * @throws \InvalidArgumentException If the prompt name is invalid - */ - private function validate(): void - { - if (! preg_match(self::PROMPT_NAME_PATTERN, $this->promptName)) { - throw new \InvalidArgumentException( - "Prompt name '{$this->promptName}' is invalid. Prompt names must match the pattern " . self::PROMPT_NAME_PATTERN - . ' (alphanumeric characters, underscores, and hyphens only).' - ); - } - } - - public function isTemplate(): bool - { - return ! empty($this->arguments); - } - - /** - * Formats the definition into the structure expected by MCP's 'prompts/list'. - * - * @return array{name: string, description?: string, arguments?: list} - */ - public function toArray(): array - { - $data = [ - 'name' => $this->promptName, - ]; - if ($this->description !== null) { - $data['description'] = $this->description; - } - if (! empty($this->arguments)) { - $data['arguments'] = array_map( - fn (PromptArgumentDefinition $arg) => $arg->toArray(), - $this->arguments - ); - } - - return $data; - } - - /** - * Reconstruct a PromptDefinition from its array representation. - * - * @param array $data The array representation of a PromptDefinition - * @return static The reconstructed PromptDefinition - */ - public static function fromArray(array $data): static - { - $arguments = []; - if (isset($data['arguments']) && is_array($data['arguments'])) { - foreach ($data['arguments'] as $argData) { - $arguments[] = PromptArgumentDefinition::fromArray($argData); - } - } - - return new self( - className: $data['className'], - methodName: $data['methodName'], - promptName: $data['promptName'], - description: $data['description'], - arguments: $arguments - ); - } - - /** - * Create a PromptDefinition from reflection data. - */ - public static function fromReflection( - \ReflectionMethod $method, - ?string $overrideName, - ?string $overrideDescription, - DocBlockParser $docBlockParser - ): self { - $className = $method->getDeclaringClass()->getName(); - $methodName = $method->getName(); - $promptName = $overrideName ?? ($methodName === '__invoke' ? $method->getDeclaringClass()->getShortName() : $methodName); - $docBlock = $docBlockParser->parseDocBlock($method->getDocComment() ?: null); - $description = $overrideDescription ?? $docBlockParser->getSummary($docBlock) ?? null; - - $arguments = []; - $paramTags = $docBlockParser->getParamTags($docBlock); - foreach ($method->getParameters() as $param) { - $reflectionType = $param->getType(); - - if ($reflectionType instanceof \ReflectionNamedType && ! $reflectionType->isBuiltin()) { - continue; - } - - $paramTag = $paramTags['$' . $param->getName()] ?? null; - $arguments[] = PromptArgumentDefinition::fromReflection($param, $paramTag); - } - - return new self( - className: $className, - methodName: $methodName, - promptName: $promptName, - description: $description, - arguments: $arguments - ); - } -} diff --git a/src/Definitions/ResourceDefinition.php b/src/Definitions/ResourceDefinition.php deleted file mode 100644 index 03ba3c8..0000000 --- a/src/Definitions/ResourceDefinition.php +++ /dev/null @@ -1,159 +0,0 @@ -validate(); - } - - /** - * Validates the definition parameters - * - * @throws \InvalidArgumentException If the URI is invalid - */ - private function validate(): void - { - if (! preg_match(self::URI_PATTERN, $this->uri)) { - throw new \InvalidArgumentException( - "Resource URI '{$this->uri}' is invalid. URIs must match the pattern " . self::URI_PATTERN - . ' (valid scheme followed by :// and optional path).' - ); - } - - if (! preg_match(self::RESOURCE_NAME_PATTERN, $this->name)) { - throw new \InvalidArgumentException( - "Resource name '{$this->name}' is invalid. Resource names must match the pattern " . self::RESOURCE_NAME_PATTERN - . ' (alphanumeric characters, underscores, and hyphens only).' - ); - } - } - - /** - * Formats the definition into the structure expected by MCP's 'resources/list'. - * - * @return array{uri: string, name: string, description?: string, mimeType?: string, size?: int, annotations?: array} - */ - public function toArray(): array - { - $data = [ - 'uri' => $this->uri, - 'name' => $this->name, - ]; - if ($this->description !== null) { - $data['description'] = $this->description; - } - if ($this->mimeType !== null) { - $data['mimeType'] = $this->mimeType; - } - if ($this->annotations !== null) { - $data['annotations'] = $this->annotations->toArray(); - } - if ($this->size !== null) { - $data['size'] = $this->size; - } - - return $data; - } - - /** - * Reconstruct a ResourceDefinition from its array representation. - * - * @param array $data The array representation of a ResourceDefinition - * @return static The reconstructed ResourceDefinition - */ - public static function fromArray(array $data): static - { - $annotations = isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null; - - return new self( - className: $data['className'], - methodName: $data['methodName'], - uri: $data['uri'], - name: $data['name'], - description: $data['description'], - mimeType: $data['mimeType'], - annotations: $annotations, - size: $data['size'], - ); - } - - /** - * Create a ResourceDefinition from reflection data. - * - * @param ReflectionMethod $method The reflection method marked with McpResource. - * @param McpResource $attribute The attribute instance. - * @param DocBlockParser $docBlockParser Utility to parse docblocks. - */ - public static function fromReflection( - ReflectionMethod $method, - ?string $overrideName, - ?string $overrideDescription, - string $uri, - ?string $mimeType, - ?Annotations $annotations, - ?int $size, - DocBlockParser $docBlockParser - ): self { - $docBlock = $docBlockParser->parseDocBlock($method->getDocComment() ?: null); - $description = $overrideDescription ?? $docBlockParser->getSummary($docBlock) ?? null; - - $name = $overrideName ?? ($method->getName() === '__invoke' - ? $method->getDeclaringClass()->getShortName() - : $method->getName()); - - return new self( - className: $method->getDeclaringClass()->getName(), - methodName: $method->getName(), - uri: $uri, - name: $name, - description: $description, - mimeType: $mimeType, - annotations: $annotations, - size: $size, - ); - } -} diff --git a/src/Definitions/ResourceTemplateDefinition.php b/src/Definitions/ResourceTemplateDefinition.php deleted file mode 100644 index 545e319..0000000 --- a/src/Definitions/ResourceTemplateDefinition.php +++ /dev/null @@ -1,150 +0,0 @@ -validate(); - } - - /** - * Validates the definition parameters - * - * @throws \InvalidArgumentException If the URI template is invalid - */ - private function validate(): void - { - if (! preg_match(self::URI_TEMPLATE_PATTERN, $this->uriTemplate)) { - throw new \InvalidArgumentException( - "Resource URI template '{$this->uriTemplate}' is invalid. URI templates must match the pattern " - . self::URI_TEMPLATE_PATTERN . ' (valid scheme followed by :// and path with placeholder(s) in curly braces).' - ); - } - - if (! preg_match(self::RESOURCE_NAME_PATTERN, $this->name)) { - throw new \InvalidArgumentException( - "Resource name '{$this->name}' is invalid. Resource names must match the pattern " . self::RESOURCE_NAME_PATTERN - . ' (alphanumeric characters, underscores, and hyphens only).' - ); - } - } - - /** - * Formats the definition into the structure expected by MCP's 'resources/templates/list'. - * - * @return array{uriTemplate: string, name: string, description?: string, mimeType?: string, annotations?: array} - */ - public function toArray(): array - { - $data = [ - 'uriTemplate' => $this->uriTemplate, - 'name' => $this->name, - ]; - if ($this->description !== null) { - $data['description'] = $this->description; - } - if ($this->mimeType !== null) { - $data['mimeType'] = $this->mimeType; - } - if ($this->annotations !== null) { - $data['annotations'] = $this->annotations->toArray(); - } - return $data; - } - - /** - * Reconstruct a ResourceTemplateDefinition from its array representation. - * - * @param array $data The array representation of a ResourceTemplateDefinition - * @return static The reconstructed ResourceTemplateDefinition - */ - public static function fromArray(array $data): static - { - $annotations = isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null; - return new self( - className: $data['className'], - methodName: $data['methodName'], - uriTemplate: $data['uriTemplate'], - name: $data['name'], - description: $data['description'] ?? null, - mimeType: $data['mimeType'] ?? null, - annotations: $annotations, - ); - } - - /** - * Create a ResourceTemplateDefinition from reflection data. - * - * @param ReflectionMethod $method The reflection method marked with McpResourceTemplate. - * @param string|null $overrideName The name for the resource. - * @param string|null $overrideDescription The description for the resource. - * @param string $uriTemplate The URI template for the resource. - * @param string|null $mimeType The MIME type for the resource. - * @param DocBlockParser $docBlockParser Utility to parse docblocks. - */ - public static function fromReflection( - ReflectionMethod $method, - ?string $overrideName, - ?string $overrideDescription, - string $uriTemplate, - ?string $mimeType, - ?Annotations $annotations, - DocBlockParser $docBlockParser - ): self { - $docBlock = $docBlockParser->parseDocBlock($method->getDocComment() ?: null); - $description = $overrideDescription ?? $docBlockParser->getSummary($docBlock) ?? null; - - $name = $overrideName ?? ($method->getName() === '__invoke' - ? $method->getDeclaringClass()->getShortName() - : $method->getName()); - - return new self( - className: $method->getDeclaringClass()->getName(), - methodName: $method->getName(), - uriTemplate: $uriTemplate, - name: $name, - description: $description, - mimeType: $mimeType, - annotations: $annotations, - ); - } -} diff --git a/src/Definitions/ToolDefinition.php b/src/Definitions/ToolDefinition.php deleted file mode 100644 index 41ee213..0000000 --- a/src/Definitions/ToolDefinition.php +++ /dev/null @@ -1,130 +0,0 @@ - $inputSchema A JSON Schema object (as a PHP array) defining the expected 'arguments' for the tool. Complies with MCP 'Tool.inputSchema'. - * - * @throws \InvalidArgumentException If the tool name doesn't match the required pattern. - */ - public function __construct( - public readonly string $className, - public readonly string $methodName, - public readonly string $toolName, - public readonly ?string $description, - public readonly array $inputSchema, - public readonly ?ToolAnnotations $annotations - ) { - $this->validate(); - } - - /** - * Validates the definition parameters - * - * @throws \InvalidArgumentException If the tool name is invalid - */ - private function validate(): void - { - if (! preg_match(self::TOOL_NAME_PATTERN, $this->toolName)) { - throw new \InvalidArgumentException( - "Tool name '{$this->toolName}' is invalid. Tool names must match the pattern " . self::TOOL_NAME_PATTERN - . ' (alphanumeric characters, underscores, and hyphens only).' - ); - } - } - - /** - * Convert the tool definition to MCP format. - */ - public function toArray(): array - { - $result = [ - 'name' => $this->toolName, - ]; - - if ($this->description) { - $result['description'] = $this->description; - } - - if ($this->inputSchema) { - $result['inputSchema'] = $this->inputSchema; - } - - if ($this->annotations !== null) { - $result['annotations'] = $this->annotations->toArray(); - } - - return $result; - } - - /** - * Reconstruct a ToolDefinition from its array representation. - * - * @param array $data The array representation of a ToolDefinition - * @return static The reconstructed ToolDefinition - */ - public static function fromArray(array $data): static - { - return new self( - className: $data['className'], - methodName: $data['methodName'], - toolName: $data['toolName'], - description: $data['description'] ?? null, - inputSchema: $data['inputSchema'] ?? [], - annotations: $data['annotations'] ?? null, - ); - } - - /** - * Create a ToolDefinition from reflection data. - * - * @param ReflectionMethod $method The reflection method for the tool. - * @param McpTool $attribute The attribute instance. - * @param DocBlockParser $docBlockParser Utility to parse docblocks. - * @param SchemaGenerator $schemaGenerator Utility to generate JSON schema. - */ - public static function fromReflection( - ReflectionMethod $method, - ?string $overrideName, - ?string $overrideDescription, - ?ToolAnnotations $annotations, - DocBlockParser $docBlockParser, - SchemaGenerator $schemaGenerator - ): self { - $docBlock = $docBlockParser->parseDocBlock($method->getDocComment() ?? null); - $description = $overrideDescription ?? $docBlockParser->getSummary($docBlock) ?? null; - $inputSchema = $schemaGenerator->fromMethodParameters($method); - $toolName = $overrideName ?? ($method->getName() === '__invoke' - ? $method->getDeclaringClass()->getShortName() - : $method->getName()); - - return new self( - className: $method->getDeclaringClass()->getName(), - methodName: $method->getName(), - toolName: $toolName, - description: $description, - inputSchema: $inputSchema, - annotations: $annotations, - ); - } -} diff --git a/src/Exception/McpServerException.php b/src/Exception/McpServerException.php index 2aeadb4..da9368a 100644 --- a/src/Exception/McpServerException.php +++ b/src/Exception/McpServerException.php @@ -5,7 +5,8 @@ namespace PhpMcp\Server\Exception; use Exception; -use PhpMcp\Server\JsonRpc\Messages\Error as JsonRpcError; +use PhpMcp\Schema\Constants; +use PhpMcp\Schema\JsonRpc\Error as JsonRpcError; use Throwable; /** @@ -57,7 +58,7 @@ public function getData(): mixed */ public function toJsonRpcError(string|int $id): JsonRpcError { - $code = ($this->code >= -32768 && $this->code <= -32000) ? $this->code : JsonRpcError::CODE_INTERNAL_ERROR; + $code = ($this->code >= -32768 && $this->code <= -32000) ? $this->code : Constants::INTERNAL_ERROR; return new JsonRpcError( jsonrpc: '2.0', @@ -68,27 +69,25 @@ public function toJsonRpcError(string|int $id): JsonRpcError ); } - // --- Static Factory Methods for Common JSON-RPC Errors --- - public static function parseError(string $details, ?Throwable $previous = null): self { - return new ProtocolException('Parse error: ' . $details, JsonRpcError::CODE_PARSE_ERROR, null, $previous); + return new ProtocolException('Parse error: ' . $details, Constants::PARSE_ERROR, null, $previous); } public static function invalidRequest(?string $details = 'Invalid Request', ?Throwable $previous = null): self { - return new ProtocolException($details, JsonRpcError::CODE_INVALID_REQUEST, null, $previous); + return new ProtocolException($details, Constants::INVALID_REQUEST, null, $previous); } public static function methodNotFound(string $methodName, ?string $message = null, ?Throwable $previous = null): self { - return new ProtocolException($message ?? "Method not found: {$methodName}", JsonRpcError::CODE_METHOD_NOT_FOUND, null, $previous); + return new ProtocolException($message ?? "Method not found: {$methodName}", Constants::METHOD_NOT_FOUND, null, $previous); } public static function invalidParams(string $message = 'Invalid params', $data = null, ?Throwable $previous = null): self { // Pass data (e.g., validation errors) through - return new ProtocolException($message, JsonRpcError::CODE_INVALID_PARAMS, $data, $previous); + return new ProtocolException($message, Constants::INVALID_PARAMS, $data, $previous); } public static function internalError(?string $details = 'Internal server error', ?Throwable $previous = null): self @@ -100,7 +99,7 @@ public static function internalError(?string $details = 'Internal server error', $message .= ' (See server logs)'; } - return new McpServerException($message, JsonRpcError::CODE_INTERNAL_ERROR, null, $previous); + return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); } public static function toolExecutionFailed(string $toolName, ?Throwable $previous = null): self @@ -110,7 +109,7 @@ public static function toolExecutionFailed(string $toolName, ?Throwable $previou $message .= ': ' . $previous->getMessage(); } - return new McpServerException($message, JsonRpcError::CODE_INTERNAL_ERROR, null, $previous); + return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); } public static function resourceReadFailed(string $uri, ?Throwable $previous = null): self @@ -120,7 +119,7 @@ public static function resourceReadFailed(string $uri, ?Throwable $previous = nu $message .= ': ' . $previous->getMessage(); } - return new McpServerException($message, JsonRpcError::CODE_INTERNAL_ERROR, null, $previous); + return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); } public static function promptGenerationFailed(string $promptName, ?Throwable $previous = null): self @@ -130,6 +129,6 @@ public static function promptGenerationFailed(string $promptName, ?Throwable $pr $message .= ': ' . $previous->getMessage(); } - return new McpServerException($message, JsonRpcError::CODE_INTERNAL_ERROR, null, $previous); + return new McpServerException($message, Constants::INTERNAL_ERROR, null, $previous); } } diff --git a/src/Exception/ProtocolException.php b/src/Exception/ProtocolException.php index baf6e72..32cf04c 100644 --- a/src/Exception/ProtocolException.php +++ b/src/Exception/ProtocolException.php @@ -4,7 +4,7 @@ namespace PhpMcp\Server\Exception; -use PhpMcp\Server\JsonRpc\Messages\Error as JsonRpcError; +use PhpMcp\Schema\JsonRpc\Error as JsonRpcError; /** * Exception related to violations of the JSON-RPC 2.0 or MCP structure diff --git a/src/Exception/TransportException.php b/src/Exception/TransportException.php index eaeecc2..293cda3 100644 --- a/src/Exception/TransportException.php +++ b/src/Exception/TransportException.php @@ -4,7 +4,7 @@ namespace PhpMcp\Server\Exception; -use PhpMcp\Server\JsonRpc\Messages\Error as JsonRpcError; +use PhpMcp\Schema\JsonRpc\Error as JsonRpcError; /** * Exception related to errors in the underlying transport layer diff --git a/src/JsonRpc/Contents/AudioContent.php b/src/JsonRpc/Contents/AudioContent.php deleted file mode 100644 index 6a4b271..0000000 --- a/src/JsonRpc/Contents/AudioContent.php +++ /dev/null @@ -1,88 +0,0 @@ - 'audio', - 'data' => $this->data, - 'mimeType' => $this->mimeType, - ]; - - if ($this->annotations !== null) { - $result['annotations'] = $this->annotations->toArray(); - } - - return $result; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } - - /** - * Create a new AudioContent from a file path. - * - * @param string $path Path to the audio file - * @param string|null $mimeType Optional MIME type override - * @param ?Annotations $annotations Optional annotations describing the content - * - * @throws \InvalidArgumentException If the file doesn't exist - */ - public static function fromFile(string $path, ?string $mimeType = null, ?Annotations $annotations = null): static - { - if (! file_exists($path)) { - throw new \InvalidArgumentException("Audio file not found: {$path}"); - } - - $content = file_get_contents($path); - if ($content === false) { - throw new \RuntimeException("Could not read audio file: {$path}"); - } - $data = base64_encode($content); - $detectedMime = $mimeType ?? mime_content_type($path) ?: 'application/octet-stream'; - - return new static($data, $detectedMime, $annotations); - } - - /** - * Create a new AudioContent from raw binary data. - * - * @param string $binaryData Raw binary audio data - * @param string $mimeType MIME type of the audio - * @param ?Annotations $annotations Optional annotations describing the content - */ - public static function fromBinary(string $binaryData, string $mimeType, ?Annotations $annotations = null): static - { - return new static(base64_encode($binaryData), $mimeType, $annotations); - } -} diff --git a/src/JsonRpc/Contents/EmbeddedResource.php b/src/JsonRpc/Contents/EmbeddedResource.php deleted file mode 100644 index 55fc997..0000000 --- a/src/JsonRpc/Contents/EmbeddedResource.php +++ /dev/null @@ -1,77 +0,0 @@ -resource->toArray(); - - return [ - 'type' => 'resource', - 'resource' => $resource, - ]; - } - - /** - * Create a new EmbeddedResource from a file path. - * - * @param string $uri The URI for the resource - * @param string $path Path to the file - * @param string|null $mimeType Optional MIME type override - * - * @throws \InvalidArgumentException If the file doesn't exist - */ - public static function fromFile(string $uri, string $path, ?string $mimeType = null): static - { - return new static(ResourceContent::fromFile($uri, $path, $mimeType)); - } - - /** - * Create from a stream resource. - * - * @param string $uri The URI for the resource - * @param resource $stream The stream resource - * @param string $mimeType MIME type of the content - * - * @throws \InvalidArgumentException If the parameter is not a stream resource - */ - public static function fromStream(string $uri, $stream, string $mimeType): static - { - return new static(ResourceContent::fromStream($uri, $stream, $mimeType)); - } - - /** - * Create from an SplFileInfo object. - * - * @param string $uri The URI for the resource - * @param \SplFileInfo $file The file object - * @param string|null $mimeType Optional MIME type override - * - * @throws \InvalidArgumentException If the file is not readable - */ - public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string $mimeType = null): static - { - return new static(ResourceContent::fromSplFileInfo($uri, $file, $mimeType)); - } -} diff --git a/src/JsonRpc/Contents/ImageContent.php b/src/JsonRpc/Contents/ImageContent.php deleted file mode 100644 index 624221e..0000000 --- a/src/JsonRpc/Contents/ImageContent.php +++ /dev/null @@ -1,74 +0,0 @@ - 'image', - 'data' => $this->data, - 'mimeType' => $this->mimeType, - ]; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } - - /** - * Create a new ImageContent from a file path. - * - * @param string $path Path to the image file - * @param string|null $mimeType Optional MIME type override - * - * @throws \InvalidArgumentException If the file doesn't exist - */ - public static function fromFile(string $path, ?string $mimeType = null): static - { - if (! file_exists($path)) { - throw new \InvalidArgumentException("Image file not found: {$path}"); - } - - $data = base64_encode(file_get_contents($path)); - $detectedMime = $mimeType ?? mime_content_type($path) ?? 'image/png'; - - return new static($data, $detectedMime); - } - - /** - * Create a new ImageContent from raw binary data. - * - * @param string $binaryData Raw binary image data - * @param string $mimeType MIME type of the image - */ - public static function fromBinary(string $binaryData, string $mimeType): static - { - return new static(base64_encode($binaryData), $mimeType); - } -} diff --git a/src/JsonRpc/Contents/PromptMessage.php b/src/JsonRpc/Contents/PromptMessage.php deleted file mode 100644 index 62ba405..0000000 --- a/src/JsonRpc/Contents/PromptMessage.php +++ /dev/null @@ -1,76 +0,0 @@ - $this->role->value, - 'content' => $this->content->toArray(), - ]; - } - - /** - * Create a new user message with text content. - * - * @param string $text The message text - */ - public static function user(string $text): static - { - return new static(Role::User, new TextContent($text)); - } - - /** - * Create a new assistant message with text content. - * - * @param string $text The message text - */ - public static function assistant(string $text): static - { - return new static(Role::Assistant, new TextContent($text)); - } - - /** - * Create a new user message with any content type. - * - * @param Content $content The message content - */ - public static function userWithContent(TextContent|ImageContent|AudioContent|EmbeddedResource $content): static - { - return new static(Role::User, $content); - } - - /** - * Create a new assistant message with any content type. - * - * @param Content $content The message content - */ - public static function assistantWithContent(TextContent|ImageContent|AudioContent|EmbeddedResource $content): static - { - return new static(Role::Assistant, $content); - } -} diff --git a/src/JsonRpc/Contents/ResourceContent.php b/src/JsonRpc/Contents/ResourceContent.php deleted file mode 100644 index ea67457..0000000 --- a/src/JsonRpc/Contents/ResourceContent.php +++ /dev/null @@ -1,135 +0,0 @@ - $this->uri, - 'mimeType' => $this->mimeType, - ]; - - if ($this->text !== null) { - $resource['text'] = $this->text; - } elseif ($this->blob !== null) { - $resource['blob'] = $this->blob; - } - - return $resource; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } - - /** - * Determines if the given MIME type is likely to be text-based. - * - * @param string $mimeType The MIME type to check - */ - private static function isTextMimeType(string $mimeType): bool - { - return str_starts_with($mimeType, 'text/') || - in_array($mimeType, ['application/json', 'application/xml', 'application/javascript', 'application/yaml']); - } - - /** - * Create a new EmbeddedResource from a file path. - * - * @param string $uri The URI for the resource - * @param string $path Path to the file - * @param string|null $mimeType Optional MIME type override - * - * @throws \InvalidArgumentException If the file doesn't exist - */ - public static function fromFile(string $uri, string $path, ?string $mimeType = null): static - { - if (! file_exists($path)) { - throw new \InvalidArgumentException("File not found: {$path}"); - } - - $detectedMime = $mimeType ?? mime_content_type($path) ?? 'application/octet-stream'; - $content = file_get_contents($path); - - if (self::isTextMimeType($detectedMime)) { - return new static($uri, $detectedMime, $content); - } else { - return new static($uri, $detectedMime, null, base64_encode($content)); - } - } - - /** - * Create from a stream resource. - * - * @param string $uri The URI for the resource - * @param resource $stream The stream resource - * @param string $mimeType MIME type of the content - * - * @throws \InvalidArgumentException If the parameter is not a stream resource - */ - public static function fromStream(string $uri, $stream, string $mimeType): static - { - if (! is_resource($stream) || get_resource_type($stream) !== 'stream') { - throw new \InvalidArgumentException('Expected a stream resource'); - } - - $content = stream_get_contents($stream); - - if (self::isTextMimeType($mimeType)) { - return new static($uri, $mimeType, $content); - } else { - return new static($uri, $mimeType, null, base64_encode($content)); - } - } - - /** - * Create from an SplFileInfo object. - * - * @param string $uri The URI for the resource - * @param \SplFileInfo $file The file object - * @param string|null $mimeType Optional MIME type override - * - * @throws \InvalidArgumentException If the file is not readable - */ - public static function fromSplFileInfo(string $uri, \SplFileInfo $file, ?string $mimeType = null): static - { - if (! $file->isReadable()) { - throw new \InvalidArgumentException("File is not readable: {$file->getPathname()}"); - } - - return self::fromFile($uri, $file->getPathname(), $mimeType); - } -} diff --git a/src/JsonRpc/Contents/TextContent.php b/src/JsonRpc/Contents/TextContent.php deleted file mode 100644 index 0bf90e2..0000000 --- a/src/JsonRpc/Contents/TextContent.php +++ /dev/null @@ -1,75 +0,0 @@ - 'text', - 'text' => $this->text, - ]; - - if ($this->annotations !== null) { - $result['annotations'] = $this->annotations->toArray(); - } - - return $result; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } - - /** - * Create a new TextContent from any simple value. - * - * @param mixed $value The value to convert to text - */ - public static function make(mixed $value, ?Annotations $annotations = null): static - { - if (is_array($value) || is_object($value)) { - $text = json_encode($value, JSON_PRETTY_PRINT); - - return new static($text, $annotations); - } - - return new static((string) $value, $annotations); - } - - /** - * Create a new TextContent with markdown formatted code. - * - * @param string $code The code to format - * @param string $language The language for syntax highlighting - */ - public static function code(string $code, string $language = '', ?Annotations $annotations = null): static - { - return new static("```{$language}\n{$code}\n```", $annotations); - } -} diff --git a/src/JsonRpc/Contracts/ContentInterface.php b/src/JsonRpc/Contracts/ContentInterface.php deleted file mode 100644 index a2187d3..0000000 --- a/src/JsonRpc/Contracts/ContentInterface.php +++ /dev/null @@ -1,12 +0,0 @@ - - */ - private array $requests = []; - - /** - * Create a new JSON-RPC 2.0 batch of requests/notifications. - * - * @param array $requests Optional array of requests to initialize with - */ - public function __construct(array $requests = []) - { - foreach ($requests as $request) { - $this->add($request); - } - } - - public function getId(): string|int|null - { - return null; - } - - /** - * Create a Batch object from an array representation. - * - * @param array $data Raw decoded JSON-RPC batch data - * - * @throws McpError If the data doesn't conform to JSON-RPC 2.0 batch structure - */ - public static function fromArray(array $data): self - { - if (empty($data)) { - throw ProtocolException::invalidRequest('A batch must contain at least one request.'); - } - - $batch = new self(); - - foreach ($data as $item) { - if (! is_array($item)) { - throw ProtocolException::invalidRequest('Each item in a batch must be a valid JSON-RPC object.'); - } - - if (! isset($item['id'])) { - $batch->add(Notification::fromArray($item)); - } else { - $batch->add(Request::fromArray($item)); - } - } - - return $batch; - } - - /** - * Add a request or notification to the batch. - * - * @param Request|Notification $request The request to add - */ - public function add(Request|Notification $request): self - { - $this->requests[] = $request; - - return $this; - } - - /** - * Get all requests in this batch. - * - * @return array - */ - public function all(): array - { - return $this->requests; - } - - /** - * Get only the requests with IDs (excludes notifications). - * - * @return array - */ - public function getRequests(): array - { - return array_filter($this->requests, fn ($r) => ! $r instanceof Notification); - } - - /** - * Get only the notifications (requests without IDs). - * - * @return array - */ - public function getNotifications(): array - { - return array_filter($this->requests, fn ($r) => $r instanceof Notification); - } - - public function hasRequests(): bool - { - $hasRequests = false; - foreach ($this->requests as $request) { - if ($request instanceof Request) { - $hasRequests = true; - break; - } - } - - return $hasRequests; - } - - public function hasNotifications(): bool - { - $hasNotifications = false; - foreach ($this->requests as $request) { - if ($request instanceof Notification) { - $hasNotifications = true; - break; - } - } - return $hasNotifications; - } - - /** - * Count the total number of requests in this batch. - */ - public function count(): int - { - return count($this->requests); - } - - /** - * Convert the batch to an array. - */ - public function toArray(): array - { - return array_map(fn ($r) => $r->toArray(), $this->requests); - } -} diff --git a/src/JsonRpc/Messages/BatchResponse.php b/src/JsonRpc/Messages/BatchResponse.php deleted file mode 100644 index fe78807..0000000 --- a/src/JsonRpc/Messages/BatchResponse.php +++ /dev/null @@ -1,109 +0,0 @@ - - */ - private array $responses = []; - - /** - * Create a new JSON-RPC 2.0 batch of requests/notifications. - * - * @param array $responses Optional array of responses to initialize with - */ - public function __construct(array $responses = []) - { - foreach ($responses as $response) { - $this->add($response); - } - } - - public function getId(): string|int|null - { - return null; - } - - public static function fromArray(array $data): self - { - $batch = new self(); - - foreach ($data as $response) { - $batch->add(Message::parseResponse($response)); - } - - return $batch; - } - - /** - * Add a response to the batch. - * - * @param Response|Error $response The response to add - */ - public function add(Response|Error $response): self - { - $this->responses[] = $response; - - return $this; - } - - /** - * Get all requests in this batch. - * - * @return array - */ - public function all(): array - { - return $this->responses; - } - - /** - * Get only the requests with IDs (excludes notifications). - * - * @return array - */ - public function getResponses(): array - { - return array_filter($this->responses, fn ($r) => $r instanceof Response); - } - - /** - * Get only the notifications (requests without IDs). - * - * @return array - */ - public function getErrors(): array - { - return array_filter($this->responses, fn ($r) => $r instanceof Error); - } - - public function isEmpty(): bool - { - return empty($this->responses); - } - - /** - * Count the total number of requests in this batch. - */ - public function count(): int - { - return count($this->responses); - } - - /** - * Convert the batch to an array. - */ - public function toArray(): array - { - return array_map(fn ($r) => $r->toArray(), $this->responses); - } -} diff --git a/src/JsonRpc/Messages/Error.php b/src/JsonRpc/Messages/Error.php deleted file mode 100644 index 9f7f1cf..0000000 --- a/src/JsonRpc/Messages/Error.php +++ /dev/null @@ -1,121 +0,0 @@ -id; - } - - public static function parseError(string $message): self - { - return new self( - jsonrpc: '2.0', - id: '', - code: self::CODE_PARSE_ERROR, - message: $message, - data: null, - ); - } - - public static function invalidRequest(string $message, string $id = ''): self - { - return new self( - jsonrpc: '2.0', - id: $id, - code: self::CODE_INVALID_REQUEST, - message: $message, - data: null, - ); - } - - public static function connectionAborted(string $message): self - { - return new self( - jsonrpc: '2.0', - id: '', - code: self::CODE_CONNECTION_CLOSED, - message: $message, - data: null, - ); - } - - /** - * Create an Error object from an array representation. - * - * @param array $data Raw decoded JSON-RPC error data - */ - public static function fromArray(array $data): self - { - if (! isset($data['error']) || ! is_array($data['error'])) { - throw ProtocolException::invalidRequest('Invalid or missing "error" field.'); - } - - return new self( - jsonrpc: '2.0', - id: $data['id'] ?? null, - code: $data['error']['code'], - message: $data['error']['message'], - data: $data['error']['data'] ?? null - ); - } - - /** - * Convert the error to an array. - */ - public function toArray(): array - { - $error = [ - 'code' => $this->code, - 'message' => $this->message, - ]; - - if ($this->data !== null) { - $error['data'] = $this->data; - } - - return [ - 'jsonrpc' => $this->jsonrpc, - 'id' => $this->id, - 'error' => $error, - ]; - } -} diff --git a/src/JsonRpc/Messages/Message.php b/src/JsonRpc/Messages/Message.php deleted file mode 100644 index a744d04..0000000 --- a/src/JsonRpc/Messages/Message.php +++ /dev/null @@ -1,60 +0,0 @@ -toArray(); - } - - public static function parseRequest(string $message): Request|Notification|BatchRequest - { - $messageData = json_decode($message, true, 512, JSON_THROW_ON_ERROR); - - $isBatch = array_is_list($messageData) && count($messageData) > 0 && is_array($messageData[0] ?? null); - - if ($isBatch) { - return BatchRequest::fromArray($messageData); - } elseif (isset($messageData['method'])) { - if (isset($messageData['id']) && $messageData['id'] !== null) { - return Request::fromArray($messageData); - } else { - return Notification::fromArray($messageData); - } - } - - throw new McpServerException('Invalid JSON-RPC message'); - } - - public static function parseResponse(string $message): Response|Error|BatchResponse - { - $messageData = json_decode($message, true, 512, JSON_THROW_ON_ERROR); - - $isBatch = array_is_list($messageData) && count($messageData) > 0 && is_array($messageData[0] ?? null); - - if ($isBatch) { - return BatchResponse::fromArray($messageData); - } elseif (isset($messageData['id']) && $messageData['id'] !== null) { - return Response::fromArray($messageData); - } else { - return Error::fromArray($messageData); - } - } -} diff --git a/src/JsonRpc/Messages/Notification.php b/src/JsonRpc/Messages/Notification.php deleted file mode 100644 index f359730..0000000 --- a/src/JsonRpc/Messages/Notification.php +++ /dev/null @@ -1,78 +0,0 @@ - $this->jsonrpc, - 'method' => $this->method, - ]; - - if (! empty($this->params)) { - $result['params'] = $this->params; - } - - return $result; - } -} diff --git a/src/JsonRpc/Messages/Request.php b/src/JsonRpc/Messages/Request.php deleted file mode 100644 index 7654cde..0000000 --- a/src/JsonRpc/Messages/Request.php +++ /dev/null @@ -1,88 +0,0 @@ -id; - } - - /** - * Create a Request object from an array representation. - * - * @param array $data Raw decoded JSON-RPC data - * - * @throws McpError If the data doesn't conform to JSON-RPC 2.0 structure - */ - public static function fromArray(array $data): self - { - if (! isset($data['jsonrpc']) || $data['jsonrpc'] !== '2.0') { - throw ProtocolException::invalidRequest('Invalid or missing "jsonrpc" version. Must be "2.0".'); - } - - if (! isset($data['method']) || ! is_string($data['method'])) { - throw ProtocolException::invalidRequest('Invalid or missing "method" field.'); - } - - if (! isset($data['id'])) { - throw ProtocolException::invalidRequest('Invalid or missing "id" field.'); - } - - $params = []; - if (isset($data['params'])) { - if (! is_array($data['params'])) { - throw ProtocolException::invalidRequest('The "params" field must be an array or object.'); - } - $params = $data['params']; - } - - return new self( - $data['jsonrpc'], - $data['id'], - $data['method'], - $params, - ); - } - - /** - * Convert the request to an array. - */ - public function toArray(): array - { - $result = [ - 'jsonrpc' => $this->jsonrpc, - 'id' => $this->id, - 'method' => $this->method, - ]; - - if (! empty($this->params)) { - $result['params'] = $this->params; - } - - return $result; - } -} diff --git a/src/JsonRpc/Messages/Response.php b/src/JsonRpc/Messages/Response.php deleted file mode 100644 index 3b62a59..0000000 --- a/src/JsonRpc/Messages/Response.php +++ /dev/null @@ -1,95 +0,0 @@ -result === null) { - throw new \InvalidArgumentException('A JSON-RPC response with an ID must have a valid result.'); - } - } - - public function getId(): string|int - { - return $this->id; - } - - /** - * Create a Response object from an array representation. - * - * @param array $data Raw decoded JSON-RPC response data - * - * @throws ProtocolException If the data doesn't conform to JSON-RPC 2.0 structure - */ - public static function fromArray(array $data): self - { - if (! isset($data['jsonrpc']) || $data['jsonrpc'] !== '2.0') { - throw new ProtocolException('Invalid or missing "jsonrpc" version. Must be "2.0".'); - } - - $id = $data['id'] ?? null; - if (! (is_string($id) || is_int($id) || $id === null)) { - throw new ProtocolException('Invalid "id" field type in response.'); - } - - $result = $data['result']; - - try { - return new self('2.0', $id, $result); - } catch (\InvalidArgumentException $e) { - throw new ProtocolException('Invalid response structure: ' . $e->getMessage()); - } - } - - /** - * Create a successful response. - * - * @param T $result Method result - * @param mixed $id Request ID - */ - public static function make(mixed $result, string|int $id): self - { - return new self(jsonrpc: '2.0', result: $result, id: $id); - } - - /** - * Convert the response to an array. - */ - public function toArray(): array - { - return [ - 'jsonrpc' => $this->jsonrpc, - 'id' => $this->id, - 'result' => $this->result->toArray(), - ]; - } - - public function jsonSerialize(): mixed - { - return [ - 'jsonrpc' => $this->jsonrpc, - 'id' => $this->id, - 'result' => $this->result->jsonSerialize(), - ]; - } -} diff --git a/src/JsonRpc/Results/CallToolResult.php b/src/JsonRpc/Results/CallToolResult.php deleted file mode 100644 index 09c688f..0000000 --- a/src/JsonRpc/Results/CallToolResult.php +++ /dev/null @@ -1,36 +0,0 @@ - array_map(fn ($item) => $item->toArray(), $this->content), - 'isError' => $this->isError, - ]; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } -} diff --git a/src/JsonRpc/Results/CompletionCompleteResult.php b/src/JsonRpc/Results/CompletionCompleteResult.php deleted file mode 100644 index 54419a5..0000000 --- a/src/JsonRpc/Results/CompletionCompleteResult.php +++ /dev/null @@ -1,37 +0,0 @@ - ['values' => $this->values]]; - if ($this->total !== null) { - $result['completion']['total'] = $this->total; - } - if ($this->hasMore !== null) { - $result['completion']['hasMore'] = $this->hasMore; - } - return $result; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } -} diff --git a/src/JsonRpc/Results/EmptyResult.php b/src/JsonRpc/Results/EmptyResult.php deleted file mode 100644 index 57634a8..0000000 --- a/src/JsonRpc/Results/EmptyResult.php +++ /dev/null @@ -1,31 +0,0 @@ - array_map(fn ($message) => $message->toArray(), $this->messages), - ]; - - if ($this->description !== null) { - $result['description'] = $this->description; - } - - return $result; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } -} diff --git a/src/JsonRpc/Results/InitializeResult.php b/src/JsonRpc/Results/InitializeResult.php deleted file mode 100644 index d5b0113..0000000 --- a/src/JsonRpc/Results/InitializeResult.php +++ /dev/null @@ -1,47 +0,0 @@ - $this->serverInfo, - 'protocolVersion' => $this->protocolVersion, - 'capabilities' => $this->capabilities, - ]; - - if ($this->instructions !== null) { - $result['instructions'] = $this->instructions; - } - - return $result; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } -} diff --git a/src/JsonRpc/Results/ListPromptsResult.php b/src/JsonRpc/Results/ListPromptsResult.php deleted file mode 100644 index 0de446c..0000000 --- a/src/JsonRpc/Results/ListPromptsResult.php +++ /dev/null @@ -1,37 +0,0 @@ - $prompts The list of prompt definitions. - * @param string|null $nextCursor The cursor for the next page, or null if this is the last page. - */ - public function __construct( - public readonly array $prompts, - public readonly ?string $nextCursor = null - ) { - } - - public function toArray(): array - { - $result = [ - 'prompts' => array_map(fn (PromptDefinition $p) => $p->toArray(), $this->prompts), - ]; - - if ($this->nextCursor) { - $result['nextCursor'] = $this->nextCursor; - } - - return $result; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } -} diff --git a/src/JsonRpc/Results/ListResourceTemplatesResult.php b/src/JsonRpc/Results/ListResourceTemplatesResult.php deleted file mode 100644 index 7e02f22..0000000 --- a/src/JsonRpc/Results/ListResourceTemplatesResult.php +++ /dev/null @@ -1,40 +0,0 @@ - $resourceTemplates The list of resource template definitions. - * @param string|null $nextCursor The cursor for the next page, or null if this is the last page. - */ - public function __construct( - public readonly array $resourceTemplates, - public readonly ?string $nextCursor = null - ) { - } - - /** - * Convert the result to an array. - */ - public function toArray(): array - { - $result = [ - 'resourceTemplates' => array_map(fn (ResourceTemplateDefinition $t) => $t->toArray(), $this->resourceTemplates), - ]; - - if ($this->nextCursor) { - $result['nextCursor'] = $this->nextCursor; - } - - return $result; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } -} diff --git a/src/JsonRpc/Results/ListResourcesResult.php b/src/JsonRpc/Results/ListResourcesResult.php deleted file mode 100644 index 92298b3..0000000 --- a/src/JsonRpc/Results/ListResourcesResult.php +++ /dev/null @@ -1,40 +0,0 @@ - $resources The list of resource definitions. - * @param string|null $nextCursor The cursor for the next page, or null if this is the last page. - */ - public function __construct( - public readonly array $resources, - public readonly ?string $nextCursor = null - ) { - } - - /** - * Convert the result to an array. - */ - public function toArray(): array - { - $result = [ - 'resources' => array_map(fn (ResourceDefinition $r) => $r->toArray(), $this->resources), - ]; - - if ($this->nextCursor !== null) { - $result['nextCursor'] = $this->nextCursor; - } - - return $result; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } -} diff --git a/src/JsonRpc/Results/ListToolsResult.php b/src/JsonRpc/Results/ListToolsResult.php deleted file mode 100644 index 3f216ea..0000000 --- a/src/JsonRpc/Results/ListToolsResult.php +++ /dev/null @@ -1,37 +0,0 @@ - $tools The list of tool definitions. - * @param string|null $nextCursor The cursor for the next page, or null if this is the last page. - */ - public function __construct( - public readonly array $tools, - public readonly ?string $nextCursor = null - ) { - } - - public function toArray(): array - { - $result = [ - 'tools' => array_map(fn (ToolDefinition $t) => $t->toArray(), $this->tools), - ]; - - if ($this->nextCursor) { - $result['nextCursor'] = $this->nextCursor; - } - - return $result; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } -} diff --git a/src/JsonRpc/Results/ReadResourceResult.php b/src/JsonRpc/Results/ReadResourceResult.php deleted file mode 100644 index 193b39f..0000000 --- a/src/JsonRpc/Results/ReadResourceResult.php +++ /dev/null @@ -1,35 +0,0 @@ - array_map(fn ($resource) => $resource->toArray(), $this->contents), - ]; - } - - public function jsonSerialize(): array - { - return $this->toArray(); - } -} diff --git a/src/Model/Annotations.php b/src/Model/Annotations.php deleted file mode 100644 index 8df1768..0000000 --- a/src/Model/Annotations.php +++ /dev/null @@ -1,54 +0,0 @@ -priority !== null && ($this->priority < 0 || $this->priority > 1)) { - throw new \InvalidArgumentException('Priority must be between 0 and 1.'); - } - } - - public static function default(): self - { - return new self(null, null); - } - - public static function fromArray(array $data): self - { - return new self($data['audience'] ?? null, $data['priority'] ?? null); - } - - public function toArray(): array - { - $result = []; - - if ($this->audience !== null) { - $audience = []; - - foreach ($this->audience as $role) { - $audience[] = $role instanceof Role ? $role->value : $role; - } - - $result['audience'] = $audience; - } - if ($this->priority !== null) { - $result['priority'] = $this->priority; - } - - return $result; - } -} diff --git a/src/Model/Capabilities.php b/src/Model/Capabilities.php deleted file mode 100644 index 00036b3..0000000 --- a/src/Model/Capabilities.php +++ /dev/null @@ -1,113 +0,0 @@ -|null $experimental Optional experimental capabilities declared by the server. - */ - public static function forServer( - bool $toolsEnabled = true, - bool $toolsListChanged = false, - bool $resourcesEnabled = true, - bool $resourcesSubscribe = false, - bool $resourcesListChanged = false, - bool $promptsEnabled = true, - bool $promptsListChanged = false, - bool $loggingEnabled = false, - bool $completionsEnabled = 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->completionsEnabled) { - $data['completions'] = new stdClass(); - } - if ($this->experimental !== null && ! empty($this->experimental)) { - $data['experimental'] = $this->experimental; - } - - return empty($data) ? new stdClass() : $data; - } -} diff --git a/src/Model/Role.php b/src/Model/Role.php deleted file mode 100644 index 4c6c599..0000000 --- a/src/Model/Role.php +++ /dev/null @@ -1,14 +0,0 @@ -title !== null) { - $result['title'] = $this->title; - } - if ($this->readOnlyHint !== null) { - $result['readOnlyHint'] = $this->readOnlyHint; - } - if ($this->destructiveHint !== null) { - $result['destructiveHint'] = $this->destructiveHint; - } - if ($this->idempotentHint !== null) { - $result['idempotentHint'] = $this->idempotentHint; - } - if ($this->openWorldHint !== null) { - $result['openWorldHint'] = $this->openWorldHint; - } - - return $result; - } -} diff --git a/src/Protocol.php b/src/Protocol.php index 9b8eca4..ded2ee0 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -4,15 +4,21 @@ namespace PhpMcp\Server; +use PhpMcp\Schema\Constants; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Exception\McpServerException; -use PhpMcp\Server\JsonRpc\Messages\BatchRequest; -use PhpMcp\Server\JsonRpc\Messages\BatchResponse; -use PhpMcp\Server\JsonRpc\Messages\Error; -use PhpMcp\Server\JsonRpc\Messages\Notification; -use PhpMcp\Server\JsonRpc\Messages\Request; -use PhpMcp\Server\JsonRpc\Messages\Response; +use PhpMcp\Schema\JsonRpc\BatchRequest; +use PhpMcp\Schema\JsonRpc\BatchResponse; +use PhpMcp\Schema\JsonRpc\Error; +use PhpMcp\Schema\JsonRpc\Notification; +use PhpMcp\Schema\JsonRpc\Request; +use PhpMcp\Schema\JsonRpc\Response; +use PhpMcp\Schema\Notification\PromptListChangedNotification; +use PhpMcp\Schema\Notification\ResourceListChangedNotification; +use PhpMcp\Schema\Notification\ResourceUpdatedNotification; +use PhpMcp\Schema\Notification\RootsListChangedNotification; +use PhpMcp\Schema\Notification\ToolListChangedNotification; use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Session\SubscriptionManager; use PhpMcp\Server\Support\RequestHandler; @@ -141,21 +147,19 @@ public function processMessage(Request|Notification|BatchRequest $message, strin /** * Process a batch message */ - private function processBatchRequest(BatchRequest $batch, SessionInterface $session): BatchResponse + private function processBatchRequest(BatchRequest $batch, SessionInterface $session): ?BatchResponse { - $batchResponse = new BatchResponse(); + $items = []; foreach ($batch->getNotifications() as $notification) { $this->processNotification($notification, $session); } foreach ($batch->getRequests() as $request) { - $response = $this->processRequest($request, $session); - - $batchResponse->add($response); + $items[] = $this->processRequest($request, $session); } - return $batchResponse; + return empty($items) ? null : new BatchResponse($items); } /** @@ -163,46 +167,32 @@ private function processBatchRequest(BatchRequest $batch, SessionInterface $sess */ private function processRequest(Request $request, SessionInterface $session): Response|Error { - $method = $request->method; - $params = $request->params; - try { - if ($method !== 'initialize') { + if ($request->method !== 'initialize') { $this->assertSessionInitialized($session); } - $this->assertRequestCapability($method); - - $result = match ($method) { - 'initialize' => $this->requestHandler->handleInitialize($params, $session), - 'ping' => $this->requestHandler->handlePing($session), - 'tools/list' => $this->requestHandler->handleToolList($params), - 'tools/call' => $this->requestHandler->handleToolCall($params), - 'resources/list' => $this->requestHandler->handleResourcesList($params), - 'resources/read' => $this->requestHandler->handleResourceRead($params), - 'resources/subscribe' => $this->requestHandler->handleResourceSubscribe($params, $session), - 'resources/unsubscribe' => $this->requestHandler->handleResourceUnsubscribe($params, $session), - 'resources/templates/list' => $this->requestHandler->handleResourceTemplateList($params), - 'prompts/list' => $this->requestHandler->handlePromptsList($params), - 'prompts/get' => $this->requestHandler->handlePromptGet($params), - 'logging/setLevel' => $this->requestHandler->handleLoggingSetLevel($params, $session), - 'completion/complete' => $this->requestHandler->handleCompletionComplete($params, $session), - default => throw McpServerException::methodNotFound($method), - }; - - return Response::make($result, $request->id); + $this->assertRequestCapability($request->method); + + $result = $this->requestHandler->handleRequest($request, $session); + + return Response::make($request->id, $result); } catch (McpServerException $e) { - $this->logger->debug('MCP Processor caught McpServerException', ['method' => $method, 'code' => $e->getCode(), 'message' => $e->getMessage(), 'data' => $e->getData()]); + $this->logger->debug('MCP Processor caught McpServerException', ['method' => $request->method, 'code' => $e->getCode(), 'message' => $e->getMessage(), 'data' => $e->getData()]); return $e->toJsonRpcError($request->id); } catch (Throwable $e) { - $this->logger->error('MCP Processor caught unexpected error', ['method' => $method, 'exception' => $e->getMessage()]); + $this->logger->error('MCP Processor caught unexpected error', [ + 'method' => $request->method, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); return new Error( jsonrpc: '2.0', id: $request->id, - code: Error::CODE_INTERNAL_ERROR, - message: 'Internal error processing method ' . $method, + code: Constants::INTERNAL_ERROR, + message: 'Internal error processing method ' . $request->method, data: $e->getMessage() ); } @@ -217,9 +207,7 @@ private function processNotification(Notification $notification, SessionInterfac $params = $notification->params; try { - if ($method === 'notifications/initialized') { - $this->requestHandler->handleNotificationInitialized($params, $session); - } + $this->requestHandler->handleNotification($notification, $session); } catch (Throwable $e) { $this->logger->error('Error while processing notification', ['method' => $method, 'exception' => $e->getMessage()]); return; @@ -254,7 +242,7 @@ public function sendNotification(string $sessionId, Notification $notification): /** * Notify subscribers about resource content change */ - public function notifyResourceChanged(string $uri): void + public function notifyResourceUpdated(string $uri): void { $subscribers = $this->subscriptionManager->getSubscribers($uri); @@ -262,10 +250,7 @@ public function notifyResourceChanged(string $uri): void return; } - $notification = Notification::make( - 'notifications/resources/updated', - ['uri' => $uri] - ); + $notification = ResourceUpdatedNotification::make($uri); foreach ($subscribers as $sessionId) { $this->sendNotification($sessionId, $notification); @@ -302,7 +287,7 @@ private function assertRequestCapability(string $method): void case 'tools/list': case 'tools/call': - if (!$capabilities->toolsEnabled) { + if ($capabilities->tools === null) { throw McpServerException::methodNotFound($method, 'Tools are not enabled on this server.'); } break; @@ -310,36 +295,36 @@ private function assertRequestCapability(string $method): void case 'resources/list': case 'resources/templates/list': case 'resources/read': - if (!$capabilities->resourcesEnabled) { + if ($capabilities->resources === null) { throw McpServerException::methodNotFound($method, 'Resources are not enabled on this server.'); } break; case 'resources/subscribe': case 'resources/unsubscribe': - if (!$capabilities->resourcesEnabled) { + if ($capabilities->resources === null) { throw McpServerException::methodNotFound($method, 'Resources are not enabled on this server.'); } - if (!$capabilities->resourcesSubscribe) { + if (!$capabilities->resources['subscribe']) { throw McpServerException::methodNotFound($method, 'Resources subscription is not enabled on this server.'); } break; case 'prompts/list': case 'prompts/get': - if (!$capabilities->promptsEnabled) { + if ($capabilities->prompts === null) { throw McpServerException::methodNotFound($method, 'Prompts are not enabled on this server.'); } break; case 'logging/setLevel': - if (!$capabilities->loggingEnabled) { + if ($capabilities->logging === null) { throw McpServerException::methodNotFound($method, 'Logging is not enabled on this server.'); } break; case 'completion/complete': - if (!$capabilities->completionsEnabled) { + if ($capabilities->completions === null) { throw McpServerException::methodNotFound($method, 'Completions are not enabled on this server.'); } break; @@ -357,7 +342,7 @@ private function canSendNotification(string $method): bool switch ($method) { case 'notifications/message': - if (!$capabilities->loggingEnabled) { + if ($capabilities->logging === null) { $this->logger->warning('Logging is not enabled on this server. Notifications/message will not be sent.'); $valid = false; } @@ -365,21 +350,21 @@ private function canSendNotification(string $method): bool case "notifications/resources/updated": case "notifications/resources/list_changed": - if (!$capabilities->resourcesListChanged) { + if ($capabilities->resources === null || !$capabilities->resources['listChanged']) { $this->logger->warning('Resources list changed notifications are not enabled on this server. Notifications/resources/list_changed will not be sent.'); $valid = false; } break; case "notifications/tools/list_changed": - if (!$capabilities->toolsListChanged) { + if ($capabilities->tools === null || !$capabilities->tools['listChanged']) { $this->logger->warning('Tools list changed notifications are not enabled on this server. Notifications/tools/list_changed will not be sent.'); $valid = false; } break; case "notifications/prompts/list_changed": - if (!$capabilities->promptsListChanged) { + if ($capabilities->prompts === null || !$capabilities->prompts['listChanged']) { $this->logger->warning('Prompts list changed notifications are not enabled on this server. Notifications/prompts/list_changed will not be sent.'); $valid = false; } @@ -432,14 +417,18 @@ private function handleListChanged(string $listType): void return; } - $method = "notifications/{$listType}/list_changed"; + $notification = match ($listType) { + 'resources' => ResourceListChangedNotification::make(), + 'tools' => ToolListChangedNotification::make(), + 'prompts' => PromptListChangedNotification::make(), + 'roots' => RootsListChangedNotification::make(), + default => throw new \InvalidArgumentException("Invalid list type: {$listType}"), + }; - if (!$this->canSendNotification($method)) { + if (!$this->canSendNotification($notification->method)) { return; } - $notification = Notification::make($method); - foreach ($subscribers as $sessionId) { $this->sendNotification($sessionId, $notification); } diff --git a/src/Registry.php b/src/Registry.php index 1bccbfa..9d78749 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -6,11 +6,12 @@ use Evenement\EventEmitterInterface; use Evenement\EventEmitterTrait; -use PhpMcp\Server\Definitions\PromptDefinition; -use PhpMcp\Server\Definitions\ResourceDefinition; -use PhpMcp\Server\Definitions\ResourceTemplateDefinition; -use PhpMcp\Server\Definitions\ToolDefinition; +use PhpMcp\Schema\Prompt; +use PhpMcp\Schema\Resource; +use PhpMcp\Schema\ResourceTemplate; +use PhpMcp\Schema\Tool; use PhpMcp\Server\Exception\DefinitionException; +use PhpMcp\Server\Support\MethodInvoker; use PhpMcp\Server\Support\UriTemplateMatcher; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; @@ -23,16 +24,16 @@ class Registry implements EventEmitterInterface private const DISCOVERED_ELEMENTS_CACHE_KEY = 'mcp_server_discovered_elements'; - /** @var array */ + /** @var array */ private array $tools = []; - /** @var array */ + /** @var array */ private array $resources = []; - /** @var array */ + /** @var array */ private array $prompts = []; - /** @var array */ + /** @var array */ private array $resourceTemplates = []; /** @var array */ @@ -103,10 +104,17 @@ public function load(): void $loadCount = 0; foreach ($cached['tools'] ?? [] as $toolData) { - $toolDefinition = $toolData instanceof ToolDefinition ? $toolData : ToolDefinition::fromArray($toolData); - $toolName = $toolDefinition->toolName; + if (!isset($toolData['tool']) || !isset($toolData['invoker'])) { + $this->logger->warning('Invalid tool data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'data' => $toolData]); + continue; + } + + $toolName = $toolData['tool']['name']; if (! isset($this->manualToolNames[$toolName])) { - $this->tools[$toolName] = $toolDefinition; + $this->tools[$toolName] = [ + 'tool' => Tool::fromArray($toolData['tool']), + 'invoker' => MethodInvoker::fromArray($toolData['invoker']), + ]; $loadCount++; } else { $this->logger->debug("Skipping cached tool '{$toolName}' as manual version exists."); @@ -114,10 +122,17 @@ public function load(): void } foreach ($cached['resources'] ?? [] as $resourceData) { - $resourceDefinition = $resourceData instanceof ResourceDefinition ? $resourceData : ResourceDefinition::fromArray($resourceData); - $uri = $resourceDefinition->uri; + if (!isset($resourceData['resource']) || !isset($resourceData['invoker'])) { + $this->logger->warning('Invalid resource data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'data' => $resourceData]); + continue; + } + + $uri = $resourceData['resource']['uri']; if (! isset($this->manualResourceUris[$uri])) { - $this->resources[$uri] = $resourceDefinition; + $this->resources[$uri] = [ + 'resource' => Resource::fromArray($resourceData['resource']), + 'invoker' => MethodInvoker::fromArray($resourceData['invoker']), + ]; $loadCount++; } else { $this->logger->debug("Skipping cached resource '{$uri}' as manual version exists."); @@ -125,10 +140,17 @@ public function load(): void } foreach ($cached['prompts'] ?? [] as $promptData) { - $promptDefinition = $promptData instanceof PromptDefinition ? $promptData : PromptDefinition::fromArray($promptData); - $promptName = $promptDefinition->promptName; + if (!isset($promptData['prompt']) || !isset($promptData['invoker'])) { + $this->logger->warning('Invalid prompt data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'data' => $promptData]); + continue; + } + + $promptName = $promptData['prompt']['name']; if (! isset($this->manualPromptNames[$promptName])) { - $this->prompts[$promptName] = $promptDefinition; + $this->prompts[$promptName] = [ + 'prompt' => Prompt::fromArray($promptData['prompt']), + 'invoker' => MethodInvoker::fromArray($promptData['invoker']), + ]; $loadCount++; } else { $this->logger->debug("Skipping cached prompt '{$promptName}' as manual version exists."); @@ -136,10 +158,17 @@ public function load(): void } foreach ($cached['resourceTemplates'] ?? [] as $templateData) { - $templateDefinition = $templateData instanceof ResourceTemplateDefinition ? $templateData : ResourceTemplateDefinition::fromArray($templateData); - $uriTemplate = $templateDefinition->uriTemplate; + if (!isset($templateData['resourceTemplate']) || !isset($templateData['invoker'])) { + $this->logger->warning('Invalid resource template data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'data' => $templateData]); + continue; + } + + $uriTemplate = $templateData['resourceTemplate']['uriTemplate']; if (! isset($this->manualTemplateUris[$uriTemplate])) { - $this->resourceTemplates[$uriTemplate] = $templateDefinition; + $this->resourceTemplates[$uriTemplate] = [ + 'resourceTemplate' => ResourceTemplate::fromArray($templateData['resourceTemplate']), + 'invoker' => MethodInvoker::fromArray($templateData['invoker']), + ]; $loadCount++; } else { $this->logger->debug("Skipping cached template '{$uriTemplate}' as manual version exists."); @@ -161,9 +190,9 @@ public function load(): void } } - public function registerTool(ToolDefinition $tool, bool $isManual = false): void + public function registerTool(Tool $tool, MethodInvoker $invoker, bool $isManual = false): void { - $toolName = $tool->toolName; + $toolName = $tool->name; $exists = isset($this->tools[$toolName]); $wasManual = isset($this->manualToolNames[$toolName]); @@ -177,7 +206,10 @@ public function registerTool(ToolDefinition $tool, bool $isManual = false): void $this->logger->warning('MCP Registry: Replacing existing ' . ($wasManual ? 'manual' : 'discovered') . " tool '{$toolName}' with " . ($isManual ? 'manual' : 'discovered') . ' definition.'); } - $this->tools[$toolName] = $tool; + $this->tools[$toolName] = [ + 'tool' => $tool, + 'invoker' => $invoker, + ]; if ($isManual) { $this->manualToolNames[$toolName] = true; @@ -188,7 +220,7 @@ public function registerTool(ToolDefinition $tool, bool $isManual = false): void $this->checkAndEmitChange('tools', $this->tools); } - public function registerResource(ResourceDefinition $resource, bool $isManual = false): void + public function registerResource(Resource $resource, MethodInvoker $invoker, bool $isManual = false): void { $uri = $resource->uri; $exists = isset($this->resources[$uri]); @@ -203,7 +235,10 @@ public function registerResource(ResourceDefinition $resource, bool $isManual = $this->logger->warning('Replacing existing ' . ($wasManual ? 'manual' : 'discovered') . " resource '{$uri}' with " . ($isManual ? 'manual' : 'discovered') . ' definition.'); } - $this->resources[$uri] = $resource; + $this->resources[$uri] = [ + 'resource' => $resource, + 'invoker' => $invoker, + ]; if ($isManual) { $this->manualResourceUris[$uri] = true; @@ -214,7 +249,7 @@ public function registerResource(ResourceDefinition $resource, bool $isManual = $this->checkAndEmitChange('resources', $this->resources); } - public function registerResourceTemplate(ResourceTemplateDefinition $template, bool $isManual = false): void + public function registerResourceTemplate(ResourceTemplate $template, MethodInvoker $invoker, bool $isManual = false): void { $uriTemplate = $template->uriTemplate; $exists = isset($this->resourceTemplates[$uriTemplate]); @@ -229,7 +264,10 @@ public function registerResourceTemplate(ResourceTemplateDefinition $template, b $this->logger->warning('MCP Registry: Replacing existing ' . ($wasManual ? 'manual' : 'discovered') . " template '{$uriTemplate}' with " . ($isManual ? 'manual' : 'discovered') . ' definition.'); } - $this->resourceTemplates[$uriTemplate] = $template; + $this->resourceTemplates[$uriTemplate] = [ + 'resourceTemplate' => $template, + 'invoker' => $invoker, + ]; if ($isManual) { $this->manualTemplateUris[$uriTemplate] = true; @@ -240,9 +278,9 @@ public function registerResourceTemplate(ResourceTemplateDefinition $template, b // No listChanged for templates } - public function registerPrompt(PromptDefinition $prompt, bool $isManual = false): void + public function registerPrompt(Prompt $prompt, MethodInvoker $invoker, bool $isManual = false): void { - $promptName = $prompt->promptName; + $promptName = $prompt->name; $exists = isset($this->prompts[$promptName]); $wasManual = isset($this->manualPromptNames[$promptName]); @@ -255,7 +293,10 @@ public function registerPrompt(PromptDefinition $prompt, bool $isManual = false) $this->logger->warning('MCP Registry: Replacing existing ' . ($wasManual ? 'manual' : 'discovered') . " prompt '{$promptName}' with " . ($isManual ? 'manual' : 'discovered') . ' definition.'); } - $this->prompts[$promptName] = $prompt; + $this->prompts[$promptName] = [ + 'prompt' => $prompt, + 'invoker' => $invoker, + ]; if ($isManual) { $this->manualPromptNames[$promptName] = true; @@ -308,25 +349,37 @@ public function save(): bool foreach ($this->tools as $name => $tool) { if (! isset($this->manualToolNames[$name])) { - $discoveredData['tools'][$name] = $tool; + $discoveredData['tools'][$name] = [ + 'tool' => $tool['tool']->toArray(), + 'invoker' => $tool['invoker']->toArray(), + ]; } } foreach ($this->resources as $uri => $resource) { if (! isset($this->manualResourceUris[$uri])) { - $discoveredData['resources'][$uri] = $resource; + $discoveredData['resources'][$uri] = [ + 'resource' => $resource['resource']->toArray(), + 'invoker' => $resource['invoker']->toArray(), + ]; } } foreach ($this->prompts as $name => $prompt) { if (! isset($this->manualPromptNames[$name])) { - $discoveredData['prompts'][$name] = $prompt; + $discoveredData['prompts'][$name] = [ + 'prompt' => $prompt['prompt']->toArray(), + 'invoker' => $prompt['invoker']->toArray(), + ]; } } foreach ($this->resourceTemplates as $uriTemplate => $template) { if (! isset($this->manualTemplateUris[$uriTemplate])) { - $discoveredData['resourceTemplates'][$uriTemplate] = $template; + $discoveredData['resourceTemplates'][$uriTemplate] = [ + 'resourceTemplate' => $template['resourceTemplate']->toArray(), + 'invoker' => $template['invoker']->toArray(), + ]; } } @@ -398,71 +451,87 @@ public function clear(): void } } - $this->logger->debug("Removed {$clearCount} discovered elements from internal registry."); + if ($clearCount > 0) { + $this->logger->debug("Removed {$clearCount} discovered elements from internal registry."); + } } - public function findTool(string $name): ?ToolDefinition + /** @return array{tool: Tool, invoker: MethodInvoker}|null */ + public function getTool(string $name): ?array { return $this->tools[$name] ?? null; } - public function findPrompt(string $name): ?PromptDefinition + /** @return array{ + * resource: Resource, + * invoker: MethodInvoker, + * variables: array, + * }|null */ + public function getResource(string $uri, bool $includeTemplates = true): ?array { - return $this->prompts[$name] ?? null; - } + $registration = $this->resources[$uri] ?? null; + if ($registration) { + $registration['variables'] = []; + return $registration; + } - public function findResourceByUri(string $uri): ?ResourceDefinition - { - return $this->resources[$uri] ?? null; - } + if (! $includeTemplates) { + return null; + } - public function findResourceTemplateByUri(string $uri): ?array - { - foreach ($this->resourceTemplates as $templateDefinition) { + foreach ($this->resourceTemplates as $template) { try { - $matcher = new UriTemplateMatcher($templateDefinition->uriTemplate); + $matcher = new UriTemplateMatcher($template['resourceTemplate']->uriTemplate); $variables = $matcher->match($uri); - - if ($variables !== null) { - $this->logger->debug('MCP Registry: Matched URI to template.', ['uri' => $uri, 'template' => $templateDefinition->uriTemplate]); - - return ['definition' => $templateDefinition, 'variables' => $variables]; - } } catch (\InvalidArgumentException $e) { $this->logger->warning('Invalid resource template encountered during matching', [ - 'template' => $templateDefinition->uriTemplate, + 'template' => $template['resourceTemplate']->uriTemplate, 'error' => $e->getMessage(), ]); - continue; } + + if ($variables !== null) { + return [ + 'resource' => $template['resourceTemplate'], + 'invoker' => $template['invoker'], + 'variables' => $variables, + ]; + } } - $this->logger->debug('MCP Registry: No template matched URI.', ['uri' => $uri]); + + $this->logger->debug('No resource matched URI.', ['uri' => $uri]); return null; } - /** @return array */ + /** @return array{prompt: Prompt, invoker: MethodInvoker}|null */ + public function getPrompt(string $name): ?array + { + return $this->prompts[$name] ?? null; + } + + /** @return array */ public function getTools(): array { - return $this->tools; + return array_map(fn($registration) => $registration['tool'], $this->tools); } - /** @return array */ + /** @return array */ public function getResources(): array { - return $this->resources; + return array_map(fn($registration) => $registration['resource'], $this->resources); } - /** @return array */ + /** @return array */ public function getPrompts(): array { - return $this->prompts; + return array_map(fn($registration) => $registration['prompt'], $this->prompts); } - /** @return array */ + /** @return array */ public function getResourceTemplates(): array { - return $this->resourceTemplates; + return array_map(fn($registration) => $registration['resourceTemplate'], $this->resourceTemplates); } } diff --git a/src/Server.php b/src/Server.php index 2d76649..36a649e 100644 --- a/src/Server.php +++ b/src/Server.php @@ -41,8 +41,7 @@ public function __construct( protected readonly Registry $registry, protected readonly Protocol $protocol, protected readonly SessionManager $sessionManager, - ) { - } + ) {} public static function make(): ServerBuilder { @@ -98,7 +97,6 @@ public function discover( $discoverer->discover($realBasePath, $scanDirs, $excludeDirs); $this->discoveryRan = true; - $this->configuration->logger->info('Element discovery process finished.'); if ($shouldSaveCache) { $this->registry->save(); diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index c0dcd42..55982bc 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -4,31 +4,37 @@ namespace PhpMcp\Server; +use PhpMcp\Schema\Annotations; +use PhpMcp\Schema\Implementation; +use PhpMcp\Schema\Prompt; +use PhpMcp\Schema\PromptArgument; +use PhpMcp\Schema\Resource; +use PhpMcp\Schema\ResourceTemplate; +use PhpMcp\Schema\ServerCapabilities; +use PhpMcp\Schema\Tool; +use PhpMcp\Schema\ToolAnnotations; +use PhpMcp\Server\Contracts\SessionHandlerInterface; use PhpMcp\Server\Defaults\BasicContainer; use PhpMcp\Server\Exception\ConfigurationException; use PhpMcp\Server\Exception\DefinitionException; -use PhpMcp\Server\Model\Capabilities; use PhpMcp\Server\Session\ArraySessionHandler; use PhpMcp\Server\Session\CacheSessionHandler; use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Support\HandlerResolver; -use PhpMcp\Server\Support\RequestHandler; +use PhpMcp\Server\Support\MethodInvoker; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Psr\SimpleCache\CacheInterface; use React\EventLoop\Loop; use React\EventLoop\LoopInterface; -use SessionHandlerInterface; use Throwable; final class ServerBuilder { - private ?string $name = null; + private ?Implementation $serverInfo = null; - private ?string $version = null; - - private ?Capabilities $capabilities = null; + private ?ServerCapabilities $capabilities = null; private ?LoggerInterface $logger = null; @@ -46,26 +52,50 @@ final class ServerBuilder private ?int $paginationLimit = 50; - // Temporary storage for manual registrations + /** @var array< + * array{handler: array|string, + * name: string|null, + * description: string|null, + * annotations: ToolAnnotations|null} + * > */ private array $manualTools = []; + /** @var array< + * array{handler: array|string, + * uri: string, + * name: string|null, + * description: string|null, + * mimeType: string|null, + * size: int|null, + * annotations: Annotations|null} + * > */ private array $manualResources = []; + /** @var array< + * array{handler: array|string, + * uriTemplate: string, + * name: string|null, + * description: string|null, + * mimeType: string|null, + * annotations: Annotations|null} + * > */ private array $manualResourceTemplates = []; + /** @var array< + * array{handler: array|string, + * name: string|null, + * description: string|null} + * > */ private array $manualPrompts = []; - public function __construct() - { - } + public function __construct() {} /** * Sets the server's identity. Required. */ public function withServerInfo(string $name, string $version): self { - $this->name = trim($name); - $this->version = trim($version); + $this->serverInfo = Implementation::make(name: trim($name), version: trim($version)); return $this; } @@ -73,7 +103,7 @@ public function withServerInfo(string $name, string $version): self /** * Configures the server's declared capabilities. */ - public function withCapabilities(Capabilities $capabilities): self + public function withCapabilities(ServerCapabilities $capabilities): self { $this->capabilities = $capabilities; @@ -160,7 +190,7 @@ public function withLoop(LoopInterface $loop): self /** * Manually registers a tool handler. */ - public function withTool(array|string $handler, ?string $name = null, ?string $description = null, ?array $annotations = null): self + public function withTool(array|string $handler, ?string $name = null, ?string $description = null, ?ToolAnnotations $annotations = null): self { $this->manualTools[] = compact('handler', 'name', 'description', 'annotations'); @@ -170,9 +200,9 @@ public function withTool(array|string $handler, ?string $name = null, ?string $d /** * Manually registers a resource handler. */ - public function withResource(array|string $handler, string $uri, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?int $size = null): self + public function withResource(array|string $handler, string $uri, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?int $size = null, ?Annotations $annotations = null): self { - $this->manualResources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size'); + $this->manualResources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations'); return $this; } @@ -180,9 +210,9 @@ public function withResource(array|string $handler, string $uri, ?string $name = /** * Manually registers a resource template handler. */ - public function withResourceTemplate(array|string $handler, string $uriTemplate, ?string $name = null, ?string $description = null, ?string $mimeType = null): self + public function withResourceTemplate(array|string $handler, string $uriTemplate, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?Annotations $annotations = null): self { - $this->manualResourceTemplates[] = compact('handler', 'uriTemplate', 'name', 'description', 'mimeType'); + $this->manualResourceTemplates[] = compact('handler', 'uriTemplate', 'name', 'description', 'mimeType', 'annotations'); return $this; } @@ -204,7 +234,7 @@ public function withPrompt(array|string $handler, ?string $name = null, ?string */ public function build(): Server { - if ($this->name === null || $this->version === null || $this->name === '' || $this->version === '') { + if ($this->serverInfo === null) { throw new ConfigurationException('Server name and version must be provided using withServerInfo().'); } @@ -212,11 +242,10 @@ public function build(): Server $cache = $this->cache; $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new BasicContainer(); - $capabilities = $this->capabilities ?? Capabilities::forServer(); + $capabilities = $this->capabilities ?? ServerCapabilities::make(); $configuration = new Configuration( - serverName: $this->name, - serverVersion: $this->version, + serverInfo: $this->serverInfo, capabilities: $capabilities, logger: $logger, loop: $loop, @@ -259,17 +288,22 @@ private function performManualRegistrations(Registry $registry, LoggerInterface // Register Tools foreach ($this->manualTools as $data) { try { - $resolvedHandler = HandlerResolver::resolve($data['handler']); - $def = Definitions\ToolDefinition::fromReflection( - $resolvedHandler['reflectionMethod'], - $data['name'], - $data['description'], - $data['annotations'], - $docBlockParser, - $schemaGenerator - ); - $registry->registerTool($def, true); - $logger->debug("Registered manual tool '{$def->toolName}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); + $reflectionMethod = HandlerResolver::resolve($data['handler']); + $className = $reflectionMethod->getDeclaringClass()->getName(); + $methodName = $reflectionMethod->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); + + $name = $data['name'] ?? ($reflectionMethod->getName() === '__invoke' + ? $reflectionMethod->getDeclaringClass()->getShortName() + : $reflectionMethod->getName()); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + $inputSchema = $schemaGenerator->fromMethodParameters($reflectionMethod); + + $tool = Tool::make($name, $inputSchema, $description, $data['annotations']); + $invoker = new MethodInvoker($className, $methodName); + $registry->registerTool($tool, $invoker, true); + + $logger->debug("Registered manual tool {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { $errorCount++; $logger->error('Failed to register manual tool', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); @@ -279,19 +313,23 @@ private function performManualRegistrations(Registry $registry, LoggerInterface // Register Resources foreach ($this->manualResources as $data) { try { - $resolvedHandler = HandlerResolver::resolve($data['handler']); - $def = Definitions\ResourceDefinition::fromReflection( - $resolvedHandler['reflectionMethod'], - $data['name'], - $data['description'], - $data['uri'], - $data['mimeType'], - $data['size'], - $data['annotations'], - $docBlockParser - ); - $registry->registerResource($def, true); - $logger->debug("Registered manual resource '{$def->uri}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); + $reflectionMethod = HandlerResolver::resolve($data['handler']); + $className = $reflectionMethod->getDeclaringClass()->getName(); + $methodName = $reflectionMethod->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); + + $uri = $data['uri']; + $name = $data['name'] ?? ($methodName === '__invoke' ? $reflectionMethod->getDeclaringClass()->getShortName() : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + $mimeType = $data['mimeType']; + $size = $data['size']; + $annotations = $data['annotations']; + + $resource = Resource::make($uri, $name, $description, $mimeType, $annotations, $size); + $invoker = new MethodInvoker($className, $methodName); + $registry->registerResource($resource, $invoker, true); + + $logger->debug("Registered manual resource {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { $errorCount++; $logger->error('Failed to register manual resource', ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e]); @@ -301,18 +339,22 @@ private function performManualRegistrations(Registry $registry, LoggerInterface // Register Templates foreach ($this->manualResourceTemplates as $data) { try { - $resolvedHandler = HandlerResolver::resolve($data['handler']); - $def = Definitions\ResourceTemplateDefinition::fromReflection( - $resolvedHandler['reflectionMethod'], - $data['name'], - $data['description'], - $data['uriTemplate'], - $data['mimeType'], - $data['annotations'], - $docBlockParser - ); - $registry->registerResourceTemplate($def, true); - $logger->debug("Registered manual template '{$def->uriTemplate}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); + $reflectionMethod = HandlerResolver::resolve($data['handler']); + $className = $reflectionMethod->getDeclaringClass()->getName(); + $methodName = $reflectionMethod->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); + + $uriTemplate = $data['uriTemplate']; + $name = $data['name'] ?? ($methodName === '__invoke' ? $reflectionMethod->getDeclaringClass()->getShortName() : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + $mimeType = $data['mimeType']; + $annotations = $data['annotations']; + + $template = ResourceTemplate::make($uriTemplate, $name, $description, $mimeType, $annotations); + $invoker = new MethodInvoker($className, $methodName); + $registry->registerResourceTemplate($template, $invoker, true); + + $logger->debug("Registered manual template {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { $errorCount++; $logger->error('Failed to register manual template', ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e]); @@ -322,15 +364,36 @@ private function performManualRegistrations(Registry $registry, LoggerInterface // Register Prompts foreach ($this->manualPrompts as $data) { try { - $resolvedHandler = HandlerResolver::resolve($data['handler']); - $def = Definitions\PromptDefinition::fromReflection( - $resolvedHandler['reflectionMethod'], - $data['name'], - $data['description'], - $docBlockParser - ); - $registry->registerPrompt($def, true); - $logger->debug("Registered manual prompt '{$def->promptName}' from handler {$resolvedHandler['className']}::{$resolvedHandler['methodName']}"); + $reflectionMethod = HandlerResolver::resolve($data['handler']); + $className = $reflectionMethod->getDeclaringClass()->getName(); + $methodName = $reflectionMethod->getName(); + $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); + + $name = $data['name'] ?? ($methodName === '__invoke' ? $reflectionMethod->getDeclaringClass()->getShortName() : $methodName); + $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; + + $arguments = []; + $paramTags = $docBlockParser->getParamTags($docBlock); + foreach ($reflectionMethod->getParameters() as $param) { + $reflectionType = $param->getType(); + + // Basic DI check (heuristic) + if ($reflectionType instanceof \ReflectionNamedType && ! $reflectionType->isBuiltin()) { + continue; + } + + $paramTag = $paramTags['$' . $param->getName()] ?? null; + $arguments[] = PromptArgument::make( + name: $param->getName(), + description: $paramTag ? trim((string) $paramTag->getDescription()) : null, + required: ! $param->isOptional() && ! $param->isDefaultValueAvailable() + ); + } + + $prompt = Prompt::make($name, $description, $arguments); + $invoker = new MethodInvoker($className, $methodName); + $registry->registerPrompt($prompt, $invoker, true); + $logger->debug("Registered manual prompt {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { $errorCount++; $logger->error('Failed to register manual prompt', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); diff --git a/src/State/ClientState.php b/src/State/ClientState.php deleted file mode 100644 index 6ddbc2c..0000000 --- a/src/State/ClientState.php +++ /dev/null @@ -1,63 +0,0 @@ - URIs this client is subscribed to. Key is URI, value is true. */ - public array $subscriptions = []; - - /** @var array Queued outgoing framed messages for this client. */ - public array $messageQueue = []; - - public int $lastActivityTimestamp; - - public ?string $requestedLogLevel = null; - - public function __construct(protected string $clientId) - { - $this->lastActivityTimestamp = time(); - } - - public function addSubscription(string $uri): void - { - $this->subscriptions[$uri] = true; - } - - public function removeSubscription(string $uri): void - { - unset($this->subscriptions[$uri]); - } - - public function clearSubscriptions(): void - { - $this->subscriptions = []; - } - - public function addMessageToQueue(string $message): void - { - $this->messageQueue[] = $message; - } - - /** @return array */ - public function consumeMessageQueue(): array - { - $messages = $this->messageQueue; - $this->messageQueue = []; - - return $messages; - } -} diff --git a/src/State/ClientStateManager.php b/src/State/ClientStateManager.php deleted file mode 100644 index 6f416bc..0000000 --- a/src/State/ClientStateManager.php +++ /dev/null @@ -1,501 +0,0 @@ -cachePrefix = $clientDataPrefix; - $this->cacheTtl = max(60, $cacheTtl); - - $this->cache ??= new ArrayCache(); - } - - private function getClientStateCacheKey(string $clientId): string - { - return $this->cachePrefix . $clientId; - } - - private function getResourceSubscribersCacheKey(string $uri): string - { - return self::GLOBAL_RESOURCE_SUBSCRIBERS_KEY_PREFIX . sha1($uri); - } - - private function getActiveClientsCacheKey(): string - { - return $this->cachePrefix . self::GLOBAL_ACTIVE_CLIENTS_KEY; - } - - /** - * Fetches or creates a ClientState object for a client. - */ - private function getClientState(string $clientId, bool $createIfNotFound = false): ?ClientState - { - $key = $this->getClientStateCacheKey($clientId); - - try { - $state = $this->cache->get($key); - if ($state instanceof ClientState) { - return $state; - } - - if ($state !== null) { - $this->logger->warning('Invalid data type found in cache for client state, deleting.', ['clientId' => $clientId, 'key' => $key]); - $this->cache->delete($key); - } - - if ($createIfNotFound) { - return new ClientState($clientId); - } - } catch (Throwable $e) { - $this->logger->error('Error fetching client state from cache.', ['clientId' => $clientId, 'key' => $key, 'exception' => $e]); - } - - return null; - } - - /** - * Saves a ClientState object to the cache. - */ - private function saveClientState(string $clientId, ClientState $state): bool - { - $key = $this->getClientStateCacheKey($clientId); - - try { - $state->lastActivityTimestamp = time(); - - return $this->cache->set($key, $state, $this->cacheTtl); - } catch (Throwable $e) { - $this->logger->error('Error saving client state to cache.', ['clientId' => $clientId, 'key' => $key, 'exception' => $e]); - - return false; - } - } - - /** - * Checks if a client has been initialized. - */ - public function isInitialized(string $clientId): bool - { - $state = $this->getClientState($clientId); - - return $state !== null && $state->isInitialized; - } - - /** - * Marks a client as initialized. - */ - public function markInitialized(string $clientId): void - { - $state = $this->getClientState($clientId, true); - - if ($state) { - $state->isInitialized = true; - - if ($this->saveClientState($clientId, $state)) { - $this->updateGlobalActiveClientTimestamp($clientId); - } - } else { - $this->logger->error('Failed to get/create state to mark client as initialized.', ['clientId' => $clientId]); - } - } - - /** - * Stores client information. - */ - public function storeClientInfo(array $clientInfo, string $protocolVersion, string $clientId): void - { - $state = $this->getClientState($clientId, true); - - if ($state) { - $state->clientInfo = $clientInfo; - $state->protocolVersion = $protocolVersion; - $this->saveClientState($clientId, $state); - } - } - - /** - * Gets client information. - */ - public function getClientInfo(string $clientId): ?array - { - return $this->getClientState($clientId)?->clientInfo; - } - - /** - * Gets the protocol version for a client. - */ - public function getProtocolVersion(string $clientId): ?string - { - return $this->getClientState($clientId)?->protocolVersion; - } - - /** - * Adds a resource subscription for a client. - */ - public function addResourceSubscription(string $clientId, string $uri): void - { - $clientState = $this->getClientState($clientId, true); - if (! $clientState) { - $this->logger->error('Failed to get/create client state for subscription.', ['clientId' => $clientId, 'uri' => $uri]); - - return; - } - - $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); - - try { - $clientState->addSubscription($uri); - $this->saveClientState($clientId, $clientState); - - $subscribers = $this->cache->get($resourceSubKey, []); - $subscribers = is_array($subscribers) ? $subscribers : []; - $subscribers[$clientId] = true; - $this->cache->set($resourceSubKey, $subscribers, $this->cacheTtl); - - $this->logger->debug('Client subscribed to resource.', ['clientId' => $clientId, 'uri' => $uri]); - } catch (Throwable $e) { - $this->logger->error('Failed to add resource subscription.', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); - } - } - - /** - * Removes a resource subscription for a client. - */ - public function removeResourceSubscription(string $clientId, string $uri): void - { - $clientState = $this->getClientState($clientId); - $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); - - try { - if ($clientState) { - $clientState->removeSubscription($uri); - $this->saveClientState($clientId, $clientState); - } - - $subscribers = $this->cache->get($resourceSubKey, []); - $subscribers = is_array($subscribers) ? $subscribers : []; - $changed = false; - - if (isset($subscribers[$clientId])) { - unset($subscribers[$clientId]); - $changed = true; - } - - if ($changed) { - if (empty($subscribers)) { - $this->cache->delete($resourceSubKey); - } else { - $this->cache->set($resourceSubKey, $subscribers, $this->cacheTtl); - } - $this->logger->debug('Client unsubscribed from resource.', ['clientId' => $clientId, 'uri' => $uri]); - } - } catch (Throwable $e) { - $this->logger->error('Failed to remove resource subscription.', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); - } - } - - /** - * Removes all resource subscriptions for a client. - */ - public function removeAllResourceSubscriptions(string $clientId): void - { - $clientState = $this->getClientState($clientId); - if (! $clientState || empty($clientState->subscriptions)) { - return; - } - - $urisClientWasSubscribedTo = array_keys($clientState->subscriptions); - - try { - $clientState->clearSubscriptions(); - $this->saveClientState($clientId, $clientState); - - foreach ($urisClientWasSubscribedTo as $uri) { - $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); - $subscribers = $this->cache->get($resourceSubKey, []); - $subscribers = is_array($subscribers) ? $subscribers : []; - if (isset($subscribers[$clientId])) { - unset($subscribers[$clientId]); - if (empty($subscribers)) { - $this->cache->delete($resourceSubKey); - } else { - $this->cache->set($resourceSubKey, $subscribers, $this->cacheTtl); - } - } - } - $this->logger->debug('Client removed all resource subscriptions.', ['clientId' => $clientId, 'count' => count($urisClientWasSubscribedTo)]); - } catch (Throwable $e) { - $this->logger->error('Failed to remove all resource subscriptions.', ['clientId' => $clientId, 'exception' => $e]); - } - } - - /** - * Gets the client IDs subscribed to a resource. - * - * @return string[] Client IDs subscribed to the URI - */ - public function getResourceSubscribers(string $uri): array - { - $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); - try { - $subscribers = $this->cache->get($resourceSubKey, []); - - return is_array($subscribers) ? array_keys($subscribers) : []; - } catch (Throwable $e) { - $this->logger->error('Failed to get resource subscribers.', ['uri' => $uri, 'exception' => $e]); - - return []; - } - } - - /** - * Checks if a client is subscribed to a resource. - */ - public function isSubscribedToResource(string $clientId, string $uri): bool - { - $resourceSubKey = $this->getResourceSubscribersCacheKey($uri); - - try { - $subscribers = $this->cache->get($resourceSubKey, []); - - return is_array($subscribers) && isset($subscribers[$clientId]); - } catch (Throwable $e) { - $this->logger->error('Failed to check resource subscription.', ['clientId' => $clientId, 'uri' => $uri, 'exception' => $e]); - - return false; - } - } - - /** - * Queues a message for a client. - */ - public function queueMessage(string $clientId, string $message): void - { - $state = $this->getClientState($clientId, true); - if (! $state) { - return; - } - - $state->addMessageToQueue($message); - $this->saveClientState($clientId, $state); - } - - /** - * Queues a message for all active clients. - */ - public function queueMessageForAll(string $message): void - { - $clients = $this->getActiveClients(); - - foreach ($clients as $clientId) { - $this->queueMessage($clientId, $message); - } - } - - /** - * Gets the queued messages for a client. - * - * @return array Queued messages - */ - public function getQueuedMessages(string $clientId): array - { - $state = $this->getClientState($clientId); - if (! $state) { - return []; - } - - $messages = $state->consumeMessageQueue(); - if (! empty($messages)) { - $this->saveClientState($clientId, $state); - } - - return $messages; - } - - /** - * Sets the requested log level for a specific client. - * This preference is stored in the client's state. - * - * @param string $clientId The ID of the client. - * @param string $level The PSR-3 log level string (e.g., 'debug', 'info'). - */ - public function setClientRequestedLogLevel(string $clientId, string $level): void - { - $state = $this->getClientState($clientId, true); - if (! $state) { - $this->logger->error('Failed to get/create state to set log level.', ['clientId' => $clientId, 'level' => $level]); - - return; - } - - $state->requestedLogLevel = strtolower($level); - $this->saveClientState($clientId, $state); - } - - /** - * Gets the client-requested log level. - * Returns null if the client hasn't set a specific level, implying server default should be used. - * - * @param string $clientId The ID of the client. - * @return string|null The PSR-3 log level string or null. - */ - public function getClientRequestedLogLevel(string $clientId): ?string - { - return $this->getClientState($clientId)?->requestedLogLevel; - } - - /** - * Cleans up a client's state. - */ - public function cleanupClient(string $clientId, bool $removeFromActiveList = true): void - { - $this->removeAllResourceSubscriptions($clientId); - - $clientStateKey = $this->getClientStateCacheKey($clientId); - try { - $this->cache->delete($clientStateKey); - } catch (Throwable $e) { - $this->logger->error('Failed to delete client state object.', ['clientId' => $clientId, 'key' => $clientStateKey, 'exception' => $e]); - } - - if ($removeFromActiveList) { - $activeClientsKey = $this->getActiveClientsCacheKey(); - try { - $activeClients = $this->cache->get($activeClientsKey, []); - $activeClients = is_array($activeClients) ? $activeClients : []; - if (isset($activeClients[$clientId])) { - unset($activeClients[$clientId]); - $this->cache->set($activeClientsKey, $activeClients, $this->cacheTtl); - } - } catch (Throwable $e) { - $this->logger->error('Failed to update global active clients list during cleanup.', ['clientId' => $clientId, 'exception' => $e]); - } - } - $this->logger->info('Client state cleaned up.', ['client_id' => $clientId]); - } - - /** - * Updates the global active client list with current timestamp - */ - private function updateGlobalActiveClientTimestamp(string $clientId): void - { - try { - $key = $this->getActiveClientsCacheKey(); - $activeClients = $this->cache->get($key, []); - $activeClients = is_array($activeClients) ? $activeClients : []; - $activeClients[$clientId] = time(); - $this->cache->set($key, $activeClients, $this->cacheTtl); - } catch (Throwable $e) { - $this->logger->error('Failed to update global active client timestamp.', ['clientId' => $clientId, 'exception' => $e]); - } - } - - /** - * Updates client's own lastActivityTimestamp AND the global list - */ - public function updateClientActivity(string $clientId): void - { - $state = $this->getClientState($clientId, true); - if ($state) { - if (! $this->saveClientState($clientId, $state)) { - $this->logger->warning('Failed to save client state after updating activity.', ['clientId' => $clientId]); - } - } - $this->updateGlobalActiveClientTimestamp($clientId); - } - - /** - * Gets the active clients from the global active list. - * - * @return string[] Client IDs from the global active list - */ - public function getActiveClients(int $inactiveThreshold = 300): array - { - try { - $activeClientsKey = $this->getActiveClientsCacheKey(); - $activeClientsData = $this->cache->get($activeClientsKey, []); - $activeClientsData = is_array($activeClientsData) ? $activeClientsData : []; - - $currentTime = time(); - $validActiveClientIds = []; - $clientsToCleanUp = []; - $listNeedsUpdateInCache = false; - - foreach ($activeClientsData as $id => $lastSeen) { - if (! is_string($id) || ! is_int($lastSeen)) { // Sanity check entry - $clientsToCleanUp[] = $id; - $listNeedsUpdateInCache = true; - - continue; - } - if ($currentTime - $lastSeen < $inactiveThreshold) { - $validActiveClientIds[] = $id; - } else { - $clientsToCleanUp[] = $id; - $listNeedsUpdateInCache = true; - } - } - - if ($listNeedsUpdateInCache) { - $updatedList = $activeClientsData; - foreach ($clientsToCleanUp as $idToClean) { - unset($updatedList[$idToClean]); - } - $this->cache->set($activeClientsKey, $updatedList, $this->cacheTtl); - - foreach ($clientsToCleanUp as $idToClean) { - $this->cleanupClient($idToClean, false); // false: already handled active list - } - } - - return $validActiveClientIds; - } catch (Throwable $e) { - $this->logger->error('Failed to get active clients.', ['exception' => $e]); - - return []; - } - } - - /** - * Retrieves the last activity timestamp from the global list. - */ - public function getLastActivityTime(string $clientId): ?int - { - try { - $activeClientsKey = $this->getActiveClientsCacheKey(); - $activeClients = $this->cache->get($activeClientsKey, []); - $activeClients = is_array($activeClients) ? $activeClients : []; - $lastSeen = $activeClients[$clientId] ?? null; - - return is_int($lastSeen) ? $lastSeen : null; - } catch (Throwable $e) { - $this->logger->error('Failed to get last activity time.', ['clientId' => $clientId, 'exception' => $e]); - - return null; - } - } -} diff --git a/src/Support/ArgumentPreparer.php b/src/Support/ArgumentPreparer.php deleted file mode 100644 index 9cdcd26..0000000 --- a/src/Support/ArgumentPreparer.php +++ /dev/null @@ -1,201 +0,0 @@ -logger = $logger; - } - - /** - * Prepares the arguments array in the correct order for method invocation. - * - * @param object $instance The class instance where the method resides. - * @param string $methodName The name of the method to prepare arguments for. - * @param array $validatedInput Key-value array of validated input arguments. - * @param array $schema Optional JSON Schema (as array) for the input (currently unused here). - * @return list The ordered list of arguments for splat (...) operator or invokeArgs. - * - * @throws McpException If preparation fails (e.g., required arg missing, type casting fails). - * @throws ReflectionException If method/parameter reflection fails. - */ - public function prepareMethodArguments( - object $instance, - string $methodName, - array $validatedInput, - array $schema = [] - ): array { - if (! method_exists($instance, $methodName)) { - throw new ReflectionException('Method does not exist: '.get_class($instance)."::{$methodName}"); - } - - $reflectionMethod = new ReflectionMethod($instance, $methodName); - $finalArgs = []; - - foreach ($reflectionMethod->getParameters() as $reflectionParameter) { - $paramName = $reflectionParameter->getName(); - $paramPosition = $reflectionParameter->getPosition(); - - if (isset($validatedInput[$paramName])) { - $inputValue = $validatedInput[$paramName]; - try { - $finalArgs[$paramPosition] = $this->castArgumentType($inputValue, $reflectionParameter); - } catch (InvalidArgumentException $e) { - throw McpException::invalidParams($e->getMessage(), $e); - } catch (Throwable $e) { - // Catch other unexpected casting errors - throw McpException::internalError( - "Error processing parameter `{$paramName}`: {$e->getMessage()}", - $e - ); - } - } elseif ($reflectionParameter->isDefaultValueAvailable()) { - $finalArgs[$paramPosition] = $reflectionParameter->getDefaultValue(); - } elseif ($reflectionParameter->allowsNull()) { - $finalArgs[$paramPosition] = null; - } elseif ($reflectionParameter->isOptional()) { - continue; - } else { - // If this happens, it's likely a mismatch between schema validation and reflection - $this->logger->error("Invariant violation: Missing required argument `{$paramName}` for {$reflectionMethod->class}::{$methodName} despite passing schema validation.", [ - 'method' => $methodName, - 'parameter' => $paramName, - 'validated_input_keys' => array_keys($validatedInput), - 'schema' => $schema, // Log schema for debugging - ]); - throw McpException::internalError( - "Missing required argument `{$paramName}` for {$reflectionMethod->class}::{$methodName}." - ); - } - } - return array_values($finalArgs); - } - - /** - * Attempts type casting based on ReflectionParameter type hints. - * - * @throws InvalidArgumentException If casting is impossible for the required type. - * @throws TypeError If internal PHP casting fails unexpectedly. - */ - private function castArgumentType(mixed $value, ReflectionParameter $rp): mixed - { - $type = $rp->getType(); - - if ($value === null) { - if ($type && $type->allowsNull()) { - return null; - } - } - - if (! $type instanceof ReflectionNamedType) { - return $value; - } - - $typeName = $type->getName(); - - // --- Handle Backed Enum --- - if (enum_exists($typeName) && is_subclass_of($typeName, \BackedEnum::class)) { - try { - return $typeName::from($value); - } catch (\ValueError $e) { - // Provide a more specific error message - $valueStr = is_scalar($value) ? strval($value) : gettype($value); - throw new InvalidArgumentException( - "Invalid value '{$valueStr}' for enum {$typeName}.", - 0, - $e - ); - } - } - // --- End Enum Handling --- - - // --- Handle Scalar Types --- - try { - return match (strtolower($typeName)) { - 'int', 'integer' => $this->castToInt($value), - 'string' => (string) $value, - 'bool', 'boolean' => $this->castToBoolean($value), - 'float', 'double' => $this->castToFloat($value), - 'array' => $this->castToArray($value), - default => $value, - }; - } catch (TypeError $e) { - throw new InvalidArgumentException( - "Value cannot be cast to required type `{$typeName}`.", - 0, - $e - ); - } - } - - /** Helper to cast strictly to boolean */ - private function castToBoolean(mixed $value): bool - { - if (is_bool($value)) { - return $value; - } - if ($value === 1 || $value === '1' || strtolower((string) $value) === 'true') { - return true; - } - if ($value === 0 || $value === '0' || strtolower((string) $value) === 'false') { - return false; - } - throw new InvalidArgumentException('Cannot cast value to boolean. Use true/false/1/0.'); - } - - /** Helper to cast strictly to integer */ - private function castToInt(mixed $value): int - { - if (is_int($value)) { - return $value; - } - if (is_numeric($value) && floor((float) $value) == $value && ! is_string($value)) { - return (int) $value; - } - if (is_string($value) && ctype_digit(ltrim($value, '-'))) { - return (int) $value; - } - throw new InvalidArgumentException('Cannot cast value to integer. Expected integer representation.'); - } - - /** Helper to cast strictly to float */ - private function castToFloat(mixed $value): float - { - if (is_float($value)) { - return $value; - } - if (is_int($value)) { - return (float) $value; - } - if (is_numeric($value)) { - return (float) $value; - } - throw new InvalidArgumentException('Cannot cast value to float. Expected numeric representation.'); - } - - /** Helper to cast strictly to array */ - private function castToArray(mixed $value): array - { - if (is_array($value)) { - return $value; - } - throw new InvalidArgumentException('Cannot cast value to array. Expected array.'); - } -} diff --git a/src/Support/AttributeFinder.php b/src/Support/AttributeFinder.php deleted file mode 100644 index 13f6684..0000000 --- a/src/Support/AttributeFinder.php +++ /dev/null @@ -1,139 +0,0 @@ - $attributeClass The class name of the attribute to find. - * @return array> An array of ReflectionAttribute instances. - */ - public function getClassAttributes(ReflectionClass $reflectionClass, string $attributeClass): array - { - return $reflectionClass->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF); - } - - /** - * Get the first attribute of a specific type from a class. - * - * @template T of object - * - * @param ReflectionClass $reflectionClass The reflection class. - * @param class-string $attributeClass The class name of the attribute to find. - * @return ReflectionAttribute|null The first matching ReflectionAttribute instance or null. - */ - public function getFirstClassAttribute(ReflectionClass $reflectionClass, string $attributeClass): ?ReflectionAttribute - { - $attributes = $this->getClassAttributes($reflectionClass, $attributeClass); - - return $attributes[0] ?? null; - } - - /** - * Get all attributes of a specific type from a method. - * - * @template T of object - * - * @param ReflectionMethod $reflectionMethod The reflection method. - * @param class-string $attributeClass The class name of the attribute to find. - * @return array> An array of ReflectionAttribute instances. - */ - public function getMethodAttributes(ReflectionMethod $reflectionMethod, string $attributeClass): array - { - return $reflectionMethod->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF); - } - - /** - * Get the first attribute of a specific type from a method. - * - * @template T of object - * - * @param ReflectionMethod $reflectionMethod The reflection method. - * @param class-string $attributeClass The class name of the attribute to find. - * @return ReflectionAttribute|null The first matching ReflectionAttribute instance or null. - */ - public function getFirstMethodAttribute(ReflectionMethod $reflectionMethod, string $attributeClass): ?ReflectionAttribute - { - $attributes = $this->getMethodAttributes($reflectionMethod, $attributeClass); - - return $attributes[0] ?? null; - } - - /** - * Get all attributes of a specific type from a property. - * - * @template T of object - * - * @param ReflectionProperty $reflectionProperty The reflection property. - * @param class-string $attributeClass The class name of the attribute to find. - * @return array> An array of ReflectionAttribute instances. - */ - public function getPropertyAttributes(ReflectionProperty $reflectionProperty, string $attributeClass): array - { - return $reflectionProperty->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF); - } - - /** - * Get the first attribute of a specific type from a property. - * - * @template T of object - * - * @param ReflectionProperty $reflectionProperty The reflection property. - * @param class-string $attributeClass The class name of the attribute to find. - * @return ReflectionAttribute|null The first matching ReflectionAttribute instance or null. - */ - public function getFirstPropertyAttribute(ReflectionProperty $reflectionProperty, string $attributeClass): ?ReflectionAttribute - { - $attributes = $this->getPropertyAttributes($reflectionProperty, $attributeClass); - - return $attributes[0] ?? null; - } - - /** - * Get all attributes of a specific type from a parameter. - * - * @template T of object - * - * @param ReflectionParameter $reflectionParameter The reflection parameter. - * @param class-string $attributeClass The class name of the attribute to find. - * @return array> An array of ReflectionAttribute instances. - */ - public function getParameterAttributes(ReflectionParameter $reflectionParameter, string $attributeClass): array - { - return $reflectionParameter->getAttributes($attributeClass, ReflectionAttribute::IS_INSTANCEOF); - } - - /** - * Get the first attribute of a specific type from a parameter. - * - * @template T of object - * - * @param ReflectionParameter $reflectionParameter The reflection parameter. - * @param class-string $attributeClass The class name of the attribute to find. - * @return ReflectionAttribute|null The first matching ReflectionAttribute instance or null. - */ - public function getFirstParameterAttribute(ReflectionParameter $reflectionParameter, string $attributeClass): ?ReflectionAttribute - { - $attributes = $this->getParameterAttributes($reflectionParameter, $attributeClass); - - return $attributes[0] ?? null; - } -} diff --git a/src/Support/Discoverer.php b/src/Support/Discoverer.php index d34b60e..98dfa15 100644 --- a/src/Support/Discoverer.php +++ b/src/Support/Discoverer.php @@ -4,6 +4,11 @@ namespace PhpMcp\Server\Support; +use PhpMcp\Schema\Prompt; +use PhpMcp\Schema\PromptArgument; +use PhpMcp\Schema\Resource; +use PhpMcp\Schema\ResourceTemplate; +use PhpMcp\Schema\Tool; use PhpMcp\Server\Attributes\McpPrompt; use PhpMcp\Server\Attributes\McpResource; use PhpMcp\Server\Attributes\McpResourceTemplate; @@ -13,6 +18,7 @@ use PhpMcp\Server\Definitions\ResourceTemplateDefinition; use PhpMcp\Server\Definitions\ToolDefinition; use PhpMcp\Server\Exception\McpServerException; +use PhpMcp\Server\Handler; use PhpMcp\Server\Registry; use Psr\Log\LoggerInterface; use ReflectionAttribute; @@ -25,8 +31,6 @@ class Discoverer { - private AttributeFinder $attributeFinder; - private DocBlockParser $docBlockParser; private SchemaGenerator $schemaGenerator; @@ -36,9 +40,7 @@ public function __construct( private LoggerInterface $logger, ?DocBlockParser $docBlockParser = null, ?SchemaGenerator $schemaGenerator = null, - ?AttributeFinder $attributeFinder = null ) { - $this->attributeFinder = $attributeFinder ?? new AttributeFinder(); $this->docBlockParser = $docBlockParser ?? new DocBlockParser($this->logger); $this->schemaGenerator = $schemaGenerator ?? new SchemaGenerator($this->docBlockParser); } @@ -92,7 +94,7 @@ public function discover(string $basePath, array $directories, array $excludeDir } $duration = microtime(true) - $startTime; - $this->logger->info('MCP: Attribute discovery finished.', [ + $this->logger->info('Attribute discovery finished.', [ 'duration_sec' => round($duration, 3), 'tools' => $discoveredCount['tools'], 'resources' => $discoveredCount['resources'], @@ -133,10 +135,7 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void if ($invokeMethod->isPublic() && ! $invokeMethod->isStatic()) { $attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class]; foreach ($attributeTypes as $attributeType) { - $classAttribute = $this->attributeFinder->getFirstClassAttribute( - $reflectionClass, - $attributeType - ); + $classAttribute = $reflectionClass->getAttributes($attributeType, ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; if ($classAttribute) { $this->processMethod($invokeMethod, $discoveredCount, $classAttribute); $processedViaClassAttribute = true; @@ -156,10 +155,7 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void } $attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class]; foreach ($attributeTypes as $attributeType) { - $methodAttribute = $this->attributeFinder->getFirstMethodAttribute( - $method, - $attributeType - ); + $methodAttribute = $method->getAttributes($attributeType, ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; if ($methodAttribute) { $this->processMethod($method, $discoveredCount, $methodAttribute); break; @@ -190,6 +186,7 @@ private function processFile(SplFileInfo $file, array &$discoveredCount): void private function processMethod(ReflectionMethod $method, array &$discoveredCount, ReflectionAttribute $attribute): void { $className = $method->getDeclaringClass()->getName(); + $classShortName = $method->getDeclaringClass()->getShortName(); $methodName = $method->getName(); $attributeClassName = $attribute->getName(); @@ -198,61 +195,58 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount switch ($attributeClassName) { case McpTool::class: - $definition = ToolDefinition::fromReflection( - $method, - $instance->name, - $instance->description, - $instance->annotations, - $this->docBlockParser, - $this->schemaGenerator - ); - $this->registry->registerTool($definition); + $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); + $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; + $inputSchema = $this->schemaGenerator->fromMethodParameters($method); + $tool = Tool::make($name, $inputSchema, $description, $instance->annotations); + $invoker = new MethodInvoker($className, $methodName); + $this->registry->registerTool($tool, $invoker, true); $discoveredCount['tools']++; break; case McpResource::class: - if (! isset($instance->uri)) { - throw new McpServerException("McpResource attribute on {$className}::{$methodName} requires a 'uri'."); - } - $definition = ResourceDefinition::fromReflection( - $method, - $instance->name, - $instance->description, - $instance->uri, - $instance->mimeType, - $instance->annotations, - $instance->size, - $this->docBlockParser - ); - $this->registry->registerResource($definition); + $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); + $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; + $mimeType = $instance->mimeType; + $size = $instance->size; + $annotations = $instance->annotations; + $resource = Resource::make($instance->uri, $name, $description, $mimeType, $annotations, $size); + $invoker = new MethodInvoker($className, $methodName); + $this->registry->registerResource($resource, $invoker, true); $discoveredCount['resources']++; break; case McpPrompt::class: - $definition = PromptDefinition::fromReflection( - $method, - $instance->name, - $instance->description, - $this->docBlockParser - ); - $this->registry->registerPrompt($definition); + $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); + $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; + $arguments = []; + $paramTags = $this->docBlockParser->getParamTags($docBlock); + foreach ($method->getParameters() as $param) { + $reflectionType = $param->getType(); + if ($reflectionType instanceof \ReflectionNamedType && ! $reflectionType->isBuiltin()) { + continue; + } + $paramTag = $paramTags['$' . $param->getName()] ?? null; + $arguments[] = PromptArgument::make($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, ! $param->isOptional() && ! $param->isDefaultValueAvailable()); + } + $prompt = Prompt::make($name, $description, $arguments); + $invoker = new MethodInvoker($className, $methodName); + $this->registry->registerPrompt($prompt, $invoker, true); $discoveredCount['prompts']++; break; case McpResourceTemplate::class: - if (! isset($instance->uriTemplate)) { - throw new McpServerException("McpResourceTemplate attribute on {$className}::{$methodName} requires a 'uriTemplate'."); - } - $definition = ResourceTemplateDefinition::fromReflection( - $method, - $instance->name, - $instance->description, - $instance->uriTemplate, - $instance->mimeType, - $instance->annotations, - $this->docBlockParser - ); - $this->registry->registerResourceTemplate($definition); + $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); + $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName); + $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; + $mimeType = $instance->mimeType; + $annotations = $instance->annotations; + $resourceTemplate = ResourceTemplate::make($instance->uriTemplate, $name, $description, $mimeType, $annotations); + $invoker = new MethodInvoker($className, $methodName); + $this->registry->registerResourceTemplate($resourceTemplate, $invoker, true); $discoveredCount['resourceTemplates']++; break; } diff --git a/src/Support/HandlerResolver.php b/src/Support/HandlerResolver.php index 225b458..0d9feb5 100644 --- a/src/Support/HandlerResolver.php +++ b/src/Support/HandlerResolver.php @@ -21,13 +21,12 @@ class HandlerResolver * - A string: InvokableClassName::class (which will resolve to its '__invoke' method) * * @param array|string $handler The handler to resolve. - * @return array{className: class-string, methodName: string, reflectionMethod: ReflectionMethod} - * An associative array containing 'className', 'methodName', and 'reflectionMethod'. + * @return ReflectionMethod * * @throws InvalidArgumentException If the handler format is invalid, the class/method doesn't exist, * or the method is unsuitable (e.g., static, private, abstract). */ - public static function resolve(array|string $handler): array + public static function resolve(array|string $handler): ReflectionMethod { $className = null; $methodName = null; @@ -69,11 +68,7 @@ public static function resolve(array|string $handler): array throw new InvalidArgumentException("Handler method '{$className}::{$methodName}' cannot be a constructor or destructor."); } - return [ - 'className' => $className, - 'methodName' => $methodName, - 'reflectionMethod' => $reflectionMethod, - ]; + return $reflectionMethod; } catch (ReflectionException $e) { // This typically occurs if class_exists passed but ReflectionMethod still fails (rare) throw new InvalidArgumentException("Reflection error for handler '{$className}::{$methodName}': {$e->getMessage()}", 0, $e); diff --git a/src/Support/MethodInvoker.php b/src/Support/MethodInvoker.php new file mode 100644 index 0000000..1e01da9 --- /dev/null +++ b/src/Support/MethodInvoker.php @@ -0,0 +1,194 @@ +get($this->className); + $arguments = $this->prepareArguments($instance, $arguments); + $method = $this->methodName; + + return $instance->$method(...$arguments); + } + + private function prepareArguments(object $instance, array $arguments): array + { + if (! method_exists($instance, $this->methodName)) { + throw new ReflectionException("Method does not exist: {$this->className}::{$this->methodName}"); + } + + $reflectionMethod = new ReflectionMethod($instance, $this->methodName); + + $finalArgs = []; + + foreach ($reflectionMethod->getParameters() as $parameter) { + $paramName = $parameter->getName(); + $paramPosition = $parameter->getPosition(); + + if (isset($arguments[$paramName])) { + $argument = $arguments[$paramName]; + try { + $finalArgs[$paramPosition] = $this->castArgumentType($argument, $parameter); + } catch (InvalidArgumentException $e) { + throw McpServerException::invalidParams($e->getMessage(), $e); + } catch (Throwable $e) { + throw McpServerException::internalError( + "Error processing parameter `{$paramName}`: {$e->getMessage()}", + $e + ); + } + } elseif ($parameter->isDefaultValueAvailable()) { + $finalArgs[$paramPosition] = $parameter->getDefaultValue(); + } elseif ($parameter->allowsNull()) { + $finalArgs[$paramPosition] = null; + } elseif ($parameter->isOptional()) { + continue; + } else { + throw McpServerException::internalError( + "Missing required argument `{$paramName}` for {$reflectionMethod->class}::{$this->methodName}." + ); + } + } + + return array_values($finalArgs); + } + + public static function fromArray(array $data): self + { + return new self($data['className'], $data['methodName']); + } + + public function toArray(): array + { + return [ + 'className' => $this->className, + 'methodName' => $this->methodName, + ]; + } + + /** + * Attempts type casting based on ReflectionParameter type hints. + * + * @throws InvalidArgumentException If casting is impossible for the required type. + * @throws TypeError If internal PHP casting fails unexpectedly. + */ + private function castArgumentType(mixed $argument, ReflectionParameter $parameter): mixed + { + $type = $parameter->getType(); + + if ($argument === null) { + if ($type && $type->allowsNull()) { + return null; + } + } + + if (! $type instanceof ReflectionNamedType) { + return $argument; + } + + $typeName = $type->getName(); + + if (enum_exists($typeName) && is_subclass_of($typeName, \BackedEnum::class)) { + try { + return $typeName::from($argument); + } catch (\ValueError $e) { + $valueStr = is_scalar($argument) ? strval($argument) : gettype($argument); + throw new InvalidArgumentException( + "Invalid value '{$valueStr}' for enum {$typeName}.", + 0, + $e + ); + } + } + + try { + return match (strtolower($typeName)) { + 'int', 'integer' => $this->castToInt($argument), + 'string' => (string) $argument, + 'bool', 'boolean' => $this->castToBoolean($argument), + 'float', 'double' => $this->castToFloat($argument), + 'array' => $this->castToArray($argument), + default => $argument, + }; + } catch (TypeError $e) { + throw new InvalidArgumentException( + "Value cannot be cast to required type `{$typeName}`.", + 0, + $e + ); + } + } + + /** Helper to cast strictly to boolean */ + private function castToBoolean(mixed $argument): bool + { + if (is_bool($argument)) { + return $argument; + } + if ($argument === 1 || $argument === '1' || strtolower((string) $argument) === 'true') { + return true; + } + if ($argument === 0 || $argument === '0' || strtolower((string) $argument) === 'false') { + return false; + } + throw new InvalidArgumentException('Cannot cast value to boolean. Use true/false/1/0.'); + } + + /** Helper to cast strictly to integer */ + private function castToInt(mixed $argument): int + { + if (is_int($argument)) { + return $argument; + } + if (is_numeric($argument) && floor((float) $argument) == $argument && ! is_string($argument)) { + return (int) $argument; + } + if (is_string($argument) && ctype_digit(ltrim($argument, '-'))) { + return (int) $argument; + } + throw new InvalidArgumentException('Cannot cast value to integer. Expected integer representation.'); + } + + /** Helper to cast strictly to float */ + private function castToFloat(mixed $argument): float + { + if (is_float($argument)) { + return $argument; + } + if (is_int($argument)) { + return (float) $argument; + } + if (is_numeric($argument)) { + return (float) $argument; + } + throw new InvalidArgumentException('Cannot cast value to float. Expected numeric representation.'); + } + + /** Helper to cast strictly to array */ + private function castToArray(mixed $argument): array + { + if (is_array($argument)) { + return $argument; + } + throw new InvalidArgumentException('Cannot cast value to array. Expected array.'); + } +} diff --git a/src/Support/RequestHandler.php b/src/Support/RequestHandler.php index 5b74459..c1f1c63 100644 --- a/src/Support/RequestHandler.php +++ b/src/Support/RequestHandler.php @@ -5,29 +5,43 @@ namespace PhpMcp\Server\Support; use JsonException; +use PhpMcp\Schema\JsonRpc\Request; +use PhpMcp\Schema\JsonRpc\Notification; +use PhpMcp\Schema\JsonRpc\Result; +use PhpMcp\Schema\Notification\InitializedNotification; +use PhpMcp\Schema\Request\CallToolRequest; +use PhpMcp\Schema\Request\CompletionCompleteRequest; +use PhpMcp\Schema\Request\GetPromptRequest; +use PhpMcp\Schema\Request\InitializeRequest; +use PhpMcp\Schema\Request\ListPromptsRequest; +use PhpMcp\Schema\Request\ListResourcesRequest; +use PhpMcp\Schema\Request\ListResourceTemplatesRequest; +use PhpMcp\Schema\Request\ListToolsRequest; +use PhpMcp\Schema\Request\PingRequest; +use PhpMcp\Schema\Request\ReadResourceRequest; +use PhpMcp\Schema\Request\ResourceSubscribeRequest; +use PhpMcp\Schema\Request\ResourceUnsubscribeRequest; +use PhpMcp\Schema\Request\SetLogLevelRequest; use PhpMcp\Server\Configuration; use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Server\JsonRpc\Contents\TextContent; -use PhpMcp\Server\JsonRpc\Results\CallToolResult; -use PhpMcp\Server\JsonRpc\Results\CompletionCompleteResult; -use PhpMcp\Server\JsonRpc\Results\EmptyResult; -use PhpMcp\Server\JsonRpc\Results\GetPromptResult; -use PhpMcp\Server\JsonRpc\Results\InitializeResult; -use PhpMcp\Server\JsonRpc\Results\ListPromptsResult; -use PhpMcp\Server\JsonRpc\Results\ListResourcesResult; -use PhpMcp\Server\JsonRpc\Results\ListResourceTemplatesResult; -use PhpMcp\Server\JsonRpc\Results\ListToolsResult; -use PhpMcp\Server\JsonRpc\Results\ReadResourceResult; +use PhpMcp\Schema\Result\CallToolResult; +use PhpMcp\Schema\Result\CompletionCompleteResult; +use PhpMcp\Schema\Result\EmptyResult; +use PhpMcp\Schema\Result\GetPromptResult; +use PhpMcp\Schema\Result\InitializeResult; +use PhpMcp\Schema\Result\ListPromptsResult; +use PhpMcp\Schema\Result\ListResourcesResult; +use PhpMcp\Schema\Result\ListResourceTemplatesResult; +use PhpMcp\Schema\Result\ListToolsResult; +use PhpMcp\Schema\Result\ReadResourceResult; use PhpMcp\Server\Protocol; use PhpMcp\Server\Registry; -use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Session\SubscriptionManager; use PhpMcp\Server\Traits\ResponseFormatter; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; -use Psr\Log\LogLevel; -use stdClass; use Throwable; class RequestHandler @@ -42,60 +56,97 @@ public function __construct( protected Registry $registry, protected SubscriptionManager $subscriptionManager, protected ?SchemaValidator $schemaValidator = null, - protected ?ArgumentPreparer $argumentPreparer = null, ) { $this->container = $this->configuration->container; $this->logger = $this->configuration->logger; $this->schemaValidator ??= new SchemaValidator($this->logger); - $this->argumentPreparer ??= new ArgumentPreparer($this->logger); } - public function handleInitialize(array $params, SessionInterface $session): InitializeResult + public function handleRequest(Request $request, SessionInterface $session): Result { - $protocolVersion = $params['protocolVersion'] ?? null; - if (! $protocolVersion) { - throw McpServerException::invalidParams("Missing 'protocolVersion' parameter."); + switch ($request->method) { + case 'initialize': + $request = InitializeRequest::fromRequest($request); + return $this->handleInitialize($request, $session); + case 'ping': + $request = PingRequest::fromRequest($request); + return $this->handlePing($request); + case 'tools/list': + $request = ListToolsRequest::fromRequest($request); + return $this->handleToolList($request); + case 'tools/call': + $request = CallToolRequest::fromRequest($request); + return $this->handleToolCall($request); + case 'resources/list': + $request = ListResourcesRequest::fromRequest($request); + return $this->handleResourcesList($request); + case 'resources/templates/list': + $request = ListResourceTemplatesRequest::fromRequest($request); + return $this->handleResourceTemplateList($request); + case 'resources/read': + $request = ReadResourceRequest::fromRequest($request); + return $this->handleResourceRead($request); + case 'resources/subscribe': + $request = ResourceSubscribeRequest::fromRequest($request); + return $this->handleResourceSubscribe($request, $session); + case 'resources/unsubscribe': + $request = ResourceUnsubscribeRequest::fromRequest($request); + return $this->handleResourceUnsubscribe($request, $session); + case 'prompts/list': + $request = ListPromptsRequest::fromRequest($request); + return $this->handlePromptsList($request); + case 'prompts/get': + $request = GetPromptRequest::fromRequest($request); + return $this->handlePromptGet($request); + case 'logging/setLevel': + $request = SetLogLevelRequest::fromRequest($request); + return $this->handleLoggingSetLevel($request, $session); + case 'completion/complete': + $request = CompletionCompleteRequest::fromRequest($request); + return $this->handleCompletionComplete($request, $session); + default: + throw McpServerException::methodNotFound("Method '{$request->method}' not found."); } + } - if (! in_array($protocolVersion, Protocol::SUPPORTED_PROTOCOL_VERSIONS)) { - $this->logger->warning("Unsupported protocol version: {$protocolVersion}", [ - 'supportedVersions' => Protocol::SUPPORTED_PROTOCOL_VERSIONS, - ]); + public function handleNotification(Notification $notification, SessionInterface $session): void + { + switch ($notification->method) { + case 'notifications/initialized': + $notification = InitializedNotification::fromNotification($notification); + $this->handleNotificationInitialized($notification, $session); } + } - $serverProtocolVersion = Protocol::LATEST_PROTOCOL_VERSION; - - $clientInfo = $params['clientInfo'] ?? null; - if (! is_array($clientInfo)) { - throw McpServerException::invalidParams("Missing or invalid 'clientInfo' parameter."); + public function handleInitialize(InitializeRequest $request, SessionInterface $session): InitializeResult + { + if (! in_array($request->protocolVersion, Protocol::SUPPORTED_PROTOCOL_VERSIONS)) { + $this->logger->warning("Unsupported protocol version: {$request->protocolVersion}", [ + 'supportedVersions' => Protocol::SUPPORTED_PROTOCOL_VERSIONS, + ]); } - $session->set('client_info', $clientInfo); + $protocolVersion = Protocol::LATEST_PROTOCOL_VERSION; - $serverInfo = [ - 'name' => $this->configuration->serverName, - 'version' => $this->configuration->serverVersion, - ]; + $session->set('client_info', $request->clientInfo); - $serverCapabilities = $this->configuration->capabilities; - $responseCapabilities = $serverCapabilities->toInitializeResponseArray(); - $instructions = $serverCapabilities->instructions; + $serverInfo = $this->configuration->serverInfo; + $capabilities = $this->configuration->capabilities; - return new InitializeResult($serverInfo, $serverProtocolVersion, $responseCapabilities, $instructions); + return new InitializeResult($protocolVersion, $capabilities, $serverInfo); } - public function handlePing(): EmptyResult + public function handlePing(PingRequest $request): EmptyResult { return new EmptyResult(); } - public function handleToolList(array $params): ListToolsResult + public function handleToolList(ListToolsRequest $request): ListToolsResult { - $cursor = $params['cursor'] ?? null; $limit = $this->configuration->paginationLimit; - $offset = $this->decodeCursor($cursor); + $offset = $this->decodeCursor($request->cursor); $allItems = $this->registry->getTools(); $pagedItems = array_slice($allItems, $offset, $limit); $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); @@ -103,63 +154,17 @@ public function handleToolList(array $params): ListToolsResult return new ListToolsResult(array_values($pagedItems), $nextCursor); } - public function handleResourcesList(array $params): ListResourcesResult - { - $cursor = $params['cursor'] ?? null; - $limit = $this->configuration->paginationLimit; - $offset = $this->decodeCursor($cursor); - $allItems = $this->registry->getResources(); - $pagedItems = array_slice($allItems, $offset, $limit); - $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); - - return new ListResourcesResult(array_values($pagedItems), $nextCursor); - } - - public function handleResourceTemplateList(array $params): ListResourceTemplatesResult - { - $cursor = $params['cursor'] ?? null; - $limit = $this->configuration->paginationLimit; - $offset = $this->decodeCursor($cursor); - $allItems = $this->registry->getResourceTemplates(); - $pagedItems = array_slice($allItems, $offset, $limit); - $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); - - return new ListResourceTemplatesResult(array_values($pagedItems), $nextCursor); - } - - public function handlePromptsList(array $params): ListPromptsResult + public function handleToolCall(CallToolRequest $request): CallToolResult { - $cursor = $params['cursor'] ?? null; - $limit = $this->configuration->paginationLimit; - $offset = $this->decodeCursor($cursor); - $allItems = $this->registry->getPrompts(); - $pagedItems = array_slice($allItems, $offset, $limit); - $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); + $toolName = $request->name; + $arguments = $request->arguments; - return new ListPromptsResult(array_values($pagedItems), $nextCursor); - } - - public function handleToolCall(array $params): CallToolResult - { - $toolName = $params['name'] ?? null; - $arguments = $params['arguments'] ?? null; - - if (! is_string($toolName) || empty($toolName)) { - throw McpServerException::invalidParams("Missing or invalid 'name' parameter for tools/call."); - } - - if ($arguments === null || $arguments === []) { - $arguments = new stdClass(); - } elseif (! is_array($arguments) && ! $arguments instanceof stdClass) { - throw McpServerException::invalidParams("Parameter 'arguments' must be an object/array for tools/call."); - } - - $definition = $this->registry->findTool($toolName); - if (! $definition) { + ['tool' => $tool, 'invoker' => $invoker] = $this->registry->getTool($toolName); + if (! $tool) { throw McpServerException::methodNotFound("Tool '{$toolName}' not found."); } - $inputSchema = $definition->inputSchema; + $inputSchema = $tool->inputSchema; $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($arguments, $inputSchema); @@ -181,21 +186,9 @@ public function handleToolCall(array $params): CallToolResult throw McpServerException::invalidParams($summaryMessage, data: ['validation_errors' => $validationErrors]); } - $argumentsForPhpCall = (array) $arguments; - try { - $instance = $this->container->get($definition->className); - $methodName = $definition->methodName; - - $args = $this->argumentPreparer->prepareMethodArguments( - $instance, - $methodName, - $argumentsForPhpCall, - $inputSchema - ); - - $toolExecutionResult = $instance->{$methodName}(...$args); - $formattedResult = $this->formatToolResult($toolExecutionResult); + $result = $invoker->invoke($this->container, $arguments); + $formattedResult = $this->formatToolResult($result); return new CallToolResult($formattedResult, false); } catch (JsonException $e) { @@ -211,43 +204,42 @@ public function handleToolCall(array $params): CallToolResult } } - public function handleResourceRead(array $params): ReadResourceResult + public function handleResourcesList(ListResourcesRequest $request): ListResourcesResult { - $uri = $params['uri'] ?? null; - if (! is_string($uri) || empty($uri)) { - throw McpServerException::invalidParams("Missing or invalid 'uri' parameter for resources/read."); - } + $limit = $this->configuration->paginationLimit; + $offset = $this->decodeCursor($request->cursor); + $allItems = $this->registry->getResources(); + $pagedItems = array_slice($allItems, $offset, $limit); + $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); - $definition = null; - $uriVariables = []; + return new ListResourcesResult(array_values($pagedItems), $nextCursor); + } - $definition = $this->registry->findResourceByUri($uri); + public function handleResourceTemplateList(ListResourceTemplatesRequest $request): ListResourceTemplatesResult + { + $limit = $this->configuration->paginationLimit; + $offset = $this->decodeCursor($request->cursor); + $allItems = $this->registry->getResourceTemplates(); + $pagedItems = array_slice($allItems, $offset, $limit); + $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); - if (! $definition) { - $templateResult = $this->registry->findResourceTemplateByUri($uri); - if ($templateResult) { - $definition = $templateResult['definition']; - $uriVariables = $templateResult['variables']; - } else { - throw McpServerException::invalidParams("Resource URI '{$uri}' not found or no handler available."); - } - } + return new ListResourceTemplatesResult(array_values($pagedItems), $nextCursor); + } - try { - $instance = $this->container->get($definition->className); - $methodName = $definition->methodName; + public function handleResourceRead(ReadResourceRequest $request): ReadResourceResult + { + $uri = $request->uri; - $methodParams = array_merge($uriVariables, ['uri' => $uri]); + ['resource' => $resource, 'invoker' => $invoker, 'variables' => $uriVariables] = $this->registry->getResource($uri); - $args = $this->argumentPreparer->prepareMethodArguments( - $instance, - $methodName, - $methodParams, - [] - ); + if (! $resource) { + throw McpServerException::invalidParams("Resource URI '{$uri}' not found."); + } - $readResult = $instance->{$methodName}(...$args); - $contents = $this->formatResourceContents($readResult, $uri, $definition->mimeType); + try { + $arguments = array_merge($uriVariables, ['uri' => $uri]); + $result = $invoker->invoke($this->container, $arguments); + $contents = $this->formatResourceContents($result, $uri, $resource->mimeType); return new ReadResourceResult($contents); } catch (JsonException $e) { @@ -261,70 +253,52 @@ public function handleResourceRead(array $params): ReadResourceResult } } - - public function handleResourceSubscribe(array $params, SessionInterface $session): EmptyResult + public function handleResourceSubscribe(ResourceSubscribeRequest $request, SessionInterface $session): EmptyResult { - $uri = $params['uri'] ?? null; - if (!is_string($uri) || empty($uri)) { - throw McpServerException::invalidParams("Missing or invalid 'uri' parameter"); - } - - $this->subscriptionManager->subscribe($session->getId(), $uri); + $this->subscriptionManager->subscribe($session->getId(), $request->uri); return new EmptyResult(); } - - public function handleResourceUnsubscribe(array $params, SessionInterface $session): EmptyResult + public function handleResourceUnsubscribe(ResourceUnsubscribeRequest $request, SessionInterface $session): EmptyResult { - $uri = $params['uri'] ?? null; - if (!is_string($uri) || empty($uri)) { - throw McpServerException::invalidParams("Missing or invalid 'uri' parameter"); - } - - $this->subscriptionManager->unsubscribe($session->getId(), $uri); + $this->subscriptionManager->unsubscribe($session->getId(), $request->uri); return new EmptyResult(); } - public function handlePromptGet(array $params): GetPromptResult + public function handlePromptsList(ListPromptsRequest $request): ListPromptsResult { - $promptName = $params['name'] ?? null; - $arguments = $params['arguments'] ?? []; + $limit = $this->configuration->paginationLimit; + $offset = $this->decodeCursor($request->cursor); + $allItems = $this->registry->getPrompts(); + $pagedItems = array_slice($allItems, $offset, $limit); + $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); - if (! is_string($promptName) || empty($promptName)) { - throw McpServerException::invalidParams("Missing or invalid 'name' parameter for prompts/get."); - } - if (! is_array($arguments) && ! $arguments instanceof stdClass) { - throw McpServerException::invalidParams("Parameter 'arguments' must be an object/array for prompts/get."); - } + return new ListPromptsResult(array_values($pagedItems), $nextCursor); + } + + public function handlePromptGet(GetPromptRequest $request): GetPromptResult + { + $promptName = $request->name; + $arguments = $request->arguments; - $definition = $this->registry->findPrompt($promptName); - if (! $definition) { + ['prompt' => $prompt, 'invoker' => $invoker] = $this->registry->getPrompt($promptName); + if (! $prompt) { throw McpServerException::invalidParams("Prompt '{$promptName}' not found."); } $arguments = (array) $arguments; - foreach ($definition->arguments as $argDef) { + foreach ($prompt->arguments as $argDef) { if ($argDef->required && ! array_key_exists($argDef->name, $arguments)) { throw McpServerException::invalidParams("Missing required argument '{$argDef->name}' for prompt '{$promptName}'."); } } try { - $instance = $this->container->get($definition->className); - $methodName = $definition->methodName; + $result = $invoker->invoke($this->container, $arguments); + $messages = $this->formatPromptMessages($result); - $args = $this->argumentPreparer->prepareMethodArguments( - $instance, - $methodName, - $arguments, - [] - ); - - $promptGenerationResult = $instance->{$methodName}(...$args); - $messages = $this->formatPromptMessages($promptGenerationResult); - - return new GetPromptResult($messages, $definition->description); + return new GetPromptResult($messages, $prompt->description); } catch (JsonException $e) { $this->logger->warning('MCP SDK: Failed to JSON encode prompt messages.', ['exception' => $e, 'promptName' => $promptName]); throw McpServerException::internalError("Failed to serialize prompt messages for '{$promptName}'.", $e); @@ -336,49 +310,21 @@ public function handlePromptGet(array $params): GetPromptResult } } - public function handleLoggingSetLevel(array $params, SessionInterface $session): EmptyResult + public function handleLoggingSetLevel(SetLogLevelRequest $request, SessionInterface $session): EmptyResult { - $level = $params['level'] ?? null; - $validLevels = [ - LogLevel::EMERGENCY, - LogLevel::ALERT, - LogLevel::CRITICAL, - LogLevel::ERROR, - LogLevel::WARNING, - LogLevel::NOTICE, - LogLevel::INFO, - LogLevel::DEBUG, - ]; - - if (! is_string($level) || ! in_array(strtolower($level), $validLevels)) { - throw McpServerException::invalidParams("Invalid or missing 'level'. Must be one of: " . implode(', ', $validLevels)); - } + $level = $request->level; - $session->set('log_level', strtolower($level)); + $session->set('log_level', $level->value); - $this->logger->info("Log level set to '{$level}'.", ['sessionId' => $session->getId()]); + $this->logger->info("Log level set to '{$level->value}'.", ['sessionId' => $session->getId()]); return new EmptyResult(); } - public function handleCompletionComplete(array $params, SessionInterface $session): CompletionCompleteResult + public function handleCompletionComplete(CompletionCompleteRequest $request, SessionInterface $session): CompletionCompleteResult { - $ref = $params['ref'] ?? null; - $argumentContext = $params['argument'] ?? null; - - if ( - !is_array($ref) - || !isset($ref['type']) - || !is_array($argumentContext) - || !isset($argumentContext['name']) - || !array_key_exists('value', $argumentContext) - ) { - throw McpServerException::invalidParams("Missing or invalid 'ref' or 'argument' parameters for completion/complete."); - } - - $type = $ref['type']; - $name = $argumentContext['name']; - $value = $argumentContext['value']; + $ref = $request->ref; + $argument = $request->argument; $completionValues = []; $total = null; @@ -390,17 +336,17 @@ public function handleCompletionComplete(array $params, SessionInterface $sessio // 2. Determine if that definition has a completion provider for the given $argName. // 3. Invoke that provider with $currentValue and $session (for context). - // --- Placeholder/Example Logic --- - if ($name === 'userId') { + // --- Example Logic --- + if ($argument['name'] === 'userId') { $completionValues = ['101', '102', '103']; $total = 3; } - // --- End Placeholder --- + // --- End Example --- return new CompletionCompleteResult($completionValues, $total, $hasMore); } - public function handleNotificationInitialized(array $params, SessionInterface $session): EmptyResult + public function handleNotificationInitialized(InitializedNotification $notification, SessionInterface $session): EmptyResult { $session->set('initialized', true); diff --git a/src/Support/RequestProcessor.php b/src/Support/RequestProcessor.php deleted file mode 100644 index 956e447..0000000 --- a/src/Support/RequestProcessor.php +++ /dev/null @@ -1,535 +0,0 @@ -container = $configuration->container; - $this->logger = $configuration->logger; - - $this->schemaValidator = $schemaValidator ?? new SchemaValidator($this->configuration->logger); - $this->argumentPreparer = $argumentPreparer ?? new ArgumentPreparer($this->configuration->logger); - } - - public function processNotification(Notification $message, string $sessionId): void - { - $method = $message->method; - $params = $message->params; - - if ($method === 'notifications/initialized') { - $this->handleNotificationInitialized($params, $sessionId); - } - } - - public function processRequest(Request $message, string $sessionId): Response|Error - { - $method = $message->method; - $params = $message->params; - - try { - /** @var Result|null $result */ - $result = null; - - if ($method === 'initialize') { - $result = $this->handleInitialize($params, $sessionId); - } elseif ($method === 'ping') { - $result = $this->handlePing($sessionId); - } else { - $this->validateSessionInitialized($sessionId); - [$type, $action] = $this->parseMethod($method); - $this->validateCapabilityEnabled($type); - - $result = match ($type) { - 'tools' => match ($action) { - 'list' => $this->handleToolList($params), - 'call' => $this->handleToolCall($params), - default => throw McpServerException::methodNotFound($method), - }, - 'resources' => match ($action) { - 'list' => $this->handleResourcesList($params), - 'read' => $this->handleResourceRead($params), - 'subscribe' => $this->handleResourceSubscribe($params, $sessionId), - 'unsubscribe' => $this->handleResourceUnsubscribe($params, $sessionId), - 'templates/list' => $this->handleResourceTemplateList($params), - default => throw McpServerException::methodNotFound($method), - }, - 'prompts' => match ($action) { - 'list' => $this->handlePromptsList($params), - 'get' => $this->handlePromptGet($params), - default => throw McpServerException::methodNotFound($method), - }, - 'logging' => match ($action) { - 'setLevel' => $this->handleLoggingSetLevel($params, $sessionId), - default => throw McpServerException::methodNotFound($method), - }, - default => throw McpServerException::methodNotFound($method), - }; - } - - if (isset($id) && $result === null && $method !== 'notifications/initialized') { - $this->logger->error('MCP Processor resulted in null for a request requiring a response', ['method' => $method]); - throw McpServerException::internalError("Processing method '{$method}' failed to return a result."); - } - - return Response::make($result, $message->id); - } catch (McpServerException $e) { - $this->logger->debug('MCP Processor caught McpServerException', ['method' => $method, 'code' => $e->getCode(), 'message' => $e->getMessage(), 'data' => $e->getData()]); - - return $e->toJsonRpcError($message->id); - } catch (Throwable $e) { - $this->logger->error('MCP Processor caught unexpected error', ['method' => $method, 'exception' => $e]); - - return new Error( - jsonrpc: '2.0', - id: $message->id, - code: Error::CODE_INTERNAL_ERROR, - message: 'Internal error processing method ' . $method, - data: $e->getMessage() - ); - } - } - - private function parseMethod(string $method): array - { - if (str_contains($method, '/')) { - $parts = explode('/', $method, 2); - if (count($parts) === 2) { - return [$parts[0], $parts[1]]; - } - } - - return [$method, '']; - } - - private function validateSessionInitialized(string $sessionId): void - { - if (! $this->sessionManager->isSessionInitialized($sessionId)) { - throw McpServerException::invalidRequest('Client session not initialized.'); - } - } - - private function validateCapabilityEnabled(string $type): void - { - $caps = $this->configuration->capabilities; - - $enabled = match ($type) { - 'tools' => $caps->toolsEnabled, - 'resources', 'resources/templates' => $caps->resourcesEnabled, - 'resources/subscribe', 'resources/unsubscribe' => $caps->resourcesEnabled && $caps->resourcesSubscribe, - 'prompts' => $caps->promptsEnabled, - 'logging' => $caps->loggingEnabled, - default => false, - }; - - if (! $enabled) { - $methodSegment = explode('/', $type)[0]; - throw McpServerException::methodNotFound("MCP capability '{$methodSegment}' is not enabled on this server."); - } - } - - private function handleInitialize(array $params, string $sessionId): InitializeResult - { - $protocolVersion = $params['protocolVersion'] ?? null; - if (! $protocolVersion) { - throw McpServerException::invalidParams("Missing 'protocolVersion' parameter."); - } - - if (! in_array($protocolVersion, self::SUPPORTED_PROTOCOL_VERSIONS)) { - $this->logger->warning("Unsupported protocol version: {$protocolVersion}", [ - 'supportedVersions' => self::SUPPORTED_PROTOCOL_VERSIONS, - ]); - } - - $serverProtocolVersion = self::SUPPORTED_PROTOCOL_VERSIONS[count(self::SUPPORTED_PROTOCOL_VERSIONS) - 1]; - - $clientInfo = $params['clientInfo'] ?? null; - if (! is_array($clientInfo)) { - throw McpServerException::invalidParams("Missing or invalid 'clientInfo' parameter."); - } - - $this->sessionManager->storeClientInfo($sessionId, $clientInfo); - - $serverInfo = [ - 'name' => $this->configuration->serverName, - 'version' => $this->configuration->serverVersion, - ]; - - $serverCapabilities = $this->configuration->capabilities; - $responseCapabilities = $serverCapabilities->toInitializeResponseArray(); - - $instructions = $serverCapabilities->instructions; - - return new InitializeResult($serverInfo, $serverProtocolVersion, $responseCapabilities, $instructions); - } - - private function handlePing(string $sessionId): EmptyResult - { - return new EmptyResult(); - } - - private function handleNotificationInitialized(array $params, string $sessionId): EmptyResult - { - $this->sessionManager->initializeSession($sessionId); - - return new EmptyResult(); - } - - - private function handleToolList(array $params): ListToolsResult - { - $cursor = $params['cursor'] ?? null; - $limit = $this->configuration->paginationLimit; - $offset = $this->decodeCursor($cursor); - $allItems = $this->registry->allTools()->getArrayCopy(); - $pagedItems = array_slice($allItems, $offset, $limit); - $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); - - return new ListToolsResult(array_values($pagedItems), $nextCursor); - } - - private function handleResourcesList(array $params): ListResourcesResult - { - $cursor = $params['cursor'] ?? null; - $limit = $this->configuration->paginationLimit; - $offset = $this->decodeCursor($cursor); - $allItems = $this->registry->allResources()->getArrayCopy(); - $pagedItems = array_slice($allItems, $offset, $limit); - $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); - - return new ListResourcesResult(array_values($pagedItems), $nextCursor); - } - - private function handleResourceTemplateList(array $params): ListResourceTemplatesResult - { - $cursor = $params['cursor'] ?? null; - $limit = $this->configuration->paginationLimit; - $offset = $this->decodeCursor($cursor); - $allItems = $this->registry->allResourceTemplates()->getArrayCopy(); - $pagedItems = array_slice($allItems, $offset, $limit); - $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); - - return new ListResourceTemplatesResult(array_values($pagedItems), $nextCursor); - } - - private function handlePromptsList(array $params): ListPromptsResult - { - $cursor = $params['cursor'] ?? null; - $limit = $this->configuration->paginationLimit; - $offset = $this->decodeCursor($cursor); - $allItems = $this->registry->allPrompts()->getArrayCopy(); - $pagedItems = array_slice($allItems, $offset, $limit); - $nextCursor = $this->encodeNextCursor($offset, count($pagedItems), count($allItems), $limit); - - return new ListPromptsResult(array_values($pagedItems), $nextCursor); - } - - private function handleToolCall(array $params): CallToolResult - { - $toolName = $params['name'] ?? null; - $arguments = $params['arguments'] ?? null; - - if (! is_string($toolName) || empty($toolName)) { - throw McpServerException::invalidParams("Missing or invalid 'name' parameter for tools/call."); - } - - if ($arguments === null || $arguments === []) { - $arguments = new stdClass(); - } elseif (! is_array($arguments) && ! $arguments instanceof stdClass) { - throw McpServerException::invalidParams("Parameter 'arguments' must be an object/array for tools/call."); - } - - $definition = $this->registry->findTool($toolName); - if (! $definition) { - throw McpServerException::methodNotFound("Tool '{$toolName}' not found."); - } - - $inputSchema = $definition->inputSchema; - - $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($arguments, $inputSchema); - - if (! empty($validationErrors)) { - $errorMessages = []; - - foreach ($validationErrors as $errorDetail) { - $pointer = $errorDetail['pointer'] ?? ''; - $message = $errorDetail['message'] ?? 'Unknown validation error'; - $errorMessages[] = ($pointer !== '/' && $pointer !== '' ? "Property '{$pointer}': " : '') . $message; - } - - $summaryMessage = "Invalid parameters for tool '{$toolName}': " . implode('; ', array_slice($errorMessages, 0, 3)); - - if (count($errorMessages) > 3) { - $summaryMessage .= '; ...and more errors.'; - } - - throw McpServerException::invalidParams($summaryMessage, data: ['validation_errors' => $validationErrors]); - } - - $argumentsForPhpCall = (array) $arguments; - - try { - $instance = $this->container->get($definition->className); - $methodName = $definition->methodName; - - $args = $this->argumentPreparer->prepareMethodArguments( - $instance, - $methodName, - $argumentsForPhpCall, - $inputSchema - ); - - $toolExecutionResult = $instance->{$methodName}(...$args); - $formattedResult = $this->formatToolResult($toolExecutionResult); - - return new CallToolResult($formattedResult, false); - } catch (JsonException $e) { - $this->logger->warning('MCP SDK: Failed to JSON encode tool result.', ['tool' => $toolName, 'exception' => $e]); - $errorMessage = "Failed to serialize tool result: {$e->getMessage()}"; - - return new CallToolResult([new TextContent($errorMessage)], true); - } catch (Throwable $toolError) { - $this->logger->error('MCP SDK: Tool execution failed.', ['tool' => $toolName, 'exception' => $toolError]); - $errorContent = $this->formatToolErrorResult($toolError); - - return new CallToolResult($errorContent, true); - } - } - - private function handleResourceRead(array $params): ReadResourceResult - { - $uri = $params['uri'] ?? null; - if (! is_string($uri) || empty($uri)) { - throw McpServerException::invalidParams("Missing or invalid 'uri' parameter for resources/read."); - } - - $definition = null; - $uriVariables = []; - - $definition = $this->registry->findResourceByUri($uri); - - if (! $definition) { - $templateResult = $this->registry->findResourceTemplateByUri($uri); - if ($templateResult) { - $definition = $templateResult['definition']; - $uriVariables = $templateResult['variables']; - } else { - throw McpServerException::invalidParams("Resource URI '{$uri}' not found or no handler available."); - } - } - - try { - $instance = $this->container->get($definition->className); - $methodName = $definition->methodName; - - $methodParams = array_merge($uriVariables, ['uri' => $uri]); - - $args = $this->argumentPreparer->prepareMethodArguments( - $instance, - $methodName, - $methodParams, - [] - ); - - $readResult = $instance->{$methodName}(...$args); - $contents = $this->formatResourceContents($readResult, $uri, $definition->mimeType); - - return new ReadResourceResult($contents); - } catch (JsonException $e) { - $this->logger->warning('MCP SDK: Failed to JSON encode resource content.', ['exception' => $e, 'uri' => $uri]); - throw McpServerException::internalError("Failed to serialize resource content for '{$uri}'.", $e); - } catch (McpServerException $e) { - throw $e; - } catch (Throwable $e) { - $this->logger->error('MCP SDK: Resource read failed.', ['uri' => $uri, 'exception' => $e]); - throw McpServerException::resourceReadFailed($uri, $e); - } - } - - private function handleResourceSubscribe(array $params, string $sessionId): EmptyResult - { - $uri = $params['uri'] ?? null; - if (! is_string($uri) || empty($uri)) { - throw McpServerException::invalidParams("Missing or invalid 'uri' parameter for resources/subscribe."); - } - - $this->validateCapabilityEnabled('resources/subscribe'); - - $this->sessionManager->addResourceSubscription($sessionId, $uri); - - return new EmptyResult(); - } - - private function handleResourceUnsubscribe(array $params, string $sessionId): EmptyResult - { - $uri = $params['uri'] ?? null; - if (! is_string($uri) || empty($uri)) { - throw McpServerException::invalidParams("Missing or invalid 'uri' parameter for resources/unsubscribe."); - } - - $this->validateCapabilityEnabled('resources/unsubscribe'); - - $this->sessionManager->removeResourceSubscription($sessionId, $uri); - - return new EmptyResult(); - } - - private function handlePromptGet(array $params): GetPromptResult - { - $promptName = $params['name'] ?? null; - $arguments = $params['arguments'] ?? []; - - if (! is_string($promptName) || empty($promptName)) { - throw McpServerException::invalidParams("Missing or invalid 'name' parameter for prompts/get."); - } - if (! is_array($arguments) && ! $arguments instanceof stdClass) { - throw McpServerException::invalidParams("Parameter 'arguments' must be an object/array for prompts/get."); - } - - $definition = $this->registry->findPrompt($promptName); - if (! $definition) { - throw McpServerException::invalidParams("Prompt '{$promptName}' not found."); - } - - $arguments = (array) $arguments; - - foreach ($definition->arguments as $argDef) { - if ($argDef->required && ! array_key_exists($argDef->name, $arguments)) { - throw McpServerException::invalidParams("Missing required argument '{$argDef->name}' for prompt '{$promptName}'."); - } - } - - try { - $instance = $this->container->get($definition->className); - $methodName = $definition->methodName; - - $args = $this->argumentPreparer->prepareMethodArguments( - $instance, - $methodName, - $arguments, - [] - ); - - $promptGenerationResult = $instance->{$methodName}(...$args); - $messages = $this->formatPromptMessages($promptGenerationResult); - - return new GetPromptResult($messages, $definition->description); - } catch (JsonException $e) { - $this->logger->warning('MCP SDK: Failed to JSON encode prompt messages.', ['exception' => $e, 'promptName' => $promptName]); - throw McpServerException::internalError("Failed to serialize prompt messages for '{$promptName}'.", $e); - } catch (McpServerException $e) { - throw $e; // Re-throw known MCP errors - } catch (Throwable $e) { - $this->logger->error('MCP SDK: Prompt generation failed.', ['promptName' => $promptName, 'exception' => $e]); - throw McpServerException::promptGenerationFailed($promptName, $e); // Use specific factory - } - } - - private function handleLoggingSetLevel(array $params, string $sessionId): EmptyResult - { - $level = $params['level'] ?? null; - $validLevels = [ - LogLevel::EMERGENCY, - LogLevel::ALERT, - LogLevel::CRITICAL, - LogLevel::ERROR, - LogLevel::WARNING, - LogLevel::NOTICE, - LogLevel::INFO, - LogLevel::DEBUG, - ]; - - if (! is_string($level) || ! in_array(strtolower($level), $validLevels)) { - throw McpServerException::invalidParams("Invalid or missing 'level'. Must be one of: " . implode(', ', $validLevels)); - } - - $this->validateCapabilityEnabled('logging'); - - $this->sessionManager->setLogLevel($sessionId, strtolower($level)); - - $this->logger->info("Log level set to '{$level}'.", ['sessionId' => $sessionId]); - - return new EmptyResult(); - } - - private function decodeCursor(?string $cursor): int - { - if ($cursor === null) { - return 0; - } - $decoded = base64_decode($cursor, true); - if ($decoded === false) { - $this->logger->warning('Received invalid pagination cursor (not base64)', ['cursor' => $cursor]); - - return 0; - } - if (preg_match('/^offset=(\d+)$/', $decoded, $matches)) { - return (int) $matches[1]; - } - $this->logger->warning('Received invalid pagination cursor format', ['cursor' => $decoded]); - - return 0; - } - - private function encodeNextCursor(int $currentOffset, int $returnedCount, int $totalCount, int $limit): ?string - { - $nextOffset = $currentOffset + $returnedCount; - if ($returnedCount > 0 && $nextOffset < $totalCount) { - return base64_encode("offset={$nextOffset}"); - } - - return null; - } -} diff --git a/src/Traits/ResponseFormatter.php b/src/Traits/ResponseFormatter.php index 64d5cbd..8de7ba4 100644 --- a/src/Traits/ResponseFormatter.php +++ b/src/Traits/ResponseFormatter.php @@ -3,14 +3,16 @@ namespace PhpMcp\Server\Traits; use JsonException; -use PhpMcp\Server\JsonRpc\Contents\AudioContent; -use PhpMcp\Server\JsonRpc\Contents\Content; -use PhpMcp\Server\JsonRpc\Contents\EmbeddedResource; -use PhpMcp\Server\JsonRpc\Contents\ImageContent; -use PhpMcp\Server\JsonRpc\Contents\PromptMessage; -use PhpMcp\Server\JsonRpc\Contents\ResourceContent; -use PhpMcp\Server\JsonRpc\Contents\TextContent; -use PhpMcp\Server\Model\Role; +use PhpMcp\Schema\Content\AudioContent; +use PhpMcp\Schema\Content\BlobResourceContents; +use PhpMcp\Schema\Enum\Role; +use PhpMcp\Schema\Content\Content; +use PhpMcp\Schema\Content\EmbeddedResource; +use PhpMcp\Schema\Content\ImageContent; +use PhpMcp\Schema\Content\PromptMessage; +use PhpMcp\Schema\Content\ResourceContents; +use PhpMcp\Schema\Content\TextContent; +use PhpMcp\Schema\Content\TextResourceContents; use Throwable; /** @@ -37,10 +39,7 @@ protected function formatToolResult(mixed $toolExecutionResult): array } if ($toolExecutionResult === null) { - if (($outputSchema['type'] ?? 'mixed') !== 'void') { - return [TextContent::make('(null)')]; - } - return []; + return [TextContent::make('(null)')]; } if (is_bool($toolExecutionResult)) { @@ -71,7 +70,7 @@ protected function formatToolErrorResult(Throwable $toolError): array $errorMessage .= ' (Type: ' . get_class($toolError) . ')'; return [ - new TextContent($errorMessage), + TextContent::make($errorMessage), ]; } @@ -98,7 +97,7 @@ protected function formatToolErrorResult(Throwable $toolError): array */ protected function formatResourceContents(mixed $readResult, string $uri, ?string $defaultMimeType): array { - if ($readResult instanceof ResourceContent) { + if ($readResult instanceof ResourceContents) { return [$readResult]; } @@ -106,30 +105,28 @@ protected function formatResourceContents(mixed $readResult, string $uri, ?strin return [$readResult->resource]; } - if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof ResourceContent) { + if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof ResourceContents) { return $readResult; } if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof EmbeddedResource) { - return array_map(fn ($item) => $item->resource, $readResult); + return array_map(fn($item) => $item->resource, $readResult); } if (is_string($readResult)) { $mimeType = $defaultMimeType ?? $this->guessMimeTypeFromString($readResult); - return [new ResourceContent($uri, $mimeType, $readResult)]; + return [TextResourceContents::make($uri, $mimeType, $readResult)]; } if (is_resource($readResult) && get_resource_type($readResult) === 'stream') { - $result = ResourceContent::fromStream( + $result = BlobResourceContents::fromStream( $uri, $readResult, $defaultMimeType ?? 'application/octet-stream' ); - if (is_resource($readResult)) { - @fclose($readResult); - } + @fclose($readResult); return [$result]; } @@ -137,17 +134,17 @@ protected function formatResourceContents(mixed $readResult, string $uri, ?strin if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) { $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'application/octet-stream'; - return [new ResourceContent($uri, $mimeType, null, $readResult['blob'])]; + return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])]; } if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) { $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'text/plain'; - return [new ResourceContent($uri, $mimeType, $readResult['text'])]; + return [TextResourceContents::make($uri, $mimeType, $readResult['text'])]; } if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { - return [ResourceContent::fromSplFileInfo($uri, $readResult, $defaultMimeType)]; + return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $defaultMimeType)]; } if (is_array($readResult)) { @@ -156,7 +153,7 @@ protected function formatResourceContents(mixed $readResult, string $uri, ?strin try { $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); - return [new ResourceContent($uri, $defaultMimeType, $jsonString)]; + return [TextResourceContents::make($uri, $defaultMimeType, $jsonString)]; } catch (\JsonException $e) { $this->logger->warning('MCP SDK: Failed to JSON encode array resource result', [ 'uri' => $uri, @@ -175,7 +172,7 @@ protected function formatResourceContents(mixed $readResult, string $uri, ?strin 'usedMimeType' => $mimeType, ]); - return [new ResourceContent($uri, $mimeType, $jsonString)]; + return [TextResourceContents::make($uri, $mimeType, $jsonString)]; } catch (\JsonException $e) { $this->logger->error('MCP SDK: Failed to encode array resource result as JSON', [ 'uri' => $uri, @@ -238,10 +235,10 @@ protected function formatPromptMessages(mixed $promptGenerationResult): array $result = []; if (isset($promptGenerationResult['user'])) { - $result[] = PromptMessage::user($promptGenerationResult['user']); + $result[] = PromptMessage::make(Role::User, $promptGenerationResult['user']); } if (isset($promptGenerationResult['assistant'])) { - $result[] = PromptMessage::assistant($promptGenerationResult['assistant']); + $result[] = PromptMessage::make(Role::Assistant, $promptGenerationResult['assistant']); } if (! empty($result)) { @@ -297,7 +294,7 @@ protected function formatPromptMessages(mixed $promptGenerationResult): array if (! isset($content['text']) || ! is_string($content['text'])) { throw new \RuntimeException("Invalid 'text' content: Missing or invalid 'text' string."); } - $contentObj = new TextContent($content['text']); + $contentObj = TextContent::make($content['text']); break; case 'image': @@ -307,7 +304,7 @@ protected function formatPromptMessages(mixed $promptGenerationResult): array if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { throw new \RuntimeException("Invalid 'image' content: Missing or invalid 'mimeType' string."); } - $contentObj = new ImageContent($content['data'], $content['mimeType']); + $contentObj = ImageContent::make($content['data'], $content['mimeType']); break; case 'audio': @@ -317,7 +314,7 @@ protected function formatPromptMessages(mixed $promptGenerationResult): array if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { throw new \RuntimeException("Invalid 'audio' content: Missing or invalid 'mimeType' string."); } - $contentObj = new AudioContent($content['data'], $content['mimeType']); + $contentObj = AudioContent::make($content['data'], $content['mimeType']); break; case 'resource': @@ -332,12 +329,11 @@ protected function formatPromptMessages(mixed $promptGenerationResult): array $resourceObj = null; if (isset($resource['text']) && is_string($resource['text'])) { - $resourceObj = new ResourceContent($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); + $resourceObj = TextResourceContents::make($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); } elseif (isset($resource['blob']) && is_string($resource['blob'])) { - $resourceObj = new ResourceContent( + $resourceObj = BlobResourceContents::make( $resource['uri'], $resource['mimeType'] ?? 'application/octet-stream', - null, $resource['blob'] ); } else { diff --git a/src/Transports/HttpServerTransport.php b/src/Transports/HttpServerTransport.php index 87b460f..b87fb7a 100644 --- a/src/Transports/HttpServerTransport.php +++ b/src/Transports/HttpServerTransport.php @@ -5,12 +5,18 @@ namespace PhpMcp\Server\Transports; use Evenement\EventEmitterTrait; +use PhpMcp\Schema\Constants; use PhpMcp\Server\Contracts\IdGeneratorInterface; use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\TransportException; -use PhpMcp\Server\JsonRpc\Messages\Message as JsonRpcMessage; +use PhpMcp\Schema\JsonRpc\Message; +use PhpMcp\Schema\JsonRpc\Request; +use PhpMcp\Schema\JsonRpc\Notification; +use PhpMcp\Schema\JsonRpc\BatchRequest; +use PhpMcp\Schema\JsonRpc\Error; +use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Server\Support\RandomIdGenerator; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; @@ -217,21 +223,27 @@ protected function handleMessagePostRequest(ServerRequestInterface $request): Re { $queryParams = $request->getQueryParams(); $sessionId = $queryParams['clientId'] ?? null; + $jsonEncodeFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; if (! $sessionId || ! is_string($sessionId)) { $this->logger->warning('Received POST without valid clientId query parameter.'); + $error = Error::forInvalidRequest('Missing or invalid clientId query parameter'); - return new Response(400, ['Content-Type' => 'text/plain'], 'Missing or invalid clientId query parameter'); + return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); } if (! isset($this->activeSseStreams[$sessionId])) { $this->logger->warning('Received POST for unknown or disconnected sessionId.', ['sessionId' => $sessionId]); - return new Response(404, ['Content-Type' => 'text/plain'], 'Session ID not found or disconnected'); + $error = Error::forInvalidRequest('Session ID not found or disconnected'); + + return new Response(404, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); } if (! str_contains(strtolower($request->getHeaderLine('Content-Type')), 'application/json')) { - return new Response(415, ['Content-Type' => 'text/plain'], 'Content-Type must be application/json'); + $error = Error::forInvalidRequest('Content-Type must be application/json'); + + return new Response(415, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); } $body = $request->getBody()->getContents(); @@ -239,14 +251,19 @@ protected function handleMessagePostRequest(ServerRequestInterface $request): Re if (empty($body)) { $this->logger->warning('Received empty POST body', ['sessionId' => $sessionId]); - return new Response(400, ['Content-Type' => 'text/plain'], 'Empty request body'); + $error = Error::forInvalidRequest('Empty request body'); + + return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); } try { - $message = JsonRpcMessage::parseRequest($body); + $message = self::parseRequest($body); } catch (Throwable $e) { $this->logger->error('Error parsing message', ['sessionId' => $sessionId, 'exception' => $e]); - return new Response(400, ['Content-Type' => 'text/plain'], 'Invalid JSON-RPC message: ' . $e->getMessage()); + + $error = Error::forParseError('Invalid JSON-RPC message: ' . $e->getMessage()); + + return new Response(400, ['Content-Type' => 'application/json'], json_encode($error, $jsonEncodeFlags)); } $this->emit('message', [$message, $sessionId]); @@ -254,11 +271,30 @@ protected function handleMessagePostRequest(ServerRequestInterface $request): Re return new Response(202, ['Content-Type' => 'text/plain'], 'Accepted'); } + public static function parseRequest(string $message): Request|Notification|BatchRequest + { + $messageData = json_decode($message, true, 512, JSON_THROW_ON_ERROR); + + $isBatch = array_is_list($messageData) && count($messageData) > 0 && is_array($messageData[0] ?? null); + + if ($isBatch) { + return BatchRequest::fromArray($messageData); + } elseif (isset($messageData['method'])) { + if (isset($messageData['id']) && $messageData['id'] !== null) { + return Request::fromArray($messageData); + } else { + return Notification::fromArray($messageData); + } + } + + throw new McpServerException('Invalid JSON-RPC message'); + } + /** * Sends a raw JSON-RPC message frame to a specific client via SSE. */ - public function sendMessage(JsonRpcMessage $message, string $sessionId, array $context = []): PromiseInterface + public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface { if (! isset($this->activeSseStreams[$sessionId])) { return reject(new TransportException("Cannot send message: Client '{$sessionId}' not connected via SSE.")); diff --git a/src/Transports/StdioServerTransport.php b/src/Transports/StdioServerTransport.php index 1ae4b47..d36431f 100644 --- a/src/Transports/StdioServerTransport.php +++ b/src/Transports/StdioServerTransport.php @@ -5,12 +5,17 @@ namespace PhpMcp\Server\Transports; use Evenement\EventEmitterTrait; +use PhpMcp\Schema\Constants; +use PhpMcp\Schema\JsonRpc\BatchRequest; use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\TransportException; -use PhpMcp\Server\JsonRpc\Messages\Message as JsonRpcMessage; -use PhpMcp\Server\JsonRpc\Messages\Error as JsonRpcError; +use PhpMcp\Schema\JsonRpc\Notification; +use PhpMcp\Schema\JsonRpc\Request; +use PhpMcp\Schema\JsonRpc\Error; +use PhpMcp\Schema\JsonRpc\Message; +use PhpMcp\Server\Exception\McpServerException; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use React\ChildProcess\Process; @@ -176,10 +181,10 @@ private function processBuffer(): void } try { - $message = JsonRpcMessage::parseRequest($trimmedLine); + $message = self::parseRequest($trimmedLine); } catch (Throwable $e) { $this->logger->error('Error parsing message', ['exception' => $e]); - $error = JsonRpcError::parseError("Invalid JSON: " . $e->getMessage()); + $error = Error::forParseError("Invalid JSON: " . $e->getMessage()); $this->sendMessage($error, self::CLIENT_ID); continue; } @@ -188,10 +193,29 @@ private function processBuffer(): void } } + public static function parseRequest(string $message): Request|Notification|BatchRequest + { + $messageData = json_decode($message, true, 512, JSON_THROW_ON_ERROR); + + $isBatch = array_is_list($messageData) && count($messageData) > 0 && is_array($messageData[0] ?? null); + + if ($isBatch) { + return BatchRequest::fromArray($messageData); + } elseif (isset($messageData['method'])) { + if (isset($messageData['id']) && $messageData['id'] !== null) { + return Request::fromArray($messageData); + } else { + return Notification::fromArray($messageData); + } + } + + throw new McpServerException('Invalid JSON-RPC message'); + } + /** * Sends a raw, framed message to STDOUT. */ - public function sendMessage(JsonRpcMessage $message, string $sessionId, array $context = []): PromiseInterface + public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface { if ($this->closing || ! $this->stdout || ! $this->stdout->isWritable()) { return reject(new TransportException('Stdio transport is closed or STDOUT is not writable.')); diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php index 1be9c4f..811c2d0 100644 --- a/src/Transports/StreamableHttpServerTransport.php +++ b/src/Transports/StreamableHttpServerTransport.php @@ -12,12 +12,13 @@ use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Server\Exception\TransportException; -use PhpMcp\Server\JsonRpc\Messages\Message as JsonRpcMessage; -use PhpMcp\Server\JsonRpc\Messages\BatchRequest; -use PhpMcp\Server\JsonRpc\Messages\BatchResponse; -use PhpMcp\Server\JsonRpc\Messages\Error as JsonRpcError; -use PhpMcp\Server\JsonRpc\Messages\Request as JsonRpcRequest; -use PhpMcp\Server\JsonRpc\Messages\Response as JsonRpcResponse; +use PhpMcp\Schema\JsonRpc\Message; +use PhpMcp\Schema\JsonRpc\BatchRequest; +use PhpMcp\Schema\JsonRpc\BatchResponse; +use PhpMcp\Schema\JsonRpc\Error; +use PhpMcp\Schema\JsonRpc\Notification; +use PhpMcp\Schema\JsonRpc\Request; +use PhpMcp\Schema\JsonRpc\Response; use PhpMcp\Server\Support\RandomIdGenerator; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; @@ -143,7 +144,7 @@ private function createRequestHandler(): callable $this->logger->debug("Request received", ['method' => $method, 'path' => $path, 'target' => $this->mcpPath]); if ($path !== $this->mcpPath) { - $error = JsonRpcError::invalidRequest("Not found: {$path}"); + $error = Error::forInvalidRequest("Not found: {$path}"); return new HttpResponse(404, ['Content-Type' => 'application/json'], json_encode($error)); } @@ -166,9 +167,9 @@ private function createRequestHandler(): callable try { return match ($method) { - 'GET' => $this->handleGetRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), - 'POST' => $this->handlePostRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), - 'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), + 'GET' => $this->handleGetRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), + 'POST' => $this->handlePostRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), + 'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), default => $addCors($this->handleUnsupportedRequest($request)), }; } catch (Throwable $e) { @@ -181,14 +182,14 @@ private function handleGetRequest(ServerRequestInterface $request): PromiseInter { $acceptHeader = $request->getHeaderLine('Accept'); if (!str_contains($acceptHeader, 'text/event-stream')) { - $error = JsonRpcError::connectionAborted("Not Acceptable: Client must accept text/event-stream for GET requests."); + $error = Error::forInvalidRequest("Not Acceptable: Client must accept text/event-stream for GET requests."); return resolve(new HttpResponse(406, ['Content-Type' => 'application/json'], json_encode($error))); } $sessionId = $request->getHeaderLine('Mcp-Session-Id'); if (empty($sessionId)) { $this->logger->warning("GET request without Mcp-Session-Id."); - $error = JsonRpcError::invalidRequest("Mcp-Session-Id header required for GET requests."); + $error = Error::forInvalidRequest("Mcp-Session-Id header required for GET requests."); return resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); } @@ -227,13 +228,13 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte $acceptHeader = $request->getHeaderLine('Accept'); if (!str_contains($acceptHeader, 'application/json') && !str_contains($acceptHeader, 'text/event-stream')) { - $error = JsonRpcError::connectionAborted("Not Acceptable: Client must accept application/json or text/event-stream"); + $error = Error::forInvalidRequest("Not Acceptable: Client must accept application/json or text/event-stream"); $deferred->resolve(new HttpResponse(406, ['Content-Type' => 'application/json'], json_encode($error))); return $deferred->promise(); } if (!str_contains($request->getHeaderLine('Content-Type'), 'application/json')) { - $error = JsonRpcError::connectionAborted("Unsupported Media Type: Content-Type must be application/json"); + $error = Error::forInvalidRequest("Unsupported Media Type: Content-Type must be application/json"); $deferred->resolve(new HttpResponse(415, ['Content-Type' => 'application/json'], json_encode($error))); return $deferred->promise(); } @@ -242,27 +243,27 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte if (empty($body)) { $this->logger->warning("Received empty POST body"); - $error = JsonRpcError::invalidRequest("Empty request body."); + $error = Error::forInvalidRequest("Empty request body."); $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); return $deferred->promise(); } try { - $message = JsonRpcMessage::parseRequest($body); + $message = self::parseRequest($body); } catch (Throwable $e) { $this->logger->error("Failed to parse MCP message from POST body", ['error' => $e->getMessage()]); - $error = JsonRpcError::parseError("Invalid JSON: " . $e->getMessage()); + $error = Error::forParseError("Invalid JSON: " . $e->getMessage()); $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); return $deferred->promise(); } - $isInitializeRequest = ($message instanceof JsonRpcRequest && $message->method === 'initialize'); + $isInitializeRequest = ($message instanceof Request && $message->method === 'initialize'); $sessionId = null; if ($isInitializeRequest) { if ($request->hasHeader('Mcp-Session-Id')) { $this->logger->warning("Client sent Mcp-Session-Id with InitializeRequest. Ignoring.", ['clientSentId' => $request->getHeaderLine('Mcp-Session-Id')]); - $error = JsonRpcError::invalidRequest("Invalid request: Session already initialized. Mcp-Session-Id header not allowed with InitializeRequest."); + $error = Error::forInvalidRequest("Invalid request: Session already initialized. Mcp-Session-Id header not allowed with InitializeRequest.", $message->id); $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); return $deferred->promise(); } @@ -274,7 +275,7 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte if (empty($sessionId)) { $this->logger->warning("POST request without Mcp-Session-Id."); - $error = JsonRpcError::invalidRequest("Mcp-Session-Id header required for POST requests."); + $error = Error::forInvalidRequest("Mcp-Session-Id header required for POST requests.", $message->id); $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); return $deferred->promise(); } @@ -286,7 +287,7 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte $hasRequests = false; $nRequests = 0; - if ($message instanceof JsonRpcRequest) { + if ($message instanceof Request) { $hasRequests = true; $nRequests = 1; } elseif ($message instanceof BatchRequest) { @@ -368,7 +369,7 @@ private function handleDeleteRequest(ServerRequestInterface $request): PromiseIn $sessionId = $request->getHeaderLine('Mcp-Session-Id'); if (empty($sessionId)) { $this->logger->warning("DELETE request without Mcp-Session-Id."); - $error = JsonRpcError::invalidRequest("Mcp-Session-Id header required for DELETE."); + $error = Error::forInvalidRequest("Mcp-Session-Id header required for DELETE."); return resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); } @@ -391,7 +392,7 @@ private function handleDeleteRequest(ServerRequestInterface $request): PromiseIn private function handleUnsupportedRequest(ServerRequestInterface $request): HttpResponse { - $error = JsonRpcError::connectionAborted("Method not allowed: {$request->getMethod()}"); + $error = Error::forInvalidRequest("Method not allowed: {$request->getMethod()}"); $headers = [ 'Content-Type' => 'application/json', 'Allow' => 'GET, POST, DELETE, OPTIONS', @@ -408,21 +409,40 @@ private function handleRequestError(Throwable $e, ServerRequestInterface $reques ]); if ($e instanceof TransportException) { - $error = JsonRpcError::connectionAborted("Transport Error: " . $e->getMessage()); + $error = Error::forInternalError("Transport Error: " . $e->getMessage()); return new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($error)); } - $error = JsonRpcError::connectionAborted("Internal Server Error during HTTP request processing."); + $error = Error::forInternalError("Internal Server Error during HTTP request processing."); return new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($error)); } - public function sendMessage(JsonRpcMessage $message, string $sessionId, array $context = []): PromiseInterface + public static function parseRequest(string $message): Request|Notification|BatchRequest + { + $messageData = json_decode($message, true, 512, JSON_THROW_ON_ERROR); + + $isBatch = array_is_list($messageData) && count($messageData) > 0 && is_array($messageData[0] ?? null); + + if ($isBatch) { + return BatchRequest::fromArray($messageData); + } elseif (isset($messageData['method'])) { + if (isset($messageData['id']) && $messageData['id'] !== null) { + return Request::fromArray($messageData); + } else { + return Notification::fromArray($messageData); + } + } + + throw new McpServerException('Invalid JSON-RPC message'); + } + + public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface { if ($this->closing) { return reject(new TransportException('Transport is closing.')); } - $isInitializeResponse = ($context['is_initialize_request'] ?? false) && ($message instanceof JsonRpcResponse); + $isInitializeResponse = ($context['is_initialize_request'] ?? false) && ($message instanceof Response); switch ($context['type'] ?? null) { case 'post_202_sent': @@ -443,13 +463,13 @@ public function sendMessage(JsonRpcMessage $message, string $sessionId, array $c $sentCountThisCall = 0; - if ($message instanceof JsonRpcResponse || $message instanceof JsonRpcError) { + if ($message instanceof Response || $message instanceof Error) { $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $eventId = $this->eventStore ? $this->eventStore->storeEvent($streamId, $json) : null; $this->sendSseEventToStream($stream, $json, $eventId); $sentCountThisCall = 1; } elseif ($message instanceof BatchResponse) { - foreach ($message->all() as $singleResponse) { + foreach ($message->getAll() as $singleResponse) { $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $eventId = $this->eventStore ? $this->eventStore->storeEvent($streamId, $json) : null; $this->sendSseEventToStream($stream, $json, $eventId); @@ -496,12 +516,12 @@ public function sendMessage(JsonRpcMessage $message, string $sessionId, array $c $this->logger->warning("GET SSE stream is not writable.", ['sessionId' => $sessionId]); return reject(new TransportException("GET SSE stream not writable.")); } - if ($message instanceof JsonRpcResponse || $message instanceof JsonRpcError) { + if ($message instanceof Response || $message instanceof Error) { $json = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $eventId = $this->eventStore ? $this->eventStore->storeEvent('GET_STREAM', $json) : null; $this->sendSseEventToStream($this->getStream, $json, $eventId); } elseif ($message instanceof BatchResponse) { - foreach ($message->all() as $singleResponse) { + foreach ($message->getAll() as $singleResponse) { $json = json_encode($singleResponse, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $eventId = $this->eventStore ? $this->eventStore->storeEvent('GET_STREAM', $json) : null; $this->sendSseEventToStream($this->getStream, $json, $eventId); diff --git a/tests/Unit/JsonRpc/NotificationTest.php b/tests/Unit/JsonRpc/NotificationTest.php index 2788ce2..817d9de 100644 --- a/tests/Unit/JsonRpc/NotificationTest.php +++ b/tests/Unit/JsonRpc/NotificationTest.php @@ -3,7 +3,7 @@ namespace PhpMcp\Server\Tests\Unit\JsonRpc; use PhpMcp\Server\Exception\ProtocolException; -use PhpMcp\Server\JsonRpc\Messages\Notification; +use PhpMcp\Schema\JsonRpc\Notification; test('notification construction sets properties correctly', function () { $notification = new Notification('2.0', 'test.method', ['param1' => 'value1']); @@ -60,27 +60,27 @@ 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 + expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException for missing jsonrpc', function () { $data = ['method' => 'test.method']; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED + expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException for missing method', function () { $data = ['jsonrpc' => '2.0']; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED + expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException for non-string method', function () { $data = ['jsonrpc' => '2.0', 'method' => 123]; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED + expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException if params is not an array/object', function () { $data = ['jsonrpc' => '2.0', 'method' => 'test', 'params' => 'string']; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); + expect(fn() => Notification::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 index 017e842..5402be1 100644 --- a/tests/Unit/JsonRpc/ResponseTest.php +++ b/tests/Unit/JsonRpc/ResponseTest.php @@ -7,7 +7,7 @@ use PhpMcp\Server\JsonRpc\Error; use PhpMcp\Server\JsonRpc\Response; use PhpMcp\Server\JsonRpc\Result; -use PhpMcp\Server\JsonRpc\Results\EmptyResult; +use PhpMcp\Schema\Result\EmptyResult; // --- Construction and Factory Tests --- @@ -41,24 +41,24 @@ }); test('response constructor throws exception if ID present but no result/error', function () { - expect(fn () => new Response('2.0', 1, null, null)) + 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)) + 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)) + 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); + expect(fn() => new Response('2.0', 1, $result, $error))->toThrow(InvalidArgumentException::class); }); test('success static method creates success response', function () { @@ -175,39 +175,39 @@ test('fromArray throws exception for invalid jsonrpc version', function () { $data = ['jsonrpc' => '1.0', 'id' => 1, 'result' => []]; - expect(fn () => Response::fromArray($data))->toThrow(ProtocolException::class); + 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"'); + expect(fn() => Response::fromArray($data))->toThrow(ProtocolException::class, 'must contain either "result" or "error"'); }); test('fromArray throws exception for response with null ID but missing error', function () { $data = ['jsonrpc' => '2.0', 'id' => null]; - expect(fn () => Response::fromArray($data))->toThrow(ProtocolException::class, 'must contain "error" when ID is null'); + expect(fn() => Response::fromArray($data))->toThrow(ProtocolException::class, 'must contain "error" when ID is null'); }); test('fromArray throws exception for response with null ID and result present', function () { $data = ['jsonrpc' => '2.0', 'id' => null, 'result' => 'abc', 'error' => ['code' => -32700, 'message' => 'e']]; $dataOnlyResult = ['jsonrpc' => '2.0', 'id' => null, 'result' => 'abc']; - expect(fn () => Response::fromArray($dataOnlyResult)) + expect(fn() => Response::fromArray($dataOnlyResult)) ->toThrow(ProtocolException::class, 'must contain "error" when ID is null'); }); test('fromArray throws exception for invalid ID type', function () { $data = ['jsonrpc' => '2.0', 'id' => [], 'result' => 'ok']; - expect(fn () => Response::fromArray($data))->toThrow(ProtocolException::class, 'Invalid "id" field type'); + 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'); + 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)) + expect(fn() => Response::fromArray($data)) ->toThrow(ProtocolException::class, 'Invalid "error" object structure'); }); diff --git a/tests/Unit/JsonRpc/ResultTest.php b/tests/Unit/JsonRpc/ResultTest.php index 67318da..54dc111 100644 --- a/tests/Unit/JsonRpc/ResultTest.php +++ b/tests/Unit/JsonRpc/ResultTest.php @@ -3,7 +3,7 @@ namespace PhpMcp\Server\Tests\Unit\JsonRpc; use PhpMcp\Server\JsonRpc\Result; -use PhpMcp\Server\JsonRpc\Results\EmptyResult; +use PhpMcp\Schema\Result\EmptyResult; test('Result class can be extended', function () { $result = new EmptyResult(); diff --git a/tests/Unit/JsonRpc/Results/EmptyResultTest.php b/tests/Unit/JsonRpc/Results/EmptyResultTest.php index 933232d..aa7a2d5 100644 --- a/tests/Unit/JsonRpc/Results/EmptyResultTest.php +++ b/tests/Unit/JsonRpc/Results/EmptyResultTest.php @@ -3,7 +3,7 @@ namespace PhpMcp\Server\Tests\Unit\JsonRpc\Results; use PhpMcp\Server\JsonRpc\Result; -use PhpMcp\Server\JsonRpc\Results\EmptyResult; +use PhpMcp\Schema\Result\EmptyResult; test('EmptyResult extends the Result base class', function () { $result = new EmptyResult(); diff --git a/tests/Unit/ProtocolTest.php b/tests/Unit/ProtocolTest.php index c0371b4..741e402 100644 --- a/tests/Unit/ProtocolTest.php +++ b/tests/Unit/ProtocolTest.php @@ -7,10 +7,10 @@ use PhpMcp\Server\Configuration; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\McpServerException; -use PhpMcp\Server\JsonRpc\Messages\Notification; -use PhpMcp\Server\JsonRpc\Messages\Request; -use PhpMcp\Server\JsonRpc\Messages\Response; -use PhpMcp\Server\JsonRpc\Results\EmptyResult; +use PhpMcp\Schema\JsonRpc\Notification; +use PhpMcp\Schema\JsonRpc\Request; +use PhpMcp\Schema\JsonRpc\Response; +use PhpMcp\Schema\Result\EmptyResult; use PhpMcp\Server\Model\Capabilities; use PhpMcp\Server\Protocol; use PhpMcp\Server\Registry; @@ -102,7 +102,7 @@ $rawJson = '{"jsonrpc":"2.0", "id":'; $this->requestProcessor->shouldNotReceive('process'); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32700') && str_contains($json, '"id":null')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32700') && str_contains($json, '"id":null')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); @@ -112,7 +112,7 @@ $rawJson = '{"jsonrpc":"2.0", "id": 456}'; // Missing method $this->requestProcessor->shouldNotReceive('process'); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32600') && str_contains($json, '"id":456')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32600') && str_contains($json, '"id":456')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); @@ -125,7 +125,7 @@ $mcpException = McpServerException::methodNotFound($method); $this->requestProcessor->shouldReceive('process')->once()->andThrow($mcpException); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32601') && str_contains($json, '"id":789')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32601') && str_contains($json, '"id":789')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); @@ -138,7 +138,7 @@ $internalException = new \RuntimeException('Borked'); $this->requestProcessor->shouldReceive('process')->once()->andThrow($internalException); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32603') && str_contains($json, '"id":101')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32603') && str_contains($json, '"id":101')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); diff --git a/tests/Unit/Support/RequestProcessorTest.php b/tests/Unit/Support/RequestProcessorTest.php index e937899..648c4ef 100644 --- a/tests/Unit/Support/RequestProcessorTest.php +++ b/tests/Unit/Support/RequestProcessorTest.php @@ -9,13 +9,13 @@ use PhpMcp\Server\Exception\McpServerException; use PhpMcp\Server\JsonRpc\Contents\TextContent; use PhpMcp\Server\JsonRpc\Error as JsonRpcError; -use PhpMcp\Server\JsonRpc\Messages\Notification; -use PhpMcp\Server\JsonRpc\Messages\Request; -use PhpMcp\Server\JsonRpc\Messages\Response; -use PhpMcp\Server\JsonRpc\Results\CallToolResult; -use PhpMcp\Server\JsonRpc\Results\EmptyResult; -use PhpMcp\Server\JsonRpc\Results\InitializeResult; -use PhpMcp\Server\JsonRpc\Results\ListToolsResult; +use PhpMcp\Schema\JsonRpc\Notification; +use PhpMcp\Schema\JsonRpc\Request; +use PhpMcp\Schema\JsonRpc\Response; +use PhpMcp\Schema\Result\CallToolResult; +use PhpMcp\Schema\Result\EmptyResult; +use PhpMcp\Schema\Result\InitializeResult; +use PhpMcp\Schema\Result\ListToolsResult; use PhpMcp\Server\Model\Capabilities; use PhpMcp\Server\Support\RequestProcessor; use PhpMcp\Server\Registry; From f49ff78f8a4e0c833540d511d37544f1383f93aa Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Tue, 10 Jun 2025 10:17:17 +0100 Subject: [PATCH 10/27] refactor: rename RequestHandler to Dispatcher for better semantic clarity --- .../02-discovery-http-userprofile/server.php | 4 +- src/Attributes/McpResource.php | 3 +- src/Attributes/McpResourceTemplate.php | 3 +- src/Attributes/McpTool.php | 3 +- src/Configuration.php | 3 +- .../RequestHandler.php => Dispatcher.php} | 17 ++--- src/Protocol.php | 8 +-- src/Registry.php | 66 +++++++++---------- src/Server.php | 3 +- src/ServerBuilder.php | 22 ++++--- src/Support/Discoverer.php | 21 +++--- .../{MethodInvoker.php => Handler.php} | 7 +- src/Traits/ResponseFormatter.php | 2 +- .../StreamableHttpServerTransport.php | 6 +- tests/Mocks/DiscoveryStubs/ToolOnlyStub.php | 8 ++- .../SupportStubs/DocBlockArrayTestStub.php | 14 ++-- .../SupportStubs/SchemaAttributeTestStub.php | 27 ++++---- tests/Unit/JsonRpc/NotificationTest.php | 10 +-- tests/Unit/JsonRpc/ResponseTest.php | 22 +++---- tests/Unit/ProtocolTest.php | 8 +-- 20 files changed, 132 insertions(+), 125 deletions(-) rename src/{Support/RequestHandler.php => Dispatcher.php} (97%) rename src/Support/{MethodInvoker.php => Handler.php} (98%) diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php index 1b18423..4b8bf33 100644 --- a/examples/02-discovery-http-userprofile/server.php +++ b/examples/02-discovery-http-userprofile/server.php @@ -71,8 +71,8 @@ public function log($level, \Stringable|string $message, array $context = []): v $server->discover(__DIR__, ['.']); - $transport = new HttpServerTransport('127.0.0.1', 8080, 'mcp'); - // $transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'); + // $transport = new HttpServerTransport('127.0.0.1', 8080, 'mcp'); + $transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'); $server->listen($transport); diff --git a/src/Attributes/McpResource.php b/src/Attributes/McpResource.php index 709f6fd..f367301 100644 --- a/src/Attributes/McpResource.php +++ b/src/Attributes/McpResource.php @@ -27,5 +27,6 @@ public function __construct( public ?string $mimeType = null, public ?int $size = null, public ?Annotations $annotations = null, - ) {} + ) { + } } diff --git a/src/Attributes/McpResourceTemplate.php b/src/Attributes/McpResourceTemplate.php index e007ce5..9448d26 100644 --- a/src/Attributes/McpResourceTemplate.php +++ b/src/Attributes/McpResourceTemplate.php @@ -25,5 +25,6 @@ public function __construct( public ?string $description = null, public ?string $mimeType = null, public ?Annotations $annotations = null, - ) {} + ) { + } } diff --git a/src/Attributes/McpTool.php b/src/Attributes/McpTool.php index 4d82435..d062002 100644 --- a/src/Attributes/McpTool.php +++ b/src/Attributes/McpTool.php @@ -17,5 +17,6 @@ public function __construct( public ?string $name = null, public ?string $description = null, public ?ToolAnnotations $annotations = null, - ) {} + ) { + } } diff --git a/src/Configuration.php b/src/Configuration.php index 1cab84e..a10a10e 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -37,5 +37,6 @@ public function __construct( public readonly ContainerInterface $container, public readonly int $definitionCacheTtl = 3600, public readonly int $paginationLimit = 50, - ) {} + ) { + } } diff --git a/src/Support/RequestHandler.php b/src/Dispatcher.php similarity index 97% rename from src/Support/RequestHandler.php rename to src/Dispatcher.php index c1f1c63..33b4407 100644 --- a/src/Support/RequestHandler.php +++ b/src/Dispatcher.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpMcp\Server\Support; +namespace PhpMcp\Server; use JsonException; use PhpMcp\Schema\JsonRpc\Request; @@ -39,12 +39,13 @@ use PhpMcp\Server\Protocol; use PhpMcp\Server\Registry; use PhpMcp\Server\Session\SubscriptionManager; +use PhpMcp\Server\Support\SchemaValidator; use PhpMcp\Server\Traits\ResponseFormatter; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Throwable; -class RequestHandler +class Dispatcher { use ResponseFormatter; @@ -159,7 +160,7 @@ public function handleToolCall(CallToolRequest $request): CallToolResult $toolName = $request->name; $arguments = $request->arguments; - ['tool' => $tool, 'invoker' => $invoker] = $this->registry->getTool($toolName); + ['tool' => $tool, 'handler' => $handler] = $this->registry->getTool($toolName); if (! $tool) { throw McpServerException::methodNotFound("Tool '{$toolName}' not found."); } @@ -187,7 +188,7 @@ public function handleToolCall(CallToolRequest $request): CallToolResult } try { - $result = $invoker->invoke($this->container, $arguments); + $result = $handler->handle($this->container, $arguments); $formattedResult = $this->formatToolResult($result); return new CallToolResult($formattedResult, false); @@ -230,7 +231,7 @@ public function handleResourceRead(ReadResourceRequest $request): ReadResourceRe { $uri = $request->uri; - ['resource' => $resource, 'invoker' => $invoker, 'variables' => $uriVariables] = $this->registry->getResource($uri); + ['resource' => $resource, 'handler' => $handler, 'variables' => $uriVariables] = $this->registry->getResource($uri); if (! $resource) { throw McpServerException::invalidParams("Resource URI '{$uri}' not found."); @@ -238,7 +239,7 @@ public function handleResourceRead(ReadResourceRequest $request): ReadResourceRe try { $arguments = array_merge($uriVariables, ['uri' => $uri]); - $result = $invoker->invoke($this->container, $arguments); + $result = $handler->handle($this->container, $arguments); $contents = $this->formatResourceContents($result, $uri, $resource->mimeType); return new ReadResourceResult($contents); @@ -281,7 +282,7 @@ public function handlePromptGet(GetPromptRequest $request): GetPromptResult $promptName = $request->name; $arguments = $request->arguments; - ['prompt' => $prompt, 'invoker' => $invoker] = $this->registry->getPrompt($promptName); + ['prompt' => $prompt, 'handler' => $handler] = $this->registry->getPrompt($promptName); if (! $prompt) { throw McpServerException::invalidParams("Prompt '{$promptName}' not found."); } @@ -295,7 +296,7 @@ public function handlePromptGet(GetPromptRequest $request): GetPromptResult } try { - $result = $invoker->invoke($this->container, $arguments); + $result = $handler->handle($this->container, $arguments); $messages = $this->formatPromptMessages($result); return new GetPromptResult($messages, $prompt->description); diff --git a/src/Protocol.php b/src/Protocol.php index ded2ee0..091d27d 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -51,12 +51,12 @@ public function __construct( protected Configuration $configuration, protected Registry $registry, protected SessionManager $sessionManager, - protected ?RequestHandler $requestHandler = null, + protected ?Dispatcher $dispatcher = null, protected ?SubscriptionManager $subscriptionManager = null, ) { $this->logger = $this->configuration->logger; $this->subscriptionManager ??= new SubscriptionManager($this->logger); - $this->requestHandler ??= new RequestHandler($this->configuration, $this->registry, $this->subscriptionManager); + $this->dispatcher ??= new Dispatcher($this->configuration, $this->registry, $this->subscriptionManager); $this->sessionManager->on('session_deleted', function (string $sessionId) { $this->subscriptionManager->cleanupSession($sessionId); @@ -174,7 +174,7 @@ private function processRequest(Request $request, SessionInterface $session): Re $this->assertRequestCapability($request->method); - $result = $this->requestHandler->handleRequest($request, $session); + $result = $this->dispatcher->handleRequest($request, $session); return Response::make($request->id, $result); } catch (McpServerException $e) { @@ -207,7 +207,7 @@ private function processNotification(Notification $notification, SessionInterfac $params = $notification->params; try { - $this->requestHandler->handleNotification($notification, $session); + $this->dispatcher->handleNotification($notification, $session); } catch (Throwable $e) { $this->logger->error('Error while processing notification', ['method' => $method, 'exception' => $e->getMessage()]); return; diff --git a/src/Registry.php b/src/Registry.php index 9d78749..6cc5414 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -11,7 +11,7 @@ use PhpMcp\Schema\ResourceTemplate; use PhpMcp\Schema\Tool; use PhpMcp\Server\Exception\DefinitionException; -use PhpMcp\Server\Support\MethodInvoker; +use PhpMcp\Server\Support\Handler; use PhpMcp\Server\Support\UriTemplateMatcher; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; @@ -24,16 +24,16 @@ class Registry implements EventEmitterInterface private const DISCOVERED_ELEMENTS_CACHE_KEY = 'mcp_server_discovered_elements'; - /** @var array */ + /** @var array */ private array $tools = []; - /** @var array */ + /** @var array */ private array $resources = []; - /** @var array */ + /** @var array */ private array $prompts = []; - /** @var array */ + /** @var array */ private array $resourceTemplates = []; /** @var array */ @@ -104,7 +104,7 @@ public function load(): void $loadCount = 0; foreach ($cached['tools'] ?? [] as $toolData) { - if (!isset($toolData['tool']) || !isset($toolData['invoker'])) { + if (!isset($toolData['tool']) || !isset($toolData['handler'])) { $this->logger->warning('Invalid tool data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'data' => $toolData]); continue; } @@ -113,7 +113,7 @@ public function load(): void if (! isset($this->manualToolNames[$toolName])) { $this->tools[$toolName] = [ 'tool' => Tool::fromArray($toolData['tool']), - 'invoker' => MethodInvoker::fromArray($toolData['invoker']), + 'handler' => Handler::fromArray($toolData['handler']), ]; $loadCount++; } else { @@ -122,7 +122,7 @@ public function load(): void } foreach ($cached['resources'] ?? [] as $resourceData) { - if (!isset($resourceData['resource']) || !isset($resourceData['invoker'])) { + if (!isset($resourceData['resource']) || !isset($resourceData['handler'])) { $this->logger->warning('Invalid resource data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'data' => $resourceData]); continue; } @@ -131,7 +131,7 @@ public function load(): void if (! isset($this->manualResourceUris[$uri])) { $this->resources[$uri] = [ 'resource' => Resource::fromArray($resourceData['resource']), - 'invoker' => MethodInvoker::fromArray($resourceData['invoker']), + 'handler' => Handler::fromArray($resourceData['handler']), ]; $loadCount++; } else { @@ -140,7 +140,7 @@ public function load(): void } foreach ($cached['prompts'] ?? [] as $promptData) { - if (!isset($promptData['prompt']) || !isset($promptData['invoker'])) { + if (!isset($promptData['prompt']) || !isset($promptData['handler'])) { $this->logger->warning('Invalid prompt data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'data' => $promptData]); continue; } @@ -149,7 +149,7 @@ public function load(): void if (! isset($this->manualPromptNames[$promptName])) { $this->prompts[$promptName] = [ 'prompt' => Prompt::fromArray($promptData['prompt']), - 'invoker' => MethodInvoker::fromArray($promptData['invoker']), + 'handler' => Handler::fromArray($promptData['handler']), ]; $loadCount++; } else { @@ -158,7 +158,7 @@ public function load(): void } foreach ($cached['resourceTemplates'] ?? [] as $templateData) { - if (!isset($templateData['resourceTemplate']) || !isset($templateData['invoker'])) { + if (!isset($templateData['resourceTemplate']) || !isset($templateData['handler'])) { $this->logger->warning('Invalid resource template data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'data' => $templateData]); continue; } @@ -167,7 +167,7 @@ public function load(): void if (! isset($this->manualTemplateUris[$uriTemplate])) { $this->resourceTemplates[$uriTemplate] = [ 'resourceTemplate' => ResourceTemplate::fromArray($templateData['resourceTemplate']), - 'invoker' => MethodInvoker::fromArray($templateData['invoker']), + 'handler' => Handler::fromArray($templateData['handler']), ]; $loadCount++; } else { @@ -190,7 +190,7 @@ public function load(): void } } - public function registerTool(Tool $tool, MethodInvoker $invoker, bool $isManual = false): void + public function registerTool(Tool $tool, Handler $handler, bool $isManual = false): void { $toolName = $tool->name; $exists = isset($this->tools[$toolName]); @@ -208,7 +208,7 @@ public function registerTool(Tool $tool, MethodInvoker $invoker, bool $isManual $this->tools[$toolName] = [ 'tool' => $tool, - 'invoker' => $invoker, + 'handler' => $handler, ]; if ($isManual) { @@ -220,7 +220,7 @@ public function registerTool(Tool $tool, MethodInvoker $invoker, bool $isManual $this->checkAndEmitChange('tools', $this->tools); } - public function registerResource(Resource $resource, MethodInvoker $invoker, bool $isManual = false): void + public function registerResource(Resource $resource, Handler $handler, bool $isManual = false): void { $uri = $resource->uri; $exists = isset($this->resources[$uri]); @@ -237,7 +237,7 @@ public function registerResource(Resource $resource, MethodInvoker $invoker, boo $this->resources[$uri] = [ 'resource' => $resource, - 'invoker' => $invoker, + 'handler' => $handler, ]; if ($isManual) { @@ -249,7 +249,7 @@ public function registerResource(Resource $resource, MethodInvoker $invoker, boo $this->checkAndEmitChange('resources', $this->resources); } - public function registerResourceTemplate(ResourceTemplate $template, MethodInvoker $invoker, bool $isManual = false): void + public function registerResourceTemplate(ResourceTemplate $template, Handler $handler, bool $isManual = false): void { $uriTemplate = $template->uriTemplate; $exists = isset($this->resourceTemplates[$uriTemplate]); @@ -266,7 +266,7 @@ public function registerResourceTemplate(ResourceTemplate $template, MethodInvok $this->resourceTemplates[$uriTemplate] = [ 'resourceTemplate' => $template, - 'invoker' => $invoker, + 'handler' => $handler, ]; if ($isManual) { @@ -278,7 +278,7 @@ public function registerResourceTemplate(ResourceTemplate $template, MethodInvok // No listChanged for templates } - public function registerPrompt(Prompt $prompt, MethodInvoker $invoker, bool $isManual = false): void + public function registerPrompt(Prompt $prompt, Handler $handler, bool $isManual = false): void { $promptName = $prompt->name; $exists = isset($this->prompts[$promptName]); @@ -295,7 +295,7 @@ public function registerPrompt(Prompt $prompt, MethodInvoker $invoker, bool $isM $this->prompts[$promptName] = [ 'prompt' => $prompt, - 'invoker' => $invoker, + 'handler' => $handler, ]; if ($isManual) { @@ -351,7 +351,7 @@ public function save(): bool if (! isset($this->manualToolNames[$name])) { $discoveredData['tools'][$name] = [ 'tool' => $tool['tool']->toArray(), - 'invoker' => $tool['invoker']->toArray(), + 'handler' => $tool['handler']->toArray(), ]; } } @@ -360,7 +360,7 @@ public function save(): bool if (! isset($this->manualResourceUris[$uri])) { $discoveredData['resources'][$uri] = [ 'resource' => $resource['resource']->toArray(), - 'invoker' => $resource['invoker']->toArray(), + 'handler' => $resource['handler']->toArray(), ]; } } @@ -369,7 +369,7 @@ public function save(): bool if (! isset($this->manualPromptNames[$name])) { $discoveredData['prompts'][$name] = [ 'prompt' => $prompt['prompt']->toArray(), - 'invoker' => $prompt['invoker']->toArray(), + 'handler' => $prompt['handler']->toArray(), ]; } } @@ -378,7 +378,7 @@ public function save(): bool if (! isset($this->manualTemplateUris[$uriTemplate])) { $discoveredData['resourceTemplates'][$uriTemplate] = [ 'resourceTemplate' => $template['resourceTemplate']->toArray(), - 'invoker' => $template['invoker']->toArray(), + 'handler' => $template['handler']->toArray(), ]; } } @@ -456,7 +456,7 @@ public function clear(): void } } - /** @return array{tool: Tool, invoker: MethodInvoker}|null */ + /** @return array{tool: Tool, handler: Handler}|null */ public function getTool(string $name): ?array { return $this->tools[$name] ?? null; @@ -464,7 +464,7 @@ public function getTool(string $name): ?array /** @return array{ * resource: Resource, - * invoker: MethodInvoker, + * handler: Handler, * variables: array, * }|null */ public function getResource(string $uri, bool $includeTemplates = true): ?array @@ -494,7 +494,7 @@ public function getResource(string $uri, bool $includeTemplates = true): ?array if ($variables !== null) { return [ 'resource' => $template['resourceTemplate'], - 'invoker' => $template['invoker'], + 'handler' => $template['handler'], 'variables' => $variables, ]; } @@ -505,7 +505,7 @@ public function getResource(string $uri, bool $includeTemplates = true): ?array return null; } - /** @return array{prompt: Prompt, invoker: MethodInvoker}|null */ + /** @return array{prompt: Prompt, handler: Handler}|null */ public function getPrompt(string $name): ?array { return $this->prompts[$name] ?? null; @@ -514,24 +514,24 @@ public function getPrompt(string $name): ?array /** @return array */ public function getTools(): array { - return array_map(fn($registration) => $registration['tool'], $this->tools); + return array_map(fn ($registration) => $registration['tool'], $this->tools); } /** @return array */ public function getResources(): array { - return array_map(fn($registration) => $registration['resource'], $this->resources); + return array_map(fn ($registration) => $registration['resource'], $this->resources); } /** @return array */ public function getPrompts(): array { - return array_map(fn($registration) => $registration['prompt'], $this->prompts); + return array_map(fn ($registration) => $registration['prompt'], $this->prompts); } /** @return array */ public function getResourceTemplates(): array { - return array_map(fn($registration) => $registration['resourceTemplate'], $this->resourceTemplates); + return array_map(fn ($registration) => $registration['resourceTemplate'], $this->resourceTemplates); } } diff --git a/src/Server.php b/src/Server.php index 36a649e..9d3e0c5 100644 --- a/src/Server.php +++ b/src/Server.php @@ -41,7 +41,8 @@ public function __construct( protected readonly Registry $registry, protected readonly Protocol $protocol, protected readonly SessionManager $sessionManager, - ) {} + ) { + } public static function make(): ServerBuilder { diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index 55982bc..f0d7df5 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -21,7 +21,7 @@ use PhpMcp\Server\Session\CacheSessionHandler; use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Support\HandlerResolver; -use PhpMcp\Server\Support\MethodInvoker; +use PhpMcp\Server\Support\Handler; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -88,7 +88,9 @@ final class ServerBuilder * > */ private array $manualPrompts = []; - public function __construct() {} + public function __construct() + { + } /** * Sets the server's identity. Required. @@ -300,8 +302,8 @@ private function performManualRegistrations(Registry $registry, LoggerInterface $inputSchema = $schemaGenerator->fromMethodParameters($reflectionMethod); $tool = Tool::make($name, $inputSchema, $description, $data['annotations']); - $invoker = new MethodInvoker($className, $methodName); - $registry->registerTool($tool, $invoker, true); + $handler = new Handler($className, $methodName); + $registry->registerTool($tool, $handler, true); $logger->debug("Registered manual tool {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { @@ -326,8 +328,8 @@ private function performManualRegistrations(Registry $registry, LoggerInterface $annotations = $data['annotations']; $resource = Resource::make($uri, $name, $description, $mimeType, $annotations, $size); - $invoker = new MethodInvoker($className, $methodName); - $registry->registerResource($resource, $invoker, true); + $handler = new Handler($className, $methodName); + $registry->registerResource($resource, $handler, true); $logger->debug("Registered manual resource {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { @@ -351,8 +353,8 @@ private function performManualRegistrations(Registry $registry, LoggerInterface $annotations = $data['annotations']; $template = ResourceTemplate::make($uriTemplate, $name, $description, $mimeType, $annotations); - $invoker = new MethodInvoker($className, $methodName); - $registry->registerResourceTemplate($template, $invoker, true); + $handler = new Handler($className, $methodName); + $registry->registerResourceTemplate($template, $handler, true); $logger->debug("Registered manual template {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { @@ -391,8 +393,8 @@ private function performManualRegistrations(Registry $registry, LoggerInterface } $prompt = Prompt::make($name, $description, $arguments); - $invoker = new MethodInvoker($className, $methodName); - $registry->registerPrompt($prompt, $invoker, true); + $handler = new Handler($className, $methodName); + $registry->registerPrompt($prompt, $handler, true); $logger->debug("Registered manual prompt {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { $errorCount++; diff --git a/src/Support/Discoverer.php b/src/Support/Discoverer.php index 98dfa15..fb839ae 100644 --- a/src/Support/Discoverer.php +++ b/src/Support/Discoverer.php @@ -13,12 +13,7 @@ use PhpMcp\Server\Attributes\McpResource; use PhpMcp\Server\Attributes\McpResourceTemplate; use PhpMcp\Server\Attributes\McpTool; -use PhpMcp\Server\Definitions\PromptDefinition; -use PhpMcp\Server\Definitions\ResourceDefinition; -use PhpMcp\Server\Definitions\ResourceTemplateDefinition; -use PhpMcp\Server\Definitions\ToolDefinition; use PhpMcp\Server\Exception\McpServerException; -use PhpMcp\Server\Handler; use PhpMcp\Server\Registry; use Psr\Log\LoggerInterface; use ReflectionAttribute; @@ -200,8 +195,8 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; $inputSchema = $this->schemaGenerator->fromMethodParameters($method); $tool = Tool::make($name, $inputSchema, $description, $instance->annotations); - $invoker = new MethodInvoker($className, $methodName); - $this->registry->registerTool($tool, $invoker, true); + $handler = new Handler($className, $methodName); + $this->registry->registerTool($tool, $handler, true); $discoveredCount['tools']++; break; @@ -213,8 +208,8 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $size = $instance->size; $annotations = $instance->annotations; $resource = Resource::make($instance->uri, $name, $description, $mimeType, $annotations, $size); - $invoker = new MethodInvoker($className, $methodName); - $this->registry->registerResource($resource, $invoker, true); + $handler = new Handler($className, $methodName); + $this->registry->registerResource($resource, $handler, true); $discoveredCount['resources']++; break; @@ -233,8 +228,8 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $arguments[] = PromptArgument::make($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, ! $param->isOptional() && ! $param->isDefaultValueAvailable()); } $prompt = Prompt::make($name, $description, $arguments); - $invoker = new MethodInvoker($className, $methodName); - $this->registry->registerPrompt($prompt, $invoker, true); + $handler = new Handler($className, $methodName); + $this->registry->registerPrompt($prompt, $handler, true); $discoveredCount['prompts']++; break; @@ -245,8 +240,8 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $mimeType = $instance->mimeType; $annotations = $instance->annotations; $resourceTemplate = ResourceTemplate::make($instance->uriTemplate, $name, $description, $mimeType, $annotations); - $invoker = new MethodInvoker($className, $methodName); - $this->registry->registerResourceTemplate($resourceTemplate, $invoker, true); + $handler = new Handler($className, $methodName); + $this->registry->registerResourceTemplate($resourceTemplate, $handler, true); $discoveredCount['resourceTemplates']++; break; } diff --git a/src/Support/MethodInvoker.php b/src/Support/Handler.php similarity index 98% rename from src/Support/MethodInvoker.php rename to src/Support/Handler.php index 1e01da9..b079e84 100644 --- a/src/Support/MethodInvoker.php +++ b/src/Support/Handler.php @@ -14,14 +14,15 @@ use Throwable; use TypeError; -class MethodInvoker +class Handler { public function __construct( public readonly string $className, public readonly string $methodName, - ) {} + ) { + } - public function invoke(ContainerInterface $container, array $arguments): mixed + public function handle(ContainerInterface $container, array $arguments): mixed { $instance = $container->get($this->className); $arguments = $this->prepareArguments($instance, $arguments); diff --git a/src/Traits/ResponseFormatter.php b/src/Traits/ResponseFormatter.php index 8de7ba4..96c4d74 100644 --- a/src/Traits/ResponseFormatter.php +++ b/src/Traits/ResponseFormatter.php @@ -110,7 +110,7 @@ protected function formatResourceContents(mixed $readResult, string $uri, ?strin } if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof EmbeddedResource) { - return array_map(fn($item) => $item->resource, $readResult); + return array_map(fn ($item) => $item->resource, $readResult); } if (is_string($readResult)) { diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php index 811c2d0..fbfa436 100644 --- a/src/Transports/StreamableHttpServerTransport.php +++ b/src/Transports/StreamableHttpServerTransport.php @@ -167,9 +167,9 @@ private function createRequestHandler(): callable try { return match ($method) { - 'GET' => $this->handleGetRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), - 'POST' => $this->handlePostRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), - 'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), + 'GET' => $this->handleGetRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), + 'POST' => $this->handlePostRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), + 'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), default => $addCors($this->handleUnsupportedRequest($request)), }; } catch (Throwable $e) { diff --git a/tests/Mocks/DiscoveryStubs/ToolOnlyStub.php b/tests/Mocks/DiscoveryStubs/ToolOnlyStub.php index 9235a67..c5f6414 100644 --- a/tests/Mocks/DiscoveryStubs/ToolOnlyStub.php +++ b/tests/Mocks/DiscoveryStubs/ToolOnlyStub.php @@ -6,8 +6,12 @@ class ToolOnlyStub { - public function __invoke(): void {} + public function __invoke(): void + { + } #[McpTool(name: 'tool-from-file1')] - public function tool1(): void {} + public function tool1(): void + { + } } diff --git a/tests/Mocks/SupportStubs/DocBlockArrayTestStub.php b/tests/Mocks/SupportStubs/DocBlockArrayTestStub.php index df5aac0..e366d91 100644 --- a/tests/Mocks/SupportStubs/DocBlockArrayTestStub.php +++ b/tests/Mocks/SupportStubs/DocBlockArrayTestStub.php @@ -9,7 +9,7 @@ class DocBlockArrayTestStub { /** * Method with simple array[] syntax - * + * * @param string[] $strings Array of strings using [] syntax * @param int[] $integers Array of integers using [] syntax * @param bool[] $booleans Array of booleans using [] syntax @@ -20,7 +20,7 @@ class DocBlockArrayTestStub public function simpleArraySyntax( array $strings, array $integers, - array $booleans, + array $booleans, array $floats, array $objects, array $dateTimeInstances @@ -29,7 +29,7 @@ public function simpleArraySyntax( /** * Method with array generic syntax - * + * * @param array $strings Array of strings using generic syntax * @param array $integers Array of integers using generic syntax * @param array $booleans Array of booleans using generic syntax @@ -39,7 +39,7 @@ public function simpleArraySyntax( */ public function genericArraySyntax( array $strings, - array $integers, + array $integers, array $booleans, array $floats, array $objects, @@ -49,7 +49,7 @@ public function genericArraySyntax( /** * Method with nested array syntax - * + * * @param array> $nestedStringArrays Array of arrays of strings * @param array> $nestedIntArrays Array of arrays of integers * @param string[][] $doubleStringArrays Array of arrays of strings using double [] @@ -65,7 +65,7 @@ public function nestedArraySyntax( /** * Method with object-like array syntax - * + * * @param array{name: string, age: int} $person Simple object array with name and age * @param array{id: int, title: string, tags: string[]} $article Article with array of tags * @param array{user: array{id: int, name: string}, items: array} $order Order with nested user object and array of item IDs @@ -76,4 +76,4 @@ public function objectArraySyntax( array $order ): void { } -} \ No newline at end of file +} diff --git a/tests/Mocks/SupportStubs/SchemaAttributeTestStub.php b/tests/Mocks/SupportStubs/SchemaAttributeTestStub.php index 1f73cee..a0de043 100644 --- a/tests/Mocks/SupportStubs/SchemaAttributeTestStub.php +++ b/tests/Mocks/SupportStubs/SchemaAttributeTestStub.php @@ -31,13 +31,11 @@ public function stringConstraints( * @param int $count Count with multipleOf constraint */ public function numericConstraints( - #[Schema(minimum: 18, maximum: 120)] + #[Schema(minimum: 18, maximum: 120)] int $age, - #[Schema(minimum: 0, maximum: 5, exclusiveMaximum: true)] float $rating, - - #[Schema(minimum: 0, multipleOf: 10)] + #[Schema(minimum: 0, multipleOf: 10)] int $count ): void { } @@ -50,9 +48,9 @@ public function numericConstraints( * @param array $mixed Mixed array with no constraints */ public function arrayConstraints( - #[Schema(minItems: 1, uniqueItems: true)] + #[Schema(minItems: 1, uniqueItems: true)] array $tags, - #[Schema(minItems: 1, maxItems: 5, items: new ArrayItems(minimum: 0, maximum: 100))] + #[Schema(minItems: 1, maxItems: 5, items: new ArrayItems(minimum: 0, maximum: 100))] array $scores, array $mixed ): void { @@ -72,10 +70,9 @@ public function objectConstraints( new Property('age', minimum: 18) ], required: ['name', 'email'] - )] + )] array $user, - - #[Schema(additionalProperties: true)] + #[Schema(additionalProperties: true)] array $config ): void { } @@ -88,14 +85,16 @@ public function objectConstraints( public function nestedConstraints( #[Schema( properties: [ - new Property('customer', + new Property( + 'customer', properties: [ new Property('id', pattern: '^CUS-[0-9]{6}$'), new Property('name', minLength: 2) ], required: ['id'] ), - new Property('items', + new Property( + 'items', minItems: 1, items: new ArrayItems( properties: [ @@ -121,11 +120,11 @@ public function nestedConstraints( public function typePrecedenceTest( string $numericString, // PHP says string - #[Schema(format: Format::EMAIL)] + #[Schema(format: Format::EMAIL)] string $stringWithFormat, // PHP + Schema - #[Schema(items: new ArrayItems(minimum: 1, maximum: 100))] + #[Schema(items: new ArrayItems(minimum: 1, maximum: 100))] array $arrayWithItems // Schema overrides DocBlock ): void { } -} \ No newline at end of file +} diff --git a/tests/Unit/JsonRpc/NotificationTest.php b/tests/Unit/JsonRpc/NotificationTest.php index 817d9de..79417a6 100644 --- a/tests/Unit/JsonRpc/NotificationTest.php +++ b/tests/Unit/JsonRpc/NotificationTest.php @@ -60,27 +60,27 @@ 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 + expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException for missing jsonrpc', function () { $data = ['method' => 'test.method']; - expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED + expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException for missing method', function () { $data = ['jsonrpc' => '2.0']; - expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED + expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException for non-string method', function () { $data = ['jsonrpc' => '2.0', 'method' => 123]; - expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED + expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED }); test('fromArray throws ProtocolException if params is not an array/object', function () { $data = ['jsonrpc' => '2.0', 'method' => 'test', 'params' => 'string']; - expect(fn() => Notification::fromArray($data))->toThrow(ProtocolException::class); + expect(fn () => Notification::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 index 5402be1..d22b14e 100644 --- a/tests/Unit/JsonRpc/ResponseTest.php +++ b/tests/Unit/JsonRpc/ResponseTest.php @@ -41,24 +41,24 @@ }); test('response constructor throws exception if ID present but no result/error', function () { - expect(fn() => new Response('2.0', 1, null, null)) + 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)) + 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)) + 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); + expect(fn () => new Response('2.0', 1, $result, $error))->toThrow(InvalidArgumentException::class); }); test('success static method creates success response', function () { @@ -175,39 +175,39 @@ test('fromArray throws exception for invalid jsonrpc version', function () { $data = ['jsonrpc' => '1.0', 'id' => 1, 'result' => []]; - expect(fn() => Response::fromArray($data))->toThrow(ProtocolException::class); + 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"'); + expect(fn () => Response::fromArray($data))->toThrow(ProtocolException::class, 'must contain either "result" or "error"'); }); test('fromArray throws exception for response with null ID but missing error', function () { $data = ['jsonrpc' => '2.0', 'id' => null]; - expect(fn() => Response::fromArray($data))->toThrow(ProtocolException::class, 'must contain "error" when ID is null'); + expect(fn () => Response::fromArray($data))->toThrow(ProtocolException::class, 'must contain "error" when ID is null'); }); test('fromArray throws exception for response with null ID and result present', function () { $data = ['jsonrpc' => '2.0', 'id' => null, 'result' => 'abc', 'error' => ['code' => -32700, 'message' => 'e']]; $dataOnlyResult = ['jsonrpc' => '2.0', 'id' => null, 'result' => 'abc']; - expect(fn() => Response::fromArray($dataOnlyResult)) + expect(fn () => Response::fromArray($dataOnlyResult)) ->toThrow(ProtocolException::class, 'must contain "error" when ID is null'); }); test('fromArray throws exception for invalid ID type', function () { $data = ['jsonrpc' => '2.0', 'id' => [], 'result' => 'ok']; - expect(fn() => Response::fromArray($data))->toThrow(ProtocolException::class, 'Invalid "id" field type'); + 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'); + 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)) + expect(fn () => Response::fromArray($data)) ->toThrow(ProtocolException::class, 'Invalid "error" object structure'); }); diff --git a/tests/Unit/ProtocolTest.php b/tests/Unit/ProtocolTest.php index 741e402..28762f4 100644 --- a/tests/Unit/ProtocolTest.php +++ b/tests/Unit/ProtocolTest.php @@ -102,7 +102,7 @@ $rawJson = '{"jsonrpc":"2.0", "id":'; $this->requestProcessor->shouldNotReceive('process'); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32700') && str_contains($json, '"id":null')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32700') && str_contains($json, '"id":null')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); @@ -112,7 +112,7 @@ $rawJson = '{"jsonrpc":"2.0", "id": 456}'; // Missing method $this->requestProcessor->shouldNotReceive('process'); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32600') && str_contains($json, '"id":456')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32600') && str_contains($json, '"id":456')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); @@ -125,7 +125,7 @@ $mcpException = McpServerException::methodNotFound($method); $this->requestProcessor->shouldReceive('process')->once()->andThrow($mcpException); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32601') && str_contains($json, '"id":789')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32601') && str_contains($json, '"id":789')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); @@ -138,7 +138,7 @@ $internalException = new \RuntimeException('Borked'); $this->requestProcessor->shouldReceive('process')->once()->andThrow($internalException); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn($json) => str_contains($json, '"code":-32603') && str_contains($json, '"id":101')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32603') && str_contains($json, '"id":101')))->andReturn(resolve(null)); $this->protocol->handleRawMessage($rawJson, $clientId); }); From a6fbb96893b74657be5b9e1cf2bfea47550a689d Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Tue, 10 Jun 2025 12:40:25 +0100 Subject: [PATCH 11/27] feat: Implement argument completion provider registration for MCP elements - New `CompletionProviderInterface` and `CompletionProvider` attribute. - Updated the Dispatcher to handle completion requests, integrating completion logic for prompts and resource templates. - Enhanced the Registry to manage completion providers, allowing for better organization and retrieval of completion logic. - Introduced methods in ServerBuilder and Discoverer to facilitate automatic registration of completion providers during element discovery and server setup. --- .../McpElements.php | 17 +++- .../UserIdCompletionProvider.php | 19 ++++ .../02-discovery-http-userprofile/server.php | 3 + src/Attributes/CompletionProvider.php | 17 ++++ src/Contracts/CompletionProviderInterface.php | 17 ++++ src/Dispatcher.php | 76 ++++++++++++---- src/Protocol.php | 6 ++ src/Registry.php | 91 ++++++++++++++++++- src/ServerBuilder.php | 38 +++++++- src/Support/Discoverer.php | 26 +++++- src/Support/UriTemplateMatcher.php | 5 + 11 files changed, 282 insertions(+), 33 deletions(-) create mode 100644 examples/02-discovery-http-userprofile/UserIdCompletionProvider.php create mode 100644 src/Attributes/CompletionProvider.php create mode 100644 src/Contracts/CompletionProviderInterface.php diff --git a/examples/02-discovery-http-userprofile/McpElements.php b/examples/02-discovery-http-userprofile/McpElements.php index 59cb20c..51cc47b 100644 --- a/examples/02-discovery-http-userprofile/McpElements.php +++ b/examples/02-discovery-http-userprofile/McpElements.php @@ -2,6 +2,7 @@ namespace Mcp\HttpUserProfileExample; +use PhpMcp\Server\Attributes\CompletionProvider; use PhpMcp\Server\Attributes\McpPrompt; use PhpMcp\Server\Attributes\McpResource; use PhpMcp\Server\Attributes\McpResourceTemplate; @@ -40,8 +41,11 @@ public function __construct(LoggerInterface $logger) description: 'Get profile information for a specific user ID.', mimeType: 'application/json' )] - public function getUserProfile(string $userId): array - { + + public function getUserProfile( + #[CompletionProvider(providerClass: UserIdCompletionProvider::class)] + string $userId + ): array { $this->logger->info('Reading resource: user profile', ['userId' => $userId]); if (! isset($this->users[$userId])) { // Throwing an exception that Processor can turn into an error response @@ -87,7 +91,7 @@ public function sendWelcomeMessage(string $userId, ?string $customMessage = null $user = $this->users[$userId]; $message = "Welcome, {$user['name']}!"; if ($customMessage) { - $message .= ' '.$customMessage; + $message .= ' ' . $customMessage; } // Simulate sending $this->logger->info("Simulated sending message to {$user['email']}: {$message}"); @@ -105,8 +109,11 @@ public function sendWelcomeMessage(string $userId, ?string $customMessage = null * @throws McpServerException If user not found. */ #[McpPrompt(name: 'generate_bio_prompt')] - public function generateBio(string $userId, string $tone = 'professional'): array - { + public function generateBio( + #[CompletionProvider(providerClass: UserIdCompletionProvider::class)] + string $userId, + string $tone = 'professional' + ): array { $this->logger->info('Executing prompt: generate_bio', ['userId' => $userId, 'tone' => $tone]); if (! isset($this->users[$userId])) { throw McpServerException::invalidParams("User not found for bio prompt: {$userId}"); diff --git a/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php b/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php new file mode 100644 index 0000000..983494a --- /dev/null +++ b/examples/02-discovery-http-userprofile/UserIdCompletionProvider.php @@ -0,0 +1,19 @@ + str_contains($userId, $currentValue)); + + return $filteredUserIds; + } +} diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php index 4b8bf33..5ef393e 100644 --- a/examples/02-discovery-http-userprofile/server.php +++ b/examples/02-discovery-http-userprofile/server.php @@ -39,7 +39,9 @@ chdir(__DIR__); require_once '../../vendor/autoload.php'; require_once 'McpElements.php'; +require_once 'UserIdCompletionProvider.php'; +use PhpMcp\Schema\ServerCapabilities; use PhpMcp\Server\Defaults\BasicContainer; use PhpMcp\Server\Server; use PhpMcp\Server\Transports\HttpServerTransport; @@ -65,6 +67,7 @@ public function log($level, \Stringable|string $message, array $context = []): v $server = Server::make() ->withServerInfo('HTTP User Profiles', '1.0.0') + ->withCapabilities(ServerCapabilities::make(completionsEnabled: true)) ->withLogger($logger) ->withContainer($container) ->build(); diff --git a/src/Attributes/CompletionProvider.php b/src/Attributes/CompletionProvider.php new file mode 100644 index 0000000..fea330d --- /dev/null +++ b/src/Attributes/CompletionProvider.php @@ -0,0 +1,17 @@ + $providerClass FQCN of the completion provider class. + */ + public function __construct(public string $providerClass) {} +} diff --git a/src/Contracts/CompletionProviderInterface.php b/src/Contracts/CompletionProviderInterface.php new file mode 100644 index 0000000..eecff9a --- /dev/null +++ b/src/Contracts/CompletionProviderInterface.php @@ -0,0 +1,17 @@ +ref; - $argument = $request->argument; - - $completionValues = []; - $total = null; - $hasMore = null; - - // TODO: Implement actual completion logic here. - // This requires a way to: - // 1. Find the target prompt or resource template definition. - // 2. Determine if that definition has a completion provider for the given $argName. - // 3. Invoke that provider with $currentValue and $session (for context). - - // --- Example Logic --- - if ($argument['name'] === 'userId') { - $completionValues = ['101', '102', '103']; - $total = 3; + $argumentName = $request->argument['name']; + $currentValue = $request->argument['value']; + + $identifier = null; + + if ($ref->type === 'ref/prompt') { + $identifier = $ref->name; + ['prompt' => $prompt] = $this->registry->getPrompt($identifier); + if (! $prompt) { + throw McpServerException::invalidParams("Prompt '{$identifier}' not found."); + } + + $foundArg = false; + foreach ($prompt->arguments as $arg) { + if ($arg->name === $argumentName) { + $foundArg = true; + break; + } + } + if (! $foundArg) { + throw McpServerException::invalidParams("Argument '{$argumentName}' not found in prompt '{$identifier}'."); + } + } else if ($ref->type === 'ref/resource') { + $identifier = $ref->uri; + ['resourceTemplate' => $resourceTemplate, 'variables' => $uriVariables] = $this->registry->getResourceTemplate($identifier); + if (! $resourceTemplate) { + throw McpServerException::invalidParams("Resource template '{$identifier}' not found."); + } + + $foundArg = false; + foreach ($uriVariables as $uriVariableName) { + if ($uriVariableName === $argumentName) { + $foundArg = true; + break; + } + } + + if (! $foundArg) { + throw McpServerException::invalidParams("URI variable '{$argumentName}' not found in resource template '{$identifier}'."); + } + } else { + throw McpServerException::invalidParams("Invalid ref type '{$ref->type}' for completion complete request."); + } + + $providerClass = $this->registry->getCompletionProvider($ref->type, $identifier, $argumentName); + if (! $providerClass) { + $this->logger->warning("No completion provider found for argument '{$argumentName}' in '{$ref->type}' '{$identifier}'."); + return new CompletionCompleteResult([]); } - // --- End Example --- - return new CompletionCompleteResult($completionValues, $total, $hasMore); + $provider = $this->container->get($providerClass); + + $completions = $provider->getCompletions($currentValue, $session); + + $total = count($completions); + $hasMore = $total > 100; + + $pagedCompletions = array_slice($completions, 0, 100); + + return new CompletionCompleteResult($pagedCompletions, $total, $hasMore); } public function handleNotificationInitialized(InitializedNotification $notification, SessionInterface $session): EmptyResult diff --git a/src/Protocol.php b/src/Protocol.php index 091d27d..c4de56e 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -119,6 +119,12 @@ public function processMessage(Request|Notification|BatchRequest $message, strin $session = $this->sessionManager->getSession($sessionId); + if ($session === null) { + $error = Error::forInvalidRequest('Invalid or expired session. Please re-initialize the session.', $message->id); + $this->transport->sendMessage($error, $sessionId, $context); + return; + } + $response = null; if ($message instanceof BatchRequest) { diff --git a/src/Registry.php b/src/Registry.php index 6cc5414..95fde7f 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -10,6 +10,7 @@ use PhpMcp\Schema\Resource; use PhpMcp\Schema\ResourceTemplate; use PhpMcp\Schema\Tool; +use PhpMcp\Server\Contracts\CompletionProviderInterface; use PhpMcp\Server\Exception\DefinitionException; use PhpMcp\Server\Support\Handler; use PhpMcp\Server\Support\UriTemplateMatcher; @@ -55,6 +56,30 @@ class Registry implements EventEmitterInterface 'prompts' => '', ]; + /** + * Stores completion providers. + * Structure: + * [ + * 'ref/prompt' => [ // Ref Type + * 'prompt_name_1' => [ // Element Name/URI + * 'argument_name_A' => 'ProviderClassFQCN_For_Prompt1_ArgA', + * 'argument_name_B' => 'ProviderClassFQCN_For_Prompt1_ArgB', + * ], + * 'prompt_name_2' => [ //... ], + * ], + * 'ref/resource' => [ // Ref Type (for URI templates) + * 'resource_template_uri_1' => [ // Element URI Template + * 'uri_variable_name_X' => 'ProviderClassFQCN_For_Template1_VarX', + * ], + * ], + * ] + * @var array>>> + */ + private array $completionProviders = [ + 'ref/prompt' => [], + 'ref/resource' => [], + ]; + private bool $notificationsEnabled = true; public function __construct( @@ -307,6 +332,23 @@ public function registerPrompt(Prompt $prompt, Handler $handler, bool $isManual $this->checkAndEmitChange('prompts', $this->prompts); } + /** + * @param 'ref/prompt'|'ref/resource' $refType + * @param string $identifier Name for prompts, URI template for resource templates + * @param string $argument The argument name to register the completion provider for. + * @param class-string $providerClass + */ + public function registerCompletionProvider(string $refType, string $identifier, string $argument, string $providerClass): void + { + if (!in_array($refType, ['ref/prompt', 'ref/resource'])) { + $this->logger->warning("Invalid refType '{$refType}' for completion provider registration."); + return; + } + + $this->completionProviders[$refType][$identifier][$argument] = $providerClass; + $this->logger->debug("Registered completion provider for {$refType} '{$identifier}', argument '{$argument}'", ['provider' => $providerClass]); + } + public function enableNotifications(): void { $this->notificationsEnabled = true; @@ -505,6 +547,36 @@ public function getResource(string $uri, bool $includeTemplates = true): ?array return null; } + /** @return array{ + * resourceTemplate: ResourceTemplate, + * handler: Handler, + * variables: array, + * }|null */ + public function getResourceTemplate(string $uriTemplate): ?array + { + $registration = $this->resourceTemplates[$uriTemplate] ?? null; + if (!$registration) { + return null; + } + + try { + $matcher = new UriTemplateMatcher($uriTemplate); + $variables = $matcher->getVariables(); + } catch (\InvalidArgumentException $e) { + $this->logger->warning('Invalid resource template encountered during matching', [ + 'template' => $registration['resourceTemplate']->uriTemplate, + 'error' => $e->getMessage(), + ]); + return null; + } + + return [ + 'resourceTemplate' => $registration['resourceTemplate'], + 'handler' => $registration['handler'], + 'variables' => $variables, + ]; + } + /** @return array{prompt: Prompt, handler: Handler}|null */ public function getPrompt(string $name): ?array { @@ -514,24 +586,35 @@ public function getPrompt(string $name): ?array /** @return array */ public function getTools(): array { - return array_map(fn ($registration) => $registration['tool'], $this->tools); + return array_map(fn($registration) => $registration['tool'], $this->tools); } /** @return array */ public function getResources(): array { - return array_map(fn ($registration) => $registration['resource'], $this->resources); + return array_map(fn($registration) => $registration['resource'], $this->resources); } /** @return array */ public function getPrompts(): array { - return array_map(fn ($registration) => $registration['prompt'], $this->prompts); + return array_map(fn($registration) => $registration['prompt'], $this->prompts); } /** @return array */ public function getResourceTemplates(): array { - return array_map(fn ($registration) => $registration['resourceTemplate'], $this->resourceTemplates); + return array_map(fn($registration) => $registration['resourceTemplate'], $this->resourceTemplates); + } + + /** + * @param 'ref/prompt'|'ref/resource' $refType + * @param string $elementIdentifier Name for prompts, URI template for resource templates + * @param string $argumentName + * @return class-string|null + */ + public function getCompletionProvider(string $refType, string $identifier, string $argument): ?string + { + return $this->completionProviders[$refType][$identifier][$argument] ?? null; } } diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index f0d7df5..fa18235 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -13,6 +13,7 @@ use PhpMcp\Schema\ServerCapabilities; use PhpMcp\Schema\Tool; use PhpMcp\Schema\ToolAnnotations; +use PhpMcp\Server\Attributes\CompletionProvider; use PhpMcp\Server\Contracts\SessionHandlerInterface; use PhpMcp\Server\Defaults\BasicContainer; use PhpMcp\Server\Exception\ConfigurationException; @@ -88,9 +89,7 @@ final class ServerBuilder * > */ private array $manualPrompts = []; - public function __construct() - { - } + public function __construct() {} /** * Sets the server's identity. Required. @@ -264,7 +263,7 @@ public function build(): Server $registry->disableNotifications(); - $this->performManualRegistrations($registry, $logger); + $this->registerManualElements($registry, $logger); $registry->enableNotifications(); @@ -277,7 +276,7 @@ public function build(): Server * Helper to perform the actual registration based on stored data. * Moved into the builder. */ - private function performManualRegistrations(Registry $registry, LoggerInterface $logger): void + private function registerManualElements(Registry $registry, LoggerInterface $logger): void { if (empty($this->manualTools) && empty($this->manualResources) && empty($this->manualResourceTemplates) && empty($this->manualPrompts)) { return; @@ -356,6 +355,8 @@ private function performManualRegistrations(Registry $registry, LoggerInterface $handler = new Handler($className, $methodName); $registry->registerResourceTemplate($template, $handler, true); + $this->registerManualCompletionProviders('ref/resource', $uriTemplate, $reflectionMethod, $registry); + $logger->debug("Registered manual template {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { $errorCount++; @@ -395,6 +396,9 @@ private function performManualRegistrations(Registry $registry, LoggerInterface $prompt = Prompt::make($name, $description, $arguments); $handler = new Handler($className, $methodName); $registry->registerPrompt($prompt, $handler, true); + + $this->registerManualCompletionProviders('ref/prompt', $name, $reflectionMethod, $registry); + $logger->debug("Registered manual prompt {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { $errorCount++; @@ -408,4 +412,28 @@ private function performManualRegistrations(Registry $registry, LoggerInterface $logger->debug('Manual element registration complete.'); } + + /** + * Register completion providers for a given MCP Element. + * + * @param 'ref/prompt'|'ref/resource' $refType + * @param string $identifier The identifier of the MCP Element (prompt name, resource template URI) + * @param \ReflectionMethod $handlerMethod The method to register completion providers for + * @param Registry $registry The registry to register the completion providers to + */ + private function registerManualCompletionProviders(string $refType, string $identifier, \ReflectionMethod $handlerMethod, Registry $registry): void + { + foreach ($handlerMethod->getParameters() as $param) { + $reflectionType = $param->getType(); + if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { + continue; + } + + $completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF); + if (!empty($completionAttributes)) { + $attributeInstance = $completionAttributes[0]->newInstance(); + $registry->registerCompletionProvider($refType, $identifier, $param->getName(), $attributeInstance->providerClass); + } + } + } } diff --git a/src/Support/Discoverer.php b/src/Support/Discoverer.php index fb839ae..a13aca1 100644 --- a/src/Support/Discoverer.php +++ b/src/Support/Discoverer.php @@ -9,6 +9,7 @@ use PhpMcp\Schema\Resource; use PhpMcp\Schema\ResourceTemplate; use PhpMcp\Schema\Tool; +use PhpMcp\Server\Attributes\CompletionProvider; use PhpMcp\Server\Attributes\McpPrompt; use PhpMcp\Server\Attributes\McpResource; use PhpMcp\Server\Attributes\McpResourceTemplate; @@ -231,6 +232,7 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $handler = new Handler($className, $methodName); $this->registry->registerPrompt($prompt, $handler, true); $discoveredCount['prompts']++; + $this->discoverAndRegisterCompletionProviders('ref/prompt', $instance->name, $method); break; case McpResourceTemplate::class: @@ -243,6 +245,7 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $handler = new Handler($className, $methodName); $this->registry->registerResourceTemplate($resourceTemplate, $handler, true); $discoveredCount['resourceTemplates']++; + $this->discoverAndRegisterCompletionProviders('ref/resource', $instance->uriTemplate, $method); break; } } catch (McpServerException $e) { @@ -252,6 +255,27 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount } } + /** + * Discover and register completion providers for a given MCP Element. + * + * @param 'ref/prompt'|'ref/resource'|'ref/tool' $refType + */ + private function discoverAndRegisterCompletionProviders(string $refType, string $identifier, ReflectionMethod $handlerMethod): void + { + foreach ($handlerMethod->getParameters() as $param) { + $reflectionType = $param->getType(); + if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { + continue; + } + + $completionAttributes = $param->getAttributes(CompletionProvider::class, ReflectionAttribute::IS_INSTANCEOF); + if (!empty($completionAttributes)) { + $attributeInstance = $completionAttributes[0]->newInstance(); + $this->registry->registerCompletionProvider($refType, $identifier, $param->getName(), $attributeInstance->providerClass); + } + } + } + /** * Attempt to determine the FQCN from a PHP file path. * Uses tokenization to extract namespace and class name. @@ -353,7 +377,7 @@ private function getClassFromFile(string $filePath): ?string if (! empty($potentialClasses)) { if (! class_exists($potentialClasses[0], false)) { - $this->logger->debug('getClassFromFile returning potential non-class type', ['file' => $filePath, 'type' => $potentialClasses[0]]); + $this->logger->debug('getClassFromFile returning potential non-class type. Are you sure this class has been autoloaded?', ['file' => $filePath, 'type' => $potentialClasses[0]]); } return $potentialClasses[0]; diff --git a/src/Support/UriTemplateMatcher.php b/src/Support/UriTemplateMatcher.php index 8ebb004..fbf1a70 100644 --- a/src/Support/UriTemplateMatcher.php +++ b/src/Support/UriTemplateMatcher.php @@ -39,6 +39,11 @@ private function compileTemplate(): void $this->regex = '#^' . implode('', $regexParts) . '$#'; } + public function getVariables(): array + { + return $this->variableNames; + } + public function match(string $uri): ?array { if (preg_match($this->regex, $uri, $matches)) { From 74575ca2053c1e0bccdddebbf0191ff5c2d8dc60 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Thu, 12 Jun 2025 13:15:01 +0100 Subject: [PATCH 12/27] refactor: use Parser for JSON-RPC message parsing across transports - Replaced custom `parseRequest` methods in `HttpServerTransport`, `StdioServerTransport`, and `StreamableHttpServerTransport` with a unified `Parser::parse` method from the `php-mcp/schema` package. - Removed redundant code for request parsing, enhancing maintainability and consistency across transport implementations. - Updated error handling to utilize the new message parsing approach, improving logging and response management. --- src/Registry.php | 1 - src/Transports/HttpServerTransport.php | 27 +------ src/Transports/StdioServerTransport.php | 27 +------ .../StreamableHttpServerTransport.php | 71 +++++++------------ 4 files changed, 28 insertions(+), 98 deletions(-) diff --git a/src/Registry.php b/src/Registry.php index 95fde7f..11a09a4 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -346,7 +346,6 @@ public function registerCompletionProvider(string $refType, string $identifier, } $this->completionProviders[$refType][$identifier][$argument] = $providerClass; - $this->logger->debug("Registered completion provider for {$refType} '{$identifier}', argument '{$argument}'", ['provider' => $providerClass]); } public function enableNotifications(): void diff --git a/src/Transports/HttpServerTransport.php b/src/Transports/HttpServerTransport.php index b87fb7a..fc280ef 100644 --- a/src/Transports/HttpServerTransport.php +++ b/src/Transports/HttpServerTransport.php @@ -5,18 +5,14 @@ namespace PhpMcp\Server\Transports; use Evenement\EventEmitterTrait; -use PhpMcp\Schema\Constants; use PhpMcp\Server\Contracts\IdGeneratorInterface; use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\TransportException; use PhpMcp\Schema\JsonRpc\Message; -use PhpMcp\Schema\JsonRpc\Request; -use PhpMcp\Schema\JsonRpc\Notification; -use PhpMcp\Schema\JsonRpc\BatchRequest; use PhpMcp\Schema\JsonRpc\Error; -use PhpMcp\Server\Exception\McpServerException; +use PhpMcp\Schema\JsonRpc\Parser; use PhpMcp\Server\Support\RandomIdGenerator; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; @@ -257,7 +253,7 @@ protected function handleMessagePostRequest(ServerRequestInterface $request): Re } try { - $message = self::parseRequest($body); + $message = Parser::parse($body); } catch (Throwable $e) { $this->logger->error('Error parsing message', ['sessionId' => $sessionId, 'exception' => $e]); @@ -271,25 +267,6 @@ protected function handleMessagePostRequest(ServerRequestInterface $request): Re return new Response(202, ['Content-Type' => 'text/plain'], 'Accepted'); } - public static function parseRequest(string $message): Request|Notification|BatchRequest - { - $messageData = json_decode($message, true, 512, JSON_THROW_ON_ERROR); - - $isBatch = array_is_list($messageData) && count($messageData) > 0 && is_array($messageData[0] ?? null); - - if ($isBatch) { - return BatchRequest::fromArray($messageData); - } elseif (isset($messageData['method'])) { - if (isset($messageData['id']) && $messageData['id'] !== null) { - return Request::fromArray($messageData); - } else { - return Notification::fromArray($messageData); - } - } - - throw new McpServerException('Invalid JSON-RPC message'); - } - /** * Sends a raw JSON-RPC message frame to a specific client via SSE. diff --git a/src/Transports/StdioServerTransport.php b/src/Transports/StdioServerTransport.php index d36431f..011efe6 100644 --- a/src/Transports/StdioServerTransport.php +++ b/src/Transports/StdioServerTransport.php @@ -5,17 +5,13 @@ namespace PhpMcp\Server\Transports; use Evenement\EventEmitterTrait; -use PhpMcp\Schema\Constants; -use PhpMcp\Schema\JsonRpc\BatchRequest; +use PhpMcp\Schema\JsonRpc\Parser; use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\TransportException; -use PhpMcp\Schema\JsonRpc\Notification; -use PhpMcp\Schema\JsonRpc\Request; use PhpMcp\Schema\JsonRpc\Error; use PhpMcp\Schema\JsonRpc\Message; -use PhpMcp\Server\Exception\McpServerException; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use React\ChildProcess\Process; @@ -181,7 +177,7 @@ private function processBuffer(): void } try { - $message = self::parseRequest($trimmedLine); + $message = Parser::parse($trimmedLine); } catch (Throwable $e) { $this->logger->error('Error parsing message', ['exception' => $e]); $error = Error::forParseError("Invalid JSON: " . $e->getMessage()); @@ -193,25 +189,6 @@ private function processBuffer(): void } } - public static function parseRequest(string $message): Request|Notification|BatchRequest - { - $messageData = json_decode($message, true, 512, JSON_THROW_ON_ERROR); - - $isBatch = array_is_list($messageData) && count($messageData) > 0 && is_array($messageData[0] ?? null); - - if ($isBatch) { - return BatchRequest::fromArray($messageData); - } elseif (isset($messageData['method'])) { - if (isset($messageData['id']) && $messageData['id'] !== null) { - return Request::fromArray($messageData); - } else { - return Notification::fromArray($messageData); - } - } - - throw new McpServerException('Invalid JSON-RPC message'); - } - /** * Sends a raw, framed message to STDOUT. */ diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php index fbfa436..a0402f2 100644 --- a/src/Transports/StreamableHttpServerTransport.php +++ b/src/Transports/StreamableHttpServerTransport.php @@ -16,7 +16,7 @@ use PhpMcp\Schema\JsonRpc\BatchRequest; use PhpMcp\Schema\JsonRpc\BatchResponse; use PhpMcp\Schema\JsonRpc\Error; -use PhpMcp\Schema\JsonRpc\Notification; +use PhpMcp\Schema\JsonRpc\Parser; use PhpMcp\Schema\JsonRpc\Request; use PhpMcp\Schema\JsonRpc\Response; use PhpMcp\Server\Support\RandomIdGenerator; @@ -56,7 +56,7 @@ class StreamableHttpServerTransport implements ServerTransportInterface, LoggerA * Keyed by a unique pendingRequestId. * @var array */ - private array $pendingDirectPostResponses = []; + private array $pendingRequests = []; /** * Stores active SSE streams. @@ -167,9 +167,9 @@ private function createRequestHandler(): callable try { return match ($method) { - 'GET' => $this->handleGetRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), - 'POST' => $this->handlePostRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), - 'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), + 'GET' => $this->handleGetRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), + 'POST' => $this->handlePostRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), + 'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), default => $addCors($this->handleUnsupportedRequest($request)), }; } catch (Throwable $e) { @@ -249,7 +249,7 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte } try { - $message = self::parseRequest($body); + $message = Parser::parse($body); } catch (Throwable $e) { $this->logger->error("Failed to parse MCP message from POST body", ['error' => $e->getMessage()]); $error = Error::forParseError("Invalid JSON: " . $e->getMessage()); @@ -263,7 +263,7 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte if ($isInitializeRequest) { if ($request->hasHeader('Mcp-Session-Id')) { $this->logger->warning("Client sent Mcp-Session-Id with InitializeRequest. Ignoring.", ['clientSentId' => $request->getHeaderLine('Mcp-Session-Id')]); - $error = Error::forInvalidRequest("Invalid request: Session already initialized. Mcp-Session-Id header not allowed with InitializeRequest.", $message->id); + $error = Error::forInvalidRequest("Invalid request: Session already initialized. Mcp-Session-Id header not allowed with InitializeRequest.", $message->getId()); $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); return $deferred->promise(); } @@ -275,7 +275,7 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte if (empty($sessionId)) { $this->logger->warning("POST request without Mcp-Session-Id."); - $error = Error::forInvalidRequest("Mcp-Session-Id header required for POST requests.", $message->id); + $error = Error::forInvalidRequest("Mcp-Session-Id header required for POST requests.", $message->getId()); $deferred->resolve(new HttpResponse(400, ['Content-Type' => 'application/json'], json_encode($error))); return $deferred->promise(); } @@ -285,17 +285,13 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte 'is_initialize_request' => $isInitializeRequest, ]; - $hasRequests = false; - $nRequests = 0; - if ($message instanceof Request) { - $hasRequests = true; - $nRequests = 1; - } elseif ($message instanceof BatchRequest) { - $hasRequests = $message->hasRequests(); - $nRequests = count($message->getRequests()); - } + $nRequests = match (true) { + $message instanceof Request => 1, + $message instanceof BatchRequest => $message->nRequests(), + default => 0, + }; - if (!$hasRequests) { + if ($nRequests === 0) { $deferred->resolve(new HttpResponse(202)); $context['type'] = 'post_202_sent'; } else { @@ -338,19 +334,19 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte $context['nRequests'] = $nRequests; } else { $pendingRequestId = $this->idGenerator->generateId(); - $this->pendingDirectPostResponses[$pendingRequestId] = $deferred; + $this->pendingRequests[$pendingRequestId] = $deferred; $timeoutTimer = $this->loop->addTimer(30, function () use ($pendingRequestId, $sessionId) { - if (isset($this->pendingDirectPostResponses[$pendingRequestId])) { - $deferred = $this->pendingDirectPostResponses[$pendingRequestId]; - unset($this->pendingDirectPostResponses[$pendingRequestId]); + if (isset($this->pendingRequests[$pendingRequestId])) { + $deferred = $this->pendingRequests[$pendingRequestId]; + unset($this->pendingRequests[$pendingRequestId]); $this->logger->warning("Timeout waiting for direct JSON response processing.", ['pending_request_id' => $pendingRequestId, 'session_id' => $sessionId]); $errorResponse = McpServerException::internalError("Request processing timed out.")->toJsonRpcError($pendingRequestId); $deferred->resolve(new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($errorResponse->toArray()))); } }); - $this->pendingDirectPostResponses[$pendingRequestId]->promise()->finally(function () use ($timeoutTimer) { + $this->pendingRequests[$pendingRequestId]->promise()->finally(function () use ($timeoutTimer) { $this->loop->cancelTimer($timeoutTimer); }); @@ -417,25 +413,6 @@ private function handleRequestError(Throwable $e, ServerRequestInterface $reques return new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($error)); } - public static function parseRequest(string $message): Request|Notification|BatchRequest - { - $messageData = json_decode($message, true, 512, JSON_THROW_ON_ERROR); - - $isBatch = array_is_list($messageData) && count($messageData) > 0 && is_array($messageData[0] ?? null); - - if ($isBatch) { - return BatchRequest::fromArray($messageData); - } elseif (isset($messageData['method'])) { - if (isset($messageData['id']) && $messageData['id'] !== null) { - return Request::fromArray($messageData); - } else { - return Notification::fromArray($messageData); - } - } - - throw new McpServerException('Invalid JSON-RPC message'); - } - public function sendMessage(Message $message, string $sessionId, array $context = []): PromiseInterface { if ($this->closing) { @@ -489,13 +466,13 @@ public function sendMessage(Message $message, string $sessionId, array $context case 'post_json': $pendingRequestId = $context['pending_request_id']; - if (!isset($this->pendingDirectPostResponses[$pendingRequestId])) { + if (!isset($this->pendingRequests[$pendingRequestId])) { $this->logger->error("Pending direct JSON request not found.", ['pending_request_id' => $pendingRequestId, 'session_id' => $sessionId]); return reject(new TransportException("Pending request {$pendingRequestId} not found.")); } - $deferred = $this->pendingDirectPostResponses[$pendingRequestId]; - unset($this->pendingDirectPostResponses[$pendingRequestId]); + $deferred = $this->pendingRequests[$pendingRequestId]; + unset($this->pendingRequests[$pendingRequestId]); $responseBody = json_encode($message, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $headers = ['Content-Type' => 'application/json']; @@ -596,12 +573,12 @@ public function close(): void $this->getStream = null; } - foreach ($this->pendingDirectPostResponses as $pendingRequestId => $deferred) { + foreach ($this->pendingRequests as $pendingRequestId => $deferred) { $deferred->reject(new TransportException('Transport is closing.')); } $this->activeSseStreams = []; - $this->pendingDirectPostResponses = []; + $this->pendingRequests = []; $this->emit('close', ['Transport closed.']); $this->removeAllListeners(); From b51fe79425adb4dfa15fffaebbf6c7376c18fe60 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Mon, 16 Jun 2025 10:26:04 +0100 Subject: [PATCH 13/27] fix: allow controlling the error code for POST error responses - Updated the Protocol class to set a 404 status code for invalid or expired sessions and improved logging for response sending and error handling. - Adjusted response status code handling to utilize the context's status code, ensuring more accurate HTTP responses. --- src/Protocol.php | 11 ++++++++++- src/Transports/StreamableHttpServerTransport.php | 15 ++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/Protocol.php b/src/Protocol.php index c4de56e..e34631d 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -121,7 +121,16 @@ public function processMessage(Request|Notification|BatchRequest $message, strin if ($session === null) { $error = Error::forInvalidRequest('Invalid or expired session. Please re-initialize the session.', $message->id); - $this->transport->sendMessage($error, $sessionId, $context); + $context['status_code'] = 404; + + $this->transport->sendMessage($error, $sessionId, $context) + ->then(function () use ($sessionId, $error, $context) { + $this->logger->debug('Response sent.', ['sessionId' => $sessionId, 'payload' => $error, 'context' => $context]); + }) + ->catch(function (Throwable $e) use ($sessionId, $error, $context) { + $this->logger->error('Failed to send response.', ['sessionId' => $sessionId, 'error' => $e->getMessage()]); + }); + return; } diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php index a0402f2..40352c1 100644 --- a/src/Transports/StreamableHttpServerTransport.php +++ b/src/Transports/StreamableHttpServerTransport.php @@ -68,12 +68,16 @@ class StreamableHttpServerTransport implements ServerTransportInterface, LoggerA private ?ThroughStream $getStream = null; + /** + * @param bool $enableJsonResponse If true, the server will return JSON responses instead of starting an SSE stream. + * This can be useful for simple request/response scenarios without streaming. + */ public function __construct( private readonly string $host = '127.0.0.1', private readonly int $port = 8080, private string $mcpPath = '/mcp', private ?array $sslContext = null, - private readonly bool $preferDirectJsonResponse = true, + private readonly bool $enableJsonResponse = true, ?IdGeneratorInterface $idGenerator = null, ?EventStoreInterface $eventStore = null ) { @@ -295,11 +299,7 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte $deferred->resolve(new HttpResponse(202)); $context['type'] = 'post_202_sent'; } else { - $clientPrefersSse = str_contains($acceptHeader, 'text/event-stream'); - $clientAcceptsJson = str_contains($acceptHeader, 'application/json'); - $useSse = $clientPrefersSse && !($this->preferDirectJsonResponse && $clientAcceptsJson); - - if ($useSse) { + if (!$this->enableJsonResponse) { $streamId = $this->idGenerator->generateId(); $sseStream = new ThroughStream(); $this->activeSseStreams[$streamId] = [ @@ -480,7 +480,8 @@ public function sendMessage(Message $message, string $sessionId, array $context $headers['Mcp-Session-Id'] = $sessionId; } - $deferred->resolve(new HttpResponse(200, $headers, $responseBody)); + $statusCode = $context['status_code'] ?? 200; + $deferred->resolve(new HttpResponse($statusCode, $headers, $responseBody . "\n")); return resolve(null); default: From abfeb2c3e10579a6d88f7d1822d78a6e8d5c19c0 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Mon, 16 Jun 2025 13:39:21 +0100 Subject: [PATCH 14/27] refactor(core)!: Introduce RegisteredElement objects to encapsulate element logic This commit introduces a new set of classes under the `PhpMcp\Server\Elements` namespace (`RegisteredElement`, `RegisteredTool`, `RegisteredResource`, `RegisteredPrompt`, `RegisteredResourceTemplate`). These classes now encapsulate: - The MCP schema DTO (e.g., `PhpMcp\Schema\Tool`). - The handler invocation logic (previously in `Support\Handler` and `Support\ArgumentPreparer`), including argument preparation and type casting, now part of the `RegisteredElement` base class. - Specific execution methods (e.g., `call()` on `RegisteredTool`, `read()` on `RegisteredResource`). - Result formatting logic (previously in `Traits\ResponseFormatter`) moved into the respective `Registered*` classes. - URI template matching logic is now self-contained within `RegisteredResourceTemplate`. - Completion provider storage is now part of `RegisteredTool`, `RegisteredPrompt`, and `RegisteredResourceTemplate`. --- .../02-discovery-http-userprofile/server.php | 2 +- src/Dispatcher.php | 74 ++-- .../RegisteredElement.php} | 39 +- src/Elements/RegisteredPrompt.php | 200 +++++++++ src/Elements/RegisteredResource.php | 164 +++++++ src/Elements/RegisteredResourceTemplate.php | 221 ++++++++++ src/Elements/RegisteredTool.php | 77 ++++ src/Protocol.php | 1 - src/Registry.php | 413 +++++------------- src/Server.php | 5 +- src/ServerBuilder.php | 42 +- src/Support/UriTemplateMatcher.php | 62 --- src/Traits/ResponseFormatter.php | 362 --------------- src/Transports/HttpServerTransport.php | 2 +- .../StreamableHttpServerTransport.php | 4 +- src/{Support => Utils}/Discoverer.php | 36 +- src/{Support => Utils}/DocBlockParser.php | 6 +- src/{Support => Utils}/HandlerResolver.php | 2 +- src/{Support => Utils}/RandomIdGenerator.php | 2 +- src/{Support => Utils}/SchemaGenerator.php | 33 +- src/{Support => Utils}/SchemaValidator.php | 17 +- .../Unit/Definitions/PromptDefinitionTest.php | 204 --------- .../Definitions/ResourceDefinitionTest.php | 241 ---------- .../ResourceTemplateDefinitionTest.php | 231 ---------- tests/Unit/Definitions/ToolDefinitionTest.php | 215 --------- tests/Unit/JsonRpc/BatchTest.php | 162 ------- tests/Unit/JsonRpc/ErrorTest.php | 68 --- tests/Unit/JsonRpc/MessageTest.php | 35 -- tests/Unit/JsonRpc/NotificationTest.php | 116 ----- tests/Unit/JsonRpc/RequestTest.php | 112 ----- tests/Unit/JsonRpc/ResponseTest.php | 276 ------------ tests/Unit/JsonRpc/ResultTest.php | 59 --- .../Unit/JsonRpc/Results/EmptyResultTest.php | 40 -- 33 files changed, 898 insertions(+), 2625 deletions(-) rename src/{Support/Handler.php => Elements/RegisteredElement.php} (87%) create mode 100644 src/Elements/RegisteredPrompt.php create mode 100644 src/Elements/RegisteredResource.php create mode 100644 src/Elements/RegisteredResourceTemplate.php create mode 100644 src/Elements/RegisteredTool.php delete mode 100644 src/Support/UriTemplateMatcher.php delete mode 100644 src/Traits/ResponseFormatter.php rename src/{Support => Utils}/Discoverer.php (92%) rename src/{Support => Utils}/DocBlockParser.php (96%) rename src/{Support => Utils}/HandlerResolver.php (99%) rename src/{Support => Utils}/RandomIdGenerator.php (88%) rename src/{Support => Utils}/SchemaGenerator.php (96%) rename src/{Support => Utils}/SchemaValidator.php (96%) delete mode 100644 tests/Unit/Definitions/PromptDefinitionTest.php delete mode 100644 tests/Unit/Definitions/ResourceDefinitionTest.php delete mode 100644 tests/Unit/Definitions/ResourceTemplateDefinitionTest.php delete mode 100644 tests/Unit/Definitions/ToolDefinitionTest.php delete mode 100644 tests/Unit/JsonRpc/BatchTest.php delete mode 100644 tests/Unit/JsonRpc/ErrorTest.php delete mode 100644 tests/Unit/JsonRpc/MessageTest.php delete mode 100644 tests/Unit/JsonRpc/NotificationTest.php delete mode 100644 tests/Unit/JsonRpc/RequestTest.php delete mode 100644 tests/Unit/JsonRpc/ResponseTest.php delete mode 100644 tests/Unit/JsonRpc/ResultTest.php delete mode 100644 tests/Unit/JsonRpc/Results/EmptyResultTest.php diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php index 5ef393e..4a9994e 100644 --- a/examples/02-discovery-http-userprofile/server.php +++ b/examples/02-discovery-http-userprofile/server.php @@ -67,7 +67,7 @@ public function log($level, \Stringable|string $message, array $context = []): v $server = Server::make() ->withServerInfo('HTTP User Profiles', '1.0.0') - ->withCapabilities(ServerCapabilities::make(completionsEnabled: true)) + ->withCapabilities(ServerCapabilities::make(completionsEnabled: true, loggingEnabled: true)) ->withLogger($logger) ->withContainer($container) ->build(); diff --git a/src/Dispatcher.php b/src/Dispatcher.php index 67f9da4..9b8e98b 100644 --- a/src/Dispatcher.php +++ b/src/Dispatcher.php @@ -39,16 +39,13 @@ use PhpMcp\Server\Protocol; use PhpMcp\Server\Registry; use PhpMcp\Server\Session\SubscriptionManager; -use PhpMcp\Server\Support\SchemaValidator; -use PhpMcp\Server\Traits\ResponseFormatter; +use PhpMcp\Server\Utils\SchemaValidator; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Throwable; class Dispatcher { - use ResponseFormatter; - protected ContainerInterface $container; protected LoggerInterface $logger; @@ -160,12 +157,12 @@ public function handleToolCall(CallToolRequest $request): CallToolResult $toolName = $request->name; $arguments = $request->arguments; - ['tool' => $tool, 'handler' => $handler] = $this->registry->getTool($toolName); - if (! $tool) { + $registeredTool = $this->registry->getTool($toolName); + if (! $registeredTool) { throw McpServerException::methodNotFound("Tool '{$toolName}' not found."); } - $inputSchema = $tool->inputSchema; + $inputSchema = $registeredTool->schema->inputSchema; $validationErrors = $this->schemaValidator->validateAgainstJsonSchema($arguments, $inputSchema); @@ -188,20 +185,19 @@ public function handleToolCall(CallToolRequest $request): CallToolResult } try { - $result = $handler->handle($this->container, $arguments); - $formattedResult = $this->formatToolResult($result); + $result = $registeredTool->call($this->container, $arguments); - return new CallToolResult($formattedResult, false); + return new CallToolResult($result, false); } catch (JsonException $e) { - $this->logger->warning('MCP SDK: Failed to JSON encode tool result.', ['tool' => $toolName, 'exception' => $e]); + $this->logger->warning('Failed to JSON encode tool result.', ['tool' => $toolName, 'exception' => $e]); $errorMessage = "Failed to serialize tool result: {$e->getMessage()}"; return new CallToolResult([new TextContent($errorMessage)], true); } catch (Throwable $toolError) { - $this->logger->error('MCP SDK: Tool execution failed.', ['tool' => $toolName, 'exception' => $toolError]); - $errorContent = $this->formatToolErrorResult($toolError); + $this->logger->error('Tool execution failed.', ['tool' => $toolName, 'exception' => $toolError]); + $errorMessage = "Tool execution failed: {$toolError->getMessage()}"; - return new CallToolResult($errorContent, true); + return new CallToolResult([new TextContent($errorMessage)], true); } } @@ -231,25 +227,23 @@ public function handleResourceRead(ReadResourceRequest $request): ReadResourceRe { $uri = $request->uri; - ['resource' => $resource, 'handler' => $handler, 'variables' => $uriVariables] = $this->registry->getResource($uri); + $registeredResource = $this->registry->getResource($uri); - if (! $resource) { + if (! $registeredResource) { throw McpServerException::invalidParams("Resource URI '{$uri}' not found."); } try { - $arguments = array_merge($uriVariables, ['uri' => $uri]); - $result = $handler->handle($this->container, $arguments); - $contents = $this->formatResourceContents($result, $uri, $resource->mimeType); + $result = $registeredResource->read($this->container, $uri); - return new ReadResourceResult($contents); + return new ReadResourceResult($result); } catch (JsonException $e) { - $this->logger->warning('MCP SDK: Failed to JSON encode resource content.', ['exception' => $e, 'uri' => $uri]); + $this->logger->warning('Failed to JSON encode resource content.', ['exception' => $e, 'uri' => $uri]); throw McpServerException::internalError("Failed to serialize resource content for '{$uri}'.", $e); } catch (McpServerException $e) { throw $e; } catch (Throwable $e) { - $this->logger->error('MCP SDK: Resource read failed.', ['uri' => $uri, 'exception' => $e]); + $this->logger->error('Resource read failed.', ['uri' => $uri, 'exception' => $e]); throw McpServerException::resourceReadFailed($uri, $e); } } @@ -282,32 +276,31 @@ public function handlePromptGet(GetPromptRequest $request): GetPromptResult $promptName = $request->name; $arguments = $request->arguments; - ['prompt' => $prompt, 'handler' => $handler] = $this->registry->getPrompt($promptName); - if (! $prompt) { + $registeredPrompt = $this->registry->getPrompt($promptName); + if (! $registeredPrompt) { throw McpServerException::invalidParams("Prompt '{$promptName}' not found."); } $arguments = (array) $arguments; - foreach ($prompt->arguments as $argDef) { + foreach ($registeredPrompt->schema->arguments as $argDef) { if ($argDef->required && ! array_key_exists($argDef->name, $arguments)) { throw McpServerException::invalidParams("Missing required argument '{$argDef->name}' for prompt '{$promptName}'."); } } try { - $result = $handler->handle($this->container, $arguments); - $messages = $this->formatPromptMessages($result); + $result = $registeredPrompt->get($this->container, $arguments); - return new GetPromptResult($messages, $prompt->description); + return new GetPromptResult($result, $registeredPrompt->schema->description); } catch (JsonException $e) { - $this->logger->warning('MCP SDK: Failed to JSON encode prompt messages.', ['exception' => $e, 'promptName' => $promptName]); + $this->logger->warning('Failed to JSON encode prompt messages.', ['exception' => $e, 'promptName' => $promptName]); throw McpServerException::internalError("Failed to serialize prompt messages for '{$promptName}'.", $e); } catch (McpServerException $e) { - throw $e; // Re-throw known MCP errors + throw $e; } catch (Throwable $e) { - $this->logger->error('MCP SDK: Prompt generation failed.', ['promptName' => $promptName, 'exception' => $e]); - throw McpServerException::promptGenerationFailed($promptName, $e); // Use specific factory + $this->logger->error('Prompt generation failed.', ['promptName' => $promptName, 'exception' => $e]); + throw McpServerException::promptGenerationFailed($promptName, $e); } } @@ -332,13 +325,13 @@ public function handleCompletionComplete(CompletionCompleteRequest $request, Ses if ($ref->type === 'ref/prompt') { $identifier = $ref->name; - ['prompt' => $prompt] = $this->registry->getPrompt($identifier); - if (! $prompt) { + $registeredPrompt = $this->registry->getPrompt($identifier); + if (! $registeredPrompt) { throw McpServerException::invalidParams("Prompt '{$identifier}' not found."); } $foundArg = false; - foreach ($prompt->arguments as $arg) { + foreach ($registeredPrompt->schema->arguments as $arg) { if ($arg->name === $argumentName) { $foundArg = true; break; @@ -347,15 +340,17 @@ public function handleCompletionComplete(CompletionCompleteRequest $request, Ses if (! $foundArg) { throw McpServerException::invalidParams("Argument '{$argumentName}' not found in prompt '{$identifier}'."); } + + $providerClass = $registeredPrompt->getCompletionProvider($argumentName); } else if ($ref->type === 'ref/resource') { $identifier = $ref->uri; - ['resourceTemplate' => $resourceTemplate, 'variables' => $uriVariables] = $this->registry->getResourceTemplate($identifier); - if (! $resourceTemplate) { + $registeredResourceTemplate = $this->registry->getResourceTemplate($identifier); + if (! $registeredResourceTemplate) { throw McpServerException::invalidParams("Resource template '{$identifier}' not found."); } $foundArg = false; - foreach ($uriVariables as $uriVariableName) { + foreach ($registeredResourceTemplate->getVariableNames() as $uriVariableName) { if ($uriVariableName === $argumentName) { $foundArg = true; break; @@ -365,11 +360,12 @@ public function handleCompletionComplete(CompletionCompleteRequest $request, Ses if (! $foundArg) { throw McpServerException::invalidParams("URI variable '{$argumentName}' not found in resource template '{$identifier}'."); } + + $providerClass = $registeredResourceTemplate->getCompletionProvider($argumentName); } else { throw McpServerException::invalidParams("Invalid ref type '{$ref->type}' for completion complete request."); } - $providerClass = $this->registry->getCompletionProvider($ref->type, $identifier, $argumentName); if (! $providerClass) { $this->logger->warning("No completion provider found for argument '{$argumentName}' in '{$ref->type}' '{$identifier}'."); return new CompletionCompleteResult([]); diff --git a/src/Support/Handler.php b/src/Elements/RegisteredElement.php similarity index 87% rename from src/Support/Handler.php rename to src/Elements/RegisteredElement.php index b079e84..66222ab 100644 --- a/src/Support/Handler.php +++ b/src/Elements/RegisteredElement.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpMcp\Server\Support; +namespace PhpMcp\Server\Elements; use InvalidArgumentException; use PhpMcp\Server\Exception\McpServerException; @@ -14,30 +14,30 @@ use Throwable; use TypeError; -class Handler +class RegisteredElement { public function __construct( - public readonly string $className, - public readonly string $methodName, - ) { - } + public readonly string $handlerClass, + public readonly string $handlerMethod, + public readonly bool $isManual = false, + ) {} public function handle(ContainerInterface $container, array $arguments): mixed { - $instance = $container->get($this->className); + $instance = $container->get($this->handlerClass); $arguments = $this->prepareArguments($instance, $arguments); - $method = $this->methodName; + $method = $this->handlerMethod; return $instance->$method(...$arguments); } - private function prepareArguments(object $instance, array $arguments): array + protected function prepareArguments(object $instance, array $arguments): array { - if (! method_exists($instance, $this->methodName)) { - throw new ReflectionException("Method does not exist: {$this->className}::{$this->methodName}"); + if (! method_exists($instance, $this->handlerMethod)) { + throw new ReflectionException("Method does not exist: {$this->handlerClass}::{$this->handlerMethod}"); } - $reflectionMethod = new ReflectionMethod($instance, $this->methodName); + $reflectionMethod = new ReflectionMethod($instance, $this->handlerMethod); $finalArgs = []; @@ -65,7 +65,7 @@ private function prepareArguments(object $instance, array $arguments): array continue; } else { throw McpServerException::internalError( - "Missing required argument `{$paramName}` for {$reflectionMethod->class}::{$this->methodName}." + "Missing required argument `{$paramName}` for {$reflectionMethod->class}::{$this->handlerMethod}." ); } } @@ -73,19 +73,6 @@ private function prepareArguments(object $instance, array $arguments): array return array_values($finalArgs); } - public static function fromArray(array $data): self - { - return new self($data['className'], $data['methodName']); - } - - public function toArray(): array - { - return [ - 'className' => $this->className, - 'methodName' => $this->methodName, - ]; - } - /** * Attempts type casting based on ReflectionParameter type hints. * diff --git a/src/Elements/RegisteredPrompt.php b/src/Elements/RegisteredPrompt.php new file mode 100644 index 0000000..7202c38 --- /dev/null +++ b/src/Elements/RegisteredPrompt.php @@ -0,0 +1,200 @@ +handle($container, $arguments); + + return $this->formatResult($result); + } + + public function getCompletionProvider(string $argumentName): ?string + { + return $this->completionProviders[$argumentName] ?? null; + } + + /** + * Formats the raw result of a prompt generator into an array of MCP PromptMessages. + * + * @param mixed $promptGenerationResult Expected: array of message structures. + * @return array Array of PromptMessage objects. + * + * @throws \RuntimeException If the result cannot be formatted. + * @throws \JsonException If JSON encoding fails. + */ + protected function formatResult(mixed $promptGenerationResult): array + { + if ( + is_array($promptGenerationResult) && ! empty($promptGenerationResult) + && $promptGenerationResult[array_key_first($promptGenerationResult)] instanceof PromptMessage + ) { + return $promptGenerationResult; + } + + if ( + is_array($promptGenerationResult) && ! array_is_list($promptGenerationResult) + && (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant'])) + ) { + + $result = []; + if (isset($promptGenerationResult['user'])) { + $result[] = PromptMessage::make(Role::User, $promptGenerationResult['user']); + } + if (isset($promptGenerationResult['assistant'])) { + $result[] = PromptMessage::make(Role::Assistant, $promptGenerationResult['assistant']); + } + + if (! empty($result)) { + return $result; + } + } + + if (! is_array($promptGenerationResult)) { + throw new \RuntimeException('Prompt generator method must return an array of messages.'); + } + + if (! array_is_list($promptGenerationResult)) { + throw new \RuntimeException('Prompt generator method must return a list (sequential array) of messages, not an associative array.'); + } + + $formattedMessages = []; + foreach ($promptGenerationResult as $index => $message) { + if ($message instanceof PromptMessage) { + $formattedMessages[] = $message; + + continue; + } + + if (is_array($message) && isset($message['role']) && isset($message['content'])) { + $role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']); + $content = $message['content']; + + if ($role === null) { + throw new \RuntimeException("Invalid role '{$message['role']}' in prompt message at index {$index}. Only 'user' or 'assistant' are supported."); + } + + if ($content instanceof Content) { + $formattedMessages[] = new PromptMessage($role, $content); + + continue; + } + + if (is_string($content)) { + $formattedMessages[] = new PromptMessage($role, new TextContent($content)); + + continue; + } + + if (is_array($content) && isset($content['type'])) { + $type = $content['type']; + if (! in_array($type, ['text', 'image', 'audio', 'resource'])) { + throw new \RuntimeException("Invalid content type '{$type}' in prompt message at index {$index}."); + } + + $contentObj = null; + switch ($type) { + case 'text': + if (! isset($content['text']) || ! is_string($content['text'])) { + throw new \RuntimeException("Invalid 'text' content: Missing or invalid 'text' string."); + } + $contentObj = TextContent::make($content['text']); + break; + + case 'image': + if (! isset($content['data']) || ! is_string($content['data'])) { + throw new \RuntimeException("Invalid 'image' content: Missing or invalid 'data' string (base64)."); + } + if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { + throw new \RuntimeException("Invalid 'image' content: Missing or invalid 'mimeType' string."); + } + $contentObj = ImageContent::make($content['data'], $content['mimeType']); + break; + + case 'audio': + if (! isset($content['data']) || ! is_string($content['data'])) { + throw new \RuntimeException("Invalid 'audio' content: Missing or invalid 'data' string (base64)."); + } + if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { + throw new \RuntimeException("Invalid 'audio' content: Missing or invalid 'mimeType' string."); + } + $contentObj = AudioContent::make($content['data'], $content['mimeType']); + break; + + case 'resource': + if (! isset($content['resource']) || ! is_array($content['resource'])) { + throw new \RuntimeException("Invalid 'resource' content: Missing or invalid 'resource' object."); + } + + $resource = $content['resource']; + if (! isset($resource['uri']) || ! is_string($resource['uri'])) { + throw new \RuntimeException("Invalid resource: Missing or invalid 'uri'."); + } + + $resourceObj = null; + if (isset($resource['text']) && is_string($resource['text'])) { + $resourceObj = TextResourceContents::make($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); + } elseif (isset($resource['blob']) && is_string($resource['blob'])) { + $resourceObj = BlobResourceContents::make( + $resource['uri'], + $resource['mimeType'] ?? 'application/octet-stream', + $resource['blob'] + ); + } else { + throw new \RuntimeException("Invalid resource: Must contain 'text' or 'blob'."); + } + + $contentObj = new EmbeddedResource($resourceObj); + break; + } + + if ($contentObj) { + $formattedMessages[] = new PromptMessage($role, $contentObj); + + continue; + } + } + + throw new \RuntimeException("Invalid content format at index {$index}. Must be a string, Content object, or valid content array."); + } + + throw new \RuntimeException("Invalid message format at index {$index}. Expected a PromptMessage or an array with 'role' and 'content' keys."); + } + + return $formattedMessages; + } +} diff --git a/src/Elements/RegisteredResource.php b/src/Elements/RegisteredResource.php new file mode 100644 index 0000000..eff41f4 --- /dev/null +++ b/src/Elements/RegisteredResource.php @@ -0,0 +1,164 @@ + Array of ResourceContents objects. + */ + public function read(ContainerInterface $container, string $uri): array + { + $result = $this->handle($container, ['uri' => $uri]); + + return $this->formatResult($result, $uri, $this->schema->mimeType); + } + + /** + * Formats the raw result of a resource read operation into MCP ResourceContent items. + * + * @param mixed $readResult The raw result from the resource handler method. + * @param string $uri The URI of the resource that was read. + * @param ?string $defaultMimeType The default MIME type from the ResourceDefinition. + * @return array Array of ResourceContents objects. + * + * @throws \RuntimeException If the result cannot be formatted. + * + * Supported result types: + * - EmbeddedResource: Used as-is + * - ResourceContent: Embedded resource is extracted + * - string: Converted to text content with guessed or provided MIME type + * - stream resource: Read and converted to blob with provided MIME type + * - array with 'blob' key: Used as blob content + * - array with 'text' key: Used as text content + * - SplFileInfo: Read and converted to blob + * - array: Converted to JSON if MIME type is application/json or contains 'json' + * For other MIME types, will try to convert to JSON with a warning + */ + protected function formatResult(mixed $readResult, string $uri, ?string $defaultMimeType): array + { + if ($readResult instanceof ResourceContents) { + return [$readResult]; + } + + if ($readResult instanceof EmbeddedResource) { + return [$readResult->resource]; + } + + if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof ResourceContents) { + return $readResult; + } + + if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof EmbeddedResource) { + return array_map(fn($item) => $item->resource, $readResult); + } + + if (is_string($readResult)) { + $mimeType = $defaultMimeType ?? $this->guessMimeTypeFromString($readResult); + + return [TextResourceContents::make($uri, $mimeType, $readResult)]; + } + + if (is_resource($readResult) && get_resource_type($readResult) === 'stream') { + $result = BlobResourceContents::fromStream( + $uri, + $readResult, + $defaultMimeType ?? 'application/octet-stream' + ); + + @fclose($readResult); + + return [$result]; + } + + if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) { + $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'application/octet-stream'; + + return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])]; + } + + if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) { + $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'text/plain'; + + return [TextResourceContents::make($uri, $mimeType, $readResult['text'])]; + } + + if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { + return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $defaultMimeType)]; + } + + if (is_array($readResult)) { + if ($defaultMimeType && (str_contains(strtolower($defaultMimeType), 'json') || + $defaultMimeType === 'application/json')) { + try { + $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + + return [TextResourceContents::make($uri, $defaultMimeType, $jsonString)]; + } catch (\JsonException $e) { + throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); + } + } + + try { + $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + $mimeType = 'application/json'; + + return [TextResourceContents::make($uri, $mimeType, $jsonString)]; + } catch (\JsonException $e) { + throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); + } + } + + throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: " . gettype($readResult)); + } + + /** Guesses MIME type from string content (very basic) */ + private function guessMimeTypeFromString(string $content): string + { + $trimmed = ltrim($content); + if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) { + // Looks like HTML or XML? Prefer text/plain unless sure. + if (stripos($trimmed, 'compileTemplate(); + } + + public static function make(ResourceTemplate $schema, string $handlerClass, string $handlerMethod, bool $isManual = false, array $completionProviders = []): self + { + return new self($schema, $handlerClass, $handlerMethod, $isManual, $completionProviders); + } + + /** + * Gets the resource template. + * + * @return array Array of ResourceContents objects. + */ + public function read(ContainerInterface $container, string $uri): array + { + $arguments = array_merge($this->uriVariables, ['uri' => $uri]); + + $result = $this->handle($container, $arguments); + + return $this->formatResult($result, $uri, $this->schema->mimeType); + } + + public function getCompletionProvider(string $argumentName): ?string + { + return $this->completionProviders[$argumentName] ?? null; + } + + public function getVariableNames(): array + { + return $this->variableNames; + } + + public function matches(string $uri): bool + { + if (preg_match($this->uriTemplateRegex, $uri, $matches)) { + $variables = []; + foreach ($this->variableNames as $varName) { + if (isset($matches[$varName])) { + $variables[$varName] = $matches[$varName]; + } + } + + $this->uriVariables = $variables; + + return true; + } + + return false; + } + + private function compileTemplate(): void + { + $this->variableNames = []; + $regexParts = []; + + $segments = preg_split('/(\{\w+\})/', $this->schema->uriTemplate, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + + foreach ($segments as $segment) { + if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) { + $varName = $matches[1]; + $this->variableNames[] = $varName; + $regexParts[] = '(?P<' . $varName . '>[^/]+)'; + } else { + $regexParts[] = preg_quote($segment, '#'); + } + } + + $this->uriTemplateRegex = '#^' . implode('', $regexParts) . '$#'; + } + + /** + * Formats the raw result of a resource read operation into MCP ResourceContent items. + * + * @param mixed $readResult The raw result from the resource handler method. + * @param string $uri The URI of the resource that was read. + * @param ?string $defaultMimeType The default MIME type from the ResourceDefinition. + * @return array Array of ResourceContents objects. + * + * @throws \RuntimeException If the result cannot be formatted. + * + * Supported result types: + * - EmbeddedResource: Used as-is + * - ResourceContent: Embedded resource is extracted + * - string: Converted to text content with guessed or provided MIME type + * - stream resource: Read and converted to blob with provided MIME type + * - array with 'blob' key: Used as blob content + * - array with 'text' key: Used as text content + * - SplFileInfo: Read and converted to blob + * - array: Converted to JSON if MIME type is application/json or contains 'json' + * For other MIME types, will try to convert to JSON with a warning + */ + protected function formatResult(mixed $readResult, string $uri, ?string $defaultMimeType): array + { + if ($readResult instanceof ResourceContents) { + return [$readResult]; + } + + if ($readResult instanceof EmbeddedResource) { + return [$readResult->resource]; + } + + if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof ResourceContents) { + return $readResult; + } + + if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof EmbeddedResource) { + return array_map(fn($item) => $item->resource, $readResult); + } + + if (is_string($readResult)) { + $mimeType = $defaultMimeType ?? $this->guessMimeTypeFromString($readResult); + + return [TextResourceContents::make($uri, $mimeType, $readResult)]; + } + + if (is_resource($readResult) && get_resource_type($readResult) === 'stream') { + $result = BlobResourceContents::fromStream( + $uri, + $readResult, + $defaultMimeType ?? 'application/octet-stream' + ); + + @fclose($readResult); + + return [$result]; + } + + if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) { + $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'application/octet-stream'; + + return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])]; + } + + if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) { + $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'text/plain'; + + return [TextResourceContents::make($uri, $mimeType, $readResult['text'])]; + } + + if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { + return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $defaultMimeType)]; + } + + if (is_array($readResult)) { + if ($defaultMimeType && (str_contains(strtolower($defaultMimeType), 'json') || + $defaultMimeType === 'application/json')) { + try { + $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + + return [TextResourceContents::make($uri, $defaultMimeType, $jsonString)]; + } catch (\JsonException $e) { + throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); + } + } + + try { + $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + $mimeType = 'application/json'; + + return [TextResourceContents::make($uri, $mimeType, $jsonString)]; + } catch (\JsonException $e) { + throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); + } + } + + throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: " . gettype($readResult)); + } + + /** Guesses MIME type from string content (very basic) */ + private function guessMimeTypeFromString(string $content): string + { + $trimmed = ltrim($content); + if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) { + // Looks like HTML or XML? Prefer text/plain unless sure. + if (stripos($trimmed, 'handle($container, $arguments); + + return $this->formatResult($result); + } + + /** + * Formats the result of a successful tool execution into the MCP CallToolResult structure. + * + * @param mixed $toolExecutionResult The raw value returned by the tool's PHP method. + * @return Content[] The content items for CallToolResult. + * + * @throws JsonException if JSON encoding fails + */ + protected function formatResult(mixed $toolExecutionResult): array + { + if (is_array($toolExecutionResult) && ! empty($toolExecutionResult) && $toolExecutionResult[array_key_first($toolExecutionResult)] instanceof Content) { + return $toolExecutionResult; + } + + if ($toolExecutionResult instanceof Content) { + return [$toolExecutionResult]; + } + + if ($toolExecutionResult === null) { + return [TextContent::make('(null)')]; + } + + if (is_bool($toolExecutionResult)) { + return [TextContent::make($toolExecutionResult ? 'true' : 'false')]; + } + + if (is_scalar($toolExecutionResult)) { + return [TextContent::make($toolExecutionResult)]; + } + + $jsonResult = json_encode( + $toolExecutionResult, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE + ); + + return [TextContent::make($jsonResult)]; + } +} diff --git a/src/Protocol.php b/src/Protocol.php index e34631d..d186423 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -21,7 +21,6 @@ use PhpMcp\Schema\Notification\ToolListChangedNotification; use PhpMcp\Server\Session\SessionManager; use PhpMcp\Server\Session\SubscriptionManager; -use PhpMcp\Server\Support\RequestHandler; use Psr\Log\LoggerInterface; use React\Promise\PromiseInterface; use Throwable; diff --git a/src/Registry.php b/src/Registry.php index 11a09a4..7034959 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -11,9 +11,11 @@ use PhpMcp\Schema\ResourceTemplate; use PhpMcp\Schema\Tool; use PhpMcp\Server\Contracts\CompletionProviderInterface; +use PhpMcp\Server\Elements\RegisteredPrompt; +use PhpMcp\Server\Elements\RegisteredResource; +use PhpMcp\Server\Elements\RegisteredResourceTemplate; +use PhpMcp\Server\Elements\RegisteredTool; use PhpMcp\Server\Exception\DefinitionException; -use PhpMcp\Server\Support\Handler; -use PhpMcp\Server\Support\UriTemplateMatcher; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use Psr\SimpleCache\InvalidArgumentException as CacheInvalidArgumentException; @@ -25,30 +27,18 @@ class Registry implements EventEmitterInterface private const DISCOVERED_ELEMENTS_CACHE_KEY = 'mcp_server_discovered_elements'; - /** @var array */ + /** @var array */ private array $tools = []; - /** @var array */ + /** @var array */ private array $resources = []; - /** @var array */ + /** @var array */ private array $prompts = []; - /** @var array */ + /** @var array */ private array $resourceTemplates = []; - /** @var array */ - private array $manualToolNames = []; - - /** @var array */ - private array $manualResourceUris = []; - - /** @var array */ - private array $manualPromptNames = []; - - /** @var array */ - private array $manualTemplateUris = []; - private array $listHashes = [ 'tools' => '', 'resources' => '', @@ -56,30 +46,6 @@ class Registry implements EventEmitterInterface 'prompts' => '', ]; - /** - * Stores completion providers. - * Structure: - * [ - * 'ref/prompt' => [ // Ref Type - * 'prompt_name_1' => [ // Element Name/URI - * 'argument_name_A' => 'ProviderClassFQCN_For_Prompt1_ArgA', - * 'argument_name_B' => 'ProviderClassFQCN_For_Prompt1_ArgB', - * ], - * 'prompt_name_2' => [ //... ], - * ], - * 'ref/resource' => [ // Ref Type (for URI templates) - * 'resource_template_uri_1' => [ // Element URI Template - * 'uri_variable_name_X' => 'ProviderClassFQCN_For_Template1_VarX', - * ], - * ], - * ] - * @var array>>> - */ - private array $completionProviders = [ - 'ref/prompt' => [], - 'ref/resource' => [], - ]; - private bool $notificationsEnabled = true; public function __construct( @@ -125,87 +91,74 @@ public function load(): void try { $cached = $this->cache->get(self::DISCOVERED_ELEMENTS_CACHE_KEY); - if (is_array($cached)) { - $loadCount = 0; - - foreach ($cached['tools'] ?? [] as $toolData) { - if (!isset($toolData['tool']) || !isset($toolData['handler'])) { - $this->logger->warning('Invalid tool data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'data' => $toolData]); - continue; - } - - $toolName = $toolData['tool']['name']; - if (! isset($this->manualToolNames[$toolName])) { - $this->tools[$toolName] = [ - 'tool' => Tool::fromArray($toolData['tool']), - 'handler' => Handler::fromArray($toolData['handler']), - ]; - $loadCount++; - } else { - $this->logger->debug("Skipping cached tool '{$toolName}' as manual version exists."); - } + if (!is_array($cached)) { + $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); + return; + } + + $loadCount = 0; + + foreach ($cached['tools'] ?? [] as $toolData) { + /** @var RegisteredTool $cachedTool */ + $cachedTool = unserialize($toolData); + $toolName = $cachedTool->schema->name; + $existingTool = $this->tools[$toolName] ?? null; + + if ($existingTool && $existingTool->isManual) { + $this->logger->debug("Skipping cached tool '{$toolName}' as manual version exists."); + continue; } - foreach ($cached['resources'] ?? [] as $resourceData) { - if (!isset($resourceData['resource']) || !isset($resourceData['handler'])) { - $this->logger->warning('Invalid resource data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'data' => $resourceData]); - continue; - } - - $uri = $resourceData['resource']['uri']; - if (! isset($this->manualResourceUris[$uri])) { - $this->resources[$uri] = [ - 'resource' => Resource::fromArray($resourceData['resource']), - 'handler' => Handler::fromArray($resourceData['handler']), - ]; - $loadCount++; - } else { - $this->logger->debug("Skipping cached resource '{$uri}' as manual version exists."); - } + $this->tools[$toolName] = $cachedTool; + $loadCount++; + } + + foreach ($cached['resources'] ?? [] as $resourceData) { + /** @var RegisteredResource $cachedResource */ + $cachedResource = unserialize($resourceData); + $uri = $cachedResource->schema->uri; + $existingResource = $this->resources[$uri] ?? null; + + if ($existingResource && $existingResource->isManual) { + $this->logger->debug("Skipping cached resource '{$uri}' as manual version exists."); + continue; } - foreach ($cached['prompts'] ?? [] as $promptData) { - if (!isset($promptData['prompt']) || !isset($promptData['handler'])) { - $this->logger->warning('Invalid prompt data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'data' => $promptData]); - continue; - } - - $promptName = $promptData['prompt']['name']; - if (! isset($this->manualPromptNames[$promptName])) { - $this->prompts[$promptName] = [ - 'prompt' => Prompt::fromArray($promptData['prompt']), - 'handler' => Handler::fromArray($promptData['handler']), - ]; - $loadCount++; - } else { - $this->logger->debug("Skipping cached prompt '{$promptName}' as manual version exists."); - } + $this->resources[$uri] = $cachedResource; + $loadCount++; + } + + foreach ($cached['prompts'] ?? [] as $promptData) { + /** @var RegisteredPrompt $cachedPrompt */ + $cachedPrompt = unserialize($promptData); + $promptName = $cachedPrompt->schema->name; + $existingPrompt = $this->prompts[$promptName] ?? null; + + if ($existingPrompt && $existingPrompt->isManual) { + $this->logger->debug("Skipping cached prompt '{$promptName}' as manual version exists."); + continue; } - foreach ($cached['resourceTemplates'] ?? [] as $templateData) { - if (!isset($templateData['resourceTemplate']) || !isset($templateData['handler'])) { - $this->logger->warning('Invalid resource template data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'data' => $templateData]); - continue; - } - - $uriTemplate = $templateData['resourceTemplate']['uriTemplate']; - if (! isset($this->manualTemplateUris[$uriTemplate])) { - $this->resourceTemplates[$uriTemplate] = [ - 'resourceTemplate' => ResourceTemplate::fromArray($templateData['resourceTemplate']), - 'handler' => Handler::fromArray($templateData['handler']), - ]; - $loadCount++; - } else { - $this->logger->debug("Skipping cached template '{$uriTemplate}' as manual version exists."); - } + $this->prompts[$promptName] = $cachedPrompt; + $loadCount++; + } + + foreach ($cached['resourceTemplates'] ?? [] as $templateData) { + /** @var RegisteredResourceTemplate $cachedTemplate */ + $cachedTemplate = unserialize($templateData); + $uriTemplate = $cachedTemplate->schema->uriTemplate; + $existingTemplate = $this->resourceTemplates[$uriTemplate] ?? null; + + if ($existingTemplate && $existingTemplate->isManual) { + $this->logger->debug("Skipping cached template '{$uriTemplate}' as manual version exists."); + continue; } - $this->logger->debug("Loaded {$loadCount} elements from cache."); - } elseif ($cached !== null) { - $this->logger->warning('Invalid data type found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); - } else { - $this->logger->debug('Cache miss or empty.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY]); + $this->resourceTemplates[$uriTemplate] = $cachedTemplate; + $loadCount++; } + + $this->logger->debug("Loaded {$loadCount} elements from cache."); } catch (CacheInvalidArgumentException $e) { $this->logger->error('Invalid registry cache key used.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'exception' => $e]); } catch (DefinitionException $e) { @@ -215,139 +168,80 @@ public function load(): void } } - public function registerTool(Tool $tool, Handler $handler, bool $isManual = false): void + public function registerTool(Tool $tool, string $handlerClass, string $handlerMethod, bool $isManual = false): void { $toolName = $tool->name; - $exists = isset($this->tools[$toolName]); - $wasManual = isset($this->manualToolNames[$toolName]); + $existing = $this->tools[$toolName] ?? null; - if ($exists && ! $isManual && $wasManual) { + if ($existing && ! $isManual && $existing->isManual) { $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.'); + return; } - $this->tools[$toolName] = [ - 'tool' => $tool, - 'handler' => $handler, - ]; - - if ($isManual) { - $this->manualToolNames[$toolName] = true; - } elseif ($wasManual) { - unset($this->manualToolNames[$toolName]); - } + $this->tools[$toolName] = RegisteredTool::make($tool, $handlerClass, $handlerMethod, $isManual); $this->checkAndEmitChange('tools', $this->tools); } - public function registerResource(Resource $resource, Handler $handler, bool $isManual = false): void + public function registerResource(Resource $resource, string $handlerClass, string $handlerMethod, bool $isManual = false): void { $uri = $resource->uri; - $exists = isset($this->resources[$uri]); - $wasManual = isset($this->manualResourceUris[$uri]); + $existing = $this->resources[$uri] ?? null; - if ($exists && ! $isManual && $wasManual) { + if ($existing && ! $isManual && $existing->isManual) { $this->logger->debug("Ignoring discovered resource '{$uri}' as it conflicts with a manually registered one."); return; } - if ($exists) { - $this->logger->warning('Replacing existing ' . ($wasManual ? 'manual' : 'discovered') . " resource '{$uri}' with " . ($isManual ? 'manual' : 'discovered') . ' definition.'); - } - $this->resources[$uri] = [ - 'resource' => $resource, - 'handler' => $handler, - ]; - - if ($isManual) { - $this->manualResourceUris[$uri] = true; - } elseif ($wasManual) { - unset($this->manualResourceUris[$uri]); - } + $this->resources[$uri] = RegisteredResource::make($resource, $handlerClass, $handlerMethod, $isManual); $this->checkAndEmitChange('resources', $this->resources); } - public function registerResourceTemplate(ResourceTemplate $template, Handler $handler, bool $isManual = false): void - { + public function registerResourceTemplate( + ResourceTemplate $template, + string $handlerClass, + string $handlerMethod, + bool $isManual = false, + array $completionProviders = [] + ): void { $uriTemplate = $template->uriTemplate; - $exists = isset($this->resourceTemplates[$uriTemplate]); - $wasManual = isset($this->manualTemplateUris[$uriTemplate]); + $existing = $this->resourceTemplates[$uriTemplate] ?? null; - if ($exists && ! $isManual && $wasManual) { + if ($existing && ! $isManual && $existing->isManual) { $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] = [ - 'resourceTemplate' => $template, - 'handler' => $handler, - ]; + $this->resourceTemplates[$uriTemplate] = RegisteredResourceTemplate::make($template, $handlerClass, $handlerMethod, $isManual, $completionProviders); - if ($isManual) { - $this->manualTemplateUris[$uriTemplate] = true; - } elseif ($wasManual) { - unset($this->manualTemplateUris[$uriTemplate]); - } - - // No listChanged for templates + $this->checkAndEmitChange('resource_templates', $this->resourceTemplates); } - public function registerPrompt(Prompt $prompt, Handler $handler, bool $isManual = false): void - { + public function registerPrompt( + Prompt $prompt, + string $handlerClass, + string $handlerMethod, + bool $isManual = false, + array $completionProviders = [] + ): void { $promptName = $prompt->name; - $exists = isset($this->prompts[$promptName]); - $wasManual = isset($this->manualPromptNames[$promptName]); + $existing = $this->prompts[$promptName] ?? null; - if ($exists && ! $isManual && $wasManual) { + if ($existing && ! $isManual && $existing->isManual) { $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' => $prompt, - 'handler' => $handler, - ]; - - if ($isManual) { - $this->manualPromptNames[$promptName] = true; - } elseif ($wasManual) { - unset($this->manualPromptNames[$promptName]); - } + $this->prompts[$promptName] = RegisteredPrompt::make($prompt, $handlerClass, $handlerMethod, $isManual, $completionProviders); $this->checkAndEmitChange('prompts', $this->prompts); } - /** - * @param 'ref/prompt'|'ref/resource' $refType - * @param string $identifier Name for prompts, URI template for resource templates - * @param string $argument The argument name to register the completion provider for. - * @param class-string $providerClass - */ - public function registerCompletionProvider(string $refType, string $identifier, string $argument, string $providerClass): void - { - if (!in_array($refType, ['ref/prompt', 'ref/resource'])) { - $this->logger->warning("Invalid refType '{$refType}' for completion provider registration."); - return; - } - - $this->completionProviders[$refType][$identifier][$argument] = $providerClass; - } - public function enableNotifications(): void { $this->notificationsEnabled = true; @@ -389,38 +283,26 @@ public function save(): bool ]; foreach ($this->tools as $name => $tool) { - if (! isset($this->manualToolNames[$name])) { - $discoveredData['tools'][$name] = [ - 'tool' => $tool['tool']->toArray(), - 'handler' => $tool['handler']->toArray(), - ]; + if (! $tool->isManual) { + $discoveredData['tools'][$name] = serialize($tool); } } foreach ($this->resources as $uri => $resource) { - if (! isset($this->manualResourceUris[$uri])) { - $discoveredData['resources'][$uri] = [ - 'resource' => $resource['resource']->toArray(), - 'handler' => $resource['handler']->toArray(), - ]; + if (! $resource->isManual) { + $discoveredData['resources'][$uri] = serialize($resource); } } foreach ($this->prompts as $name => $prompt) { - if (! isset($this->manualPromptNames[$name])) { - $discoveredData['prompts'][$name] = [ - 'prompt' => $prompt['prompt']->toArray(), - 'handler' => $prompt['handler']->toArray(), - ]; + if (! $prompt->isManual) { + $discoveredData['prompts'][$name] = serialize($prompt); } } foreach ($this->resourceTemplates as $uriTemplate => $template) { - if (! isset($this->manualTemplateUris[$uriTemplate])) { - $discoveredData['resourceTemplates'][$uriTemplate] = [ - 'resourceTemplate' => $template['resourceTemplate']->toArray(), - 'handler' => $template['handler']->toArray(), - ]; + if (! $template->isManual) { + $discoveredData['resourceTemplates'][$uriTemplate] = serialize($template); } } @@ -497,22 +379,17 @@ public function clear(): void } } - /** @return array{tool: Tool, handler: Handler}|null */ - public function getTool(string $name): ?array + /** @return RegisteredTool|null */ + public function getTool(string $name): ?RegisteredTool { return $this->tools[$name] ?? null; } - /** @return array{ - * resource: Resource, - * handler: Handler, - * variables: array, - * }|null */ - public function getResource(string $uri, bool $includeTemplates = true): ?array + /** @return RegisteredResource|RegisteredResourceTemplate|null */ + public function getResource(string $uri, bool $includeTemplates = true): RegisteredResource|RegisteredResourceTemplate|null { $registration = $this->resources[$uri] ?? null; if ($registration) { - $registration['variables'] = []; return $registration; } @@ -521,23 +398,8 @@ public function getResource(string $uri, bool $includeTemplates = true): ?array } foreach ($this->resourceTemplates as $template) { - try { - $matcher = new UriTemplateMatcher($template['resourceTemplate']->uriTemplate); - $variables = $matcher->match($uri); - } catch (\InvalidArgumentException $e) { - $this->logger->warning('Invalid resource template encountered during matching', [ - 'template' => $template['resourceTemplate']->uriTemplate, - 'error' => $e->getMessage(), - ]); - continue; - } - - if ($variables !== null) { - return [ - 'resource' => $template['resourceTemplate'], - 'handler' => $template['handler'], - 'variables' => $variables, - ]; + if ($template->matches($uri)) { + return $template; } } @@ -546,38 +408,14 @@ public function getResource(string $uri, bool $includeTemplates = true): ?array return null; } - /** @return array{ - * resourceTemplate: ResourceTemplate, - * handler: Handler, - * variables: array, - * }|null */ - public function getResourceTemplate(string $uriTemplate): ?array + /** @return RegisteredResourceTemplate|null */ + public function getResourceTemplate(string $uriTemplate): ?RegisteredResourceTemplate { - $registration = $this->resourceTemplates[$uriTemplate] ?? null; - if (!$registration) { - return null; - } - - try { - $matcher = new UriTemplateMatcher($uriTemplate); - $variables = $matcher->getVariables(); - } catch (\InvalidArgumentException $e) { - $this->logger->warning('Invalid resource template encountered during matching', [ - 'template' => $registration['resourceTemplate']->uriTemplate, - 'error' => $e->getMessage(), - ]); - return null; - } - - return [ - 'resourceTemplate' => $registration['resourceTemplate'], - 'handler' => $registration['handler'], - 'variables' => $variables, - ]; + return $this->resourceTemplates[$uriTemplate] ?? null; } - /** @return array{prompt: Prompt, handler: Handler}|null */ - public function getPrompt(string $name): ?array + /** @return RegisteredPrompt|null */ + public function getPrompt(string $name): ?RegisteredPrompt { return $this->prompts[$name] ?? null; } @@ -585,35 +423,24 @@ public function getPrompt(string $name): ?array /** @return array */ public function getTools(): array { - return array_map(fn($registration) => $registration['tool'], $this->tools); + return array_map(fn($tool) => $tool->schema, $this->tools); } /** @return array */ public function getResources(): array { - return array_map(fn($registration) => $registration['resource'], $this->resources); + return array_map(fn($resource) => $resource->schema, $this->resources); } /** @return array */ public function getPrompts(): array { - return array_map(fn($registration) => $registration['prompt'], $this->prompts); + return array_map(fn($prompt) => $prompt->schema, $this->prompts); } /** @return array */ public function getResourceTemplates(): array { - return array_map(fn($registration) => $registration['resourceTemplate'], $this->resourceTemplates); - } - - /** - * @param 'ref/prompt'|'ref/resource' $refType - * @param string $elementIdentifier Name for prompts, URI template for resource templates - * @param string $argumentName - * @return class-string|null - */ - public function getCompletionProvider(string $refType, string $identifier, string $argument): ?string - { - return $this->completionProviders[$refType][$identifier][$argument] ?? null; + return array_map(fn($template) => $template->schema, $this->resourceTemplates); } } diff --git a/src/Server.php b/src/Server.php index 9d3e0c5..f6feb50 100644 --- a/src/Server.php +++ b/src/Server.php @@ -11,7 +11,7 @@ use PhpMcp\Server\Exception\ConfigurationException; use PhpMcp\Server\Exception\DiscoveryException; use PhpMcp\Server\Session\SessionManager; -use PhpMcp\Server\Support\Discoverer; +use PhpMcp\Server\Utils\Discoverer; use Throwable; /** @@ -41,8 +41,7 @@ public function __construct( protected readonly Registry $registry, protected readonly Protocol $protocol, protected readonly SessionManager $sessionManager, - ) { - } + ) {} public static function make(): ServerBuilder { diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index fa18235..a7f40d9 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -21,8 +21,7 @@ use PhpMcp\Server\Session\ArraySessionHandler; use PhpMcp\Server\Session\CacheSessionHandler; use PhpMcp\Server\Session\SessionManager; -use PhpMcp\Server\Support\HandlerResolver; -use PhpMcp\Server\Support\Handler; +use PhpMcp\Server\Utils\HandlerResolver; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -283,8 +282,8 @@ private function registerManualElements(Registry $registry, LoggerInterface $log } $errorCount = 0; - $docBlockParser = new Support\DocBlockParser($logger); - $schemaGenerator = new Support\SchemaGenerator($docBlockParser); + $docBlockParser = new Utils\DocBlockParser($logger); + $schemaGenerator = new Utils\SchemaGenerator($docBlockParser); // Register Tools foreach ($this->manualTools as $data) { @@ -301,8 +300,7 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $inputSchema = $schemaGenerator->fromMethodParameters($reflectionMethod); $tool = Tool::make($name, $inputSchema, $description, $data['annotations']); - $handler = new Handler($className, $methodName); - $registry->registerTool($tool, $handler, true); + $registry->registerTool($tool, $className, $methodName, true); $logger->debug("Registered manual tool {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { @@ -327,8 +325,7 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $annotations = $data['annotations']; $resource = Resource::make($uri, $name, $description, $mimeType, $annotations, $size); - $handler = new Handler($className, $methodName); - $registry->registerResource($resource, $handler, true); + $registry->registerResource($resource, $className, $methodName, true); $logger->debug("Registered manual resource {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { @@ -352,10 +349,8 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $annotations = $data['annotations']; $template = ResourceTemplate::make($uriTemplate, $name, $description, $mimeType, $annotations); - $handler = new Handler($className, $methodName); - $registry->registerResourceTemplate($template, $handler, true); - - $this->registerManualCompletionProviders('ref/resource', $uriTemplate, $reflectionMethod, $registry); + $completionProviders = $this->getCompletionProviders($reflectionMethod); + $registry->registerResourceTemplate($template, $className, $methodName, true, $completionProviders); $logger->debug("Registered manual template {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { @@ -394,10 +389,8 @@ private function registerManualElements(Registry $registry, LoggerInterface $log } $prompt = Prompt::make($name, $description, $arguments); - $handler = new Handler($className, $methodName); - $registry->registerPrompt($prompt, $handler, true); - - $this->registerManualCompletionProviders('ref/prompt', $name, $reflectionMethod, $registry); + $completionProviders = $this->getCompletionProviders($reflectionMethod); + $registry->registerPrompt($prompt, $className, $methodName, true, $completionProviders); $logger->debug("Registered manual prompt {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { @@ -413,17 +406,10 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $logger->debug('Manual element registration complete.'); } - /** - * Register completion providers for a given MCP Element. - * - * @param 'ref/prompt'|'ref/resource' $refType - * @param string $identifier The identifier of the MCP Element (prompt name, resource template URI) - * @param \ReflectionMethod $handlerMethod The method to register completion providers for - * @param Registry $registry The registry to register the completion providers to - */ - private function registerManualCompletionProviders(string $refType, string $identifier, \ReflectionMethod $handlerMethod, Registry $registry): void + private function getCompletionProviders(\ReflectionMethod $reflectionMethod): array { - foreach ($handlerMethod->getParameters() as $param) { + $completionProviders = []; + foreach ($reflectionMethod->getParameters() as $param) { $reflectionType = $param->getType(); if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { continue; @@ -432,8 +418,10 @@ private function registerManualCompletionProviders(string $refType, string $iden $completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF); if (!empty($completionAttributes)) { $attributeInstance = $completionAttributes[0]->newInstance(); - $registry->registerCompletionProvider($refType, $identifier, $param->getName(), $attributeInstance->providerClass); + $completionProviders[$param->getName()] = $attributeInstance->providerClass; } } + + return $completionProviders; } } diff --git a/src/Support/UriTemplateMatcher.php b/src/Support/UriTemplateMatcher.php deleted file mode 100644 index fbf1a70..0000000 --- a/src/Support/UriTemplateMatcher.php +++ /dev/null @@ -1,62 +0,0 @@ -template = $template; - $this->compileTemplate(); - } - - private function compileTemplate(): void - { - $this->variableNames = []; - $regexParts = []; - - // Split the template by placeholders, keeping the delimiters - $segments = preg_split('/(\{\w+\})/', $this->template, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); - - foreach ($segments as $segment) { - if (preg_match('/^\{(\w+)\}$/', $segment, $matches)) { - // This segment is a placeholder like {var} - $varName = $matches[1]; - $this->variableNames[] = $varName; - // Append named capture group (match non-slash characters) - $regexParts[] = '(?P<' . $varName . '>[^/]+)'; - } else { - // This is a literal part, escape it - $regexParts[] = preg_quote($segment, '#'); - } - } - - $this->regex = '#^' . implode('', $regexParts) . '$#'; - } - - public function getVariables(): array - { - return $this->variableNames; - } - - public function match(string $uri): ?array - { - if (preg_match($this->regex, $uri, $matches)) { - $variables = []; - // Extract only the named capture groups - foreach ($this->variableNames as $varName) { - if (isset($matches[$varName])) { - $variables[$varName] = $matches[$varName]; - } - } - return $variables; - } - - return null; - } -} diff --git a/src/Traits/ResponseFormatter.php b/src/Traits/ResponseFormatter.php deleted file mode 100644 index 96c4d74..0000000 --- a/src/Traits/ResponseFormatter.php +++ /dev/null @@ -1,362 +0,0 @@ - Content objects describing the error. - */ - protected function formatToolErrorResult(Throwable $toolError): array - { - $errorMessage = 'Tool execution failed: ' . $toolError->getMessage(); - $errorMessage .= ' (Type: ' . get_class($toolError) . ')'; - - return [ - TextContent::make($errorMessage), - ]; - } - - /** - * Formats the raw result of a resource read operation into MCP ResourceContent items. - * - * @param mixed $readResult The raw result from the resource handler method. - * @param string $uri The URI of the resource that was read. - * @param ?string $defaultMimeType The default MIME type from the ResourceDefinition. - * @return array Array of EmbeddedResource objects. - * - * @throws \RuntimeException If the result cannot be formatted. - * - * Supported result types: - * - EmbeddedResource: Used as-is - * - ResourceContent: Embedded resource is extracted - * - string: Converted to text content with guessed or provided MIME type - * - stream resource: Read and converted to blob with provided MIME type - * - array with 'blob' key: Used as blob content - * - array with 'text' key: Used as text content - * - SplFileInfo: Read and converted to blob - * - array: Converted to JSON if MIME type is application/json or contains 'json' - * For other MIME types, will try to convert to JSON with a warning - */ - protected function formatResourceContents(mixed $readResult, string $uri, ?string $defaultMimeType): array - { - if ($readResult instanceof ResourceContents) { - return [$readResult]; - } - - if ($readResult instanceof EmbeddedResource) { - return [$readResult->resource]; - } - - if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof ResourceContents) { - return $readResult; - } - - if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof EmbeddedResource) { - return array_map(fn ($item) => $item->resource, $readResult); - } - - if (is_string($readResult)) { - $mimeType = $defaultMimeType ?? $this->guessMimeTypeFromString($readResult); - - return [TextResourceContents::make($uri, $mimeType, $readResult)]; - } - - if (is_resource($readResult) && get_resource_type($readResult) === 'stream') { - $result = BlobResourceContents::fromStream( - $uri, - $readResult, - $defaultMimeType ?? 'application/octet-stream' - ); - - @fclose($readResult); - - return [$result]; - } - - if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) { - $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'application/octet-stream'; - - return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])]; - } - - if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) { - $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'text/plain'; - - return [TextResourceContents::make($uri, $mimeType, $readResult['text'])]; - } - - if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { - return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $defaultMimeType)]; - } - - if (is_array($readResult)) { - if ($defaultMimeType && (str_contains(strtolower($defaultMimeType), 'json') || - $defaultMimeType === 'application/json')) { - try { - $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); - - return [TextResourceContents::make($uri, $defaultMimeType, $jsonString)]; - } catch (\JsonException $e) { - $this->logger->warning('MCP SDK: Failed to JSON encode array resource result', [ - 'uri' => $uri, - 'exception' => $e->getMessage(), - ]); - throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); - } - } - - try { - $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); - $mimeType = 'application/json'; - $this->logger->warning('MCP SDK: Automatically converted array to JSON for resource', [ - 'uri' => $uri, - 'requestedMimeType' => $defaultMimeType, - 'usedMimeType' => $mimeType, - ]); - - return [TextResourceContents::make($uri, $mimeType, $jsonString)]; - } catch (\JsonException $e) { - $this->logger->error('MCP SDK: Failed to encode array resource result as JSON', [ - 'uri' => $uri, - 'exception' => $e->getMessage(), - ]); - } - } - - $this->logger->error('MCP SDK: Unformattable resource read result type.', ['type' => gettype($readResult), 'uri' => $uri]); - throw new \RuntimeException("Cannot format resource read result for URI '{$uri}'. Handler method returned unhandled type: " . gettype($readResult)); - } - - /** Guesses MIME type from string content (very basic) */ - private function guessMimeTypeFromString(string $content): string - { - $trimmed = ltrim($content); - if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) { - // Looks like HTML or XML? Prefer text/plain unless sure. - if (stripos($trimmed, ' Array of PromptMessage objects. - * - * @throws \RuntimeException If the result cannot be formatted. - * @throws \JsonException If JSON encoding fails. - */ - protected function formatPromptMessages(mixed $promptGenerationResult): array - { - if ( - is_array($promptGenerationResult) && ! empty($promptGenerationResult) - && $promptGenerationResult[array_key_first($promptGenerationResult)] instanceof PromptMessage - ) { - return $promptGenerationResult; - } - - if ( - is_array($promptGenerationResult) && ! array_is_list($promptGenerationResult) - && (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant'])) - ) { - - $result = []; - if (isset($promptGenerationResult['user'])) { - $result[] = PromptMessage::make(Role::User, $promptGenerationResult['user']); - } - if (isset($promptGenerationResult['assistant'])) { - $result[] = PromptMessage::make(Role::Assistant, $promptGenerationResult['assistant']); - } - - if (! empty($result)) { - return $result; - } - } - - if (! is_array($promptGenerationResult)) { - throw new \RuntimeException('Prompt generator method must return an array of messages.'); - } - - if (! array_is_list($promptGenerationResult)) { - throw new \RuntimeException('Prompt generator method must return a list (sequential array) of messages, not an associative array.'); - } - - $formattedMessages = []; - foreach ($promptGenerationResult as $index => $message) { - if ($message instanceof PromptMessage) { - $formattedMessages[] = $message; - - continue; - } - - if (is_array($message) && isset($message['role']) && isset($message['content'])) { - $role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']); - $content = $message['content']; - - if ($role === null) { - throw new \RuntimeException("Invalid role '{$message['role']}' in prompt message at index {$index}. Only 'user' or 'assistant' are supported."); - } - - if ($content instanceof Content) { - $formattedMessages[] = new PromptMessage($role, $content); - - continue; - } - - if (is_string($content)) { - $formattedMessages[] = new PromptMessage($role, new TextContent($content)); - - continue; - } - - if (is_array($content) && isset($content['type'])) { - $type = $content['type']; - if (! in_array($type, ['text', 'image', 'audio', 'resource'])) { - throw new \RuntimeException("Invalid content type '{$type}' in prompt message at index {$index}."); - } - - $contentObj = null; - switch ($type) { - case 'text': - if (! isset($content['text']) || ! is_string($content['text'])) { - throw new \RuntimeException("Invalid 'text' content: Missing or invalid 'text' string."); - } - $contentObj = TextContent::make($content['text']); - break; - - case 'image': - if (! isset($content['data']) || ! is_string($content['data'])) { - throw new \RuntimeException("Invalid 'image' content: Missing or invalid 'data' string (base64)."); - } - if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { - throw new \RuntimeException("Invalid 'image' content: Missing or invalid 'mimeType' string."); - } - $contentObj = ImageContent::make($content['data'], $content['mimeType']); - break; - - case 'audio': - if (! isset($content['data']) || ! is_string($content['data'])) { - throw new \RuntimeException("Invalid 'audio' content: Missing or invalid 'data' string (base64)."); - } - if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { - throw new \RuntimeException("Invalid 'audio' content: Missing or invalid 'mimeType' string."); - } - $contentObj = AudioContent::make($content['data'], $content['mimeType']); - break; - - case 'resource': - if (! isset($content['resource']) || ! is_array($content['resource'])) { - throw new \RuntimeException("Invalid 'resource' content: Missing or invalid 'resource' object."); - } - - $resource = $content['resource']; - if (! isset($resource['uri']) || ! is_string($resource['uri'])) { - throw new \RuntimeException("Invalid resource: Missing or invalid 'uri'."); - } - - $resourceObj = null; - if (isset($resource['text']) && is_string($resource['text'])) { - $resourceObj = TextResourceContents::make($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); - } elseif (isset($resource['blob']) && is_string($resource['blob'])) { - $resourceObj = BlobResourceContents::make( - $resource['uri'], - $resource['mimeType'] ?? 'application/octet-stream', - $resource['blob'] - ); - } else { - throw new \RuntimeException("Invalid resource: Must contain 'text' or 'blob'."); - } - - $contentObj = new EmbeddedResource($resourceObj); - break; - } - - if ($contentObj) { - $formattedMessages[] = new PromptMessage($role, $contentObj); - - continue; - } - } - - throw new \RuntimeException("Invalid content format at index {$index}. Must be a string, Content object, or valid content array."); - } - - throw new \RuntimeException("Invalid message format at index {$index}. Expected a PromptMessage or an array with 'role' and 'content' keys."); - } - - return $formattedMessages; - } -} diff --git a/src/Transports/HttpServerTransport.php b/src/Transports/HttpServerTransport.php index fc280ef..bd77d24 100644 --- a/src/Transports/HttpServerTransport.php +++ b/src/Transports/HttpServerTransport.php @@ -13,7 +13,7 @@ use PhpMcp\Schema\JsonRpc\Message; use PhpMcp\Schema\JsonRpc\Error; use PhpMcp\Schema\JsonRpc\Parser; -use PhpMcp\Server\Support\RandomIdGenerator; +use PhpMcp\Server\Utils\RandomIdGenerator; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php index 40352c1..4c1ee0f 100644 --- a/src/Transports/StreamableHttpServerTransport.php +++ b/src/Transports/StreamableHttpServerTransport.php @@ -19,7 +19,7 @@ use PhpMcp\Schema\JsonRpc\Parser; use PhpMcp\Schema\JsonRpc\Request; use PhpMcp\Schema\JsonRpc\Response; -use PhpMcp\Server\Support\RandomIdGenerator; +use PhpMcp\Server\Utils\RandomIdGenerator; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -232,7 +232,7 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte $acceptHeader = $request->getHeaderLine('Accept'); if (!str_contains($acceptHeader, 'application/json') && !str_contains($acceptHeader, 'text/event-stream')) { - $error = Error::forInvalidRequest("Not Acceptable: Client must accept application/json or text/event-stream"); + $error = Error::forInvalidRequest("Not Acceptable: Client must accept both application/json or text/event-stream"); $deferred->resolve(new HttpResponse(406, ['Content-Type' => 'application/json'], json_encode($error))); return $deferred->promise(); } diff --git a/src/Support/Discoverer.php b/src/Utils/Discoverer.php similarity index 92% rename from src/Support/Discoverer.php rename to src/Utils/Discoverer.php index a13aca1..5b13a39 100644 --- a/src/Support/Discoverer.php +++ b/src/Utils/Discoverer.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpMcp\Server\Support; +namespace PhpMcp\Server\Utils; use PhpMcp\Schema\Prompt; use PhpMcp\Schema\PromptArgument; @@ -196,8 +196,7 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; $inputSchema = $this->schemaGenerator->fromMethodParameters($method); $tool = Tool::make($name, $inputSchema, $description, $instance->annotations); - $handler = new Handler($className, $methodName); - $this->registry->registerTool($tool, $handler, true); + $this->registry->registerTool($tool, $className, $methodName, true); $discoveredCount['tools']++; break; @@ -209,8 +208,7 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $size = $instance->size; $annotations = $instance->annotations; $resource = Resource::make($instance->uri, $name, $description, $mimeType, $annotations, $size); - $handler = new Handler($className, $methodName); - $this->registry->registerResource($resource, $handler, true); + $this->registry->registerResource($resource, $className, $methodName, true); $discoveredCount['resources']++; break; @@ -229,10 +227,9 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $arguments[] = PromptArgument::make($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, ! $param->isOptional() && ! $param->isDefaultValueAvailable()); } $prompt = Prompt::make($name, $description, $arguments); - $handler = new Handler($className, $methodName); - $this->registry->registerPrompt($prompt, $handler, true); + $completionProviders = $this->getCompletionProviders($method); + $this->registry->registerPrompt($prompt, $className, $methodName, true, $completionProviders); $discoveredCount['prompts']++; - $this->discoverAndRegisterCompletionProviders('ref/prompt', $instance->name, $method); break; case McpResourceTemplate::class: @@ -242,10 +239,9 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $mimeType = $instance->mimeType; $annotations = $instance->annotations; $resourceTemplate = ResourceTemplate::make($instance->uriTemplate, $name, $description, $mimeType, $annotations); - $handler = new Handler($className, $methodName); - $this->registry->registerResourceTemplate($resourceTemplate, $handler, true); + $completionProviders = $this->getCompletionProviders($method); + $this->registry->registerResourceTemplate($resourceTemplate, $className, $methodName, true, $completionProviders); $discoveredCount['resourceTemplates']++; - $this->discoverAndRegisterCompletionProviders('ref/resource', $instance->uriTemplate, $method); break; } } catch (McpServerException $e) { @@ -255,25 +251,23 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount } } - /** - * Discover and register completion providers for a given MCP Element. - * - * @param 'ref/prompt'|'ref/resource'|'ref/tool' $refType - */ - private function discoverAndRegisterCompletionProviders(string $refType, string $identifier, ReflectionMethod $handlerMethod): void + private function getCompletionProviders(\ReflectionMethod $reflectionMethod): array { - foreach ($handlerMethod->getParameters() as $param) { + $completionProviders = []; + foreach ($reflectionMethod->getParameters() as $param) { $reflectionType = $param->getType(); - if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { + if ($reflectionType instanceof \ReflectionNamedType && ! $reflectionType->isBuiltin()) { continue; } - $completionAttributes = $param->getAttributes(CompletionProvider::class, ReflectionAttribute::IS_INSTANCEOF); + $completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF); if (!empty($completionAttributes)) { $attributeInstance = $completionAttributes[0]->newInstance(); - $this->registry->registerCompletionProvider($refType, $identifier, $param->getName(), $attributeInstance->providerClass); + $completionProviders[$param->getName()] = $attributeInstance->providerClass; } } + + return $completionProviders; } /** diff --git a/src/Support/DocBlockParser.php b/src/Utils/DocBlockParser.php similarity index 96% rename from src/Support/DocBlockParser.php rename to src/Utils/DocBlockParser.php index 2fa7a1d..476d756 100644 --- a/src/Support/DocBlockParser.php +++ b/src/Utils/DocBlockParser.php @@ -1,6 +1,6 @@ getDescription()); if ($summary && $descriptionBody) { - return $summary."\n\n".$descriptionBody; + return $summary . "\n\n" . $descriptionBody; } if ($summary) { return $summary; @@ -94,7 +94,7 @@ public function getParamTags(?DocBlock $docBlock): array $paramTags = []; foreach ($docBlock->getTagsByName('param') as $tag) { if ($tag instanceof Param && $tag->getVariableName()) { - $paramTags['$'.$tag->getVariableName()] = $tag; + $paramTags['$' . $tag->getVariableName()] = $tag; } } diff --git a/src/Support/HandlerResolver.php b/src/Utils/HandlerResolver.php similarity index 99% rename from src/Support/HandlerResolver.php rename to src/Utils/HandlerResolver.php index 0d9feb5..090cbeb 100644 --- a/src/Support/HandlerResolver.php +++ b/src/Utils/HandlerResolver.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpMcp\Server\Support; +namespace PhpMcp\Server\Utils; use InvalidArgumentException; use ReflectionMethod; diff --git a/src/Support/RandomIdGenerator.php b/src/Utils/RandomIdGenerator.php similarity index 88% rename from src/Support/RandomIdGenerator.php rename to src/Utils/RandomIdGenerator.php index 96eea5c..2b4e486 100644 --- a/src/Support/RandomIdGenerator.php +++ b/src/Utils/RandomIdGenerator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpMcp\Server\Support; +namespace PhpMcp\Server\Utils; use PhpMcp\Server\Contracts\IdGeneratorInterface; diff --git a/src/Support/SchemaGenerator.php b/src/Utils/SchemaGenerator.php similarity index 96% rename from src/Support/SchemaGenerator.php rename to src/Utils/SchemaGenerator.php index 2fa4bb4..7343f5e 100644 --- a/src/Support/SchemaGenerator.php +++ b/src/Utils/SchemaGenerator.php @@ -1,6 +1,6 @@ mapPhpTypeToJsonSchemaType($typeString); - $nonNullItemTypes = array_filter($itemJsonTypes, fn ($t) => $t !== 'null'); + $nonNullItemTypes = array_filter($itemJsonTypes, fn($t) => $t !== 'null'); if (count($nonNullItemTypes) === 1) { $paramSchema['items'] = ['type' => $nonNullItemTypes[0]]; } @@ -78,7 +78,7 @@ public function fromMethodParameters(ReflectionMethod $method): array sort($jsonTypes); } - $nonNullTypes = array_filter($jsonTypes, fn ($t) => $t !== 'null'); + $nonNullTypes = array_filter($jsonTypes, fn($t) => $t !== 'null'); if (count($jsonTypes) === 1) { $paramSchema['type'] = $jsonTypes[0]; } elseif (count($jsonTypes) > 1) { @@ -248,7 +248,7 @@ private function parseParametersInfo(ReflectionMethod $method, ?DocBlock $docBlo foreach ($method->getParameters() as $rp) { $paramName = $rp->getName(); - $paramTag = $paramTags['$'.$paramName] ?? null; + $paramTag = $paramTags['$' . $paramName] ?? null; $reflectionType = $rp->getType(); $typeString = $this->getParameterTypeString($rp, $paramTag); @@ -351,7 +351,7 @@ private function getParameterTypeString(ReflectionParameter $rp, ?Param $paramTa if (stripos($docBlockType, 'null') !== false && $reflectionTypeString && stripos($reflectionTypeString, 'null') === false && ! str_ends_with($reflectionTypeString, '|null')) { // If reflection didn't capture null, but docblock did, append |null (if not already mixed) if ($reflectionTypeString !== 'mixed') { - return $reflectionTypeString.'|null'; + return $reflectionTypeString . '|null'; } } @@ -382,10 +382,9 @@ private function getTypeStringFromReflection(?ReflectionType $type, bool $native $types[] = $this->getTypeStringFromReflection($innerType, $innerType->allowsNull()); } if ($nativeAllowsNull) { - $types = array_filter($types, fn ($t) => strtolower($t) !== 'null'); + $types = array_filter($types, fn($t) => strtolower($t) !== 'null'); } $typeString = implode('|', array_unique(array_filter($types))); - } elseif ($type instanceof ReflectionIntersectionType) { foreach ($type->getTypes() as $innerType) { $types[] = $this->getTypeStringFromReflection($innerType, false); @@ -428,7 +427,7 @@ private function getTypeStringFromReflection(?ReflectionType $type, bool $native // Remove leading backslash from class names, but handle built-ins like 'int' or unions like 'int|string' if (str_contains($typeString, '\\')) { $parts = preg_split('/([|&])/', $typeString, -1, PREG_SPLIT_DELIM_CAPTURE); - $processedParts = array_map(fn ($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts); + $processedParts = array_map(fn($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts); $typeString = implode('', $processedParts); } @@ -450,8 +449,10 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array } // PRIORITY 2: Check for array syntax first (T[] or generics) - if (str_contains($normalizedType, '[]') || - preg_match('/^(array|list|iterable|collection)> syntax or T[][] syntax - if (preg_match('/^(\\??)array\s*<\s*array\s*<\s*([\w\\\\|]+)\s*>\s*>$/i', $normalizedType, $matches) || - preg_match('/^(\\??)([\w\\\\]+)\s*\[\]\[\]$/i', $normalizedType, $matches)) { + if ( + preg_match('/^(\\??)array\s*<\s*array\s*<\s*([\w\\\\|]+)\s*>\s*>$/i', $normalizedType, $matches) || + preg_match('/^(\\??)([\w\\\\]+)\s*\[\]\[\]$/i', $normalizedType, $matches) + ) { $innerType = $this->mapSimpleTypeToJsonSchema(isset($matches[2]) ? strtolower($matches[2]) : 'any'); // Return a schema for array with items being arrays return [ @@ -600,8 +603,10 @@ private function parsePropertyDefinition(string $propDefinition, array &$propert $properties[$propName] = $nestedSchema; } // Check for array or T[] syntax - elseif (preg_match('/^array\s*<\s*([\w\\\\|]+)\s*>$/i', $propType, $arrayMatches) || - preg_match('/^([\w\\\\]+)\s*\[\]$/i', $propType, $arrayMatches)) { + elseif ( + preg_match('/^array\s*<\s*([\w\\\\|]+)\s*>$/i', $propType, $arrayMatches) || + preg_match('/^([\w\\\\]+)\s*\[\]$/i', $propType, $arrayMatches) + ) { $itemType = $arrayMatches[1] ?? 'any'; $properties[$propName] = [ 'type' => 'array', diff --git a/src/Support/SchemaValidator.php b/src/Utils/SchemaValidator.php similarity index 96% rename from src/Support/SchemaValidator.php rename to src/Utils/SchemaValidator.php index 6941ddd..06aec6d 100644 --- a/src/Support/SchemaValidator.php +++ b/src/Utils/SchemaValidator.php @@ -1,6 +1,6 @@ convertDataForValidator($data); - } catch (JsonException $e) { $this->logger->error('MCP SDK: Invalid schema structure provided for validation (JSON conversion failed).', ['exception' => $e]); @@ -75,7 +74,7 @@ public function validateAgainstJsonSchema(mixed $data, array|object $schema): ar 'schema' => json_encode($schemaObject), ]); - return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Schema validation process failed: '.$e->getMessage()]]; + return [['pointer' => '', 'keyword' => 'internal', 'message' => 'Schema validation process failed: ' . $e->getMessage()]]; } if ($result->isValid()) { @@ -178,7 +177,7 @@ private function formatJsonPointerPath(?array $pathComponents): string return str_replace(['~', '/'], ['~0', '~1'], $componentStr); }, $pathComponents); - return '/'.implode('/', $escapedComponents); + return '/' . implode('/', $escapedComponents); } /** @@ -193,7 +192,7 @@ private function formatValidationError(ValidationError $error): string switch (strtolower($keyword)) { case 'required': $missing = $args['missing'] ?? []; - $formattedMissing = implode(', ', array_map(fn ($p) => "`{$p}`", $missing)); + $formattedMissing = implode(', ', array_map(fn($p) => "`{$p}`", $missing)); $message = "Missing required properties: {$formattedMissing}."; break; case 'type': @@ -216,7 +215,7 @@ private function formatValidationError(ValidationError $error): string } else { $formattedAllowed = array_map(function ($v) { /* ... formatting logic ... */ if (is_string($v)) { - return '"'.$v.'"'; + return '"' . $v . '"'; } if (is_bool($v)) { return $v ? 'true' : 'false'; @@ -227,7 +226,7 @@ private function formatValidationError(ValidationError $error): string return (string) $v; }, $allowedValues); - $message = 'Value must be one of the allowed values: '.implode(', ', $formattedAllowed).'.'; + $message = 'Value must be one of the allowed values: ' . implode(', ', $formattedAllowed) . '.'; } break; case 'const': @@ -287,7 +286,7 @@ private function formatValidationError(ValidationError $error): string break; case 'additionalProperties': // Corrected casing $unexpected = $args['properties'] ?? []; - $formattedUnexpected = implode(', ', array_map(fn ($p) => "`{$p}`", $unexpected)); + $formattedUnexpected = implode(', ', array_map(fn($p) => "`{$p}`", $unexpected)); $message = "Object contains unexpected additional properties: {$formattedUnexpected}."; break; case 'format': @@ -300,7 +299,7 @@ private function formatValidationError(ValidationError $error): string $placeholders = $args ?? []; $builtInMessage = preg_replace_callback('/\{(\w+)\}/', function ($match) use ($placeholders) { $key = $match[1]; - $value = $placeholders[$key] ?? '{'.$key.'}'; + $value = $placeholders[$key] ?? '{' . $key . '}'; return is_array($value) ? json_encode($value) : (string) $value; }, $builtInMessage); diff --git a/tests/Unit/Definitions/PromptDefinitionTest.php b/tests/Unit/Definitions/PromptDefinitionTest.php deleted file mode 100644 index 76576ae..0000000 --- a/tests/Unit/Definitions/PromptDefinitionTest.php +++ /dev/null @@ -1,204 +0,0 @@ - new PromptDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - promptName: $promptName, - description: 'Desc', - arguments: [] - ); - - if ($shouldFail) { - expect($action)->toThrow(\InvalidArgumentException::class, "Prompt name '{$promptName}' is invalid"); - } else { - expect($action)->not->toThrow(\InvalidArgumentException::class); - } -})->with([ - ['valid-prompt_name1', false], - ['validPrompt', false], - ['invalid name', true], // Space - ['invalid!@#', true], // Special chars - ['', true], // Empty -]); - -// --- fromReflection Tests --- - -beforeEach(function () { - $this->docBlockParser = Mockery::mock(DocBlockParser::class); -}); - -test('fromReflection creates definition with explicit name and description', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpPrompt(name: 'explicit-prompt-name', description: 'Explicit Description'); - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Mock parser - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - // Mocks for argument processing (needed for fromReflection to run) - $this->docBlockParser->shouldReceive('getParamTags')->once()->with(null)->andReturn([]); - - // Act - $definition = PromptDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $this->docBlockParser - ); - - // Assert - expect($definition->promptName)->toBe('explicit-prompt-name'); - expect($definition->description)->toBe('Explicit Description'); - expect($definition->className)->toBe(AllElementsStub::class); - expect($definition->methodName)->toBe('templateMethod'); - // Assert arguments based on reflection (templateMethod has 1 param: $id) - expect($definition->arguments)->toBeArray()->toHaveCount(1); - expect($definition->arguments[0]->name)->toBe('id'); -}); - -test('fromReflection uses method name and docblock summary as defaults', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpPrompt(); - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Read the actual summary from the stub file - $stubContent = file_get_contents(__DIR__ . '/../../Mocks/DiscoveryStubs/AllElementsStub.php'); - preg_match('/\/\*\*(.*?)\*\/\s+public function templateMethod/s', $stubContent, $matches); - $actualDocComment = isset($matches[1]) ? trim(preg_replace('/^\s*\*\s?/?m', '', $matches[1])) : ''; - $expectedSummary = explode("\n", $actualDocComment)[0] ?? null; - - // Mock parser - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn($expectedSummary); - $this->docBlockParser->shouldReceive('getParamTags')->once()->with(null)->andReturn([]); - - // Act - $definition = PromptDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $this->docBlockParser - ); - - // Assert - expect($definition->promptName)->toBe('templateMethod'); // Default to method name - expect($definition->description)->toBe($expectedSummary); // Default to summary - expect($definition->className)->toBe(AllElementsStub::class); - expect($definition->methodName)->toBe('templateMethod'); - expect($definition->arguments)->toBeArray()->toHaveCount(1); // templateMethod has 1 param -}); - -test('fromReflection handles missing docblock summary', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(ToolOnlyStub::class, 'tool1'); - $attribute = new McpPrompt(); - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Mock parser - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn(null); - $this->docBlockParser->shouldReceive('getParamTags')->once()->with(null)->andReturn([]); - - // Act - $definition = PromptDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $this->docBlockParser - ); - - // Assert - expect($definition->promptName)->toBe('tool1'); - expect($definition->description)->toBeNull(); - expect($definition->className)->toBe(ToolOnlyStub::class); - expect($definition->methodName)->toBe('tool1'); - expect($definition->arguments)->toBeArray()->toBeEmpty(); // tool1 has no params -}); - -// --- Serialization Tests --- - -test('can be serialized and unserialized correctly via toArray/fromArray', function () { - // Arrange - // Use a real argument definition based on the stub method - $reflectionParam = new ReflectionParameter([AllElementsStub::class, 'templateMethod'], 'id'); - $arg1 = PromptArgumentDefinition::fromReflection($reflectionParam, null); // Assume null tag for simplicity - - $original = new PromptDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - promptName: 'serial-prompt', - description: 'Testing serialization', - arguments: [$arg1] - ); - - // Act - $mcpArray = $original->toArray(); - $internalArray = [ - 'className' => $original->className, - 'methodName' => $original->methodName, - 'promptName' => $original->promptName, - 'description' => $original->description, - 'arguments' => $mcpArray['arguments'], // Use the toArray version of arguments - ]; - - $reconstructed = PromptDefinition::fromArray($internalArray); - - // Assert - expect($reconstructed)->toEqual($original); // Should work with real argument object - expect($reconstructed->arguments[0]->name)->toBe('id'); -}); - -test('toArray produces correct MCP format', function () { - // Arrange - // Create real arguments based on stub - $reflectionParam = new ReflectionParameter([AllElementsStub::class, 'templateMethod'], 'id'); - $arg1 = PromptArgumentDefinition::fromReflection($reflectionParam, null); - - $definition = new PromptDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - promptName: 'mcp-prompt', - description: 'MCP Description', - arguments: [$arg1] - ); - $definitionMinimal = new PromptDefinition( - className: ToolOnlyStub::class, - methodName: 'tool1', - promptName: 'mcp-minimal', - description: null, - arguments: [] - ); - - // Act - $array = $definition->toArray(); - $arrayMinimal = $definitionMinimal->toArray(); - - // Assert - expect($array)->toBe([ - 'name' => 'mcp-prompt', - 'description' => 'MCP Description', - 'arguments' => [ - ['name' => 'id', 'required' => true], - ], - ]); - expect($arrayMinimal)->toBe([ - 'name' => 'mcp-minimal', - ]); - expect($arrayMinimal)->not->toHaveKeys(['description', 'arguments']); -}); diff --git a/tests/Unit/Definitions/ResourceDefinitionTest.php b/tests/Unit/Definitions/ResourceDefinitionTest.php deleted file mode 100644 index 82536ad..0000000 --- a/tests/Unit/Definitions/ResourceDefinitionTest.php +++ /dev/null @@ -1,241 +0,0 @@ - new ResourceDefinition( - className: AllElementsStub::class, - methodName: 'resourceMethod', - uri: 'file:///valid/uri', - name: $resourceName, - description: 'Desc', - mimeType: 'text/plain', - size: 100, - ); - - if ($shouldFail) { - expect($action)->toThrow(\InvalidArgumentException::class, "Resource name '{$resourceName}' is invalid"); - } else { - expect($action)->not->toThrow(\InvalidArgumentException::class); - } -})->with([ - ['valid-res_name1', false], - ['validRes', false], - ['invalid name', true], // Space - ['invalid!@#', true], // Special chars - ['', true], // Empty -]); - -test('constructor validates URI pattern', function (string $uri, bool $shouldFail) { - $action = fn () => new ResourceDefinition( - className: AllElementsStub::class, - methodName: 'resourceMethod', - uri: $uri, - name: 'valid-name', - description: 'Desc', - mimeType: 'text/plain', - size: 100, - ); - - if ($shouldFail) { - expect($action)->toThrow(\InvalidArgumentException::class, "Resource URI '{$uri}' is invalid"); - } else { - expect($action)->not->toThrow(\InvalidArgumentException::class); - } -})->with([ - ['file:///valid/path', false], - ['config://app/settings', false], - ['custom+scheme://data?id=1', false], - ['noscheme', true], // Missing :// - ['invalid-scheme:/path', true], // Missing // - ['file:/invalid//path', true], // Missing // - ['http://', false], // Valid scheme, empty authority/path is allowed by regex - ['http://host:port/path', false], - [' ', true], // Empty/Whitespace -]); - -// --- fromReflection Tests --- - -beforeEach(function () { - $this->docBlockParser = Mockery::mock(DocBlockParser::class); -}); - -test('fromReflection creates definition with explicit values from attribute', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'resourceMethod'); - $attribute = new McpResource( - uri: 'test://explicit/uri', - name: 'explicit-res-name', - description: 'Explicit Description', - mimeType: 'application/json', - size: 1234, - ); - $docComment = $reflectionMethod->getDocComment() ?: null; - - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - - // Act - $definition = ResourceDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $attribute->uri, - $attribute->mimeType, - $attribute->size, - $this->docBlockParser - ); - - // Assert - expect($definition->uri)->toBe('test://explicit/uri'); - expect($definition->name)->toBe('explicit-res-name'); - expect($definition->description)->toBe('Explicit Description'); - expect($definition->className)->toBe(AllElementsStub::class); - expect($definition->methodName)->toBe('resourceMethod'); - expect($definition->mimeType)->toBe('application/json'); - expect($definition->size)->toBe(1234); -}); - -test('fromReflection uses method name and docblock summary as defaults', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'resourceMethod'); - $attribute = new McpResource(uri: 'test://default/uri'); - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Read the actual summary from the stub file - $stubContent = file_get_contents(__DIR__ . '/../../Mocks/DiscoveryStubs/AllElementsStub.php'); - preg_match('/\/\*\*(.*?)\*\/\s+public function resourceMethod/s', $stubContent, $matches); - $actualDocComment = isset($matches[1]) ? trim(preg_replace('/^\s*\*\s?/?m', '', $matches[1])) : ''; - $expectedSummary = explode("\n", $actualDocComment)[0] ?? null; - - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn($expectedSummary); - - // Act - $definition = ResourceDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $attribute->uri, - $attribute->mimeType, - $attribute->size, - $this->docBlockParser - ); - - // Assert - expect($definition->uri)->toBe('test://default/uri'); - expect($definition->name)->toBe('resourceMethod'); // Default to method name - expect($definition->description)->toBe($expectedSummary); // Default to summary - expect($definition->className)->toBe(AllElementsStub::class); - expect($definition->methodName)->toBe('resourceMethod'); - expect($definition->mimeType)->toBeNull(); - expect($definition->size)->toBeNull(); -}); - -test('fromReflection handles missing docblock summary', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(ResourceOnlyStub::class, 'resource2'); - $attribute = new McpResource(uri: 'test://no/desc'); - $docComment = $reflectionMethod->getDocComment() ?: null; - - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn(null); - - // Act - $definition = ResourceDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $attribute->uri, - $attribute->mimeType, - $attribute->size, - $this->docBlockParser - ); - - // Assert - expect($definition->name)->toBe('resource2'); - expect($definition->description)->toBeNull(); - expect($definition->className)->toBe(ResourceOnlyStub::class); - expect($definition->methodName)->toBe('resource2'); -}); - -// --- Serialization Tests --- - -test('can be serialized and unserialized correctly via toArray/fromArray', function () { - // Arrange - $original = new ResourceDefinition( - className: AllElementsStub::class, - methodName: 'resourceMethod', - uri: 'serial://test/resource', - name: 'serial-res', - description: 'Testing serialization', - mimeType: 'image/jpeg', - size: 9876, - ); - - // Act - $mcpArray = $original->toArray(); - $internalArray = [ - 'className' => $original->className, - 'methodName' => $original->methodName, - 'uri' => $original->uri, - 'name' => $original->name, - 'description' => $original->description, - 'mimeType' => $original->mimeType, - 'size' => $original->size, - ]; - $reconstructed = ResourceDefinition::fromArray($internalArray); - - // Assert - expect($reconstructed)->toEqual($original); - expect($reconstructed->size)->toBe($original->size); -}); - -test('toArray produces correct MCP format', function () { - // Arrange - $definition = new ResourceDefinition( - className: AllElementsStub::class, - methodName: 'resourceMethod', - uri: 'mcp://resource', - name: 'mcp-res', - description: 'MCP Description', - mimeType: 'text/markdown', - size: 555, - ); - $definitionMinimal = new ResourceDefinition( - className: ResourceOnlyStub::class, - methodName: 'resource2', - uri: 'mcp://minimal', - name: 'mcp-minimal', - description: null, - mimeType: null, - size: null, - ); - - // Act - $array = $definition->toArray(); - $arrayMinimal = $definitionMinimal->toArray(); - - // Assert - expect($array)->toBe([ - 'uri' => 'mcp://resource', - 'name' => 'mcp-res', - 'description' => 'MCP Description', - 'mimeType' => 'text/markdown', - 'size' => 555, - ]); - expect($arrayMinimal)->toBe([ - 'uri' => 'mcp://minimal', - 'name' => 'mcp-minimal', - ]); - expect($arrayMinimal)->not->toHaveKeys(['description', 'mimeType', 'size']); -}); diff --git a/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php b/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php deleted file mode 100644 index 30c3f87..0000000 --- a/tests/Unit/Definitions/ResourceTemplateDefinitionTest.php +++ /dev/null @@ -1,231 +0,0 @@ - new ResourceTemplateDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - uriTemplate: 'user://{userId}/profile', - name: $templateName, - description: 'Desc', - mimeType: 'application/json', - ); - - if ($shouldFail) { - expect($action)->toThrow(\InvalidArgumentException::class, "Resource name '{$templateName}' is invalid"); - } else { - expect($action)->not->toThrow(\InvalidArgumentException::class); - } -})->with([ - ['valid-tmpl_name1', false], - ['validTmpl', false], - ['invalid name', true], // Space - ['invalid!@#', true], // Special chars - ['', true], // Empty -]); - -test('constructor validates URI template pattern', function (string $uriTemplate, bool $shouldFail) { - $action = fn () => new ResourceTemplateDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - uriTemplate: $uriTemplate, - name: 'valid-name', - description: 'Desc', - mimeType: 'application/json', - ); - - if ($shouldFail) { - expect($action)->toThrow(\InvalidArgumentException::class, "Resource URI template '{$uriTemplate}' is invalid"); - } else { - expect($action)->not->toThrow(\InvalidArgumentException::class); - } -})->with([ - ['file:///{path}', false], - ['config://{setting}/value', false], - ['user://{user_id}/data/{data_id}', false], - ['noscheme/{id}', true], // Missing :// - ['file://no_placeholder', true], // Missing {} - ['file://{id', true], // Missing closing } - ['file://id}', true], // Missing opening { - ['http://{path}/sub', false], - ['http://host:port/{path}', false], - [' ', true], // Empty/Whitespace -]); - -// --- fromReflection Tests --- - -beforeEach(function () { - $this->docBlockParser = Mockery::mock(DocBlockParser::class); -}); - -test('fromReflection creates definition with explicit values from attribute', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpResourceTemplate( - uriTemplate: 'test://explicit/{id}/uri', - name: 'explicit-tmpl-name', - description: 'Explicit Description', - mimeType: 'application/xml', - ); - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Mock parser - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - - // Act - $definition = ResourceTemplateDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $attribute->uriTemplate, - $attribute->mimeType, - $this->docBlockParser - ); - - // Assert - expect($definition->uriTemplate)->toBe('test://explicit/{id}/uri'); - expect($definition->name)->toBe('explicit-tmpl-name'); - expect($definition->description)->toBe('Explicit Description'); - expect($definition->className)->toBe(AllElementsStub::class); - expect($definition->methodName)->toBe('templateMethod'); - expect($definition->mimeType)->toBe('application/xml'); -}); - -test('fromReflection uses method name and docblock summary as defaults', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpResourceTemplate(uriTemplate: 'test://default/{tmplId}'); - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Read the actual summary from the stub file - $stubContent = file_get_contents(__DIR__ . '/../../Mocks/DiscoveryStubs/AllElementsStub.php'); - preg_match('/\/\*\*(.*?)\*\/\s+public function templateMethod/s', $stubContent, $matches); - $actualDocComment = isset($matches[1]) ? trim(preg_replace('/^\s*\*\s?/?m', '', $matches[1])) : ''; - $expectedSummary = explode("\n", $actualDocComment)[0] ?? null; - - // Mock parser - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn($expectedSummary); - - // Act - $definition = ResourceTemplateDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $attribute->uriTemplate, - $attribute->mimeType, - $this->docBlockParser - ); - - // Assert - expect($definition->uriTemplate)->toBe('test://default/{tmplId}'); - expect($definition->name)->toBe('templateMethod'); // Default to method name - expect($definition->description)->toBe($expectedSummary); // Default to summary - expect($definition->className)->toBe(AllElementsStub::class); - expect($definition->methodName)->toBe('templateMethod'); - expect($definition->mimeType)->toBeNull(); -}); - -test('fromReflection handles missing docblock summary', function () { - // Arrange - // Use the same stub method, but mock the parser to return null for summary - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpResourceTemplate(uriTemplate: 'test://no/desc/{id}'); - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Mock parser - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn(null); // Mock no summary - - // Act - $definition = ResourceTemplateDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $attribute->uriTemplate, - $attribute->mimeType, - $this->docBlockParser - ); - - // Assert - expect($definition->name)->toBe('templateMethod'); // Still defaults to method name - expect($definition->description)->toBeNull(); // No description available - expect($definition->className)->toBe(AllElementsStub::class); - expect($definition->methodName)->toBe('templateMethod'); -}); - -// --- Serialization Tests --- - -test('can be serialized and unserialized correctly via toArray/fromArray', function () { - // Arrange - $original = new ResourceTemplateDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - uriTemplate: 'serial://{type}/resource', - name: 'serial-tmpl', - description: 'Testing serialization', - mimeType: 'text/csv', - ); - - // Act - $mcpArray = $original->toArray(); - $internalArray = [ - 'className' => $original->className, - 'methodName' => $original->methodName, - 'uriTemplate' => $original->uriTemplate, - 'name' => $original->name, - 'description' => $original->description, - 'mimeType' => $original->mimeType, - ]; - $reconstructed = ResourceTemplateDefinition::fromArray($internalArray); - - // Assert - expect($reconstructed)->toEqual($original); -}); - -test('toArray produces correct MCP format', function () { - // Arrange - $definition = new ResourceTemplateDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - uriTemplate: 'mcp://{entity}/{id}', - name: 'mcp-tmpl', - description: 'MCP Description', - mimeType: 'application/vnd.api+json', - ); - $definitionMinimal = new ResourceTemplateDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - uriTemplate: 'mcp://minimal/{key}', - name: 'mcp-minimal', - description: null, - mimeType: null, - ); - - // Act - $array = $definition->toArray(); - $arrayMinimal = $definitionMinimal->toArray(); - - // Assert - expect($array)->toBe([ - 'uriTemplate' => 'mcp://{entity}/{id}', - 'name' => 'mcp-tmpl', - 'description' => 'MCP Description', - 'mimeType' => 'application/vnd.api+json', - ]); - expect($arrayMinimal)->toBe([ - 'uriTemplate' => 'mcp://minimal/{key}', - 'name' => 'mcp-minimal', - ]); - expect($arrayMinimal)->not->toHaveKeys(['description', 'mimeType']); -}); diff --git a/tests/Unit/Definitions/ToolDefinitionTest.php b/tests/Unit/Definitions/ToolDefinitionTest.php deleted file mode 100644 index 5dee833..0000000 --- a/tests/Unit/Definitions/ToolDefinitionTest.php +++ /dev/null @@ -1,215 +0,0 @@ - new ToolDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - toolName: $toolName, - description: 'Desc', - inputSchema: ['type' => 'object'] - ); - - if ($shouldFail) { - expect($action)->toThrow(\InvalidArgumentException::class, "Tool name '{$toolName}' is invalid"); - } else { - expect($action)->not->toThrow(\InvalidArgumentException::class); - } -})->with([ - ['valid-tool_name1', false], - ['validTool', false], - ['invalid name', true], // Space - ['invalid!@#', true], // Special chars - ['', true], // Empty -]); - -// --- fromReflection Tests --- - -beforeEach(function () { - $this->docBlockParser = Mockery::mock(DocBlockParser::class); - $this->schemaGenerator = Mockery::mock(SchemaGenerator::class); -}); - -test('fromReflection creates definition with explicit name and description', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpTool(name: 'explicit-tool-name', description: 'Explicit Description'); - $expectedSchema = ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]]; - $docComment = $reflectionMethod->getDocComment() ?: null; - - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->schemaGenerator->shouldReceive('fromMethodParameters')->once()->with($reflectionMethod)->andReturn($expectedSchema); - - // Act - $definition = ToolDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $this->docBlockParser, - $this->schemaGenerator - ); - - // Assert - expect($definition->toolName)->toBe('explicit-tool-name'); - expect($definition->description)->toBe('Explicit Description'); - expect($definition->className)->toBe(AllElementsStub::class); - expect($definition->methodName)->toBe('templateMethod'); - expect($definition->inputSchema)->toBe($expectedSchema); -}); - -test('fromReflection uses method name and docblock summary as defaults', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(AllElementsStub::class, 'templateMethod'); - $attribute = new McpTool(); - - $expectedSchema = ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]]; - $docComment = $reflectionMethod->getDocComment() ?: null; - - // Read the actual summary from the stub file to make the test robust - $stubContent = file_get_contents(__DIR__ . '/../../Mocks/DiscoveryStubs/AllElementsStub.php'); - preg_match('/\/\*\*(.*?)\*\/\s+public function templateMethod/s', $stubContent, $matches); - $actualDocComment = isset($matches[1]) ? trim(preg_replace('/^\s*\*\s?/?m', '', $matches[1])) : ''; - $expectedSummary = explode("\n", $actualDocComment)[0] ?? null; // First line is summary - - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn($expectedSummary); - $this->schemaGenerator->shouldReceive('fromMethodParameters')->once()->with($reflectionMethod)->andReturn($expectedSchema); - - // Act - $definition = ToolDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $this->docBlockParser, - $this->schemaGenerator - ); - - // Assert - expect($definition->toolName)->toBe('templateMethod'); // Default to method name - expect($definition->description)->toBe($expectedSummary); // Default to actual summary - expect($definition->className)->toBe(AllElementsStub::class); - expect($definition->methodName)->toBe('templateMethod'); - expect($definition->inputSchema)->toBe($expectedSchema); -}); - -test('fromReflection uses class short name as default tool name for invokable classes', function () { - $reflectionMethod = new ReflectionMethod(ToolOnlyStub::class, '__invoke'); - - $docComment = $reflectionMethod->getDocComment() ?: null; - - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->schemaGenerator->shouldReceive('fromMethodParameters')->once()->with($reflectionMethod)->andReturn(['type' => 'object']); - - $definition = ToolDefinition::fromReflection( - $reflectionMethod, - null, - "Some description", - $this->docBlockParser, - $this->schemaGenerator - ); - - expect($definition->toolName)->toBe('ToolOnlyStub'); - expect($definition->className)->toBe(ToolOnlyStub::class); - expect($definition->methodName)->toBe('__invoke'); - expect($definition->inputSchema)->toBe(['type' => 'object']); -}); - -test('fromReflection handles missing docblock summary', function () { - // Arrange - $reflectionMethod = new ReflectionMethod(ToolOnlyStub::class, 'tool1'); - $attribute = new McpTool(); - $expectedSchema = ['type' => 'object', 'properties' => []]; // tool1 has no params - $docComment = $reflectionMethod->getDocComment() ?: null; // Will be null/empty - - $this->docBlockParser->shouldReceive('parseDocBlock')->once()->with($docComment)->andReturn(null); - $this->docBlockParser->shouldReceive('getSummary')->once()->with(null)->andReturn(null); - $this->schemaGenerator->shouldReceive('fromMethodParameters')->once()->with($reflectionMethod)->andReturn($expectedSchema); - - // Act - $definition = ToolDefinition::fromReflection( - $reflectionMethod, - $attribute->name, - $attribute->description, - $this->docBlockParser, - $this->schemaGenerator - ); - - // Assert - expect($definition->toolName)->toBe('tool1'); - expect($definition->description)->toBeNull(); // No description available - expect($definition->className)->toBe(ToolOnlyStub::class); - expect($definition->methodName)->toBe('tool1'); - expect($definition->inputSchema)->toBe($expectedSchema); -}); - -// --- Serialization Tests --- - -test('can be serialized and unserialized correctly via toArray/fromArray', function () { - // Arrange - $original = new ToolDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - toolName: 'serial-tool', - description: 'Testing serialization', - inputSchema: ['type' => 'object', 'required' => ['id'], 'properties' => ['id' => ['type' => 'string']]] - ); - - // Act - $mcpArray = $original->toArray(); - $internalArray = [ - 'className' => $original->className, - 'methodName' => $original->methodName, - 'toolName' => $original->toolName, - 'description' => $original->description, - 'inputSchema' => $original->inputSchema, - ]; - $reconstructed = ToolDefinition::fromArray($internalArray); - - // Assert - expect($reconstructed)->toEqual($original); - expect($reconstructed->inputSchema)->toBe($original->inputSchema); -}); - -test('toArray produces correct MCP format', function () { - // Arrange - $definition = new ToolDefinition( - className: AllElementsStub::class, - methodName: 'templateMethod', - toolName: 'mcp-tool', - description: 'MCP Description', - inputSchema: ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]] - ); - $definitionNoDesc = new ToolDefinition( - className: ToolOnlyStub::class, - methodName: 'tool1', - toolName: 'mcp-tool-no-desc', - description: null, - inputSchema: ['type' => 'object'] - ); - - // Act - $array = $definition->toArray(); - $arrayNoDesc = $definitionNoDesc->toArray(); - - // Assert - expect($array)->toBe([ - 'name' => 'mcp-tool', - 'description' => 'MCP Description', - 'inputSchema' => ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]], - ]); - expect($arrayNoDesc)->toBe([ - 'name' => 'mcp-tool-no-desc', - 'inputSchema' => ['type' => 'object'], - ]); - expect($arrayNoDesc)->not->toHaveKey('description'); -}); diff --git a/tests/Unit/JsonRpc/BatchTest.php b/tests/Unit/JsonRpc/BatchTest.php deleted file mode 100644 index cb4c902..0000000 --- a/tests/Unit/JsonRpc/BatchTest.php +++ /dev/null @@ -1,162 +0,0 @@ -getRequests())->toBeArray(); - expect($batch->getRequests())->toBeEmpty(); - expect($batch->count())->toBe(0); -}); - -test('batch construction with requests array', function () { - $request = new Request('2.0', 1, 'test.method'); - $notification = new Notification('2.0', 'test.notification'); - - $batch = new Batch([$request, $notification]); - - expect($batch->getRequests())->toHaveCount(2); - expect($batch->getRequests()[0])->toBeInstanceOf(Request::class); - expect($batch->getRequests()[1])->toBeInstanceOf(Notification::class); - expect($batch->count())->toBe(2); -}); - -test('addRequest adds a request to the batch', function () { - $batch = new Batch(); - $request = new Request('2.0', 1, 'test.method'); - - $batch->addRequest($request); - - expect($batch->getRequests())->toHaveCount(1); - expect($batch->getRequests()[0])->toBeInstanceOf(Request::class); -}); - -test('addRequest is chainable', function () { - $batch = new Batch(); - $request = new Request('2.0', 1, 'test.method'); - $notification = new Notification('2.0', 'test.notification'); - - $result = $batch->addRequest($request)->addRequest($notification); - - expect($result)->toBe($batch); - expect($batch->getRequests())->toHaveCount(2); -}); - -test('getRequestsWithIds returns only requests with IDs', function () { - $request1 = new Request('2.0', 1, 'test.method1'); - $request2 = new Request('2.0', 2, 'test.method2'); - $notification = new Notification('2.0', 'test.notification'); - - $batch = new Batch([$request1, $notification, $request2]); - - $requestsWithIds = $batch->getRequestsWithIds(); - expect($requestsWithIds)->toHaveCount(2); - expect($requestsWithIds[0])->toBeInstanceOf(Request::class); - expect($requestsWithIds[0]->id)->toBe(1); - expect($requestsWithIds[2])->toBeInstanceOf(Request::class); - expect($requestsWithIds[2]->id)->toBe(2); -}); - -test('getNotifications returns only notifications', function () { - $request = new Request('2.0', 1, 'test.method'); - $notification1 = new Notification('2.0', 'test.notification1'); - $notification2 = new Notification('2.0', 'test.notification2'); - - $batch = new Batch([$request, $notification1, $notification2]); - - $notifications = $batch->getNotifications(); - expect($notifications)->toHaveCount(2); - expect($notifications[1])->toBeInstanceOf(Notification::class); - expect($notifications[1]->method)->toBe('test.notification1'); - expect($notifications[2])->toBeInstanceOf(Notification::class); - expect($notifications[2]->method)->toBe('test.notification2'); -}); - -test('count returns correct number of requests', function () { - $batch = new Batch(); - expect($batch->count())->toBe(0); - - $batch->addRequest(new Request('2.0', 1, 'test.method')); - expect($batch->count())->toBe(1); - - $batch->addRequest(new Notification('2.0', 'test.notification')); - expect($batch->count())->toBe(2); -}); - -test('fromArray creates batch from array of requests and notifications', function () { - $data = [ - [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'test.method1', - 'params' => [], - ], - [ - 'jsonrpc' => '2.0', - 'method' => 'test.notification', - 'params' => [], - ], - [ - 'jsonrpc' => '2.0', - 'id' => 2, - 'method' => 'test.method2', - 'params' => ['param1' => 'value1'], - ], - ]; - - $batch = Batch::fromArray($data); - - expect($batch->count())->toBe(3); - expect($batch->getRequests()[0])->toBeInstanceOf(Request::class); - expect($batch->getRequests()[0]->id)->toBe(1); - expect($batch->getRequests()[1])->toBeInstanceOf(Notification::class); - expect($batch->getRequests()[1]->method)->toBe('test.notification'); - expect($batch->getRequests()[2])->toBeInstanceOf(Request::class); - expect($batch->getRequests()[2]->id)->toBe(2); - expect($batch->getRequests()[2]->params)->toBe(['param1' => 'value1']); -}); - -test('fromArray throws exception for empty array', function () { - expect(fn () => Batch::fromArray([]))->toThrow(ProtocolException::class); -}); - -test('fromArray throws exception for non-array item', function () { - $data = [ - [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'test.method', - ], - 'not an array', - ]; - - expect(fn () => Batch::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('toArray returns array of request representations', function () { - $request = new Request('2.0', 1, 'test.method', ['param1' => 'value1']); - $notification = new Notification('2.0', 'test.notification'); - - $batch = new Batch([$request, $notification]); - - $array = $batch->toArray(); - - expect($array)->toBeArray(); - expect($array)->toHaveCount(2); - expect($array[0])->toBe([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'test.method', - 'params' => ['param1' => 'value1'], - ]); - expect($array[1])->toBe([ - 'jsonrpc' => '2.0', - 'method' => 'test.notification', - ]); -}); diff --git a/tests/Unit/JsonRpc/ErrorTest.php b/tests/Unit/JsonRpc/ErrorTest.php deleted file mode 100644 index ab297d1..0000000 --- a/tests/Unit/JsonRpc/ErrorTest.php +++ /dev/null @@ -1,68 +0,0 @@ - 'error details']); - - expect($error->code)->toBe(100); - expect($error->message)->toBe('Test error message'); - expect($error->data)->toBe(['details' => 'error details']); -}); - -test('error can be created without data', function () { - $error = new Error(100, 'Test error message'); - - expect($error->data)->toBeNull(); -}); - -test('fromArray creates valid error from complete data', function () { - $data = [ - 'code' => 100, - 'message' => 'Test error message', - 'data' => ['details' => 'error details'], - ]; - - $error = Error::fromArray($data); - - expect($error->code)->toBe(100); - expect($error->message)->toBe('Test error message'); - expect($error->data)->toBe(['details' => 'error details']); -}); - -test('fromArray handles missing data', function () { - $data = [ - 'code' => 100, - 'message' => 'Test error message', - ]; - - $error = Error::fromArray($data); - - expect($error->data)->toBeNull(); -}); - -test('toArray returns correct structure with data', function () { - $error = new Error(100, 'Test error message', ['details' => 'error details']); - - $array = $error->toArray(); - - expect($array)->toBe([ - 'code' => 100, - 'message' => 'Test error message', - 'data' => ['details' => 'error details'], - ]); -}); - -test('toArray omits null data', function () { - $error = new Error(100, 'Test error message'); - - $array = $error->toArray(); - - expect($array)->toBe([ - 'code' => 100, - 'message' => 'Test error message', - ]); - expect($array)->not->toHaveKey('data'); -}); diff --git a/tests/Unit/JsonRpc/MessageTest.php b/tests/Unit/JsonRpc/MessageTest.php deleted file mode 100644 index 208231c..0000000 --- a/tests/Unit/JsonRpc/MessageTest.php +++ /dev/null @@ -1,35 +0,0 @@ -jsonrpc)->toBe('2.0'); -}); - -test('toArray returns correct structure', function () { - $message = new Message('2.0'); - - $array = $message->toArray(); - - expect($array)->toBe(['jsonrpc' => '2.0']); -}); - -test('jsonSerialize returns same result as toArray', function () { - $message = new Message('2.0'); - - $array = $message->toArray(); - $json = $message->jsonSerialize(); - - expect($json)->toBe($array); -}); - -test('message can be json encoded directly', function () { - $message = new Message('2.0'); - - $json = json_encode($message); - - expect($json)->toBe('{"jsonrpc":"2.0"}'); -}); diff --git a/tests/Unit/JsonRpc/NotificationTest.php b/tests/Unit/JsonRpc/NotificationTest.php deleted file mode 100644 index 79417a6..0000000 --- a/tests/Unit/JsonRpc/NotificationTest.php +++ /dev/null @@ -1,116 +0,0 @@ - 'value1']); - - expect($notification->jsonrpc)->toBe('2.0'); - expect($notification->method)->toBe('test.method'); - expect($notification->params)->toBe(['param1' => 'value1']); -}); - -test('notification can be created without params', function () { - $notification = new Notification('2.0', 'test.method'); - - expect($notification->params)->toBe([]); -}); - -test('make static method creates notification with default jsonrpc version', function () { - $notification = Notification::make('test.method', ['param1' => 'value1']); - - expect($notification->jsonrpc)->toBe('2.0'); - expect($notification->method)->toBe('test.method'); - expect($notification->params)->toBe(['param1' => 'value1']); -}); - -test('make static method handles empty params', function () { - $notification = Notification::make('test.method'); - - expect($notification->params)->toBe([]); -}); - -test('fromArray creates valid notification from complete data', function () { - $data = [ - 'jsonrpc' => '2.0', - 'method' => 'test.method', - 'params' => ['param1' => 'value1'], - ]; - - $notification = Notification::fromArray($data); - - expect($notification->jsonrpc)->toBe('2.0'); - expect($notification->method)->toBe('test.method'); - expect($notification->params)->toBe(['param1' => 'value1']); -}); - -test('fromArray handles missing params', function () { - $data = [ - 'jsonrpc' => '2.0', - 'method' => 'test.method', - ]; - - $notification = Notification::fromArray($data); - - expect($notification->params)->toBe([]); -}); - -test('fromArray throws ProtocolException for invalid jsonrpc version', function () { - $data = ['jsonrpc' => '1.0', 'method' => 'test.method']; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED -}); - -test('fromArray throws ProtocolException for missing jsonrpc', function () { - $data = ['method' => 'test.method']; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED -}); - -test('fromArray throws ProtocolException for missing method', function () { - $data = ['jsonrpc' => '2.0']; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED -}); - -test('fromArray throws ProtocolException for non-string method', function () { - $data = ['jsonrpc' => '2.0', 'method' => 123]; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); // UPDATED -}); - -test('fromArray throws ProtocolException if params is not an array/object', function () { - $data = ['jsonrpc' => '2.0', 'method' => 'test', 'params' => 'string']; - expect(fn () => Notification::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('toArray returns correct structure with params', function () { - $notification = new Notification('2.0', 'test.method', ['param1' => 'value1']); - - $array = $notification->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'method' => 'test.method', - 'params' => ['param1' => 'value1'], - ]); -}); - -test('toArray omits empty params', function () { - $notification = new Notification('2.0', 'test.method'); - - $array = $notification->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'method' => 'test.method', - ]); - expect($array)->not->toHaveKey('params'); -}); - -test('notification can be json encoded', function () { - $notification = new Notification('2.0', 'test.method', ['param1' => 'value1']); - - $json = json_encode($notification); - - expect($json)->toBe('{"jsonrpc":"2.0","method":"test.method","params":{"param1":"value1"}}'); -}); diff --git a/tests/Unit/JsonRpc/RequestTest.php b/tests/Unit/JsonRpc/RequestTest.php deleted file mode 100644 index 9290dfc..0000000 --- a/tests/Unit/JsonRpc/RequestTest.php +++ /dev/null @@ -1,112 +0,0 @@ - 'value1']); - - expect($request->jsonrpc)->toBe('2.0'); - expect($request->id)->toBe(1); - expect($request->method)->toBe('test.method'); - expect($request->params)->toBe(['param1' => 'value1']); -}); - -test('request can be created with string id', function () { - $request = new Request('2.0', 'abc123', 'test.method'); - - expect($request->id)->toBe('abc123'); -}); - -test('request can be created without params', function () { - $request = new Request('2.0', 1, 'test.method'); - - expect($request->params)->toBe([]); -}); - -test('fromArray creates valid request from complete data', function () { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'test.method', - 'params' => ['param1' => 'value1'], - ]; - - $request = Request::fromArray($data); - - expect($request->jsonrpc)->toBe('2.0'); - expect($request->id)->toBe(1); - expect($request->method)->toBe('test.method'); - expect($request->params)->toBe(['param1' => 'value1']); -}); - -test('fromArray handles missing params', function () { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'test.method', - ]; - - $request = Request::fromArray($data); - - expect($request->params)->toBe([]); -}); - -test('fromArray throws ProtocolException for invalid jsonrpc version', function () { - $data = ['jsonrpc' => '1.0', 'id' => 1, 'method' => 'test.method']; - expect(fn () => Request::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('fromArray throws ProtocolException for missing jsonrpc', function () { - $data = ['id' => 1, 'method' => 'test.method']; - expect(fn () => Request::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('fromArray throws ProtocolException for missing method', function () { - $data = ['jsonrpc' => '2.0', 'id' => 1]; - expect(fn () => Request::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('fromArray throws ProtocolException for non-string method', function () { - $data = ['jsonrpc' => '2.0', 'id' => 1, 'method' => 123]; - expect(fn () => Request::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('fromArray throws ProtocolException for missing id', function () { - $data = ['jsonrpc' => '2.0', 'method' => 'test.method']; - expect(fn () => Request::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('fromArray throws ProtocolException for non-array params', function () { - $data = ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'test.method', 'params' => 'invalid']; - // This check was correct - expect(fn () => Request::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('toArray returns correct structure with params', function () { - $request = new Request('2.0', 1, 'test.method', ['param1' => 'value1']); - - $array = $request->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'test.method', - 'params' => ['param1' => 'value1'], - ]); -}); - -test('toArray omits empty params', function () { - $request = new Request('2.0', 1, 'test.method'); - - $array = $request->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'method' => 'test.method', - ]); - expect($array)->not->toHaveKey('params'); -}); diff --git a/tests/Unit/JsonRpc/ResponseTest.php b/tests/Unit/JsonRpc/ResponseTest.php deleted file mode 100644 index d22b14e..0000000 --- a/tests/Unit/JsonRpc/ResponseTest.php +++ /dev/null @@ -1,276 +0,0 @@ -jsonrpc)->toBe('2.0'); - expect($response->id)->toBe(1); - expect($response->result)->toBeInstanceOf(EmptyResult::class); - expect($response->error)->toBeNull(); -}); - -test('response construction sets all properties for error response', function () { - $error = new Error(100, 'Test error'); - $response = new Response('2.0', 1, null, $error); - - expect($response->jsonrpc)->toBe('2.0'); - expect($response->id)->toBe(1); - expect($response->result)->toBeNull(); - expect($response->error)->toBeInstanceOf(Error::class); -}); - -test('response construction allows null ID for error response', function () { - $error = new Error(100, 'Test error'); - $response = new Response('2.0', null, null, $error); - - expect($response->id)->toBeNull(); - expect($response->error)->toBe($error); - expect($response->result)->toBeNull(); -}); - -test('response constructor throws exception if ID present but no result/error', function () { - expect(fn () => new Response('2.0', 1, null, null)) - ->toThrow(InvalidArgumentException::class, 'must have either result or error'); -}); - -test('response constructor throws exception if ID null but no error', function () { - expect(fn () => new Response('2.0', null, null, null)) - ->toThrow(InvalidArgumentException::class, 'must have an error object'); -}); - -test('response constructor throws exception if ID null and result present', function () { - expect(fn () => new Response('2.0', null, ['data'], null)) - ->toThrow(InvalidArgumentException::class, 'response with null ID must have an error object'); -}); - -test('response throws exception if both result and error are provided with ID', function () { - $result = new EmptyResult(); - $error = new Error(100, 'Test error'); - expect(fn () => new Response('2.0', 1, $result, $error))->toThrow(InvalidArgumentException::class); -}); - -test('success static method creates success response', function () { - $result = new EmptyResult(); - $response = Response::success($result, 1); - - expect($response->jsonrpc)->toBe('2.0'); - expect($response->id)->toBe(1); - expect($response->result)->toBeInstanceOf(EmptyResult::class); - expect($response->error)->toBeNull(); -}); - -test('error static method creates error response', function () { - $error = new Error(100, 'Test error'); - $response = Response::error($error, 1); - - expect($response->jsonrpc)->toBe('2.0'); - expect($response->id)->toBe(1); - expect($response->result)->toBeNull(); - expect($response->error)->toBeInstanceOf(Error::class); -}); - -test('error static method creates error response with null ID', function () { - $error = new Error(100, 'Parse error'); - $response = Response::error($error, null); - - expect($response->jsonrpc)->toBe('2.0'); - expect($response->id)->toBeNull(); - expect($response->result)->toBeNull(); - expect($response->error)->toBeInstanceOf(Error::class); -}); - -// --- Status Check Tests --- - -test('isSuccess returns true for success response', function () { - $result = new EmptyResult(); - $response = Response::success($result, 1); - expect($response->isSuccess())->toBeTrue(); -}); - -test('isSuccess returns false for error response', function () { - $error = new Error(100, 'Test error'); - $response = Response::error($error, 1); // Use factory - expect($response->isSuccess())->toBeFalse(); -}); - -test('isError returns true for error response', function () { - $error = new Error(100, 'Test error'); - $response = Response::error($error, 1); - expect($response->isError())->toBeTrue(); -}); - -test('isError returns false for success response', function () { - $result = new EmptyResult(); - $response = Response::success($result, 1); - expect($response->isError())->toBeFalse(); -}); - -// --- fromArray Tests (Updated) --- - -test('fromArray creates valid success response with RAW result data', function () { - $rawResultData = ['key' => 'value', 'items' => [1, 2]]; - $data = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'result' => $rawResultData, - ]; - - $response = Response::fromArray($data); - - expect($response->jsonrpc)->toBe('2.0'); - expect($response->id)->toBe(1); - expect($response->result)->toEqual($rawResultData); - expect($response->result)->not->toBeInstanceOf(Result::class); - expect($response->error)->toBeNull(); - expect($response->isSuccess())->toBeTrue(); -}); - -test('fromArray creates valid error response with ID', function () { - $data = [ - 'jsonrpc' => '2.0', - 'id' => 1, - 'error' => ['code' => 100, 'message' => 'Test error'], - ]; - - $response = Response::fromArray($data); - - expect($response->jsonrpc)->toBe('2.0'); - expect($response->id)->toBe(1); - expect($response->result)->toBeNull(); - expect($response->error)->toBeInstanceOf(Error::class); - expect($response->error->code)->toBe(100); - expect($response->error->message)->toBe('Test error'); - expect($response->isError())->toBeTrue(); -}); - -test('fromArray creates valid error response with null ID', function () { - $data = [ - 'jsonrpc' => '2.0', - 'id' => null, - 'error' => ['code' => -32700, 'message' => 'Parse error'], - ]; - - $response = Response::fromArray($data); - - expect($response->jsonrpc)->toBe('2.0'); - expect($response->id)->toBeNull(); - expect($response->result)->toBeNull(); - expect($response->error)->toBeInstanceOf(Error::class); - expect($response->error->code)->toBe(-32700); - expect($response->error->message)->toBe('Parse error'); - expect($response->isError())->toBeTrue(); -}); - -test('fromArray throws exception for invalid jsonrpc version', function () { - $data = ['jsonrpc' => '1.0', 'id' => 1, 'result' => []]; - expect(fn () => Response::fromArray($data))->toThrow(ProtocolException::class); -}); - -test('fromArray throws exception for response with ID but missing result/error', function () { - $data = ['jsonrpc' => '2.0', 'id' => 1]; - expect(fn () => Response::fromArray($data))->toThrow(ProtocolException::class, 'must contain either "result" or "error"'); -}); - -test('fromArray throws exception for response with null ID but missing error', function () { - $data = ['jsonrpc' => '2.0', 'id' => null]; - expect(fn () => Response::fromArray($data))->toThrow(ProtocolException::class, 'must contain "error" when ID is null'); -}); - -test('fromArray throws exception for response with null ID and result present', function () { - $data = ['jsonrpc' => '2.0', 'id' => null, 'result' => 'abc', 'error' => ['code' => -32700, 'message' => 'e']]; - $dataOnlyResult = ['jsonrpc' => '2.0', 'id' => null, 'result' => 'abc']; - expect(fn () => Response::fromArray($dataOnlyResult)) - ->toThrow(ProtocolException::class, 'must contain "error" when ID is null'); -}); - -test('fromArray throws exception for invalid ID type', function () { - $data = ['jsonrpc' => '2.0', 'id' => [], 'result' => 'ok']; - expect(fn () => Response::fromArray($data))->toThrow(ProtocolException::class, 'Invalid "id" field type'); -}); - -test('fromArray throws exception for non-object error', function () { - $data = ['jsonrpc' => '2.0', 'id' => 1, 'error' => 'not an object']; - expect(fn () => Response::fromArray($data))->toThrow(ProtocolException::class, 'Invalid "error" field'); -}); - -test('fromArray throws exception for invalid error object structure', function () { - $data = ['jsonrpc' => '2.0', 'id' => 1, 'error' => ['code_missing' => -1]]; - expect(fn () => Response::fromArray($data)) - ->toThrow(ProtocolException::class, 'Invalid "error" object structure'); -}); - -// --- toArray / jsonSerialize Tests --- - -test('toArray returns correct structure for success response with raw result', function () { - $rawResult = ['some' => 'data']; - $response = new Response('2.0', 1, $rawResult); - - $array = $response->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'result' => $rawResult, - ]); -}); - -test('toArray returns correct structure when using success factory (with Result obj)', function () { - $resultObject = new EmptyResult(); - $response = Response::success($resultObject, 1); - - $array = $response->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'result' => [], - ]); -}); - -test('toArray returns correct structure for error response', function () { - $error = new Error(100, 'Test error'); - $response = Response::error($error, 1); - - $array = $response->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'id' => 1, - 'error' => ['code' => 100, 'message' => 'Test error'], - ]); -}); - -test('toArray returns correct structure for error response with null ID', function () { - $error = new Error(-32700, 'Parse error'); - $response = Response::error($error, null); - - $array = $response->toArray(); - - expect($array)->toBe([ - 'jsonrpc' => '2.0', - 'id' => null, - 'error' => ['code' => -32700, 'message' => 'Parse error'], - ]); -}); - -test('jsonSerialize returns same result as toArray', function () { - $result = new EmptyResult(); - $response = Response::success($result, 1); - - $array = $response->toArray(); - $json = $response->jsonSerialize(); - - expect($json)->toBe($array); -}); diff --git a/tests/Unit/JsonRpc/ResultTest.php b/tests/Unit/JsonRpc/ResultTest.php deleted file mode 100644 index 54dc111..0000000 --- a/tests/Unit/JsonRpc/ResultTest.php +++ /dev/null @@ -1,59 +0,0 @@ -toBeInstanceOf(Result::class); -}); - -test('Result implementation must define toArray method', function () { - $result = new EmptyResult(); - - expect($result->toArray())->toBe([]); -}); - -test('jsonSerialize calls toArray method', function () { - $result = new EmptyResult(); - - $serialized = $result->jsonSerialize(); - - expect($serialized)->toBe([]); -}); - -test('Result can be json encoded directly', function () { - $result = new EmptyResult(); - - $json = json_encode($result); - - expect($json)->toBe('[]'); -}); - -// Define a custom Result implementation for testing -class TestResult extends Result -{ - private array $data; - - public function __construct(array $data) - { - $this->data = $data; - } - - public function toArray(): array - { - return $this->data; - } -} - -test('Custom Result implementation works correctly', function () { - $data = ['key' => 'value', 'nested' => ['nested_key' => 'nested_value']]; - $result = new TestResult($data); - - expect($result->toArray())->toBe($data); - expect($result->jsonSerialize())->toBe($data); - expect(json_encode($result))->toBe('{"key":"value","nested":{"nested_key":"nested_value"}}'); -}); diff --git a/tests/Unit/JsonRpc/Results/EmptyResultTest.php b/tests/Unit/JsonRpc/Results/EmptyResultTest.php deleted file mode 100644 index aa7a2d5..0000000 --- a/tests/Unit/JsonRpc/Results/EmptyResultTest.php +++ /dev/null @@ -1,40 +0,0 @@ -toBeInstanceOf(Result::class); -}); - -test('EmptyResult constructor takes no parameters', function () { - $result = new EmptyResult(); - - expect($result)->toBeInstanceOf(EmptyResult::class); -}); - -test('toArray returns an empty array', function () { - $result = new EmptyResult(); - - expect($result->toArray())->toBe([]); - expect($result->toArray())->toBeEmpty(); -}); - -test('jsonSerialize returns an empty array', function () { - $result = new EmptyResult(); - - expect($result->jsonSerialize())->toBe([]); - expect($result->jsonSerialize())->toBeEmpty(); -}); - -test('json_encode produces an empty JSON object', function () { - $result = new EmptyResult(); - - $json = json_encode($result); - - expect($json)->toBe('[]'); -}); From 5b4fb277ed08cd251ae69b373838d068a8228f4d Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Tue, 17 Jun 2025 11:57:21 +0100 Subject: [PATCH 15/27] feat: improve enum handling for unit enums when preparing element arguments --- src/Elements/RegisteredElement.php | 56 ++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/src/Elements/RegisteredElement.php b/src/Elements/RegisteredElement.php index 66222ab..503b24f 100644 --- a/src/Elements/RegisteredElement.php +++ b/src/Elements/RegisteredElement.php @@ -5,6 +5,7 @@ namespace PhpMcp\Server\Elements; use InvalidArgumentException; +use JsonSerializable; use PhpMcp\Server\Exception\McpServerException; use Psr\Container\ContainerInterface; use ReflectionException; @@ -14,7 +15,7 @@ use Throwable; use TypeError; -class RegisteredElement +class RegisteredElement implements JsonSerializable { public function __construct( public readonly string $handlerClass, @@ -95,16 +96,35 @@ private function castArgumentType(mixed $argument, ReflectionParameter $paramete $typeName = $type->getName(); - if (enum_exists($typeName) && is_subclass_of($typeName, \BackedEnum::class)) { - try { - return $typeName::from($argument); - } catch (\ValueError $e) { - $valueStr = is_scalar($argument) ? strval($argument) : gettype($argument); - throw new InvalidArgumentException( - "Invalid value '{$valueStr}' for enum {$typeName}.", - 0, - $e - ); + if (enum_exists($typeName)) { + if (is_object($argument) && $argument instanceof $typeName) { + return $argument; + } + + if (is_subclass_of($typeName, \BackedEnum::class)) { + $value = $typeName::tryFrom($argument); + if ($value === null) { + throw new InvalidArgumentException( + "Invalid value '{$argument}' for backed enum {$typeName}. Expected one of its backing values.", + ); + } + return $value; + } else { + if (is_string($argument)) { + foreach ($typeName::cases() as $case) { + if ($case->name === $argument) { + return $case; + } + } + $validNames = array_map(fn($c) => $c->name, $typeName::cases()); + throw new InvalidArgumentException( + "Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: " . implode(', ', $validNames) . "." + ); + } else { + throw new InvalidArgumentException( + "Invalid value type '{$argument}' for unit enum {$typeName}. Expected a string matching a case name." + ); + } } } @@ -179,4 +199,18 @@ private function castToArray(mixed $argument): array } throw new InvalidArgumentException('Cannot cast value to array. Expected array.'); } + + public function toArray(): array + { + return [ + 'handlerClass' => $this->handlerClass, + 'handlerMethod' => $this->handlerMethod, + 'isManual' => $this->isManual, + ]; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } } From f9ccb5963a85824b02cbc26d52d4737efa2d9c6b Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 18 Jun 2025 22:10:08 +0100 Subject: [PATCH 16/27] feat: use PSR20 ClockInterface for session handling - Introduced a new `SystemClock` class implementing `ClockInterface` for time management. - Updated `ArraySessionHandler` and `CacheSessionHandler` to utilize the `SystemClock` for timestamping, enhancing testability and flexibility. - Added a `FixedClock` mock for testing purposes, allowing controlled time manipulation in tests. --- composer.json | 1 + src/Defaults/SystemClock.php | 16 ++++++ src/Session/ArraySessionHandler.php | 17 +++++-- src/Session/CacheSessionHandler.php | 25 +++++++-- tests/Mocks/Clock/FixedClock.php | 78 +++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 src/Defaults/SystemClock.php create mode 100644 tests/Mocks/Clock/FixedClock.php diff --git a/composer.json b/composer.json index b2140c7..01f20bb 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "opis/json-schema": "^2.4", "php-mcp/schema": "dev-main", "phpdocumentor/reflection-docblock": "^5.6", + "psr/clock": "^1.0", "psr/container": "^1.0 || ^2.0", "psr/event-dispatcher": "^1.0", "psr/log": "^1.0 || ^2.0 || ^3.0", diff --git a/src/Defaults/SystemClock.php b/src/Defaults/SystemClock.php new file mode 100644 index 0000000..a18c4ce --- /dev/null +++ b/src/Defaults/SystemClock.php @@ -0,0 +1,16 @@ +clock = $clock ?? new SystemClock(); } public function read(string $sessionId): string|false @@ -24,7 +31,7 @@ public function read(string $sessionId): string|false return false; } - $currentTimestamp = time(); + $currentTimestamp = $this->clock->now()->getTimestamp(); if ($currentTimestamp - $session['timestamp'] > $this->ttl) { unset($this->store[$sessionId]); @@ -38,7 +45,7 @@ public function write(string $sessionId, string $data): bool { $this->store[$sessionId] = [ 'data' => $data, - 'timestamp' => time(), + 'timestamp' => $this->clock->now()->getTimestamp(), ]; return true; @@ -55,7 +62,7 @@ public function destroy(string $sessionId): bool public function gc(int $maxLifetime): array { - $currentTimestamp = time(); + $currentTimestamp = $this->clock->now()->getTimestamp(); $deletedSessions = []; foreach ($this->store as $sessionId => $session) { diff --git a/src/Session/CacheSessionHandler.php b/src/Session/CacheSessionHandler.php index 1925e6f..88f54ab 100644 --- a/src/Session/CacheSessionHandler.php +++ b/src/Session/CacheSessionHandler.php @@ -5,30 +5,45 @@ namespace PhpMcp\Server\Session; use PhpMcp\Server\Contracts\SessionHandlerInterface; +use PhpMcp\Server\Defaults\SystemClock; use Psr\SimpleCache\CacheInterface; +use Psr\Clock\ClockInterface; class CacheSessionHandler implements SessionHandlerInterface { private const SESSION_INDEX_KEY = 'mcp_session_index'; private array $sessionIndex = []; + private ClockInterface $clock; public function __construct( public readonly CacheInterface $cache, - public readonly int $ttl = 3600 + public readonly int $ttl = 3600, + ?ClockInterface $clock = null ) { $this->sessionIndex = $this->cache->get(self::SESSION_INDEX_KEY, []); + $this->clock = $clock ?? new SystemClock(); } public function read(string $sessionId): string|false { - return $this->cache->get($sessionId, false); + $session = $this->cache->get($sessionId, false); + if ($session === false) { + return false; + } + + if ($this->clock->now()->getTimestamp() - $this->sessionIndex[$sessionId] > $this->ttl) { + $this->cache->delete($sessionId); + return false; + } + + return $session; } public function write(string $sessionId, string $data): bool { - $this->sessionIndex[$sessionId] = time(); + $this->sessionIndex[$sessionId] = $this->clock->now()->getTimestamp(); $this->cache->set(self::SESSION_INDEX_KEY, $this->sessionIndex); - return $this->cache->set($sessionId, $data, $this->ttl); + return $this->cache->set($sessionId, $data); } public function destroy(string $sessionId): bool @@ -40,7 +55,7 @@ public function destroy(string $sessionId): bool public function gc(int $maxLifetime): array { - $currentTime = time(); + $currentTime = $this->clock->now()->getTimestamp(); $deletedSessions = []; foreach ($this->sessionIndex as $sessionId => $timestamp) { diff --git a/tests/Mocks/Clock/FixedClock.php b/tests/Mocks/Clock/FixedClock.php new file mode 100644 index 0000000..24b4271 --- /dev/null +++ b/tests/Mocks/Clock/FixedClock.php @@ -0,0 +1,78 @@ +currentTime = $initialTime; + } else { + $this->currentTime = new DateTimeImmutable($initialTime, $timezone); + } + } + + public function now(): DateTimeImmutable + { + return $this->currentTime; + } + + public function setCurrentTime(string|DateTimeImmutable $newTime, ?DateTimeZone $timezone = null): void + { + if ($newTime instanceof DateTimeImmutable) { + $this->currentTime = $newTime; + } else { + $this->currentTime = new DateTimeImmutable($newTime, $timezone); + } + } + + public function advance(DateInterval $interval): void + { + $this->currentTime = $this->currentTime->add($interval); + } + + public function rewind(DateInterval $interval): void + { + $this->currentTime = $this->currentTime->sub($interval); + } + + public function addSecond(): void + { + $this->advance(new DateInterval("PT1S")); + } + + public function addSeconds(int $seconds): void + { + $this->advance(new DateInterval("PT{$seconds}S")); + } + + public function addMinutes(int $minutes): void + { + $this->advance(new DateInterval("PT{$minutes}M")); + } + + public function addHours(int $hours): void + { + $this->advance(new DateInterval("PT{$hours}H")); + } + + public function subSeconds(int $seconds): void + { + $this->rewind(new DateInterval("PT{$seconds}S")); + } + + public function subMinutes(int $minutes): void + { + $this->rewind(new DateInterval("PT{$minutes}M")); + } +} From 348028b51253e4c7b85358be6655495bc06d9647 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 18 Jun 2025 22:29:54 +0100 Subject: [PATCH 17/27] feat(tests): Comprehensive test suite refactor and new integration tests - Complete refactor of unit tests for all `src/` components. - New robust integration tests for the transports - Addressed bugs discovered when adding integration tests --- composer.json | 2 +- src/Contracts/IdGeneratorInterface.php | 10 - src/Dispatcher.php | 2 +- src/Elements/RegisteredElement.php | 1 + src/Elements/RegisteredPrompt.php | 311 ++++++--- src/Elements/RegisteredResource.php | 115 +++- src/Elements/RegisteredResourceTemplate.php | 115 +++- src/Elements/RegisteredTool.php | 75 ++- src/Protocol.php | 26 +- src/Registry.php | 59 +- src/Server.php | 24 +- src/ServerBuilder.php | 18 +- src/Session/SessionManager.php | 12 +- src/Transports/HttpServerTransport.php | 13 +- .../StreamableHttpServerTransport.php | 65 +- src/Utils/Discoverer.php | 8 +- src/Utils/DocBlockParser.php | 9 +- src/Utils/RandomIdGenerator.php | 15 - src/Utils/SchemaGenerator.php | 60 +- .../Discovery/DiscoverablePromptHandler.php | 38 ++ .../Discovery/DiscoverableResourceHandler.php | 40 ++ .../Discovery/DiscoverableTemplateHandler.php | 42 ++ .../Discovery/DiscoverableToolHandler.php | 51 ++ .../Discovery/InvocablePromptFixture.php | 20 + .../Discovery/InvocableResourceFixture.php | 16 + .../InvocableResourceTemplateFixture.php | 20 + .../Discovery/InvocableToolFixture.php | 25 + .../Discovery/NonDiscoverableClass.php | 25 + .../Fixtures/Discovery/SubDir/HiddenTool.php | 13 + .../Enums}/BackedIntEnum.php | 2 +- .../Enums}/BackedStringEnum.php | 2 +- .../Enums}/UnitEnum.php | 2 +- .../General/CompletionProviderFixture.php | 21 + .../General/DocBlockTestFixture.php} | 30 +- .../General/InvokableHandlerFixture.php | 20 + .../Fixtures/General/PromptHandlerFixture.php | 154 +++++ .../General/ResourceHandlerFixture.php | 152 +++++ tests/Fixtures/General/ToolHandlerFixture.php | 134 ++++ .../Fixtures/General/VariousTypesHandler.php | 143 ++++ .../Schema/SchemaGenerationTarget.php | 126 ++++ .../Fixtures/ServerScripts/HttpTestServer.php | 46 ++ .../ServerScripts/StdioTestServer.php | 44 ++ .../StreamableHttpTestServer.php | 61 ++ .../Utils/AttributeFixtures.php} | 27 +- .../Utils/DockBlockParserFixture.php} | 16 +- .../Utils/SchemaAttributeFixture.php} | 22 +- .../Utils/SchemaGeneratorFixture.php} | 58 +- tests/Integration/DiscoveryTest.php | 150 +++++ tests/Integration/HttpServerTransportTest.php | 421 ++++++++++++ .../Integration/StdioServerTransportTest.php | 326 +++++++++ .../StreamableHttpServerTransportTest.php | 572 ++++++++++++++++ tests/Mocks/Clients/MockJsonHttpClient.php | 114 ++++ tests/Mocks/Clients/MockSseClient.php | 237 +++++++ tests/Mocks/Clients/MockStreamHttpClient.php | 252 +++++++ tests/Mocks/DiscoveryStubs/AbstractStub.php | 11 - .../Mocks/DiscoveryStubs/AllElementsStub.php | 39 -- .../Mocks/DiscoveryStubs/ChildInheriting.php | 8 - .../Mocks/DiscoveryStubs/ClassUsingTrait.php | 8 - .../Mocks/DiscoveryStubs/ConstructorStub.php | 13 - tests/Mocks/DiscoveryStubs/EnumStub.php | 15 - tests/Mocks/DiscoveryStubs/InterfaceStub.php | 12 - .../DiscoveryStubs/InvokablePromptStub.php | 25 - .../DiscoveryStubs/InvokableResourceStub.php | 22 - .../DiscoveryStubs/InvokableTemplateStub.php | 23 - .../DiscoveryStubs/InvokableToolStub.php | 23 - .../DiscoveryStubs/MixedValidityStub.php | 25 - tests/Mocks/DiscoveryStubs/ParentWithTool.php | 13 - tests/Mocks/DiscoveryStubs/PlainPhpClass.php | 10 - .../DiscoveryStubs/PrivateMethodStub.php | 13 - .../DiscoveryStubs/ProtectedMethodStub.php | 13 - .../Mocks/DiscoveryStubs/ResourceOnlyStub.php | 14 - .../Mocks/DiscoveryStubs/StaticMethodStub.php | 13 - tests/Mocks/DiscoveryStubs/ToolOnlyStub.php | 17 - tests/Mocks/DiscoveryStubs/ToolTrait.php | 13 - tests/Mocks/DiscoveryStubs/TraitStub.php | 13 - .../ManualRegistrationStubs/HandlerStub.php | 29 - .../InvokableHandlerStub.php | 14 - tests/Pest.php | 105 +-- tests/Unit/ConfigurationTest.php | 32 +- tests/Unit/DispatcherTest.php | 480 +++++++++++++ tests/Unit/Elements/RegisteredElementTest.php | 234 +++++++ tests/Unit/Elements/RegisteredPromptTest.php | 237 +++++++ .../RegisteredResourceTemplateTest.php | 222 +++++++ .../Unit/Elements/RegisteredResourceTest.php | 230 +++++++ tests/Unit/Elements/RegisteredToolTest.php | 180 +++++ tests/Unit/ProtocolTest.php | 589 +++++++++++----- tests/Unit/RegistryTest.php | 628 ++++++++++-------- tests/Unit/ServerBuilderTest.php | 408 +++++++----- tests/Unit/ServerTest.php | 301 +++++---- .../Unit/Session/ArraySessionHandlerTest.php | 216 ++++++ .../Unit/Session/CacheSessionHandlerTest.php | 245 +++++++ tests/Unit/Session/SessionManagerTest.php | 210 ++++++ tests/Unit/Session/SessionTest.php | 237 +++++++ tests/Unit/State/ClientStateManagerTest.php | 437 ------------ tests/Unit/State/ClientStateTest.php | 133 ---- tests/Unit/Support/ArgumentPreparerTest.php | 210 ------ tests/Unit/Support/AttributeFinderTest.php | 166 ----- tests/Unit/Support/DiscovererTest.php | 215 ------ tests/Unit/Support/RequestProcessorTest.php | 263 -------- tests/Unit/Support/UriTemplateMatcherTest.php | 122 ---- tests/Unit/Traits/ResponseFormatterTest.php | 326 --------- .../Transports/HttpServerTransportTest.php | 418 ------------ .../Transports/StdioServerTransportTest.php | 254 ------- .../{Support => Utils}/DocBlockParserTest.php | 62 +- .../HandlerResolverTest.php | 50 +- .../SchemaGeneratorTest.php | 56 +- .../SchemaValidatorTest.php | 4 +- 107 files changed, 7742 insertions(+), 4276 deletions(-) delete mode 100644 src/Contracts/IdGeneratorInterface.php delete mode 100644 src/Utils/RandomIdGenerator.php create mode 100644 tests/Fixtures/Discovery/DiscoverablePromptHandler.php create mode 100644 tests/Fixtures/Discovery/DiscoverableResourceHandler.php create mode 100644 tests/Fixtures/Discovery/DiscoverableTemplateHandler.php create mode 100644 tests/Fixtures/Discovery/DiscoverableToolHandler.php create mode 100644 tests/Fixtures/Discovery/InvocablePromptFixture.php create mode 100644 tests/Fixtures/Discovery/InvocableResourceFixture.php create mode 100644 tests/Fixtures/Discovery/InvocableResourceTemplateFixture.php create mode 100644 tests/Fixtures/Discovery/InvocableToolFixture.php create mode 100644 tests/Fixtures/Discovery/NonDiscoverableClass.php create mode 100644 tests/Fixtures/Discovery/SubDir/HiddenTool.php rename tests/{Mocks/SupportStubs => Fixtures/Enums}/BackedIntEnum.php (60%) rename tests/{Mocks/SupportStubs => Fixtures/Enums}/BackedStringEnum.php (64%) rename tests/{Mocks/SupportStubs => Fixtures/Enums}/UnitEnum.php (51%) create mode 100644 tests/Fixtures/General/CompletionProviderFixture.php rename tests/{Mocks/SupportStubs/DocBlockTestStub.php => Fixtures/General/DocBlockTestFixture.php} (74%) create mode 100644 tests/Fixtures/General/InvokableHandlerFixture.php create mode 100644 tests/Fixtures/General/PromptHandlerFixture.php create mode 100644 tests/Fixtures/General/ResourceHandlerFixture.php create mode 100644 tests/Fixtures/General/ToolHandlerFixture.php create mode 100644 tests/Fixtures/General/VariousTypesHandler.php create mode 100644 tests/Fixtures/Schema/SchemaGenerationTarget.php create mode 100755 tests/Fixtures/ServerScripts/HttpTestServer.php create mode 100755 tests/Fixtures/ServerScripts/StdioTestServer.php create mode 100755 tests/Fixtures/ServerScripts/StreamableHttpTestServer.php rename tests/{Mocks/SupportStubs/AttributeTestStub.php => Fixtures/Utils/AttributeFixtures.php} (73%) rename tests/{Mocks/SupportStubs/DocBlockArrayTestStub.php => Fixtures/Utils/DockBlockParserFixture.php} (94%) rename tests/{Mocks/SupportStubs/SchemaAttributeTestStub.php => Fixtures/Utils/SchemaAttributeFixture.php} (95%) rename tests/{Mocks/SupportStubs/SchemaGeneratorTestStub.php => Fixtures/Utils/SchemaGeneratorFixture.php} (85%) create mode 100644 tests/Integration/DiscoveryTest.php create mode 100644 tests/Integration/HttpServerTransportTest.php create mode 100644 tests/Integration/StdioServerTransportTest.php create mode 100644 tests/Integration/StreamableHttpServerTransportTest.php create mode 100644 tests/Mocks/Clients/MockJsonHttpClient.php create mode 100644 tests/Mocks/Clients/MockSseClient.php create mode 100644 tests/Mocks/Clients/MockStreamHttpClient.php delete mode 100644 tests/Mocks/DiscoveryStubs/AbstractStub.php delete mode 100644 tests/Mocks/DiscoveryStubs/AllElementsStub.php delete mode 100644 tests/Mocks/DiscoveryStubs/ChildInheriting.php delete mode 100644 tests/Mocks/DiscoveryStubs/ClassUsingTrait.php delete mode 100644 tests/Mocks/DiscoveryStubs/ConstructorStub.php delete mode 100644 tests/Mocks/DiscoveryStubs/EnumStub.php delete mode 100644 tests/Mocks/DiscoveryStubs/InterfaceStub.php delete mode 100644 tests/Mocks/DiscoveryStubs/InvokablePromptStub.php delete mode 100644 tests/Mocks/DiscoveryStubs/InvokableResourceStub.php delete mode 100644 tests/Mocks/DiscoveryStubs/InvokableTemplateStub.php delete mode 100644 tests/Mocks/DiscoveryStubs/InvokableToolStub.php delete mode 100644 tests/Mocks/DiscoveryStubs/MixedValidityStub.php delete mode 100644 tests/Mocks/DiscoveryStubs/ParentWithTool.php delete mode 100644 tests/Mocks/DiscoveryStubs/PlainPhpClass.php delete mode 100644 tests/Mocks/DiscoveryStubs/PrivateMethodStub.php delete mode 100644 tests/Mocks/DiscoveryStubs/ProtectedMethodStub.php delete mode 100644 tests/Mocks/DiscoveryStubs/ResourceOnlyStub.php delete mode 100644 tests/Mocks/DiscoveryStubs/StaticMethodStub.php delete mode 100644 tests/Mocks/DiscoveryStubs/ToolOnlyStub.php delete mode 100644 tests/Mocks/DiscoveryStubs/ToolTrait.php delete mode 100644 tests/Mocks/DiscoveryStubs/TraitStub.php delete mode 100644 tests/Mocks/ManualRegistrationStubs/HandlerStub.php delete mode 100644 tests/Mocks/ManualRegistrationStubs/InvokableHandlerStub.php create mode 100644 tests/Unit/DispatcherTest.php create mode 100644 tests/Unit/Elements/RegisteredElementTest.php create mode 100644 tests/Unit/Elements/RegisteredPromptTest.php create mode 100644 tests/Unit/Elements/RegisteredResourceTemplateTest.php create mode 100644 tests/Unit/Elements/RegisteredResourceTest.php create mode 100644 tests/Unit/Elements/RegisteredToolTest.php create mode 100644 tests/Unit/Session/ArraySessionHandlerTest.php create mode 100644 tests/Unit/Session/CacheSessionHandlerTest.php create mode 100644 tests/Unit/Session/SessionManagerTest.php create mode 100644 tests/Unit/Session/SessionTest.php delete mode 100644 tests/Unit/State/ClientStateManagerTest.php delete mode 100644 tests/Unit/State/ClientStateTest.php delete mode 100644 tests/Unit/Support/ArgumentPreparerTest.php delete mode 100644 tests/Unit/Support/AttributeFinderTest.php delete mode 100644 tests/Unit/Support/DiscovererTest.php delete mode 100644 tests/Unit/Support/RequestProcessorTest.php delete mode 100644 tests/Unit/Support/UriTemplateMatcherTest.php delete mode 100644 tests/Unit/Traits/ResponseFormatterTest.php delete mode 100644 tests/Unit/Transports/HttpServerTransportTest.php delete mode 100644 tests/Unit/Transports/StdioServerTransportTest.php rename tests/Unit/{Support => Utils}/DocBlockParserTest.php (75%) rename tests/Unit/{Support => Utils}/HandlerResolverTest.php (75%) rename tests/Unit/{Support => Utils}/SchemaGeneratorTest.php (91%) rename tests/Unit/{Support => Utils}/SchemaValidatorTest.php (99%) diff --git a/composer.json b/composer.json index 01f20bb..da66705 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,6 @@ "phpdocumentor/reflection-docblock": "^5.6", "psr/clock": "^1.0", "psr/container": "^1.0 || ^2.0", - "psr/event-dispatcher": "^1.0", "psr/log": "^1.0 || ^2.0 || ^3.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", "react/event-loop": "^1.5", @@ -31,6 +30,7 @@ "mockery/mockery": "^1.6", "pestphp/pest": "^2.36.0|^3.5.0", "react/async": "^4.0", + "react/child-process": "^0.6.6", "symfony/var-dumper": "^6.4.11|^7.1.5" }, "suggest": { diff --git a/src/Contracts/IdGeneratorInterface.php b/src/Contracts/IdGeneratorInterface.php deleted file mode 100644 index 90f0431..0000000 --- a/src/Contracts/IdGeneratorInterface.php +++ /dev/null @@ -1,10 +0,0 @@ -getParameters() as $parameter) { + // TODO: Handle variadic parameters. $paramName = $parameter->getName(); $paramPosition = $parameter->getPosition(); diff --git a/src/Elements/RegisteredPrompt.php b/src/Elements/RegisteredPrompt.php index 7202c38..b9bc8e9 100644 --- a/src/Elements/RegisteredPrompt.php +++ b/src/Elements/RegisteredPrompt.php @@ -15,6 +15,7 @@ use PhpMcp\Schema\Content\TextResourceContents; use PhpMcp\Schema\Enum\Role; use Psr\Container\ContainerInterface; +use Throwable; class RegisteredPrompt extends RegisteredElement { @@ -35,6 +36,10 @@ public static function make(Prompt $schema, string $handlerClass, string $handle /** * Gets the prompt messages. + * + * @param ContainerInterface $container + * @param array $arguments + * @return PromptMessage[] */ public function get(ContainerInterface $container, array $arguments): array { @@ -52,149 +57,237 @@ public function getCompletionProvider(string $argumentName): ?string * Formats the raw result of a prompt generator into an array of MCP PromptMessages. * * @param mixed $promptGenerationResult Expected: array of message structures. - * @return array Array of PromptMessage objects. + * @return PromptMessage[] Array of PromptMessage objects. * * @throws \RuntimeException If the result cannot be formatted. * @throws \JsonException If JSON encoding fails. */ protected function formatResult(mixed $promptGenerationResult): array { - if ( - is_array($promptGenerationResult) && ! empty($promptGenerationResult) - && $promptGenerationResult[array_key_first($promptGenerationResult)] instanceof PromptMessage - ) { - return $promptGenerationResult; + if ($promptGenerationResult instanceof PromptMessage) { + return [$promptGenerationResult]; + } + + if (! is_array($promptGenerationResult)) { + throw new \RuntimeException('Prompt generator method must return an array of messages.'); } - if ( - is_array($promptGenerationResult) && ! array_is_list($promptGenerationResult) - && (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant'])) - ) { + if (empty($promptGenerationResult)) { + return []; + } + + if (is_array($promptGenerationResult)) { + $allArePromptMessages = true; + $hasPromptMessages = false; - $result = []; - if (isset($promptGenerationResult['user'])) { - $result[] = PromptMessage::make(Role::User, $promptGenerationResult['user']); + foreach ($promptGenerationResult as $item) { + if ($item instanceof PromptMessage) { + $hasPromptMessages = true; + } else { + $allArePromptMessages = false; + } } - if (isset($promptGenerationResult['assistant'])) { - $result[] = PromptMessage::make(Role::Assistant, $promptGenerationResult['assistant']); + + if ($allArePromptMessages && $hasPromptMessages) { + return $promptGenerationResult; } - if (! empty($result)) { + if ($hasPromptMessages) { + $result = []; + foreach ($promptGenerationResult as $index => $item) { + if ($item instanceof PromptMessage) { + $result[] = $item; + } else { + $result = array_merge($result, $this->formatResult($item)); + } + } return $result; } + + if (! array_is_list($promptGenerationResult)) { + if (isset($promptGenerationResult['user']) || isset($promptGenerationResult['assistant'])) { + $result = []; + if (isset($promptGenerationResult['user'])) { + $userContent = $this->formatContent($promptGenerationResult['user']); + $result[] = PromptMessage::make(Role::User, $userContent); + } + if (isset($promptGenerationResult['assistant'])) { + $assistantContent = $this->formatContent($promptGenerationResult['assistant']); + $result[] = PromptMessage::make(Role::Assistant, $assistantContent); + } + return $result; + } + + if (isset($promptGenerationResult['role']) && isset($promptGenerationResult['content'])) { + return [$this->formatMessage($promptGenerationResult)]; + } + + throw new \RuntimeException('Associative array must contain either role/content keys or user/assistant keys.'); + } + + $formattedMessages = []; + foreach ($promptGenerationResult as $index => $message) { + if ($message instanceof PromptMessage) { + $formattedMessages[] = $message; + } else { + $formattedMessages[] = $this->formatMessage($message, $index); + } + } + return $formattedMessages; } - if (! is_array($promptGenerationResult)) { - throw new \RuntimeException('Prompt generator method must return an array of messages.'); + throw new \RuntimeException('Invalid prompt generation result format.'); + } + + /** + * Formats a single message into a PromptMessage. + */ + private function formatMessage(mixed $message, ?int $index = null): PromptMessage + { + $indexStr = $index !== null ? " at index {$index}" : ''; + + if (! is_array($message) || ! array_key_exists('role', $message) || ! array_key_exists('content', $message)) { + throw new \RuntimeException("Invalid message format{$indexStr}. Expected an array with 'role' and 'content' keys."); } - if (! array_is_list($promptGenerationResult)) { - throw new \RuntimeException('Prompt generator method must return a list (sequential array) of messages, not an associative array.'); + $role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']); + if ($role === null) { + throw new \RuntimeException("Invalid role '{$message['role']}' in prompt message{$indexStr}. Only 'user' or 'assistant' are supported."); } - $formattedMessages = []; - foreach ($promptGenerationResult as $index => $message) { - if ($message instanceof PromptMessage) { - $formattedMessages[] = $message; + $content = $this->formatContent($message['content'], $index); - continue; - } + return new PromptMessage($role, $content); + } - if (is_array($message) && isset($message['role']) && isset($message['content'])) { - $role = $message['role'] instanceof Role ? $message['role'] : Role::tryFrom($message['role']); - $content = $message['content']; + /** + * Formats content into a proper Content object. + */ + private function formatContent(mixed $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource + { + $indexStr = $index !== null ? " at index {$index}" : ''; + + if ($content instanceof Content) { + if ( + $content instanceof TextContent || $content instanceof ImageContent || + $content instanceof AudioContent || $content instanceof EmbeddedResource + ) { + return $content; + } + throw new \RuntimeException("Invalid Content type{$indexStr}. PromptMessage only supports TextContent, ImageContent, AudioContent, or EmbeddedResource."); + } - if ($role === null) { - throw new \RuntimeException("Invalid role '{$message['role']}' in prompt message at index {$index}. Only 'user' or 'assistant' are supported."); - } + if (is_string($content)) { + return TextContent::make($content); + } - if ($content instanceof Content) { - $formattedMessages[] = new PromptMessage($role, $content); + if (is_array($content) && isset($content['type'])) { + return $this->formatTypedContent($content, $index); + } - continue; - } + if (is_scalar($content) || $content === null) { + $stringContent = $content === null ? '(null)' : (is_bool($content) ? ($content ? 'true' : 'false') : (string)$content); + return TextContent::make($stringContent); + } - if (is_string($content)) { - $formattedMessages[] = new PromptMessage($role, new TextContent($content)); + $jsonContent = json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + return TextContent::make($jsonContent); + } - continue; - } + /** + * Formats typed content arrays into Content objects. + */ + private function formatTypedContent(array $content, ?int $index = null): TextContent|ImageContent|AudioContent|EmbeddedResource + { + $indexStr = $index !== null ? " at index {$index}" : ''; + $type = $content['type']; + + return match ($type) { + 'text' => $this->formatTextContent($content, $indexStr), + 'image' => $this->formatImageContent($content, $indexStr), + 'audio' => $this->formatAudioContent($content, $indexStr), + 'resource' => $this->formatResourceContent($content, $indexStr), + default => throw new \RuntimeException("Invalid content type '{$type}'{$indexStr}.") + }; + } - if (is_array($content) && isset($content['type'])) { - $type = $content['type']; - if (! in_array($type, ['text', 'image', 'audio', 'resource'])) { - throw new \RuntimeException("Invalid content type '{$type}' in prompt message at index {$index}."); - } + private function formatTextContent(array $content, string $indexStr): TextContent + { + if (! isset($content['text']) || ! is_string($content['text'])) { + throw new \RuntimeException("Invalid 'text' content{$indexStr}: Missing or invalid 'text' string."); + } + return TextContent::make($content['text']); + } - $contentObj = null; - switch ($type) { - case 'text': - if (! isset($content['text']) || ! is_string($content['text'])) { - throw new \RuntimeException("Invalid 'text' content: Missing or invalid 'text' string."); - } - $contentObj = TextContent::make($content['text']); - break; - - case 'image': - if (! isset($content['data']) || ! is_string($content['data'])) { - throw new \RuntimeException("Invalid 'image' content: Missing or invalid 'data' string (base64)."); - } - if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { - throw new \RuntimeException("Invalid 'image' content: Missing or invalid 'mimeType' string."); - } - $contentObj = ImageContent::make($content['data'], $content['mimeType']); - break; - - case 'audio': - if (! isset($content['data']) || ! is_string($content['data'])) { - throw new \RuntimeException("Invalid 'audio' content: Missing or invalid 'data' string (base64)."); - } - if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { - throw new \RuntimeException("Invalid 'audio' content: Missing or invalid 'mimeType' string."); - } - $contentObj = AudioContent::make($content['data'], $content['mimeType']); - break; - - case 'resource': - if (! isset($content['resource']) || ! is_array($content['resource'])) { - throw new \RuntimeException("Invalid 'resource' content: Missing or invalid 'resource' object."); - } - - $resource = $content['resource']; - if (! isset($resource['uri']) || ! is_string($resource['uri'])) { - throw new \RuntimeException("Invalid resource: Missing or invalid 'uri'."); - } - - $resourceObj = null; - if (isset($resource['text']) && is_string($resource['text'])) { - $resourceObj = TextResourceContents::make($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); - } elseif (isset($resource['blob']) && is_string($resource['blob'])) { - $resourceObj = BlobResourceContents::make( - $resource['uri'], - $resource['mimeType'] ?? 'application/octet-stream', - $resource['blob'] - ); - } else { - throw new \RuntimeException("Invalid resource: Must contain 'text' or 'blob'."); - } - - $contentObj = new EmbeddedResource($resourceObj); - break; - } + private function formatImageContent(array $content, string $indexStr): ImageContent + { + if (! isset($content['data']) || ! is_string($content['data'])) { + throw new \RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'data' string (base64)."); + } + if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { + throw new \RuntimeException("Invalid 'image' content{$indexStr}: Missing or invalid 'mimeType' string."); + } + return ImageContent::make($content['data'], $content['mimeType']); + } - if ($contentObj) { - $formattedMessages[] = new PromptMessage($role, $contentObj); + private function formatAudioContent(array $content, string $indexStr): AudioContent + { + if (! isset($content['data']) || ! is_string($content['data'])) { + throw new \RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'data' string (base64)."); + } + if (! isset($content['mimeType']) || ! is_string($content['mimeType'])) { + throw new \RuntimeException("Invalid 'audio' content{$indexStr}: Missing or invalid 'mimeType' string."); + } + return AudioContent::make($content['data'], $content['mimeType']); + } - continue; - } - } + private function formatResourceContent(array $content, string $indexStr): EmbeddedResource + { + if (! isset($content['resource']) || ! is_array($content['resource'])) { + throw new \RuntimeException("Invalid 'resource' content{$indexStr}: Missing or invalid 'resource' object."); + } - throw new \RuntimeException("Invalid content format at index {$index}. Must be a string, Content object, or valid content array."); - } + $resource = $content['resource']; + if (! isset($resource['uri']) || ! is_string($resource['uri'])) { + throw new \RuntimeException("Invalid resource{$indexStr}: Missing or invalid 'uri'."); + } - throw new \RuntimeException("Invalid message format at index {$index}. Expected a PromptMessage or an array with 'role' and 'content' keys."); + if (isset($resource['text']) && is_string($resource['text'])) { + $resourceObj = TextResourceContents::make($resource['uri'], $resource['mimeType'] ?? 'text/plain', $resource['text']); + } elseif (isset($resource['blob']) && is_string($resource['blob'])) { + $resourceObj = BlobResourceContents::make( + $resource['uri'], + $resource['mimeType'] ?? 'application/octet-stream', + $resource['blob'] + ); + } else { + throw new \RuntimeException("Invalid resource{$indexStr}: Must contain 'text' or 'blob'."); } - return $formattedMessages; + return new EmbeddedResource($resourceObj); + } + + public function toArray(): array + { + return [ + 'schema' => $this->schema->toArray(), + 'completionProviders' => $this->completionProviders, + ...parent::toArray(), + ]; + } + + public static function fromArray(array $data): self|false + { + try { + return new self( + Prompt::fromArray($data['schema']), + $data['handlerClass'], + $data['handlerMethod'], + $data['isManual'] ?? false, + $data['completionProviders'] ?? [], + ); + } catch (Throwable $e) { + return false; + } } } diff --git a/src/Elements/RegisteredResource.php b/src/Elements/RegisteredResource.php index eff41f4..9911fac 100644 --- a/src/Elements/RegisteredResource.php +++ b/src/Elements/RegisteredResource.php @@ -10,6 +10,7 @@ use PhpMcp\Schema\Content\TextResourceContents; use PhpMcp\Schema\Resource; use Psr\Container\ContainerInterface; +use Throwable; class RegisteredResource extends RegisteredElement { @@ -44,14 +45,14 @@ public function read(ContainerInterface $container, string $uri): array * * @param mixed $readResult The raw result from the resource handler method. * @param string $uri The URI of the resource that was read. - * @param ?string $defaultMimeType The default MIME type from the ResourceDefinition. + * @param ?string $mimeType The MIME type from the ResourceDefinition. * @return array Array of ResourceContents objects. * * @throws \RuntimeException If the result cannot be formatted. * * Supported result types: - * - EmbeddedResource: Used as-is - * - ResourceContent: Embedded resource is extracted + * - ResourceContent: Used as-is + * - EmbeddedResource: Resource is extracted from the EmbeddedResource * - string: Converted to text content with guessed or provided MIME type * - stream resource: Read and converted to blob with provided MIME type * - array with 'blob' key: Used as blob content @@ -60,7 +61,7 @@ public function read(ContainerInterface $container, string $uri): array * - array: Converted to JSON if MIME type is application/json or contains 'json' * For other MIME types, will try to convert to JSON with a warning */ - protected function formatResult(mixed $readResult, string $uri, ?string $defaultMimeType): array + protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array { if ($readResult instanceof ResourceContents) { return [$readResult]; @@ -70,16 +71,54 @@ protected function formatResult(mixed $readResult, string $uri, ?string $default return [$readResult->resource]; } - if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof ResourceContents) { - return $readResult; - } + if (is_array($readResult)) { + if (empty($readResult)) { + return [TextResourceContents::make($uri, 'application/json', '[]')]; + } - if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof EmbeddedResource) { - return array_map(fn($item) => $item->resource, $readResult); + $allAreResourceContents = true; + $hasResourceContents = false; + $allAreEmbeddedResource = true; + $hasEmbeddedResource = false; + + foreach ($readResult as $item) { + if ($item instanceof ResourceContents) { + $hasResourceContents = true; + $allAreEmbeddedResource = false; + } elseif ($item instanceof EmbeddedResource) { + $hasEmbeddedResource = true; + $allAreResourceContents = false; + } else { + $allAreResourceContents = false; + $allAreEmbeddedResource = false; + } + } + + if ($allAreResourceContents && $hasResourceContents) { + return $readResult; + } + + if ($allAreEmbeddedResource && $hasEmbeddedResource) { + return array_map(fn($item) => $item->resource, $readResult); + } + + if ($hasResourceContents || $hasEmbeddedResource) { + $result = []; + foreach ($readResult as $item) { + if ($item instanceof ResourceContents) { + $result[] = $item; + } elseif ($item instanceof EmbeddedResource) { + $result[] = $item->resource; + } else { + $result = array_merge($result, $this->formatResult($item, $uri, $mimeType)); + } + } + return $result; + } } if (is_string($readResult)) { - $mimeType = $defaultMimeType ?? $this->guessMimeTypeFromString($readResult); + $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); return [TextResourceContents::make($uri, $mimeType, $readResult)]; } @@ -88,7 +127,7 @@ protected function formatResult(mixed $readResult, string $uri, ?string $default $result = BlobResourceContents::fromStream( $uri, $readResult, - $defaultMimeType ?? 'application/octet-stream' + $mimeType ?? 'application/octet-stream' ); @fclose($readResult); @@ -97,28 +136,32 @@ protected function formatResult(mixed $readResult, string $uri, ?string $default } if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) { - $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'application/octet-stream'; + $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])]; } if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) { - $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'text/plain'; + $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; return [TextResourceContents::make($uri, $mimeType, $readResult['text'])]; } if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { - return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $defaultMimeType)]; + if ($mimeType && str_contains(strtolower($mimeType), 'text')) { + return [TextResourceContents::make($uri, $mimeType, file_get_contents($readResult->getPathname()))]; + } + + return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)]; } if (is_array($readResult)) { - if ($defaultMimeType && (str_contains(strtolower($defaultMimeType), 'json') || - $defaultMimeType === 'application/json')) { + if ($mimeType && (str_contains(strtolower($mimeType), 'json') || + $mimeType === 'application/json')) { try { $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); - return [TextResourceContents::make($uri, $defaultMimeType, $jsonString)]; + return [TextResourceContents::make($uri, $mimeType, $jsonString)]; } catch (\JsonException $e) { throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); } @@ -126,7 +169,7 @@ protected function formatResult(mixed $readResult, string $uri, ?string $default try { $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); - $mimeType = 'application/json'; + $mimeType = $mimeType ?? 'application/json'; return [TextResourceContents::make($uri, $mimeType, $jsonString)]; } catch (\JsonException $e) { @@ -141,24 +184,48 @@ protected function formatResult(mixed $readResult, string $uri, ?string $default private function guessMimeTypeFromString(string $content): string { $trimmed = ltrim($content); + if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) { - // Looks like HTML or XML? Prefer text/plain unless sure. - if (stripos($trimmed, ' $this->schema->toArray(), + ...parent::toArray(), + ]; + } + + public static function fromArray(array $data): self|false + { + try { + return new self( + Resource::fromArray($data['schema']), + $data['handlerClass'], + $data['handlerMethod'], + $data['isManual'] ?? false, + ); + } catch (Throwable $e) { + return false; + } } } diff --git a/src/Elements/RegisteredResourceTemplate.php b/src/Elements/RegisteredResourceTemplate.php index fddd6a8..1cebedd 100644 --- a/src/Elements/RegisteredResourceTemplate.php +++ b/src/Elements/RegisteredResourceTemplate.php @@ -10,6 +10,7 @@ use PhpMcp\Schema\Content\TextResourceContents; use PhpMcp\Schema\ResourceTemplate; use Psr\Container\ContainerInterface; +use Throwable; class RegisteredResourceTemplate extends RegisteredElement { @@ -107,8 +108,8 @@ private function compileTemplate(): void * @throws \RuntimeException If the result cannot be formatted. * * Supported result types: - * - EmbeddedResource: Used as-is - * - ResourceContent: Embedded resource is extracted + * - ResourceContent: Used as-is + * - EmbeddedResource: Resource is extracted from the EmbeddedResource * - string: Converted to text content with guessed or provided MIME type * - stream resource: Read and converted to blob with provided MIME type * - array with 'blob' key: Used as blob content @@ -117,7 +118,7 @@ private function compileTemplate(): void * - array: Converted to JSON if MIME type is application/json or contains 'json' * For other MIME types, will try to convert to JSON with a warning */ - protected function formatResult(mixed $readResult, string $uri, ?string $defaultMimeType): array + protected function formatResult(mixed $readResult, string $uri, ?string $mimeType): array { if ($readResult instanceof ResourceContents) { return [$readResult]; @@ -127,16 +128,54 @@ protected function formatResult(mixed $readResult, string $uri, ?string $default return [$readResult->resource]; } - if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof ResourceContents) { - return $readResult; - } + if (is_array($readResult)) { + if (empty($readResult)) { + return [TextResourceContents::make($uri, 'application/json', '[]')]; + } - if (is_array($readResult) && ! empty($readResult) && $readResult[array_key_first($readResult)] instanceof EmbeddedResource) { - return array_map(fn($item) => $item->resource, $readResult); + $allAreResourceContents = true; + $hasResourceContents = false; + $allAreEmbeddedResource = true; + $hasEmbeddedResource = false; + + foreach ($readResult as $item) { + if ($item instanceof ResourceContents) { + $hasResourceContents = true; + $allAreEmbeddedResource = false; + } elseif ($item instanceof EmbeddedResource) { + $hasEmbeddedResource = true; + $allAreResourceContents = false; + } else { + $allAreResourceContents = false; + $allAreEmbeddedResource = false; + } + } + + if ($allAreResourceContents && $hasResourceContents) { + return $readResult; + } + + if ($allAreEmbeddedResource && $hasEmbeddedResource) { + return array_map(fn($item) => $item->resource, $readResult); + } + + if ($hasResourceContents || $hasEmbeddedResource) { + $result = []; + foreach ($readResult as $item) { + if ($item instanceof ResourceContents) { + $result[] = $item; + } elseif ($item instanceof EmbeddedResource) { + $result[] = $item->resource; + } else { + $result = array_merge($result, $this->formatResult($item, $uri, $mimeType)); + } + } + return $result; + } } if (is_string($readResult)) { - $mimeType = $defaultMimeType ?? $this->guessMimeTypeFromString($readResult); + $mimeType = $mimeType ?? $this->guessMimeTypeFromString($readResult); return [TextResourceContents::make($uri, $mimeType, $readResult)]; } @@ -145,7 +184,7 @@ protected function formatResult(mixed $readResult, string $uri, ?string $default $result = BlobResourceContents::fromStream( $uri, $readResult, - $defaultMimeType ?? 'application/octet-stream' + $mimeType ?? 'application/octet-stream' ); @fclose($readResult); @@ -154,28 +193,32 @@ protected function formatResult(mixed $readResult, string $uri, ?string $default } if (is_array($readResult) && isset($readResult['blob']) && is_string($readResult['blob'])) { - $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'application/octet-stream'; + $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'application/octet-stream'; return [BlobResourceContents::make($uri, $mimeType, $readResult['blob'])]; } if (is_array($readResult) && isset($readResult['text']) && is_string($readResult['text'])) { - $mimeType = $readResult['mimeType'] ?? $defaultMimeType ?? 'text/plain'; + $mimeType = $readResult['mimeType'] ?? $mimeType ?? 'text/plain'; return [TextResourceContents::make($uri, $mimeType, $readResult['text'])]; } if ($readResult instanceof \SplFileInfo && $readResult->isFile() && $readResult->isReadable()) { - return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $defaultMimeType)]; + if ($mimeType && str_contains(strtolower($mimeType), 'text')) { + return [TextResourceContents::make($uri, $mimeType, file_get_contents($readResult->getPathname()))]; + } + + return [BlobResourceContents::fromSplFileInfo($uri, $readResult, $mimeType)]; } if (is_array($readResult)) { - if ($defaultMimeType && (str_contains(strtolower($defaultMimeType), 'json') || - $defaultMimeType === 'application/json')) { + if ($mimeType && (str_contains(strtolower($mimeType), 'json') || + $mimeType === 'application/json')) { try { $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); - return [TextResourceContents::make($uri, $defaultMimeType, $jsonString)]; + return [TextResourceContents::make($uri, $mimeType, $jsonString)]; } catch (\JsonException $e) { throw new \RuntimeException("Failed to encode array as JSON for URI '{$uri}': {$e->getMessage()}"); } @@ -183,7 +226,7 @@ protected function formatResult(mixed $readResult, string $uri, ?string $default try { $jsonString = json_encode($readResult, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); - $mimeType = 'application/json'; + $mimeType = $mimeType ?? 'application/json'; return [TextResourceContents::make($uri, $mimeType, $jsonString)]; } catch (\JsonException $e) { @@ -198,24 +241,50 @@ protected function formatResult(mixed $readResult, string $uri, ?string $default private function guessMimeTypeFromString(string $content): string { $trimmed = ltrim($content); + if (str_starts_with($trimmed, '<') && str_ends_with(rtrim($content), '>')) { - // Looks like HTML or XML? Prefer text/plain unless sure. - if (stripos($trimmed, ' $this->schema->toArray(), + 'completionProviders' => $this->completionProviders, + ...parent::toArray(), + ]; + } + + public static function fromArray(array $data): self|false + { + try { + return new self( + ResourceTemplate::fromArray($data['schema']), + $data['handlerClass'], + $data['handlerMethod'], + $data['isManual'] ?? false, + $data['completionProviders'] ?? [], + ); + } catch (Throwable $e) { + return false; + } } } diff --git a/src/Elements/RegisteredTool.php b/src/Elements/RegisteredTool.php index 41724b9..d16e99b 100644 --- a/src/Elements/RegisteredTool.php +++ b/src/Elements/RegisteredTool.php @@ -8,6 +8,7 @@ use PhpMcp\Schema\Content\TextContent; use Psr\Container\ContainerInterface; use PhpMcp\Schema\Tool; +use Throwable; class RegisteredTool extends RegisteredElement { @@ -38,23 +39,61 @@ public function call(ContainerInterface $container, array $arguments): array } /** - * Formats the result of a successful tool execution into the MCP CallToolResult structure. + * Formats the result of a tool execution into an array of MCP Content items. + * + * - If the result is already a Content object, it's wrapped in an array. + * - If the result is an array: + * - If all elements are Content objects, the array is returned as is. + * - If it's a mixed array (Content and non-Content items), non-Content items are + * individually formatted (scalars to TextContent, others to JSON TextContent). + * - If it's an array with no Content items, the entire array is JSON-encoded into a single TextContent. + * - Scalars (string, int, float, bool) are wrapped in TextContent. + * - null is represented as TextContent('(null)'). + * - Other objects are JSON-encoded and wrapped in TextContent. * * @param mixed $toolExecutionResult The raw value returned by the tool's PHP method. * @return Content[] The content items for CallToolResult. - * - * @throws JsonException if JSON encoding fails + * @throws JsonException if JSON encoding fails for non-Content array/object results. */ protected function formatResult(mixed $toolExecutionResult): array { - if (is_array($toolExecutionResult) && ! empty($toolExecutionResult) && $toolExecutionResult[array_key_first($toolExecutionResult)] instanceof Content) { - return $toolExecutionResult; - } - if ($toolExecutionResult instanceof Content) { return [$toolExecutionResult]; } + if (is_array($toolExecutionResult)) { + if (empty($toolExecutionResult)) { + return [TextContent::make('[]')]; + } + + $allAreContent = true; + $hasContent = false; + + foreach ($toolExecutionResult as $item) { + if ($item instanceof Content) { + $hasContent = true; + } else { + $allAreContent = false; + } + } + + if ($allAreContent && $hasContent) { + return $toolExecutionResult; + } + + if ($hasContent) { + $result = []; + foreach ($toolExecutionResult as $item) { + if ($item instanceof Content) { + $result[] = $item; + } else { + $result = array_merge($result, $this->formatResult($item)); + } + } + return $result; + } + } + if ($toolExecutionResult === null) { return [TextContent::make('(null)')]; } @@ -74,4 +113,26 @@ protected function formatResult(mixed $toolExecutionResult): array return [TextContent::make($jsonResult)]; } + + public function toArray(): array + { + return [ + 'schema' => $this->schema->toArray(), + ...parent::toArray(), + ]; + } + + public static function fromArray(array $data): self|false + { + try { + return new self( + Tool::fromArray($data['schema']), + $data['handlerClass'], + $data['handlerMethod'], + $data['isManual'] ?? false, + ); + } catch (Throwable $e) { + return false; + } + } } diff --git a/src/Protocol.php b/src/Protocol.php index d186423..f9d5685 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -26,6 +26,7 @@ use Throwable; use function React\Promise\reject; +use function React\Promise\resolve; /** * Bridges the core MCP Processor logic with a ServerTransportInterface @@ -231,7 +232,7 @@ private function processNotification(Notification $notification, SessionInterfac /** * Send a notification to a session */ - public function sendNotification(string $sessionId, Notification $notification): PromiseInterface + public function sendNotification(Notification $notification, string $sessionId): PromiseInterface { if ($this->transport === null) { $this->logger->error('Cannot send notification, transport not bound', [ @@ -241,16 +242,13 @@ public function sendNotification(string $sessionId, Notification $notification): return reject(new McpServerException('Transport not bound')); } - try { - return $this->transport->sendMessage($notification, $sessionId, []); - } catch (Throwable $e) { - $this->logger->error('Failed to send notification', [ - 'sessionId' => $sessionId, - 'method' => $notification->method, - 'error' => $e->getMessage() - ]); - return reject(new McpServerException('Failed to send notification: ' . $e->getMessage())); - } + return $this->transport->sendMessage($notification, $sessionId, []) + ->then(function () { + return resolve(null); + }) + ->catch(function (Throwable $e) { + return reject(new McpServerException('Failed to send notification: ' . $e->getMessage(), previous: $e)); + }); } /** @@ -267,7 +265,7 @@ public function notifyResourceUpdated(string $uri): void $notification = ResourceUpdatedNotification::make($uri); foreach ($subscribers as $sessionId) { - $this->sendNotification($sessionId, $notification); + $this->sendNotification($notification, $sessionId); } $this->logger->debug("Sent resource change notification", [ @@ -422,7 +420,7 @@ public function handleClientDisconnected(string $sessionId, ?string $reason = nu /** * Handle list changed event from registry */ - private function handleListChanged(string $listType): void + public function handleListChanged(string $listType): void { $listChangeUri = "mcp://changes/{$listType}"; @@ -444,7 +442,7 @@ private function handleListChanged(string $listType): void } foreach ($subscribers as $sessionId) { - $this->sendNotification($sessionId, $notification); + $this->sendNotification($notification, $sessionId); } $this->logger->debug("Sent list change notification", [ diff --git a/src/Registry.php b/src/Registry.php index 7034959..35d014a 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -10,7 +10,6 @@ use PhpMcp\Schema\Resource; use PhpMcp\Schema\ResourceTemplate; use PhpMcp\Schema\Tool; -use PhpMcp\Server\Contracts\CompletionProviderInterface; use PhpMcp\Server\Elements\RegisteredPrompt; use PhpMcp\Server\Elements\RegisteredResource; use PhpMcp\Server\Elements\RegisteredResourceTemplate; @@ -99,8 +98,12 @@ public function load(): void $loadCount = 0; foreach ($cached['tools'] ?? [] as $toolData) { - /** @var RegisteredTool $cachedTool */ - $cachedTool = unserialize($toolData); + $cachedTool = RegisteredTool::fromArray(json_decode($toolData, true)); + if ($cachedTool === false) { + $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); + continue; + } + $toolName = $cachedTool->schema->name; $existingTool = $this->tools[$toolName] ?? null; @@ -114,8 +117,12 @@ public function load(): void } foreach ($cached['resources'] ?? [] as $resourceData) { - /** @var RegisteredResource $cachedResource */ - $cachedResource = unserialize($resourceData); + $cachedResource = RegisteredResource::fromArray(json_decode($resourceData, true)); + if ($cachedResource === false) { + $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); + continue; + } + $uri = $cachedResource->schema->uri; $existingResource = $this->resources[$uri] ?? null; @@ -129,8 +136,12 @@ public function load(): void } foreach ($cached['prompts'] ?? [] as $promptData) { - /** @var RegisteredPrompt $cachedPrompt */ - $cachedPrompt = unserialize($promptData); + $cachedPrompt = RegisteredPrompt::fromArray(json_decode($promptData, true)); + if ($cachedPrompt === false) { + $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); + continue; + } + $promptName = $cachedPrompt->schema->name; $existingPrompt = $this->prompts[$promptName] ?? null; @@ -144,8 +155,12 @@ public function load(): void } foreach ($cached['resourceTemplates'] ?? [] as $templateData) { - /** @var RegisteredResourceTemplate $cachedTemplate */ - $cachedTemplate = unserialize($templateData); + $cachedTemplate = RegisteredResourceTemplate::fromArray(json_decode($templateData, true)); + if ($cachedTemplate === false) { + $this->logger->warning('Invalid or missing data found in registry cache, ignoring.', ['key' => self::DISCOVERED_ELEMENTS_CACHE_KEY, 'type' => gettype($cached)]); + continue; + } + $uriTemplate = $cachedTemplate->schema->uriTemplate; $existingTemplate = $this->resourceTemplates[$uriTemplate] ?? null; @@ -174,7 +189,7 @@ public function registerTool(Tool $tool, string $handlerClass, string $handlerMe $existing = $this->tools[$toolName] ?? null; if ($existing && ! $isManual && $existing->isManual) { - $this->logger->debug("MCP Registry: Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one."); + $this->logger->debug("Ignoring discovered tool '{$toolName}' as it conflicts with a manually registered one."); return; } @@ -204,14 +219,14 @@ public function registerResourceTemplate( ResourceTemplate $template, string $handlerClass, string $handlerMethod, + array $completionProviders = [], bool $isManual = false, - array $completionProviders = [] ): void { $uriTemplate = $template->uriTemplate; $existing = $this->resourceTemplates[$uriTemplate] ?? null; if ($existing && ! $isManual && $existing->isManual) { - $this->logger->debug("MCP Registry: Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one."); + $this->logger->debug("Ignoring discovered template '{$uriTemplate}' as it conflicts with a manually registered one."); return; } @@ -225,14 +240,14 @@ public function registerPrompt( Prompt $prompt, string $handlerClass, string $handlerMethod, + array $completionProviders = [], bool $isManual = false, - array $completionProviders = [] ): void { $promptName = $prompt->name; $existing = $this->prompts[$promptName] ?? null; if ($existing && ! $isManual && $existing->isManual) { - $this->logger->debug("MCP Registry: Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one."); + $this->logger->debug("Ignoring discovered prompt '{$promptName}' as it conflicts with a manually registered one."); return; } @@ -284,25 +299,25 @@ public function save(): bool foreach ($this->tools as $name => $tool) { if (! $tool->isManual) { - $discoveredData['tools'][$name] = serialize($tool); + $discoveredData['tools'][$name] = json_encode($tool); } } foreach ($this->resources as $uri => $resource) { if (! $resource->isManual) { - $discoveredData['resources'][$uri] = serialize($resource); + $discoveredData['resources'][$uri] = json_encode($resource); } } foreach ($this->prompts as $name => $prompt) { if (! $prompt->isManual) { - $discoveredData['prompts'][$name] = serialize($prompt); + $discoveredData['prompts'][$name] = json_encode($prompt); } } foreach ($this->resourceTemplates as $uriTemplate => $template) { if (! $template->isManual) { - $discoveredData['resourceTemplates'][$uriTemplate] = serialize($template); + $discoveredData['resourceTemplates'][$uriTemplate] = json_encode($template); } } @@ -350,25 +365,25 @@ public function clear(): void $clearCount = 0; foreach ($this->tools as $name => $tool) { - if (! isset($this->manualToolNames[$name])) { + if (! $tool->isManual) { unset($this->tools[$name]); $clearCount++; } } foreach ($this->resources as $uri => $resource) { - if (! isset($this->manualResourceUris[$uri])) { + if (! $resource->isManual) { unset($this->resources[$uri]); $clearCount++; } } foreach ($this->prompts as $name => $prompt) { - if (! isset($this->manualPromptNames[$name])) { + if (! $prompt->isManual) { unset($this->prompts[$name]); $clearCount++; } } foreach ($this->resourceTemplates as $uriTemplate => $template) { - if (! isset($this->manualTemplateUris[$uriTemplate])) { + if (! $template->isManual) { unset($this->resourceTemplates[$uriTemplate]); $clearCount++; } diff --git a/src/Server.php b/src/Server.php index f6feb50..30a9026 100644 --- a/src/Server.php +++ b/src/Server.php @@ -65,14 +65,15 @@ public function discover( array $scanDirs = ['.', 'src'], array $excludeDirs = [], bool $force = false, - bool $saveToCache = true + bool $saveToCache = true, + ?Discoverer $discoverer = null ): void { $realBasePath = realpath($basePath); if ($realBasePath === false || ! is_dir($realBasePath)) { throw new \InvalidArgumentException("Invalid discovery base path provided to discover(): {$basePath}"); } - $excludeDirs = array_merge($excludeDirs, ['vendor', 'tests', 'test', 'storage', 'cache', 'samples', 'docs', 'node_modules']); + $excludeDirs = array_merge($excludeDirs, ['vendor', 'tests', 'test', 'storage', 'cache', 'samples', 'docs', 'node_modules', '.git', '.svn']); if ($this->discoveryRan && ! $force) { $this->configuration->logger->debug('Discovery skipped: Already run or loaded from cache.'); @@ -92,7 +93,7 @@ public function discover( $this->registry->clear(); try { - $discoverer = new Discoverer($this->registry, $this->configuration->logger); + $discoverer ??= new Discoverer($this->registry, $this->configuration->logger); $discoverer->discover($realBasePath, $scanDirs, $excludeDirs); @@ -154,9 +155,11 @@ public function listen(ServerTransportInterface $transport, bool $runLoop = true $this->isListening = true; if ($runLoop) { + $this->sessionManager->startGcTimer(); + $this->configuration->loop->run(); - $this->endListen($transport); // If the loop ends, we need to clean up + $this->endListen($transport); } } catch (Throwable $e) { $this->configuration->logger->critical('Failed to start listening or event loop crashed.', ['exception' => $e->getMessage()]); @@ -169,14 +172,15 @@ public function endListen(ServerTransportInterface $transport): void { $protocol = $this->getProtocol(); - if ($this->isListening) { - $protocol->unbindTransport(); - $transport->removeAllListeners('close'); - $transport->close(); - } + $protocol->unbindTransport(); + + $this->sessionManager->stopGcTimer(); + + $transport->removeAllListeners('close'); + $transport->close(); $this->isListening = false; - $this->configuration->logger->info("Server '{$this->configuration->serverName}' listener shut down."); + $this->configuration->logger->info("Server '{$this->configuration->serverInfo->name}' listener shut down."); } /** diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index a7f40d9..abdb528 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -290,12 +290,11 @@ private function registerManualElements(Registry $registry, LoggerInterface $log try { $reflectionMethod = HandlerResolver::resolve($data['handler']); $className = $reflectionMethod->getDeclaringClass()->getName(); + $classShortName = $reflectionMethod->getDeclaringClass()->getShortName(); $methodName = $reflectionMethod->getName(); $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); - $name = $data['name'] ?? ($reflectionMethod->getName() === '__invoke' - ? $reflectionMethod->getDeclaringClass()->getShortName() - : $reflectionMethod->getName()); + $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; $inputSchema = $schemaGenerator->fromMethodParameters($reflectionMethod); @@ -314,11 +313,12 @@ private function registerManualElements(Registry $registry, LoggerInterface $log try { $reflectionMethod = HandlerResolver::resolve($data['handler']); $className = $reflectionMethod->getDeclaringClass()->getName(); + $classShortName = $reflectionMethod->getDeclaringClass()->getShortName(); $methodName = $reflectionMethod->getName(); $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); $uri = $data['uri']; - $name = $data['name'] ?? ($methodName === '__invoke' ? $reflectionMethod->getDeclaringClass()->getShortName() : $methodName); + $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; $mimeType = $data['mimeType']; $size = $data['size']; @@ -339,18 +339,19 @@ private function registerManualElements(Registry $registry, LoggerInterface $log try { $reflectionMethod = HandlerResolver::resolve($data['handler']); $className = $reflectionMethod->getDeclaringClass()->getName(); + $classShortName = $reflectionMethod->getDeclaringClass()->getShortName(); $methodName = $reflectionMethod->getName(); $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); $uriTemplate = $data['uriTemplate']; - $name = $data['name'] ?? ($methodName === '__invoke' ? $reflectionMethod->getDeclaringClass()->getShortName() : $methodName); + $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; $mimeType = $data['mimeType']; $annotations = $data['annotations']; $template = ResourceTemplate::make($uriTemplate, $name, $description, $mimeType, $annotations); $completionProviders = $this->getCompletionProviders($reflectionMethod); - $registry->registerResourceTemplate($template, $className, $methodName, true, $completionProviders); + $registry->registerResourceTemplate($template, $className, $methodName, $completionProviders, true); $logger->debug("Registered manual template {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { @@ -364,10 +365,11 @@ private function registerManualElements(Registry $registry, LoggerInterface $log try { $reflectionMethod = HandlerResolver::resolve($data['handler']); $className = $reflectionMethod->getDeclaringClass()->getName(); + $classShortName = $reflectionMethod->getDeclaringClass()->getShortName(); $methodName = $reflectionMethod->getName(); $docBlock = $docBlockParser->parseDocBlock($reflectionMethod->getDocComment() ?? null); - $name = $data['name'] ?? ($methodName === '__invoke' ? $reflectionMethod->getDeclaringClass()->getShortName() : $methodName); + $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; $arguments = []; @@ -390,7 +392,7 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $prompt = Prompt::make($name, $description, $arguments); $completionProviders = $this->getCompletionProviders($reflectionMethod); - $registry->registerPrompt($prompt, $className, $methodName, true, $completionProviders); + $registry->registerPrompt($prompt, $className, $methodName, $completionProviders, true); $logger->debug("Registered manual prompt {$name} from handler {$className}::{$methodName}"); } catch (Throwable $e) { diff --git a/src/Session/SessionManager.php b/src/Session/SessionManager.php index 7a1f076..0edd63c 100644 --- a/src/Session/SessionManager.php +++ b/src/Session/SessionManager.php @@ -18,16 +18,15 @@ class SessionManager implements EventEmitterInterface use EventEmitterTrait; protected ?TimerInterface $gcTimer = null; - protected int $gcInterval = 300; // 5 minutes public function __construct( protected SessionHandlerInterface $handler, protected LoggerInterface $logger, protected ?LoopInterface $loop = null, - protected int $ttl = 3600 + protected int $ttl = 3600, + protected int|float $gcInterval = 300 ) { $this->loop ??= Loop::get(); - $this->startGcTimer(); } /** @@ -59,7 +58,7 @@ public function startGcTimer(): void */ public function stopGcTimer(): void { - if ($this->gcTimer !== null && $this->loop !== null) { + if ($this->gcTimer !== null) { $this->loop->cancelTimer($this->gcTimer); $this->gcTimer = null; } @@ -153,9 +152,4 @@ public function hasQueuedMessages(string $sessionId): bool return $session->hasQueuedMessages(); } - - public function __destruct() - { - $this->stopGcTimer(); - } } diff --git a/src/Transports/HttpServerTransport.php b/src/Transports/HttpServerTransport.php index bd77d24..1965704 100644 --- a/src/Transports/HttpServerTransport.php +++ b/src/Transports/HttpServerTransport.php @@ -5,7 +5,6 @@ namespace PhpMcp\Server\Transports; use Evenement\EventEmitterTrait; -use PhpMcp\Server\Contracts\IdGeneratorInterface; use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; @@ -13,7 +12,6 @@ use PhpMcp\Schema\JsonRpc\Message; use PhpMcp\Schema\JsonRpc\Error; use PhpMcp\Schema\JsonRpc\Parser; -use PhpMcp\Server\Utils\RandomIdGenerator; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -48,8 +46,6 @@ class HttpServerTransport implements ServerTransportInterface, LoggerAwareInterf protected ?HttpServer $http = null; - private IdGeneratorInterface $idGenerator; - /** @var array sessionId => SSE Stream */ private array $activeSseStreams = []; @@ -72,13 +68,11 @@ public function __construct( private readonly int $port = 8080, private readonly string $mcpPathPrefix = 'mcp', private readonly ?array $sslContext = null, - ?IdGeneratorInterface $idGenerator = null ) { $this->logger = new NullLogger(); $this->loop = Loop::get(); $this->ssePath = '/' . trim($mcpPathPrefix, '/') . '/sse'; $this->messagePath = '/' . trim($mcpPathPrefix, '/') . '/message'; - $this->idGenerator = $idGenerator ?? new RandomIdGenerator(); } public function setLogger(LoggerInterface $logger): void @@ -91,6 +85,11 @@ public function setLoop(LoopInterface $loop): void $this->loop = $loop; } + protected function generateId(): string + { + return bin2hex(random_bytes(16)); // 32 hex characters + } + /** * Starts the HTTP server listener. * @@ -162,7 +161,7 @@ protected function createRequestHandler(): callable /** Handles a new SSE connection request */ protected function handleSseRequest(ServerRequestInterface $request): Response { - $sessionId = $this->idGenerator->generateId(); + $sessionId = $this->generateId(); $this->logger->info('New SSE connection', ['sessionId' => $sessionId]); $sseStream = new ThroughStream(); diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php index 4c1ee0f..e932ffe 100644 --- a/src/Transports/StreamableHttpServerTransport.php +++ b/src/Transports/StreamableHttpServerTransport.php @@ -6,7 +6,6 @@ use Evenement\EventEmitterTrait; use PhpMcp\Server\Contracts\EventStoreInterface; -use PhpMcp\Server\Contracts\IdGeneratorInterface; use PhpMcp\Server\Contracts\LoggerAwareInterface; use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; @@ -19,7 +18,6 @@ use PhpMcp\Schema\JsonRpc\Parser; use PhpMcp\Schema\JsonRpc\Request; use PhpMcp\Schema\JsonRpc\Response; -use PhpMcp\Server\Utils\RandomIdGenerator; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -48,7 +46,6 @@ class StreamableHttpServerTransport implements ServerTransportInterface, LoggerA private bool $listening = false; private bool $closing = false; - private IdGeneratorInterface $idGenerator; private ?EventStoreInterface $eventStore; /** @@ -78,16 +75,19 @@ public function __construct( private string $mcpPath = '/mcp', private ?array $sslContext = null, private readonly bool $enableJsonResponse = true, - ?IdGeneratorInterface $idGenerator = null, ?EventStoreInterface $eventStore = null ) { $this->logger = new NullLogger(); $this->loop = Loop::get(); $this->mcpPath = '/' . trim($mcpPath, '/'); - $this->idGenerator = $idGenerator ?? new RandomIdGenerator(); $this->eventStore = $eventStore; } + protected function generateId(): string + { + return bin2hex(random_bytes(16)); // 32 hex characters + } + public function setLogger(LoggerInterface $logger): void { $this->logger = $logger; @@ -272,7 +272,7 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte return $deferred->promise(); } - $sessionId = $this->idGenerator->generateId(); + $sessionId = $this->generateId(); $this->emit('client_connected', [$sessionId]); } else { $sessionId = $request->getHeaderLine('Mcp-Session-Id'); @@ -299,8 +299,28 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte $deferred->resolve(new HttpResponse(202)); $context['type'] = 'post_202_sent'; } else { - if (!$this->enableJsonResponse) { - $streamId = $this->idGenerator->generateId(); + if ($this->enableJsonResponse) { + $pendingRequestId = $this->generateId(); + $this->pendingRequests[$pendingRequestId] = $deferred; + + $timeoutTimer = $this->loop->addTimer(30, function () use ($pendingRequestId, $sessionId) { + if (isset($this->pendingRequests[$pendingRequestId])) { + $deferred = $this->pendingRequests[$pendingRequestId]; + unset($this->pendingRequests[$pendingRequestId]); + $this->logger->warning("Timeout waiting for direct JSON response processing.", ['pending_request_id' => $pendingRequestId, 'session_id' => $sessionId]); + $errorResponse = McpServerException::internalError("Request processing timed out.")->toJsonRpcError($pendingRequestId); + $deferred->resolve(new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($errorResponse->toArray()))); + } + }); + + $this->pendingRequests[$pendingRequestId]->promise()->finally(function () use ($timeoutTimer) { + $this->loop->cancelTimer($timeoutTimer); + }); + + $context['type'] = 'post_json'; + $context['pending_request_id'] = $pendingRequestId; + } else { + $streamId = $this->generateId(); $sseStream = new ThroughStream(); $this->activeSseStreams[$streamId] = [ 'stream' => $sseStream, @@ -332,30 +352,12 @@ private function handlePostRequest(ServerRequestInterface $request): PromiseInte $context['type'] = 'post_sse'; $context['streamId'] = $streamId; $context['nRequests'] = $nRequests; - } else { - $pendingRequestId = $this->idGenerator->generateId(); - $this->pendingRequests[$pendingRequestId] = $deferred; - - $timeoutTimer = $this->loop->addTimer(30, function () use ($pendingRequestId, $sessionId) { - if (isset($this->pendingRequests[$pendingRequestId])) { - $deferred = $this->pendingRequests[$pendingRequestId]; - unset($this->pendingRequests[$pendingRequestId]); - $this->logger->warning("Timeout waiting for direct JSON response processing.", ['pending_request_id' => $pendingRequestId, 'session_id' => $sessionId]); - $errorResponse = McpServerException::internalError("Request processing timed out.")->toJsonRpcError($pendingRequestId); - $deferred->resolve(new HttpResponse(500, ['Content-Type' => 'application/json'], json_encode($errorResponse->toArray()))); - } - }); - - $this->pendingRequests[$pendingRequestId]->promise()->finally(function () use ($timeoutTimer) { - $this->loop->cancelTimer($timeoutTimer); - }); - - $context['type'] = 'post_json'; - $context['pending_request_id'] = $pendingRequestId; } } - $this->emit('message', [$message, $sessionId, $context]); + $this->loop->futureTick(function () use ($message, $sessionId, $context) { + $this->emit('message', [$message, $sessionId, $context]); + }); return $deferred->promise(); } @@ -381,6 +383,11 @@ private function handleDeleteRequest(ServerRequestInterface $request): PromiseIn unset($this->activeSseStreams[$streamId]); } + if ($this->getStream !== null) { + $this->getStream->end(); + $this->getStream = null; + } + $this->emit('client_disconnected', [$sessionId, 'Session terminated by DELETE request']); return resolve(new HttpResponse(204)); diff --git a/src/Utils/Discoverer.php b/src/Utils/Discoverer.php index 5b13a39..593fde4 100644 --- a/src/Utils/Discoverer.php +++ b/src/Utils/Discoverer.php @@ -196,7 +196,7 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; $inputSchema = $this->schemaGenerator->fromMethodParameters($method); $tool = Tool::make($name, $inputSchema, $description, $instance->annotations); - $this->registry->registerTool($tool, $className, $methodName, true); + $this->registry->registerTool($tool, $className, $methodName); $discoveredCount['tools']++; break; @@ -208,7 +208,7 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $size = $instance->size; $annotations = $instance->annotations; $resource = Resource::make($instance->uri, $name, $description, $mimeType, $annotations, $size); - $this->registry->registerResource($resource, $className, $methodName, true); + $this->registry->registerResource($resource, $className, $methodName); $discoveredCount['resources']++; break; @@ -228,7 +228,7 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount } $prompt = Prompt::make($name, $description, $arguments); $completionProviders = $this->getCompletionProviders($method); - $this->registry->registerPrompt($prompt, $className, $methodName, true, $completionProviders); + $this->registry->registerPrompt($prompt, $className, $methodName, $completionProviders); $discoveredCount['prompts']++; break; @@ -240,7 +240,7 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $annotations = $instance->annotations; $resourceTemplate = ResourceTemplate::make($instance->uriTemplate, $name, $description, $mimeType, $annotations); $completionProviders = $this->getCompletionProviders($method); - $this->registry->registerResourceTemplate($resourceTemplate, $className, $methodName, true, $completionProviders); + $this->registry->registerResourceTemplate($resourceTemplate, $className, $methodName, $completionProviders); $discoveredCount['resourceTemplates']++; break; } diff --git a/src/Utils/DocBlockParser.php b/src/Utils/DocBlockParser.php index 476d756..85bcb75 100644 --- a/src/Utils/DocBlockParser.php +++ b/src/Utils/DocBlockParser.php @@ -7,6 +7,7 @@ use phpDocumentor\Reflection\DocBlock\Tags\Return_; use phpDocumentor\Reflection\DocBlockFactory; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Throwable; /** @@ -15,18 +16,20 @@ class DocBlockParser { private DocBlockFactory $docBlockFactory; + private LoggerInterface $logger; - public function __construct(private LoggerInterface $logger) + public function __construct(?LoggerInterface $logger = null) { $this->docBlockFactory = DocBlockFactory::createInstance(); + $this->logger = $logger ?? new NullLogger(); } /** * Safely parses a DocComment string into a DocBlock object. */ - public function parseDocBlock(?string $docComment): ?DocBlock + public function parseDocBlock(string|null|false $docComment): ?DocBlock { - if (empty($docComment)) { + if ($docComment === false || $docComment === null || empty($docComment)) { return null; } try { diff --git a/src/Utils/RandomIdGenerator.php b/src/Utils/RandomIdGenerator.php deleted file mode 100644 index 2b4e486..0000000 --- a/src/Utils/RandomIdGenerator.php +++ /dev/null @@ -1,15 +0,0 @@ -isBuiltin() && function_exists('enum_exists') && enum_exists($reflectionType->getName())) { + // Handle enums + if ($reflectionType instanceof ReflectionNamedType && ! $reflectionType->isBuiltin() && enum_exists($reflectionType->getName())) { $enumClass = $reflectionType->getName(); - if (method_exists($enumClass, 'cases')) { // Ensure it's actually an enum - $isBacked = ! empty($enumClass::cases()) && isset($enumClass::cases()[0]->value); - $enumReflection = new ReflectionEnum($enumClass); - $backingTypeReflection = $enumReflection->getBackingType(); - - if ($isBacked && $backingTypeReflection instanceof ReflectionNamedType) { - $paramSchema['enum'] = array_column($enumClass::cases(), 'value'); - $jsonBackingType = match ($backingTypeReflection->getName()) { - 'int' => 'integer', - 'string' => 'string', - default => null, // Should not happen for valid backed enums - }; - - if ($jsonBackingType) { - // Ensure schema type matches backing type, considering nullability - if (isset($paramSchema['type']) && is_array($paramSchema['type']) && in_array('null', $paramSchema['type'])) { - $paramSchema['type'] = [$jsonBackingType, 'null']; - } else { - $paramSchema['type'] = $jsonBackingType; - } - } - } else { - // Non-backed enum - use names as enum values - $paramSchema['enum'] = array_column($enumClass::cases(), 'name'); - // Ensure schema type is string, considering nullability + $enumReflection = new ReflectionEnum($enumClass); + $backingTypeReflection = $enumReflection->getBackingType(); + + if ($enumReflection->isBacked() && $backingTypeReflection instanceof ReflectionNamedType) { + $paramSchema['enum'] = array_column($enumClass::cases(), 'value'); + $jsonBackingType = match ($backingTypeReflection->getName()) { + 'int' => 'integer', + 'string' => 'string', + default => null, // Should not happen for valid backed enums + }; + + if ($jsonBackingType) { + // Ensure schema type matches backing type, considering nullability if (isset($paramSchema['type']) && is_array($paramSchema['type']) && in_array('null', $paramSchema['type'])) { - $paramSchema['type'] = ['string', 'null']; + $paramSchema['type'] = [$jsonBackingType, 'null']; } else { - $paramSchema['type'] = 'string'; + $paramSchema['type'] = $jsonBackingType; } } + } else { + // Non-backed enum - use names as enum values + $paramSchema['enum'] = array_column($enumClass::cases(), 'name'); + // Ensure schema type is string, considering nullability + if (isset($paramSchema['type']) && is_array($paramSchema['type']) && in_array('null', $paramSchema['type'])) { + $paramSchema['type'] = ['string', 'null']; + } else { + $paramSchema['type'] = 'string'; + } } } - // TODO: Revisit format inference or add explicit @schema docblock tag for formats in a future version. - // For now, parameters typed as 'string' will not have a 'format' keyword automatically added. - // Users needing specific string format validation (date-time, email, uri, regex pattern) - // would need to perform that validation within their tool/resource handler method. - // Handle array items type if possible if (isset($paramSchema['type'])) { $schemaType = is_array($paramSchema['type']) ? (in_array('array', $paramSchema['type']) ? 'array' : null) : $paramSchema['type']; diff --git a/tests/Fixtures/Discovery/DiscoverablePromptHandler.php b/tests/Fixtures/Discovery/DiscoverablePromptHandler.php new file mode 100644 index 0000000..54d6a00 --- /dev/null +++ b/tests/Fixtures/Discovery/DiscoverablePromptHandler.php @@ -0,0 +1,38 @@ + "user", "content" => "Write a {$genre} story about a lost robot, approximately {$lengthWords} words long."] + ]; + } + + #[McpPrompt] + public function simpleQuestionPrompt(string $question): array + { + return [ + ["role" => "user", "content" => $question], + ["role" => "assistant", "content" => "I will try to answer that."] + ]; + } +} diff --git a/tests/Fixtures/Discovery/DiscoverableResourceHandler.php b/tests/Fixtures/Discovery/DiscoverableResourceHandler.php new file mode 100644 index 0000000..4d52dbd --- /dev/null +++ b/tests/Fixtures/Discovery/DiscoverableResourceHandler.php @@ -0,0 +1,40 @@ + "dark", "fontSize" => 14]; + } + + public function someOtherMethod(): void {} +} diff --git a/tests/Fixtures/Discovery/DiscoverableTemplateHandler.php b/tests/Fixtures/Discovery/DiscoverableTemplateHandler.php new file mode 100644 index 0000000..ee7171f --- /dev/null +++ b/tests/Fixtures/Discovery/DiscoverableTemplateHandler.php @@ -0,0 +1,42 @@ + $productId, + "name" => "Product " . $productId, + "region" => $region, + "price" => ($region === "EU" ? "โ‚ฌ" : "$") . (hexdec(substr(md5($productId), 0, 4)) / 100) + ]; + } + + #[McpResourceTemplate(uriTemplate: "file://{path}/{filename}.{extension}")] + public function getFileContent(string $path, string $filename, string $extension): string + { + return "Content of {$path}/{$filename}.{$extension}"; + } +} diff --git a/tests/Fixtures/Discovery/DiscoverableToolHandler.php b/tests/Fixtures/Discovery/DiscoverableToolHandler.php new file mode 100644 index 0000000..5cd7003 --- /dev/null +++ b/tests/Fixtures/Discovery/DiscoverableToolHandler.php @@ -0,0 +1,51 @@ + $count, 'loudly' => $loudly, 'mode' => $mode->value, 'message' => "Action repeated."]; + } + + // This method should NOT be discovered as a tool + public function internalHelperMethod(int $value): int + { + return $value * 2; + } + + #[McpTool(name: "private_tool_should_be_ignored")] // On private method + private function aPrivateTool(): void {} + + #[McpTool(name: "protected_tool_should_be_ignored")] // On protected method + protected function aProtectedTool(): void {} + + #[McpTool(name: "static_tool_should_be_ignored")] // On static method + public static function aStaticTool(): void {} +} diff --git a/tests/Fixtures/Discovery/InvocablePromptFixture.php b/tests/Fixtures/Discovery/InvocablePromptFixture.php new file mode 100644 index 0000000..443e292 --- /dev/null +++ b/tests/Fixtures/Discovery/InvocablePromptFixture.php @@ -0,0 +1,20 @@ + 'user', 'content' => "Generate a short greeting for {$personName}."]]; + } +} diff --git a/tests/Fixtures/Discovery/InvocableResourceFixture.php b/tests/Fixtures/Discovery/InvocableResourceFixture.php new file mode 100644 index 0000000..6c14779 --- /dev/null +++ b/tests/Fixtures/Discovery/InvocableResourceFixture.php @@ -0,0 +1,16 @@ + "OK", "load" => rand(1, 100) / 100.0]; + } +} diff --git a/tests/Fixtures/Discovery/InvocableResourceTemplateFixture.php b/tests/Fixtures/Discovery/InvocableResourceTemplateFixture.php new file mode 100644 index 0000000..2c70f84 --- /dev/null +++ b/tests/Fixtures/Discovery/InvocableResourceTemplateFixture.php @@ -0,0 +1,20 @@ + $userId, "email" => "user{$userId}@example-invokable.com"]; + } +} diff --git a/tests/Fixtures/Discovery/InvocableToolFixture.php b/tests/Fixtures/Discovery/InvocableToolFixture.php new file mode 100644 index 0000000..c31871d --- /dev/null +++ b/tests/Fixtures/Discovery/InvocableToolFixture.php @@ -0,0 +1,25 @@ + str_starts_with($item, $currentValue)); + } +} diff --git a/tests/Mocks/SupportStubs/DocBlockTestStub.php b/tests/Fixtures/General/DocBlockTestFixture.php similarity index 74% rename from tests/Mocks/SupportStubs/DocBlockTestStub.php rename to tests/Fixtures/General/DocBlockTestFixture.php index 0d7e17d..cc4d66b 100644 --- a/tests/Mocks/SupportStubs/DocBlockTestStub.php +++ b/tests/Fixtures/General/DocBlockTestFixture.php @@ -1,18 +1,16 @@ $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. @@ -58,7 +52,7 @@ public function methodWithReturn(): string * @return bool Status of the operation. * @throws \RuntimeException If processing fails. * @deprecated Use newMethod() instead. - * @see \PhpMcp\Server\Tests\Mocks\SupportStubs\DocBlockTestStub::newMethod() + * @see \PhpMcp\Server\Tests\Fixtures\General\DocBlockTestFixture::newMethod() */ public function methodWithMultipleTags(float $value): bool { @@ -68,16 +62,10 @@ public function methodWithMultipleTags(float $value): bool /** * 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/Fixtures/General/InvokableHandlerFixture.php b/tests/Fixtures/General/InvokableHandlerFixture.php new file mode 100644 index 0000000..ee3dfdd --- /dev/null +++ b/tests/Fixtures/General/InvokableHandlerFixture.php @@ -0,0 +1,20 @@ +type = $type; + } + + public function __invoke(string $arg1, int $arg2 = 0): array + { + $this->argsReceived = func_get_args(); + return ['invoked' => $this->type, 'arg1' => $arg1, 'arg2' => $arg2]; + } +} diff --git a/tests/Fixtures/General/PromptHandlerFixture.php b/tests/Fixtures/General/PromptHandlerFixture.php new file mode 100644 index 0000000..92c0bca --- /dev/null +++ b/tests/Fixtures/General/PromptHandlerFixture.php @@ -0,0 +1,154 @@ + 'user', 'content' => "Craft a {$style} greeting for {$name}."] + ]; + } + + public function returnSinglePromptMessageObject(): PromptMessage + { + return PromptMessage::make(Role::User, TextContent::make("Single PromptMessage object.")); + } + + public function returnArrayOfPromptMessageObjects(): array + { + return [ + PromptMessage::make(Role::User, TextContent::make("First message object.")), + PromptMessage::make(Role::Assistant, ImageContent::make("img_data", "image/png")), + ]; + } + + public function returnEmptyArrayForPrompt(): array + { + return []; + } + + public function returnSimpleUserAssistantMap(): array + { + return [ + 'user' => "This is the user's turn.", + 'assistant' => "And this is the assistant's reply." + ]; + } + + public function returnUserAssistantMapWithContentObjects(): array + { + return [ + 'user' => TextContent::make("User text content object."), + 'assistant' => ImageContent::make("asst_img_data", "image/gif"), + ]; + } + + public function returnUserAssistantMapWithMixedContent(): array + { + return [ + 'user' => "Plain user string.", + 'assistant' => AudioContent::make("aud_data", "audio/mp3"), + ]; + } + + public function returnUserAssistantMapWithArrayContent(): array + { + return [ + 'user' => ['type' => 'text', 'text' => 'User array content'], + 'assistant' => ['type' => 'image', 'data' => 'asst_arr_img_data', 'mimeType' => 'image/jpeg'], + ]; + } + + public function returnListOfRawMessageArrays(): array + { + return [ + ['role' => 'user', 'content' => "First raw message string."], + ['role' => 'assistant', 'content' => TextContent::make("Second raw message with Content obj.")], + ['role' => 'user', 'content' => ['type' => 'image', 'data' => 'raw_img_data', 'mimeType' => 'image/webp']], + ['role' => 'assistant', 'content' => ['type' => 'audio', 'data' => 'raw_aud_data', 'mimeType' => 'audio/ogg']], + ['role' => 'user', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'file://doc.pdf', 'blob' => base64_encode('pdf-data'), 'mimeType' => 'application/pdf']]], + ['role' => 'assistant', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'config://settings.json', 'text' => '{"theme":"dark"}']]], + ]; + } + + public function returnListOfRawMessageArraysWithScalars(): array + { + return [ + ['role' => 'user', 'content' => 123], // int + ['role' => 'assistant', 'content' => true], // bool + ['role' => 'user', 'content' => null], // null + ['role' => 'assistant', 'content' => 3.14], // float + ['role' => 'user', 'content' => ['key' => 'value']], // array that becomes JSON + ]; + } + + public function returnMixedArrayOfPromptMessagesAndRaw(): array + { + return [ + PromptMessage::make(Role::User, TextContent::make("This is a PromptMessage object.")), + ['role' => 'assistant', 'content' => "This is a raw message array."], + PromptMessage::make(Role::User, ImageContent::make("pm_img", "image/bmp")), + ['role' => 'assistant', 'content' => ['type' => 'text', 'text' => 'Raw message with typed content.']], + ]; + } + + public function promptWithArgumentCompletion( + #[CompletionProvider(providerClass: CompletionProviderFixture::class)] + string $entityName, + string $action = "describe" + ): array { + return [ + ['role' => 'user', 'content' => "Please {$action} the entity: {$entityName}."] + ]; + } + + public function promptReturnsNonArray(): string + { + return "This is not a valid prompt return type."; + } + + public function promptReturnsArrayWithInvalidRole(): array + { + return [['role' => 'system', 'content' => 'System messages are not directly supported.']]; + } + + public function promptReturnsInvalidRole(): array + { + return [['role' => 'system', 'content' => 'System messages are not directly supported.']]; + } + + public function promptReturnsArrayWithInvalidContentStructure(): array + { + return [['role' => 'user', 'content' => ['text_only_no_type' => 'invalid']]]; + } + + public function promptReturnsArrayWithInvalidTypedContent(): array + { + return [['role' => 'user', 'content' => ['type' => 'image', 'source' => 'url.jpg']]]; // 'image' needs 'data' and 'mimeType' + } + + public function promptReturnsArrayWithInvalidResourceContent(): array + { + return [ + [ + 'role' => 'user', + 'content' => ['type' => 'resource', 'resource' => ['uri' => 'uri://uri']] + ] + ]; + } + + public function promptHandlerThrows(): void + { + throw new \LogicException("Prompt generation failed inside handler."); + } +} diff --git a/tests/Fixtures/General/ResourceHandlerFixture.php b/tests/Fixtures/General/ResourceHandlerFixture.php new file mode 100644 index 0000000..94486ae --- /dev/null +++ b/tests/Fixtures/General/ResourceHandlerFixture.php @@ -0,0 +1,152 @@ +dynamicContentStore['dynamic://data/item1'] = "Content for item 1"; + } + + public function returnStringText(string $uri): string + { + return "Plain string content for {$uri}"; + } + + public function returnStringJson(string $uri): string + { + return json_encode(['uri_in_json' => $uri, 'data' => 'some json string']); + } + + public function returnStringHtml(string $uri): string + { + return "{$uri}Content"; + } + + public function returnArrayJson(string $uri): array + { + return ['uri_in_array' => $uri, 'message' => 'This is JSON data from array', 'timestamp' => time()]; + } + + public function returnEmptyArray(string $uri): array + { + return []; + } + + public function returnStream(string $uri) // Returns a stream resource + { + $stream = fopen('php://memory', 'r+'); + fwrite($stream, "Streamed content for {$uri}"); + rewind($stream); + return $stream; + } + + public function returnSplFileInfo(string $uri): SplFileInfo + { + self::$unlinkableSplFile = tempnam(sys_get_temp_dir(), 'res_fixture_spl_'); + file_put_contents(self::$unlinkableSplFile, "Content from SplFileInfo for {$uri}"); + return new SplFileInfo(self::$unlinkableSplFile); + } + + public function returnEmbeddedResource(string $uri): EmbeddedResource + { + return EmbeddedResource::make( + TextResourceContents::make($uri, 'application/vnd.custom-embedded', 'Direct EmbeddedResource content') + ); + } + + public function returnTextResourceContents(string $uri): TextResourceContents + { + return TextResourceContents::make($uri, 'text/special-contents', 'Direct TextResourceContents'); + } + + public function returnBlobResourceContents(string $uri): BlobResourceContents + { + return BlobResourceContents::make($uri, 'application/custom-blob-contents', base64_encode('blobbycontents')); + } + + public function returnArrayForBlobSchema(string $uri): array + { + return ['blob' => base64_encode("Blob for {$uri} via array"), 'mimeType' => 'application/x-custom-blob-array']; + } + + public function returnArrayForTextSchema(string $uri): array + { + return ['text' => "Text from array for {$uri} via array", 'mimeType' => 'text/vnd.custom-array-text']; + } + + public function returnArrayOfResourceContents(string $uri): array + { + return [ + TextResourceContents::make($uri . "_part1", 'text/plain', 'Part 1 of many RC'), + BlobResourceContents::make($uri . "_part2", 'image/png', base64_encode('pngdata')), + ]; + } + + public function returnArrayOfEmbeddedResources(string $uri): array + { + return [ + EmbeddedResource::make(TextResourceContents::make($uri . "_emb1", 'text/xml', '')), + EmbeddedResource::make(BlobResourceContents::make($uri . "_emb2", 'font/woff2', base64_encode('fontdata'))), + ]; + } + + public function returnMixedArrayWithResourceTypes(string $uri): array + { + return [ + "A raw string piece", // Will be formatted + TextResourceContents::make($uri . "_rc1", 'text/markdown', '**Markdown!**'), // Used as is + ['nested_array_data' => 'value', 'for_uri' => $uri], // Will be formatted (JSON) + EmbeddedResource::make(TextResourceContents::make($uri . "_emb1", 'text/csv', 'col1,col2')), // Extracted + ]; + } + + public function handlerThrowsException(string $uri): void + { + throw new \DomainException("Cannot read resource {$uri} - handler error."); + } + + public function returnUnformattableType(string $uri) + { + return new \DateTimeImmutable(); + } + + public function resourceHandlerNeedsUri(string $uri): string + { + return "Handler received URI: " . $uri; + } + + public function resourceHandlerDoesNotNeedUri(): string + { + return "Handler did not need or receive URI parameter."; + } + + public function getTemplatedContent( + string $category, + string $itemId, + string $format, + ): array { + return [ + 'message' => "Content for item {$itemId} in category {$category}, format {$format}.", + 'category_received' => $category, + 'itemId_received' => $itemId, + 'format_received' => $format, + ]; + } + + public function getStaticText(): string + { + return self::$staticTextContent; + } +} diff --git a/tests/Fixtures/General/ToolHandlerFixture.php b/tests/Fixtures/General/ToolHandlerFixture.php new file mode 100644 index 0000000..b7afee8 --- /dev/null +++ b/tests/Fixtures/General/ToolHandlerFixture.php @@ -0,0 +1,134 @@ + 'ok', 'timestamp' => time()]; + } + + public function processBackedEnum(BackedStringEnum $status): string + { + return "Status processed: " . $status->value; + } + + public function returnString(): string + { + return "This is a string result."; + } + + public function returnInteger(): int + { + return 12345; + } + + public function returnFloat(): float + { + return 67.89; + } + + public function returnBooleanTrue(): bool + { + return true; + } + + public function returnBooleanFalse(): bool + { + return false; + } + + public function returnNull(): ?string + { + return null; + } + + public function returnArray(): array + { + return ['message' => 'Array result', 'data' => [1, 2, 3]]; + } + + public function returnStdClass(): \stdClass + { + $obj = new \stdClass(); + $obj->property = "value"; + return $obj; + } + + public function returnTextContent(): TextContent + { + return TextContent::make("Pre-formatted TextContent."); + } + + public function returnImageContent(): ImageContent + { + return ImageContent::make("base64data==", "image/png"); + } + + public function returnAudioContent(): AudioContent + { + return AudioContent::make("base64audio==", "audio/mp3"); + } + + public function returnArrayOfContent(): array + { + return [ + TextContent::make("Part 1"), + ImageContent::make("imgdata", "image/jpeg") + ]; + } + + public function returnMixedArray(): array + { + return [ + "A raw string", + TextContent::make("A TextContent object"), + 123, + true, + null, + ['nested_key' => 'nested_value', 'sub_array' => [4, 5]], + ImageContent::make("img_data_mixed", "image/gif"), + (object)['obj_prop' => 'obj_val'] + ]; + } + + public function returnEmptyArray(): array + { + return []; + } + + public function toolThatThrows(): void + { + throw new \InvalidArgumentException("Something went wrong in the tool."); + } + + public function toolUnencodableResult() + { + return fopen('php://memory', 'r'); + } +} diff --git a/tests/Fixtures/General/VariousTypesHandler.php b/tests/Fixtures/General/VariousTypesHandler.php new file mode 100644 index 0000000..9071ce3 --- /dev/null +++ b/tests/Fixtures/General/VariousTypesHandler.php @@ -0,0 +1,143 @@ + $pIntArrayGeneric Array of integers (generic style) + * @param array $pAssocArray Associative array + * @param BackedIntEnum[] $pEnumArray Array of enums + * @param array{name: string, age: int} $pShapeArray Typed array shape + * @param array $pArrayOfShapes Array of shapes + */ + public function arrayTypes( + array $pStringArray, + array $pIntArrayGeneric, + array $pAssocArray, + array $pEnumArray, + array $pShapeArray, + array $pArrayOfShapes + ): void {} + + /** + * Enum types. + * @param BackedStringEnum $pBackedStringEnum Backed string enum + * @param BackedIntEnum $pBackedIntEnum Backed int enum + * @param UnitEnum $pUnitEnum Unit enum + */ + public function enumTypes(BackedStringEnum $pBackedStringEnum, BackedIntEnum $pBackedIntEnum, UnitEnum $pUnitEnum): void {} + + /** + * Variadic parameters. + * @param string ...$pVariadicStrings Variadic strings + */ + public function variadicParams(string ...$pVariadicStrings): void {} + + /** + * Mixed type. + * @param mixed $pMixed Mixed type + */ + public function mixedType(mixed $pMixed): void {} + + /** + * With #[Schema] attributes for enhanced validation. + * @param string $email With email format. + * @param int $quantity With numeric constraints. + * @param string[] $tags With array constraints. + * @param array $userProfile With object property constraints. + */ + public function withSchemaAttributes( + #[Schema(format: Format::EMAIL)] + string $email, + + #[Schema(minimum: 1, maximum: 100, multipleOf: 5)] + int $quantity, + + #[Schema(minItems: 1, maxItems: 5, uniqueItems: true, items: new ArrayItems(minLength: 3))] + array $tags, + + #[Schema( + properties: [ + new Property(name: 'id', minimum: 1), + new Property(name: 'username', pattern: '^[a-z0-9_]{3,16}$'), + ], + required: ['id', 'username'], + additionalProperties: false + )] + array $userProfile + ): void {} +} diff --git a/tests/Fixtures/ServerScripts/HttpTestServer.php b/tests/Fixtures/ServerScripts/HttpTestServer.php new file mode 100755 index 0000000..07d66ae --- /dev/null +++ b/tests/Fixtures/ServerScripts/HttpTestServer.php @@ -0,0 +1,46 @@ +#!/usr/bin/env php +withServerInfo('HttpIntegrationTestServer', '0.1.0') + ->withLogger($logger) + ->withTool([ToolHandlerFixture::class, 'greet'], 'greet_http_tool') + ->withResource([ResourceHandlerFixture::class, 'getStaticText'], "test://http/static", 'static_http_resource') + ->withPrompt([PromptHandlerFixture::class, 'generateSimpleGreeting'], 'simple_http_prompt') + ->build(); + + $transport = new HttpServerTransport($host, $port, $mcpPathPrefix); + $server->listen($transport); + + exit(0); +} catch (\Throwable $e) { + fwrite(STDERR, "[HTTP_SERVER_CRITICAL_ERROR]\nHost:{$host} Port:{$port} Prefix:{$mcpPathPrefix}\n" . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); + exit(1); +} diff --git a/tests/Fixtures/ServerScripts/StdioTestServer.php b/tests/Fixtures/ServerScripts/StdioTestServer.php new file mode 100755 index 0000000..7651a92 --- /dev/null +++ b/tests/Fixtures/ServerScripts/StdioTestServer.php @@ -0,0 +1,44 @@ +#!/usr/bin/env php +info('StdioTestServer listener starting.'); + + $server = Server::make() + ->withServerInfo('StdioIntegrationTestServer', '0.1.0') + ->withLogger($logger) + ->withTool([ToolHandlerFixture::class, 'greet'], 'greet_stdio_tool') + ->withResource([ResourceHandlerFixture::class, 'getStaticText'], 'test://stdio/static', 'static_stdio_resource') + ->withPrompt([PromptHandlerFixture::class, 'generateSimpleGreeting'], 'simple_stdio_prompt') + ->build(); + + $transport = new StdioServerTransport(); + $server->listen($transport); + + $logger->info('StdioTestServer listener stopped.'); + exit(0); +} catch (\Throwable $e) { + fwrite(STDERR, "[STDIO_SERVER_CRITICAL_ERROR]\n" . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); + exit(1); +} diff --git a/tests/Fixtures/ServerScripts/StreamableHttpTestServer.php b/tests/Fixtures/ServerScripts/StreamableHttpTestServer.php new file mode 100755 index 0000000..b6c91a1 --- /dev/null +++ b/tests/Fixtures/ServerScripts/StreamableHttpTestServer.php @@ -0,0 +1,61 @@ +#!/usr/bin/env php +info("Starting StreamableHttpTestServer on {$host}:{$port}/{$mcpPath}, JSON Mode: " . ($enableJsonResponse ? 'ON' : 'OFF')); + + $eventStore = $useEventStore ? new InMemoryEventStore() : null; + + $server = Server::make() + ->withServerInfo('StreamableHttpIntegrationServer', '0.1.0') + ->withLogger($logger) + ->withTool([ToolHandlerFixture::class, 'greet'], 'greet_streamable_tool') + ->withTool([ToolHandlerFixture::class, 'sum'], 'sum_streamable_tool') // For batch testing + ->withResource([ResourceHandlerFixture::class, 'getStaticText'], "test://streamable/static", 'static_streamable_resource') + ->withPrompt([PromptHandlerFixture::class, 'generateSimpleGreeting'], 'simple_streamable_prompt') + ->build(); + + $transport = new StreamableHttpServerTransport( + host: $host, + port: $port, + mcpPath: $mcpPath, + enableJsonResponse: $enableJsonResponse, + eventStore: $eventStore + ); + + $server->listen($transport); + + $logger->info("StreamableHttpTestServer listener stopped on {$host}:{$port}."); + exit(0); +} catch (\Throwable $e) { + fwrite(STDERR, "[STREAMABLE_HTTP_SERVER_CRITICAL_ERROR]\nHost:{$host} Port:{$port} Prefix:{$mcpPath}\n" . $e->getMessage() . "\n" . $e->getTraceAsString() . "\n"); + exit(1); +} diff --git a/tests/Mocks/SupportStubs/AttributeTestStub.php b/tests/Fixtures/Utils/AttributeFixtures.php similarity index 73% rename from tests/Mocks/SupportStubs/AttributeTestStub.php rename to tests/Fixtures/Utils/AttributeFixtures.php index 1b15004..39b6504 100644 --- a/tests/Mocks/SupportStubs/AttributeTestStub.php +++ b/tests/Fixtures/Utils/AttributeFixtures.php @@ -1,35 +1,28 @@ generic syntax @@ -44,8 +43,7 @@ public function genericArraySyntax( array $floats, array $objects, array $dateTimeInstances - ): void { - } + ): void {} /** * Method with nested array syntax @@ -60,8 +58,7 @@ public function nestedArraySyntax( array $nestedIntArrays, array $doubleStringArrays, array $doubleIntArrays - ): void { - } + ): void {} /** * Method with object-like array syntax @@ -74,6 +71,5 @@ public function objectArraySyntax( array $person, array $article, array $order - ): void { - } + ): void {} } diff --git a/tests/Mocks/SupportStubs/SchemaAttributeTestStub.php b/tests/Fixtures/Utils/SchemaAttributeFixture.php similarity index 95% rename from tests/Mocks/SupportStubs/SchemaAttributeTestStub.php rename to tests/Fixtures/Utils/SchemaAttributeFixture.php index a0de043..605850e 100644 --- a/tests/Mocks/SupportStubs/SchemaAttributeTestStub.php +++ b/tests/Fixtures/Utils/SchemaAttributeFixture.php @@ -1,13 +1,13 @@ registry = new Registry($logger); + + $docBlockParser = new DocBlockParser($logger); + $schemaGenerator = new SchemaGenerator($docBlockParser); + $this->discoverer = new Discoverer($this->registry, $logger, $docBlockParser, $schemaGenerator); + + $this->fixtureBasePath = realpath(__DIR__ . '/../Fixtures'); +}); + +it('discovers all element types correctly from fixture files', function () { + $scanDir = 'Discovery'; + + $this->discoverer->discover($this->fixtureBasePath, [$scanDir]); + + // --- Assert Tools --- + $tools = $this->registry->getTools(); + expect($tools)->toHaveCount(4); // greet_user, repeatAction, InvokableCalculator, hidden_subdir_tool + + $greetUserTool = $this->registry->getTool('greet_user'); + expect($greetUserTool)->toBeInstanceOf(RegisteredTool::class) + ->and($greetUserTool->isManual)->toBeFalse() + ->and($greetUserTool->schema->name)->toBe('greet_user') + ->and($greetUserTool->schema->description)->toBe('Greets a user by name.') + ->and($greetUserTool->handlerClass)->toBe(\PhpMcp\Server\Tests\Fixtures\Discovery\DiscoverableToolHandler::class) + ->and($greetUserTool->handlerMethod)->toBe('greet'); + expect($greetUserTool->schema->inputSchema['properties'] ?? [])->toHaveKey('name'); + + $repeatActionTool = $this->registry->getTool('repeatAction'); + expect($repeatActionTool)->toBeInstanceOf(RegisteredTool::class) + ->and($repeatActionTool->isManual)->toBeFalse() + ->and($repeatActionTool->schema->description)->toBe('A tool with more complex parameters and inferred name/description.') + ->and($repeatActionTool->schema->annotations->readOnlyHint)->toBeTrue(); + expect(array_keys($repeatActionTool->schema->inputSchema['properties'] ?? []))->toEqual(['count', 'loudly', 'mode']); + + $invokableCalcTool = $this->registry->getTool('InvokableCalculator'); + expect($invokableCalcTool)->toBeInstanceOf(RegisteredTool::class) + ->and($invokableCalcTool->isManual)->toBeFalse() + ->and($invokableCalcTool->handlerClass)->toBe(\PhpMcp\Server\Tests\Fixtures\Discovery\InvocableToolFixture::class) + ->and($invokableCalcTool->handlerMethod)->toBe('__invoke'); + + expect($this->registry->getTool('private_tool_should_be_ignored'))->toBeNull(); + expect($this->registry->getTool('protected_tool_should_be_ignored'))->toBeNull(); + expect($this->registry->getTool('static_tool_should_be_ignored'))->toBeNull(); + + + // --- Assert Resources --- + $resources = $this->registry->getResources(); + expect($resources)->toHaveCount(3); // app_version, ui_settings_discovered, InvocableResourceFixture + + $appVersionRes = $this->registry->getResource('app://info/version'); + expect($appVersionRes)->toBeInstanceOf(RegisteredResource::class) + ->and($appVersionRes->isManual)->toBeFalse() + ->and($appVersionRes->schema->name)->toBe('app_version') + ->and($appVersionRes->schema->mimeType)->toBe('text/plain'); + + $invokableStatusRes = $this->registry->getResource('invokable://config/status'); + expect($invokableStatusRes)->toBeInstanceOf(RegisteredResource::class) + ->and($invokableStatusRes->isManual)->toBeFalse() + ->and($invokableStatusRes->handlerClass)->toBe(\PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceFixture::class) + ->and($invokableStatusRes->handlerMethod)->toBe('__invoke'); + + + // --- Assert Prompts --- + $prompts = $this->registry->getPrompts(); + expect($prompts)->toHaveCount(3); // creative_story_prompt, simpleQuestionPrompt, InvocablePromptFixture + + $storyPrompt = $this->registry->getPrompt('creative_story_prompt'); + expect($storyPrompt)->toBeInstanceOf(RegisteredPrompt::class) + ->and($storyPrompt->isManual)->toBeFalse() + ->and($storyPrompt->schema->arguments)->toHaveCount(2) // genre, lengthWords + ->and($storyPrompt->getCompletionProvider('genre'))->toBe(CompletionProviderFixture::class); + + $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); // Inferred name + expect($simplePrompt)->toBeInstanceOf(RegisteredPrompt::class) + ->and($simplePrompt->isManual)->toBeFalse(); + + $invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt'); + expect($invokableGreeter)->toBeInstanceOf(RegisteredPrompt::class) + ->and($invokableGreeter->isManual)->toBeFalse() + ->and($invokableGreeter->handlerClass)->toBe(\PhpMcp\Server\Tests\Fixtures\Discovery\InvocablePromptFixture::class); + + + // --- Assert Resource Templates --- + $templates = $this->registry->getResourceTemplates(); + expect($templates)->toHaveCount(3); // product_details_template, getFileContent, InvocableResourceTemplateFixture + + $productTemplate = $this->registry->getResourceTemplate('product://{region}/details/{productId}'); + expect($productTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) + ->and($productTemplate->isManual)->toBeFalse() + ->and($productTemplate->schema->name)->toBe('product_details_template') + ->and($productTemplate->getCompletionProvider('region'))->toBe(CompletionProviderFixture::class); + expect($productTemplate->getVariableNames())->toEqualCanonicalizing(['region', 'productId']); + + $invokableUserTemplate = $this->registry->getResourceTemplate('invokable://user-profile/{userId}'); + expect($invokableUserTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) + ->and($invokableUserTemplate->isManual)->toBeFalse() + ->and($invokableUserTemplate->handlerClass)->toBe(\PhpMcp\Server\Tests\Fixtures\Discovery\InvocableResourceTemplateFixture::class); +}); + +it('does not discover elements from excluded directories', function () { + $this->discoverer->discover($this->fixtureBasePath, ['Discovery']); + + expect($this->registry->getTool('hidden_subdir_tool'))->not->toBeNull(); + + $this->registry->clear(); + + $this->discoverer->discover($this->fixtureBasePath, ['Discovery'], ['SubDir']); + expect($this->registry->getTool('hidden_subdir_tool'))->toBeNull(); +}); + + +it('handles empty directories or directories with no PHP files', function () { + $this->discoverer->discover($this->fixtureBasePath, ['EmptyDir']); + expect($this->registry->getTools())->toBeEmpty(); +}); + +it('correctly infers names and descriptions from methods/classes if not set in attribute', function () { + $this->discoverer->discover($this->fixtureBasePath, ['Discovery']); + + $repeatActionTool = $this->registry->getTool('repeatAction'); + expect($repeatActionTool->schema->name)->toBe('repeatAction'); // Method name + expect($repeatActionTool->schema->description)->toBe('A tool with more complex parameters and inferred name/description.'); // Docblock summary + + $simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt'); + expect($simplePrompt->schema->name)->toBe('simpleQuestionPrompt'); + expect($simplePrompt->schema->description)->toBeNull(); + + $invokableCalc = $this->registry->getTool('InvokableCalculator'); // Name comes from Attr + expect($invokableCalc->schema->name)->toBe('InvokableCalculator'); + expect($invokableCalc->schema->description)->toBe('An invokable calculator tool.'); +}); diff --git a/tests/Integration/HttpServerTransportTest.php b/tests/Integration/HttpServerTransportTest.php new file mode 100644 index 0000000..38b8db2 --- /dev/null +++ b/tests/Integration/HttpServerTransportTest.php @@ -0,0 +1,421 @@ +loop = Loop::get(); + + if (!is_file(HTTP_SERVER_SCRIPT_PATH)) { + $this->markTestSkipped("Server script not found: " . HTTP_SERVER_SCRIPT_PATH); + } + if (!is_executable(HTTP_SERVER_SCRIPT_PATH)) { + chmod(HTTP_SERVER_SCRIPT_PATH, 0755); + } + + $phpPath = PHP_BINARY ?: 'php'; + $commandPhpPath = str_contains($phpPath, ' ') ? '"' . $phpPath . '"' : $phpPath; + $commandArgs = [ + escapeshellarg(HTTP_SERVER_HOST), + escapeshellarg((string)HTTP_SERVER_PORT), + escapeshellarg(HTTP_MCP_PATH_PREFIX) + ]; + $commandScriptPath = escapeshellarg(HTTP_SERVER_SCRIPT_PATH); + $command = $commandPhpPath . ' ' . $commandScriptPath . ' ' . implode(' ', $commandArgs); + + $this->process = new Process($command, getcwd() ?: null, null, []); + $this->process->start($this->loop); + + $this->processErrorOutput = ''; + if ($this->process->stderr instanceof ReadableStreamInterface) { + $this->process->stderr->on('data', function ($chunk) { + $this->processErrorOutput .= $chunk; + }); + } + + return await(delay(0.2, $this->loop)); +}); + +afterEach(function () { + if ($this->sseClient ?? null) { + $this->sseClient->close(); + } + + if ($this->process instanceof Process && $this->process->isRunning()) { + if ($this->process->stdout instanceof ReadableStreamInterface) $this->process->stdout->close(); + if ($this->process->stderr instanceof ReadableStreamInterface) $this->process->stderr->close(); + + $this->process->terminate(SIGTERM); + try { + await(delay(0.02, $this->loop)); + } catch (\Throwable $e) { + } + + if ($this->process->isRunning()) { + $this->process->terminate(SIGKILL); + } + } + $this->process = null; +}); + +afterAll(function () { + // Loop::stop(); +}); + + +it('starts the http server, initializes, calls a tool, and closes', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + + // 1. Connect + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + expect($this->sseClient->endpointUrl)->toBeString(); + expect($this->sseClient->clientId)->toBeString(); + + // 2. Initialize Request + await($this->sseClient->sendHttpRequest('init-http-1', 'initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'HttpPestClient', 'version' => '1.0'], + 'capabilities' => [] + ])); + $initResponse = await($this->sseClient->getNextMessageResponse('init-http-1')); + + expect($initResponse['id'])->toBe('init-http-1'); + expect($initResponse)->not->toHaveKey('error'); + expect($initResponse['result']['protocolVersion'])->toBe(Protocol::LATEST_PROTOCOL_VERSION); + expect($initResponse['result']['serverInfo']['name'])->toBe('HttpIntegrationTestServer'); + + // 3. Initialized Notification + await($this->sseClient->sendHttpNotification('notifications/initialized', ['messageQueueSupported' => true])); + await(delay(0.05, $this->loop)); + + // 4. Call a tool + await($this->sseClient->sendHttpRequest('tool-http-1', 'tools/call', [ + 'name' => 'greet_http_tool', + 'arguments' => ['name' => 'HTTP Integration User'] + ])); + $toolResponse = await($this->sseClient->getNextMessageResponse('tool-http-1')); + + expect($toolResponse['id'])->toBe('tool-http-1'); + expect($toolResponse)->not->toHaveKey('error'); + expect($toolResponse['result']['content'][0]['text'])->toBe('Hello, HTTP Integration User!'); + expect($toolResponse['result']['isError'])->toBeFalse(); + + // 5. Close + $this->sseClient->close(); +})->group('integration', 'http_transport'); + +it('can handle invalid JSON from client', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + + // 1. Connect + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + + expect($this->sseClient->endpointUrl)->toBeString(); + + $malformedJson = '{"jsonrpc":"2.0", "id": "bad-json-1", "method": "tools/list", "params": {"broken"}'; + + // 2. Send invalid JSON + $postPromise = $this->sseClient->browser->post( + $this->sseClient->endpointUrl, + ['Content-Type' => 'application/json'], + $malformedJson + ); + + // 3. Expect error response + try { + await(timeout($postPromise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop)); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(400); + + $errorResponse = json_decode($e->getResponse()->getBody(), true); + expect($errorResponse['jsonrpc'])->toBe('2.0'); + expect($errorResponse['id'])->toBe(''); + expect($errorResponse['error']['code'])->toBe(-32700); + expect($errorResponse['error']['message'])->toContain('Invalid JSON-RPC message'); + } +})->group('integration', 'http_transport'); + + +it('can handle request for non-existent method after initialization', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + + // 1. Connect + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + expect($this->sseClient->endpointUrl)->toBeString(); + + // 2. Initialize Request + await($this->sseClient->sendHttpRequest('init-http-nonexist', 'initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'Test'], + 'capabilities' => [] + ])); + await($this->sseClient->getNextMessageResponse('init-http-nonexist')); + await($this->sseClient->sendHttpNotification('notifications/initialized', ['messageQueueSupported' => true])); + await(delay(0.05, $this->loop)); + + // 3. Send request for non-existent method + await($this->sseClient->sendHttpRequest('err-meth-http-1', 'non/existentHttpTool', [])); + $errorResponse = await($this->sseClient->getNextMessageResponse('err-meth-http-1')); + + // 4. Expect error response + expect($errorResponse['id'])->toBe('err-meth-http-1'); + expect($errorResponse['error']['code'])->toBe(-32601); + expect($errorResponse['error']['message'])->toContain("Method 'non/existentHttpTool' not found"); +})->group('integration', 'http_transport'); + +it('can handle batch requests correctly over HTTP/SSE', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + + // 1. Connect + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + expect($this->sseClient->endpointUrl)->toBeString(); + + // 2. Initialize Request + await($this->sseClient->sendHttpRequest('init-batch-http', 'initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'HttpBatchClient', 'version' => '1.0'], + 'capabilities' => [] + ])); + await($this->sseClient->getNextMessageResponse('init-batch-http')); + + // 3. Initialized notification + await($this->sseClient->sendHttpNotification('notifications/initialized', ['messageQueueSupported' => true])); + await(delay(0.05, $this->loop)); + + // 4. Send Batch Request + $batchRequests = [ + ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_http_tool', 'arguments' => ['name' => 'Batch Item 1']]], + ['jsonrpc' => '2.0', 'method' => 'notifications/something'], + ['jsonrpc' => '2.0', 'id' => 'batch-req-2', 'method' => 'tools/call', 'params' => ['name' => 'greet_http_tool', 'arguments' => ['name' => 'Batch Item 2']]], + ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method'] + ]; + + await($this->sseClient->sendHttpBatchRequest($batchRequests)); + + // 5. Read Batch Response + $batchResponseArray = await($this->sseClient->getNextBatchMessageResponse(3)); + + expect($batchResponseArray)->toBeArray()->toHaveCount(3); + + $findResponseById = function (array $batch, $id) { + foreach ($batch as $item) { + if (isset($item['id']) && $item['id'] === $id) { + return $item; + } + } + return null; + }; + + $response1 = $findResponseById($batchResponseArray, 'batch-req-1'); + $response2 = $findResponseById($batchResponseArray, 'batch-req-2'); + $response3 = $findResponseById($batchResponseArray, 'batch-req-3'); + + expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!'); + expect($response2['result']['content'][0]['text'])->toBe('Hello, Batch Item 2!'); + expect($response3['error']['code'])->toBe(-32601); + expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); + + $this->sseClient->close(); +})->group('integration', 'http_transport'); + + +it('can handle tool list request over HTTP/SSE', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + + await($this->sseClient->sendHttpRequest('init-http-tools', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []])); + await($this->sseClient->getNextMessageResponse('init-http-tools')); + await($this->sseClient->sendHttpNotification('notifications/initialized')); + await(delay(0.1, $this->loop)); + + await($this->sseClient->sendHttpRequest('tool-list-http-1', 'tools/list', [])); + $toolListResponse = await($this->sseClient->getNextMessageResponse('tool-list-http-1')); + + expect($toolListResponse['id'])->toBe('tool-list-http-1'); + expect($toolListResponse)->not->toHaveKey('error'); + expect($toolListResponse['result']['tools'])->toBeArray()->toHaveCount(1); + expect($toolListResponse['result']['tools'][0]['name'])->toBe('greet_http_tool'); + + $this->sseClient->close(); +})->group('integration', 'http_transport'); + +it('can read a registered resource over HTTP/SSE', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + + await($this->sseClient->sendHttpRequest('init-http-res', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []])); + await($this->sseClient->getNextMessageResponse('init-http-res')); + await($this->sseClient->sendHttpNotification('notifications/initialized')); + await(delay(0.1, $this->loop)); + + await($this->sseClient->sendHttpRequest('res-read-http-1', 'resources/read', ['uri' => 'test://http/static'])); + $resourceResponse = await($this->sseClient->getNextMessageResponse('res-read-http-1')); + + expect($resourceResponse['id'])->toBe('res-read-http-1'); + expect($resourceResponse)->not->toHaveKey('error'); + expect($resourceResponse['result']['contents'])->toBeArray()->toHaveCount(1); + expect($resourceResponse['result']['contents'][0]['uri'])->toBe('test://http/static'); + expect($resourceResponse['result']['contents'][0]['text'])->toBe(ResourceHandlerFixture::$staticTextContent); + expect($resourceResponse['result']['contents'][0]['mimeType'])->toBe('text/plain'); + + $this->sseClient->close(); +})->group('integration', 'http_transport'); + +it('can get a registered prompt over HTTP/SSE', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + + await($this->sseClient->sendHttpRequest('init-http-prompt', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []])); + await($this->sseClient->getNextMessageResponse('init-http-prompt')); + await($this->sseClient->sendHttpNotification('notifications/initialized')); + await(delay(0.1, $this->loop)); + + await($this->sseClient->sendHttpRequest('prompt-get-http-1', 'prompts/get', [ + 'name' => 'simple_http_prompt', + 'arguments' => ['name' => 'HttpPromptUser', 'style' => 'polite'] + ])); + $promptResponse = await($this->sseClient->getNextMessageResponse('prompt-get-http-1')); + + expect($promptResponse['id'])->toBe('prompt-get-http-1'); + expect($promptResponse)->not->toHaveKey('error'); + expect($promptResponse['result']['messages'])->toBeArray()->toHaveCount(1); + expect($promptResponse['result']['messages'][0]['role'])->toBe('user'); + expect($promptResponse['result']['messages'][0]['content']['text'])->toBe('Craft a polite greeting for HttpPromptUser.'); + + $this->sseClient->close(); +})->group('integration', 'http_transport'); + +it('rejects subsequent requests if client does not send initialized notification', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + + // 1. Send Initialize + await($this->sseClient->sendHttpRequest('init-http-no-ack', 'initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'HttpForgetfulClient', 'version' => '1.0'], + 'capabilities' => [] + ])); + await($this->sseClient->getNextMessageResponse('init-http-no-ack')); + // Client "forgets" to send notifications/initialized back + + await(delay(0.1, $this->loop)); + + // 2. Attempt to Call a tool + await($this->sseClient->sendHttpRequest('tool-call-http-no-ack', 'tools/call', [ + 'name' => 'greet_http_tool', + 'arguments' => ['name' => 'NoAckHttpUser'] + ])); + $toolResponse = await($this->sseClient->getNextMessageResponse('tool-call-http-no-ack')); + + expect($toolResponse['id'])->toBe('tool-call-http-no-ack'); + expect($toolResponse['error']['code'])->toBe(-32600); // Invalid Request + expect($toolResponse['error']['message'])->toContain('Client session not initialized'); + + $this->sseClient->close(); +})->group('integration', 'http_transport'); + +it('returns 404 for POST to /message without valid clientId in query', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + $validEndpointUrl = $this->sseClient->endpointUrl; + $this->sseClient->close(); + + $malformedEndpoint = (string) (new Uri($validEndpointUrl))->withQuery(''); + + $payload = ['jsonrpc' => '2.0', 'id' => 'post-no-clientid', 'method' => 'ping', 'params' => []]; + $postPromise = $this->sseClient->browser->post( + $malformedEndpoint, + ['Content-Type' => 'application/json'], + json_encode($payload) + ); + + try { + await(timeout($postPromise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop)); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(400); + $bodyContent = (string) $e->getResponse()->getBody(); + $errorData = json_decode($bodyContent, true); + expect($errorData['error']['message'])->toContain('Missing or invalid clientId'); + } +})->group('integration', 'http_transport'); + + +it('returns 404 for POST to /message with clientId for a disconnected SSE stream', function () { + $this->sseClient = new MockSseClient(); + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + + await($this->sseClient->connect($sseBaseUrl)); + await(delay(0.05, $this->loop)); + $originalEndpointUrl = $this->sseClient->endpointUrl; + $this->sseClient->close(); + + await(delay(0.1, $this->loop)); + + $payload = ['jsonrpc' => '2.0', 'id' => 'post-stale-clientid', 'method' => 'ping', 'params' => []]; + $postPromise = $this->sseClient->browser->post( + $originalEndpointUrl, + ['Content-Type' => 'application/json'], + json_encode($payload) + ); + + try { + await(timeout($postPromise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop)); + } catch (ResponseException $e) { + $bodyContent = (string) $e->getResponse()->getBody(); + $errorData = json_decode($bodyContent, true); + expect($errorData['error']['message'])->toContain('Session ID not found or disconnected'); + } +})->group('integration', 'http_transport'); + +it('returns 404 for unknown paths', function () { + $browser = new Browser($this->loop); + $unknownUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/unknown/path"; + + $promise = $browser->get($unknownUrl); + + try { + await(timeout($promise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop)); + $this->fail("Request to unknown path should have failed with 404."); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(404); + $body = (string) $e->getResponse()->getBody(); + expect($body)->toContain("Not Found"); + } catch (\Throwable $e) { + $this->fail("Request to unknown path failed with unexpected error: " . $e->getMessage()); + } +})->group('integration', 'http_transport'); diff --git a/tests/Integration/StdioServerTransportTest.php b/tests/Integration/StdioServerTransportTest.php new file mode 100644 index 0000000..f7b0db5 --- /dev/null +++ b/tests/Integration/StdioServerTransportTest.php @@ -0,0 +1,326 @@ + '2.0', + 'id' => $requestId, + 'method' => $method, + 'params' => $params, + ]); + $process->stdin->write($request . "\n"); +} + +function sendNotificationToServer(Process $process, string $method, array $params = []): void +{ + $notification = json_encode([ + 'jsonrpc' => '2.0', + 'method' => $method, + 'params' => $params, + ]); + + $process->stdin->write($notification . "\n"); +} + +function readResponseFromServer(Process $process, string $expectedRequestId, LoopInterface $loop): PromiseInterface +{ + $deferred = new Deferred(); + $buffer = ''; + + $dataListener = function ($chunk) use (&$buffer, $deferred, $expectedRequestId, $process, &$dataListener) { + $buffer .= $chunk; + if (str_contains($buffer, "\n")) { + $lines = explode("\n", $buffer); + $buffer = array_pop($lines); + + foreach ($lines as $line) { + if (empty(trim($line))) continue; + try { + $response = json_decode(trim($line), true); + if (array_key_exists('id', $response) && $response['id'] == $expectedRequestId) { + $process->stdout->removeListener('data', $dataListener); + $deferred->resolve($response); + return; + } else if (isset($response['method']) && str_starts_with($response['method'], 'notifications/')) { + // It's a notification, log it or handle if necessary for a specific test, but don't resolve + } + } catch (\JsonException $e) { + $process->stdout->removeListener('data', $dataListener); + $deferred->reject(new \RuntimeException("Failed to decode JSON response: " . $line, 0, $e)); + return; + } + } + } + }; + + $process->stdout->on('data', $dataListener); + + return timeout($deferred->promise(), PROCESS_TIMEOUT_SECONDS, $loop) + ->catch(function ($reason) use ($expectedRequestId) { + if ($reason instanceof \RuntimeException && str_contains($reason->getMessage(), 'Timed out after')) { + throw new \RuntimeException("Timeout waiting for response to request ID '{$expectedRequestId}'"); + } + throw $reason; + }) + ->finally(function () use ($process, $dataListener) { + $process->stdout->removeListener('data', $dataListener); + }); +} + +beforeEach(function () { + $this->loop = Loop::get(); + + if (!is_executable(STDIO_SERVER_SCRIPT_PATH)) { + chmod(STDIO_SERVER_SCRIPT_PATH, 0755); + } + + $phpPath = PHP_BINARY ?: 'php'; + $command = escapeshellarg($phpPath) . ' ' . escapeshellarg(STDIO_SERVER_SCRIPT_PATH); + $this->process = new Process($command); + $this->process->start($this->loop); + + $this->processErrorOutput = ''; + $this->process->stderr->on('data', function ($chunk) { + $this->processErrorOutput .= $chunk; + }); +}); + +afterEach(function () { + if ($this->process instanceof Process && $this->process->isRunning()) { + if ($this->process->stdin->isWritable()) { + $this->process->stdin->end(); + } + $this->process->stdout->close(); + $this->process->stdin->close(); + $this->process->stderr->close(); + $this->process->terminate(SIGTERM); + await(delay(0.05, $this->loop)); + if ($this->process->isRunning()) { + $this->process->terminate(SIGKILL); + } + } + $this->process = null; +}); + + +it('starts the stdio server, initializes, calls a tool, and closes', function () { + // 1. Initialize Request + sendRequestToServer($this->process, 'init-1', 'initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'PestTestClient', 'version' => '1.0'], + 'capabilities' => [] + ]); + $initResponse = await(readResponseFromServer($this->process, 'init-1', $this->loop)); + + expect($initResponse['id'])->toBe('init-1'); + expect($initResponse)->not->toHaveKey('error'); + expect($initResponse['result']['protocolVersion'])->toBe(Protocol::LATEST_PROTOCOL_VERSION); + expect($initResponse['result']['serverInfo']['name'])->toBe('StdioIntegrationTestServer'); + + // 2. Initialized Notification + sendNotificationToServer($this->process, 'notifications/initialized'); + + await(delay(0.05, $this->loop)); + + // 3. Call a tool + sendRequestToServer($this->process, 'tool-call-1', 'tools/call', [ + 'name' => 'greet_stdio_tool', + 'arguments' => ['name' => 'Integration Tester'] + ]); + $toolResponse = await(readResponseFromServer($this->process, 'tool-call-1', $this->loop)); + + expect($toolResponse['id'])->toBe('tool-call-1'); + expect($toolResponse)->not->toHaveKey('error'); + expect($toolResponse['result']['content'][0]['text'])->toBe('Hello, Integration Tester!'); + expect($toolResponse['result']['isError'])->toBeFalse(); + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); + + +it('can handle invalid JSON request from client', function () { + $this->process->stdin->write("this is not json\n"); + + $response = await(readResponseFromServer($this->process, '', $this->loop)); + + expect($response['id'])->toBe(''); + expect($response['error']['code'])->toBe(-32700); + expect($response['error']['message'])->toContain('Invalid JSON'); + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); + + +it('handles request for non-existent method', function () { + sendRequestToServer($this->process, 'init-err', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); + await(readResponseFromServer($this->process, 'init-err', $this->loop)); + + sendNotificationToServer($this->process, 'notifications/initialized'); + await(delay(0.05, $this->loop)); + + sendRequestToServer($this->process, 'err-meth-1', 'non/existentMethod', []); + $response = await(readResponseFromServer($this->process, 'err-meth-1', $this->loop)); + + expect($response['id'])->toBe('err-meth-1'); + expect($response['error']['code'])->toBe(-32601); + expect($response['error']['message'])->toContain("Method 'non/existentMethod' not found"); + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); + +it('can handle batch requests correctly', function () { + // 1. Initialize + sendRequestToServer($this->process, 'init-batch', 'initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'BatchClient', 'version' => '1.0'], + 'capabilities' => [] + ]); + await(readResponseFromServer($this->process, 'init-batch', $this->loop)); + sendNotificationToServer($this->process, 'notifications/initialized'); + await(delay(0.05, $this->loop)); + + // 2. Send Batch Request + $batchRequests = [ + ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_stdio_tool', 'arguments' => ['name' => 'Batch Item 1']]], + ['jsonrpc' => '2.0', 'method' => 'notifications/something'], + ['jsonrpc' => '2.0', 'id' => 'batch-req-2', 'method' => 'tools/call', 'params' => ['name' => 'greet_stdio_tool', 'arguments' => ['name' => 'Batch Item 2']]], + ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method'] + ]; + + $rawBatchRequest = json_encode($batchRequests); + $this->process->stdin->write($rawBatchRequest . "\n"); + + // 3. Read Batch Response + $batchResponsePromise = new Deferred(); + $fullBuffer = ''; + $batchDataListener = function ($chunk) use (&$fullBuffer, $batchResponsePromise, &$batchDataListener) { + $fullBuffer .= $chunk; + if (str_contains($fullBuffer, "\n")) { + $line = trim($fullBuffer); + $fullBuffer = ''; + try { + $decoded = json_decode($line, true); + if (is_array($decoded)) { // Batch response is an array + $this->process->stdout->removeListener('data', $batchDataListener); + $batchResponsePromise->resolve($decoded); + } + } catch (\JsonException $e) { + $this->process->stdout->removeListener('data', $batchDataListener); + $batchResponsePromise->reject(new \RuntimeException("Batch JSON decode failed: " . $line, 0, $e)); + } + } + }; + $this->process->stdout->on('data', $batchDataListener); + + $batchResponseArray = await(timeout($batchResponsePromise->promise(), PROCESS_TIMEOUT_SECONDS, $this->loop)); + + expect($batchResponseArray)->toBeArray()->toHaveCount(3); // greet1, greet2, error + + $response1 = array_values(array_filter($batchResponseArray, fn($response) => $response['id'] === 'batch-req-1'))[0] ?? null; + $response2 = array_values(array_filter($batchResponseArray, fn($response) => $response['id'] === 'batch-req-2'))[0] ?? null; + $response3 = array_values(array_filter($batchResponseArray, fn($response) => $response['id'] === 'batch-req-3'))[0] ?? null; + + expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!'); + expect($response2['result']['content'][0]['text'])->toBe('Hello, Batch Item 2!'); + expect($response3['error']['code'])->toBe(-32601); + expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); + + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); + + +it('can handle tool list request', function () { + sendRequestToServer($this->process, 'init-tool-list', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); + await(readResponseFromServer($this->process, 'init-tool-list', $this->loop)); + sendNotificationToServer($this->process, 'notifications/initialized'); + await(delay(0.05, $this->loop)); + + sendRequestToServer($this->process, 'tool-list-1', 'tools/list', []); + $toolListResponse = await(readResponseFromServer($this->process, 'tool-list-1', $this->loop)); + + expect($toolListResponse['id'])->toBe('tool-list-1'); + expect($toolListResponse)->not->toHaveKey('error'); + expect($toolListResponse['result']['tools'])->toBeArray()->toHaveCount(1); + expect($toolListResponse['result']['tools'][0]['name'])->toBe('greet_stdio_tool'); + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); + +it('can read a registered resource', function () { + sendRequestToServer($this->process, 'init-res', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); + await(readResponseFromServer($this->process, 'init-res', $this->loop)); + sendNotificationToServer($this->process, 'notifications/initialized'); + await(delay(0.05, $this->loop)); + + sendRequestToServer($this->process, 'res-read-1', 'resources/read', ['uri' => 'test://stdio/static']); + $resourceResponse = await(readResponseFromServer($this->process, 'res-read-1', $this->loop)); + + expect($resourceResponse['id'])->toBe('res-read-1'); + expect($resourceResponse)->not->toHaveKey('error'); + expect($resourceResponse['result']['contents'])->toBeArray()->toHaveCount(1); + expect($resourceResponse['result']['contents'][0]['uri'])->toBe('test://stdio/static'); + expect($resourceResponse['result']['contents'][0]['text'])->toBe(ResourceHandlerFixture::$staticTextContent); + expect($resourceResponse['result']['contents'][0]['mimeType'])->toBe('text/plain'); + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); + +it('can get a registered prompt', function () { + sendRequestToServer($this->process, 'init-prompt', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); + await(readResponseFromServer($this->process, 'init-prompt', $this->loop)); + sendNotificationToServer($this->process, 'notifications/initialized'); + await(delay(0.05, $this->loop)); + + sendRequestToServer($this->process, 'prompt-get-1', 'prompts/get', [ + 'name' => 'simple_stdio_prompt', + 'arguments' => ['name' => 'StdioPromptUser'] + ]); + $promptResponse = await(readResponseFromServer($this->process, 'prompt-get-1', $this->loop)); + + expect($promptResponse['id'])->toBe('prompt-get-1'); + expect($promptResponse)->not->toHaveKey('error'); + expect($promptResponse['result']['messages'])->toBeArray()->toHaveCount(1); + expect($promptResponse['result']['messages'][0]['role'])->toBe('user'); + expect($promptResponse['result']['messages'][0]['content']['text'])->toBe('Craft a friendly greeting for StdioPromptUser.'); + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); + +it('handles client not sending initialized notification before other requests', function () { + sendRequestToServer($this->process, 'init-no-ack', 'initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'ForgetfulClient', 'version' => '1.0'], + 'capabilities' => [] + ]); + await(readResponseFromServer($this->process, 'init-no-ack', $this->loop)); + // Client "forgets" to send notifications/initialized + + + sendRequestToServer($this->process, 'tool-call-no-ack', 'tools/call', [ + 'name' => 'greet_stdio_tool', + 'arguments' => ['name' => 'NoAckUser'] + ]); + $toolResponse = await(readResponseFromServer($this->process, 'tool-call-no-ack', $this->loop)); + + expect($toolResponse['id'])->toBe('tool-call-no-ack'); + expect($toolResponse['error']['code'])->toBe(-32600); + expect($toolResponse['error']['message'])->toContain('Client session not initialized'); + + $this->process->stdin->end(); +})->group('integration', 'stdio_transport'); diff --git a/tests/Integration/StreamableHttpServerTransportTest.php b/tests/Integration/StreamableHttpServerTransportTest.php new file mode 100644 index 0000000..b2abeec --- /dev/null +++ b/tests/Integration/StreamableHttpServerTransportTest.php @@ -0,0 +1,572 @@ +markTestSkipped("Server script not found: " . STREAMABLE_HTTP_SCRIPT_PATH); + } + if (!is_executable(STREAMABLE_HTTP_SCRIPT_PATH)) { + chmod(STREAMABLE_HTTP_SCRIPT_PATH, 0755); + } + + $phpPath = PHP_BINARY ?: 'php'; + $commandPhpPath = str_contains($phpPath, ' ') ? '"' . $phpPath . '"' : $phpPath; + $commandScriptPath = escapeshellarg(STREAMABLE_HTTP_SCRIPT_PATH); + + $jsonModeCommandArgs = [ + escapeshellarg(STREAMABLE_HTTP_HOST), + escapeshellarg((string)STREAMABLE_HTTP_PORT), + escapeshellarg(STREAMABLE_MCP_PATH), + escapeshellarg('true'), // enableJsonResponse = true + ]; + $this->jsonModeCommand = $commandPhpPath . ' ' . $commandScriptPath . ' ' . implode(' ', $jsonModeCommandArgs); + + $streamModeCommandArgs = [ + escapeshellarg(STREAMABLE_HTTP_HOST), + escapeshellarg((string)STREAMABLE_HTTP_PORT), + escapeshellarg(STREAMABLE_MCP_PATH), + escapeshellarg('false'), // enableJsonResponse = false + ]; + $this->streamModeCommand = $commandPhpPath . ' ' . $commandScriptPath . ' ' . implode(' ', $streamModeCommandArgs); + + $this->process = null; +}); + +afterEach(function () { + if ($this->process instanceof Process && $this->process->isRunning()) { + if ($this->process->stdout instanceof ReadableStreamInterface) $this->process->stdout->close(); + if ($this->process->stderr instanceof ReadableStreamInterface) $this->process->stderr->close(); + + $this->process->terminate(SIGTERM); + try { + await(delay(0.02)); + } catch (\Throwable $e) { + } + if ($this->process->isRunning()) { + $this->process->terminate(SIGKILL); + } + } + $this->process = null; +}); + + +describe('JSON MODE', function () { + beforeEach(function () { + $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); + $this->process->start(); + + $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, STREAMABLE_HTTP_PORT, STREAMABLE_MCP_PATH); + + await(delay(0.2)); + }); + + it('server starts, initializes via POST JSON, calls a tool, and closes', function () { + // 1. Initialize + $initResult = await($this->jsonClient->sendRequest('initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-json-1')); + + expect($initResult['statusCode'])->toBe(200); + expect($initResult['body']['id'])->toBe('init-json-1'); + expect($initResult['body'])->not->toHaveKey('error'); + expect($initResult['body']['result']['protocolVersion'])->toBe(Protocol::LATEST_PROTOCOL_VERSION); + expect($initResult['body']['result']['serverInfo']['name'])->toBe('StreamableHttpIntegrationServer'); + expect($this->jsonClient->sessionId)->toBeString()->not->toBeEmpty(); + + // 2. Initialized notification + $notifResult = await($this->jsonClient->sendNotification('notifications/initialized')); + expect($notifResult['statusCode'])->toBe(202); + + // 3. Call a tool + $toolResult = await($this->jsonClient->sendRequest('tools/call', [ + 'name' => 'greet_streamable_tool', + 'arguments' => ['name' => 'JSON Mode User'] + ], 'tool-json-1')); + + expect($toolResult['statusCode'])->toBe(200); + expect($toolResult['body']['id'])->toBe('tool-json-1'); + expect($toolResult['body'])->not->toHaveKey('error'); + expect($toolResult['body']['result']['content'][0]['text'])->toBe('Hello, JSON Mode User!'); + + // Server process is terminated in afterEach + })->group('integration', 'streamable_http_json'); + + + it('return HTTP 400 error response for invalid JSON in POST request', function () { + $malformedJson = '{"jsonrpc":"2.0", "id": "bad-json-post-1", "method": "tools/list", "params": {"broken"}'; + + $promise = $this->jsonClient->browser->post( + $this->jsonClient->baseUrl, + ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + $malformedJson + ); + + try { + await(timeout($promise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(400); + $bodyContent = (string) $e->getResponse()->getBody(); + $decodedBody = json_decode($bodyContent, true); + + expect($decodedBody['jsonrpc'])->toBe('2.0'); + expect($decodedBody['id'])->toBe(''); + expect($decodedBody['error']['code'])->toBe(-32700); + expect($decodedBody['error']['message'])->toContain('Invalid JSON'); + } + })->group('integration', 'streamable_http_json'); + + it('returns JSON-RPC error result for request for non-existent method', function () { + // 1. Initialize + await($this->jsonClient->sendRequest('initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 'capabilities' => []], 'init-json-err')); + await($this->jsonClient->sendNotification('notifications/initialized')); + + // 2. Request non-existent method + $errorResult = await($this->jsonClient->sendRequest('non/existentToolViaJson', [], 'err-meth-json-1')); + + expect($errorResult['statusCode'])->toBe(200); + expect($errorResult['body']['id'])->toBe('err-meth-json-1'); + expect($errorResult['body']['error']['code'])->toBe(-32601); + expect($errorResult['body']['error']['message'])->toContain("Method 'non/existentToolViaJson' not found"); + })->group('integration', 'streamable_http_json'); + + it('can handle batch requests correctly', function () { + // 1. Initialize + await($this->jsonClient->sendRequest('initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-json-batch')); + expect($this->jsonClient->sessionId)->toBeString()->not->toBeEmpty(); + await($this->jsonClient->sendNotification('notifications/initialized')); + + // 2. Send Batch Request + $batchRequests = [ + ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_streamable_tool', 'arguments' => ['name' => 'Batch Item 1']]], + ['jsonrpc' => '2.0', 'method' => 'notifications/something'], + ['jsonrpc' => '2.0', 'id' => 'batch-req-2', 'method' => 'tools/call', 'params' => ['name' => 'sum_streamable_tool', 'arguments' => ['a' => 10, 'b' => 20]]], + ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method'] + ]; + + $batchResponse = await($this->jsonClient->sendBatchRequest($batchRequests)); + + + + $findResponseById = function (array $batch, $id) { + foreach ($batch as $item) { + if (isset($item['id']) && $item['id'] === $id) { + return $item; + } + } + return null; + }; + + expect($batchResponse['statusCode'])->toBe(200); + expect($batchResponse['body'])->toBeArray()->toHaveCount(3); + + $response1 = $findResponseById($batchResponse['body'], 'batch-req-1'); + $response2 = $findResponseById($batchResponse['body'], 'batch-req-2'); + $response3 = $findResponseById($batchResponse['body'], 'batch-req-3'); + + expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!'); + expect($response2['result']['content'][0]['text'])->toBe('30'); + expect($response3['error']['code'])->toBe(-32601); + expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); + })->group('integration', 'streamable_http_json'); + + it('can handle tool list request', function () { + await($this->jsonClient->sendRequest('initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-json-tools')); + await($this->jsonClient->sendNotification('notifications/initialized')); + + $toolListResult = await($this->jsonClient->sendRequest('tools/list', [], 'tool-list-json-1')); + + expect($toolListResult['statusCode'])->toBe(200); + expect($toolListResult['body']['id'])->toBe('tool-list-json-1'); + expect($toolListResult['body']['result']['tools'])->toBeArray(); + expect(count($toolListResult['body']['result']['tools']))->toBe(2); + expect($toolListResult['body']['result']['tools'][0]['name'])->toBe('greet_streamable_tool'); + expect($toolListResult['body']['result']['tools'][1]['name'])->toBe('sum_streamable_tool'); + })->group('integration', 'streamable_http_json'); + + it('can read a registered resource', function () { + await($this->jsonClient->sendRequest('initialize', [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-json-res')); + await($this->jsonClient->sendNotification('notifications/initialized')); + + $resourceResult = await($this->jsonClient->sendRequest('resources/read', ['uri' => 'test://streamable/static'], 'res-read-json-1')); + + expect($resourceResult['statusCode'])->toBe(200); + expect($resourceResult['body']['id'])->toBe('res-read-json-1'); + $contents = $resourceResult['body']['result']['contents']; + expect($contents[0]['uri'])->toBe('test://streamable/static'); + expect($contents[0]['text'])->toBe(\PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture::$staticTextContent); + })->group('integration', 'streamable_http_json'); + + it('can get a registered prompt', function () { + await($this->jsonClient->sendRequest('initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 'capabilities' => []], 'init-json-prompt')); + await($this->jsonClient->sendNotification('notifications/initialized')); + + $promptResult = await($this->jsonClient->sendRequest('prompts/get', [ + 'name' => 'simple_streamable_prompt', + 'arguments' => ['name' => 'JsonPromptUser', 'style' => 'terse'] + ], 'prompt-get-json-1')); + + expect($promptResult['statusCode'])->toBe(200); + expect($promptResult['body']['id'])->toBe('prompt-get-json-1'); + $messages = $promptResult['body']['result']['messages']; + expect($messages[0]['content']['text'])->toBe('Craft a terse greeting for JsonPromptUser.'); + })->group('integration', 'streamable_http_json'); + + it('rejects subsequent requests if client does not send initialized notification', function () { + // 1. Initialize ONLY + await($this->jsonClient->sendRequest('initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 'capabilities' => []], 'init-json-noack')); + // Client "forgets" to send notifications/initialized back + + // 2. Attempt to Call a tool + $toolResult = await($this->jsonClient->sendRequest('tools/call', [ + 'name' => 'greet_streamable_tool', + 'arguments' => ['name' => 'NoAckJsonUser'] + ], 'tool-json-noack')); + + expect($toolResult['statusCode'])->toBe(200); // HTTP is fine + expect($toolResult['body']['id'])->toBe('tool-json-noack'); + expect($toolResult['body']['error']['code'])->toBe(-32600); // Invalid Request + expect($toolResult['body']['error']['message'])->toContain('Client session not initialized'); + })->group('integration', 'streamable_http_json'); + + it('returns HTTP 400 error for non-initialize requests without Mcp-Session-Id', function () { + await($this->jsonClient->sendRequest('initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 'capabilities' => []], 'init-sess-test')); + $this->jsonClient->sessionId = null; + + try { + await($this->jsonClient->sendRequest('tools/list', [], 'tools-list-no-session')); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(400); + $bodyContent = (string) $e->getResponse()->getBody(); + $decodedBody = json_decode($bodyContent, true); + + expect($decodedBody['jsonrpc'])->toBe('2.0'); + expect($decodedBody['id'])->toBe('tools-list-no-session'); + expect($decodedBody['error']['code'])->toBe(-32600); + expect($decodedBody['error']['message'])->toContain('Mcp-Session-Id header required'); + } + })->group('integration', 'streamable_http_json'); +}); + +describe('STREAM MODE', function () { + beforeEach(function () { + $this->process = new Process($this->streamModeCommand, getcwd() ?: null, null, []); + $this->process->start(); + $this->streamClient = new MockStreamHttpClient(STREAMABLE_HTTP_HOST, STREAMABLE_HTTP_PORT, STREAMABLE_MCP_PATH); + await(delay(0.2)); + }); + afterEach(function () { + if ($this->streamClient ?? null) { + $this->streamClient->closeMainSseStream(); + } + }); + + it('server starts, initializes via POST JSON, calls a tool, and closes', function () { + // 1. Initialize Request + $initResponse = await($this->streamClient->sendInitializeRequest([ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'StreamModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-stream-1')); + + expect($this->streamClient->sessionId)->toBeString()->not->toBeEmpty(); + expect($initResponse['id'])->toBe('init-stream-1'); + expect($initResponse)->not->toHaveKey('error'); + expect($initResponse['result']['protocolVersion'])->toBe(Protocol::LATEST_PROTOCOL_VERSION); + expect($initResponse['result']['serverInfo']['name'])->toBe('StreamableHttpIntegrationServer'); + + // 2. Send Initialized Notification + $notifResult = await($this->streamClient->sendHttpNotification('notifications/initialized')); + expect($notifResult['statusCode'])->toBe(202); + + // 3. Call a tool + $toolResponse = await($this->streamClient->sendRequest('tools/call', [ + 'name' => 'greet_streamable_tool', + 'arguments' => ['name' => 'Stream Mode User'] + ], 'tool-stream-1')); + + expect($toolResponse['id'])->toBe('tool-stream-1'); + expect($toolResponse)->not->toHaveKey('error'); + expect($toolResponse['result']['content'][0]['text'])->toBe('Hello, Stream Mode User!'); + })->group('integration', 'streamable_http_stream'); + + it('return HTTP 400 error response for invalid JSON in POST request', function () { + $malformedJson = '{"jsonrpc":"2.0", "id": "bad-json-stream-1", "method": "tools/list", "params": {"broken"}'; + + $postPromise = $this->streamClient->browser->post( + $this->streamClient->baseMcpUrl, + ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream'], + $malformedJson + ); + + try { + await(timeout($postPromise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); + } catch (ResponseException $e) { + $httpResponse = $e->getResponse(); + $bodyContent = (string) $httpResponse->getBody(); + $decodedBody = json_decode($bodyContent, true); + + expect($httpResponse->getStatusCode())->toBe(400); + expect($decodedBody['jsonrpc'])->toBe('2.0'); + expect($decodedBody['id'])->toBe(''); + expect($decodedBody['error']['code'])->toBe(-32700); + expect($decodedBody['error']['message'])->toContain('Invalid JSON'); + } + })->group('integration', 'streamable_http_stream'); + + it('returns JSON-RPC error result for request for non-existent method', function () { + // 1. Initialize + await($this->streamClient->sendInitializeRequest([ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'StreamModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-stream-err')); + await($this->streamClient->sendHttpNotification('notifications/initialized')); + + // 2. Send Request + $errorResponse = await($this->streamClient->sendRequest('non/existentToolViaStream', [], 'err-meth-stream-1')); + + expect($errorResponse['id'])->toBe('err-meth-stream-1'); + expect($errorResponse['error']['code'])->toBe(-32601); + expect($errorResponse['error']['message'])->toContain("Method 'non/existentToolViaStream' not found"); + })->group('integration', 'streamable_http_stream'); + + it('can handle batch requests correctly', function () { + await($this->streamClient->sendInitializeRequest([ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'StreamModeBatchClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-stream-batch')); + expect($this->streamClient->sessionId)->toBeString()->not->toBeEmpty(); + await($this->streamClient->sendHttpNotification('notifications/initialized')); + + $batchRequests = [ + ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_streamable_tool', 'arguments' => ['name' => 'Batch Item 1']]], + ['jsonrpc' => '2.0', 'method' => 'notifications/something'], + ['jsonrpc' => '2.0', 'id' => 'batch-req-2', 'method' => 'tools/call', 'params' => ['name' => 'sum_streamable_tool', 'arguments' => ['a' => 10, 'b' => 20]]], + ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method'] + ]; + + $batchResponseArray = await($this->streamClient->sendBatchRequest($batchRequests)); + + expect($batchResponseArray)->toBeArray()->toHaveCount(3); + + $findResponseById = function (array $batch, $id) { + foreach ($batch as $item) { + if (isset($item['id']) && $item['id'] === $id) { + return $item; + } + } + return null; + }; + + $response1 = $findResponseById($batchResponseArray, 'batch-req-1'); + $response2 = $findResponseById($batchResponseArray, 'batch-req-2'); + $response3 = $findResponseById($batchResponseArray, 'batch-req-3'); + + expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!'); + expect($response2['result']['content'][0]['text'])->toBe('30'); + expect($response3['error']['code'])->toBe(-32601); + expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); + })->group('integration', 'streamable_http_stream'); + + it('can handle tool list request', function () { + await($this->streamClient->sendInitializeRequest(['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => []], 'init-stream-tools')); + await($this->streamClient->sendHttpNotification('notifications/initialized')); + + $toolListResponse = await($this->streamClient->sendRequest('tools/list', [], 'tool-list-stream-1')); + + expect($toolListResponse['id'])->toBe('tool-list-stream-1'); + expect($toolListResponse)->not->toHaveKey('error'); + expect($toolListResponse['result']['tools'])->toBeArray(); + expect(count($toolListResponse['result']['tools']))->toBe(2); + expect($toolListResponse['result']['tools'][0]['name'])->toBe('greet_streamable_tool'); + expect($toolListResponse['result']['tools'][1]['name'])->toBe('sum_streamable_tool'); + })->group('integration', 'streamable_http_stream'); + + it('can read a registered resource', function () { + await($this->streamClient->sendInitializeRequest(['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => []], 'init-stream-res')); + await($this->streamClient->sendHttpNotification('notifications/initialized')); + + $resourceResponse = await($this->streamClient->sendRequest('resources/read', ['uri' => 'test://streamable/static'], 'res-read-stream-1')); + + expect($resourceResponse['id'])->toBe('res-read-stream-1'); + $contents = $resourceResponse['result']['contents']; + expect($contents[0]['uri'])->toBe('test://streamable/static'); + expect($contents[0]['text'])->toBe(\PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture::$staticTextContent); + })->group('integration', 'streamable_http_stream'); + + it('can get a registered prompt', function () { + await($this->streamClient->sendInitializeRequest(['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => []], 'init-stream-prompt')); + await($this->streamClient->sendHttpNotification('notifications/initialized')); + + $promptResponse = await($this->streamClient->sendRequest('prompts/get', [ + 'name' => 'simple_streamable_prompt', + 'arguments' => ['name' => 'StreamPromptUser', 'style' => 'formal'] + ], 'prompt-get-stream-1')); + + expect($promptResponse['id'])->toBe('prompt-get-stream-1'); + $messages = $promptResponse['result']['messages']; + expect($messages[0]['content']['text'])->toBe('Craft a formal greeting for StreamPromptUser.'); + })->group('integration', 'streamable_http_stream'); + + it('rejects subsequent requests if client does not send initialized notification', function () { + await($this->streamClient->sendInitializeRequest([ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'StreamModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-stream-noack')); + + $toolResponse = await($this->streamClient->sendRequest('tools/call', [ + 'name' => 'greet_streamable_tool', + 'arguments' => ['name' => 'NoAckStreamUser'] + ], 'tool-stream-noack')); + + expect($toolResponse['id'])->toBe('tool-stream-noack'); + expect($toolResponse['error']['code'])->toBe(-32600); + expect($toolResponse['error']['message'])->toContain('Client session not initialized'); + })->group('integration', 'streamable_http_stream'); + + it('returns HTTP 400 error for non-initialize requests without Mcp-Session-Id', function () { + await($this->streamClient->sendInitializeRequest([ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'StreamModeClient', 'version' => '1.0'], + 'capabilities' => [] + ], 'init-stream-sess-test')); + $validSessionId = $this->streamClient->sessionId; + $this->streamClient->sessionId = null; + + try { + await($this->streamClient->sendRequest('tools/list', [], 'tools-list-no-session-stream')); + $this->fail("Expected request to tools/list to fail with 400, but it succeeded."); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(400); + // Body can't be a json since the header accepts only text/event-stream + } + + $this->streamClient->sessionId = $validSessionId; + })->group('integration', 'streamable_http_stream'); +}); + +it('responds to OPTIONS request with CORS headers', function () { + $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); + $this->process->start(); + $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, STREAMABLE_HTTP_PORT, STREAMABLE_MCP_PATH); + await(delay(0.1)); + + $browser = new Browser(); + $optionsUrl = $this->jsonClient->baseUrl; + + $promise = $browser->request('OPTIONS', $optionsUrl); + $response = await(timeout($promise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); + + expect($response->getStatusCode())->toBe(204); + expect($response->getHeaderLine('Access-Control-Allow-Origin'))->toBe('*'); + expect($response->getHeaderLine('Access-Control-Allow-Methods'))->toContain('POST'); + expect($response->getHeaderLine('Access-Control-Allow-Methods'))->toContain('GET'); + expect($response->getHeaderLine('Access-Control-Allow-Headers'))->toContain('Mcp-Session-Id'); +})->group('integration', 'streamable_http'); + +it('returns 404 for unknown paths', function () { + $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); + $this->process->start(); + $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, STREAMABLE_HTTP_PORT, STREAMABLE_MCP_PATH); + await(delay(0.1)); + + $browser = new Browser(); + $unknownUrl = "http://" . STREAMABLE_HTTP_HOST . ":" . STREAMABLE_HTTP_PORT . "/completely/unknown/path"; + + $promise = $browser->get($unknownUrl); + + try { + await(timeout($promise, STREAMABLE_HTTP_PROCESS_TIMEOUT - 2)); + $this->fail("Request to unknown path should have failed with 404."); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(404); + $decodedBody = json_decode((string)$e->getResponse()->getBody(), true); + expect($decodedBody['error']['message'])->toContain('Not found'); + } +})->group('integration', 'streamable_http'); + +it('can delete client session with DELETE request', function () { + $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); + $this->process->start(); + $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, STREAMABLE_HTTP_PORT, STREAMABLE_MCP_PATH); + await(delay(0.1)); + + // 1. Initialize + await($this->jsonClient->sendRequest('initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => ['name' => 'JsonModeClient', 'version' => '1.0'], 'capabilities' => []], 'init-delete-test')); + $sessionIdForDelete = $this->jsonClient->sessionId; + expect($sessionIdForDelete)->toBeString(); + await($this->jsonClient->sendNotification('notifications/initialized')); + + // 2. Establish a GET SSE connection + $sseUrl = $this->jsonClient->baseUrl; + $browserForSse = (new Browser())->withTimeout(3); + $ssePromise = $browserForSse->requestStreaming('GET', $sseUrl, [ + 'Accept' => 'text/event-stream', + 'Mcp-Session-Id' => $sessionIdForDelete + ]); + $ssePsrResponse = await(timeout($ssePromise, 3)); + expect($ssePsrResponse->getStatusCode())->toBe(200); + expect($ssePsrResponse->getHeaderLine('Content-Type'))->toBe('text/event-stream'); + + $sseStream = $ssePsrResponse->getBody(); + assert($sseStream instanceof ReadableStreamInterface); + + $isSseStreamClosed = false; + $sseStream->on('close', function () use (&$isSseStreamClosed) { + $isSseStreamClosed = true; + }); + + // 3. Send DELETE request + $deleteResponse = await($this->jsonClient->sendDeleteRequest()); + expect($deleteResponse['statusCode'])->toBe(204); + + // 4. Assert that the GET SSE stream was closed + await(delay(0.1)); + expect($isSseStreamClosed)->toBeTrue("The GET SSE stream for session {$sessionIdForDelete} was not closed after DELETE request."); + + // 5. Assert that the client session was deleted + try { + await($this->jsonClient->sendRequest('tools/list', [], 'tool-list-json-1')); + $this->fail("Expected request to tools/list to fail with 400, but it succeeded."); + } catch (ResponseException $e) { + expect($e->getResponse()->getStatusCode())->toBe(404); + $bodyContent = (string) $e->getResponse()->getBody(); + $decodedBody = json_decode($bodyContent, true); + expect($decodedBody['error']['code'])->toBe(-32600); + expect($decodedBody['error']['message'])->toContain('Invalid or expired session'); + } +})->group('integration', 'streamable_http_json'); diff --git a/tests/Mocks/Clients/MockJsonHttpClient.php b/tests/Mocks/Clients/MockJsonHttpClient.php new file mode 100644 index 0000000..34bfbc2 --- /dev/null +++ b/tests/Mocks/Clients/MockJsonHttpClient.php @@ -0,0 +1,114 @@ +browser = (new Browser())->withTimeout($timeout); + $this->baseUrl = "http://{$host}:{$port}/{$mcpPath}"; + } + + public function sendRequest(string $method, array $params = [], ?string $id = null): PromiseInterface + { + $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params]; + if ($id !== null) { + $payload['id'] = $id; + } + + $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream']; + if ($this->sessionId && $method !== 'initialize') { + $headers['Mcp-Session-Id'] = $this->sessionId; + } + + $body = json_encode($payload); + + return $this->browser->post($this->baseUrl, $headers, $body) + ->then(function (ResponseInterface $response) use ($method) { + $bodyContent = (string) $response->getBody()->getContents(); + $statusCode = $response->getStatusCode(); + + if ($method === 'initialize' && $statusCode === 200) { + $this->sessionId = $response->getHeaderLine('Mcp-Session-Id'); + } + + if ($statusCode === 202) { + if ($bodyContent !== '') { + throw new \RuntimeException("Expected empty body for 202 response, got: {$bodyContent}"); + } + return ['statusCode' => $statusCode, 'body' => null, 'headers' => $response->getHeaders()]; + } + + try { + $decoded = json_decode($bodyContent, true, 512, JSON_THROW_ON_ERROR); + return ['statusCode' => $statusCode, 'body' => $decoded, 'headers' => $response->getHeaders()]; + } catch (\JsonException $e) { + throw new \RuntimeException("Failed to decode JSON response body: {$bodyContent} Error: {$e->getMessage()}", $statusCode, $e); + } + }); + } + + public function sendBatchRequest(array $batchRequestObjects): PromiseInterface + { + $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json']; + if ($this->sessionId) { + $headers['Mcp-Session-Id'] = $this->sessionId; + } + $body = json_encode($batchRequestObjects); + + return $this->browser->post($this->baseUrl, $headers, $body) + ->then(function (ResponseInterface $response) { + $bodyContent = (string) $response->getBody()->getContents(); + $statusCode = $response->getStatusCode(); + if ($statusCode === 202) { + if ($bodyContent !== '') { + throw new \RuntimeException("Expected empty body for 202 response, got: {$bodyContent}"); + } + return ['statusCode' => $statusCode, 'body' => null, 'headers' => $response->getHeaders()]; + } + + try { + $decoded = json_decode($bodyContent, true, 512, JSON_THROW_ON_ERROR); + return ['statusCode' => $statusCode, 'body' => $decoded, 'headers' => $response->getHeaders()]; + } catch (\JsonException $e) { + throw new \RuntimeException("Failed to decode JSON response body: {$bodyContent} Error: {$e->getMessage()}", $statusCode, $e); + } + }); + } + + public function sendDeleteRequest(): PromiseInterface + { + $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json']; + if ($this->sessionId) { + $headers['Mcp-Session-Id'] = $this->sessionId; + } + + return $this->browser->delete($this->baseUrl, $headers) + ->then(function (ResponseInterface $response) { + $bodyContent = (string) $response->getBody()->getContents(); + $statusCode = $response->getStatusCode(); + return ['statusCode' => $statusCode, 'body' => $bodyContent, 'headers' => $response->getHeaders()]; + }); + } + + public function sendNotification(string $method, array $params = []): PromiseInterface + { + return $this->sendRequest($method, $params, null); + } + + public function connectSseForNotifications(): PromiseInterface + { + return resolve(null); + } +} diff --git a/tests/Mocks/Clients/MockSseClient.php b/tests/Mocks/Clients/MockSseClient.php new file mode 100644 index 0000000..4726893 --- /dev/null +++ b/tests/Mocks/Clients/MockSseClient.php @@ -0,0 +1,237 @@ +browser = (new Browser())->withTimeout($timeout); + } + + public function connect(string $sseBaseUrl): PromiseInterface + { + return $this->browser->requestStreaming('GET', $sseBaseUrl) + ->then(function (ResponseInterface $response) { + if ($response->getStatusCode() !== 200) { + $body = (string) $response->getBody(); + throw new \RuntimeException("SSE connection failed with status {$response->getStatusCode()}: {$body}"); + } + $stream = $response->getBody(); + assert($stream instanceof ReadableStreamInterface, "SSE response body is not a readable stream"); + $this->stream = $stream; + $this->stream->on('data', [$this, 'handleSseData']); + $this->stream->on('close', function () { + $this->stream = null; + }); + return $this; + }); + } + + public function handleSseData(string $chunk): void + { + $this->buffer .= $chunk; + + while (($eventPos = strpos($this->buffer, "\n\n")) !== false) { + $eventBlock = substr($this->buffer, 0, $eventPos); + $this->buffer = substr($this->buffer, $eventPos + 2); + + $lines = explode("\n", $eventBlock); + $event = ['type' => 'message', 'data' => '', 'id' => null]; + + foreach ($lines as $line) { + if (str_starts_with($line, "event:")) { + $event['type'] = trim(substr($line, strlen("event:"))); + } elseif (str_starts_with($line, "data:")) { + $event['data'] .= (empty($event['data']) ? "" : "\n") . trim(substr($line, strlen("data:"))); + } elseif (str_starts_with($line, "id:")) { + $event['id'] = trim(substr($line, strlen("id:"))); + } + } + $this->receivedSseEvents[] = $event; + + if ($event['type'] === 'endpoint' && $event['data']) { + $this->endpointUrl = $event['data']; + $query = parse_url($this->endpointUrl, PHP_URL_QUERY); + if ($query) { + parse_str($query, $params); + $this->clientId = $params['clientId'] ?? null; + } + } elseif ($event['type'] === 'message' && $event['data']) { + try { + $decodedJson = json_decode($event['data'], true, 512, JSON_THROW_ON_ERROR); + $this->receivedMessages[] = $decodedJson; + } catch (\JsonException $e) { + } + } + } + } + + public function getNextMessageResponse(string $expectedRequestId, int $timeoutSecs = 2): PromiseInterface + { + $deferred = new Deferred(); + $startTime = microtime(true); + + $checkMessages = null; + $checkMessages = function () use (&$checkMessages, $deferred, $expectedRequestId, $startTime, $timeoutSecs) { + foreach ($this->receivedMessages as $i => $msg) { + if (isset($msg['id']) && $msg['id'] === $expectedRequestId) { + unset($this->receivedMessages[$i]); // Consume message + $this->receivedMessages = array_values($this->receivedMessages); + $deferred->resolve($msg); + return; + } + } + + if (microtime(true) - $startTime > $timeoutSecs) { + $deferred->reject(new TimeoutException("Timeout waiting for SSE message with ID '{$expectedRequestId}'")); + return; + } + + if ($this->stream) { + Loop::addTimer(0.05, $checkMessages); + } else { + $deferred->reject(new \RuntimeException("SSE Stream closed while waiting for message ID '{$expectedRequestId}'")); + } + }; + + $checkMessages(); // Start checking + return $deferred->promise(); + } + + public function getNextBatchMessageResponse(int $expectedItemCount, int $timeoutSecs = 2): PromiseInterface + { + $deferred = new Deferred(); + $startTime = microtime(true); + + $checkMessages = null; + $checkMessages = function () use (&$checkMessages, $deferred, $expectedItemCount, $startTime, $timeoutSecs) { + foreach ($this->receivedMessages as $i => $msg) { + if (is_array($msg) && !isset($msg['jsonrpc']) && count($msg) === $expectedItemCount) { + $isLikelyBatchResponse = true; + if (empty($msg) && $expectedItemCount === 0) { + } elseif (empty($msg) && $expectedItemCount > 0) { + $isLikelyBatchResponse = false; + } else { + foreach ($msg as $item) { + if (!is_array($item) || (!isset($item['id']) && !isset($item['method']))) { + $isLikelyBatchResponse = false; + break; + } + } + } + + if ($isLikelyBatchResponse) { + unset($this->receivedMessages[$i]); + $this->receivedMessages = array_values($this->receivedMessages); + $deferred->resolve($msg); + return; + } + } + } + + if (microtime(true) - $startTime > $timeoutSecs) { + $deferred->reject(new TimeoutException("Timeout waiting for SSE Batch Response with {$expectedItemCount} items.")); + return; + } + + if ($this->stream) { + Loop::addTimer(0.05, $checkMessages); + } else { + $deferred->reject(new \RuntimeException("SSE Stream closed while waiting for Batch Response.")); + } + }; + + $checkMessages(); + return $deferred->promise(); + } + + public function sendHttpRequest(string $requestId, string $method, array $params = []): PromiseInterface + { + if (!$this->endpointUrl || !$this->clientId) { + return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing).")); + } + $payload = [ + 'jsonrpc' => '2.0', + 'id' => $requestId, + 'method' => $method, + 'params' => $params, + ]; + $body = json_encode($payload); + + return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body) + ->then(function (ResponseInterface $response) use ($requestId) { + $bodyContent = (string) $response->getBody(); + if ($response->getStatusCode() !== 202) { + throw new \RuntimeException("HTTP POST request failed with status {$response->getStatusCode()}: {$bodyContent}"); + } + return $response; + }); + } + + public function sendHttpBatchRequest(array $batchRequestObjects): PromiseInterface + { + if (!$this->endpointUrl || !$this->clientId) { + return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing).")); + } + $body = json_encode($batchRequestObjects); + + return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body) + ->then(function (ResponseInterface $response) { + $bodyContent = (string) $response->getBody(); + if ($response->getStatusCode() !== 202) { + throw new \RuntimeException("HTTP BATCH POST request failed with status {$response->getStatusCode()}: {$bodyContent}"); + } + return $response; + }); + } + + public function sendHttpNotification(string $method, array $params = []): PromiseInterface + { + if (!$this->endpointUrl || !$this->clientId) { + return reject(new \LogicException("SSE Client not fully initialized (endpoint or clientId missing).")); + } + $payload = [ + 'jsonrpc' => '2.0', + 'method' => $method, + 'params' => $params, + ]; + $body = json_encode($payload); + return $this->browser->post($this->endpointUrl, ['Content-Type' => 'application/json'], $body) + ->then(function (ResponseInterface $response) { + $bodyContent = (string) $response->getBody(); + if ($response->getStatusCode() !== 202) { + throw new \RuntimeException("HTTP POST notification failed with status {$response->getStatusCode()}: {$bodyContent}"); + } + return null; + }); + } + + public function close(): void + { + if ($this->stream) { + $this->stream->close(); + $this->stream = null; + } + } +} diff --git a/tests/Mocks/Clients/MockStreamHttpClient.php b/tests/Mocks/Clients/MockStreamHttpClient.php new file mode 100644 index 0000000..1d0f47d --- /dev/null +++ b/tests/Mocks/Clients/MockStreamHttpClient.php @@ -0,0 +1,252 @@ +browser = (new Browser())->withTimeout($timeout); + $this->baseMcpUrl = "http://{$host}:{$port}/{$mcpPath}"; + } + + public function connectMainSseStream(): PromiseInterface + { + if (!$this->sessionId) { + return reject(new \LogicException("Cannot connect main SSE stream without a session ID. Initialize first.")); + } + + return $this->browser->requestStreaming('GET', $this->baseMcpUrl, [ + 'Accept' => 'text/event-stream', + 'Mcp-Session-Id' => $this->sessionId + ]) + ->then(function (ResponseInterface $response) { + if ($response->getStatusCode() !== 200) { + $body = (string) $response->getBody(); + throw new \RuntimeException("Main SSE GET connection failed with status {$response->getStatusCode()}: {$body}"); + } + $stream = $response->getBody(); + assert($stream instanceof ReadableStreamInterface); + $this->mainSseGetStream = $stream; + + $this->mainSseGetStream->on('data', function ($chunk) { + $this->mainSseGetBuffer .= $chunk; + $this->processBufferForNotifications($this->mainSseGetBuffer, $this->mainSseReceivedNotifications); + }); + return $this; + }); + } + + private function processBufferForNotifications(string &$buffer, array &$targetArray): void + { + while (($eventPos = strpos($buffer, "\n\n")) !== false) { + $eventBlock = substr($buffer, 0, $eventPos); + $buffer = substr($buffer, $eventPos + 2); + $lines = explode("\n", $eventBlock); + $eventData = ''; + foreach ($lines as $line) { + if (str_starts_with($line, "data:")) { + $eventData .= (empty($eventData) ? "" : "\n") . trim(substr($line, strlen("data:"))); + } + } + if (!empty($eventData)) { + try { + $decodedJson = json_decode($eventData, true, 512, JSON_THROW_ON_ERROR); + if (isset($decodedJson['method']) && str_starts_with($decodedJson['method'], 'notifications/')) { + $targetArray[] = $decodedJson; + } + } catch (\JsonException $e) { /* ignore non-json data lines or log */ + } + } + } + } + + + public function sendInitializeRequest(array $params, string $id = 'init-stream-1'): PromiseInterface + { + $payload = ['jsonrpc' => '2.0', 'method' => 'initialize', 'params' => $params, 'id' => $id]; + $headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream']; + $body = json_encode($payload); + + return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body) + ->then(function (ResponseInterface $response) use ($id) { + $statusCode = $response->getStatusCode(); + + if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) { + throw new \RuntimeException("Initialize POST failed or did not return SSE stream. Status: {$statusCode}"); + } + + $this->sessionId = $response->getHeaderLine('Mcp-Session-Id'); + + $stream = $response->getBody(); + assert($stream instanceof ReadableStreamInterface); + return $this->collectSingleSseResponse($stream, $id, "Initialize"); + }); + } + + public function sendRequest(string $method, array $params, string $id): PromiseInterface + { + $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params, 'id' => $id]; + $headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream']; + if ($this->sessionId) $headers['Mcp-Session-Id'] = $this->sessionId; + + $body = json_encode($payload); + + return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body) + ->then(function (ResponseInterface $response) use ($id, $method) { + $statusCode = $response->getStatusCode(); + + if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) { + $bodyContent = (string) $response->getBody(); + throw new \RuntimeException("Request '{$method}' (ID: {$id}) POST failed or did not return SSE stream. Status: {$statusCode}, Body: {$bodyContent}"); + } + + $stream = $response->getBody(); + assert($stream instanceof ReadableStreamInterface); + return $this->collectSingleSseResponse($stream, $id, $method); + }); + } + + public function sendBatchRequest(array $batchPayload): PromiseInterface + { + if (!$this->sessionId) { + return reject(new \LogicException("Session ID not set. Initialize first for batch request.")); + } + + $headers = ['Content-Type' => 'application/json', 'Accept' => 'text/event-stream', 'Mcp-Session-Id' => $this->sessionId]; + $body = json_encode($batchPayload); + + return $this->browser->requestStreaming('POST', $this->baseMcpUrl, $headers, $body) + ->then(function (ResponseInterface $response) { + $statusCode = $response->getStatusCode(); + + if ($statusCode !== 200 || !str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) { + throw new \RuntimeException("Batch POST failed or did not return SSE stream. Status: {$statusCode}"); + } + + $stream = $response->getBody(); + assert($stream instanceof ReadableStreamInterface); + return $this->collectSingleSseResponse($stream, null, "Batch", true); + }); + } + + private function collectSingleSseResponse(ReadableStreamInterface $stream, ?string $expectedRequestId, string $contextHint, bool $expectBatchArray = false): PromiseInterface + { + $deferred = new Deferred(); + $buffer = ''; + $streamClosed = false; + + $dataListener = function ($chunk) use (&$buffer, $deferred, $expectedRequestId, $expectBatchArray, $contextHint, &$streamClosed, &$dataListener, $stream) { + if ($streamClosed) return; + $buffer .= $chunk; + + if (str_contains($buffer, "event: message\n")) { + if (preg_match('/data: (.*)\n\n/s', $buffer, $matches)) { + $jsonData = trim($matches[1]); + + try { + $decoded = json_decode($jsonData, true, 512, JSON_THROW_ON_ERROR); + $isValid = false; + if ($expectBatchArray) { + $isValid = is_array($decoded) && !isset($decoded['jsonrpc']); + } else { + $isValid = isset($decoded['id']) && $decoded['id'] === $expectedRequestId; + } + + if ($isValid) { + $deferred->resolve($decoded); + $stream->removeListener('data', $dataListener); + $stream->close(); + return; + } + } catch (\JsonException $e) { + $deferred->reject(new \RuntimeException("SSE JSON decode failed for {$contextHint}: {$jsonData}", 0, $e)); + $stream->removeListener('data', $dataListener); + $stream->close(); + return; + } + } + } + }; + + $stream->on('data', $dataListener); + $stream->on('close', function () use ($deferred, $contextHint, &$streamClosed) { + $streamClosed = true; + $deferred->reject(new \RuntimeException("SSE stream for {$contextHint} closed before expected response was received.")); + }); + $stream->on('error', function ($err) use ($deferred, $contextHint, &$streamClosed) { + $streamClosed = true; + $deferred->reject(new \RuntimeException("SSE stream error for {$contextHint}.", 0, $err instanceof \Throwable ? $err : null)); + }); + + return timeout($deferred->promise(), 2, Loop::get()) + ->finally(function () use ($stream, $dataListener) { + if ($stream->isReadable()) { + $stream->removeListener('data', $dataListener); + } + }); + } + + public function sendHttpNotification(string $method, array $params = []): PromiseInterface + { + if (!$this->sessionId) { + return reject(new \LogicException("Session ID not set for notification. Initialize first.")); + } + $payload = ['jsonrpc' => '2.0', 'method' => $method, 'params' => $params]; + $headers = ['Content-Type' => 'application/json', 'Accept' => 'application/json', 'Mcp-Session-Id' => $this->sessionId]; + $body = json_encode($payload); + + return $this->browser->post($this->baseMcpUrl, $headers, $body) + ->then(function (ResponseInterface $response) { + $statusCode = $response->getStatusCode(); + + if ($statusCode !== 202) { + throw new \RuntimeException("POST Notification failed with status {$statusCode}: " . (string)$response->getBody()); + } + + return ['statusCode' => $statusCode, 'body' => null]; + }); + } + + public function sendDeleteRequest(): PromiseInterface + { + if (!$this->sessionId) { + return reject(new \LogicException("Session ID not set for DELETE request. Initialize first.")); + } + + $headers = ['Mcp-Session-Id' => $this->sessionId]; + + return $this->browser->request('DELETE', $this->baseMcpUrl, $headers) + ->then(function (ResponseInterface $response) { + $statusCode = $response->getStatusCode(); + return ['statusCode' => $statusCode, 'body' => (string)$response->getBody()]; + }); + } + + public function closeMainSseStream(): void + { + if ($this->mainSseGetStream) { + $this->mainSseGetStream->close(); + $this->mainSseGetStream = null; + } + } +} diff --git a/tests/Mocks/DiscoveryStubs/AbstractStub.php b/tests/Mocks/DiscoveryStubs/AbstractStub.php deleted file mode 100644 index c28acb4..0000000 --- a/tests/Mocks/DiscoveryStubs/AbstractStub.php +++ /dev/null @@ -1,11 +0,0 @@ - 'user', 'content' => "Generate something about {$topic}"], - ]; - } -} diff --git a/tests/Mocks/DiscoveryStubs/InvokableResourceStub.php b/tests/Mocks/DiscoveryStubs/InvokableResourceStub.php deleted file mode 100644 index bbd0397..0000000 --- a/tests/Mocks/DiscoveryStubs/InvokableResourceStub.php +++ /dev/null @@ -1,22 +0,0 @@ - $id, 'data' => 'Invoked template data']; - } -} diff --git a/tests/Mocks/DiscoveryStubs/InvokableToolStub.php b/tests/Mocks/DiscoveryStubs/InvokableToolStub.php deleted file mode 100644 index 2b1bb03..0000000 --- a/tests/Mocks/DiscoveryStubs/InvokableToolStub.php +++ /dev/null @@ -1,23 +0,0 @@ - 'user', 'content' => "Prompt for {$topic}"]]; - } - - public function templateHandler(string $id): array - { - return ['id' => $id, 'content' => 'Template data']; - } -} diff --git a/tests/Mocks/ManualRegistrationStubs/InvokableHandlerStub.php b/tests/Mocks/ManualRegistrationStubs/InvokableHandlerStub.php deleted file mode 100644 index e4b9ae3..0000000 --- a/tests/Mocks/ManualRegistrationStubs/InvokableHandlerStub.php +++ /dev/null @@ -1,14 +0,0 @@ -getProperty($propertyName); + $property->setAccessible(true); + return $property->getValue($object); } -function setupTempDir(): void +function delay($time, ?LoopInterface $loop = null) { - if (is_dir(TEST_DISCOVERY_DIR)) { - deleteDirectory(TEST_DISCOVERY_DIR); + if ($loop === null) { + $loop = Loop::get(); } - mkdir(TEST_DISCOVERY_DIR, 0777, true); -} -function cleanupTempDir(): void -{ - if (is_dir(TEST_DISCOVERY_DIR)) { - deleteDirectory(TEST_DISCOVERY_DIR); - } + /** @var TimerInterface $timer */ + $timer = null; + return new Promise(function ($resolve) use ($loop, $time, &$timer) { + $timer = $loop->addTimer($time, function () use ($resolve) { + $resolve(null); + }); + }, function () use (&$timer, $loop) { + $loop->cancelTimer($timer); + $timer = null; + + throw new \RuntimeException('Timer cancelled'); + }); } -/** - * Creates a test file in the temporary discovery directory by copying a stub. - * - * @param string $stubName The name of the stub file (without .php) in TEST_STUBS_DIR. - * @param string|null $targetFileName The desired name for the file in TEST_DISCOVERY_DIR (defaults to stubName.php). - * @return string The full path to the created file. - * - * @throws \Exception If the stub file does not exist. - */ -function createDiscoveryTestFile(string $stubName, ?string $targetFileName = null): string +function timeout(PromiseInterface $promise, $time, ?LoopInterface $loop = null) { - $stubPath = TEST_STUBS_DIR.'/'.$stubName.'.php'; - $targetName = $targetFileName ?? ($stubName.'.php'); - $targetPath = TEST_DISCOVERY_DIR.'/'.$targetName; - - if (! file_exists($stubPath)) { - throw new \Exception("Discovery test stub file not found: {$stubPath}"); + $canceller = null; + if (\method_exists($promise, 'cancel')) { + $canceller = function () use (&$promise) { + $promise->cancel(); + $promise = null; + }; } - if (! copy($stubPath, $targetPath)) { - throw new \Exception("Failed to copy discovery test stub '{$stubName}' to '{$targetName}'"); + if ($loop === null) { + $loop = Loop::get(); } - return $targetPath; + return new Promise(function ($resolve, $reject) use ($loop, $time, $promise) { + $timer = null; + $promise = $promise->then(function ($v) use (&$timer, $loop, $resolve) { + if ($timer) { + $loop->cancelTimer($timer); + } + $timer = false; + $resolve($v); + }, function ($v) use (&$timer, $loop, $reject) { + if ($timer) { + $loop->cancelTimer($timer); + } + $timer = false; + $reject($v); + }); + + if ($timer === false) { + return; + } + + // start timeout timer which will cancel the input promise + $timer = $loop->addTimer($time, function () use ($time, &$promise, $reject) { + $reject(new \RuntimeException('Timed out after ' . $time . ' seconds')); + + if (\method_exists($promise, 'cancel')) { + $promise->cancel(); + } + $promise = null; + }); + }, $canceller); } diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index 41b1f81..5ce10be 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -1,23 +1,23 @@ name = 'TestServer'; - $this->version = '1.1.0'; + $this->serverInfo = Implementation::make('TestServer', '1.1.0'); $this->logger = Mockery::mock(LoggerInterface::class); $this->loop = Mockery::mock(LoopInterface::class); $this->cache = Mockery::mock(CacheInterface::class); $this->container = Mockery::mock(ContainerInterface::class); - $this->capabilities = Capabilities::forServer(); + $this->capabilities = ServerCapabilities::make(); }); afterEach(function () { @@ -28,8 +28,7 @@ $ttl = 1800; $paginationLimit = 100; $config = new Configuration( - serverName: $this->name, - serverVersion: $this->version, + serverInfo: $this->serverInfo, capabilities: $this->capabilities, logger: $this->logger, loop: $this->loop, @@ -39,8 +38,7 @@ paginationLimit: $paginationLimit ); - expect($config->serverName)->toBe($this->name); - expect($config->serverVersion)->toBe($this->version); + expect($config->serverInfo)->toBe($this->serverInfo); expect($config->capabilities)->toBe($this->capabilities); expect($config->logger)->toBe($this->logger); expect($config->loop)->toBe($this->loop); @@ -52,8 +50,7 @@ it('constructs configuration object with default TTL', function () { $config = new Configuration( - serverName: $this->name, - serverVersion: $this->version, + serverInfo: $this->serverInfo, capabilities: $this->capabilities, logger: $this->logger, loop: $this->loop, @@ -66,8 +63,7 @@ it('constructs configuration object with default pagination limit', function () { $config = new Configuration( - serverName: $this->name, - serverVersion: $this->version, + serverInfo: $this->serverInfo, capabilities: $this->capabilities, logger: $this->logger, loop: $this->loop, @@ -80,8 +76,7 @@ it('constructs configuration object with null cache', function () { $config = new Configuration( - serverName: $this->name, - serverVersion: $this->version, + serverInfo: $this->serverInfo, capabilities: $this->capabilities, logger: $this->logger, loop: $this->loop, @@ -93,15 +88,13 @@ }); it('constructs configuration object with specific capabilities', function () { - $customCaps = Capabilities::forServer( + $customCaps = ServerCapabilities::make( resourcesSubscribe: true, loggingEnabled: true, - instructions: 'Use wisely.' ); $config = new Configuration( - serverName: $this->name, - serverVersion: $this->version, + serverInfo: $this->serverInfo, capabilities: $customCaps, logger: $this->logger, loop: $this->loop, @@ -112,5 +105,4 @@ 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/Unit/DispatcherTest.php b/tests/Unit/DispatcherTest.php new file mode 100644 index 0000000..43b7a32 --- /dev/null +++ b/tests/Unit/DispatcherTest.php @@ -0,0 +1,480 @@ +configuration = Mockery::mock(Configuration::class); + /** @var MockInterface&Registry $registry */ + $this->registry = Mockery::mock(Registry::class); + /** @var MockInterface&SubscriptionManager $subscriptionManager */ + $this->subscriptionManager = Mockery::mock(SubscriptionManager::class); + /** @var MockInterface&SchemaValidator $schemaValidator */ + $this->schemaValidator = Mockery::mock(SchemaValidator::class); + /** @var MockInterface&SessionInterface $session */ + $this->session = Mockery::mock(SessionInterface::class); + /** @var MockInterface&ContainerInterface $container */ + $this->container = Mockery::mock(ContainerInterface::class); + + $configuration = new Configuration( + serverInfo: Implementation::make('DispatcherTestServer', '1.0'), + capabilities: ServerCapabilities::make(), + paginationLimit: DISPATCHER_PAGINATION_LIMIT, + logger: new NullLogger(), + loop: Loop::get(), + cache: null, + container: $this->container + ); + + $this->dispatcher = new Dispatcher( + $configuration, + $this->registry, + $this->subscriptionManager, + $this->schemaValidator + ); +}); + +it('routes to handleInitialize for initialize request', function () { + $request = new JsonRpcRequest( + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: [ + 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, + 'clientInfo' => ['name' => 'client', 'version' => '1.0'], + 'capabilities' => [], + ] + ); + $this->session->shouldReceive('set')->with('client_info', Mockery::on(fn($value) => $value->name === 'client' && $value->version === '1.0'))->once(); + + $result = $this->dispatcher->handleRequest($request, $this->session); + expect($result)->toBeInstanceOf(InitializeResult::class); + expect($result->protocolVersion)->toBe(Protocol::LATEST_PROTOCOL_VERSION); + expect($result->serverInfo->name)->toBe('DispatcherTestServer'); +}); + +it('routes to handlePing for ping request', function () { + $request = new JsonRpcRequest('2.0', 'id1', 'ping', []); + $result = $this->dispatcher->handleRequest($request, $this->session); + expect($result)->toBeInstanceOf(EmptyResult::class); +}); + +it('throws MethodNotFound for unknown request method', function () { + $rawRequest = new JsonRpcRequest('2.0', 'id1', 'unknown/method', []); + $this->dispatcher->handleRequest($rawRequest, $this->session); +})->throws(McpServerException::class, "Method 'unknown/method' not found."); + +it('routes to handleNotificationInitialized for initialized notification', function () { + $notification = new JsonRpcNotification('2.0', 'notifications/initialized', []); + $this->session->shouldReceive('set')->with('initialized', true)->once(); + $this->dispatcher->handleNotification($notification, $this->session); +}); + +it('does nothing for unknown notification method', function () { + $rawNotification = new JsonRpcNotification('2.0', 'unknown/notification', []); + $this->session->shouldNotReceive('set'); + $this->dispatcher->handleNotification($rawNotification, $this->session); +}); + + +it('can handle initialize request', function () { + $clientInfo = Implementation::make('TestClient', '0.9.9'); + $request = InitializeRequest::make(1, Protocol::LATEST_PROTOCOL_VERSION, ClientCapabilities::make(), $clientInfo, []); + $this->session->shouldReceive('set')->with('client_info', $clientInfo)->once(); + + $result = $this->dispatcher->handleInitialize($request, $this->session); + expect($result->protocolVersion)->toBe(Protocol::LATEST_PROTOCOL_VERSION); + expect($result->serverInfo->name)->toBe('DispatcherTestServer'); + expect($result->capabilities)->toBeInstanceOf(ServerCapabilities::class); +}); + +it('can handle tool list request and return paginated tools', function () { + $toolSchemas = [ + ToolSchema::make('tool1', ['type' => 'object', 'properties' => []]), + ToolSchema::make('tool2', ['type' => 'object', 'properties' => []]), + ToolSchema::make('tool3', ['type' => 'object', 'properties' => []]), + ToolSchema::make('tool4', ['type' => 'object', 'properties' => []]), + ]; + $this->registry->shouldReceive('getTools')->andReturn($toolSchemas); + + $request = ListToolsRequest::make(1); + $result = $this->dispatcher->handleToolList($request); + expect($result->tools)->toHaveCount(DISPATCHER_PAGINATION_LIMIT); + expect($result->tools[0]->name)->toBe('tool1'); + expect($result->nextCursor)->toBeString(); + + $nextCursor = $result->nextCursor; + $requestPage2 = ListToolsRequest::make(2, $nextCursor); + $resultPage2 = $this->dispatcher->handleToolList($requestPage2); + expect($resultPage2->tools)->toHaveCount(count($toolSchemas) - DISPATCHER_PAGINATION_LIMIT); + expect($resultPage2->tools[0]->name)->toBe('tool4'); + expect($resultPage2->nextCursor)->toBeNull(); +}); + +it('can handle tool call request and return result', function () { + $toolName = 'my-calculator'; + $args = ['a' => 10, 'b' => 5]; + $toolSchema = ToolSchema::make($toolName, ['type' => 'object', 'properties' => ['a' => ['type' => 'integer'], 'b' => ['type' => 'integer']]]); + $registeredToolMock = Mockery::mock(RegisteredTool::class, [$toolSchema, 'MyToolHandler', 'handleTool', false]); + + $this->registry->shouldReceive('getTool')->with($toolName)->andReturn($registeredToolMock); + $this->schemaValidator->shouldReceive('validateAgainstJsonSchema')->with($args, $toolSchema->inputSchema)->andReturn([]); // No validation errors + $registeredToolMock->shouldReceive('call')->with($this->container, $args)->andReturn([TextContent::make("Result: 15")]); + + $request = CallToolRequest::make(1, $toolName, $args); + $result = $this->dispatcher->handleToolCall($request); + + expect($result)->toBeInstanceOf(CallToolResult::class); + expect($result->content[0]->text)->toBe("Result: 15"); + expect($result->isError)->toBeFalse(); +}); + +it('can handle tool call request and throw exception if tool not found', function () { + $this->registry->shouldReceive('getTool')->with('unknown-tool')->andReturn(null); + $request = CallToolRequest::make(1, 'unknown-tool', []); + $this->dispatcher->handleToolCall($request); +})->throws(McpServerException::class, "Tool 'unknown-tool' not found."); + +it('can handle tool call request and throw exception if argument validation fails', function () { + $toolName = 'strict-tool'; + $args = ['param' => 'wrong_type']; + $toolSchema = ToolSchema::make($toolName, ['type' => 'object', 'properties' => ['param' => ['type' => 'integer']]]); + $registeredToolMock = Mockery::mock(RegisteredTool::class, [$toolSchema, 'MyToolHandler', 'handleTool', false]); + + $this->registry->shouldReceive('getTool')->with($toolName)->andReturn($registeredToolMock); + $validationErrors = [['pointer' => '/param', 'keyword' => 'type', 'message' => 'Expected integer']]; + $this->schemaValidator->shouldReceive('validateAgainstJsonSchema')->with($args, $toolSchema->inputSchema)->andReturn($validationErrors); + + $request = CallToolRequest::make(1, $toolName, $args); + try { + $this->dispatcher->handleToolCall($request); + } catch (McpServerException $e) { + expect($e->getMessage())->toContain("Invalid parameters for tool 'strict-tool'"); + expect($e->getData()['validation_errors'])->toBeArray(); + } +}); + +it('can handle tool call request and return error if tool execution throws exception', function () { + $toolName = 'failing-tool'; + $toolSchema = ToolSchema::make($toolName, ['type' => 'object', 'properties' => []]); + $registeredToolMock = Mockery::mock(RegisteredTool::class, [$toolSchema, 'MyToolHandler', 'handleTool', false]); + + $this->registry->shouldReceive('getTool')->with($toolName)->andReturn($registeredToolMock); + $this->schemaValidator->shouldReceive('validateAgainstJsonSchema')->andReturn([]); + $registeredToolMock->shouldReceive('call')->andThrow(new \RuntimeException("Tool crashed!")); + + $request = CallToolRequest::make(1, $toolName, []); + $result = $this->dispatcher->handleToolCall($request); + + expect($result->isError)->toBeTrue(); + expect($result->content[0]->text)->toBe("Tool execution failed: Tool crashed!"); +}); + +it('can handle tool call request and return error if result formatting fails', function () { + $toolName = 'bad-result-tool'; + $toolSchema = ToolSchema::make($toolName, ['type' => 'object', 'properties' => []]); + $registeredToolMock = Mockery::mock(RegisteredTool::class, [$toolSchema, 'MyToolHandler', 'handleTool', false]); + + $this->registry->shouldReceive('getTool')->with($toolName)->andReturn($registeredToolMock); + $this->schemaValidator->shouldReceive('validateAgainstJsonSchema')->andReturn([]); + $registeredToolMock->shouldReceive('call')->andThrow(new \JsonException("Unencodable.")); + + + $request = CallToolRequest::make(1, $toolName, []); + $result = $this->dispatcher->handleToolCall($request); + + expect($result->isError)->toBeTrue(); + expect($result->content[0]->text)->toBe("Failed to serialize tool result: Unencodable."); +}); + + +it('can handle resources list request and return paginated resources', function () { + $resourceSchemas = [ + ResourceSchema::make('res://1', 'Resource1'), + ResourceSchema::make('res://2', 'Resource2'), + ResourceSchema::make('res://3', 'Resource3'), + ResourceSchema::make('res://4', 'Resource4'), + ResourceSchema::make('res://5', 'Resource5') + ]; + $this->registry->shouldReceive('getResources')->andReturn($resourceSchemas); + + $requestP1 = ListResourcesRequest::make(1); + $resultP1 = $this->dispatcher->handleResourcesList($requestP1); + expect($resultP1->resources)->toHaveCount(DISPATCHER_PAGINATION_LIMIT); + expect(array_map(fn($r) => $r->name, $resultP1->resources))->toEqual(['Resource1', 'Resource2', 'Resource3']); + expect($resultP1->nextCursor)->toBe(base64_encode('offset=3')); + + // Page 2 + $requestP2 = ListResourcesRequest::make(2, $resultP1->nextCursor); + $resultP2 = $this->dispatcher->handleResourcesList($requestP2); + expect($resultP2->resources)->toHaveCount(2); + expect(array_map(fn($r) => $r->name, $resultP2->resources))->toEqual(['Resource4', 'Resource5']); + expect($resultP2->nextCursor)->toBeNull(); +}); + +it('can handle resources list request and return empty if registry has no resources', function () { + $this->registry->shouldReceive('getResources')->andReturn([]); + $request = ListResourcesRequest::make(1); + $result = $this->dispatcher->handleResourcesList($request); + expect($result->resources)->toBeEmpty(); + expect($result->nextCursor)->toBeNull(); +}); + +it('can handle resource template list request and return paginated templates', function () { + $templateSchemas = [ + ResourceTemplateSchema::make('tpl://{id}/1', 'Template1'), + ResourceTemplateSchema::make('tpl://{id}/2', 'Template2'), + ResourceTemplateSchema::make('tpl://{id}/3', 'Template3'), + ResourceTemplateSchema::make('tpl://{id}/4', 'Template4'), + ]; + $this->registry->shouldReceive('getResourceTemplates')->andReturn($templateSchemas); + + // Page 1 + $requestP1 = ListResourceTemplatesRequest::make(1); + $resultP1 = $this->dispatcher->handleResourceTemplateList($requestP1); + expect($resultP1->resourceTemplates)->toHaveCount(DISPATCHER_PAGINATION_LIMIT); + expect(array_map(fn($rt) => $rt->name, $resultP1->resourceTemplates))->toEqual(['Template1', 'Template2', 'Template3']); + expect($resultP1->nextCursor)->toBe(base64_encode('offset=3')); + + // Page 2 + $requestP2 = ListResourceTemplatesRequest::make(2, $resultP1->nextCursor); + $resultP2 = $this->dispatcher->handleResourceTemplateList($requestP2); + expect($resultP2->resourceTemplates)->toHaveCount(1); + expect(array_map(fn($rt) => $rt->name, $resultP2->resourceTemplates))->toEqual(['Template4']); + expect($resultP2->nextCursor)->toBeNull(); +}); + +it('can handle resource read request and return resource contents', function () { + $uri = 'file://data.txt'; + $resourceSchema = ResourceSchema::make($uri, 'file_resource'); + $registeredResourceMock = Mockery::mock(RegisteredResource::class, [$resourceSchema, 'MyResourceHandler', 'read', false]); + $resourceContents = [TextContent::make('File content')]; + + $this->registry->shouldReceive('getResource')->with($uri)->andReturn($registeredResourceMock); + $registeredResourceMock->shouldReceive('read')->with($this->container, $uri)->andReturn($resourceContents); + + $request = ReadResourceRequest::make(1, $uri); + $result = $this->dispatcher->handleResourceRead($request); + + expect($result)->toBeInstanceOf(ReadResourceResult::class); + expect($result->contents)->toEqual($resourceContents); +}); + +it('can handle resource read request and throw exception if resource not found', function () { + $this->registry->shouldReceive('getResource')->with('unknown://uri')->andReturn(null); + $request = ReadResourceRequest::make(1, 'unknown://uri'); + $this->dispatcher->handleResourceRead($request); +})->throws(McpServerException::class, "Resource URI 'unknown://uri' not found."); + +it('can handle resource subscribe request and call subscription manager', function () { + $uri = 'news://updates'; + $this->session->shouldReceive('getId')->andReturn(DISPATCHER_SESSION_ID); + $this->subscriptionManager->shouldReceive('subscribe')->with(DISPATCHER_SESSION_ID, $uri)->once(); + $request = ResourceSubscribeRequest::make(1, $uri); + $result = $this->dispatcher->handleResourceSubscribe($request, $this->session); + expect($result)->toBeInstanceOf(EmptyResult::class); +}); + +it('can handle prompts list request and return paginated prompts', function () { + $promptSchemas = [ + PromptSchema::make('promptA', '', []), + PromptSchema::make('promptB', '', []), + PromptSchema::make('promptC', '', []), + PromptSchema::make('promptD', '', []), + PromptSchema::make('promptE', '', []), + PromptSchema::make('promptF', '', []), + ]; // 6 prompts + $this->registry->shouldReceive('getPrompts')->andReturn($promptSchemas); + + // Page 1 + $requestP1 = ListPromptsRequest::make(1); + $resultP1 = $this->dispatcher->handlePromptsList($requestP1); + expect($resultP1->prompts)->toHaveCount(DISPATCHER_PAGINATION_LIMIT); + expect(array_map(fn($p) => $p->name, $resultP1->prompts))->toEqual(['promptA', 'promptB', 'promptC']); + expect($resultP1->nextCursor)->toBe(base64_encode('offset=3')); + + // Page 2 + $requestP2 = ListPromptsRequest::make(2, $resultP1->nextCursor); + $resultP2 = $this->dispatcher->handlePromptsList($requestP2); + expect($resultP2->prompts)->toHaveCount(DISPATCHER_PAGINATION_LIMIT); // 3 more + expect(array_map(fn($p) => $p->name, $resultP2->prompts))->toEqual(['promptD', 'promptE', 'promptF']); + expect($resultP2->nextCursor)->toBeNull(); // End of list +}); + +it('can handle prompt get request and return prompt messages', function () { + $promptName = 'daily-summary'; + $args = ['date' => '2024-07-16']; + $promptSchema = PromptSchema::make($promptName, 'summary_prompt', [PromptArgument::make('date', required: true)]); + $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, 'MyPromptHandler', 'get', false]); + $promptMessages = [PromptMessage::make(Role::User, TextContent::make("Summary for 2024-07-16"))]; + + $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); + $registeredPromptMock->shouldReceive('get')->with($this->container, $args)->andReturn($promptMessages); + + $request = GetPromptRequest::make(1, $promptName, $args); + $result = $this->dispatcher->handlePromptGet($request, $this->session); + + expect($result)->toBeInstanceOf(GetPromptResult::class); + expect($result->messages)->toEqual($promptMessages); + expect($result->description)->toBe($promptSchema->description); +}); + +it('can handle prompt get request and throw exception if required argument is missing', function () { + $promptName = 'needs-topic'; + $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make('topic', required: true)]); + $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, 'MyPromptHandler', 'get', false]); + $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); + + $request = GetPromptRequest::make(1, $promptName, ['other_arg' => 'value']); // 'topic' is missing + $this->dispatcher->handlePromptGet($request, $this->session); +})->throws(McpServerException::class, "Missing required argument 'topic' for prompt 'needs-topic'."); + + +it('can handle logging set level request and set log level on session', function () { + $level = LoggingLevel::Debug; + $this->session->shouldReceive('getId')->andReturn(DISPATCHER_SESSION_ID); + $this->session->shouldReceive('set')->with('log_level', 'debug')->once(); + + $request = SetLogLevelRequest::make(1, $level); + $result = $this->dispatcher->handleLoggingSetLevel($request, $this->session); + + expect($result)->toBeInstanceOf(EmptyResult::class); +}); + +it('can handle completion complete request for prompt and delegate to provider', function () { + $promptName = 'my-completable-prompt'; + $argName = 'tagName'; + $currentValue = 'php'; + $completions = ['php-mcp', 'php-fig']; + $mockCompletionProvider = Mockery::mock(CompletionProviderInterface::class); + $providerClass = get_class($mockCompletionProvider); + + $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make($argName)]); + $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, 'MyPromptHandler', 'get', false]); + $registeredPromptMock->shouldReceive('getCompletionProvider')->with($argName)->andReturn($providerClass); + + $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); + $this->container->shouldReceive('get')->with($providerClass)->andReturn($mockCompletionProvider); + $mockCompletionProvider->shouldReceive('getCompletions')->with($currentValue, $this->session)->andReturn($completions); + + $request = CompletionCompleteRequest::make(1, PromptReference::make($promptName), ['name' => $argName, 'value' => $currentValue]); + $result = $this->dispatcher->handleCompletionComplete($request, $this->session); + + expect($result)->toBeInstanceOf(CompletionCompleteResult::class); + expect($result->values)->toEqual($completions); + expect($result->total)->toBe(count($completions)); + expect($result->hasMore)->toBeFalse(); +}); + +it('can handle completion complete request for resource template and delegate to provider', function () { + $templateUri = 'item://{itemId}/category/{catName}'; + $uriVarName = 'catName'; + $currentValue = 'boo'; + $completions = ['books', 'boomerangs']; + $mockCompletionProvider = Mockery::mock(CompletionProviderInterface::class); + $providerClass = get_class($mockCompletionProvider); + + $templateSchema = ResourceTemplateSchema::make($templateUri, 'item-template'); + $registeredTemplateMock = Mockery::mock(RegisteredResourceTemplate::class, [$templateSchema, 'MyResourceTemplateHandler', 'get', false]); + $registeredTemplateMock->shouldReceive('getVariableNames')->andReturn(['itemId', 'catName']); + $registeredTemplateMock->shouldReceive('getCompletionProvider')->with($uriVarName)->andReturn($providerClass); + + $this->registry->shouldReceive('getResourceTemplate')->with($templateUri)->andReturn($registeredTemplateMock); + $this->container->shouldReceive('get')->with($providerClass)->andReturn($mockCompletionProvider); + $mockCompletionProvider->shouldReceive('getCompletions')->with($currentValue, $this->session)->andReturn($completions); + + $request = CompletionCompleteRequest::make(1, ResourceReference::make($templateUri), ['name' => $uriVarName, 'value' => $currentValue]); + $result = $this->dispatcher->handleCompletionComplete($request, $this->session); + + expect($result->values)->toEqual($completions); +}); + +it('can handle completion complete request and return empty if no provider', function () { + $promptName = 'no-provider-prompt'; + $promptSchema = PromptSchema::make($promptName, '', [PromptArgument::make('arg')]); + $registeredPromptMock = Mockery::mock(RegisteredPrompt::class, [$promptSchema, 'MyPromptHandler', 'get', false]); + $registeredPromptMock->shouldReceive('getCompletionProvider')->andReturn(null); + $this->registry->shouldReceive('getPrompt')->with($promptName)->andReturn($registeredPromptMock); + + $request = CompletionCompleteRequest::make(1, PromptReference::make($promptName), ['name' => 'arg', 'value' => '']); + $result = $this->dispatcher->handleCompletionComplete($request, $this->session); + expect($result->values)->toBeEmpty(); +}); + + +it('decodeCursor handles null and invalid cursors', function () { + $method = new \ReflectionMethod(Dispatcher::class, 'decodeCursor'); + $method->setAccessible(true); + + expect($method->invoke($this->dispatcher, null))->toBe(0); + expect($method->invoke($this->dispatcher, 'not_base64_$$$'))->toBe(0); + expect($method->invoke($this->dispatcher, base64_encode('invalid_format')))->toBe(0); + expect($method->invoke($this->dispatcher, base64_encode('offset=123')))->toBe(123); +}); + +it('encodeNextCursor generates correct cursor or null', function () { + $method = new \ReflectionMethod(Dispatcher::class, 'encodeNextCursor'); + $method->setAccessible(true); + $limit = DISPATCHER_PAGINATION_LIMIT; + + expect($method->invoke($this->dispatcher, 0, $limit, 10, $limit))->toBe(base64_encode('offset=3')); + expect($method->invoke($this->dispatcher, 0, $limit, $limit, $limit))->toBeNull(); + expect($method->invoke($this->dispatcher, $limit, 2, $limit + 2 + 1, $limit))->toBe(base64_encode('offset=' . ($limit + 2))); + expect($method->invoke($this->dispatcher, $limit, 1, $limit + 1, $limit))->toBeNull(); + expect($method->invoke($this->dispatcher, 0, 0, 10, $limit))->toBeNull(); +}); diff --git a/tests/Unit/Elements/RegisteredElementTest.php b/tests/Unit/Elements/RegisteredElementTest.php new file mode 100644 index 0000000..4a896fa --- /dev/null +++ b/tests/Unit/Elements/RegisteredElementTest.php @@ -0,0 +1,234 @@ +container = Mockery::mock(ContainerInterface::class); + $this->container->shouldReceive('get')->with(VariousTypesHandler::class)->andReturn(new VariousTypesHandler()); +}); + +it('can be constructed as manual or discovered', function () { + $elManual = new RegisteredElement(VariousTypesHandler::class, 'noArgsMethod', true); + $elDiscovered = new RegisteredElement(VariousTypesHandler::class, 'noArgsMethod', false); + expect($elManual->isManual)->toBeTrue(); + expect($elDiscovered->isManual)->toBeFalse(); + expect($elDiscovered->handlerClass)->toBe(VariousTypesHandler::class); + expect($elDiscovered->handlerMethod)->toBe('noArgsMethod'); +}); + +it('prepares arguments in correct order for simple required types', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'simpleRequiredArgs'); + $args = ['pString' => 'hello', 'pBool' => true, 'pInt' => 123]; + $result = $element->handle($this->container, $args); + + $expectedResult = ['pString' => 'hello', 'pInt' => 123, 'pBool' => true]; + + expect($result)->toBe($expectedResult); +}); + +it('uses default values for missing optional arguments', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'optionalArgsWithDefaults'); + + $result1 = $element->handle($this->container, ['pString' => 'override']); + expect($result1['pString'])->toBe('override'); + expect($result1['pInt'])->toBe(100); + expect($result1['pNullableBool'])->toBeTrue(); + expect($result1['pFloat'])->toBe(3.14); + + $result2 = $element->handle($this->container, []); + expect($result2['pString'])->toBe('default_string'); + expect($result2['pInt'])->toBe(100); + expect($result2['pNullableBool'])->toBeTrue(); + expect($result2['pFloat'])->toBe(3.14); +}); + +it('passes null for nullable arguments if not provided', function () { + $elementNoDefaults = new RegisteredElement(VariousTypesHandler::class, 'nullableArgsWithoutDefaults'); + $result2 = $elementNoDefaults->handle($this->container, []); + expect($result2['pString'])->toBeNull(); + expect($result2['pInt'])->toBeNull(); + expect($result2['pArray'])->toBeNull(); +}); + +it('passes null explicitly for nullable arguments', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'nullableArgsWithoutDefaults'); + $result = $element->handle($this->container, ['pString' => null, 'pInt' => null, 'pArray' => null]); + expect($result['pString'])->toBeNull(); + expect($result['pInt'])->toBeNull(); + expect($result['pArray'])->toBeNull(); +}); + +it('handles mixed type arguments', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'mixedTypeArg'); + $obj = new stdClass(); + $testValues = [ + 'a string', + 123, + true, + null, + ['an', 'array'], + $obj + ]; + foreach ($testValues as $value) { + $result = $element->handle($this->container, ['pMixed' => $value]); + expect($result['pMixed'])->toBe($value); + } +}); + +it('throws McpServerException for missing required argument', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'simpleRequiredArgs'); + $element->handle($this->container, ['pString' => 'hello', 'pInt' => 123]); +})->throws(McpServerException::class, 'Missing required argument `pBool`'); + +dataset('valid_type_casts', [ + 'string_from_int' => ['strParam', 123, '123'], + 'int_from_valid_string' => ['intParam', '456', 456], + 'int_from_neg_string' => ['intParam', '-10', -10], + 'int_from_float_whole' => ['intParam', 77.0, 77], + 'bool_from_int_1' => ['boolProp', 1, true], + 'bool_from_string_true' => ['boolProp', 'true', true], + 'bool_from_string_TRUE' => ['boolProp', 'TRUE', true], + 'bool_from_int_0' => ['boolProp', 0, false], + 'bool_from_string_false' => ['boolProp', 'false', false], + 'bool_from_string_FALSE' => ['boolProp', 'FALSE', false], + 'float_from_valid_string' => ['floatParam', '7.89', 7.89], + 'float_from_int' => ['floatParam', 10, 10.0], + 'array_passthrough' => ['arrayParam', ['x', 'y'], ['x', 'y']], + 'object_passthrough' => ['objectParam', (object)['a' => 1], (object)['a' => 1]], + 'string_for_int_cast_specific' => ['stringForIntCast', '999', 999], + 'string_for_float_cast_specific' => ['stringForFloatCast', '123.45', 123.45], + 'string_for_bool_true_cast_specific' => ['stringForBoolTrueCast', '1', true], + 'string_for_bool_false_cast_specific' => ['stringForBoolFalseCast', '0', false], + 'int_for_string_cast_specific' => ['intForStringCast', 55, '55'], + 'int_for_float_cast_specific' => ['intForFloatCast', 66, 66.0], + 'bool_for_string_cast_specific' => ['boolForStringCast', true, '1'], + 'backed_string_enum_valid_val' => ['backedStringEnumParam', 'A', BackedStringEnum::OptionA], + 'backed_int_enum_valid_val' => ['backedIntEnumParam', 1, BackedIntEnum::First], + 'unit_enum_valid_val' => ['unitEnumParam', 'Yes', UnitEnum::Yes], +]); + +it('casts argument types correctly for valid inputs (comprehensive)', function (string $paramName, mixed $inputValue, mixed $expectedValue) { + $element = new RegisteredElement(VariousTypesHandler::class, 'comprehensiveArgumentTest'); + + $allArgs = [ + 'strParam' => 'default string', + 'intParam' => 0, + 'boolProp' => false, + 'floatParam' => 0.0, + 'arrayParam' => [], + 'backedStringEnumParam' => BackedStringEnum::OptionA, + 'backedIntEnumParam' => BackedIntEnum::First, + 'unitEnumParam' => 'Yes', + 'nullableStringParam' => null, + 'mixedParam' => 'default mixed', + 'objectParam' => new stdClass(), + 'stringForIntCast' => '0', + 'stringForFloatCast' => '0.0', + 'stringForBoolTrueCast' => 'false', + 'stringForBoolFalseCast' => 'true', + 'intForStringCast' => 0, + 'intForFloatCast' => 0, + 'boolForStringCast' => false, + 'valueForBackedStringEnum' => 'A', + 'valueForBackedIntEnum' => 1, + ]; + $testArgs = array_merge($allArgs, [$paramName => $inputValue]); + + $result = $element->handle($this->container, $testArgs); + expect($result[$paramName])->toEqual($expectedValue); +})->with('valid_type_casts'); + + +dataset('invalid_type_casts', [ + 'int_from_alpha_string' => ['intParam', 'abc', '/Cannot cast value to integer/i'], + 'int_from_float_non_whole' => ['intParam', 12.3, '/Cannot cast value to integer/i'], + 'bool_from_string_random' => ['boolProp', 'random', '/Cannot cast value to boolean/i'], + 'bool_from_int_invalid' => ['boolProp', 2, '/Cannot cast value to boolean/i'], + 'float_from_alpha_string' => ['floatParam', 'xyz', '/Cannot cast value to float/i'], + 'array_from_string' => ['arrayParam', 'not_an_array', '/Cannot cast value to array/i'], + 'backed_string_enum_invalid_val' => ['backedStringEnumParam', 'Z', "/Invalid value 'Z' for backed enum .*BackedStringEnum/i"], + 'backed_int_enum_invalid_val' => ['backedIntEnumParam', 99, "/Invalid value '99' for backed enum .*BackedIntEnum/i"], + 'unit_enum_invalid_string_val' => ['unitEnumParam', 'Maybe', "/Invalid value 'Maybe' for unit enum .*UnitEnum/i"], +]); + +it('throws McpServerException for invalid type casting', function (string $paramName, mixed $invalidValue, string $expectedMsgRegex) { + $element = new RegisteredElement(VariousTypesHandler::class, 'comprehensiveArgumentTest'); + $allArgs = [ /* fill with defaults as in valid_type_casts */ + 'strParam' => 's', + 'intParam' => 1, + 'boolProp' => true, + 'floatParam' => 1.1, + 'arrayParam' => [], + 'backedStringEnumParam' => BackedStringEnum::OptionA, + 'backedIntEnumParam' => BackedIntEnum::First, + 'unitEnumParam' => UnitEnum::Yes, + 'nullableStringParam' => null, + 'mixedParam' => 'mix', + 'objectParam' => new stdClass(), + 'stringForIntCast' => '0', + 'stringForFloatCast' => '0.0', + 'stringForBoolTrueCast' => 'false', + 'stringForBoolFalseCast' => 'true', + 'intForStringCast' => 0, + 'intForFloatCast' => 0, + 'boolForStringCast' => false, + 'valueForBackedStringEnum' => 'A', + 'valueForBackedIntEnum' => 1, + ]; + $testArgs = array_merge($allArgs, [$paramName => $invalidValue]); + + try { + $element->handle($this->container, $testArgs); + } catch (McpServerException $e) { + expect($e->getMessage())->toMatch($expectedMsgRegex); + } +})->with('invalid_type_casts'); + +it('casts to BackedStringEnum correctly', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'backedEnumArgs'); + $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 1]); + expect($result['pBackedString'])->toBe(BackedStringEnum::OptionA); +}); + +it('throws for invalid BackedStringEnum value', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'backedEnumArgs'); + $element->handle($this->container, ['pBackedString' => 'Invalid', 'pBackedInt' => 1]); +})->throws(McpServerException::class, "Invalid value 'Invalid' for backed enum"); + +it('casts to BackedIntEnum correctly', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'backedEnumArgs'); + $result = $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 2]); + expect($result['pBackedInt'])->toBe(BackedIntEnum::Second); +}); + +it('throws for invalid BackedIntEnum value', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'backedEnumArgs'); + $element->handle($this->container, ['pBackedString' => 'A', 'pBackedInt' => 999]); +})->throws(McpServerException::class, "Invalid value '999' for backed enum"); + +it('casts to UnitEnum correctly', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'unitEnumArg'); + $result = $element->handle($this->container, ['pUnitEnum' => 'Yes']); + expect($result['pUnitEnum'])->toBe(UnitEnum::Yes); +}); + +it('throws for invalid UnitEnum value', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'unitEnumArg'); + $element->handle($this->container, ['pUnitEnum' => 'Invalid']); +})->throws(McpServerException::class, "Invalid value 'Invalid' for unit enum"); + + +it('throws ReflectionException if handler method does not exist', function () { + $element = new RegisteredElement(VariousTypesHandler::class, 'nonExistentMethod'); + $element->handle($this->container, []); +})->throws(\ReflectionException::class, "Method does not exist"); diff --git a/tests/Unit/Elements/RegisteredPromptTest.php b/tests/Unit/Elements/RegisteredPromptTest.php new file mode 100644 index 0000000..47bb3cc --- /dev/null +++ b/tests/Unit/Elements/RegisteredPromptTest.php @@ -0,0 +1,237 @@ +container = Mockery::mock(ContainerInterface::class); + $this->container->shouldReceive('get') + ->with(PromptHandlerFixture::class) + ->andReturn(new PromptHandlerFixture()) + ->byDefault(); + + $this->promptSchema = PromptSchema::make( + 'test-greeting-prompt', + 'Generates a greeting.', + [PromptArgument::make('name', 'The name to greet.', true)] + ); +}); + +it('constructs correctly with schema, handler, and completion providers', function () { + $providers = ['name' => CompletionProviderFixture::class]; + $prompt = RegisteredPrompt::make( + $this->promptSchema, + PromptHandlerFixture::class, + 'promptWithArgumentCompletion', + false, + $providers + ); + + expect($prompt->schema)->toBe($this->promptSchema); + expect($prompt->handlerClass)->toBe(PromptHandlerFixture::class); + expect($prompt->handlerMethod)->toBe('promptWithArgumentCompletion'); + expect($prompt->isManual)->toBeFalse(); + expect($prompt->completionProviders)->toEqual($providers); + expect($prompt->getCompletionProvider('name'))->toBe(CompletionProviderFixture::class); + expect($prompt->getCompletionProvider('nonExistentArg'))->toBeNull(); +}); + +it('can be made as a manual registration', function () { + $manualPrompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'generateSimpleGreeting', true); + expect($manualPrompt->isManual)->toBeTrue(); +}); + +it('calls handler with prepared arguments via get()', function () { + $handlerMock = Mockery::mock(PromptHandlerFixture::class); + $handlerMock->shouldReceive('generateSimpleGreeting') + ->with('Alice', 'warm') + ->once() + ->andReturn([['role' => 'user', 'content' => 'Warm greeting for Alice.']]); + $this->container->shouldReceive('get')->with(PromptHandlerFixture::class)->andReturn($handlerMock); + + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'generateSimpleGreeting'); + $messages = $prompt->get($this->container, ['name' => 'Alice', 'style' => 'warm']); + + expect($messages[0]->content->text)->toBe('Warm greeting for Alice.'); +}); + +it('formats single PromptMessage object from handler', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnSinglePromptMessageObject'); + $messages = $prompt->get($this->container, []); + expect($messages)->toBeArray()->toHaveCount(1); + expect($messages[0])->toBeInstanceOf(PromptMessage::class); + expect($messages[0]->content->text)->toBe("Single PromptMessage object."); +}); + +it('formats array of PromptMessage objects from handler as is', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnArrayOfPromptMessageObjects'); + $messages = $prompt->get($this->container, []); + expect($messages)->toBeArray()->toHaveCount(2); + expect($messages[0]->content->text)->toBe("First message object."); + expect($messages[1]->content)->toBeInstanceOf(ImageContent::class); +}); + +it('formats empty array from handler as empty array', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnEmptyArrayForPrompt'); + $messages = $prompt->get($this->container, []); + expect($messages)->toBeArray()->toBeEmpty(); +}); + +it('formats simple user/assistant map from handler', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnSimpleUserAssistantMap'); + $messages = $prompt->get($this->container, []); + expect($messages)->toHaveCount(2); + expect($messages[0]->role)->toBe(Role::User); + expect($messages[0]->content->text)->toBe("This is the user's turn."); + expect($messages[1]->role)->toBe(Role::Assistant); + expect($messages[1]->content->text)->toBe("And this is the assistant's reply."); +}); + +it('formats user/assistant map with Content objects', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnUserAssistantMapWithContentObjects'); + $messages = $prompt->get($this->container, []); + expect($messages[0]->role)->toBe(Role::User); + expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("User text content object."); + expect($messages[1]->role)->toBe(Role::Assistant); + expect($messages[1]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("asst_img_data"); +}); + +it('formats user/assistant map with mixed content (string and Content object)', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnUserAssistantMapWithMixedContent'); + $messages = $prompt->get($this->container, []); + expect($messages[0]->role)->toBe(Role::User); + expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("Plain user string."); + expect($messages[1]->role)->toBe(Role::Assistant); + expect($messages[1]->content)->toBeInstanceOf(AudioContent::class)->data->toBe("aud_data"); +}); + +it('formats user/assistant map with array content', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnUserAssistantMapWithArrayContent'); + $messages = $prompt->get($this->container, []); + expect($messages[0]->role)->toBe(Role::User); + expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("User array content"); + expect($messages[1]->role)->toBe(Role::Assistant); + expect($messages[1]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("asst_arr_img_data"); +}); + +it('formats list of raw message arrays with various content types', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnListOfRawMessageArrays'); + $messages = $prompt->get($this->container, []); + expect($messages)->toHaveCount(6); + expect($messages[0]->content->text)->toBe("First raw message string."); + expect($messages[1]->content)->toBeInstanceOf(TextContent::class); + expect($messages[2]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("raw_img_data"); + expect($messages[3]->content)->toBeInstanceOf(AudioContent::class)->data->toBe("raw_aud_data"); + expect($messages[4]->content)->toBeInstanceOf(EmbeddedResource::class); + expect($messages[4]->content->resource->blob)->toBe(base64_encode('pdf-data')); + expect($messages[5]->content)->toBeInstanceOf(EmbeddedResource::class); + expect($messages[5]->content->resource->text)->toBe('{"theme":"dark"}'); +}); + +it('formats list of raw message arrays with scalar or array content (becoming JSON TextContent)', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnListOfRawMessageArraysWithScalars'); + $messages = $prompt->get($this->container, []); + expect($messages)->toHaveCount(5); + expect($messages[0]->content->text)->toBe("123"); + expect($messages[1]->content->text)->toBe("true"); + expect($messages[2]->content->text)->toBe("(null)"); + expect($messages[3]->content->text)->toBe("3.14"); + expect($messages[4]->content->text)->toBe(json_encode(['key' => 'value'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); +}); + +it('formats mixed array of PromptMessage objects and raw message arrays', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'returnMixedArrayOfPromptMessagesAndRaw'); + $messages = $prompt->get($this->container, []); + expect($messages)->toHaveCount(4); + expect($messages[0]->content)->toBeInstanceOf(TextContent::class)->text->toBe("This is a PromptMessage object."); + expect($messages[1]->content)->toBeInstanceOf(TextContent::class)->text->toBe("This is a raw message array."); + expect($messages[2]->content)->toBeInstanceOf(ImageContent::class)->data->toBe("pm_img"); + expect($messages[3]->content)->toBeInstanceOf(TextContent::class)->text->toBe("Raw message with typed content."); +}); + + +dataset('prompt_format_errors', [ + 'non_array_return' => ['promptReturnsNonArray', '/Prompt generator method must return an array/'], + 'invalid_role_in_array' => ['promptReturnsInvalidRole', "/Invalid role 'system'/"], + 'invalid_content_structure_in_array' => ['promptReturnsArrayWithInvalidContentStructure', "/Invalid message format at index 0. Expected an array with 'role' and 'content' keys./"], // More specific from formatMessage + 'invalid_typed_content_in_array' => ['promptReturnsArrayWithInvalidTypedContent', "/Invalid 'image' content at index 0: Missing or invalid 'data' string/"], + 'invalid_resource_content_in_array' => ['promptReturnsArrayWithInvalidResourceContent', "/Invalid resource at index 0: Must contain 'text' or 'blob'./"], +]); + +it('throws RuntimeException for invalid prompt result formats', function (string|callable $handlerMethodOrCallable, string $expectedErrorPattern) { + $methodName = is_string($handlerMethodOrCallable) ? $handlerMethodOrCallable : 'customReturn'; + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, $methodName); + + if (is_callable($handlerMethodOrCallable)) { + $this->container->shouldReceive('get')->with(PromptHandlerFixture::class)->andReturn( + Mockery::mock(PromptHandlerFixture::class, [$methodName => $handlerMethodOrCallable()]) + ); + } + + try { + $prompt->get($this->container, []); + } catch (\RuntimeException $e) { + expect($e->getMessage())->toMatch($expectedErrorPattern); + } + + expect($prompt->toArray())->toBeArray(); +})->with('prompt_format_errors'); + + +it('propagates exceptions from handler during get()', function () { + $prompt = RegisteredPrompt::make($this->promptSchema, PromptHandlerFixture::class, 'promptHandlerThrows'); + $prompt->get($this->container, []); +})->throws(\LogicException::class, "Prompt generation failed inside handler."); + + +it('can be serialized to array and deserialized with completion providers', function () { + $schema = PromptSchema::make( + 'serialize-prompt', + 'Test SerDe', + [PromptArgument::make('arg1', required: true), PromptArgument::make('arg2', 'description for arg2')] + ); + $providers = ['arg1' => CompletionProviderFixture::class]; + $original = RegisteredPrompt::make( + $schema, + PromptHandlerFixture::class, + 'generateSimpleGreeting', + true, + $providers + ); + + $array = $original->toArray(); + + expect($array['schema']['name'])->toBe('serialize-prompt'); + expect($array['schema']['arguments'])->toHaveCount(2); + expect($array['handlerClass'])->toBe(PromptHandlerFixture::class); + expect($array['handlerMethod'])->toBe('generateSimpleGreeting'); + expect($array['isManual'])->toBeTrue(); + expect($array['completionProviders'])->toEqual($providers); + + $rehydrated = RegisteredPrompt::fromArray($array); + expect($rehydrated)->toBeInstanceOf(RegisteredPrompt::class); + expect($rehydrated->schema->name)->toEqual($original->schema->name); + expect($rehydrated->isManual)->toBeTrue(); + expect($rehydrated->completionProviders)->toEqual($providers); +}); + +it('fromArray returns false on failure for prompt', function () { + $badData = ['schema' => ['name' => 'fail'], 'handlerClass' => null, 'handlerMethod' => null]; + expect(RegisteredPrompt::fromArray($badData))->toBeFalse(); +}); diff --git a/tests/Unit/Elements/RegisteredResourceTemplateTest.php b/tests/Unit/Elements/RegisteredResourceTemplateTest.php new file mode 100644 index 0000000..bc74a52 --- /dev/null +++ b/tests/Unit/Elements/RegisteredResourceTemplateTest.php @@ -0,0 +1,222 @@ +container = Mockery::mock(ContainerInterface::class); + $this->handlerInstance = new ResourceHandlerFixture(); + $this->container->shouldReceive('get') + ->with(ResourceHandlerFixture::class) + ->andReturn($this->handlerInstance) + ->byDefault(); + + $this->templateUri = 'item://{category}/{itemId}/details'; + $this->resourceTemplateSchema = ResourceTemplate::make( + $this->templateUri, + 'item-details-template', + mimeType: 'application/json' + ); + + $this->defaultHandlerMethod = 'getUserDocument'; + $this->matchingTemplateSchema = ResourceTemplate::make( + 'user://{userId}/doc/{documentId}', + 'user-doc-template', + mimeType: 'application/json' + ); +}); + +it('constructs correctly with schema, handler, and completion providers', function () { + $completionProviders = [ + 'userId' => CompletionProviderFixture::class, + 'documentId' => 'Another\ProviderClass' + ]; + + $schema = ResourceTemplate::make( + 'user://{userId}/doc/{documentId}', + 'user-doc-template', + mimeType: 'application/json' + ); + + $template = RegisteredResourceTemplate::make( + schema: $schema, + handlerClass: ResourceHandlerFixture::class, + handlerMethod: 'getUserDocument', + completionProviders: $completionProviders + ); + + expect($template->schema)->toBe($schema); + expect($template->handlerClass)->toBe(ResourceHandlerFixture::class); + expect($template->handlerMethod)->toBe('getUserDocument'); + expect($template->isManual)->toBeFalse(); + expect($template->completionProviders)->toEqual($completionProviders); + expect($template->getCompletionProvider('userId'))->toBe(CompletionProviderFixture::class); + expect($template->getCompletionProvider('documentId'))->toBe('Another\ProviderClass'); + expect($template->getCompletionProvider('nonExistentVar'))->toBeNull(); +}); + +it('can be made as a manual registration', function () { + $schema = ResourceTemplate::make( + 'user://{userId}/doc/{documentId}', + 'user-doc-template', + mimeType: 'application/json' + ); + + $manualTemplate = RegisteredResourceTemplate::make( + schema: $schema, + handlerClass: ResourceHandlerFixture::class, + handlerMethod: 'getUserDocument', + isManual: true + ); + + expect($manualTemplate->isManual)->toBeTrue(); +}); + +dataset('uri_template_matching_cases', [ + 'simple_var' => ['user://{userId}', 'user://12345', ['userId' => '12345']], + 'simple_var_alpha' => ['user://{userId}', 'user://abc-def', ['userId' => 'abc-def']], + 'no_match_missing_var_part' => ['user://{userId}', 'user://', null], + 'no_match_prefix' => ['user://{userId}', 'users://12345', null], + 'multi_var' => ['item://{category}/{itemId}/details', 'item://books/978-abc/details', ['category' => 'books', 'itemId' => '978-abc']], + 'multi_var_empty_segment_fail' => ['item://{category}/{itemId}/details', 'item://books//details', null], // [^/]+ fails on empty segment + 'multi_var_wrong_literal_end' => ['item://{category}/{itemId}/details', 'item://books/978-abc/summary', null], + 'multi_var_no_suffix_literal' => ['item://{category}/{itemId}', 'item://tools/hammer', ['category' => 'tools', 'itemId' => 'hammer']], + 'multi_var_extra_segment_fail' => ['item://{category}/{itemId}', 'item://tools/hammer/extra', null], + 'mixed_literals_vars' => ['user://{userId}/profile/pic_{picId}.jpg', 'user://kp/profile/pic_main.jpg', ['userId' => 'kp', 'picId' => 'main']], + 'mixed_wrong_extension' => ['user://{userId}/profile/pic_{picId}.jpg', 'user://kp/profile/pic_main.png', null], + 'mixed_wrong_literal_prefix' => ['user://{userId}/profile/img_{picId}.jpg', 'user://kp/profile/pic_main.jpg', null], + 'escapable_chars_in_literal' => ['search://{query}/results.json?page={pageNo}', 'search://term.with.dots/results.json?page=2', ['query' => 'term.with.dots', 'pageNo' => '2']], +]); + +it('matches URIs against template and extracts variables correctly', function (string $templateString, string $uriToTest, ?array $expectedVariables) { + $schema = ResourceTemplate::make($templateString, 'test-match'); + $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'getUserDocument'); + + if ($expectedVariables !== null) { + expect($template->matches($uriToTest))->toBeTrue(); + $reflection = new \ReflectionClass($template); + $prop = $reflection->getProperty('uriVariables'); + $prop->setAccessible(true); + expect($prop->getValue($template))->toEqual($expectedVariables); + } else { + expect($template->matches($uriToTest))->toBeFalse(); + } +})->with('uri_template_matching_cases'); + +it('gets variable names from compiled template', function () { + $schema = ResourceTemplate::make('foo://{varA}/bar/{varB_ext}.{format}', 'vars-test'); + $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'getUserDocument'); + expect($template->getVariableNames())->toEqualCanonicalizing(['varA', 'varB_ext', 'format']); +}); + +it('reads resource using handler with extracted URI variables', function () { + $uriTemplate = 'item://{category}/{itemId}?format={format}'; + $uri = 'item://electronics/tv-123?format=json_pretty'; + $schema = ResourceTemplate::make($uriTemplate, 'item-details-template'); + $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'getTemplatedContent'); + + expect($template->matches($uri))->toBeTrue(); + + $resultContents = $template->read($this->container, $uri); + + expect($resultContents)->toBeArray()->toHaveCount(1); + + $content = $resultContents[0]; + expect($content)->toBeInstanceOf(TextResourceContents::class); + expect($content->uri)->toBe($uri); + expect($content->mimeType)->toBe('application/json'); + + $decodedText = json_decode($content->text, true); + expect($decodedText['message'])->toBe("Content for item tv-123 in category electronics, format json_pretty."); + expect($decodedText['category_received'])->toBe('electronics'); + expect($decodedText['itemId_received'])->toBe('tv-123'); + expect($decodedText['format_received'])->toBe('json_pretty'); +}); + +it('uses mimeType from schema if handler result does not specify one', function () { + $uriTemplate = 'item://{category}/{itemId}?format={format}'; + $uri = 'item://books/bestseller?format=json_pretty'; + $schema = ResourceTemplate::make($uriTemplate, 'test-mime', mimeType: 'application/vnd.custom-template-xml'); + $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'getTemplatedContent'); + expect($template->matches($uri))->toBeTrue(); + + $resultContents = $template->read($this->container, $uri); + expect($resultContents[0]->mimeType)->toBe('application/vnd.custom-template-xml'); +}); + +it('formats a simple string result from handler correctly for template', function () { + $uri = 'item://tools/hammer'; + $schema = ResourceTemplate::make('item://{type}/{name}', 'test-simple-string', mimeType: 'text/x-custom'); + $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'returnStringText'); + expect($template->matches($uri))->toBeTrue(); + + $mockHandler = Mockery::mock(ResourceHandlerFixture::class); + $mockHandler->shouldReceive('returnStringText')->with($uri)->once()->andReturn('Simple content from template handler'); + $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($mockHandler); + + $resultContents = $template->read($this->container, $uri); + expect($resultContents[0])->toBeInstanceOf(TextResourceContents::class) + ->and($resultContents[0]->text)->toBe('Simple content from template handler') + ->and($resultContents[0]->mimeType)->toBe('text/x-custom'); // From schema +}); + +it('propagates exceptions from handler during read', function () { + $uri = 'item://tools/hammer'; + $schema = ResourceTemplate::make('item://{type}/{name}', 'test-simple-string', mimeType: 'text/x-custom'); + $template = RegisteredResourceTemplate::make($schema, ResourceHandlerFixture::class, 'handlerThrowsException'); + expect($template->matches($uri))->toBeTrue(); + $template->read($this->container, $uri); +})->throws(\DomainException::class, "Cannot read resource"); + +it('can be serialized to array and deserialized', function () { + $schema = ResourceTemplate::make( + 'obj://{type}/{id}', + 'my-template', + mimeType: 'application/template+json', + annotations: Annotations::make(priority: 0.7) + ); + + $providers = ['type' => CompletionProviderFixture::class]; + + $original = RegisteredResourceTemplate::make( + $schema, + ResourceHandlerFixture::class, + 'getUserDocument', + true, + $providers + ); + + $array = $original->toArray(); + + expect($array['schema']['uriTemplate'])->toBe('obj://{type}/{id}'); + expect($array['schema']['name'])->toBe('my-template'); + expect($array['schema']['mimeType'])->toBe('application/template+json'); + expect($array['schema']['annotations']['priority'])->toBe(0.7); + expect($array['handlerClass'])->toBe(ResourceHandlerFixture::class); + expect($array['handlerMethod'])->toBe('getUserDocument'); + expect($array['isManual'])->toBeTrue(); + expect($array['completionProviders'])->toEqual($providers); + + $rehydrated = RegisteredResourceTemplate::fromArray($array); + expect($rehydrated)->toBeInstanceOf(RegisteredResourceTemplate::class); + expect($rehydrated->schema->uriTemplate)->toEqual($original->schema->uriTemplate); + expect($rehydrated->schema->name)->toEqual($original->schema->name); + expect($rehydrated->isManual)->toBeTrue(); + expect($rehydrated->completionProviders)->toEqual($providers); +}); + +it('fromArray returns false on failure', function () { + $badData = ['schema' => ['uriTemplate' => 'fail'], 'handlerMethod' => null]; + expect(RegisteredResourceTemplate::fromArray($badData))->toBeFalse(); +}); diff --git a/tests/Unit/Elements/RegisteredResourceTest.php b/tests/Unit/Elements/RegisteredResourceTest.php new file mode 100644 index 0000000..4349a7b --- /dev/null +++ b/tests/Unit/Elements/RegisteredResourceTest.php @@ -0,0 +1,230 @@ +container = Mockery::mock(ContainerInterface::class); + $this->handlerInstance = new ResourceHandlerFixture(); + $this->container->shouldReceive('get') + ->with(ResourceHandlerFixture::class) + ->andReturn($this->handlerInstance) + ->byDefault(); + + $this->testUri = 'test://resource/item.txt'; + $this->resourceSchema = ResourceSchema::make($this->testUri, 'test-resource', mimeType: 'text/plain'); + $this->registeredResource = RegisteredResource::make( + $this->resourceSchema, + ResourceHandlerFixture::class, + 'returnStringText' + ); +}); + +afterEach(function () { + if (ResourceHandlerFixture::$unlinkableSplFile && file_exists(ResourceHandlerFixture::$unlinkableSplFile)) { + @unlink(ResourceHandlerFixture::$unlinkableSplFile); + ResourceHandlerFixture::$unlinkableSplFile = null; + } +}); + +it('constructs correctly and exposes schema', function () { + expect($this->registeredResource->schema)->toBe($this->resourceSchema); + expect($this->registeredResource->handlerClass)->toBe(ResourceHandlerFixture::class); + expect($this->registeredResource->handlerMethod)->toBe('returnStringText'); + expect($this->registeredResource->isManual)->toBeFalse(); +}); + +it('can be made as a manual registration', function () { + $manualResource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'returnStringText', true); + expect($manualResource->isManual)->toBeTrue(); +}); + +it('passes URI to handler if handler method expects it', function () { + $resource = RegisteredResource::make( + ResourceSchema::make($this->testUri, 'needs-uri'), + ResourceHandlerFixture::class, + 'resourceHandlerNeedsUri' + ); + + $handlerMock = Mockery::mock(ResourceHandlerFixture::class); + $handlerMock->shouldReceive('resourceHandlerNeedsUri') + ->with($this->testUri) + ->once() + ->andReturn("Confirmed URI: {$this->testUri}"); + $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($handlerMock); + + $result = $resource->read($this->container, $this->testUri); + expect($result[0]->text)->toBe("Confirmed URI: {$this->testUri}"); +}); + +it('does not require handler method to accept URI', function () { + $resource = RegisteredResource::make( + ResourceSchema::make($this->testUri, 'no-uri-param'), + ResourceHandlerFixture::class, + 'resourceHandlerDoesNotNeedUri' + ); + $handlerMock = Mockery::mock(ResourceHandlerFixture::class); + $handlerMock->shouldReceive('resourceHandlerDoesNotNeedUri')->once()->andReturn("Success no URI"); + $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn($handlerMock); + + $result = $resource->read($this->container, $this->testUri); + expect($result[0]->text)->toBe("Success no URI"); +}); + + +dataset('resource_handler_return_types', [ + 'string_text' => ['returnStringText', 'text/plain', fn($text, $uri) => expect($text)->toBe("Plain string content for {$uri}"), null], + 'string_json_guess' => ['returnStringJson', 'application/json', fn($text, $uri) => expect(json_decode($text, true)['uri_in_json'])->toBe($uri), null], + 'string_html_guess' => ['returnStringHtml', 'text/html', fn($text, $uri) => expect($text)->toContain("{$uri}"), null], + 'array_json_schema_mime' => ['returnArrayJson', 'application/json', fn($text, $uri) => expect(json_decode($text, true)['uri_in_array'])->toBe($uri), null], // schema has text/plain, overridden by array + JSON content + 'empty_array' => ['returnEmptyArray', 'application/json', fn($text) => expect($text)->toBe('[]'), null], + 'stream_octet' => ['returnStream', 'application/octet-stream', null, fn($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Streamed content for {$uri}")], + 'array_for_blob' => ['returnArrayForBlobSchema', 'application/x-custom-blob-array', null, fn($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Blob for {$uri} via array")], + 'array_for_text' => ['returnArrayForTextSchema', 'text/vnd.custom-array-text', fn($text, $uri) => expect($text)->toBe("Text from array for {$uri} via array"), null], + 'direct_TextResourceContents' => ['returnTextResourceContents', 'text/special-contents', fn($text) => expect($text)->toBe('Direct TextResourceContents'), null], + 'direct_BlobResourceContents' => ['returnBlobResourceContents', 'application/custom-blob-contents', null, fn($blob) => expect(base64_decode($blob ?? ''))->toBe('blobbycontents')], + 'direct_EmbeddedResource' => ['returnEmbeddedResource', 'application/vnd.custom-embedded', fn($text) => expect($text)->toBe('Direct EmbeddedResource content'), null], +]); + +it('formats various handler return types correctly', function (string $handlerMethod, string $expectedMime, ?callable $textAssertion, ?callable $blobAssertion) { + $schema = ResourceSchema::make($this->testUri, 'format-test'); + $resource = RegisteredResource::make($schema, ResourceHandlerFixture::class, $handlerMethod); + + $resultContents = $resource->read($this->container, $this->testUri); + + expect($resultContents)->toBeArray()->toHaveCount(1); + $content = $resultContents[0]; + + expect($content->uri)->toBe($this->testUri); + expect($content->mimeType)->toBe($expectedMime); + + if ($textAssertion) { + expect($content)->toBeInstanceOf(TextResourceContents::class); + $textAssertion($content->text, $this->testUri); + } + if ($blobAssertion) { + expect($content)->toBeInstanceOf(BlobResourceContents::class); + $blobAssertion($content->blob, $this->testUri); + } +})->with('resource_handler_return_types'); + +it('formats SplFileInfo based on schema MIME type (text)', function () { + $schema = ResourceSchema::make($this->testUri, 'spl-text', mimeType: 'text/markdown'); + $resource = RegisteredResource::make($schema, ResourceHandlerFixture::class, 'returnSplFileInfo'); + $result = $resource->read($this->container, $this->testUri); + + expect($result[0])->toBeInstanceOf(TextResourceContents::class); + expect($result[0]->mimeType)->toBe('text/markdown'); + expect($result[0]->text)->toBe("Content from SplFileInfo for {$this->testUri}"); +}); + +it('formats SplFileInfo based on schema MIME type (blob if not text like)', function () { + $schema = ResourceSchema::make($this->testUri, 'spl-blob', mimeType: 'image/png'); + $resource = RegisteredResource::make($schema, ResourceHandlerFixture::class, 'returnSplFileInfo'); + $result = $resource->read($this->container, $this->testUri); + + expect($result[0])->toBeInstanceOf(BlobResourceContents::class); + expect($result[0]->mimeType)->toBe('image/png'); + expect(base64_decode($result[0]->blob ?? ''))->toBe("Content from SplFileInfo for {$this->testUri}"); +}); + +it('formats array of ResourceContents as is', function () { + $resource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'returnArrayOfResourceContents'); + $results = $resource->read($this->container, $this->testUri); + expect($results)->toHaveCount(2); + expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe('Part 1 of many RC'); + expect($results[1])->toBeInstanceOf(BlobResourceContents::class)->blob->toBe(base64_encode('pngdata')); +}); + +it('formats array of EmbeddedResources by extracting their inner resource', function () { + $resource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'returnArrayOfEmbeddedResources'); + $results = $resource->read($this->container, $this->testUri); + expect($results)->toHaveCount(2); + expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe(''); + expect($results[1])->toBeInstanceOf(BlobResourceContents::class)->blob->toBe(base64_encode('fontdata')); +}); + +it('formats mixed array with ResourceContent/EmbeddedResource by processing each item', function () { + $resource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'returnMixedArrayWithResourceTypes'); + $results = $resource->read($this->container, $this->testUri); + + expect($results)->toBeArray()->toHaveCount(4); + expect($results[0])->toBeInstanceOf(TextResourceContents::class)->text->toBe("A raw string piece"); + expect($results[1])->toBeInstanceOf(TextResourceContents::class)->text->toBe("**Markdown!**"); + expect($results[2])->toBeInstanceOf(TextResourceContents::class); + expect(json_decode($results[2]->text, true))->toEqual(['nested_array_data' => 'value', 'for_uri' => $this->testUri]); + expect($results[3])->toBeInstanceOf(TextResourceContents::class)->text->toBe("col1,col2"); +}); + + +it('propagates McpServerException from handler during read', function () { + $resource = RegisteredResource::make( + $this->resourceSchema, + ResourceHandlerFixture::class, + 'resourceHandlerNeedsUri' + ); + $this->container->shouldReceive('get')->with(ResourceHandlerFixture::class)->andReturn( + Mockery::mock(ResourceHandlerFixture::class, function (Mockery\MockInterface $mock) { + $mock->shouldReceive('resourceHandlerNeedsUri')->andThrow(McpServerException::invalidParams("Test error")); + }) + ); + $resource->read($this->container, $this->testUri); +})->throws(McpServerException::class, "Test error"); + +it('propagates other exceptions from handler during read', function () { + $resource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'handlerThrowsException'); + $resource->read($this->container, $this->testUri); +})->throws(\DomainException::class, "Cannot read resource"); + +it('throws RuntimeException for unformattable handler result', function () { + $resource = RegisteredResource::make($this->resourceSchema, ResourceHandlerFixture::class, 'returnUnformattableType'); + $resource->read($this->container, $this->testUri); +})->throws(\RuntimeException::class, "Cannot format resource read result for URI"); + + +it('can be serialized to array and deserialized', function () { + $original = RegisteredResource::make( + ResourceSchema::make( + 'uri://test', + 'my-resource', + 'desc', + 'app/foo', + ), + ResourceHandlerFixture::class, + 'getStaticText', + true + ); + + $array = $original->toArray(); + + expect($array['schema']['uri'])->toBe('uri://test'); + expect($array['schema']['name'])->toBe('my-resource'); + expect($array['schema']['description'])->toBe('desc'); + expect($array['schema']['mimeType'])->toBe('app/foo'); + expect($array['handlerClass'])->toBe(ResourceHandlerFixture::class); + expect($array['handlerMethod'])->toBe('getStaticText'); + expect($array['isManual'])->toBeTrue(); + + $rehydrated = RegisteredResource::fromArray($array); + expect($rehydrated)->toBeInstanceOf(RegisteredResource::class); + expect($rehydrated->schema->uri)->toEqual($original->schema->uri); + expect($rehydrated->schema->name)->toEqual($original->schema->name); + expect($rehydrated->isManual)->toBeTrue(); +}); + +it('fromArray returns false on failure', function () { + $badData = ['schema' => ['uri' => 'fail'], 'handlerClass' => null]; + expect(RegisteredResource::fromArray($badData))->toBeFalse(); +}); diff --git a/tests/Unit/Elements/RegisteredToolTest.php b/tests/Unit/Elements/RegisteredToolTest.php new file mode 100644 index 0000000..46256b5 --- /dev/null +++ b/tests/Unit/Elements/RegisteredToolTest.php @@ -0,0 +1,180 @@ +container = Mockery::mock(ContainerInterface::class); + $this->handlerInstance = new ToolHandlerFixture(); + $this->container->shouldReceive('get')->with(ToolHandlerFixture::class) + ->andReturn($this->handlerInstance)->byDefault(); + + $this->toolSchema = Tool::make( + name: 'test-tool', + inputSchema: ['type' => 'object', 'properties' => ['name' => ['type' => 'string']]] + ); + + $this->registeredTool = RegisteredTool::make( + $this->toolSchema, + ToolHandlerFixture::class, + 'greet' + ); +}); + +it('constructs correctly and exposes schema', function () { + expect($this->registeredTool->schema)->toBe($this->toolSchema); + expect($this->registeredTool->handlerClass)->toBe(ToolHandlerFixture::class); + expect($this->registeredTool->handlerMethod)->toBe('greet'); + expect($this->registeredTool->isManual)->toBeFalse(); +}); + +it('can be made as a manual registration', function () { + $manualTool = RegisteredTool::make($this->toolSchema, ToolHandlerFixture::class, 'greet', true); + expect($manualTool->isManual)->toBeTrue(); +}); + +it('calls the handler with prepared arguments', function () { + $tool = RegisteredTool::make( + Tool::make('sum-tool', ['type' => 'object', 'properties' => ['a' => ['type' => 'integer'], 'b' => ['type' => 'integer']]]), + ToolHandlerFixture::class, + 'sum' + ); + $mockHandler = Mockery::mock(ToolHandlerFixture::class); + $mockHandler->shouldReceive('sum')->with(5, 10)->once()->andReturn(15); + $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->andReturn($mockHandler); + + $resultContents = $tool->call($this->container, ['a' => 5, 'b' => '10']); // '10' will be cast to int by prepareArguments + + expect($resultContents)->toBeArray()->toHaveCount(1); + expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe('15'); +}); + +it('calls handler with no arguments if tool takes none and none provided', function () { + $tool = RegisteredTool::make( + Tool::make('no-args-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + 'noParamsTool' + ); + $mockHandler = Mockery::mock(ToolHandlerFixture::class); + $mockHandler->shouldReceive('noParamsTool')->withNoArgs()->once()->andReturn(['status' => 'done']); + $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->andReturn($mockHandler); + + $resultContents = $tool->call($this->container, []); + expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode(['status' => 'done'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); +}); + + +dataset('tool_handler_return_values', [ + 'string' => ['returnString', "This is a string result."], + 'integer' => ['returnInteger', "12345"], + 'float' => ['returnFloat', "67.89"], + 'boolean_true' => ['returnBooleanTrue', "true"], + 'boolean_false' => ['returnBooleanFalse', "false"], + 'null' => ['returnNull', "(null)"], + 'array_to_json' => ['returnArray', json_encode(['message' => 'Array result', 'data' => [1, 2, 3]], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)], + 'object_to_json' => ['returnStdClass', json_encode((object)['property' => "value"], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)], +]); + +it('formats various scalar and simple object/array handler results into TextContent', function (string $handlerMethod, string $expectedText) { + $tool = RegisteredTool::make( + Tool::make('format-test-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + $handlerMethod + ); + + $resultContents = $tool->call($this->container, []); + + expect($resultContents)->toBeArray()->toHaveCount(1); + expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe($expectedText); +})->with('tool_handler_return_values'); + +it('returns single Content object from handler as array with one Content object', function () { + $tool = RegisteredTool::make( + Tool::make('content-test-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + 'returnTextContent' + ); + $resultContents = $tool->call($this->container, []); + + expect($resultContents)->toBeArray()->toHaveCount(1); + expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("Pre-formatted TextContent."); +}); + +it('returns array of Content objects from handler as is', function () { + $tool = RegisteredTool::make( + Tool::make('content-array-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + 'returnArrayOfContent' + ); + $resultContents = $tool->call($this->container, []); + + expect($resultContents)->toBeArray()->toHaveCount(2); + expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("Part 1"); + expect($resultContents[1])->toBeInstanceOf(ImageContent::class)->data->toBe("imgdata"); +}); + +it('formats mixed array from handler into array of Content objects', function () { + $tool = RegisteredTool::make( + Tool::make('mixed-array-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + 'returnMixedArray' + ); + $resultContents = $tool->call($this->container, []); + + expect($resultContents)->toBeArray()->toHaveCount(8); + + expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe("A raw string"); + expect($resultContents[1])->toBeInstanceOf(TextContent::class)->text->toBe("A TextContent object"); // Original TextContent is preserved + expect($resultContents[2])->toBeInstanceOf(TextContent::class)->text->toBe("123"); + expect($resultContents[3])->toBeInstanceOf(TextContent::class)->text->toBe("true"); + expect($resultContents[4])->toBeInstanceOf(TextContent::class)->text->toBe("(null)"); + expect($resultContents[5])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode(['nested_key' => 'nested_value', 'sub_array' => [4, 5]], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + expect($resultContents[6])->toBeInstanceOf(ImageContent::class)->data->toBe("img_data_mixed"); // Original ImageContent is preserved + expect($resultContents[7])->toBeInstanceOf(TextContent::class)->text->toBe(json_encode((object)['obj_prop' => 'obj_val'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); +}); + +it('formats empty array from handler into TextContent with "[]"', function () { + $tool = RegisteredTool::make( + Tool::make('empty-array-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + 'returnEmptyArray' + ); + $resultContents = $tool->call($this->container, []); + + expect($resultContents)->toBeArray()->toHaveCount(1); + expect($resultContents[0])->toBeInstanceOf(TextContent::class)->text->toBe('[]'); +}); + +it('throws JsonException during formatResult if handler returns unencodable value', function () { + $tool = RegisteredTool::make( + Tool::make('unencodable-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + 'toolUnencodableResult' + ); + $tool->call($this->container, []); +})->throws(JsonException::class); + +it('re-throws exceptions from handler execution wrapped in McpServerException from handle()', function () { + $tool = RegisteredTool::make( + Tool::make('exception-tool', ['type' => 'object', 'properties' => []]), + ToolHandlerFixture::class, + 'toolThatThrows' + ); + + $this->container->shouldReceive('get')->with(ToolHandlerFixture::class)->once()->andReturn(new ToolHandlerFixture()); + + $tool->call($this->container, []); +})->throws(InvalidArgumentException::class, "Something went wrong in the tool."); diff --git a/tests/Unit/ProtocolTest.php b/tests/Unit/ProtocolTest.php index 28762f4..79de85c 100644 --- a/tests/Unit/ProtocolTest.php +++ b/tests/Unit/ProtocolTest.php @@ -4,245 +4,538 @@ use Mockery; use Mockery\MockInterface; +use PhpMcp\Schema\Implementation; use PhpMcp\Server\Configuration; use PhpMcp\Server\Contracts\ServerTransportInterface; +use PhpMcp\Server\Dispatcher; use PhpMcp\Server\Exception\McpServerException; +use PhpMcp\Schema\JsonRpc\BatchRequest; +use PhpMcp\Schema\JsonRpc\BatchResponse; +use PhpMcp\Schema\JsonRpc\Error; use PhpMcp\Schema\JsonRpc\Notification; use PhpMcp\Schema\JsonRpc\Request; use PhpMcp\Schema\JsonRpc\Response; +use PhpMcp\Schema\Notification\ResourceListChangedNotification; +use PhpMcp\Schema\Notification\ResourceUpdatedNotification; +use PhpMcp\Schema\Notification\ToolListChangedNotification; use PhpMcp\Schema\Result\EmptyResult; -use PhpMcp\Server\Model\Capabilities; +use PhpMcp\Schema\ServerCapabilities; use PhpMcp\Server\Protocol; use PhpMcp\Server\Registry; -use PhpMcp\Server\State\ClientStateManager; -use PhpMcp\Server\Support\RequestProcessor; +use PhpMcp\Server\Session\SessionManager; +use PhpMcp\Server\Contracts\SessionInterface; +use PhpMcp\Server\Session\SubscriptionManager; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; -use React\EventLoop\Loop; - +use React\EventLoop\LoopInterface; use function React\Async\await; use function React\Promise\resolve; +use function React\Promise\reject; + +const SESSION_ID = 'session-test-789'; +const SUPPORTED_VERSION_PROTO = Protocol::LATEST_PROTOCOL_VERSION; +const SERVER_NAME_PROTO = 'Test Protocol Server'; +const SERVER_VERSION_PROTO = '0.3.0'; + +function createRequest(string $method, array $params = [], string|int $id = 'req-proto-1'): Request +{ + return new Request('2.0', $id, $method, $params); +} + +function createNotification(string $method, array $params = []): Notification +{ + return new Notification('2.0', $method, $params); +} + +function expectErrorResponse(mixed $response, int $expectedCode, string|int|null $expectedId = 'req-proto-1'): void +{ + test()->expect($response)->toBeInstanceOf(Error::class); + test()->expect($response->id)->toBe($expectedId); + test()->expect($response->code)->toBe($expectedCode); + test()->expect($response->jsonrpc)->toBe('2.0'); +} + +function expectSuccessResponse(mixed $response, mixed $expectedResult, string|int|null $expectedId = 'req-proto-1'): void +{ + test()->expect($response)->toBeInstanceOf(Response::class); + test()->expect($response->id)->toBe($expectedId); + test()->expect($response->jsonrpc)->toBe('2.0'); + test()->expect($response->result)->toBe($expectedResult); +} + beforeEach(function () { - $this->requestProcessor = Mockery::mock(RequestProcessor::class); - $this->clientStateManager = Mockery::mock(ClientStateManager::class); + /** @var MockInterface&Registry $registry */ $this->registry = Mockery::mock(Registry::class); - /** @var MockInterface&LoggerInterface */ + /** @var MockInterface&SessionManager $sessionManager */ + $this->sessionManager = Mockery::mock(SessionManager::class); + /** @var MockInterface&Dispatcher $dispatcher */ + $this->dispatcher = Mockery::mock(Dispatcher::class); + /** @var MockInterface&SubscriptionManager $subscriptionManager */ + $this->subscriptionManager = Mockery::mock(SubscriptionManager::class); + /** @var MockInterface&LoggerInterface $logger */ $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); + /** @var MockInterface&ServerTransportInterface $transport */ + $this->transport = Mockery::mock(ServerTransportInterface::class); + /** @var MockInterface&SessionInterface $session */ + $this->session = Mockery::mock(SessionInterface::class); - /** @var MockInterface&CacheInterface */ + /** @var MockInterface&LoopInterface $loop */ + $loop = Mockery::mock(LoopInterface::class); + /** @var MockInterface&CacheInterface $cache */ $cache = Mockery::mock(CacheInterface::class); - $loop = Loop::get(); + /** @var MockInterface&ContainerInterface $container */ $container = Mockery::mock(ContainerInterface::class); $this->configuration = new Configuration( - 'Test Server', - '1.0.0', - Capabilities::forServer(), - $this->logger, - $loop, - $cache, - $container, + serverInfo: Implementation::make(SERVER_NAME_PROTO, SERVER_VERSION_PROTO), + capabilities: ServerCapabilities::make(), + logger: $this->logger, + loop: $loop, + cache: $cache, + container: $container ); - $this->transport = Mockery::mock(ServerTransportInterface::class); + $this->sessionManager->shouldReceive('getSession')->with(SESSION_ID)->andReturn($this->session)->byDefault(); + $this->sessionManager->shouldReceive('on')->withAnyArgs()->byDefault(); - $this->protocol = new Protocol( - $this->configuration, - $this->registry, - $this->clientStateManager, - $this->requestProcessor, - ); + $this->registry->shouldReceive('on')->withAnyArgs()->byDefault(); + + $this->session->shouldReceive('get')->with('initialized', false)->andReturn(true)->byDefault(); + $this->session->shouldReceive('save')->byDefault(); $this->transport->shouldReceive('on')->withAnyArgs()->byDefault(); $this->transport->shouldReceive('removeListener')->withAnyArgs()->byDefault(); - $this->transport->shouldReceive('sendToClientAsync') + $this->transport->shouldReceive('sendMessage') ->withAnyArgs() ->andReturn(resolve(null)) ->byDefault(); + $this->protocol = new Protocol( + $this->configuration, + $this->registry, + $this->sessionManager, + $this->dispatcher, + $this->subscriptionManager + ); + $this->protocol->bindTransport($this->transport); }); -afterEach(function () { - Mockery::close(); +it('listens to SessionManager events on construction', function () { + $this->sessionManager->shouldHaveReceived('on')->with('session_deleted', Mockery::type('callable')); }); -it('can handle a valid request', function () { - $clientId = 'client-req-1'; - $requestId = 123; - $method = 'test/method'; - $params = ['a' => 1]; - $rawJson = json_encode(['jsonrpc' => '2.0', 'id' => $requestId, 'method' => $method, 'params' => $params]); - $expectedResponse = Response::make(new EmptyResult(), $requestId); - $expectedResponseJson = json_encode($expectedResponse->toArray()); +it('listens to Registry events on construction', function () { + $this->registry->shouldHaveReceived('on')->with('list_changed', Mockery::type('callable')); +}); - $this->requestProcessor->shouldReceive('process')->once()->with(Mockery::type(Request::class), $clientId)->andReturn($expectedResponse); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, $expectedResponseJson . "\n")->andReturn(resolve(null)); +it('binds to a transport and attaches listeners', function () { + $newTransport = Mockery::mock(ServerTransportInterface::class); + $newTransport->shouldReceive('on')->with('message', Mockery::type('callable'))->once(); + $newTransport->shouldReceive('on')->with('client_connected', Mockery::type('callable'))->once(); + $newTransport->shouldReceive('on')->with('client_disconnected', Mockery::type('callable'))->once(); + $newTransport->shouldReceive('on')->with('error', Mockery::type('callable'))->once(); - $this->protocol->handleRawMessage($rawJson, $clientId); - // Mockery verifies calls + $this->protocol->bindTransport($newTransport); }); -it('can handle a valid notification', function () { - $clientId = 'client-notif-1'; - $method = 'notify/event'; - $params = ['b' => 2]; - $rawJson = json_encode(['jsonrpc' => '2.0', 'method' => $method, 'params' => $params]); +it('unbinds from a previous transport when binding a new one', function () { + $this->transport->shouldReceive('removeListener')->times(4); - $this->requestProcessor->shouldReceive('process')->once()->with(Mockery::type(Notification::class), $clientId)->andReturn(null); - $this->transport->shouldNotReceive('sendToClientAsync'); + $newTransport = Mockery::mock(ServerTransportInterface::class); + $newTransport->shouldReceive('on')->times(4); - $this->protocol->handleRawMessage($rawJson, $clientId); + $this->protocol->bindTransport($newTransport); }); -it('sends a parse error response for invalid JSON', function () { - $clientId = 'client-err-parse'; - $rawJson = '{"jsonrpc":"2.0", "id":'; +it('unbinds transport and removes listeners', function () { + $this->transport->shouldReceive('removeListener')->with('message', Mockery::type('callable'))->once(); + $this->transport->shouldReceive('removeListener')->with('client_connected', Mockery::type('callable'))->once(); + $this->transport->shouldReceive('removeListener')->with('client_disconnected', Mockery::type('callable'))->once(); + $this->transport->shouldReceive('removeListener')->with('error', Mockery::type('callable'))->once(); - $this->requestProcessor->shouldNotReceive('process'); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32700') && str_contains($json, '"id":null')))->andReturn(resolve(null)); + $this->protocol->unbindTransport(); - $this->protocol->handleRawMessage($rawJson, $clientId); + $reflection = new \ReflectionClass($this->protocol); + $transportProp = $reflection->getProperty('transport'); + $transportProp->setAccessible(true); + expect($transportProp->getValue($this->protocol))->toBeNull(); }); -it('sends an invalid request error response for a request with missing method', function () { - $clientId = 'client-err-invalid'; - $rawJson = '{"jsonrpc":"2.0", "id": 456}'; // Missing method +it('processes a valid Request message', function () { + $request = createRequest('test/method', ['param' => 1]); + $result = new EmptyResult(); + $expectedResponse = Response::make($request->id, $result); + + $this->dispatcher->shouldReceive('handleRequest')->once() + ->with(Mockery::on(fn($arg) => $arg instanceof Request && $arg->method === 'test/method'), $this->session) + ->andReturn($result); - $this->requestProcessor->shouldNotReceive('process'); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32600') && str_contains($json, '"id":456')))->andReturn(resolve(null)); + $this->transport->shouldReceive('sendMessage')->once() + ->with(Mockery::on(fn($arg) => $arg instanceof Response && $arg->id === $request->id && $arg->result === $result), SESSION_ID, Mockery::any()) + ->andReturn(resolve(null)); - $this->protocol->handleRawMessage($rawJson, $clientId); + $this->protocol->processMessage($request, SESSION_ID); + $this->session->shouldHaveReceived('save'); }); -it('sends a mcp error response for a method not found', function () { - $clientId = 'client-err-mcp'; - $requestId = 789; - $method = 'nonexistent/method'; - $rawJson = json_encode(['jsonrpc' => '2.0', 'id' => $requestId, 'method' => $method]); - $mcpException = McpServerException::methodNotFound($method); +it('processes a valid Notification message', function () { + $notification = createNotification('test/notify', ['data' => 'info']); + + $this->dispatcher->shouldReceive('handleNotification')->once() + ->with(Mockery::on(fn($arg) => $arg instanceof Notification && $arg->method === 'test/notify'), $this->session) + ->andReturnNull(); - $this->requestProcessor->shouldReceive('process')->once()->andThrow($mcpException); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32601') && str_contains($json, '"id":789')))->andReturn(resolve(null)); + $this->transport->shouldNotReceive('sendMessage'); - $this->protocol->handleRawMessage($rawJson, $clientId); + $this->protocol->processMessage($notification, SESSION_ID); + $this->session->shouldHaveReceived('save'); }); -it('sends an internal error response on processor exception', function () { - $clientId = 'client-err-internal'; - $requestId = 101; - $method = 'explode/now'; - $rawJson = json_encode(['jsonrpc' => '2.0', 'id' => $requestId, 'method' => $method]); - $internalException = new \RuntimeException('Borked'); +it('processes a BatchRequest with mixed requests and notifications', function () { + $req1 = createRequest('req/1', [], 'batch-id-1'); + $notif1 = createNotification('notif/1'); + $req2 = createRequest('req/2', [], 'batch-id-2'); + $batchRequest = new BatchRequest([$req1, $notif1, $req2]); + + $result1 = new EmptyResult(); + $result2 = new EmptyResult(); + + $this->dispatcher->shouldReceive('handleRequest')->once()->with(Mockery::on(fn(Request $r) => $r->id === 'batch-id-1'), $this->session)->andReturn($result1); + $this->dispatcher->shouldReceive('handleNotification')->once()->with(Mockery::on(fn(Notification $n) => $n->method === 'notif/1'), $this->session); + $this->dispatcher->shouldReceive('handleRequest')->once()->with(Mockery::on(fn(Request $r) => $r->id === 'batch-id-2'), $this->session)->andReturn($result2); + + + $this->transport->shouldReceive('sendMessage')->once() + ->with(Mockery::on(function (BatchResponse $response) use ($req1, $req2, $result1, $result2) { + expect(count($response->items))->toBe(2); + expect($response->items[0]->id)->toBe($req1->id); + expect($response->items[0]->result)->toBe($result1); + expect($response->items[1]->id)->toBe($req2->id); + expect($response->items[1]->result)->toBe($result2); + return true; + }), SESSION_ID, Mockery::any()) + ->andReturn(resolve(null)); + + $this->protocol->processMessage($batchRequest, SESSION_ID); + $this->session->shouldHaveReceived('save'); +}); - $this->requestProcessor->shouldReceive('process')->once()->andThrow($internalException); - $this->transport->shouldReceive('sendToClientAsync')->once()->with($clientId, Mockery::on(fn ($json) => str_contains($json, '"code":-32603') && str_contains($json, '"id":101')))->andReturn(resolve(null)); +it('processes a BatchRequest with only notifications and sends no response', function () { + $notif1 = createNotification('notif/only1'); + $notif2 = createNotification('notif/only2'); + $batchRequest = new BatchRequest([$notif1, $notif2]); - $this->protocol->handleRawMessage($rawJson, $clientId); + $this->dispatcher->shouldReceive('handleNotification')->twice(); + $this->transport->shouldNotReceive('sendMessage'); + + $this->protocol->processMessage($batchRequest, SESSION_ID); + $this->session->shouldHaveReceived('save'); }); -// --- Test Event Handlers (Now call the handler directly) --- -it('logs info when a client connects', function () { - $clientId = 'client-connect-test'; - $this->logger->shouldReceive('info')->once()->with('Client connected', ['clientId' => $clientId]); - $this->protocol->handleClientConnected($clientId); // Call method directly +it('sends error response if session is not found', function () { + $request = createRequest('test/method'); + $this->sessionManager->shouldReceive('getSession')->with('unknown-client')->andReturn(null); + + $this->transport->shouldReceive('sendMessage')->once() + ->with(Mockery::on(function (Error $error) use ($request) { + expectErrorResponse($error, \PhpMcp\Schema\Constants::INVALID_REQUEST, $request->id); + expect($error->message)->toContain('Invalid or expired session'); + return true; + }), 'unknown-client', ['status_code' => 404, 'is_initialize_request' => false]) + ->andReturn(resolve(null)); + + $this->protocol->processMessage($request, 'unknown-client', ['is_initialize_request' => false]); + $this->session->shouldNotHaveReceived('save'); }); -it('cleans up state when a client disconnects', function () { - $clientId = 'client-disconnect-test'; - $reason = 'Connection closed by peer'; +it('sends error response if session is not initialized for non-initialize request', function () { + $request = createRequest('tools/list'); + $this->session->shouldReceive('get')->with('initialized', false)->andReturn(false); - $this->logger->shouldReceive('info')->once()->with('Client disconnected', ['clientId' => $clientId, 'reason' => $reason]); - $this->clientStateManager->shouldReceive('cleanupClient')->once()->with($clientId); + $this->transport->shouldReceive('sendMessage')->once() + ->with(Mockery::on(function (Error $error) use ($request) { + expectErrorResponse($error, \PhpMcp\Schema\Constants::INVALID_REQUEST, $request->id); + expect($error->message)->toContain('Client session not initialized'); + return true; + }), SESSION_ID, Mockery::any()) + ->andReturn(resolve(null)); - $this->protocol->handleClientDisconnected($clientId, $reason); // Call method directly + $this->protocol->processMessage($request, SESSION_ID); }); -it('cleans up client state when a transport error occurs', function () { - $clientId = 'client-transporterror-test'; - $error = new \RuntimeException('Socket error'); +it('sends error response if capability for request method is disabled', function () { + $request = createRequest('tools/list'); + $configuration = new Configuration( + serverInfo: $this->configuration->serverInfo, + capabilities: ServerCapabilities::make(toolsEnabled: false), + logger: $this->logger, + loop: $this->configuration->loop, + cache: $this->configuration->cache, + container: $this->configuration->container, + ); - $this->logger->shouldReceive('error')->once()->with('Transport error for client', Mockery::any()); - $this->clientStateManager->shouldReceive('cleanupClient')->once()->with($clientId); + $protocol = new Protocol( + $configuration, + $this->registry, + $this->sessionManager, + $this->dispatcher, + $this->subscriptionManager + ); - $this->protocol->handleTransportError($error, $clientId); // Call method directly + $protocol->bindTransport($this->transport); + + $this->transport->shouldReceive('sendMessage')->once() + ->with(Mockery::on(function (Error $error) use ($request) { + expectErrorResponse($error, \PhpMcp\Schema\Constants::METHOD_NOT_FOUND, $request->id); + expect($error->message)->toContain('Tools are not enabled'); + return true; + }), SESSION_ID, Mockery::any()) + ->andReturn(resolve(null)); + + $protocol->processMessage($request, SESSION_ID); }); -it('logs a general error when a transport error occurs', function () { - $error = new \RuntimeException('Listener setup failed'); +it('sends exceptions thrown while handling request as JSON-RPC error', function () { + $request = createRequest('fail/method'); + $exception = McpServerException::methodNotFound('fail/method'); - $this->logger->shouldReceive('error')->once()->with('General transport error', Mockery::any()); - $this->clientStateManager->shouldNotReceive('cleanupClient'); + $this->dispatcher->shouldReceive('handleRequest')->once()->andThrow($exception); - $this->protocol->handleTransportError($error, null); // Call method directly + $this->transport->shouldReceive('sendMessage')->once() + ->with(Mockery::on(function (Error $error) use ($request) { + expectErrorResponse($error, \PhpMcp\Schema\Constants::METHOD_NOT_FOUND, $request->id); + expect($error->message)->toContain('Method not found'); + return true; + }), SESSION_ID, Mockery::any()) + ->andReturn(resolve(null)); + + $this->protocol->processMessage($request, SESSION_ID); + + + $request = createRequest('explode/method'); + $exception = new \RuntimeException('Something bad happened'); + + $this->dispatcher->shouldReceive('handleRequest')->once()->andThrow($exception); + + $this->transport->shouldReceive('sendMessage')->once() + ->with(Mockery::on(function (Error $error) use ($request) { + expectErrorResponse($error, \PhpMcp\Schema\Constants::INTERNAL_ERROR, $request->id); + expect($error->message)->toContain('Internal error processing method explode/method'); + expect($error->data)->toBe('Something bad happened'); + return true; + }), SESSION_ID, Mockery::any()) + ->andReturn(resolve(null)); + + $this->protocol->processMessage($request, SESSION_ID); }); -it('attaches listeners when binding a new transport', function () { - $newTransport = Mockery::mock(ServerTransportInterface::class); - $newTransport->shouldReceive('on')->times(4); - $this->protocol->bindTransport($newTransport); - expect(true)->toBeTrue(); +it('sends a notification successfully', function () { + $notification = createNotification('event/occurred', ['value' => true]); + + $this->transport->shouldReceive('sendMessage')->once() + ->with($notification, SESSION_ID, []) + ->andReturn(resolve(null)); + + $promise = $this->protocol->sendNotification($notification, SESSION_ID); + await($promise); }); -it('removes listeners when unbinding a transport', function () { - $this->transport->shouldReceive('on')->times(4); - $this->protocol->bindTransport($this->transport); - $this->transport->shouldReceive('removeListener')->times(4); +it('rejects sending notification if transport not bound', function () { $this->protocol->unbindTransport(); - expect(true)->toBeTrue(); + $notification = createNotification('event/occurred'); + + $promise = $this->protocol->sendNotification($notification, SESSION_ID); + + await($promise->then(null, function (McpServerException $e) { + expect($e->getMessage())->toContain('Transport not bound'); + })); }); -it('unbinds previous transport when binding a new one', function () { - $transport1 = Mockery::mock(ServerTransportInterface::class); - $transport2 = Mockery::mock(ServerTransportInterface::class); - $transport1->shouldReceive('on')->times(4); - $this->protocol->bindTransport($transport1); - $transport1->shouldReceive('removeListener')->times(4); - $transport2->shouldReceive('on')->times(4); - $this->protocol->bindTransport($transport2); - expect(true)->toBeTrue(); +it('rejects sending notification if transport send fails', function () { + $notification = createNotification('event/occurred'); + $transportException = new \PhpMcp\Server\Exception\TransportException('Send failed'); + $this->transport->shouldReceive('sendMessage')->once()->andReturn(reject($transportException)); + + $promise = $this->protocol->sendNotification($notification, SESSION_ID); + await($promise->then(null, function (McpServerException $e) use ($transportException) { + expect($e->getMessage())->toContain('Failed to send notification: Send failed'); + expect($e->getPrevious())->toBe($transportException); + })); }); -it('encodes and sends a notification', function () { - $clientId = 'client-send-notif'; - $notification = new Notification('2.0', 'state/update', ['value' => true]); - $expectedJson = json_encode($notification->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $expectedFrame = $expectedJson . "\n"; +it('notifies resource updated to subscribers', function () { + $uri = 'test://resource/123'; + $subscribers = ['client-sub-1', 'client-sub-2']; + $this->subscriptionManager->shouldReceive('getSubscribers')->with($uri)->andReturn($subscribers); + + $expectedNotification = ResourceUpdatedNotification::make($uri); - $this->transport->shouldReceive('sendToClientAsync') - ->once() - ->with($clientId, $expectedFrame) + $this->transport->shouldReceive('sendMessage')->twice() + ->with(Mockery::on(function (Notification $notification) use ($expectedNotification) { + expect($notification->method)->toBe($expectedNotification->method); + expect($notification->params)->toBe($expectedNotification->params); + return true; + }), Mockery::anyOf(...$subscribers), []) ->andReturn(resolve(null)); - $promise = $this->protocol->sendNotification($clientId, $notification); - await($promise); + $this->protocol->notifyResourceUpdated($uri); +}); - expect(true)->toBeTrue(); -})->group('usesLoop'); +it('handles client connected event', function () { + $this->logger->shouldReceive('info')->with('Client connected', ['sessionId' => SESSION_ID])->once(); + $this->sessionManager->shouldReceive('createSession')->with(SESSION_ID)->once(); -it('rejects on encoding error when sending a notification', function () { - $clientId = 'client-send-notif-err'; - $resource = fopen('php://memory', 'r'); // Unencodable resource - $notification = new Notification('2.0', 'bad/data', ['res' => $resource]); + $this->protocol->handleClientConnected(SESSION_ID); +}); - $this->transport->shouldNotReceive('sendToClientAsync'); +it('handles client disconnected event', function () { + $reason = 'Connection closed'; + $this->logger->shouldReceive('info')->with('Client disconnected', ['clientId' => SESSION_ID, 'reason' => $reason])->once(); + $this->sessionManager->shouldReceive('deleteSession')->with(SESSION_ID)->once(); - // Act - $promise = $this->protocol->sendNotification($clientId, $notification); - await($promise); + $this->protocol->handleClientDisconnected(SESSION_ID, $reason); +}); - if (is_resource($resource)) { - fclose($resource); - } -})->group('usesLoop')->throws(McpServerException::class, 'Failed to encode notification'); +it('handles transport error event with client ID', function () { + $error = new \RuntimeException('Socket error'); + $this->logger->shouldReceive('error') + ->with('Transport error for client', ['error' => 'Socket error', 'exception_class' => \RuntimeException::class, 'clientId' => SESSION_ID]) + ->once(); -it('rejects if transport not bound when sending a notification', function () { - $this->protocol->unbindTransport(); - $notification = new Notification('2.0', 'test'); + $this->protocol->handleTransportError($error, SESSION_ID); +}); - $promise = $this->protocol->sendNotification('client-id', $notification); - await($promise); -})->throws(McpServerException::class, 'Transport not bound'); +it('handles transport error event without client ID', function () { + $error = new \RuntimeException('Listener setup failed'); + $this->logger->shouldReceive('error') + ->with('General transport error', ['error' => 'Listener setup failed', 'exception_class' => \RuntimeException::class]) + ->once(); + + $this->protocol->handleTransportError($error, null); +}); + +it('handles list changed event from registry and notifies subscribers', function (string $listType, string $expectedNotificationClass) { + $listChangeUri = "mcp://changes/{$listType}"; + $subscribers = ['client-sub-A', 'client-sub-B']; + + $this->subscriptionManager->shouldReceive('getSubscribers')->with($listChangeUri)->andReturn($subscribers); + $capabilities = ServerCapabilities::make( + toolsListChanged: true, + resourcesListChanged: true, + promptsListChanged: true, + ); + + $configuration = new Configuration( + serverInfo: $this->configuration->serverInfo, + capabilities: $capabilities, + logger: $this->logger, + loop: $this->configuration->loop, + cache: $this->configuration->cache, + container: $this->configuration->container, + ); + + $protocol = new Protocol( + $configuration, + $this->registry, + $this->sessionManager, + $this->dispatcher, + $this->subscriptionManager + ); + + $protocol->bindTransport($this->transport); + + $this->transport->shouldReceive('sendMessage') + ->with(Mockery::type($expectedNotificationClass), Mockery::anyOf(...$subscribers), []) + ->times(count($subscribers)) + ->andReturn(resolve(null)); + + $protocol->handleListChanged($listType); +})->with([ + 'tools' => ['tools', ToolListChangedNotification::class], + 'resources' => ['resources', ResourceListChangedNotification::class], +]); + +it('does not send list changed notification if capability is disabled', function (string $listType) { + $listChangeUri = "mcp://changes/{$listType}"; + $subscribers = ['client-sub-A']; + $this->subscriptionManager->shouldReceive('getSubscribers')->with($listChangeUri)->andReturn($subscribers); + + $caps = ServerCapabilities::make( + toolsListChanged: $listType !== 'tools', + resourcesListChanged: $listType !== 'resources', + promptsListChanged: $listType !== 'prompts', + ); + + $configuration = new Configuration( + serverInfo: $this->configuration->serverInfo, + capabilities: $caps, + logger: $this->logger, + loop: $this->configuration->loop, + cache: $this->configuration->cache, + container: $this->configuration->container, + ); + + $protocol = new Protocol( + $configuration, + $this->registry, + $this->sessionManager, + $this->dispatcher, + $this->subscriptionManager + ); + + $protocol->bindTransport($this->transport); + $this->transport->shouldNotReceive('sendMessage'); +})->with(['tools', 'resources', 'prompts',]); + + +it('allows initialize request when session not initialized', function () { + $request = createRequest('initialize', ['protocolVersion' => SUPPORTED_VERSION_PROTO]); + $this->session->shouldReceive('get')->with('initialized', false)->andReturn(false); + + $this->dispatcher->shouldReceive('handleRequest')->once() + ->with(Mockery::type(Request::class), $this->session) + ->andReturn(new EmptyResult()); + + $this->transport->shouldReceive('sendMessage')->once() + ->andReturn(resolve(null)); + + $this->protocol->processMessage($request, SESSION_ID); +}); + +it('allows initialize and ping regardless of capabilities', function (string $method) { + $request = createRequest($method); + $capabilities = ServerCapabilities::make( + toolsEnabled: false, + resourcesEnabled: false, + promptsEnabled: false, + loggingEnabled: false, + ); + $configuration = new Configuration( + serverInfo: $this->configuration->serverInfo, + capabilities: $capabilities, + logger: $this->logger, + loop: $this->configuration->loop, + cache: $this->configuration->cache, + container: $this->configuration->container, + ); + + $protocol = new Protocol( + $configuration, + $this->registry, + $this->sessionManager, + $this->dispatcher, + $this->subscriptionManager + ); + + $protocol->bindTransport($this->transport); + + $this->dispatcher->shouldReceive('handleRequest')->once()->andReturn(new EmptyResult()); + $this->transport->shouldReceive('sendMessage')->once() + ->andReturn(resolve(null)); + + $protocol->processMessage($request, SESSION_ID); +})->with(['initialize', 'ping']); diff --git a/tests/Unit/RegistryTest.php b/tests/Unit/RegistryTest.php index 00c23d0..9e9a10e 100644 --- a/tests/Unit/RegistryTest.php +++ b/tests/Unit/RegistryTest.php @@ -4,48 +4,55 @@ use Mockery; use Mockery\MockInterface; -use PhpMcp\Server\Definitions\PromptDefinition; -use PhpMcp\Server\Definitions\ResourceDefinition; -use PhpMcp\Server\Definitions\ResourceTemplateDefinition; -use PhpMcp\Server\Definitions\ToolDefinition; +use PhpMcp\Schema\Prompt; +use PhpMcp\Schema\Resource; +use PhpMcp\Schema\ResourceTemplate; +use PhpMcp\Schema\Tool; +use PhpMcp\Server\Elements\RegisteredPrompt; +use PhpMcp\Server\Elements\RegisteredResource; +use PhpMcp\Server\Elements\RegisteredResourceTemplate; +use PhpMcp\Server\Elements\RegisteredTool; use PhpMcp\Server\Registry; -use PhpMcp\Server\State\ClientStateManager; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; +use Psr\SimpleCache\InvalidArgumentException as CacheInvalidArgumentException; -const DISCOVERED_CACHE_KEY = 'mcp_server_discovered_elements'; -function createTestTool(string $name = 'test-tool'): ToolDefinition +const DISCOVERED_CACHE_KEY_REG = 'mcp_server_discovered_elements'; + +function createTestToolSchema(string $name = 'test-tool'): Tool { - return new ToolDefinition('TestClass', 'toolMethod', $name, 'Desc ' . $name, ['type' => 'object']); + return Tool::make(name: $name, inputSchema: ['type' => 'object'], description: 'Desc ' . $name); } -function createTestResource(string $uri = 'test://res', string $name = 'test-res'): ResourceDefinition + +function createTestResourceSchema(string $uri = 'test://res', string $name = 'test-res'): Resource { - return new ResourceDefinition('TestClass', 'resourceMethod', $uri, $name, 'Desc ' . $name, 'text/plain', 100, []); + return Resource::make(uri: $uri, name: $name, description: 'Desc ' . $name, mimeType: 'text/plain'); } -function createTestPrompt(string $name = 'test-prompt'): PromptDefinition + +function createTestPromptSchema(string $name = 'test-prompt'): Prompt { - return new PromptDefinition('TestClass', 'promptMethod', $name, 'Desc ' . $name, []); + return Prompt::make(name: $name, description: 'Desc ' . $name, arguments: []); } -function createTestTemplate(string $uriTemplate = 'tmpl://{id}', string $name = 'test-tmpl'): ResourceTemplateDefinition + +function createTestTemplateSchema(string $uriTemplate = 'tmpl://{id}', string $name = 'test-tmpl'): ResourceTemplate { - return new ResourceTemplateDefinition('TestClass', 'templateMethod', $uriTemplate, $name, 'Desc ' . $name, 'application/json', []); + return ResourceTemplate::make(uriTemplate: $uriTemplate, name: $name, description: 'Desc ' . $name, mimeType: 'application/json'); } beforeEach(function () { - /** @var MockInterface&LoggerInterface */ + /** @var MockInterface&LoggerInterface $logger */ $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - /** @var MockInterface&CacheInterface */ + /** @var MockInterface&CacheInterface $cache */ $this->cache = Mockery::mock(CacheInterface::class); - /** @var MockInterface&ClientStateManager */ - $this->clientStateManager = Mockery::mock(ClientStateManager::class)->shouldIgnoreMissing(); - $this->cache->allows('get')->with(DISCOVERED_CACHE_KEY)->andReturn(null)->byDefault(); - $this->cache->allows('set')->with(DISCOVERED_CACHE_KEY, Mockery::any())->andReturn(true)->byDefault(); - $this->cache->allows('delete')->with(DISCOVERED_CACHE_KEY)->andReturn(true)->byDefault(); + // Default cache behavior: miss on get, success on set/delete + $this->cache->allows('get')->with(DISCOVERED_CACHE_KEY_REG)->andReturn(null)->byDefault(); + $this->cache->allows('set')->with(DISCOVERED_CACHE_KEY_REG, Mockery::any())->andReturn(true)->byDefault(); + $this->cache->allows('delete')->with(DISCOVERED_CACHE_KEY_REG)->andReturn(true)->byDefault(); - $this->registry = new Registry($this->logger, $this->cache, $this->clientStateManager); - $this->registryNoCache = new Registry($this->logger, null, $this->clientStateManager); + $this->registry = new Registry($this->logger, $this->cache); + $this->registryNoCache = new Registry($this->logger, null); }); function getRegistryProperty(Registry $reg, string $propName) @@ -53,350 +60,403 @@ function getRegistryProperty(Registry $reg, string $propName) $reflector = new \ReflectionClass($reg); $prop = $reflector->getProperty($propName); $prop->setAccessible(true); - return $prop->getValue($reg); } -// --- Basic Registration & Retrieval --- +it('registers manual tool correctly', function () { + $toolSchema = createTestToolSchema('manual-tool-1'); + $this->registry->registerTool($toolSchema, 'HandlerClass', 'method', true); -it('registers manual tool and marks as manual', function () { - // Arrange - $tool = createTestTool('manual-tool-1'); + $registeredTool = $this->registry->getTool('manual-tool-1'); + expect($registeredTool)->toBeInstanceOf(RegisteredTool::class) + ->and($registeredTool->schema)->toBe($toolSchema) + ->and($registeredTool->isManual)->toBeTrue(); + expect($this->registry->getTools())->toHaveKey('manual-tool-1'); +}); - // Act - $this->registry->registerTool($tool, true); // Register as manual +it('registers discovered tool correctly', function () { + $toolSchema = createTestToolSchema('discovered-tool-1'); + $this->registry->registerTool($toolSchema, 'HandlerClass', 'method', false); - // Assert - expect($this->registry->findTool('manual-tool-1'))->toBe($tool); - expect($this->registry->allTools())->toHaveCount(1); - expect(getRegistryProperty($this->registry, 'manualToolNames'))->toHaveKey('manual-tool-1'); + $registeredTool = $this->registry->getTool('discovered-tool-1'); + expect($registeredTool)->toBeInstanceOf(RegisteredTool::class) + ->and($registeredTool->schema)->toBe($toolSchema) + ->and($registeredTool->isManual)->toBeFalse(); }); -it('registers discovered tool', function () { - // Arrange - $tool = createTestTool('discovered-tool-1'); +it('registers manual resource correctly', function () { + $resourceSchema = createTestResourceSchema('manual://res/1'); + $this->registry->registerResource($resourceSchema, 'HandlerClass', 'method', true); + + $registeredResource = $this->registry->getResource('manual://res/1'); + expect($registeredResource)->toBeInstanceOf(RegisteredResource::class) + ->and($registeredResource->schema)->toBe($resourceSchema) + ->and($registeredResource->isManual)->toBeTrue(); + expect($this->registry->getResources())->toHaveKey('manual://res/1'); +}); - // Act - $this->registry->registerTool($tool, false); // Register as discovered +it('registers discovered resource correctly', function () { + $resourceSchema = createTestResourceSchema('discovered://res/1'); + $this->registry->registerResource($resourceSchema, 'HandlerClass', 'method', false); - // Assert - expect($this->registry->findTool('discovered-tool-1'))->toBe($tool); - expect($this->registry->allTools())->toHaveCount(1); - expect(getRegistryProperty($this->registry, 'manualToolNames'))->toBeEmpty(); + $registeredResource = $this->registry->getResource('discovered://res/1'); + expect($registeredResource)->toBeInstanceOf(RegisteredResource::class) + ->and($registeredResource->schema)->toBe($resourceSchema) + ->and($registeredResource->isManual)->toBeFalse(); }); -it('registers manual resource and marks as manual', function () { - // Arrange - $res = createTestResource('manual://res/1'); +it('registers manual prompt correctly', function () { + $promptSchema = createTestPromptSchema('manual-prompt-1'); + $this->registry->registerPrompt($promptSchema, 'HandlerClass', 'method', [], true); - // Act - $this->registry->registerResource($res, true); + $registeredPrompt = $this->registry->getPrompt('manual-prompt-1'); + expect($registeredPrompt)->toBeInstanceOf(RegisteredPrompt::class) + ->and($registeredPrompt->schema)->toBe($promptSchema) + ->and($registeredPrompt->isManual)->toBeTrue(); + expect($this->registry->getPrompts())->toHaveKey('manual-prompt-1'); +}); - // Assert - expect($this->registry->findResourceByUri('manual://res/1'))->toBe($res); - expect(getRegistryProperty($this->registry, 'manualResourceUris'))->toHaveKey('manual://res/1'); +it('registers discovered prompt correctly', function () { + $promptSchema = createTestPromptSchema('discovered-prompt-1'); + $this->registry->registerPrompt($promptSchema, 'HandlerClass', 'method', [], false); + + $registeredPrompt = $this->registry->getPrompt('discovered-prompt-1'); + expect($registeredPrompt)->toBeInstanceOf(RegisteredPrompt::class) + ->and($registeredPrompt->schema)->toBe($promptSchema) + ->and($registeredPrompt->isManual)->toBeFalse(); }); -it('registers discovered resource', function () { - // Arrange - $res = createTestResource('discovered://res/1'); +it('registers manual resource template correctly', function () { + $templateSchema = createTestTemplateSchema('manual://tmpl/{id}'); + $this->registry->registerResourceTemplate($templateSchema, 'HandlerClass', 'method', [], true); + + $registeredTemplate = $this->registry->getResourceTemplate('manual://tmpl/{id}'); + expect($registeredTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) + ->and($registeredTemplate->schema)->toBe($templateSchema) + ->and($registeredTemplate->isManual)->toBeTrue(); + expect($this->registry->getResourceTemplates())->toHaveKey('manual://tmpl/{id}'); +}); - // Act - $this->registry->registerResource($res, false); +it('registers discovered resource template correctly', function () { + $templateSchema = createTestTemplateSchema('discovered://tmpl/{id}'); + $this->registry->registerResourceTemplate($templateSchema, 'HandlerClass', 'method', [], false); - // Assert - expect($this->registry->findResourceByUri('discovered://res/1'))->toBe($res); - expect(getRegistryProperty($this->registry, 'manualResourceUris'))->toBeEmpty(); + $registeredTemplate = $this->registry->getResourceTemplate('discovered://tmpl/{id}'); + expect($registeredTemplate)->toBeInstanceOf(RegisteredResourceTemplate::class) + ->and($registeredTemplate->schema)->toBe($templateSchema) + ->and($registeredTemplate->isManual)->toBeFalse(); }); -it('registers manual prompt and marks as manual', function () { - // Arrange - $prompt = createTestPrompt('manual-prompt'); +test('getResource finds exact URI match before template match', function () { + $exactResourceSchema = createTestResourceSchema('test://item/exact'); + $templateSchema = createTestTemplateSchema('test://item/{itemId}'); - // Act - $this->registry->registerPrompt($prompt, true); + $this->registry->registerResource($exactResourceSchema, 'H', 'm'); + $this->registry->registerResourceTemplate($templateSchema, 'H', 'm'); - // Assert - expect($this->registry->findPrompt('manual-prompt'))->toBe($prompt); - expect(getRegistryProperty($this->registry, 'manualPromptNames'))->toHaveKey('manual-prompt'); + $found = $this->registry->getResource('test://item/exact'); + expect($found)->toBeInstanceOf(RegisteredResource::class) + ->and($found->schema->uri)->toBe('test://item/exact'); }); -it('registers discovered prompt', function () { - // Arrange - $prompt = createTestPrompt('discovered-prompt'); +test('getResource finds template match if no exact URI match', function () { + $templateSchema = createTestTemplateSchema('test://item/{itemId}'); + $this->registry->registerResourceTemplate($templateSchema, 'H', 'm'); + + $found = $this->registry->getResource('test://item/123'); + expect($found)->toBeInstanceOf(RegisteredResourceTemplate::class) + ->and($found->schema->uriTemplate)->toBe('test://item/{itemId}'); +}); - // Act - $this->registry->registerPrompt($prompt, false); +test('getResource returns null if no match and templates excluded', function () { + $templateSchema = createTestTemplateSchema('test://item/{itemId}'); + $this->registry->registerResourceTemplate($templateSchema, 'H', 'm'); - // Assert - expect($this->registry->findPrompt('discovered-prompt'))->toBe($prompt); - expect(getRegistryProperty($this->registry, 'manualPromptNames'))->toBeEmpty(); + $found = $this->registry->getResource('test://item/123', false); + expect($found)->toBeNull(); }); -it('registers manual template and marks as manual', function () { - // Arrange - $template = createTestTemplate('manual://tmpl/{id}'); +test('getResource returns null if no match at all', function () { + $found = $this->registry->getResource('nonexistent://uri'); + expect($found)->toBeNull(); +}); - // Act - $this->registry->registerResourceTemplate($template, true); +it('hasElements returns true if any manual elements exist', function () { + expect($this->registry->hasElements())->toBeFalse(); + $this->registry->registerTool(createTestToolSchema('manual-only'), 'H', 'm', true); + expect($this->registry->hasElements())->toBeTrue(); +}); - // Assert - expect($this->registry->findResourceTemplateByUri('manual://tmpl/123')['definition'] ?? null)->toBe($template); - expect(getRegistryProperty($this->registry, 'manualTemplateUris'))->toHaveKey('manual://tmpl/{id}'); +it('hasElements returns true if any discovered elements exist', function () { + expect($this->registry->hasElements())->toBeFalse(); + $this->registry->registerTool(createTestToolSchema('discovered-only'), 'H', 'm', false); + expect($this->registry->hasElements())->toBeTrue(); }); -it('registers discovered template', function () { - // Arrange - $template = createTestTemplate('discovered://tmpl/{id}'); +it('overrides existing discovered element with manual registration', function (string $type) { + $nameOrUri = $type === 'resource' ? 'conflict://res' : 'conflict-element'; + $templateUri = 'conflict://tmpl/{id}'; + + $discoveredSchema = match ($type) { + 'tool' => createTestToolSchema($nameOrUri), + 'resource' => createTestResourceSchema($nameOrUri), + 'prompt' => createTestPromptSchema($nameOrUri), + 'template' => createTestTemplateSchema($templateUri), + }; + $manualSchema = clone $discoveredSchema; + + match ($type) { + 'tool' => $this->registry->registerTool($discoveredSchema, 'H', 'm', false), + 'resource' => $this->registry->registerResource($discoveredSchema, 'H', 'm', false), + 'prompt' => $this->registry->registerPrompt($discoveredSchema, 'H', 'm', [], false), + 'template' => $this->registry->registerResourceTemplate($discoveredSchema, 'H', 'm', [], false), + }; + + match ($type) { + 'tool' => $this->registry->registerTool($manualSchema, 'H', 'm', true), + 'resource' => $this->registry->registerResource($manualSchema, 'H', 'm', true), + 'prompt' => $this->registry->registerPrompt($manualSchema, 'H', 'm', [], true), + 'template' => $this->registry->registerResourceTemplate($manualSchema, 'H', 'm', [], true), + }; + + $registeredElement = match ($type) { + 'tool' => $this->registry->getTool($nameOrUri), + 'resource' => $this->registry->getResource($nameOrUri), + 'prompt' => $this->registry->getPrompt($nameOrUri), + 'template' => $this->registry->getResourceTemplate($templateUri), + }; + + expect($registeredElement->schema)->toBe($manualSchema); + expect($registeredElement->isManual)->toBeTrue(); +})->with(['tool', 'resource', 'prompt', 'template']); + +it('does not override existing manual element with discovered registration', function (string $type) { + $nameOrUri = $type === 'resource' ? 'manual-priority://res' : 'manual-priority-element'; + $templateUri = 'manual-priority://tmpl/{id}'; + + $manualSchema = match ($type) { + 'tool' => createTestToolSchema($nameOrUri), + 'resource' => createTestResourceSchema($nameOrUri), + 'prompt' => createTestPromptSchema($nameOrUri), + 'template' => createTestTemplateSchema($templateUri), + }; + $discoveredSchema = clone $manualSchema; + + match ($type) { + 'tool' => $this->registry->registerTool($manualSchema, 'H', 'm', true), + 'resource' => $this->registry->registerResource($manualSchema, 'H', 'm', true), + 'prompt' => $this->registry->registerPrompt($manualSchema, 'H', 'm', [], true), + 'template' => $this->registry->registerResourceTemplate($manualSchema, 'H', 'm', [], true), + }; + + match ($type) { + 'tool' => $this->registry->registerTool($discoveredSchema, 'H', 'm', false), + 'resource' => $this->registry->registerResource($discoveredSchema, 'H', 'm', false), + 'prompt' => $this->registry->registerPrompt($discoveredSchema, 'H', 'm', [], false), + 'template' => $this->registry->registerResourceTemplate($discoveredSchema, 'H', 'm', [], false), + }; + + $registeredElement = match ($type) { + 'tool' => $this->registry->getTool($nameOrUri), + 'resource' => $this->registry->getResource($nameOrUri), + 'prompt' => $this->registry->getPrompt($nameOrUri), + 'template' => $this->registry->getResourceTemplate($templateUri), + }; + + expect($registeredElement->schema)->toBe($manualSchema); + expect($registeredElement->isManual)->toBeTrue(); +})->with(['tool', 'resource', 'prompt', 'template']); + + +it('loads discovered elements from cache correctly on construction', function () { + $toolSchema1 = createTestToolSchema('cached-tool-1'); + $resourceSchema1 = createTestResourceSchema('cached://res/1'); + $cachedData = [ + 'tools' => [$toolSchema1->name => json_encode(RegisteredTool::make($toolSchema1, 'H', 'm'))], + 'resources' => [$resourceSchema1->uri => json_encode(RegisteredResource::make($resourceSchema1, 'H', 'm'))], + 'prompts' => [], + 'resourceTemplates' => [], + ]; + $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn($cachedData); - // Act - $this->registry->registerResourceTemplate($template, false); + $registry = new Registry($this->logger, $this->cache); - // Assert - expect($this->registry->findResourceTemplateByUri('discovered://tmpl/abc')['definition'] ?? null)->toBe($template); - expect(getRegistryProperty($this->registry, 'manualTemplateUris'))->toBeEmpty(); + expect($registry->getTool('cached-tool-1'))->toBeInstanceOf(RegisteredTool::class) + ->and($registry->getTool('cached-tool-1')->isManual)->toBeFalse(); + expect($registry->getResource('cached://res/1'))->toBeInstanceOf(RegisteredResource::class) + ->and($registry->getResource('cached://res/1')->isManual)->toBeFalse(); + expect($registry->hasElements())->toBeTrue(); }); -test('hasElements returns true if manual elements exist', function () { - // Arrange - expect($this->registry->hasElements())->toBeFalse(); // Starts empty +it('skips loading cached element if manual one with same key is registered later', function () { + $conflictName = 'conflict-tool'; + $cachedToolSchema = createTestToolSchema($conflictName); + $manualToolSchema = createTestToolSchema($conflictName); // Different instance - // Act - $this->registry->registerTool(createTestTool('manual-only'), true); + $cachedData = ['tools' => [$conflictName => json_encode(RegisteredTool::make($cachedToolSchema, 'H', 'm'))]]; + $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn($cachedData); - // Assert - expect($this->registry->hasElements())->toBeTrue(); -}); + $registry = new Registry($this->logger, $this->cache); -test('hasElements returns true if discovered elements exist', function () { - // Arrange - expect($this->registry->hasElements())->toBeFalse(); + expect($registry->getTool($conflictName)->schema->name)->toBe($cachedToolSchema->name); + expect($registry->getTool($conflictName)->isManual)->toBeFalse(); - // Act - $this->registry->registerTool(createTestTool('discovered-only'), false); + $registry->registerTool($manualToolSchema, 'H', 'm', true); - // Assert - expect($this->registry->hasElements())->toBeTrue(); + expect($registry->getTool($conflictName)->schema->name)->toBe($manualToolSchema->name); + expect($registry->getTool($conflictName)->isManual)->toBeTrue(); }); -// --- Registration Precedence --- -it('overrides existing discovered element with manual registration', function () { - // Arrange - $toolName = 'override-test'; - $discoveredTool = createTestTool($toolName); // Version 1 (Discovered) - $manualTool = createTestTool($toolName); // Version 2 (Manual) - different instance +it('saves only non-manual elements to cache', function () { + $manualToolSchema = createTestToolSchema('manual-save'); + $discoveredToolSchema = createTestToolSchema('discovered-save'); + $expectedRegisteredDiscoveredTool = RegisteredTool::make($discoveredToolSchema, 'H', 'm', false); - // Act - $this->registry->registerTool($discoveredTool, false); // Register discovered first + $this->registry->registerTool($manualToolSchema, 'H', 'm', true); + $this->registry->registerTool($discoveredToolSchema, 'H', 'm', false); - // Assert - expect($this->registry->findTool($toolName))->toBe($discoveredTool); + $expectedCachedData = [ + 'tools' => ['discovered-save' => json_encode($expectedRegisteredDiscoveredTool)], + 'resources' => [], + 'prompts' => [], + 'resourceTemplates' => [], + ]; - $this->logger->shouldReceive('warning')->with(Mockery::pattern("/Replacing existing discovered tool '{$toolName}' with manual/"))->once(); + $this->cache->shouldReceive('set')->once() + ->with(DISCOVERED_CACHE_KEY_REG, $expectedCachedData) + ->andReturn(true); - // Act - $this->registry->registerTool($manualTool, true); + $result = $this->registry->save(); + expect($result)->toBeTrue(); +}); - // Assert manual version is now stored - expect($this->registry->findTool($toolName))->toBe($manualTool); - // Assert it's marked as manual - $reflector = new \ReflectionClass($this->registry); - $manualNamesProp = $reflector->getProperty('manualToolNames'); - $manualNamesProp->setAccessible(true); - expect($manualNamesProp->getValue($this->registry))->toHaveKey($toolName); +it('does not attempt to save to cache if cache is null', function () { + $this->registryNoCache->registerTool(createTestToolSchema('discovered-no-cache'), 'H', 'm', false); + $result = $this->registryNoCache->save(); + expect($result)->toBeFalse(); }); -it('does not override existing manual element with discovered registration', function () { - // Arrange - $toolName = 'manual-priority'; - $manualTool = createTestTool($toolName); // Version 1 (Manual) - $discoveredTool = createTestTool($toolName); // Version 2 (Discovered) +it('handles invalid (non-array) data from cache gracefully during load', function () { + $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn('this is not an array'); + $this->logger->shouldReceive('warning')->with(Mockery::pattern('/Invalid or missing data found in registry cache/'), Mockery::any())->once(); - // Act - $this->registry->registerTool($manualTool, true); // Register manual first + $registry = new Registry($this->logger, $this->cache); - // Assert - expect($this->registry->findTool($toolName))->toBe($manualTool); + expect($registry->hasElements())->toBeFalse(); +}); - // Expect debug log when ignoring - $this->logger->shouldReceive('debug')->with(Mockery::pattern("/Ignoring discovered tool '{$toolName}' as it conflicts/"))->once(); +it('handles cache unserialization errors gracefully during load', function () { + $badSerializedData = ['tools' => ['bad-tool' => 'not a serialized object']]; + $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn($badSerializedData); - // Attempt to register discovered version - $this->registry->registerTool($discoveredTool, false); + $registry = new Registry($this->logger, $this->cache); - // Assert manual version is STILL stored - expect($this->registry->findTool($toolName))->toBe($manualTool); - // Assert it's still marked as manual - $reflector = new \ReflectionClass($this->registry); - $manualNamesProp = $reflector->getProperty('manualToolNames'); - $manualNamesProp->setAccessible(true); - expect($manualNamesProp->getValue($this->registry))->toHaveKey($toolName); + expect($registry->hasElements())->toBeFalse(); }); -// --- Caching Logic --- +it('handles cache general exceptions during load gracefully', function () { + $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new \RuntimeException('Cache unavailable')); -it('loads discovered elements from cache correctly', function () { - // Arrange - $cachedTool = createTestTool('cached-tool-constructor'); - $cachedResource = createTestResource('cached://res-constructor'); - $cachedData = [ - 'tools' => [$cachedTool->toolName => $cachedTool], - 'resources' => [$cachedResource->uri => $cachedResource], - 'prompts' => [], - 'resourceTemplates' => [], - ]; - $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY)->once()->andReturn($cachedData); - - // Act - $registry = new Registry($this->logger, $this->cache, $this->clientStateManager); - - // Assertions - expect($registry->findTool('cached-tool-constructor'))->toBeInstanceOf(ToolDefinition::class); - expect($registry->findResourceByUri('cached://res-constructor'))->toBeInstanceOf(ResourceDefinition::class); - expect($registry->discoveryRanOrCached())->toBeTrue(); - // Check nothing was marked as manual - expect(getRegistryProperty($registry, 'manualToolNames'))->toBeEmpty(); - expect(getRegistryProperty($registry, 'manualResourceUris'))->toBeEmpty(); + $registry = new Registry($this->logger, $this->cache); + + expect($registry->hasElements())->toBeFalse(); }); -it('skips cache items conflicting with LATER manual registration', function () { - // Arrange - $conflictName = 'conflict-tool'; - $manualTool = createTestTool($conflictName); - $cachedToolData = createTestTool($conflictName); // Tool with same name in cache +it('handles cache InvalidArgumentException during load gracefully', function () { + $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new class extends \Exception implements CacheInvalidArgumentException {}); + + $registry = new Registry($this->logger, $this->cache); + expect($registry->hasElements())->toBeFalse(); +}); - $cachedData = ['tools' => [$conflictName => $cachedToolData]]; - $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY)->once()->andReturn($cachedData); - // Act - $registry = new Registry($this->logger, $this->cache, $this->clientStateManager); +it('clears non-manual elements and deletes cache file', function () { + $this->registry->registerTool(createTestToolSchema('manual-clear'), 'H', 'm', true); + $this->registry->registerTool(createTestToolSchema('discovered-clear'), 'H', 'm', false); - // Assert the cached item IS initially loaded (because manual isn't there *yet*) - $toolBeforeManual = $registry->findTool($conflictName); - expect($toolBeforeManual)->toBeInstanceOf(ToolDefinition::class); - expect(getRegistryProperty($registry, 'manualToolNames'))->toBeEmpty(); // Not manual yet + $this->cache->shouldReceive('delete')->with(DISCOVERED_CACHE_KEY_REG)->once()->andReturn(true); - // NOW, register the manual one (simulating builder doing it after constructing Registry) - $this->logger->shouldReceive('warning')->with(Mockery::pattern("/Replacing existing discovered tool '{$conflictName}'/"))->once(); // Expect replace warning - $registry->registerTool($manualTool, true); + $this->registry->clear(); - // Assert manual version is now present and marked correctly - expect($registry->findTool($conflictName))->toBe($manualTool); - expect(getRegistryProperty($registry, 'manualToolNames'))->toHaveKey($conflictName); + expect($this->registry->getTool('manual-clear'))->not->toBeNull(); + expect($this->registry->getTool('discovered-clear'))->toBeNull(); }); -it('saves only non-manual elements to cache', function () { - // Arrange - $manualTool = createTestTool('manual-save'); - $discoveredTool = createTestTool('discovered-save'); - $expectedCachedData = [ - 'tools' => ['discovered-save' => $discoveredTool], - 'resources' => [], - 'prompts' => [], - 'resourceTemplates' => [], - ]; - // Act - $this->registry->registerTool($manualTool, true); - $this->registry->registerTool($discoveredTool, false); +it('handles cache exceptions during clear gracefully', function () { + $this->registry->registerTool(createTestToolSchema('discovered-clear'), 'H', 'm', false); + $this->cache->shouldReceive('delete')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new \RuntimeException("Cache delete failed")); - $this->cache->shouldReceive('set')->once() - ->with(DISCOVERED_CACHE_KEY, $expectedCachedData) // Expect EXACT filtered data - ->andReturn(true); + $this->registry->clear(); - $result = $this->registry->saveDiscoveredElementsToCache(); - expect($result)->toBeTrue(); + expect($this->registry->getTool('discovered-clear'))->toBeNull(); }); -it('ignores non-array cache data', function () { - // Arrange - $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY)->once()->andReturn('invalid string data'); +it('emits list_changed event when a new tool is registered', function () { + $emitted = null; + $this->registry->on('list_changed', function ($listType) use (&$emitted) { + $emitted = $listType; + }); - // Act - $registry = new Registry($this->logger, $this->cache, $this->clientStateManager); + $this->registry->registerTool(createTestToolSchema('notifying-tool'), 'H', 'm'); + expect($emitted)->toBe('tools'); +}); + +it('emits list_changed event when a new resource is registered', function () { + $emitted = null; + $this->registry->on('list_changed', function ($listType) use (&$emitted) { + $emitted = $listType; + }); - // Assert - expect($registry->discoveryRanOrCached())->toBeFalse(); // Marked loaded - expect($registry->hasElements())->toBeFalse(); // But empty + $this->registry->registerResource(createTestResourceSchema('notify://res'), 'H', 'm'); + expect($emitted)->toBe('resources'); }); -it('ignores cache on hydration error', function () { - // Arrange - $invalidToolData = ['toolName' => 'good-name', 'description' => 'good-desc', 'inputSchema' => 'not-an-array', 'className' => 'TestClass', 'methodName' => 'toolMethod']; // Invalid schema - $cachedData = ['tools' => ['good-name' => $invalidToolData]]; - $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY)->once()->andReturn($cachedData); +it('does not emit list_changed event if notifications are disabled', function () { + $this->registry->disableNotifications(); + $emitted = false; + $this->registry->on('list_changed', function () use (&$emitted) { + $emitted = true; + }); - // Act - $registry = new Registry($this->logger, $this->cache, $this->clientStateManager); + $this->registry->registerTool(createTestToolSchema('silent-tool'), 'H', 'm'); + expect($emitted)->toBeFalse(); - // Assert - expect($registry->discoveryRanOrCached())->toBeFalse(); - expect($registry->hasElements())->toBeFalse(); // Hydration failed + $this->registry->enableNotifications(); }); -it('removes only non-manual elements and optionally clears cache', function ($deleteCacheFile) { - // Arrange - $manualTool = createTestTool('manual-clear'); - $discoveredTool = createTestTool('discovered-clear'); - $manualResource = createTestResource('manual://clear'); - $discoveredResource = createTestResource('discovered://clear'); - - // Act - $this->registry->registerTool($manualTool, true); - $this->registry->registerTool($discoveredTool, false); - $this->registry->registerResource($manualResource, true); - $this->registry->registerResource($discoveredResource, false); - - // Assert - expect($this->registry->allTools())->toHaveCount(2); - expect($this->registry->allResources())->toHaveCount(2); - - if ($deleteCacheFile) { - $this->cache->shouldReceive('delete')->with(DISCOVERED_CACHE_KEY)->once()->andReturn(true); - } else { - $this->cache->shouldNotReceive('delete'); - } - - // Act - $this->registry->clearDiscoveredElements($deleteCacheFile); - - // Assert: Manual elements remain, discovered are gone - expect($this->registry->findTool('manual-clear'))->toBe($manualTool); - expect($this->registry->findTool('discovered-clear'))->toBeNull(); - expect($this->registry->findResourceByUri('manual://clear'))->toBe($manualResource); - expect($this->registry->findResourceByUri('discovered://clear'))->toBeNull(); - expect($this->registry->allTools())->toHaveCount(1); - expect($this->registry->allResources())->toHaveCount(1); - expect($this->registry->discoveryRanOrCached())->toBeFalse(); // Flag should be reset - -})->with([ - 'Delete Cache File' => [true], - 'Keep Cache File' => [false], -]); - -// --- Notifier Tests --- - -it('sends notifications when tools, resources, and prompts are registered', function () { - // Arrange - $tool = createTestTool('notify-tool'); - $resource = createTestResource('notify://res'); - $prompt = createTestPrompt('notify-prompt'); - - $this->clientStateManager->shouldReceive('queueMessageForAll')->times(3)->with(Mockery::type('string')); - - // Act - $this->registry->registerTool($tool); - $this->registry->registerResource($resource); - $this->registry->registerPrompt($prompt); +it('computes different hashes for different collections', function () { + $method = new \ReflectionMethod(Registry::class, 'computeHash'); + $method->setAccessible(true); + + $hash1 = $method->invoke($this->registry, ['a' => 1, 'b' => 2]); + $hash2 = $method->invoke($this->registry, ['b' => 2, 'a' => 1]); + $hash3 = $method->invoke($this->registry, ['a' => 1, 'c' => 3]); + + expect($hash1)->toBeString()->not->toBeEmpty(); + expect($hash2)->toBe($hash1); + expect($hash3)->not->toBe($hash1); + expect($method->invoke($this->registry, []))->toBe(''); }); -it('does not send notifications when notifications are disabled', function () { - // Arrange - $this->registry->disableNotifications(); +it('recomputes and emits list_changed only when content actually changes', function () { + $tool1 = createTestToolSchema('tool1'); + $tool2 = createTestToolSchema('tool2'); + $callCount = 0; + + $this->registry->on('list_changed', function ($listType) use (&$callCount) { + if ($listType === 'tools') { + $callCount++; + } + }); + + $this->registry->registerTool($tool1, 'H', 'm1'); + expect($callCount)->toBe(1); - $this->clientStateManager->shouldNotReceive('queueMessageForAll'); + $this->registry->registerTool($tool1, 'H', 'm1'); + expect($callCount)->toBe(1); - // Act - $this->registry->registerTool(createTestTool('notify-tool')); + $this->registry->registerTool($tool2, 'H', 'm2'); + expect($callCount)->toBe(2); }); diff --git a/tests/Unit/ServerBuilderTest.php b/tests/Unit/ServerBuilderTest.php index 477817e..ae334fe 100644 --- a/tests/Unit/ServerBuilderTest.php +++ b/tests/Unit/ServerBuilderTest.php @@ -3,279 +3,339 @@ namespace PhpMcp\Server\Tests\Unit; use Mockery; -use PhpMcp\Server\Attributes\McpTool; -use PhpMcp\Server\Configuration; +use PhpMcp\Schema\Implementation; +use PhpMcp\Schema\ServerCapabilities; +use PhpMcp\Server\Attributes\CompletionProvider; +use PhpMcp\Server\Contracts\CompletionProviderInterface; +use PhpMcp\Server\Contracts\SessionHandlerInterface; +use PhpMcp\Server\Contracts\SessionInterface; use PhpMcp\Server\Defaults\BasicContainer; +use PhpMcp\Server\Elements\RegisteredPrompt; +use PhpMcp\Server\Elements\RegisteredTool; use PhpMcp\Server\Exception\ConfigurationException; use PhpMcp\Server\Exception\DefinitionException; -use PhpMcp\Server\Model\Capabilities; +use PhpMcp\Server\Protocol; +use PhpMcp\Server\Registry; use PhpMcp\Server\Server; use PhpMcp\Server\ServerBuilder; +use PhpMcp\Server\Session\ArraySessionHandler; +use PhpMcp\Server\Session\CacheSessionHandler; +use PhpMcp\Server\Session\SessionManager; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Psr\SimpleCache\CacheInterface; use React\EventLoop\LoopInterface; +use React\EventLoop\TimerInterface; use ReflectionClass; -class DummyHandlerClass +class SB_DummyHandlerClass { - public function handle() + public function handle(string $arg): string { + return "handled: {$arg}"; } -} -class DummyInvokableClass -{ - public function __invoke() + + public function noArgsHandler(): string { + return "no-args"; + } + + public function handlerWithCompletion( + string $name, + #[CompletionProvider(providerClass: SB_DummyCompletionProvider::class)] + string $uriParam + ): array { + return []; } } -class HandlerWithDeps + +class SB_DummyInvokableClass { - public function __construct(public LoggerInterface $log) + public function __invoke(int $id): array { + return ['id' => $id]; } +} - #[McpTool(name: 'depTool')] - public function run() +class SB_DummyCompletionProvider implements CompletionProviderInterface +{ + public function getCompletions(string $currentValue, SessionInterface $session): array { + return []; } } + beforeEach(function () { $this->builder = new ServerBuilder(); }); -function getBuilderProperty(ServerBuilder $builder, string $propertyName) -{ - $reflector = new ReflectionClass($builder); - $property = $reflector->getProperty($propertyName); - $property->setAccessible(true); - - return $property->getValue($builder); -} -it('sets server info', function () { +it('sets server info correctly', function () { $this->builder->withServerInfo('MyServer', '1.2.3'); - expect(getBuilderProperty($this->builder, 'name'))->toBe('MyServer'); - expect(getBuilderProperty($this->builder, 'version'))->toBe('1.2.3'); + $serverInfo = getPrivateProperty($this->builder, 'serverInfo'); + expect($serverInfo)->toBeInstanceOf(Implementation::class) + ->and($serverInfo->name)->toBe('MyServer') + ->and($serverInfo->version)->toBe('1.2.3'); }); -it('sets capabilities', function () { - $capabilities = Capabilities::forServer(); // Use static factory +it('sets capabilities correctly', function () { + $capabilities = ServerCapabilities::make(toolsListChanged: true); $this->builder->withCapabilities($capabilities); - expect(getBuilderProperty($this->builder, 'capabilities'))->toBe($capabilities); + expect(getPrivateProperty($this->builder, 'capabilities'))->toBe($capabilities); +}); + +it('sets pagination limit correctly', function () { + $this->builder->withPaginationLimit(100); + expect(getPrivateProperty($this->builder, 'paginationLimit'))->toBe(100); }); -it('sets logger', function () { +it('sets logger correctly', function () { $logger = Mockery::mock(LoggerInterface::class); $this->builder->withLogger($logger); - expect(getBuilderProperty($this->builder, 'logger'))->toBe($logger); + expect(getPrivateProperty($this->builder, 'logger'))->toBe($logger); }); -it('sets cache and TTL', function () { +it('sets cache and TTL correctly', function () { $cache = Mockery::mock(CacheInterface::class); $this->builder->withCache($cache, 1800); - expect(getBuilderProperty($this->builder, 'cache'))->toBe($cache); - expect(getBuilderProperty($this->builder, 'definitionCacheTtl'))->toBe(1800); + expect(getPrivateProperty($this->builder, 'cache'))->toBe($cache); + expect(getPrivateProperty($this->builder, 'definitionCacheTtl'))->toBe(1800); +}); + +it('sets cache with default TTL if TTL not provided', function () { + $cache = Mockery::mock(CacheInterface::class); + $this->builder->withCache($cache); + expect(getPrivateProperty($this->builder, 'definitionCacheTtl'))->toBe(3600); }); -it('sets cache with default TTL', function () { +it('sets session handler correctly', function () { + $handler = Mockery::mock(SessionHandlerInterface::class); + $this->builder->withSessionHandler($handler, 7200); + expect(getPrivateProperty($this->builder, 'sessionHandler'))->toBe($handler); + expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(7200); +}); + +it('sets ArraySessionHandler correctly', function () { + $this->builder->withArraySessionHandler(1800); + expect(getPrivateProperty($this->builder, 'sessionHandler'))->toBeInstanceOf(ArraySessionHandler::class); + expect(getPrivateProperty($this->builder, 'sessionHandler')->ttl)->toBe(1800); + expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(1800); +}); + +it('sets CacheSessionHandler correctly', function () { $cache = Mockery::mock(CacheInterface::class); - $this->builder->withCache($cache); // No TTL provided - expect(getBuilderProperty($this->builder, 'cache'))->toBe($cache); - expect(getBuilderProperty($this->builder, 'definitionCacheTtl'))->toBe(3600); // Default + $cache->shouldReceive('get')->with('mcp_session_index', [])->andReturn([]); + $this->builder->withCacheSessionHandler($cache, 900); + $sessionHandler = getPrivateProperty($this->builder, 'sessionHandler'); + expect($sessionHandler)->toBeInstanceOf(CacheSessionHandler::class); + expect($sessionHandler->cache)->toBe($cache); + expect($sessionHandler->ttl)->toBe(900); + expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(900); }); -it('sets container', function () { + +it('sets container correctly', function () { $container = Mockery::mock(ContainerInterface::class); $this->builder->withContainer($container); - expect(getBuilderProperty($this->builder, 'container'))->toBe($container); + expect(getPrivateProperty($this->builder, 'container'))->toBe($container); }); -it('sets loop', function () { +it('sets loop correctly', function () { $loop = Mockery::mock(LoopInterface::class); $this->builder->withLoop($loop); - expect(getBuilderProperty($this->builder, 'loop'))->toBe($loop); + expect(getPrivateProperty($this->builder, 'loop'))->toBe($loop); }); it('stores manual tool registration data', function () { - $handler = [DummyHandlerClass::class, 'handle']; - $name = 'my-tool'; - $desc = 'Tool desc'; - $this->builder->withTool($handler, $name, $desc); - - $manualTools = getBuilderProperty($this->builder, 'manualTools'); - expect($manualTools)->toBeArray()->toHaveCount(1); - expect($manualTools[0])->toBe(['handler' => $handler, 'name' => $name, 'description' => $desc]); + $handler = [SB_DummyHandlerClass::class, 'handle']; + $this->builder->withTool($handler, 'my-tool', 'Tool desc'); + $manualTools = getPrivateProperty($this->builder, 'manualTools'); + expect($manualTools[0]['handler'])->toBe($handler) + ->and($manualTools[0]['name'])->toBe('my-tool') + ->and($manualTools[0]['description'])->toBe('Tool desc'); }); it('stores manual resource registration data', function () { - $handler = DummyInvokableClass::class; - $uri = 'test://resource'; - $name = 'inv-res'; - $this->builder->withResource($handler, $uri, $name); - - $manualResources = getBuilderProperty($this->builder, 'manualResources'); - expect($manualResources)->toBeArray()->toHaveCount(1); - expect($manualResources[0]['handler'])->toBe($handler); - expect($manualResources[0]['uri'])->toBe($uri); - expect($manualResources[0]['name'])->toBe($name); + $handler = [SB_DummyHandlerClass::class, 'handle']; + $this->builder->withResource($handler, 'res://resource', 'Resource name'); + $manualResources = getPrivateProperty($this->builder, 'manualResources'); + expect($manualResources[0]['handler'])->toBe($handler) + ->and($manualResources[0]['uri'])->toBe('res://resource') + ->and($manualResources[0]['name'])->toBe('Resource name'); }); it('stores manual resource template registration data', function () { - $handler = [DummyHandlerClass::class, 'handle']; - $uriTemplate = 'test://tmpl/{id}'; - $this->builder->withResourceTemplate($handler, $uriTemplate); - - $manualTemplates = getBuilderProperty($this->builder, 'manualResourceTemplates'); - expect($manualTemplates)->toBeArray()->toHaveCount(1); - expect($manualTemplates[0]['handler'])->toBe($handler); - expect($manualTemplates[0]['uriTemplate'])->toBe($uriTemplate); + $handler = [SB_DummyHandlerClass::class, 'handle']; + $this->builder->withResourceTemplate($handler, 'res://resource', 'Resource name'); + $manualResourceTemplates = getPrivateProperty($this->builder, 'manualResourceTemplates'); + expect($manualResourceTemplates[0]['handler'])->toBe($handler) + ->and($manualResourceTemplates[0]['uriTemplate'])->toBe('res://resource') + ->and($manualResourceTemplates[0]['name'])->toBe('Resource name'); }); it('stores manual prompt registration data', function () { - $handler = [DummyHandlerClass::class, 'handle']; - $name = 'my-prompt'; - $this->builder->withPrompt($handler, $name); - - $manualPrompts = getBuilderProperty($this->builder, 'manualPrompts'); - expect($manualPrompts)->toBeArray()->toHaveCount(1); - expect($manualPrompts[0]['handler'])->toBe($handler); - expect($manualPrompts[0]['name'])->toBe($name); + $handler = [SB_DummyHandlerClass::class, 'handle']; + $this->builder->withPrompt($handler, 'my-prompt', 'Prompt desc'); + $manualPrompts = getPrivateProperty($this->builder, 'manualPrompts'); + expect($manualPrompts[0]['handler'])->toBe($handler) + ->and($manualPrompts[0]['name'])->toBe('my-prompt') + ->and($manualPrompts[0]['description'])->toBe('Prompt desc'); }); -it('throws exception if build called without server info', function () { - $this->builder - // ->withDiscoveryPaths($this->tempBasePath) // No longer needed - ->withTool([DummyHandlerClass::class, 'handle']) // Provide manual element - ->build(); +it('throws ConfigurationException if server info not provided', function () { + $this->builder->build(); })->throws(ConfigurationException::class, 'Server name and version must be provided'); -it('throws exception for empty server name or version', function ($name, $version) { - $this->builder - ->withServerInfo($name, $version) - ->withTool([DummyHandlerClass::class, 'handle']) // Provide manual element - ->build(); -})->throws(ConfigurationException::class, 'Server name and version must be provided') - ->with([ - ['', '1.0'], - ['Server', ''], - [' ', '1.0'], - ]); - -it('resolves default Logger correctly when building', function () { - $server = $this->builder - ->withServerInfo('Test', '1.0') - ->withTool([DummyHandlerClass::class, 'handle']) - ->build(); - expect($server->getConfiguration()->logger)->toBeInstanceOf(NullLogger::class); + +it('resolves default Logger, Loop, Container, SessionHandler if not provided', function () { + $server = $this->builder->withServerInfo('Test', '1.0')->build(); + $config = $server->getConfiguration(); + + expect($config->logger)->toBeInstanceOf(NullLogger::class); + expect($config->loop)->toBeInstanceOf(LoopInterface::class); + expect($config->container)->toBeInstanceOf(BasicContainer::class); + + $sessionManager = $server->getSessionManager(); + $smReflection = new ReflectionClass(SessionManager::class); + $handlerProp = $smReflection->getProperty('handler'); + $handlerProp->setAccessible(true); + expect($handlerProp->getValue($sessionManager))->toBeInstanceOf(ArraySessionHandler::class); }); -it('resolves default Loop correctly when building', function () { +it('builds Server with correct Configuration, Registry, Protocol, SessionManager', function () { + $logger = new NullLogger(); + $loop = Mockery::mock(LoopInterface::class)->shouldIgnoreMissing(); + $cache = Mockery::mock(CacheInterface::class); + $container = Mockery::mock(ContainerInterface::class); + $sessionHandler = Mockery::mock(SessionHandlerInterface::class); + $capabilities = ServerCapabilities::make(promptsListChanged: true, resourcesListChanged: true); + + $loop->shouldReceive('addPeriodicTimer')->with(300, Mockery::type('callable'))->andReturn(Mockery::mock(TimerInterface::class)); + $server = $this->builder - ->withServerInfo('Test', '1.0') - ->withTool([DummyHandlerClass::class, 'handle']) + ->withServerInfo('FullBuild', '3.0') + ->withLogger($logger) + ->withLoop($loop) + ->withCache($cache) + ->withContainer($container) + ->withSessionHandler($sessionHandler) + ->withCapabilities($capabilities) + ->withPaginationLimit(75) ->build(); - expect($server->getConfiguration()->loop)->toBeInstanceOf(LoopInterface::class); + + expect($server)->toBeInstanceOf(Server::class); + + $config = $server->getConfiguration(); + expect($config->serverInfo->name)->toBe('FullBuild'); + expect($config->serverInfo->version)->toBe('3.0'); + expect($config->capabilities)->toBe($capabilities); + expect($config->logger)->toBe($logger); + expect($config->loop)->toBe($loop); + expect($config->cache)->toBe($cache); + expect($config->container)->toBe($container); + expect($config->paginationLimit)->toBe(75); + + expect($server->getRegistry())->toBeInstanceOf(Registry::class); + expect($server->getProtocol())->toBeInstanceOf(Protocol::class); + expect($server->getSessionManager())->toBeInstanceOf(SessionManager::class); + $smReflection = new ReflectionClass($server->getSessionManager()); + $handlerProp = $smReflection->getProperty('handler'); + $handlerProp->setAccessible(true); + expect($handlerProp->getValue($server->getSessionManager()))->toBe($sessionHandler); }); -it('resolves default Container correctly when building', function () { +it('registers manual tool successfully during build', function () { + $handler = [SB_DummyHandlerClass::class, 'handle']; + $server = $this->builder - ->withServerInfo('Test', '1.0') - ->withTool([DummyHandlerClass::class, 'handle']) + ->withServerInfo('ManualToolTest', '1.0') + ->withTool($handler, 'test-manual-tool', 'A test tool') ->build(); - expect($server->getConfiguration()->container)->toBeInstanceOf(BasicContainer::class); + + $registry = $server->getRegistry(); + $tool = $registry->getTool('test-manual-tool'); + + expect($tool)->toBeInstanceOf(RegisteredTool::class); + expect($tool->isManual)->toBeTrue(); + expect($tool->schema->name)->toBe('test-manual-tool'); + expect($tool->schema->description)->toBe('A test tool'); + expect($tool->schema->inputSchema)->toEqual(['type' => 'object', 'properties' => ['arg' => ['type' => 'string']], 'required' => ['arg']]); + expect($tool->handlerClass)->toBe(SB_DummyHandlerClass::class); + expect($tool->handlerMethod)->toBe('handle'); }); -it('uses provided dependencies over defaults when building', function () { - $myLoop = Mockery::mock(LoopInterface::class); - $myLogger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $myContainer = Mockery::mock(ContainerInterface::class); - $myCache = Mockery::mock(CacheInterface::class); - $myCaps = Capabilities::forServer(resourcesSubscribe: true); +it('infers tool name from invokable class if not provided', function () { + $handler = SB_DummyInvokableClass::class; $server = $this->builder - ->withServerInfo('CustomDeps', '1.0') - ->withLoop($myLoop) - ->withLogger($myLogger) - ->withContainer($myContainer) - ->withCache($myCache) - ->withCapabilities($myCaps) - ->withTool([DummyHandlerClass::class, 'handle']) // Add element + ->withServerInfo('Test', '1.0') + ->withTool($handler) ->build(); - $config = $server->getConfiguration(); - expect($config->loop)->toBe($myLoop); - expect($config->logger)->toBe($myLogger); - expect($config->container)->toBe($myContainer); - expect($config->cache)->toBe($myCache); - expect($config->capabilities)->toBe($myCaps); + $tool = $server->getRegistry()->getTool('SB_DummyInvokableClass'); + expect($tool)->not->toBeNull(); + expect($tool->schema->name)->toBe('SB_DummyInvokableClass'); }); -it('successfully creates Server with defaults', function () { - $container = new BasicContainer(); - $container->set(LoggerInterface::class, new NullLogger()); +it('infers prompt arguments and completion providers for manual prompt', function () { + $handler = [SB_DummyHandlerClass::class, 'handlerWithCompletion']; $server = $this->builder - ->withServerInfo('BuiltServer', '1.0') - ->withContainer($container) - ->withTool([DummyHandlerClass::class, 'handle'], 'manualTool') + ->withServerInfo('Test', '1.0') + ->withPrompt($handler, 'myPrompt') ->build(); - expect($server)->toBeInstanceOf(Server::class); - $config = $server->getConfiguration(); - expect($config->serverName)->toBe('BuiltServer'); - expect($server->getRegistry()->findTool('manualTool'))->not->toBeNull(); - expect($config->logger)->toBeInstanceOf(NullLogger::class); - expect($config->loop)->toBeInstanceOf(LoopInterface::class); - expect($config->container)->toBe($container); - expect($config->capabilities)->toBeInstanceOf(Capabilities::class); + $prompt = $server->getRegistry()->getPrompt('myPrompt'); + expect($prompt)->toBeInstanceOf(RegisteredPrompt::class); + expect($prompt->schema->arguments)->toHaveCount(2); + expect($prompt->schema->arguments[0]->name)->toBe('name'); + expect($prompt->schema->arguments[1]->name)->toBe('uriParam'); + expect($prompt->getCompletionProvider('uriParam'))->toBe(SB_DummyCompletionProvider::class); }); -it('successfully creates Server with custom dependencies', function () { - $myLoop = Mockery::mock(LoopInterface::class); - $myLogger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $myContainer = Mockery::mock(ContainerInterface::class); - $myCache = Mockery::mock(CacheInterface::class); - $myCaps = Capabilities::forServer(resourcesSubscribe: true); +// it('throws DefinitionException if HandlerResolver fails for a manual element', function () { +// $handler = ['NonExistentClass', 'method']; + +// $server = $this->builder +// ->withServerInfo('Test', '1.0') +// ->withTool($handler, 'badTool') +// ->build(); +// })->throws(DefinitionException::class, '1 error(s) occurred during manual element registration'); + +it('builds successfully with minimal valid config', function () { $server = $this->builder - ->withServerInfo('CustomServer', '2.0') - ->withLoop($myLoop)->withLogger($myLogger)->withContainer($myContainer) - ->withCache($myCache)->withCapabilities($myCaps) - ->withPrompt(DummyInvokableClass::class) // Add one element + ->withServerInfo('TS-Compatible', '0.1') ->build(); - expect($server)->toBeInstanceOf(Server::class); - $config = $server->getConfiguration(); - expect($config->serverName)->toBe('CustomServer'); - expect($config->logger)->toBe($myLogger); - expect($config->loop)->toBe($myLoop); - expect($config->container)->toBe($myContainer); - expect($config->cache)->toBe($myCache); - expect($config->capabilities)->toBe($myCaps); - expect($server->getRegistry()->allPrompts()->count())->toBe(1); }); -it('throws DefinitionException if manual tool registration fails', function () { - $container = new BasicContainer(); - $container->set(LoggerInterface::class, new NullLogger()); +it('can be built multiple times with different configurations', function () { + $builder = new ServerBuilder(); - $this->builder - ->withServerInfo('FailRegServer', '1.0') - ->withContainer($container) - ->withTool([DummyHandlerClass::class, 'nonExistentMethod'], 'badTool') + $server1 = $builder + ->withServerInfo('ServerOne', '1.0') + ->withTool([SB_DummyHandlerClass::class, 'handle'], 'toolOne') ->build(); -})->throws(DefinitionException::class, '1 error(s) occurred during manual element registration'); -it('throws DefinitionException if manual resource registration fails', function () { - $container = new BasicContainer(); - $container->set(LoggerInterface::class, new NullLogger()); - - $this->builder - ->withServerInfo('FailRegServer', '1.0') - ->withContainer($container) - ->withResource([DummyHandlerClass::class, 'handle'], 'invalid-uri-no-scheme') // Invalid URI + $server2 = $builder + ->withServerInfo('ServerTwo', '2.0') + ->withTool([SB_DummyHandlerClass::class, 'noArgsHandler'], 'toolTwo') ->build(); -})->throws(DefinitionException::class, '1 error(s) occurred during manual element registration'); + + expect($server1->getConfiguration()->serverInfo->name)->toBe('ServerOne'); + $registry1 = $server1->getRegistry(); + expect($registry1->getTool('toolOne'))->not->toBeNull(); + expect($registry1->getTool('toolTwo'))->toBeNull(); + + expect($server2->getConfiguration()->serverInfo->name)->toBe('ServerTwo'); + $registry2 = $server2->getRegistry(); + expect($registry2->getTool('toolOne'))->not->toBeNull(); + expect($registry2->getTool('toolTwo'))->not->toBeNull(); + + $builder3 = new ServerBuilder(); + $server3 = $builder3->withServerInfo('ServerThree', '3.0')->build(); + expect($server3->getRegistry()->hasElements())->toBeFalse(); +}); diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index 7c0f288..7e4c446 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -10,57 +10,73 @@ use PhpMcp\Server\Contracts\LoopAwareInterface; use PhpMcp\Server\Contracts\ServerTransportInterface; use PhpMcp\Server\Exception\DiscoveryException; -use PhpMcp\Server\Model\Capabilities; -use PhpMcp\Server\State\ClientStateManager; +use PhpMcp\Schema\Implementation; +use PhpMcp\Schema\ServerCapabilities; use PhpMcp\Server\Protocol; use PhpMcp\Server\Registry; use PhpMcp\Server\Server; +use PhpMcp\Server\Session\ArraySessionHandler; +use PhpMcp\Server\Session\SessionManager; +use PhpMcp\Server\Utils\Discoverer; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; +use React\EventLoop\Loop; use React\EventLoop\LoopInterface; beforeEach(function () { - /** @var MockInterface&LoggerInterface */ + /** @var MockInterface&LoggerInterface $logger */ $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $this->loop = Mockery::mock(LoopInterface::class); - - $cache = Mockery::mock(CacheInterface::class); - $container = Mockery::mock(ContainerInterface::class); - $capabilities = Capabilities::forServer(); + /** @var MockInterface&LoopInterface $loop */ + $this->loop = Mockery::mock(LoopInterface::class)->shouldIgnoreMissing(); + /** @var MockInterface&CacheInterface $cache */ + $this->cache = Mockery::mock(CacheInterface::class); + /** @var MockInterface&ContainerInterface $container */ + $this->container = Mockery::mock(ContainerInterface::class); $this->configuration = new Configuration( - 'TestServerInstance', - '1.0', - $capabilities, - $this->logger, - $this->loop, - $cache, - $container + serverInfo: Implementation::make('TestServerInstance', '1.0'), + capabilities: ServerCapabilities::make(), + logger: $this->logger, + loop: $this->loop, + cache: $this->cache, + container: $this->container ); - + /** @var MockInterface&Registry $registry */ $this->registry = Mockery::mock(Registry::class); - $this->clientStateManager = Mockery::mock(ClientStateManager::class); + /** @var MockInterface&Protocol $protocol */ $this->protocol = Mockery::mock(Protocol::class); + /** @var MockInterface&Discoverer $discoverer */ + $this->discoverer = Mockery::mock(Discoverer::class); - $this->server = new Server($this->configuration, $this->registry, $this->protocol, $this->clientStateManager); + $this->sessionManager = new SessionManager(new ArraySessionHandler(), $this->logger, $this->loop); + + $this->server = new Server( + $this->configuration, + $this->registry, + $this->protocol, + $this->sessionManager + ); $this->registry->allows('hasElements')->withNoArgs()->andReturn(false)->byDefault(); - $this->registry->allows('discoveryRanOrCached')->withNoArgs()->andReturn(false)->byDefault(); - $this->registry->allows('clearDiscoveredElements')->withAnyArgs()->andReturnNull()->byDefault(); - $this->registry->allows('saveDiscoveredElementsToCache')->withAnyArgs()->andReturn(true)->byDefault(); - $this->registry->allows('loadDiscoveredElementsFromCache')->withAnyArgs()->andReturnNull()->byDefault(); - $this->registry->allows('allTools->count')->withNoArgs()->andReturn(0)->byDefault(); - $this->registry->allows('allResources->count')->withNoArgs()->andReturn(0)->byDefault(); - $this->registry->allows('allResourceTemplates->count')->withNoArgs()->andReturn(0)->byDefault(); - $this->registry->allows('allPrompts->count')->withNoArgs()->andReturn(0)->byDefault(); + $this->registry->allows('clear')->withAnyArgs()->byDefault(); + $this->registry->allows('save')->withAnyArgs()->andReturn(true)->byDefault(); +}); + +afterEach(function () { + $this->sessionManager->stopGcTimer(); }); it('provides getters for core components', function () { expect($this->server->getConfiguration())->toBe($this->configuration); expect($this->server->getRegistry())->toBe($this->registry); expect($this->server->getProtocol())->toBe($this->protocol); + expect($this->server->getSessionManager())->toBe($this->sessionManager); +}); + +it('provides a static make method returning ServerBuilder', function () { + expect(Server::make())->toBeInstanceOf(\PhpMcp\Server\ServerBuilder::class); }); it('skips discovery if already run and not forced', function () { @@ -69,73 +85,95 @@ $prop->setAccessible(true); $prop->setValue($this->server, true); - $this->registry->shouldNotReceive('clearDiscoveredElements'); - $this->registry->shouldNotReceive('saveDiscoveredElementsToCache'); - - $this->server->discover(sys_get_temp_dir()); + $this->registry->shouldNotReceive('clear'); + $this->discoverer->shouldNotReceive('discover'); + $this->registry->shouldNotReceive('save'); + $this->server->discover(sys_get_temp_dir(), discoverer: $this->discoverer); $this->logger->shouldHaveReceived('debug')->with('Discovery skipped: Already run or loaded from cache.'); }); -it('clears discovered elements before scanning', function () { - $basePath = sys_get_temp_dir(); - - $this->registry->shouldReceive('clearDiscoveredElements')->once()->with(true); - $this->registry->shouldReceive('saveDiscoveredElementsToCache')->once()->andReturn(true); - - $this->server->discover($basePath); - +it('forces discovery even if already run, calling injected discoverer', function () { $reflector = new \ReflectionClass($this->server); $prop = $reflector->getProperty('discoveryRan'); $prop->setAccessible(true); + $prop->setValue($this->server, true); + + $basePath = realpath(sys_get_temp_dir()); + $scanDirs = ['.', 'src']; + + + $this->registry->shouldReceive('clear')->once(); + $this->discoverer->shouldReceive('discover') + ->with($basePath, $scanDirs, Mockery::type('array')) + ->once(); + $this->registry->shouldReceive('save')->once()->andReturn(true); + + $this->server->discover($basePath, $scanDirs, [], force: true, discoverer: $this->discoverer); + expect($prop->getValue($this->server))->toBeTrue(); }); -it('saves to cache after discovery when requested', function () { - // Arrange - $basePath = sys_get_temp_dir(); +it('calls registry clear and discoverer, then saves to cache by default', function () { + $basePath = realpath(sys_get_temp_dir()); + $scanDirs = ['app', 'lib']; + $userExcludeDirs = ['specific_exclude']; + $finalExcludeDirs = array_unique(array_merge( + ['vendor', 'tests', 'test', 'storage', 'cache', 'samples', 'docs', 'node_modules', '.git', '.svn'], + $userExcludeDirs + )); - $this->registry->shouldReceive('clearDiscoveredElements')->once()->with(true); - $this->registry->shouldReceive('saveDiscoveredElementsToCache')->once()->andReturn(true); - // Act - $this->server->discover($basePath, saveToCache: true); + $this->registry->shouldReceive('clear')->once(); + $this->discoverer->shouldReceive('discover') + ->with($basePath, $scanDirs, Mockery::on(function ($arg) use ($finalExcludeDirs) { + expect($arg)->toBeArray(); + expect($arg)->toEqualCanonicalizing($finalExcludeDirs); + return true; + })) + ->once(); + $this->registry->shouldReceive('save')->once()->andReturn(true); + + $this->server->discover($basePath, $scanDirs, $userExcludeDirs, discoverer: $this->discoverer); + + $reflector = new \ReflectionClass($this->server); + $prop = $reflector->getProperty('discoveryRan'); + $prop->setAccessible(true); + expect($prop->getValue($this->server))->toBeTrue(); }); -it('does NOT save to cache after discovery when requested', function () { - // Arrange - $basePath = sys_get_temp_dir(); +it('does not save to cache if saveToCache is false', function () { + $basePath = realpath(sys_get_temp_dir()); - $this->registry->shouldReceive('clearDiscoveredElements')->once()->with(false); // saveToCache=false -> deleteCacheFile=false - $this->registry->shouldNotReceive('saveDiscoveredElementsToCache'); // Expect NOT to save + $this->registry->shouldReceive('clear')->once(); + $this->discoverer->shouldReceive('discover')->once(); + $this->registry->shouldNotReceive('save'); - // Act - $this->server->discover($basePath, saveToCache: false); + $this->server->discover($basePath, saveToCache: false, discoverer: $this->discoverer); }); -it('throws InvalidArgumentException for bad base path', function () { - $this->server->discover('/non/existent/path/for/sure'); -})->throws(\InvalidArgumentException::class); +it('throws InvalidArgumentException for bad base path in discover', function () { + $this->discoverer->shouldNotReceive('discover'); + $this->server->discover('/non/existent/path/for/sure/I/hope', discoverer: $this->discoverer); +})->throws(\InvalidArgumentException::class, 'Invalid discovery base path'); -it('throws DiscoveryException if discoverer fails', function () { - $basePath = sys_get_temp_dir(); - $exception = new \RuntimeException('Filesystem error'); +it('throws DiscoveryException if Discoverer fails during discovery', function () { + $basePath = realpath(sys_get_temp_dir()); - $this->registry->shouldReceive('clearDiscoveredElements')->once(); - $this->registry->shouldReceive('saveDiscoveredElementsToCache')->once()->andThrow($exception); + $this->registry->shouldReceive('clear')->once(); + $this->discoverer->shouldReceive('discover')->once()->andThrow(new \RuntimeException('Filesystem error')); + $this->registry->shouldNotReceive('save'); - $this->server->discover($basePath); + $this->server->discover($basePath, discoverer: $this->discoverer); })->throws(DiscoveryException::class, 'Element discovery failed: Filesystem error'); -it('resets discoveryRan flag on failure', function () { - $basePath = sys_get_temp_dir(); - $exception = new \RuntimeException('Filesystem error'); - - $this->registry->shouldReceive('clearDiscoveredElements')->once(); - $this->registry->shouldReceive('saveDiscoveredElementsToCache')->once()->andThrow($exception); +it('resets discoveryRan flag on Discoverer failure', function () { + $basePath = realpath(sys_get_temp_dir()); + $this->registry->shouldReceive('clear')->once(); + $this->discoverer->shouldReceive('discover')->once()->andThrow(new \RuntimeException('Failure')); try { - $this->server->discover($basePath); + $this->server->discover($basePath, discoverer: $this->discoverer); } catch (DiscoveryException $e) { // Expected } @@ -146,30 +184,19 @@ expect($prop->getValue($this->server))->toBeFalse(); }); -it('throws exception if already listening', function () { - $transport = Mockery::mock(ServerTransportInterface::class); - $transport->shouldReceive('setLogger', 'setLoop', 'on', 'once', 'removeListener', 'close')->withAnyArgs()->byDefault(); - $transport->shouldReceive('listen')->once(); // Expect listen on first call - $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); // Allow emit - $this->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); // Simulate loop run for first call - $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->once(); - $transport->shouldReceive('removeAllListeners')->once(); +// --- Listening Tests --- +it('throws LogicException if listen is called when already listening', function () { + $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing(); + $this->protocol->shouldReceive('bindTransport')->with($transport)->once(); - $this->server->listen($transport); + $this->server->listen($transport, false); + $this->server->listen($transport, false); +})->throws(LogicException::class, 'Server is already listening'); - $reflector = new \ReflectionClass($this->server); - $prop = $reflector->getProperty('isListening'); - $prop->setAccessible(true); - $prop->setValue($this->server, true); - - // Act & Assert: Second call throws - expect(fn () => $this->server->listen($transport)) - ->toThrow(LogicException::class, 'Server is already listening'); -}); - -it('warns if no elements and discovery not run when trying to listen', function () { - $transport = Mockery::mock(ServerTransportInterface::class); +it('warns if no elements and discovery not run when listen is called', function () { + $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing(); + $this->protocol->shouldReceive('bindTransport')->with($transport)->once(); $this->registry->shouldReceive('hasElements')->andReturn(false); @@ -177,64 +204,72 @@ ->once() ->with(Mockery::pattern('/Starting listener, but no MCP elements are registered and discovery has not been run/')); - $transport->shouldReceive('setLogger', 'setLoop', 'on', 'once', 'removeListener', 'close')->withAnyArgs(); - $transport->shouldReceive('listen')->once(); - $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); // Allow emit - $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->once(); - $transport->shouldReceive('removeAllListeners')->once(); - $this->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); - - $this->server->listen($transport); + $this->server->listen($transport, false); }); -it('warns if no elements found AFTER discovery when trying to listen', function () { - $transport = Mockery::mock(ServerTransportInterface::class); - - $this->registry->shouldReceive('hasElements')->andReturn(false); - $reflector = new \ReflectionClass($this->server); - $prop = $reflector->getProperty('discoveryRan'); - $prop->setAccessible(true); - $prop->setValue($this->server, true); +it('injects logger and loop into aware transports during listen', function () { + $transport = Mockery::mock(ServerTransportInterface::class, LoggerAwareInterface::class, LoopAwareInterface::class); + $transport->shouldReceive('setLogger')->with($this->logger)->once(); + $transport->shouldReceive('setLoop')->with($this->loop)->once(); + $transport->shouldReceive('on', 'once', 'listen', 'emit', 'close', 'removeAllListeners')->withAnyArgs(); + $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->withAnyArgs(); - $this->logger->shouldReceive('warning')->once()->with(Mockery::pattern('/Starting listener, but no MCP elements were found after discovery/')); + $this->server->listen($transport); +}); - $transport->shouldReceive('setLogger', 'setLoop', 'on', 'once', 'removeListener', 'close')->withAnyArgs(); +it('binds protocol, starts transport listener, and runs loop by default', function () { + $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing(); $transport->shouldReceive('listen')->once(); - $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); - $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->once(); - $transport->shouldReceive('removeAllListeners')->once(); - $this->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); + $this->protocol->shouldReceive('bindTransport')->with($transport)->once(); + $this->loop->shouldReceive('run')->once(); + $this->protocol->shouldReceive('unbindTransport')->once(); $this->server->listen($transport); + expect(getPrivateProperty($this->server, 'isListening'))->toBeFalse(); }); -it('does not warn if elements are present when trying to listen', function () { - $transport = Mockery::mock(ServerTransportInterface::class); +it('does not run loop if runLoop is false in listen', function () { + $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing(); + $this->protocol->shouldReceive('bindTransport')->with($transport)->once(); - $this->registry->shouldReceive('hasElements')->andReturn(true); + $this->loop->shouldNotReceive('run'); - $this->logger->shouldNotReceive('warning'); + $this->server->listen($transport, runLoop: false); + expect(getPrivateProperty($this->server, 'isListening'))->toBeTrue(); - $transport->shouldReceive('setLogger', 'setLoop', 'on', 'once', 'removeListener', 'close')->withAnyArgs(); - $transport->shouldReceive('listen')->once(); - $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); - $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->once(); - $transport->shouldReceive('removeAllListeners')->once(); - $this->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); + $this->protocol->shouldReceive('unbindTransport'); + $transport->shouldReceive('removeAllListeners'); + $transport->shouldReceive('close'); + $this->server->endListen($transport); +}); - $this->server->listen($transport); +it('calls endListen if transport listen throws immediately', function () { + $transport = Mockery::mock(ServerTransportInterface::class)->shouldIgnoreMissing(); + $transport->shouldReceive('listen')->once()->andThrow(new \RuntimeException("Port in use")); + $this->protocol->shouldReceive('bindTransport')->once(); + $this->protocol->shouldReceive('unbindTransport')->once(); + + $this->loop->shouldNotReceive('run'); + + try { + $this->server->listen($transport); + } catch (\RuntimeException $e) { + expect($e->getMessage())->toBe("Port in use"); + } + expect(getPrivateProperty($this->server, 'isListening'))->toBeFalse(); }); -it('injects logger and loop into aware transports when listening', function () { - $transport = Mockery::mock(ServerTransportInterface::class, LoggerAwareInterface::class, LoopAwareInterface::class); - $transport->shouldReceive('setLogger')->with($this->logger)->once(); - $transport->shouldReceive('setLoop')->with($this->loop)->once(); - $transport->shouldReceive('on', 'once', 'removeListener', 'close')->withAnyArgs(); - $transport->shouldReceive('listen')->once(); - $transport->shouldReceive('emit')->withAnyArgs()->byDefault(); - $this->protocol->shouldReceive('bindTransport', 'unbindTransport')->once(); - $transport->shouldReceive('removeAllListeners')->once(); - $this->loop->shouldReceive('run')->once()->andReturnUsing(fn () => $transport->emit('close')); +it('endListen unbinds protocol and closes transport if listening', function () { + $transport = Mockery::mock(ServerTransportInterface::class); + $reflector = new \ReflectionClass($this->server); + $prop = $reflector->getProperty('isListening'); + $prop->setAccessible(true); + $prop->setValue($this->server, true); - $this->server->listen($transport); + $this->protocol->shouldReceive('unbindTransport')->once(); + $transport->shouldReceive('removeAllListeners')->with('close')->once(); + $transport->shouldReceive('close')->once(); + + $this->server->endListen($transport); + expect($prop->getValue($this->server))->toBeFalse(); }); diff --git a/tests/Unit/Session/ArraySessionHandlerTest.php b/tests/Unit/Session/ArraySessionHandlerTest.php new file mode 100644 index 0000000..85d0a5e --- /dev/null +++ b/tests/Unit/Session/ArraySessionHandlerTest.php @@ -0,0 +1,216 @@ +fixedClock = new FixedClock(); + $this->handler = new ArraySessionHandler(DEFAULT_TTL_ARRAY, $this->fixedClock); +}); + +it('implements SessionHandlerInterface', function () { + expect($this->handler)->toBeInstanceOf(SessionHandlerInterface::class); +}); + +it('constructs with a default TTL and SystemClock if no clock provided', function () { + $handler = new ArraySessionHandler(); + expect($handler->ttl)->toBe(DEFAULT_TTL_ARRAY); + $reflection = new \ReflectionClass($handler); + $clockProp = $reflection->getProperty('clock'); + $clockProp->setAccessible(true); + expect($clockProp->getValue($handler))->toBeInstanceOf(SystemClock::class); +}); + +it('constructs with a custom TTL and injected clock', function () { + $customTtl = 1800; + $clock = new FixedClock(); + $handler = new ArraySessionHandler($customTtl, $clock); + expect($handler->ttl)->toBe($customTtl); + $reflection = new \ReflectionClass($handler); + $clockProp = $reflection->getProperty('clock'); + $clockProp->setAccessible(true); + expect($clockProp->getValue($handler))->toBe($clock); +}); + +it('writes session data and reads it back correctly', function () { + $writeResult = $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + expect($writeResult)->toBeTrue(); + + $readData = $this->handler->read(SESSION_ID_ARRAY_1); + expect($readData)->toBe(SESSION_DATA_1); +}); + +it('returns false when reading a non-existent session', function () { + $readData = $this->handler->read('non-existent-session-id'); + expect($readData)->toBeFalse(); +}); + +it('overwrites existing session data on subsequent write', function () { + $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + $updatedData = '{"user_id":101,"cart":{"items":[{"id":"prod_A","qty":3}],"total":175.25},"theme":"light"}'; + $this->handler->write(SESSION_ID_ARRAY_1, $updatedData); + + $readData = $this->handler->read(SESSION_ID_ARRAY_1); + expect($readData)->toBe($updatedData); +}); + +it('returns false and removes data when reading an expired session due to handler TTL', function () { + $ttl = 60; + $fixedClock = new FixedClock(); + $handler = new ArraySessionHandler($ttl, $fixedClock); + $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + + $fixedClock->addSeconds($ttl + 1); + + $readData = $handler->read(SESSION_ID_ARRAY_1); + expect($readData)->toBeFalse(); + + $reflection = new \ReflectionClass($handler); + $storeProp = $reflection->getProperty('store'); + $storeProp->setAccessible(true); + $internalStore = $storeProp->getValue($handler); + expect($internalStore)->not->toHaveKey(SESSION_ID_ARRAY_1); +}); + +it('does not return data if read exactly at TTL expiration time', function () { + $shortTtl = 60; + $fixedClock = new FixedClock(); + $handler = new ArraySessionHandler($shortTtl, $fixedClock); + $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + + $fixedClock->addSeconds($shortTtl); + + $readData = $handler->read(SESSION_ID_ARRAY_1); + expect($readData)->toBe(SESSION_DATA_1); + + $fixedClock->addSecond(); + + $readDataExpired = $handler->read(SESSION_ID_ARRAY_1); + expect($readDataExpired)->toBeFalse(); +}); + + +it('updates timestamp on write, effectively extending session life', function () { + $veryShortTtl = 5; + $fixedClock = new FixedClock(); + $handler = new ArraySessionHandler($veryShortTtl, $fixedClock); + + $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + + $fixedClock->addSeconds(3); + + $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_2); + + $fixedClock->addSeconds(3); + + $readData = $handler->read(SESSION_ID_ARRAY_1); + expect($readData)->toBe(SESSION_DATA_2); +}); + +it('destroys an existing session and it cannot be read', function () { + $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + expect($this->handler->read(SESSION_ID_ARRAY_1))->toBe(SESSION_DATA_1); + + $destroyResult = $this->handler->destroy(SESSION_ID_ARRAY_1); + expect($destroyResult)->toBeTrue(); + expect($this->handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); + + $reflection = new \ReflectionClass($this->handler); + $storeProp = $reflection->getProperty('store'); + $storeProp->setAccessible(true); + expect($storeProp->getValue($this->handler))->not->toHaveKey(SESSION_ID_ARRAY_1); +}); + +it('destroy returns true and does nothing for a non-existent session', function () { + $destroyResult = $this->handler->destroy('non-existent-id'); + expect($destroyResult)->toBeTrue(); +}); + +it('garbage collects only sessions older than maxLifetime', function () { + $gcMaxLifetime = 100; + $handlerTtl = 300; + $fixedClock = new FixedClock(); + $handler = new ArraySessionHandler($handlerTtl, $fixedClock); + + $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + + $fixedClock->addSeconds(50); + $handler->write(SESSION_ID_ARRAY_2, SESSION_DATA_2); + + $fixedClock->addSeconds(80); + + $deletedSessions = $handler->gc($gcMaxLifetime); + + expect($deletedSessions)->toBeArray()->toEqual([SESSION_ID_ARRAY_1]); + expect($handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); + expect($handler->read(SESSION_ID_ARRAY_2))->toBe(SESSION_DATA_2); +}); + +it('garbage collection respects maxLifetime precisely', function () { + $maxLifetime = 60; + $fixedClock = new FixedClock(); + $handler = new ArraySessionHandler(300, $fixedClock); + + $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + + $fixedClock->addSeconds($maxLifetime); + $deleted = $handler->gc($maxLifetime); + expect($deleted)->toBeEmpty(); + expect($handler->read(SESSION_ID_ARRAY_1))->toBe(SESSION_DATA_1); + + $fixedClock->addSecond(); + $deleted2 = $handler->gc($maxLifetime); + expect($deleted2)->toEqual([SESSION_ID_ARRAY_1]); + expect($handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); +}); + +it('garbage collection returns empty array if no sessions meet criteria', function () { + $this->handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + $this->handler->write(SESSION_ID_ARRAY_2, SESSION_DATA_2); + + $this->fixedClock->addSeconds(DEFAULT_TTL_ARRAY / 2); + + $deletedSessions = $this->handler->gc(DEFAULT_TTL_ARRAY); + expect($deletedSessions)->toBeArray()->toBeEmpty(); + expect($this->handler->read(SESSION_ID_ARRAY_1))->toBe(SESSION_DATA_1); + expect($this->handler->read(SESSION_ID_ARRAY_2))->toBe(SESSION_DATA_2); +}); + +it('garbage collection correctly handles an empty store', function () { + $deletedSessions = $this->handler->gc(DEFAULT_TTL_ARRAY); + expect($deletedSessions)->toBeArray()->toBeEmpty(); +}); + +it('garbage collection removes multiple expired sessions', function () { + $maxLifetime = 30; + $fixedClock = new FixedClock(); + $handler = new ArraySessionHandler(300, $fixedClock); + + $handler->write(SESSION_ID_ARRAY_1, SESSION_DATA_1); + + $fixedClock->addSeconds(20); + $handler->write(SESSION_ID_ARRAY_2, SESSION_DATA_2); + + $fixedClock->addSeconds(20); + $handler->write(SESSION_ID_ARRAY_3, SESSION_DATA_3); + + $fixedClock->addSeconds(20); + + $deleted = $handler->gc($maxLifetime); + expect($deleted)->toHaveCount(2)->toContain(SESSION_ID_ARRAY_1)->toContain(SESSION_ID_ARRAY_2); + expect($handler->read(SESSION_ID_ARRAY_1))->toBeFalse(); + expect($handler->read(SESSION_ID_ARRAY_2))->toBeFalse(); + expect($handler->read(SESSION_ID_ARRAY_3))->toBe(SESSION_DATA_3); +}); diff --git a/tests/Unit/Session/CacheSessionHandlerTest.php b/tests/Unit/Session/CacheSessionHandlerTest.php new file mode 100644 index 0000000..eac0719 --- /dev/null +++ b/tests/Unit/Session/CacheSessionHandlerTest.php @@ -0,0 +1,245 @@ +fixedClock = new FixedClock(); + /** @var MockInterface&CacheInterface $cache */ + $this->cache = Mockery::mock(CacheInterface::class); + + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault(); + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true)->byDefault(); + + $this->handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); +}); + +it('implements SessionHandlerInterface', function () { + expect($this->handler)->toBeInstanceOf(SessionHandlerInterface::class); +}); + +it('constructs with default TTL and SystemClock if no clock provided', function () { + $cacheMock = Mockery::mock(CacheInterface::class); + $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault(); + $handler = new CacheSessionHandler($cacheMock); + + expect($handler->ttl)->toBe(DEFAULT_TTL_CACHE); + $reflection = new \ReflectionClass($handler); + $clockProp = $reflection->getProperty('clock'); + $clockProp->setAccessible(true); + expect($clockProp->getValue($handler))->toBeInstanceOf(\PhpMcp\Server\Defaults\SystemClock::class); +}); + +it('constructs with a custom TTL and injected clock', function () { + $customTtl = 7200; + $clock = new FixedClock(); + $cacheMock = Mockery::mock(CacheInterface::class); + $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([])->byDefault(); + $handler = new CacheSessionHandler($cacheMock, $customTtl, $clock); + expect($handler->ttl)->toBe($customTtl); + + $reflection = new \ReflectionClass($handler); + $clockProp = $reflection->getProperty('clock'); + $clockProp->setAccessible(true); + expect($clockProp->getValue($handler))->toBe($clock); +}); + +it('loads session index from cache on construction', function () { + $initialTimestamp = $this->fixedClock->now()->modify('-100 seconds')->getTimestamp(); + $initialIndex = [SESSION_ID_CACHE_1 => $initialTimestamp]; + + $cacheMock = Mockery::mock(CacheInterface::class); + $cacheMock->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn($initialIndex); + + new CacheSessionHandler($cacheMock, DEFAULT_TTL_CACHE, $this->fixedClock); +}); + +it('reads session data from cache', function () { + $sessionIndex = [SESSION_ID_CACHE_1 => $this->fixedClock->now()->modify('-100 seconds')->getTimestamp()]; + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn($sessionIndex); + $this->cache->shouldReceive('get')->with(SESSION_ID_CACHE_1, false)->once()->andReturn(SESSION_DATA_CACHE_1); + + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); + $readData = $handler->read(SESSION_ID_CACHE_1); + expect($readData)->toBe(SESSION_DATA_CACHE_1); +}); + +it('returns false when reading non-existent session (cache get returns default)', function () { + $this->cache->shouldReceive('get')->with('non-existent-id', false)->once()->andReturn(false); + $readData = $this->handler->read('non-existent-id'); + expect($readData)->toBeFalse(); +}); + +it('writes session data to cache with correct key and TTL, and updates session index', function () { + $expectedTimestamp = $this->fixedClock->now()->getTimestamp(); // 15:00:00 + + $this->cache->shouldReceive('set') + ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_1 => $expectedTimestamp]) + ->once()->andReturn(true); + $this->cache->shouldReceive('set') + ->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1) + ->once()->andReturn(true); + + $writeResult = $this->handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1); + expect($writeResult)->toBeTrue(); +}); + +it('updates timestamp in session index for existing session on write', function () { + $initialWriteTime = $this->fixedClock->now()->modify('-60 seconds')->getTimestamp(); // 14:59:00 + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([SESSION_ID_CACHE_1 => $initialWriteTime]); + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); + + $this->fixedClock->addSeconds(90); + $expectedNewTimestamp = $this->fixedClock->now()->getTimestamp(); + + $this->cache->shouldReceive('set') + ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_1 => $expectedNewTimestamp]) + ->once()->andReturn(true); + $this->cache->shouldReceive('set') + ->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1) + ->once()->andReturn(true); + + $handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1); +}); + +it('returns false if cache set for session data fails', function () { + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true); + $this->cache->shouldReceive('set')->with(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1) + ->once()->andReturn(false); + + $writeResult = $this->handler->write(SESSION_ID_CACHE_1, SESSION_DATA_CACHE_1); + expect($writeResult)->toBeFalse(); +}); + +it('destroys session by removing from cache and updating index', function () { + $initialTimestamp = $this->fixedClock->now()->getTimestamp(); + $initialIndex = [SESSION_ID_CACHE_1 => $initialTimestamp, SESSION_ID_CACHE_2 => $initialTimestamp]; + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex); + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); + + $this->cache->shouldReceive('set') + ->with(SESSION_INDEX_KEY_CACHE, [SESSION_ID_CACHE_2 => $initialTimestamp]) + ->once()->andReturn(true); + $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(true); + + $handler->destroy(SESSION_ID_CACHE_1); +}); + +it('destroy returns true if session ID not in index (cache delete still called)', function () { + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([]); // Empty index + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE); + + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true); // Index remains empty + $this->cache->shouldReceive('delete')->with('non-existent-id')->once()->andReturn(true); // Cache delete for data + + $destroyResult = $handler->destroy('non-existent-id'); + expect($destroyResult)->toBeTrue(); +}); + +it('destroy returns false if cache delete for session data fails', function () { + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([SESSION_ID_CACHE_1 => time()]); + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE); + + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, Mockery::any())->andReturn(true); // Index update + $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(false); // Data delete fails + + $destroyResult = $handler->destroy(SESSION_ID_CACHE_1); + expect($destroyResult)->toBeFalse(); +}); + +it('garbage collects only sessions older than maxLifetime from cache and index', function () { + $maxLifetime = 120; + + $initialIndex = [ + SESSION_ID_CACHE_1 => $this->fixedClock->now()->modify('-60 seconds')->getTimestamp(), + SESSION_ID_CACHE_2 => $this->fixedClock->now()->modify("-{$maxLifetime} seconds -10 seconds")->getTimestamp(), + SESSION_ID_CACHE_3 => $this->fixedClock->now()->modify('-1000 seconds')->getTimestamp(), + ]; + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex); + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); + + $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_2)->once()->andReturn(true); + $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_3)->once()->andReturn(true); + $this->cache->shouldNotReceive('delete')->with(SESSION_ID_CACHE_1); + + $expectedFinalIndex = [SESSION_ID_CACHE_1 => $initialIndex[SESSION_ID_CACHE_1]]; + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $expectedFinalIndex)->once()->andReturn(true); + + $deletedSessionIds = $handler->gc($maxLifetime); + + expect($deletedSessionIds)->toBeArray()->toHaveCount(2) + ->and($deletedSessionIds)->toContain(SESSION_ID_CACHE_2) + ->and($deletedSessionIds)->toContain(SESSION_ID_CACHE_3); +}); + +it('garbage collection respects maxLifetime precisely for cache handler', function () { + $maxLifetime = 60; + + $sessionTimestamp = $this->fixedClock->now()->modify("-{$maxLifetime} seconds")->getTimestamp(); + $initialIndex = [SESSION_ID_CACHE_1 => $sessionTimestamp]; + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex); + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); + + $this->cache->shouldNotReceive('delete'); + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $initialIndex)->once()->andReturn(true); + $deleted = $handler->gc($maxLifetime); + expect($deleted)->toBeEmpty(); + + $this->fixedClock->addSeconds(1); + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex); + $handlerAfterTimeAdvance = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); + + $this->cache->shouldReceive('delete')->with(SESSION_ID_CACHE_1)->once()->andReturn(true); + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true); + $deleted2 = $handlerAfterTimeAdvance->gc($maxLifetime); + expect($deleted2)->toEqual([SESSION_ID_CACHE_1]); +}); + + +it('garbage collection handles an empty session index', function () { + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn([]); + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE); + + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, [])->once()->andReturn(true); + $this->cache->shouldNotReceive('delete'); + + $deletedSessions = $handler->gc(DEFAULT_TTL_CACHE); + expect($deletedSessions)->toBeArray()->toBeEmpty(); +}); + +it('garbage collection continues updating index even if a cache delete fails', function () { + $maxLifetime = 60; + + $initialIndex = [ + 'expired_deleted_ok' => $this->fixedClock->now()->modify("-70 seconds")->getTimestamp(), + 'expired_delete_fails' => $this->fixedClock->now()->modify("-80 seconds")->getTimestamp(), + 'survivor' => $this->fixedClock->now()->modify('-30 seconds')->getTimestamp(), + ]; + $this->cache->shouldReceive('get')->with(SESSION_INDEX_KEY_CACHE, [])->andReturn($initialIndex); + $handler = new CacheSessionHandler($this->cache, DEFAULT_TTL_CACHE, $this->fixedClock); + + $this->cache->shouldReceive('delete')->with('expired_deleted_ok')->once()->andReturn(true); + $this->cache->shouldReceive('delete')->with('expired_delete_fails')->once()->andReturn(false); + + $expectedFinalIndex = ['survivor' => $initialIndex['survivor']]; + $this->cache->shouldReceive('set')->with(SESSION_INDEX_KEY_CACHE, $expectedFinalIndex)->once()->andReturn(true); + + $deletedSessionIds = $handler->gc($maxLifetime); + expect($deletedSessionIds)->toHaveCount(2)->toContain('expired_deleted_ok')->toContain('expired_delete_fails'); +}); diff --git a/tests/Unit/Session/SessionManagerTest.php b/tests/Unit/Session/SessionManagerTest.php new file mode 100644 index 0000000..29f6257 --- /dev/null +++ b/tests/Unit/Session/SessionManagerTest.php @@ -0,0 +1,210 @@ +sessionHandler = Mockery::mock(SessionHandlerInterface::class); + /** @var MockInterface&LoggerInterface $logger */ + $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); + $this->loop = Loop::get(); + + $this->sessionManager = new SessionManager( + $this->sessionHandler, + $this->logger, + $this->loop, + DEFAULT_TTL_MGR + ); + + $this->sessionHandler->shouldReceive('read')->with(Mockery::any())->andReturn(false)->byDefault(); + $this->sessionHandler->shouldReceive('write')->with(Mockery::any(), Mockery::any())->andReturn(true)->byDefault(); + $this->sessionHandler->shouldReceive('destroy')->with(Mockery::any())->andReturn(true)->byDefault(); + $this->sessionHandler->shouldReceive('gc')->with(Mockery::any())->andReturn([])->byDefault(); +}); + +it('creates a new session with default hydrated values and saves it', function () { + $this->sessionHandler->shouldReceive('write') + ->with(SESSION_ID_MGR_1, Mockery::on(function ($dataJson) { + $data = json_decode($dataJson, true); + expect($data['initialized'])->toBeFalse(); + expect($data['client_info'])->toBeNull(); + expect($data['protocol_version'])->toBeNull(); + expect($data['subscriptions'])->toEqual([]); + expect($data['message_queue'])->toEqual([]); + expect($data['log_level'])->toBeNull(); + return true; + }))->once()->andReturn(true); + + $sessionCreatedEmitted = false; + $emittedSessionId = null; + $emittedSessionObj = null; + $this->sessionManager->on('session_created', function ($id, $session) use (&$sessionCreatedEmitted, &$emittedSessionId, &$emittedSessionObj) { + $sessionCreatedEmitted = true; + $emittedSessionId = $id; + $emittedSessionObj = $session; + }); + + $session = $this->sessionManager->createSession(SESSION_ID_MGR_1); + + expect($session)->toBeInstanceOf(SessionInterface::class); + expect($session->getId())->toBe(SESSION_ID_MGR_1); + expect($session->get('initialized'))->toBeFalse(); + $this->logger->shouldHaveReceived('info')->with('Session created', ['sessionId' => SESSION_ID_MGR_1]); + expect($sessionCreatedEmitted)->toBeTrue(); + expect($emittedSessionId)->toBe(SESSION_ID_MGR_1); + expect($emittedSessionObj)->toBe($session); +}); + +it('gets an existing session if handler read returns data', function () { + $existingData = ['user_id' => 123, 'initialized' => true]; + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->once()->andReturn(json_encode($existingData)); + + $session = $this->sessionManager->getSession(SESSION_ID_MGR_1); + expect($session)->toBeInstanceOf(SessionInterface::class); + expect($session->getId())->toBe(SESSION_ID_MGR_1); + expect($session->get('user_id'))->toBe(123); +}); + +it('returns null from getSession if session does not exist (handler read returns false)', function () { + $this->sessionHandler->shouldReceive('read')->with('non-existent')->once()->andReturn(false); + $session = $this->sessionManager->getSession('non-existent'); + expect($session)->toBeNull(); +}); + +it('returns null from getSession if session data is empty after load', function () { + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->once()->andReturn(json_encode([])); + $session = $this->sessionManager->getSession(SESSION_ID_MGR_1); + expect($session)->toBeNull(); +}); + + +it('deletes a session successfully and emits event', function () { + $this->sessionHandler->shouldReceive('destroy')->with(SESSION_ID_MGR_1)->once()->andReturn(true); + + $sessionDeletedEmitted = false; + $emittedSessionId = null; + $this->sessionManager->on('session_deleted', function ($id) use (&$sessionDeletedEmitted, &$emittedSessionId) { + $sessionDeletedEmitted = true; + $emittedSessionId = $id; + }); + + $success = $this->sessionManager->deleteSession(SESSION_ID_MGR_1); + + expect($success)->toBeTrue(); + $this->logger->shouldHaveReceived('info')->with('Session deleted', ['sessionId' => SESSION_ID_MGR_1]); + expect($sessionDeletedEmitted)->toBeTrue(); + expect($emittedSessionId)->toBe(SESSION_ID_MGR_1); +}); + +it('logs warning and does not emit event if deleteSession fails', function () { + $this->sessionHandler->shouldReceive('destroy')->with(SESSION_ID_MGR_1)->once()->andReturn(false); + $sessionDeletedEmitted = false; + $this->sessionManager->on('session_deleted', function () use (&$sessionDeletedEmitted) { + $sessionDeletedEmitted = true; + }); + + $success = $this->sessionManager->deleteSession(SESSION_ID_MGR_1); + + expect($success)->toBeFalse(); + $this->logger->shouldHaveReceived('warning')->with('Failed to delete session', ['sessionId' => SESSION_ID_MGR_1]); + expect($sessionDeletedEmitted)->toBeFalse(); +}); + +it('queues message for existing session', function () { + $sessionData = ['message_queue' => []]; + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode($sessionData)); + $message = '{"id":1}'; + $updatedSessionData = ['message_queue' => [$message]]; + $this->sessionHandler->shouldReceive('write')->with(SESSION_ID_MGR_1, json_encode($updatedSessionData))->once()->andReturn(true); + + $this->sessionManager->queueMessage(SESSION_ID_MGR_1, $message); +}); + +it('does nothing on queueMessage if session does not exist', function () { + $this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false); + $this->sessionHandler->shouldNotReceive('write'); + $this->sessionManager->queueMessage('no-such-session', '{"id":1}'); +}); + +it('dequeues messages from existing session', function () { + $messages = ['{"id":1}', '{"id":2}']; + $sessionData = ['message_queue' => $messages]; + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode($sessionData)); + $this->sessionHandler->shouldReceive('write')->with(SESSION_ID_MGR_1, json_encode(['message_queue' => []]))->once()->andReturn(true); + + $dequeued = $this->sessionManager->dequeueMessages(SESSION_ID_MGR_1); + expect($dequeued)->toEqual($messages); +}); + +it('returns empty array from dequeueMessages if session does not exist', function () { + $this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false); + expect($this->sessionManager->dequeueMessages('no-such-session'))->toBe([]); +}); + +it('checks hasQueuedMessages for existing session', function () { + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_1)->andReturn(json_encode(['message_queue' => ['msg']])); + expect($this->sessionManager->hasQueuedMessages(SESSION_ID_MGR_1))->toBeTrue(); + + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_MGR_2)->andReturn(json_encode(['message_queue' => []])); + expect($this->sessionManager->hasQueuedMessages(SESSION_ID_MGR_2))->toBeFalse(); +}); + +it('returns false from hasQueuedMessages if session does not exist', function () { + $this->sessionHandler->shouldReceive('read')->with('no-such-session')->andReturn(false); + expect($this->sessionManager->hasQueuedMessages('no-such-session'))->toBeFalse(); +}); + +it('can stop GC timer on stopGcTimer ', function () { + $loop = Mockery::mock(LoopInterface::class); + $loop->shouldReceive('addPeriodicTimer')->with(Mockery::any(), Mockery::type('callable'))->once()->andReturn(Mockery::mock(TimerInterface::class)); + $loop->shouldReceive('cancelTimer')->with(Mockery::type(TimerInterface::class))->once(); + + $manager = new SessionManager($this->sessionHandler, $this->logger, $loop); + $manager->startGcTimer(); + $manager->stopGcTimer(); +}); + +it('GC timer callback deletes expired sessions', function () { + $clock = new FixedClock(); + + $sessionHandler = new ArraySessionHandler(60, $clock); + $sessionHandler->write('sess_expired', 'data'); + + $clock->addSeconds(100); + + $manager = new SessionManager( + $sessionHandler, + $this->logger, + ttl: 30, + gcInterval: 0.01 + ); + + $session = $manager->getSession('sess_expired'); + expect($session)->toBeNull(); +}); + + +it('does not start GC timer if already started', function () { + $this->loop = Mockery::mock(LoopInterface::class); + $this->loop->shouldReceive('addPeriodicTimer')->once()->andReturn(Mockery::mock(TimerInterface::class)); + + $manager = new SessionManager($this->sessionHandler, $this->logger, $this->loop); + $manager->startGcTimer(); +}); diff --git a/tests/Unit/Session/SessionTest.php b/tests/Unit/Session/SessionTest.php new file mode 100644 index 0000000..947f059 --- /dev/null +++ b/tests/Unit/Session/SessionTest.php @@ -0,0 +1,237 @@ +sessionHandler = Mockery::mock(SessionHandlerInterface::class); + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false)->byDefault(); +}); + +it('implements SessionInterface', function () { + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session)->toBeInstanceOf(SessionInterface::class); +}); + +// --- Constructor and ID Generation --- +it('uses provided ID if given', function () { + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session->getId())->toBe(SESSION_ID_SESS); +}); + +it('generates an ID if none is provided', function () { + $this->sessionHandler->shouldReceive('read')->with(Mockery::type('string'))->once()->andReturn(false); + $session = new Session($this->sessionHandler); + expect($session->getId())->toBeString()->toHaveLength(32); +}); + +it('loads data from handler on construction if session exists', function () { + $initialData = ['foo' => 'bar', 'count' => 5, 'nested' => ['value' => true]]; + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(json_encode($initialData)); + + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session->all())->toEqual($initialData); + expect($session->get('foo'))->toBe('bar'); +}); + +it('initializes with empty data if handler read returns false', function () { + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session->all())->toBeEmpty(); +}); + +it('initializes with empty data if handler read returns invalid JSON', function () { + $this->sessionHandler->shouldReceive('read')->with(SESSION_ID_SESS)->once()->andReturn('this is not json'); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session->all())->toBeEmpty(); +}); + +it('saves current data to handler', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->set('name', 'Alice'); + $session->set('level', 10); + + $expectedSavedData = json_encode(['name' => 'Alice', 'level' => 10]); + $this->sessionHandler->shouldReceive('write')->with(SESSION_ID_SESS, $expectedSavedData)->once()->andReturn(true); + + $session->save(); +}); + +it('sets and gets a top-level attribute', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->set('name', 'Bob'); + expect($session->get('name'))->toBe('Bob'); + expect($session->has('name'))->toBeTrue(); +}); + +it('gets default value if attribute does not exist', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session->get('nonexistent', 'default_val'))->toBe('default_val'); + expect($session->has('nonexistent'))->toBeFalse(); +}); + +it('sets and gets nested attributes using dot notation', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->set('user.profile.email', 'test@example.com'); + $session->set('user.profile.active', true); + $session->set('user.roles', ['admin', 'editor']); + + expect($session->get('user.profile'))->toEqual(['email' => 'test@example.com', 'active' => true]); + expect($session->get('user.roles'))->toEqual(['admin', 'editor']); + expect($session->has('user.profile.email'))->toBeTrue(); + expect($session->has('user.other_profile.settings'))->toBeFalse(); +}); + +it('set does not overwrite if overwrite is false and key exists', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->set('counter', 10); + $session->set('counter', 20, false); + expect($session->get('counter'))->toBe(10); + + $session->set('user.id', 1); + $session->set('user.id', 2, false); + expect($session->get('user.id'))->toBe(1); +}); + +it('set overwrites if overwrite is true (default)', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->set('counter', 10); + $session->set('counter', 20); + expect($session->get('counter'))->toBe(20); +}); + + +it('forgets a top-level attribute', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['name' => 'Alice', 'age' => 30])); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->forget('age'); + expect($session->has('age'))->toBeFalse(); + expect($session->has('name'))->toBeTrue(); + expect($session->all())->toEqual(['name' => 'Alice']); +}); + +it('forgets a nested attribute using dot notation', function () { + $initialData = ['user' => ['profile' => ['email' => 'test@example.com', 'status' => 'active'], 'id' => 1]]; + $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($initialData)); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + + $session->forget('user.profile.status'); + expect($session->has('user.profile.status'))->toBeFalse(); + expect($session->has('user.profile.email'))->toBeTrue(); + expect($session->get('user.profile'))->toEqual(['email' => 'test@example.com']); + + $session->forget('user.profile'); + expect($session->has('user.profile'))->toBeFalse(); + expect($session->get('user'))->toEqual(['id' => 1]); +}); + +it('forget does nothing if key does not exist', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['name' => 'Test'])); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->forget('nonexistent'); + $session->forget('another_nonexistent'); + expect($session->all())->toEqual(['name' => 'Test']); +}); + +it('pulls an attribute (gets and forgets)', function () { + $initialData = ['item' => 'important', 'user' => ['token' => 'abc123xyz']]; + $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($initialData)); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + + $pulledItem = $session->pull('item', 'default'); + expect($pulledItem)->toBe('important'); + expect($session->has('item'))->toBeFalse(); + + $pulledToken = $session->pull('user.token'); + expect($pulledToken)->toBe('abc123xyz'); + expect($session->has('user.token'))->toBeFalse(); + expect($session->has('user'))->toBeTrue(); + + $pulledNonExistent = $session->pull('nonexistent', 'fallback'); + expect($pulledNonExistent)->toBe('fallback'); +}); + +it('clears all session data', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(json_encode(['a' => 1, 'b' => 2])); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $session->clear(); + expect($session->all())->toBeEmpty(); +}); + +it('returns all data with all()', function () { + $data = ['a' => 1, 'b' => ['c' => 3]]; + $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($data)); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session->all())->toEqual($data); +}); + +it('hydrates session data, merging with defaults and removing id', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $newAttributes = [ + 'client_info' => ['name' => 'TestClient', 'version' => '1.1'], + 'protocol_version' => '2024-custom', + 'user_custom_key' => 'my_value', + 'id' => 'should_be_ignored_on_hydrate' + ]; + $session->hydrate($newAttributes); + + $allData = $session->all(); + expect($allData['initialized'])->toBeFalse(); + expect($allData['client_info'])->toEqual(['name' => 'TestClient', 'version' => '1.1']); + expect($allData['protocol_version'])->toBe('2024-custom'); + expect($allData['message_queue'])->toEqual([]); + expect($allData['log_level'])->toBeNull(); + expect($allData['user_custom_key'])->toBe('my_value'); + expect($allData)->not->toHaveKey('id'); +}); + +it('queues messages correctly', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect($session->hasQueuedMessages())->toBeFalse(); + + $msg1 = '{"jsonrpc":"2.0","method":"n1"}'; + $msg2 = '{"jsonrpc":"2.0","method":"n2"}'; + $session->queueMessage($msg1); + $session->queueMessage($msg2); + + expect($session->hasQueuedMessages())->toBeTrue(); + expect($session->get('message_queue'))->toEqual([$msg1, $msg2]); +}); + +it('dequeues messages and clears queue', function () { + $this->sessionHandler->shouldReceive('read')->andReturn(false); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + $msg1 = '{"id":1}'; + $msg2 = '{"id":2}'; + $session->queueMessage($msg1); + $session->queueMessage($msg2); + + $dequeued = $session->dequeueMessages(); + expect($dequeued)->toEqual([$msg1, $msg2]); + expect($session->hasQueuedMessages())->toBeFalse(); + expect($session->get('message_queue', 'not_found'))->toEqual([]); + + expect($session->dequeueMessages())->toEqual([]); +}); + +it('jsonSerializes to all session data', function () { + $data = ['serialize' => 'me', 'nested' => ['ok' => true]]; + $this->sessionHandler->shouldReceive('read')->andReturn(json_encode($data)); + $session = new Session($this->sessionHandler, SESSION_ID_SESS); + expect(json_encode($session))->toBe(json_encode($data)); +}); diff --git a/tests/Unit/State/ClientStateManagerTest.php b/tests/Unit/State/ClientStateManagerTest.php deleted file mode 100644 index 68cc757..0000000 --- a/tests/Unit/State/ClientStateManagerTest.php +++ /dev/null @@ -1,437 +0,0 @@ -cache = Mockery::mock(CacheInterface::class); - /** @var MockInterface&LoggerInterface */ - $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - - // Instance WITH mocked cache for most tests - $this->stateManagerWithCache = new ClientStateManager( - $this->logger, - $this->cache, - CLIENT_DATA_PREFIX_CSM, - CACHE_TTL_CSM - ); - - // Instance that will use its internal default ArrayCache - $this->stateManagerWithDefaultCache = new ClientStateManager( - $this->logger, - null, - CLIENT_DATA_PREFIX_CSM, - CACHE_TTL_CSM - ); -}); - -afterEach(function () { - Mockery::close(); -}); - -function getClientStateKey(string $clientId): string -{ - return CLIENT_DATA_PREFIX_CSM . $clientId; -} -function getResourceSubscribersKey(string $uri): string -{ - return GLOBAL_RES_SUBS_PREFIX_CSM . sha1($uri); -} -function getActiveClientsKey(): string -{ - return CLIENT_DATA_PREFIX_CSM . ClientStateManager::GLOBAL_ACTIVE_CLIENTS_KEY; -} - -it('uses provided cache or defaults to ArrayCache', function () { - // Verify with provided cache - $reflector = new \ReflectionClass($this->stateManagerWithCache); - $cacheProp = $reflector->getProperty('cache'); - $cacheProp->setAccessible(true); - expect($cacheProp->getValue($this->stateManagerWithCache))->toBe($this->cache); - - // Verify with default ArrayCache - $reflectorNoCache = new \ReflectionClass($this->stateManagerWithDefaultCache); - $cachePropNoCache = $reflectorNoCache->getProperty('cache'); - $cachePropNoCache->setAccessible(true); - expect($cachePropNoCache->getValue($this->stateManagerWithDefaultCache))->toBeInstanceOf(ArrayCache::class); -}); - -it('returns existing state object from cache', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $mockedClientState = new ClientState(TEST_CLIENT_ID_CSM); - $mockedClientState->isInitialized = true; - - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn($mockedClientState); - - $reflector = new \ReflectionClass($this->stateManagerWithCache); - $method = $reflector->getMethod('getClientState'); - $method->setAccessible(true); - $state = $method->invoke($this->stateManagerWithCache, TEST_CLIENT_ID_CSM); - - expect($state)->toBe($mockedClientState); -}); - -it('creates new state if not found and createIfNotFound is true', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn(null); // Cache miss - - $reflector = new \ReflectionClass($this->stateManagerWithCache); - $method = $reflector->getMethod('getClientState'); - $method->setAccessible(true); - $state = $method->invoke($this->stateManagerWithCache, TEST_CLIENT_ID_CSM, true); // createIfNotFound = true - - expect($state)->toBeInstanceOf(ClientState::class); - expect($state->isInitialized)->toBeFalse(); // New state default -}); - -it('returns null if not found and createIfNotFound is false', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn(null); - - $reflector = new \ReflectionClass($this->stateManagerWithCache); - $method = $reflector->getMethod('getClientState'); - $method->setAccessible(true); - $state = $method->invoke($this->stateManagerWithCache, TEST_CLIENT_ID_CSM, false); // createIfNotFound = false - - expect($state)->toBeNull(); -}); - -it('deletes invalid data from cache', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn('not a ClientState object'); - $this->cache->shouldReceive('delete')->once()->with($clientStateKey)->andReturn(true); - $this->logger->shouldReceive('warning')->once()->with(Mockery::pattern('/Invalid data type found in cache for client state/'), Mockery::any()); - - $reflector = new \ReflectionClass($this->stateManagerWithCache); - $method = $reflector->getMethod('getClientState'); - $method->setAccessible(true); - $state = $method->invoke($this->stateManagerWithCache, TEST_CLIENT_ID_CSM, true); // Try to create - - expect($state)->toBeInstanceOf(ClientState::class); // Should create a new one -}); - -it('saves state in cache and updates timestamp', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $clientState = new ClientState(TEST_CLIENT_ID_CSM); - $initialTimestamp = $clientState->lastActivityTimestamp; - - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(function (ClientState $state) use ($initialTimestamp) { - return $state->lastActivityTimestamp >= $initialTimestamp; - }), CACHE_TTL_CSM) - ->andReturn(true); - - $reflector = new \ReflectionClass($this->stateManagerWithCache); - $method = $reflector->getMethod('saveClientState'); - $method->setAccessible(true); - $success = $method->invoke($this->stateManagerWithCache, TEST_CLIENT_ID_CSM, $clientState); - - expect($success)->toBeTrue(); - expect($clientState->lastActivityTimestamp)->toBeGreaterThanOrEqual($initialTimestamp); // Timestamp updated -}); - -// --- Initialization --- -test('gets client state and checks if initialized', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $state = new ClientState(TEST_CLIENT_ID_CSM); - $state->isInitialized = true; - $this->cache->shouldReceive('get')->with($clientStateKey)->andReturn($state); - expect($this->stateManagerWithCache->isInitialized(TEST_CLIENT_ID_CSM))->toBeTrue(); - - $stateNotInit = new ClientState(TEST_CLIENT_ID_CSM); - $this->cache->shouldReceive('get')->with(getClientStateKey('client2'))->andReturn($stateNotInit); - expect($this->stateManagerWithCache->isInitialized('client2'))->toBeFalse(); -}); - -it('updates client state and global active list when client is initialized', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $activeClientsKey = getActiveClientsKey(); - - // getClientState (createIfNotFound=true) - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn(null); // Simulate not found - // saveClientState - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(fn (ClientState $s) => $s->isInitialized === true), CACHE_TTL_CSM) - ->andReturn(true); - // updateGlobalActiveClientTimestamp - $this->cache->shouldReceive('get')->once()->with($activeClientsKey, [])->andReturn([]); - $this->cache->shouldReceive('set')->once()->with($activeClientsKey, Mockery::hasKey(TEST_CLIENT_ID_CSM), CACHE_TTL_CSM)->andReturn(true); - $this->logger->shouldReceive('info')->with('ClientStateManager: Client marked initialized.', Mockery::any()); - - $this->stateManagerWithCache->markInitialized(TEST_CLIENT_ID_CSM); -}); - -// --- Client Info --- -it('updates client state when client info is stored', function () { - $clientInfo = ['name' => 'X', 'v' => '2']; - $proto = 'P1'; - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn(null); // Create new - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(function (ClientState $s) use ($clientInfo, $proto) { - return $s->clientInfo === $clientInfo && $s->protocolVersion === $proto; - }), CACHE_TTL_CSM) - ->andReturn(true); - - $this->stateManagerWithCache->storeClientInfo($clientInfo, $proto, TEST_CLIENT_ID_CSM); -}); - -// getClientInfo and getProtocolVersion now use null-safe operator, tests simplify -it('retrieves client info from ClientState', function () { - $clientInfo = ['name' => 'Y']; - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $state = new ClientState(TEST_CLIENT_ID_CSM); - $state->clientInfo = $clientInfo; - $this->cache->shouldReceive('get')->with($clientStateKey)->andReturn($state); - expect($this->stateManagerWithCache->getClientInfo(TEST_CLIENT_ID_CSM))->toBe($clientInfo); - - $this->cache->shouldReceive('get')->with(getClientStateKey('none'))->andReturn(null); - expect($this->stateManagerWithCache->getClientInfo('none'))->toBeNull(); -}); - -// --- Subscriptions --- -it('updates client state and global resource list when a resource subscription is added', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $resSubKey = getResourceSubscribersKey(TEST_URI_CSM_1); - - // getClientState (create) - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn(null); - // saveClientState - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(fn (ClientState $s) => isset($s->subscriptions[TEST_URI_CSM_1])), CACHE_TTL_CSM) - ->andReturn(true); - // Global resource sub update - $this->cache->shouldReceive('get')->once()->with($resSubKey, [])->andReturn([]); - $this->cache->shouldReceive('set')->once()->with($resSubKey, [TEST_CLIENT_ID_CSM => true], CACHE_TTL_CSM)->andReturn(true); - - $this->stateManagerWithCache->addResourceSubscription(TEST_CLIENT_ID_CSM, TEST_URI_CSM_1); -}); - -it('updates client state and global resource list when a resource subscription is removed', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $resSubKey = getResourceSubscribersKey(TEST_URI_CSM_1); - - $initialClientState = new ClientState(TEST_CLIENT_ID_CSM); - $initialClientState->addSubscription(TEST_URI_CSM_1); - $initialClientState->addSubscription(TEST_URI_CSM_2); - - // getClientState - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn($initialClientState); - // saveClientState (after removing TEST_URI_CSM_1 from client's list) - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(fn (ClientState $s) => ! isset($s->subscriptions[TEST_URI_CSM_1]) && isset($s->subscriptions[TEST_URI_CSM_2])), CACHE_TTL_CSM) - ->andReturn(true); - // Global resource sub update - $this->cache->shouldReceive('get')->once()->with($resSubKey, [])->andReturn([TEST_CLIENT_ID_CSM => true, 'other' => true]); - $this->cache->shouldReceive('set')->once()->with($resSubKey, ['other' => true], CACHE_TTL_CSM)->andReturn(true); - - $this->stateManagerWithCache->removeResourceSubscription(TEST_CLIENT_ID_CSM, TEST_URI_CSM_1); -}); - -it('clears from ClientState and all global lists when all resource subscriptions are removed', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $resSubKey1 = getResourceSubscribersKey(TEST_URI_CSM_1); - $resSubKey2 = getResourceSubscribersKey(TEST_URI_CSM_2); - - $initialClientState = new ClientState(TEST_CLIENT_ID_CSM); - $initialClientState->addSubscription(TEST_URI_CSM_1); - $initialClientState->addSubscription(TEST_URI_CSM_2); - - // Get client state - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn($initialClientState); - // Save client state with empty subscriptions - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(fn (ClientState $s) => empty($s->subscriptions)), CACHE_TTL_CSM) - ->andReturn(true); - - // Interaction with global resource sub list for URI 1 - $this->cache->shouldReceive('get')->once()->with($resSubKey1, [])->andReturn([TEST_CLIENT_ID_CSM => true, 'other' => true]); - $this->cache->shouldReceive('set')->once()->with($resSubKey1, ['other' => true], CACHE_TTL_CSM)->andReturn(true); - // Interaction with global resource sub list for URI 2 - $this->cache->shouldReceive('get')->once()->with($resSubKey2, [])->andReturn([TEST_CLIENT_ID_CSM => true]); - $this->cache->shouldReceive('delete')->once()->with($resSubKey2)->andReturn(true); // Becomes empty - - $this->stateManagerWithCache->removeAllResourceSubscriptions(TEST_CLIENT_ID_CSM); -}); - -it('can retrieve global resource list', function () { - $resSubKey = getResourceSubscribersKey(TEST_URI_CSM_1); - $this->cache->shouldReceive('get')->once()->with($resSubKey, [])->andReturn([TEST_CLIENT_ID_CSM => true, 'c2' => true]); - expect($this->stateManagerWithCache->getResourceSubscribers(TEST_URI_CSM_1))->toEqualCanonicalizing([TEST_CLIENT_ID_CSM, 'c2']); -}); - -it('can check if a client is subscribed to a resource', function () { - $resSubKey = getResourceSubscribersKey(TEST_URI_CSM_1); - $this->cache->shouldReceive('get')->with($resSubKey, [])->andReturn([TEST_CLIENT_ID_CSM => true]); - - expect($this->stateManagerWithCache->isSubscribedToResource(TEST_CLIENT_ID_CSM, TEST_URI_CSM_1))->toBeTrue(); - expect($this->stateManagerWithCache->isSubscribedToResource('other_client', TEST_URI_CSM_1))->toBeFalse(); -}); - -// --- Message Queue --- -it('can add a message to the client state queue', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $notification = json_encode((new Notification('2.0', 'event'))->toArray()); - $initialState = new ClientState(TEST_CLIENT_ID_CSM); - - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn($initialState); - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(function (ClientState $s) use ($notification) { - return count($s->messageQueue) === 1 && $s->messageQueue[0] == $notification; - }), CACHE_TTL_CSM) - ->andReturn(true); - - $this->stateManagerWithCache->queueMessage(TEST_CLIENT_ID_CSM, $notification); -}); - -it('consumes from ClientState queue and saves', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $messagesData = [json_encode(['method' => 'm1']), json_encode(['method' => 'm2'])]; - $initialState = new ClientState(TEST_CLIENT_ID_CSM); - $initialState->messageQueue = $messagesData; - - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn($initialState); - $this->cache->shouldReceive('set')->once() // Expect save after consuming - ->with($clientStateKey, Mockery::on(fn (ClientState $s) => empty($s->messageQueue)), CACHE_TTL_CSM) - ->andReturn(true); - - $retrieved = $this->stateManagerWithCache->getQueuedMessages(TEST_CLIENT_ID_CSM); - expect($retrieved)->toEqual($messagesData); -}); - -// --- Log Level Management --- -it('updates client state when log level is set', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $level = 'debug'; - - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn(null); // Create new - $this->cache->shouldReceive('set')->once() - ->with($clientStateKey, Mockery::on(fn (ClientState $s) => $s->requestedLogLevel === $level), CACHE_TTL_CSM) - ->andReturn(true); - - $this->stateManagerWithCache->setClientRequestedLogLevel(TEST_CLIENT_ID_CSM, $level); -}); - -it('can retrieve client requested log level', function () { - $level = 'info'; - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $state = new ClientState(TEST_CLIENT_ID_CSM); - $state->requestedLogLevel = $level; - $this->cache->shouldReceive('get')->with($clientStateKey)->andReturn($state); - - expect($this->stateManagerWithCache->getClientRequestedLogLevel(TEST_CLIENT_ID_CSM))->toBe($level); - - $this->cache->shouldReceive('get')->with(getClientStateKey('none_set'))->andReturn(new ClientState('none_set')); - expect($this->stateManagerWithCache->getClientRequestedLogLevel('none_set'))->toBeNull(); -}); - -// --- Client Management --- -it('performs all cleanup steps', function ($removeFromActive) { - $clientId = 'client-mgr-cleanup'; - $clientStateKey = getClientStateKey($clientId); - $activeClientsKey = getActiveClientsKey(); - - $initialClientState = new ClientState($clientId); - $initialClientState->addSubscription(TEST_URI_CSM_1); - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn($initialClientState); // For removeAllResourceSubscriptions - $this->cache->shouldReceive('set')->once()->with($clientStateKey, Mockery::on(fn (ClientState $s) => empty($s->subscriptions)), CACHE_TTL_CSM); // For removeAll... - $resSubKey1 = getResourceSubscribersKey(TEST_URI_CSM_1); - $this->cache->shouldReceive('get')->once()->with($resSubKey1, [])->andReturn([$clientId => true]); - $this->cache->shouldReceive('delete')->once()->with($resSubKey1); // Becomes empty - - $this->cache->shouldReceive('delete')->once()->with($clientStateKey)->andReturn(true); - - if ($removeFromActive) { - $this->cache->shouldReceive('get')->once()->with($activeClientsKey, [])->andReturn([$clientId => time(), 'other' => time()]); - $this->cache->shouldReceive('set')->once()->with($activeClientsKey, Mockery::on(fn ($arr) => ! isset($arr[$clientId])), CACHE_TTL_CSM)->andReturn(true); - } else { - $this->cache->shouldNotReceive('get')->with($activeClientsKey, []); // Should not touch active list - } - - $this->stateManagerWithCache->cleanupClient($clientId, $removeFromActive); -})->with([ - 'Remove From Active List' => [true], - 'Keep In Active List (manual)' => [false], -]); - -it('updates client state and global list when client activity is updated', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $activeClientsKey = getActiveClientsKey(); - $initialState = new ClientState(TEST_CLIENT_ID_CSM); - $initialActivityTime = $initialState->lastActivityTimestamp; - - $this->cache->shouldReceive('get')->once()->with($clientStateKey)->andReturn($initialState); - $this->cache->shouldReceive('set')->once() // Save ClientState - ->with($clientStateKey, Mockery::on(fn (ClientState $s) => $s->lastActivityTimestamp >= $initialActivityTime), CACHE_TTL_CSM) - ->andReturn(true); - $this->cache->shouldReceive('get')->once()->with($activeClientsKey, [])->andReturn([]); // Update global - $this->cache->shouldReceive('set')->once()->with($activeClientsKey, Mockery::on(fn ($arr) => $arr[TEST_CLIENT_ID_CSM] >= $initialActivityTime), CACHE_TTL_CSM)->andReturn(true); - - $this->stateManagerWithCache->updateClientActivity(TEST_CLIENT_ID_CSM); -}); - -it('filters and cleans up inactive clients when getting active clients', function () { - $activeKey = getActiveClientsKey(); - $active1 = 'active1'; - $inactive1 = 'inactive1'; - $invalid1 = 'invalid_ts_client'; - $now = time(); - $activeData = [$active1 => $now - 10, $inactive1 => $now - 400, $invalid1 => 'not-a-timestamp']; - $expectedFinalActiveInCache = [$active1 => $activeData[$active1]]; // Only active1 remains - - $this->cache->shouldReceive('get')->once()->with($activeKey, [])->andReturn($activeData); - $this->cache->shouldReceive('set')->once()->with($activeKey, $expectedFinalActiveInCache, CACHE_TTL_CSM)->andReturn(true); - - $inactiveClientState = new ClientState($inactive1); - $this->cache->shouldReceive('get')->once()->with(getClientStateKey($inactive1))->andReturn($inactiveClientState); - $this->cache->shouldReceive('delete')->once()->with(getClientStateKey($inactive1)); - - $invalidClientState = new ClientState($invalid1); - $this->cache->shouldReceive('get')->once()->with(getClientStateKey($invalid1))->andReturn($invalidClientState); - $this->cache->shouldReceive('delete')->once()->with(getClientStateKey($invalid1)); - - $result = $this->stateManagerWithCache->getActiveClients(300); - expect($result)->toEqual([$active1]); -}); - -it('can get last activity time', function () { - $activeKey = getActiveClientsKey(); - $now = time(); - $cacheData = [TEST_CLIENT_ID_CSM => $now - 50, 'other' => $now - 100]; - $this->cache->shouldReceive('get')->with($activeKey, [])->times(3)->andReturn($cacheData); - - expect($this->stateManagerWithCache->getLastActivityTime(TEST_CLIENT_ID_CSM))->toBe($now - 50); - expect($this->stateManagerWithCache->getLastActivityTime('other'))->toBe($now - 100); - expect($this->stateManagerWithCache->getLastActivityTime('nonexistent'))->toBeNull(); -}); - -it('gracefully handles cache exception', function () { - $clientStateKey = getClientStateKey(TEST_CLIENT_ID_CSM); - $this->cache->shouldReceive('get')->once()->with($clientStateKey) - ->andThrow(new class () extends \Exception implements CacheInvalidArgumentException {}); - $this->logger->shouldReceive('error')->once()->with(Mockery::pattern('/Error fetching client state from cache/'), Mockery::any()); - - expect($this->stateManagerWithCache->getClientInfo(TEST_CLIENT_ID_CSM))->toBeNull(); -}); diff --git a/tests/Unit/State/ClientStateTest.php b/tests/Unit/State/ClientStateTest.php deleted file mode 100644 index 790936d..0000000 --- a/tests/Unit/State/ClientStateTest.php +++ /dev/null @@ -1,133 +0,0 @@ -lastActivityTimestamp)->toBeGreaterThanOrEqual($startTime); - expect($state->lastActivityTimestamp)->toBeLessThanOrEqual($endTime); -}); - -it('has correct default property values', function () { - $state = new ClientState(TEST_CLIENT_ID_FOR_STATE); - - expect($state->isInitialized)->toBeFalse(); - expect($state->clientInfo)->toBeNull(); - expect($state->protocolVersion)->toBeNull(); - expect($state->subscriptions)->toBe([]); - expect($state->messageQueue)->toBe([]); - expect($state->requestedLogLevel)->toBeNull(); -}); - -it('can add resource subscriptions for a client', function () { - $state = new ClientState(TEST_CLIENT_ID_FOR_STATE); - $uri1 = 'file:///doc1.txt'; - $uri2 = 'config://app/settings'; - - $state->addSubscription($uri1); - expect($state->subscriptions)->toHaveKey($uri1); - expect($state->subscriptions[$uri1])->toBeTrue(); - expect($state->subscriptions)->toHaveCount(1); - - $state->addSubscription($uri2); - expect($state->subscriptions)->toHaveKey($uri2); - expect($state->subscriptions[$uri2])->toBeTrue(); - expect($state->subscriptions)->toHaveCount(2); - - // Adding the same URI again should not change the count - $state->addSubscription($uri1); - expect($state->subscriptions)->toHaveCount(2); -}); - -it('can remove a resource subscription for a client', function () { - $state = new ClientState(TEST_CLIENT_ID_FOR_STATE); - $uri1 = 'file:///doc1.txt'; - $uri2 = 'config://app/settings'; - - $state->addSubscription($uri1); - $state->addSubscription($uri2); - expect($state->subscriptions)->toHaveCount(2); - - $state->removeSubscription($uri1); - expect($state->subscriptions)->not->toHaveKey($uri1); - expect($state->subscriptions)->toHaveKey($uri2); - expect($state->subscriptions)->toHaveCount(1); - - // Removing a non-existent URI should not cause an error or change count - $state->removeSubscription('nonexistent://uri'); - expect($state->subscriptions)->toHaveCount(1); - - $state->removeSubscription($uri2); - expect($state->subscriptions)->toBeEmpty(); -}); - -it('can clear all resource subscriptions for a client', function () { - $state = new ClientState(TEST_CLIENT_ID_FOR_STATE); - $state->addSubscription('file:///doc1.txt'); - $state->addSubscription('config://app/settings'); - expect($state->subscriptions)->not->toBeEmpty(); - - $state->clearSubscriptions(); - expect($state->subscriptions)->toBeEmpty(); -}); - -// --- Message Queue Management --- - -it('can add a message to the queue', function () { - $state = new ClientState(TEST_CLIENT_ID_FOR_STATE); - $message1 = json_encode(['jsonrpc' => '2.0', 'method' => 'notify1']); - $message2 = json_encode(['jsonrpc' => '2.0', 'id' => 1, 'result' => []]); - - $state->addMessageToQueue($message1); - expect($state->messageQueue)->toHaveCount(1); - expect($state->messageQueue[0])->toBe($message1); - - $state->addMessageToQueue($message2); - expect($state->messageQueue)->toHaveCount(2); - expect($state->messageQueue[1])->toBe($message2); -}); - -it('can consume all messages from the queue', function () { - $state = new ClientState(TEST_CLIENT_ID_FOR_STATE); - $message1 = json_encode(['method' => 'msg1']); - $message2 = json_encode(['method' => 'msg2']); - - $state->addMessageToQueue($message1); - $state->addMessageToQueue($message2); - expect($state->messageQueue)->toHaveCount(2); - - $consumedMessages = $state->consumeMessageQueue(); - expect($consumedMessages)->toBeArray()->toHaveCount(2); - expect($consumedMessages[0])->toBe($message1); - expect($consumedMessages[1])->toBe($message2); - - // Verify the queue is now empty - expect($state->messageQueue)->toBeEmpty(); - expect($state->consumeMessageQueue())->toBeEmpty(); // Consuming an empty queue -}); - -test('public properties can be set and retain values', function () { - $state = new ClientState(TEST_CLIENT_ID_FOR_STATE); - - $state->isInitialized = true; - expect($state->isInitialized)->toBeTrue(); - - $clientInfoData = ['name' => 'Test Client', 'version' => '0.9']; - $state->clientInfo = $clientInfoData; - expect($state->clientInfo)->toBe($clientInfoData); - - $protoVersion = '2024-11-05-test'; - $state->protocolVersion = $protoVersion; - expect($state->protocolVersion)->toBe($protoVersion); - - $logLevel = 'debug'; - $state->requestedLogLevel = $logLevel; - expect($state->requestedLogLevel)->toBe($logLevel); -}); diff --git a/tests/Unit/Support/ArgumentPreparerTest.php b/tests/Unit/Support/ArgumentPreparerTest.php deleted file mode 100644 index cbaa38f..0000000 --- a/tests/Unit/Support/ArgumentPreparerTest.php +++ /dev/null @@ -1,210 +0,0 @@ -loggerMock = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $this->preparer = new ArgumentPreparer($this->loggerMock); - $this->stubInstance = new SchemaGeneratorTestStub(); // Instance to reflect on -}); - -// --- Helper --- -function reflectMethod(string $methodName): ReflectionMethod -{ - return new ReflectionMethod(SchemaGeneratorTestStub::class, $methodName); -} - -// --- Basic Argument Matching Tests --- - -test('prepares empty array for method with no parameters', function () { - $method = reflectMethod('noParams'); - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'noParams', []); - expect($args)->toBe([]); -}); - -test('prepares arguments in correct order for simple required types', function () { - $method = reflectMethod('simpleRequired'); - $input = [ - 'p1' => 'hello', - 'p2' => 123, - 'p3' => true, - 'p4' => 4.56, - 'p5' => ['a', 'b'], - 'p6' => new stdClass(), - ]; - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'simpleRequired', $input); - expect($args)->toBe(['hello', 123, true, 4.56, ['a', 'b'], $input['p6']]); -}); - -test('uses default values when input not provided', function () { - $method = reflectMethod('simpleOptionalDefaults'); - $input = ['p1' => 'provided']; // Only provide p1 - - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'simpleOptionalDefaults', $input); - expect($args)->toEqual(['provided', 123, true, 1.23, ['a', 'b'], null]); -}); - -test('handles nullable types without explicit default (passes null)', function () { - $method = reflectMethod('nullableWithoutDefault'); - $input = []; // Provide no input - - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'nullableWithoutDefault', $input); - // All params allow null and have no default, so they should receive null - expect($args)->toEqual([null, null, null]); -}); - -test('handles nullable types with explicit null default', function () { - $method = reflectMethod('nullableWithNullDefault'); - $input = []; // Provide no input - - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'nullableWithNullDefault', $input); - // Both have explicit null defaults - expect($args)->toEqual([null, null]); -}); - -// --- Type Casting Tests --- - -test('casts valid input values to expected types', function (string $paramName, mixed $inputVal, mixed $expectedVal) { - $method = reflectMethod('simpleRequired'); - $input = [ - 'p1' => '', 'p2' => 0, 'p3' => false, 'p4' => 0.0, 'p5' => [], 'p6' => new stdClass(), // Base values - $paramName => $inputVal, // Use $paramName - ]; - - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'simpleRequired', $input); - - // Find the parameter by name to get its position - $argPosition = -1; - foreach ($method->getParameters() as $p) { - if ($p->getName() === $paramName) { - $argPosition = $p->getPosition(); - break; - } - } - expect($argPosition)->not->toBe(-1, "Parameter {$paramName} not found in method."); // Assert parameter was found - - expect($args[$argPosition])->toEqual($expectedVal); - -})->with([ - ['p1', 123, '123'], // int to string - ['p2', '456', 456], // numeric string to int - ['p2', '-10', -10], // negative numeric string to int - ['p2', 99.0, 99], // float (whole) to int - ['p3', 1, true], // 1 to bool true - ['p3', 'true', true], // 'true' to bool true - ['p3', 0, false], // 0 to bool false - ['p3', 'false', false], // 'false' to bool false - ['p4', '7.89', 7.89], // numeric string to float - ['p4', 10, 10.0], // int to float - ['p5', [1, 2], [1, 2]], // array passes through - ['p6', (object) ['a' => 1], (object) ['a' => 1]], // object passes through -]); - -test('throws McpException for invalid type casting', function (string $paramName, mixed $invalidInput, string $expectedType) { - $method = reflectMethod('simpleRequired'); - $input = [ - 'p1' => '', 'p2' => 0, 'p3' => false, 'p4' => 0.0, 'p5' => [], 'p6' => new stdClass(), // Base values - $paramName => $invalidInput, // Use $paramName - ]; - - $this->preparer->prepareMethodArguments($this->stubInstance, 'simpleRequired', $input); - -})->throws(McpException::class) - ->with([ - ['p2', 'abc', 'int'], // non-numeric string to int - ['p2', 12.3, 'int'], // non-whole float to int - ['p2', true, 'int'], // bool to int - ['p3', 'yes', 'bool'], // 'yes' to bool - ['p3', 2, 'bool'], // 2 to bool - ['p4', 'xyz', 'float'], // non-numeric string to float - ['p4', false, 'float'], // bool to float - ['p5', 'not_array', 'array'], // string to array - ['p5', 123, 'array'], // int to array - ]); - -test('throws McpException when required argument is missing', function () { - $method = reflectMethod('simpleRequired'); - $input = ['p1' => 'hello']; // Missing p2, p3, etc. - - // Expect logger to be called because this is an invariant violation - $this->loggerMock->shouldReceive('error')->once()->with(Mockery::pattern('/Invariant violation: Missing required argument `p2`/'), Mockery::any()); - - $this->preparer->prepareMethodArguments($this->stubInstance, 'simpleRequired', $input); - -})->throws(McpException::class, 'Missing required argument `p2`'); // Throws on the first missing one - -// --- Edge Cases --- - -test('handles untyped parameter (passes value through)', function () { - $method = reflectMethod('docBlockOnly'); - $input = ['p1' => 'from_doc', 'p2' => 12345]; // p2 has no type hint - - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'docBlockOnly', $input); - expect($args)->toEqual(['from_doc', 12345]); -}); - -// --- Enum Casting Tests (Requires PHP 8.1+) --- - -test('casts valid input values to backed enums', function (string $paramName, mixed $inputVal, mixed $expectedEnumInstance) { - $method = reflectMethod('enumTypes'); // Method with enum parameters - $input = [ - // Provide valid base values for other required params (p1, p2, p3) - 'p1' => 'A', - 'p2' => 1, - 'p3' => 'Yes', // Assuming unit enums aren't handled by casting yet - // Override the param being tested - $paramName => $inputVal, - ]; - - $args = $this->preparer->prepareMethodArguments($this->stubInstance, 'enumTypes', $input); - - $argPosition = -1; - foreach ($method->getParameters() as $p) { - if ($p->getName() === $paramName) { - $argPosition = $p->getPosition(); - break; - } - } - expect($argPosition)->not->toBe(-1); - - expect($args[$argPosition])->toEqual($expectedEnumInstance); // Use toEqual for enums - -})->with([ - ['p1', 'A', \PhpMcp\Server\Tests\Mocks\SupportStubs\BackedStringEnum::OptionA], - ['p1', 'B', \PhpMcp\Server\Tests\Mocks\SupportStubs\BackedStringEnum::OptionB], - ['p2', 1, \PhpMcp\Server\Tests\Mocks\SupportStubs\BackedIntEnum::First], - ['p2', 2, \PhpMcp\Server\Tests\Mocks\SupportStubs\BackedIntEnum::Second], - // p4 is nullable enum - test passing valid value - ['p4', 'A', \PhpMcp\Server\Tests\Mocks\SupportStubs\BackedStringEnum::OptionA], - // p5 is optional with default - test passing valid value - ['p5', 2, \PhpMcp\Server\Tests\Mocks\SupportStubs\BackedIntEnum::Second], -]); - -test('throws McpException for invalid enum values', function (string $paramName, mixed $invalidValue) { - $method = reflectMethod('enumTypes'); - $input = [ - 'p1' => 'A', 'p2' => 1, 'p3' => 'Yes', // Valid base values - $paramName => $invalidValue, // Override with invalid value - ]; - - $this->preparer->prepareMethodArguments($this->stubInstance, 'enumTypes', $input); - -})->throws(McpException::class) // Expect the wrapped exception - ->with([ - ['p1', 'C'], // Invalid string for BackedStringEnum - ['p2', 3], // Invalid int for BackedIntEnum - ['p1', null], // Null for non-nullable enum - ]); - -// ReflectionParameter::isVariadic() exists, but ArgumentPreparer doesn't use it currently. -// For now, variadics aren't handled by the preparer. diff --git a/tests/Unit/Support/AttributeFinderTest.php b/tests/Unit/Support/AttributeFinderTest.php deleted file mode 100644 index a64e753..0000000 --- a/tests/Unit/Support/AttributeFinderTest.php +++ /dev/null @@ -1,166 +0,0 @@ -finder = new AttributeFinder(); -}); - -// --- Class Attribute Tests --- - -test('getFirstClassAttribute finds first matching attribute', function () { - $reflectionClass = new ReflectionClass(AttributeTestStub::class); - $attributeRefl = $this->finder->getFirstClassAttribute($reflectionClass, TestAttributeOne::class); - - expect($attributeRefl)->toBeInstanceOf(ReflectionAttribute::class); - $attributeInstance = $attributeRefl->newInstance(); - expect($attributeInstance)->toBeInstanceOf(TestAttributeOne::class); - expect($attributeInstance->value)->toBe('class-level'); -}); - -test('getFirstClassAttribute returns null if attribute not found', function () { - $reflectionClass = new ReflectionClass(PlainPhpClass::class); // Class with no attributes - $attributeRefl = $this->finder->getFirstClassAttribute($reflectionClass, TestAttributeOne::class); - expect($attributeRefl)->toBeNull(); -}); - -test('getClassAttributes finds all attributes of a type', function () { - $reflectionClass = new ReflectionClass(AttributeTestStub::class); - $attributes = $this->finder->getClassAttributes($reflectionClass, TestAttributeOne::class); - expect($attributes)->toBeArray()->toHaveCount(1); - expect($attributes[0])->toBeInstanceOf(ReflectionAttribute::class); - $instance = $attributes[0]->newInstance(); - expect($instance)->toBeInstanceOf(TestAttributeOne::class); - expect($instance->value)->toBe('class-level'); - - $attributesTwo = $this->finder->getClassAttributes($reflectionClass, TestClassOnlyAttribute::class); - expect($attributesTwo)->toBeArray()->toHaveCount(1); - expect($attributesTwo[0])->toBeInstanceOf(ReflectionAttribute::class); - $instanceTwo = $attributesTwo[0]->newInstance(); - expect($instanceTwo)->toBeInstanceOf(TestClassOnlyAttribute::class); -}); - -// --- Method Attribute Tests --- - -test('getMethodAttributes finds all attributes of a type', function () { - $reflectionMethod = new ReflectionMethod(AttributeTestStub::class, 'methodTwo'); - $attributes = $this->finder->getMethodAttributes($reflectionMethod, TestAttributeOne::class); - expect($attributes)->toBeArray()->toHaveCount(1); - expect($attributes[0])->toBeInstanceOf(ReflectionAttribute::class); - $instance1 = $attributes[0]->newInstance(); - expect($instance1)->toBeInstanceOf(TestAttributeOne::class); - expect($instance1->value)->toBe('method-two'); - - $attributesTwo = $this->finder->getMethodAttributes($reflectionMethod, TestAttributeTwo::class); - expect($attributesTwo)->toBeArray()->toHaveCount(1); - expect($attributesTwo[0])->toBeInstanceOf(ReflectionAttribute::class); - $instance2 = $attributesTwo[0]->newInstance(); - expect($instance2)->toBeInstanceOf(TestAttributeTwo::class); - expect($instance2->number)->toBe(2); -}); - -// REMOVED: test 'getMethodAttributes finds all attributes if no type specified' - -test('getMethodAttributes returns empty array if none found', function () { - $reflectionMethod = new ReflectionMethod(AttributeTestStub::class, 'methodThree'); - $attributes = $this->finder->getMethodAttributes($reflectionMethod, TestAttributeOne::class); - expect($attributes)->toBeArray()->toBeEmpty(); -}); - -test('getFirstMethodAttribute finds first matching attribute', function () { - $reflectionMethod = new ReflectionMethod(AttributeTestStub::class, 'methodTwo'); - $attributeRefl = $this->finder->getFirstMethodAttribute($reflectionMethod, TestAttributeOne::class); - expect($attributeRefl)->toBeInstanceOf(ReflectionAttribute::class); - $instance = $attributeRefl->newInstance(); - expect($instance)->toBeInstanceOf(TestAttributeOne::class); - expect($instance->value)->toBe('method-two'); -}); - -test('getFirstMethodAttribute returns null if attribute not found', function () { - $reflectionMethod = new ReflectionMethod(AttributeTestStub::class, 'methodThree'); - $attributeRefl = $this->finder->getFirstMethodAttribute($reflectionMethod, TestAttributeOne::class); - expect($attributeRefl)->toBeNull(); -}); - -// --- Parameter Attribute Tests --- - -test('getParameterAttributes finds all attributes of a type', function () { - $reflectionParam = new ReflectionParameter([AttributeTestStub::class, 'methodOne'], 'param1'); - $attributes = $this->finder->getParameterAttributes($reflectionParam, TestAttributeOne::class); - expect($attributes)->toBeArray()->toHaveCount(1); - expect($attributes[0])->toBeInstanceOf(ReflectionAttribute::class); - $instance1 = $attributes[0]->newInstance(); - expect($instance1)->toBeInstanceOf(TestAttributeOne::class); - expect($instance1->value)->toBe('param-one'); - - $attributesTwo = $this->finder->getParameterAttributes($reflectionParam, TestAttributeTwo::class); - expect($attributesTwo)->toBeArray()->toHaveCount(1); - expect($attributesTwo[0])->toBeInstanceOf(ReflectionAttribute::class); - $instance2 = $attributesTwo[0]->newInstance(); - expect($instance2)->toBeInstanceOf(TestAttributeTwo::class); - expect($instance2->number)->toBe(1); -}); - -// REMOVED: test 'getParameterAttributes finds all attributes if no type specified' - -test('getParameterAttributes returns empty array if none found', function () { - $reflectionParam = new ReflectionParameter([AttributeTestStub::class, 'methodThree'], 'unattributedParam'); - $attributes = $this->finder->getParameterAttributes($reflectionParam, TestAttributeOne::class); - expect($attributes)->toBeArray()->toBeEmpty(); -}); - -test('getFirstParameterAttribute finds first matching attribute', function () { - $reflectionParam = new ReflectionParameter([AttributeTestStub::class, 'methodOne'], 'param1'); - $attributeRefl = $this->finder->getFirstParameterAttribute($reflectionParam, TestAttributeOne::class); - expect($attributeRefl)->toBeInstanceOf(ReflectionAttribute::class); - $instance = $attributeRefl->newInstance(); - expect($instance)->toBeInstanceOf(TestAttributeOne::class); - expect($instance->value)->toBe('param-one'); -}); - -test('getFirstParameterAttribute returns null if attribute not found', function () { - $reflectionParam = new ReflectionParameter([AttributeTestStub::class, 'methodThree'], 'unattributedParam'); - $attributeRefl = $this->finder->getFirstParameterAttribute($reflectionParam, TestAttributeOne::class); - expect($attributeRefl)->toBeNull(); -}); - -// --- Property Attribute Tests --- - -test('getPropertyAttributes finds attribute', function () { - $reflectionProp = new ReflectionProperty(AttributeTestStub::class, 'propertyOne'); - $attributes = $this->finder->getPropertyAttributes($reflectionProp, TestAttributeOne::class); - expect($attributes)->toBeArray()->toHaveCount(1); - expect($attributes[0])->toBeInstanceOf(ReflectionAttribute::class); - $instance1 = $attributes[0]->newInstance(); - expect($instance1)->toBeInstanceOf(TestAttributeOne::class); - expect($instance1->value)->toBe('prop-level'); - - $attributesTwo = $this->finder->getPropertyAttributes($reflectionProp, TestAttributeTwo::class); - expect($attributesTwo)->toBeArray()->toBeEmpty(); // TestAttributeTwo not on property -}); - -test('getFirstPropertyAttribute finds attribute', function () { - $reflectionProp = new ReflectionProperty(AttributeTestStub::class, 'propertyOne'); - $attributeRefl = $this->finder->getFirstPropertyAttribute($reflectionProp, TestAttributeOne::class); - expect($attributeRefl)->toBeInstanceOf(ReflectionAttribute::class); - $instance = $attributeRefl->newInstance(); - expect($instance)->toBeInstanceOf(TestAttributeOne::class); - expect($instance->value)->toBe('prop-level'); - - $nullRefl = $this->finder->getFirstPropertyAttribute($reflectionProp, TestAttributeTwo::class); - expect($nullRefl)->toBeNull(); -}); diff --git a/tests/Unit/Support/DiscovererTest.php b/tests/Unit/Support/DiscovererTest.php deleted file mode 100644 index 3d9f446..0000000 --- a/tests/Unit/Support/DiscovererTest.php +++ /dev/null @@ -1,215 +0,0 @@ -registry = Mockery::mock(Registry::class); - /** @var LoggerInterface&MockInterface $logger */ - $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - - $attributeFinder = new AttributeFinder(); - $docBlockParser = new DocBlockParser($this->logger); - $schemaGenerator = new SchemaGenerator($docBlockParser, $attributeFinder); - - $this->discoverer = new Discoverer( - $this->registry, - $this->logger, - $docBlockParser, - $schemaGenerator, - $attributeFinder, - ); -}); - -afterEach(function () { - cleanupTempDir(); -}); - -test('discovers all element types in a single file', function () { - // Arrange - $filePath = createDiscoveryTestFile('AllElementsStub'); - - // Assert registry interactions - $this->registry->shouldReceive('registerTool')->once()->with(Mockery::on(function ($arg) { - return $arg instanceof ToolDefinition && $arg->toolName === 'discovered-tool'; - })); - $this->registry->shouldReceive('registerResource')->once()->with(Mockery::on(function ($arg) { - return $arg instanceof ResourceDefinition && $arg->uri === 'discovered://resource'; - })); - $this->registry->shouldReceive('registerPrompt')->once()->with(Mockery::on(function ($arg) { - return $arg instanceof PromptDefinition && $arg->promptName === 'discovered-prompt'; - })); - $this->registry->shouldReceive('registerResourceTemplate')->once()->with(Mockery::on(function ($arg) { - return $arg instanceof ResourceTemplateDefinition && $arg->uriTemplate === 'discovered://template/{id}'; - })); - - $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); - - // Act - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); -}); - -test('discovers elements across multiple files', function () { - // Arrange - $file1Path = createDiscoveryTestFile('ToolOnlyStub'); - $file2Path = createDiscoveryTestFile('ResourceOnlyStub'); - - // Assert registry interactions - $this->registry->shouldReceive('registerTool')->once()->with(Mockery::on(fn ($arg) => $arg->toolName === 'tool-from-file1')); - $this->registry->shouldNotReceive('registerResource'); - $this->registry->shouldNotReceive('registerPrompt'); - $this->registry->shouldNotReceive('registerResourceTemplate'); - - // Ensure no errors during processing of these files - $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && ($ctx['file'] === $file1Path || $ctx['file'] === $file2Path))); - - // Act - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); -}); - -test('handles directory with no MCP elements', function () { - // Arrange - createDiscoveryTestFile('PlainPhpClass'); - - // Assert registry interactions - $this->registry->shouldNotReceive('registerTool'); - $this->registry->shouldNotReceive('registerResource'); - $this->registry->shouldNotReceive('registerPrompt'); - $this->registry->shouldNotReceive('registerResourceTemplate'); - - // Act - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); -}); - -test('handles non-existent directory gracefully', function () { - // Arrange - $nonExistentDir = TEST_DISCOVERY_DIR . '/nonexistent'; - - // Assert registry interactions - $this->registry->shouldNotReceive('registerTool'); - $this->registry->shouldNotReceive('registerResource'); - $this->registry->shouldNotReceive('registerPrompt'); - $this->registry->shouldNotReceive('registerResourceTemplate'); - - // Assert logging - $this->logger->shouldReceive('warning')->with('No valid discovery directories found to scan.', Mockery::any())->twice(); - - // Act - $this->discoverer->discover($nonExistentDir, ['.']); // Base path doesn't exist - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['nonexistent_subdir']); -}); - -test('skips non-instantiable classes and non-public/static/constructor methods', function (string $stubName, int $expectedRegistrations) { - // Arrange - $filePath = createDiscoveryTestFile($stubName); - - if ($expectedRegistrations === 0) { - $this->registry->shouldNotReceive('registerTool'); - $this->registry->shouldNotReceive('registerResource'); - $this->registry->shouldNotReceive('registerPrompt'); - $this->registry->shouldNotReceive('registerResourceTemplate'); - } else { - // Example if one tool is expected (adjust if other types can be expected) - $this->registry->shouldReceive('registerTool')->times($expectedRegistrations); - $this->registry->shouldNotReceive('registerResource'); - $this->registry->shouldNotReceive('registerPrompt'); - $this->registry->shouldNotReceive('registerResourceTemplate'); - } - - // Ensure no processing errors for this file - $this->logger->shouldNotReceive('error')->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); - - // Act - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); -})->with([ - 'Abstract class' => ['AbstractStub', 0], - 'Interface' => ['InterfaceStub', 0], - 'Trait' => ['TraitStub', 0], - 'Enum' => ['EnumStub', 0], - 'Static method' => ['StaticMethodStub', 0], - 'Protected method' => ['ProtectedMethodStub', 0], - 'Private method' => ['PrivateMethodStub', 0], - 'Constructor' => ['ConstructorStub', 0], - 'Inherited method' => ['ChildInheriting', 0], // Child has no *declared* methods with attributes - 'Class using Trait' => ['ClassUsingTrait', 1], // Expect the trait method to be found - // Need to also test scanning the parent/trait files directly if needed -]); - -test('handles definition creation error and continues', function () { - // Arrange - $filePath = createDiscoveryTestFile('MixedValidityStub'); - - // Assert registry interactions - $this->registry->shouldReceive('registerTool') - ->with(Mockery::on(fn ($arg) => $arg instanceof ToolDefinition && $arg->toolName === 'valid-tool')) - ->once(); - $this->registry->shouldReceive('registerTool') - ->with(Mockery::on(fn ($arg) => $arg instanceof ToolDefinition && $arg->toolName === 'another-valid-tool')) - ->once(); - $this->registry->shouldNotReceive('registerResource'); - - // Ensure no *other* unexpected errors related to this class/methods - $this->logger->shouldNotReceive('error') - ->with(Mockery::any(), Mockery::on(fn ($ctx) => isset($ctx['file']) && $ctx['file'] === $filePath)); - - // Act - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); -}); - -test('handles file read error gracefully', function () { - // Arrange - $invalidFile = TEST_DISCOVERY_DIR . '/invalid.php'; - touch($invalidFile); // Create the file - chmod($invalidFile, 0000); // Make it unreadable - - // Assert registry interactions - $this->registry->shouldNotReceive('registerTool'); - $this->registry->shouldNotReceive('registerResource'); - $this->registry->shouldNotReceive('registerPrompt'); - $this->registry->shouldNotReceive('registerResourceTemplate'); - - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); - - // Cleanup permissions - chmod($invalidFile, 0644); -}); - -test('discovers attributes placed directly on invokable classes', function (string $stubName, string $registryMethod, string $expectedNameOrUri) { - // Arrange - createDiscoveryTestFile($stubName); - - // Assert registry interactions - $this->registry->shouldReceive($registryMethod) - ->once() - ->with(Mockery::on(function ($arg) use ($expectedNameOrUri, $stubName) { - // Check if it's the correct definition type and name/uri - return ($arg instanceof ToolDefinition && $arg->toolName === $expectedNameOrUri) - || ($arg instanceof ResourceDefinition && $arg->uri === $expectedNameOrUri) - || ($arg instanceof PromptDefinition && $arg->promptName === $expectedNameOrUri) - || ($arg instanceof ResourceTemplateDefinition && $arg->uriTemplate === $expectedNameOrUri) - // Verify the definition points to the __invoke method - && $arg->methodName === '__invoke' - && str_ends_with($arg->className, $stubName); - })); - - // Act - $this->discoverer->discover(TEST_DISCOVERY_DIR, ['.']); -})->with([ - 'Invokable Tool' => ['InvokableToolStub', 'registerTool', 'invokable-tool'], - 'Invokable Resource' => ['InvokableResourceStub', 'registerResource', 'invokable://resource'], - 'Invokable Prompt' => ['InvokablePromptStub', 'registerPrompt', 'invokable-prompt'], - 'Invokable Template' => ['InvokableTemplateStub', 'registerResourceTemplate', 'invokable://template/{id}'], -]); diff --git a/tests/Unit/Support/RequestProcessorTest.php b/tests/Unit/Support/RequestProcessorTest.php deleted file mode 100644 index 648c4ef..0000000 --- a/tests/Unit/Support/RequestProcessorTest.php +++ /dev/null @@ -1,263 +0,0 @@ -expect($response)->toBeInstanceOf(Response::class); - test()->expect($response->id)->toBe($id); - test()->expect($response->result)->toBeNull(); - test()->expect($response->error)->toBeInstanceOf(JsonRpcError::class); - test()->expect($response->error->code)->toBe($expectedCode); -} - -beforeEach(function () { - $this->containerMock = Mockery::mock(ContainerInterface::class); - $this->registryMock = Mockery::mock(Registry::class); - $this->clientStateManagerMock = Mockery::mock(ClientStateManager::class); - /** @var LoggerInterface&MockInterface $loggerMock */ - $this->loggerMock = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $this->schemaValidatorMock = Mockery::mock(SchemaValidator::class); - $this->argumentPreparerMock = Mockery::mock(ArgumentPreparer::class); - $this->cacheMock = Mockery::mock(CacheInterface::class); - - $this->configuration = new Configuration( - serverName: SERVER_NAME_PROC, - serverVersion: SERVER_VERSION_PROC, - capabilities: Capabilities::forServer(), - logger: $this->loggerMock, - loop: Loop::get(), - cache: $this->cacheMock, - container: $this->containerMock, - definitionCacheTtl: 3600 - ); - - $this->registryMock->allows('allTools')->withNoArgs()->andReturn(new \ArrayObject())->byDefault(); - $this->registryMock->allows('allResources')->withNoArgs()->andReturn(new \ArrayObject())->byDefault(); - $this->registryMock->allows('allResourceTemplates')->withNoArgs()->andReturn(new \ArrayObject())->byDefault(); - $this->registryMock->allows('allPrompts')->withNoArgs()->andReturn(new \ArrayObject())->byDefault(); - - $this->clientStateManagerMock->allows('isInitialized')->with(CLIENT_ID_PROC)->andReturn(false)->byDefault(); - - $this->processor = new RequestProcessor( - $this->configuration, - $this->registryMock, - $this->clientStateManagerMock, - $this->schemaValidatorMock, - $this->argumentPreparerMock - ); -}); - -it('can be instantiated', function () { - expect($this->processor)->toBeInstanceOf(RequestProcessor::class); -}); - -it('can handle an initialize request', function () { - $clientInfo = ['name' => 'TestClientProc', 'version' => '1.3.0']; - $request = createRequest('initialize', [ - 'protocolVersion' => SUPPORTED_VERSION_PROC, - 'clientInfo' => $clientInfo, - ]); - - $this->clientStateManagerMock->shouldReceive('storeClientInfo')->once()->with($clientInfo, SUPPORTED_VERSION_PROC, CLIENT_ID_PROC); - - // Mock registry counts to enable capabilities in response - $this->registryMock->allows('allTools')->andReturn(new \ArrayObject(['dummyTool' => new stdClass()])); - $this->registryMock->allows('allResources')->andReturn(new \ArrayObject(['dummyRes' => new stdClass()])); - $this->registryMock->allows('allPrompts')->andReturn(new \ArrayObject(['dummyPrompt' => new stdClass()])); - - // Override default capabilities in the configuration passed to processor for this test - $capabilities = Capabilities::forServer( - toolsEnabled: true, - toolsListChanged: true, - resourcesEnabled: true, - resourcesSubscribe: true, - resourcesListChanged: false, - promptsEnabled: true, - promptsListChanged: true, - loggingEnabled: true, - instructions: 'Test Instructions' - ); - $this->configuration = new Configuration( - serverName: SERVER_NAME_PROC, - serverVersion: SERVER_VERSION_PROC, - capabilities: $capabilities, - logger: $this->loggerMock, - loop: Loop::get(), - cache: $this->cacheMock, - container: $this->containerMock - ); - $this->processor = new RequestProcessor($this->configuration, $this->registryMock, $this->clientStateManagerMock, $this->schemaValidatorMock, $this->argumentPreparerMock); - - /** @var Response $response */ - $response = $this->processor->process($request, CLIENT_ID_PROC); - - expect($response)->toBeInstanceOf(Response::class); - expect($response->id)->toBe($request->id); - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(InitializeResult::class); - expect($response->result->serverInfo['name'])->toBe(SERVER_NAME_PROC); - expect($response->result->serverInfo['version'])->toBe(SERVER_VERSION_PROC); - expect($response->result->protocolVersion)->toBe(SUPPORTED_VERSION_PROC); - expect($response->result->capabilities)->toHaveKeys(['tools', 'resources', 'prompts', 'logging']); - expect($response->result->capabilities['tools'])->toEqual(['listChanged' => true]); - expect($response->result->capabilities['resources'])->toEqual(['subscribe' => true]); - expect($response->result->capabilities['prompts'])->toEqual(['listChanged' => true]); - expect($response->result->capabilities['logging'])->toBeInstanceOf(stdClass::class); - expect($response->result->instructions)->toBe('Test Instructions'); -}); - -it('marks client as initialized when receiving an initialized notification', function () { - $notification = createNotification('notifications/initialized'); - $this->clientStateManagerMock->shouldReceive('markInitialized')->once()->with(CLIENT_ID_PROC); - $response = $this->processor->process($notification, CLIENT_ID_PROC); - expect($response)->toBeNull(); -}); - -it('fails if client not initialized for non-initialize methods', function (string $method) { - $request = createRequest($method); - $response = $this->processor->process($request, CLIENT_ID_PROC); - expectMcpErrorResponse($response, McpServerException::CODE_INVALID_REQUEST); - expect($response->error->message)->toContain('Client not initialized'); -})->with([ - 'tools/list', - 'tools/call', - 'resources/list', // etc. -]); - -it('fails if capability is disabled', function (string $method, array $params, array $enabledCaps) { - $this->clientStateManagerMock->allows('isInitialized')->with(CLIENT_ID_PROC)->andReturn(true); - - $capabilities = Capabilities::forServer(...$enabledCaps); - $this->configuration = new Configuration( - serverName: SERVER_NAME_PROC, - serverVersion: SERVER_VERSION_PROC, - capabilities: $capabilities, - logger: $this->loggerMock, - loop: Loop::get(), - cache: $this->cacheMock, - container: $this->containerMock - ); - $this->processor = new RequestProcessor($this->configuration, $this->registryMock, $this->clientStateManagerMock, $this->schemaValidatorMock, $this->argumentPreparerMock); - - $request = createRequest($method, $params); - $response = $this->processor->process($request, CLIENT_ID_PROC); - - expectMcpErrorResponse($response, McpServerException::CODE_METHOD_NOT_FOUND); - expect($response->error->message)->toContain('capability'); - expect($response->error->message)->toContain('is not enabled'); -})->with([ - 'tools/call' => ['tools/call', [], ['toolsEnabled' => false]], - 'resources/read' => ['resources/read', [], ['resourcesEnabled' => false]], - 'resources/subscribe' => ['resources/subscribe', ['uri' => 'https://example.com/resource'], ['resourcesSubscribe' => false]], - 'resources/templates/list' => ['resources/templates/list', [], ['resourcesEnabled' => false]], - 'prompts/list' => ['prompts/list', [], ['promptsEnabled' => false]], - 'prompts/get' => ['prompts/get', [], ['promptsEnabled' => false]], - 'logging/setLevel' => ['logging/setLevel', [], ['loggingEnabled' => false]], -]); - -it('pings successfully for initialized client', function () { - $this->clientStateManagerMock->allows('isInitialized')->with(CLIENT_ID_PROC)->andReturn(true); - $request = createRequest('ping'); - $response = $this->processor->process($request, CLIENT_ID_PROC); - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(EmptyResult::class); -}); - -it('can list tools using hardcoded limit', function () { - $this->clientStateManagerMock->allows('isInitialized')->with(CLIENT_ID_PROC)->andReturn(true); - $tool1 = new ToolDefinition('Class', 'm1', 'tool1', 'd1', []); - $tool2 = new ToolDefinition('Class', 'm2', 'tool2', 'd2', []); - $this->registryMock->allows('allTools')->andReturn(new \ArrayObject([$tool1, $tool2])); - - $request = createRequest('tools/list'); - $response = $this->processor->process($request, CLIENT_ID_PROC); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(ListToolsResult::class); - expect($response->result->tools)->toHaveCount(2); // Assumes limit >= 2 -}); - -it('can call a tool using the container to get handler', function () { - $this->clientStateManagerMock->allows('isInitialized')->with(CLIENT_ID_PROC)->andReturn(true); - $toolName = 'myTool'; - $handlerClass = 'App\\Handlers\\MyToolHandler'; - $handlerMethod = 'execute'; - $rawArgs = ['p' => 'v']; - $toolResult = 'Success'; - $definition = new ToolDefinition( - className: $handlerClass, - methodName: $handlerMethod, - toolName: $toolName, - description: 'd1', - inputSchema: [], - ); - $handlerInstance = Mockery::mock($handlerClass); - - - $this->registryMock->shouldReceive('findTool')->once()->with($toolName)->andReturn($definition); - $this->schemaValidatorMock->shouldReceive('validateAgainstJsonSchema')->once()->andReturn([]); - // *** Assert container is used *** - $this->containerMock->shouldReceive('get')->once()->with($handlerClass)->andReturn($handlerInstance); - // ******************************* - $this->argumentPreparerMock->shouldReceive('prepareMethodArguments')->once()->andReturn(['v']); - $handlerInstance->shouldReceive($handlerMethod)->once()->with('v')->andReturn($toolResult); - - // Spy/mock formatToolResult - /** @var RequestProcessor&MockInterface $processorSpy */ - $processorSpy = Mockery::mock(RequestProcessor::class . '[formatToolResult]', [ - $this->configuration, - $this->registryMock, - $this->clientStateManagerMock, - $this->schemaValidatorMock, - $this->argumentPreparerMock, - ])->makePartial()->shouldAllowMockingProtectedMethods(); - $processorSpy->shouldReceive('formatToolResult')->once()->andReturn([new TextContent('Success')]); - - $request = createRequest('tools/call', ['name' => $toolName, 'arguments' => $rawArgs]); - $response = $processorSpy->process($request, CLIENT_ID_PROC); - - expect($response->error)->toBeNull(); - expect($response->result)->toBeInstanceOf(CallToolResult::class); -}); diff --git a/tests/Unit/Support/UriTemplateMatcherTest.php b/tests/Unit/Support/UriTemplateMatcherTest.php deleted file mode 100644 index d5bfd67..0000000 --- a/tests/Unit/Support/UriTemplateMatcherTest.php +++ /dev/null @@ -1,122 +0,0 @@ -match($uri); - expect($variables)->toEqual($expectedVariables); -})->with([ - ['user://{userId}', 'user://12345', ['userId' => '12345']], - ['user://{userId}', 'user://abc-def', ['userId' => 'abc-def']], - ['user://{userId}', 'user://', null], // Missing variable part - ['user://{userId}', 'users://12345', null], // Wrong scheme/path start - ['item/{itemId}', 'item/xyz', ['itemId' => 'xyz']], // No scheme - ['item/{itemId}', 'item/', null], - ['{onlyVar}', 'anything', ['onlyVar' => 'anything']], - ['{onlyVar}', '', null], -]); - -test('matches multi-variable template', function (string $template, string $uri, ?array $expectedVariables) { - $matcher = new UriTemplateMatcher($template); - $variables = $matcher->match($uri); - expect($variables)->toEqual($expectedVariables); -})->with([ - [ - 'item/{category}/{itemId}/details', - 'item/books/978-abc/details', - ['category' => 'books', 'itemId' => '978-abc'], - ], - [ - 'item/{category}/{itemId}/details', - 'item/books//details', // Empty itemId segment - null, // Currently matches [^/]+, so empty segment fails - ], - [ - 'item/{category}/{itemId}/details', - 'item/books/978-abc/summary', // Wrong literal end - null, - ], - [ - 'item/{category}/{itemId}', - 'item/tools/hammer', - ['category' => 'tools', 'itemId' => 'hammer'], - ], - [ - 'item/{category}/{itemId}', - 'item/tools/hammer/extra', // Extra path segment - null, - ], -]); - -test('matches template with literals and variables mixed', function (string $template, string $uri, ?array $expectedVariables) { - $matcher = new UriTemplateMatcher($template); - $variables = $matcher->match($uri); - expect($variables)->toEqual($expectedVariables); -})->with([ - [ - 'user://{userId}/profile/pic_{picId}.jpg', - 'user://kp/profile/pic_main.jpg', - ['userId' => 'kp', 'picId' => 'main'], - ], - [ - 'user://{userId}/profile/pic_{picId}.jpg', - 'user://kp/profile/pic_main.png', // Wrong extension - null, - ], - [ - 'user://{userId}/profile/img_{picId}.jpg', // Wrong literal prefix - 'user://kp/profile/pic_main.jpg', - null, - ], -]); - -test('matches template with no variables', function (string $template, string $uri, ?array $expectedVariables) { - $matcher = new UriTemplateMatcher($template); - $variables = $matcher->match($uri); - // Expect empty array on match, null otherwise - if ($expectedVariables !== null) { - expect($variables)->toBeArray()->toBeEmpty(); - } else { - expect($variables)->toBeNull(); - } - -})->with([ - ['config://settings/app', 'config://settings/app', []], - ['config://settings/app', 'config://settings/user', null], - ['/path/to/resource', '/path/to/resource', []], - ['/path/to/resource', '/path/to/other', null], -]); - -test('handles characters needing escaping in literals', function () { - // Characters like . ? * + ( ) [ ] | are escaped by preg_quote - $template = 'search/{query}/results.json?page={pageNo}'; - $matcher = new UriTemplateMatcher($template); - - $variables = $matcher->match('search/term.with.dots/results.json?page=2'); - expect($variables)->toEqual(['query' => 'term.with.dots', 'pageNo' => '2']); - - $noMatch = $matcher->match('search/term/results.xml?page=1'); // Wrong literal extension - expect($noMatch)->toBeNull(); -}); - -test('constructor compiles regex', function () { - $template = 'test/{id}/value'; - $matcher = new UriTemplateMatcher($template); - - // Use reflection to check the compiled regex (optional, implementation detail) - $reflection = new \ReflectionClass($matcher); - $regexProp = $reflection->getProperty('regex'); - $regexProp->setAccessible(true); - $compiledRegex = $regexProp->getValue($matcher); - - // Expected regex: starts with delimiter, ^, literals escaped, var replaced, $, delimiter - // Example: '#^test\/(?P[^/]+)\/value$#' - expect($compiledRegex)->toBeString()->toContain('^test/') - ->toContain('(?P[^/]+)') - ->toContain('/value$'); -}); diff --git a/tests/Unit/Traits/ResponseFormatterTest.php b/tests/Unit/Traits/ResponseFormatterTest.php deleted file mode 100644 index 4942bfd..0000000 --- a/tests/Unit/Traits/ResponseFormatterTest.php +++ /dev/null @@ -1,326 +0,0 @@ -formatter = new TestFormatterClass(); - /** @var \Mockery\MockInterface&\Psr\Log\LoggerInterface */ - $this->loggerMock = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - $this->formatter->logger = $this->loggerMock; - - // For SplFileInfo test - $this->tempFilePath = tempnam(sys_get_temp_dir(), 'resfmt_'); - file_put_contents($this->tempFilePath, 'splfile test content'); -}); - -afterEach(function () { - // Clean up temp file - if (isset($this->tempFilePath) && file_exists($this->tempFilePath)) { - unlink($this->tempFilePath); - } -}); - -// --- formatToolResult Tests --- - -test('formatToolResult handles scalars', function ($input, $expectedText) { - $result = $this->formatter->formatToolResult($input); - - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBeInstanceOf(TextContent::class) - ->and($result[0]->text)->toBe($expectedText); -})->with([ - ['hello world', 'hello world'], - [12345, '12345'], - [98.76, '98.76'], - [true, 'true'], - [false, 'false'], -]); - -test('formatToolResult handles null', function () { - $result = $this->formatter->formatToolResult(null); - - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBeInstanceOf(TextContent::class) - ->and($result[0]->text)->toBe('(null)'); -}); - -test('formatToolResult handles array (JSON encodes)', function () { - $data = ['key' => 'value', 'list' => [1, null, true]]; - $expectedJson = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $result = $this->formatter->formatToolResult($data); - - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBeInstanceOf(TextContent::class) - ->and($result[0]->text)->toBe($expectedJson); -}); - -test('formatToolResult handles object (JSON encodes)', function () { - $data = new stdClass(); - $data->key = 'value'; - $data->list = [1, null, true]; - $expectedJson = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $result = $this->formatter->formatToolResult($data); - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBeInstanceOf(TextContent::class) - ->and($result[0]->text)->toBe($expectedJson); -}); - -test('formatToolResult handles single Content object', function () { - $content = new ImageContent('base64data', 'image/png'); - $result = $this->formatter->formatToolResult($content); - - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBe($content); -}); - -test('formatToolResult handles array of Content objects', function () { - $contentArray = [new TextContent('one'), new TextContent('two')]; - $result = $this->formatter->formatToolResult($contentArray); - - expect($result)->toBe($contentArray); -}); - -test('formatToolResult throws JsonException for unencodable value', function () { - $resource = fopen('php://memory', 'r'); - $this->formatter->formatToolResult($resource); - if (is_resource($resource)) { - fclose($resource); - } -})->throws(JsonException::class); - -// --- formatToolErrorResult Tests --- - -test('formatToolErrorResult creates correct TextContent', function () { - $exception = new \RuntimeException('Something went wrong'); - $result = $this->formatter->formatToolErrorResult($exception); - - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBeInstanceOf(TextContent::class) - ->and($result[0]->text)->toBe('Tool execution failed: Something went wrong (Type: RuntimeException)'); -}); - -// --- formatResourceContents Tests --- - -test('formatResourceContents handles EmbeddedResource input', function () { - $resource = new EmbeddedResource('test/uri', 'text/plain', 'content'); - $result = $this->formatter->formatResourceContents($resource, 'test/uri', 'text/plain'); - - expect($result)->toBe([$resource]); -}); - -test('formatResourceContents handles ResourceContent input', function () { - $embedded = new EmbeddedResource('test/uri', 'text/plain', 'content'); - $resourceContent = new ResourceContent($embedded); - $result = $this->formatter->formatResourceContents($resourceContent, 'test/uri', 'text/plain'); - - expect($result)->toEqual([$embedded]); -}); - -test('formatResourceContents handles array of EmbeddedResource input', function () { - $resources = [ - new EmbeddedResource('test/uri1', 'text/plain', 'content1'), - new EmbeddedResource('test/uri2', 'image/png', null, 'blob2'), - ]; - $result = $this->formatter->formatResourceContents($resources, 'test/uri', 'text/plain'); - - expect($result)->toBe($resources); -}); - -test('formatResourceContents handles array of ResourceContent input', function () { - $embedded1 = new EmbeddedResource('test/uri1', 'text/plain', 'content1'); - $embedded2 = new EmbeddedResource('test/uri2', 'image/png', null, 'blob2'); - $resourceContents = [new ResourceContent($embedded1), new ResourceContent($embedded2)]; - $result = $this->formatter->formatResourceContents($resourceContents, 'test/uri', 'text/plain'); - - expect($result)->toEqual([$embedded1, $embedded2]); -}); - -test('formatResourceContents handles string input (guessing text mime)', function () { - $result = $this->formatter->formatResourceContents('Simple text', 'test/uri', null); - - expect($result)->toEqual([new EmbeddedResource('test/uri', 'text/plain', 'Simple text')]); -}); - -test('formatResourceContents handles string input (guessing json mime)', function () { - $result = $this->formatter->formatResourceContents('{"key":"value"}', 'test/uri', null); - - expect($result)->toEqual([new EmbeddedResource('test/uri', 'application/json', '{"key":"value"}')]); -}); - -test('formatResourceContents handles string input (guessing html mime)', function () { - $result = $this->formatter->formatResourceContents('Hi', 'test/uri', null); - - expect($result)->toEqual([new EmbeddedResource('test/uri', 'text/html', 'Hi')]); -}); - -test('formatResourceContents handles string input (with default mime)', function () { - $result = $this->formatter->formatResourceContents('Specific content', 'test/uri', 'text/csv'); - - expect($result)->toEqual([new EmbeddedResource('test/uri', 'text/csv', 'Specific content')]); -}); - -test('formatResourceContents handles stream input', function () { - $stream = fopen('php://memory', 'r+'); - fwrite($stream, 'stream content'); - rewind($stream); - - $result = $this->formatter->formatResourceContents($stream, 'test/uri', 'application/pdf'); - - // Stream should be closed after reading - // expect(is_resource($stream))->toBeFalse(); - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBeInstanceOf(EmbeddedResource::class) - ->and($result[0]->uri)->toBe('test/uri') - ->and($result[0]->mimeType)->toBe('application/pdf') - ->and($result[0]->text)->toBeNull() - ->and($result[0]->blob)->toBe(base64_encode('stream content')); -}); - -test('formatResourceContents handles array blob input', function () { - $data = ['blob' => base64_encode('binary'), 'mimeType' => 'image/jpeg']; - $result = $this->formatter->formatResourceContents($data, 'test/uri', 'application/octet-stream'); - - expect($result)->toEqual([new EmbeddedResource('test/uri', 'image/jpeg', null, $data['blob'])]); -}); - -test('formatResourceContents handles array text input', function () { - $data = ['text' => 'hello', 'mimeType' => 'text/markdown']; - $result = $this->formatter->formatResourceContents($data, 'test/uri', 'text/plain'); - - expect($result)->toEqual([new EmbeddedResource('test/uri', 'text/markdown', 'hello')]); -}); - -test('formatResourceContents handles SplFileInfo input', function () { - $splFile = new SplFileInfo($this->tempFilePath); - $result = $this->formatter->formatResourceContents($splFile, 'test/uri', 'text/vnd.test'); - $result2 = $this->formatter->formatResourceContents($splFile, 'test/uri', 'image/png'); - - expect($result)->toBeArray()->toHaveCount(1) - ->and($result[0])->toBeInstanceOf(EmbeddedResource::class) - ->and($result[0]->uri)->toBe('test/uri') - ->and($result[0]->mimeType)->toBe('text/vnd.test') - ->and($result[0]->text)->toBe('splfile test content') - ->and($result[0]->blob)->toBeNull(); - - expect($result2)->toBeArray()->toHaveCount(1) - ->and($result2[0])->toBeInstanceOf(EmbeddedResource::class) - ->and($result2[0]->uri)->toBe('test/uri') - ->and($result2[0]->mimeType)->toBe('image/png') - ->and($result2[0]->text)->toBeNull() - ->and($result2[0]->blob)->toBe(base64_encode('splfile test content')); -}); - -test('formatResourceContents handles array input (json mime)', function () { - $data = ['a' => 1]; - $expectedJson = json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); - $result = $this->formatter->formatResourceContents($data, 'test/uri', 'application/json'); - expect($result)->toEqual([new EmbeddedResource('test/uri', 'application/json', $expectedJson)]); -}); - -test('formatResourceContents handles array input (non-json mime, logs warning)', function () { - $data = ['b' => 2]; - $expectedJson = json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); - $this->loggerMock->shouldReceive('warning')->once()->with('MCP SDK: Automatically converted array to JSON for resource', Mockery::any()); - $result = $this->formatter->formatResourceContents($data, 'test/uri', 'text/plain'); - // It should convert to JSON and use application/json mime type - expect($result)->toEqual([new EmbeddedResource('test/uri', 'application/json', $expectedJson)]); -}); - -test('formatResourceContents throws exception for unformattable type', function () { - $data = new stdClass(); // Simple object not handled directly - $this->formatter->formatResourceContents($data, 'test/uri', 'text/plain'); -})->throws(\RuntimeException::class, 'Cannot format resource read result'); - -// --- formatPromptMessages Tests --- - -test('formatPromptMessages handles array of PromptMessage input', function () { - $messages = [PromptMessage::user('Hi'), PromptMessage::assistant('Hello')]; - $result = $this->formatter->formatPromptMessages($messages); - expect($result)->toBe($messages); -}); - -test('formatPromptMessages handles simple role=>text array', function () { - $input = ['user' => 'User input', 'assistant' => 'Assistant reply']; - $expected = [PromptMessage::user('User input'), PromptMessage::assistant('Assistant reply')]; - $result = $this->formatter->formatPromptMessages($input); - expect($result)->toEqual($expected); -}); - -test('formatPromptMessages handles list of [role, content] arrays', function () { - $input = [ - ['role' => 'user', 'content' => 'First turn'], - ['role' => 'assistant', 'content' => 'Okay'], - ['role' => 'user', 'content' => new TextContent('Use content obj')], - ['role' => 'assistant', 'content' => ['type' => 'text', 'text' => 'Use text obj']], - ['role' => 'user', 'content' => ['type' => 'image', 'mimeType' => 'image/png', 'data' => 'abc']], - ['role' => 'assistant', 'content' => ['type' => 'audio', 'mimeType' => 'audio/mpeg', 'data' => 'def']], - ['role' => 'user', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'res/1', 'text' => 'res text']]], - ['role' => 'assistant', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'res/2', 'blob' => 'ghi', 'mimeType' => 'app/bin']]], - ]; - $expected = [ - PromptMessage::user('First turn'), - PromptMessage::assistant('Okay'), - new PromptMessage('user', new TextContent('Use content obj')), - new PromptMessage('assistant', new TextContent('Use text obj')), - new PromptMessage('user', new ImageContent('abc', 'image/png')), - new PromptMessage('assistant', new AudioContent('def', 'audio/mpeg')), - new PromptMessage('user', new ResourceContent(new EmbeddedResource('res/1', 'text/plain', 'res text'))), - new PromptMessage('assistant', new ResourceContent(new EmbeddedResource('res/2', 'app/bin', null, 'ghi'))), - ]; - $result = $this->formatter->formatPromptMessages($input); - expect($result)->toEqual($expected); -}); - -test('formatPromptMessages throws for non-array input', function () { - $this->formatter->formatPromptMessages('not an array'); -})->throws(\RuntimeException::class, 'must return an array of messages'); - -test('formatPromptMessages throws for non-list array input', function () { - $this->formatter->formatPromptMessages(['a' => 'b']); // Assoc array -})->throws(\RuntimeException::class, 'must return a list (sequential array)'); - -test('formatPromptMessages throws for invalid message structure (missing role)', function () { - $this->formatter->formatPromptMessages([['content' => 'text']]); -})->throws(\RuntimeException::class, 'Expected a PromptMessage or an array with \'role\' and \'content\''); - -test('formatPromptMessages throws for invalid role', function () { - $this->formatter->formatPromptMessages([['role' => 'system', 'content' => 'text']]); -})->throws(\RuntimeException::class, 'Invalid role \'system\''); - -test('formatPromptMessages throws for invalid content type', function () { - $this->formatter->formatPromptMessages([['role' => 'user', 'content' => ['type' => 'video', 'url' => '...']]]); -})->throws(\RuntimeException::class, "Invalid content type 'video'"); - -test('formatPromptMessages throws for invalid resource content (missing uri)', function () { - $this->formatter->formatPromptMessages([['role' => 'user', 'content' => ['type' => 'resource', 'resource' => ['text' => '...']]]]); -})->throws(\RuntimeException::class, "Missing or invalid 'uri'"); - -test('formatPromptMessages throws for invalid resource content (missing text/blob)', function () { - $this->formatter->formatPromptMessages([['role' => 'user', 'content' => ['type' => 'resource', 'resource' => ['uri' => 'res/1']]]]); -})->throws(\RuntimeException::class, "Must contain 'text' or 'blob'"); diff --git a/tests/Unit/Transports/HttpServerTransportTest.php b/tests/Unit/Transports/HttpServerTransportTest.php deleted file mode 100644 index cd5333d..0000000 --- a/tests/Unit/Transports/HttpServerTransportTest.php +++ /dev/null @@ -1,418 +0,0 @@ -loop = Loop::get(); - /** @var LoggerInterface&MockInterface */ - $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - - $this->transport = new HttpServerTransport(HOST, PORT, PREFIX); - $this->transport->setLogger($this->logger); - $this->transport->setLoop($this->loop); - - // Extract the request handler logic for direct testing - $reflector = new \ReflectionClass($this->transport); - $method = $reflector->getMethod('createRequestHandler'); - $method->setAccessible(true); - $this->requestHandler = $method->invoke($this->transport); - - // Reset internal state relevant to tests - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streamsProp->setValue($this->transport, []); - - $listeningProp = $reflector->getProperty('listening'); - $listeningProp->setAccessible(true); - $listeningProp->setValue($this->transport, true); - - $closingProp = $reflector->getProperty('closing'); - $closingProp->setAccessible(true); - $closingProp->setValue($this->transport, false); - - $socketProp = $reflector->getProperty('socket'); - $socketProp->setAccessible(true); - $socketProp->setValue($this->transport, null); - - $httpProp = $reflector->getProperty('http'); - $httpProp->setAccessible(true); - $httpProp->setValue($this->transport, null); -}); - -// --- Teardown --- -afterEach(function () { - $reflector = new \ReflectionClass($this->transport); - $closingProp = $reflector->getProperty('closing'); - $closingProp->setAccessible(true); - if (! $closingProp->getValue($this->transport)) { - $this->transport->close(); - } - Mockery::close(); -}); - -function createMockRequest( - string $method, - string $path, - array $queryParams = [], - string $bodyContent = '' -): MockInterface&ServerRequestInterface { - - $uriMock = Mockery::mock(UriInterface::class); - - $currentPath = $path; - $currentQuery = http_build_query($queryParams); - - $uriMock->shouldReceive('getPath')->andReturnUsing(function () use (&$currentPath) { - return $currentPath; - })->byDefault(); - - $uriMock->shouldReceive('getQuery')->andReturnUsing(function () use (&$currentQuery) { - return $currentQuery; - })->byDefault(); - - $uriMock->shouldReceive('withPath')->andReturnUsing( - function (string $newPath) use (&$currentPath, $uriMock) { - $currentPath = $newPath; - - return $uriMock; - } - ); - - $uriMock->shouldReceive('withQuery')->andReturnUsing( - function (string $newQuery) use (&$currentQuery, $uriMock) { - $currentQuery = $newQuery; - - return $uriMock; - } - ); - - $uriMock->shouldReceive('withFragment')->andReturnSelf()->byDefault(); - $uriMock->shouldReceive('__toString')->andReturnUsing( - function () use (&$currentPath, &$currentQuery) { - return BASE_URL.$currentPath.($currentQuery ? '?'.$currentQuery : ''); - } - )->byDefault(); - - // Mock Request object - $requestMock = Mockery::mock(ServerRequestInterface::class); - $requestMock->shouldReceive('getMethod')->andReturn($method); - $requestMock->shouldReceive('getUri')->andReturn($uriMock); - $requestMock->shouldReceive('getQueryParams')->andReturn($queryParams); - $requestMock->shouldReceive('getHeaderLine')->with('Content-Type')->andReturn('application/json')->byDefault(); - $requestMock->shouldReceive('getHeaderLine')->with('User-Agent')->andReturn('PHPUnit Test')->byDefault(); - $requestMock->shouldReceive('getServerParams')->withNoArgs()->andReturn(['REMOTE_ADDR' => '127.0.0.1'])->byDefault(); - - // Use BufferedBody for PSR-7 compatibility - $bodyStream = new BufferedBody($bodyContent); - $requestMock->shouldReceive('getBody')->withNoArgs()->andReturn($bodyStream)->byDefault(); - - return $requestMock; -} - -// --- Tests --- - -test('implements correct interfaces', function () { - expect($this->transport) - ->toBeInstanceOf(ServerTransportInterface::class) - ->toBeInstanceOf(LoggerAwareInterface::class) - ->toBeInstanceOf(LoopAwareInterface::class); -}); - -test('request handler returns 404 for unknown paths', function () { - $request = createMockRequest('GET', '/unknown/path'); - $response = ($this->requestHandler)($request); - - expect($response)->toBeInstanceOf(Response::class)->getStatusCode()->toBe(404); -}); - -// --- SSE Request Handling --- -test('handler handles GET SSE request, emits connected, returns stream response', function () { - $request = createMockRequest('GET', SSE_PATH); - $connectedClientId = null; - $this->transport->on('client_connected', function ($id) use (&$connectedClientId) { - $connectedClientId = $id; - }); - - // Act - $response = ($this->requestHandler)($request); - - // Assert Response - expect($response)->toBeInstanceOf(Response::class)->getStatusCode()->toBe(200); - expect($response->getHeaderLine('Content-Type'))->toContain('text/event-stream'); - $body = $response->getBody(); - expect($body)->toBeInstanceOf(ReadableStreamInterface::class); - - // Assert internal state - $reflector = new \ReflectionClass($this->transport); - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streams = $streamsProp->getValue($this->transport); - expect($streams)->toBeArray()->toHaveCount(1); - $actualClientId = array_key_first($streams); - expect($actualClientId)->toBeString()->toStartWith('sse_'); - expect($streams[$actualClientId])->toBeInstanceOf(ReadableStreamInterface::class); - - // Assert event emission and initial SSE event send (needs loop tick) - $endpointSent = false; - $streams[$actualClientId]->on('data', function ($chunk) use (&$endpointSent, $actualClientId) { - if (str_contains($chunk, 'event: endpoint') && str_contains($chunk, "clientId={$actualClientId}")) { - $endpointSent = true; - } - }); - - $this->loop->addTimer(0.1, fn () => $this->loop->stop()); - $this->loop->run(); - - expect($connectedClientId)->toBe($actualClientId); - expect($endpointSent)->toBeTrue(); - -})->group('usesLoop'); - -test('handler cleans up SSE resources on stream close', function () { - $request = createMockRequest('GET', SSE_PATH); - - $disconnectedClientId = null; - $this->transport->on('client_disconnected', function ($id) use (&$disconnectedClientId) { - $disconnectedClientId = $id; - }); - - // Act - $response = ($this->requestHandler)($request); - /** @var ThroughStream $sseStream */ - $sseStream = $response->getBody(); - - // Get client ID - $reflector = new \ReflectionClass($this->transport); - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $clientId = array_key_first($streamsProp->getValue($this->transport)); - expect($clientId)->toBeString(); // Ensure client ID exists - - // Simulate stream closing - $this->loop->addTimer(0.01, fn () => $sseStream->close()); - $this->loop->addTimer(0.02, fn () => $this->loop->stop()); - $this->loop->run(); - - // Assert - expect($disconnectedClientId)->toBe($clientId); - expect($streamsProp->getValue($this->transport))->toBeEmpty(); - -})->group('usesLoop'); - -// --- POST Request Handling --- -test('handler handles POST message, emits message, returns 202', function () { - $clientId = 'sse_client_for_post_ok'; - $messagePayload = '{"jsonrpc":"2.0","method":"test"}'; - - $mockSseStream = new ThroughStream(); - $reflector = new \ReflectionClass($this->transport); - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streamsProp->setValue($this->transport, [$clientId => $mockSseStream]); - - $request = createMockRequest('POST', MSG_PATH, ['clientId' => $clientId], $messagePayload); - - $emittedMessage = null; - $emittedClientId = null; - $this->transport->on('message', function ($msg, $id) use (&$emittedMessage, &$emittedClientId) { - $emittedMessage = $msg; - $emittedClientId = $id; - }); - - // Act - $response = ($this->requestHandler)($request); - - // Assert - expect($response)->toBeInstanceOf(Response::class)->getStatusCode()->toBe(202); - expect($emittedMessage)->toBe($messagePayload); - expect($emittedClientId)->toBe($clientId); - -})->group('usesLoop'); - -test('handler returns 400 for POST with missing clientId', function () { - $request = createMockRequest('POST', MSG_PATH); - $response = ($this->requestHandler)($request); // Call handler directly - expect($response)->toBeInstanceOf(Response::class)->getStatusCode()->toBe(400); - // Reading body requires async handling if it's a real stream - // expect($response->getBody()->getContents())->toContain('Missing or invalid clientId'); -}); - -test('handler returns 404 for POST with unknown clientId', function () { - $request = createMockRequest('POST', MSG_PATH, ['clientId' => 'unknown']); - $response = ($this->requestHandler)($request); - expect($response)->toBeInstanceOf(Response::class)->getStatusCode()->toBe(404); -}); - -test('handler returns 415 for POST with wrong Content-Type', function () { - $clientId = 'sse_client_wrong_ct'; - $mockSseStream = new ThroughStream(); // Simulate client connected - $reflector = new \ReflectionClass($this->transport); - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streamsProp->setValue($this->transport, [$clientId => $mockSseStream]); - - $request = createMockRequest('POST', MSG_PATH, ['clientId' => $clientId]); - $request->shouldReceive('getHeaderLine')->with('Content-Type')->andReturn('text/plain'); - - $response = ($this->requestHandler)($request); - expect($response)->toBeInstanceOf(Response::class)->getStatusCode()->toBe(415); -}); - -test('handler returns 400 for POST with empty body', function () { - $clientId = 'sse_client_empty_body'; - $reflector = new \ReflectionClass($this->transport); - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streamsProp->setValue($this->transport, [$clientId => new ThroughStream()]); - - $request = createMockRequest('POST', MSG_PATH, ['clientId' => $clientId]); - - // Act - $response = ($this->requestHandler)($request); - - // Assert - expect($response)->toBeInstanceOf(Response::class)->getStatusCode()->toBe(400); - expect($response->getBody()->getContents())->toContain('Empty request body'); -})->group('usesLoop'); - -// --- sendToClientAsync Tests --- - -test('sendToClientAsync() writes SSE event correctly', function () { - $clientId = 'sse_send_test'; - $messageJson = '{"id":99,"result":"ok"}'; - $expectedSseFrame = "event: message\ndata: {\"id\":99,\"result\":\"ok\"}\n\n"; - - $sseStream = new ThroughStream(); // Use ThroughStream for testing - $receivedData = ''; - $sseStream->on('data', function ($chunk) use (&$receivedData) { - $receivedData .= $chunk; - }); - - // Inject the stream - $reflector = new \ReflectionClass($this->transport); - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streamsProp->setValue($this->transport, [$clientId => $sseStream]); - - // Act - $promise = $this->transport->sendToClientAsync($clientId, $messageJson."\n"); - - // Assert - await($promise); // Wait for promise (write is synchronous on ThroughStream if buffer allows) - expect($receivedData)->toBe($expectedSseFrame); - -})->group('usesLoop'); - -test('sendToClientAsync() rejects if client not found', function () { - $promise = $this->transport->sendToClientAsync('non_existent_sse', '{}'); - $rejected = false; - $promise->catch(function (TransportException $e) use (&$rejected) { - expect($e->getMessage())->toContain('Client \'non_existent_sse\' not connected'); - $rejected = true; - }); - // Need await or loop->run() if the rejection isn't immediate - await($promise); // Await handles loop - expect($rejected)->toBeTrue(); // Assert rejection happened -})->throws(TransportException::class); // Also assert exception type - -test('sendToClientAsync() rejects if stream not writable', function () { - $clientId = 'sse_closed_stream'; - $sseStream = new ThroughStream(); - $reflector = new \ReflectionClass($this->transport); - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streamsProp->setValue($this->transport, [$clientId => $sseStream]); - $sseStream->close(); // Close the stream - - $promise = $this->transport->sendToClientAsync($clientId, '{}'); - $rejected = false; - $promise->catch(function (TransportException $e) use (&$rejected) { - expect($e->getMessage())->toContain('not writable'); - $rejected = true; - }); - await($promise); // Await handles loop - expect($rejected)->toBeTrue(); // Assert rejection happened -})->throws(TransportException::class); - -// --- close() Test --- - -test('close() closes active streams and sets state', function () { - $sseStream1 = new ThroughStream(); - $sseStream2 = new ThroughStream(); - $s1Closed = false; - $s2Closed = false; - - $sseStream1->on('close', function () use (&$s1Closed) { - $s1Closed = true; - }); - $sseStream2->on('close', function () use (&$s2Closed) { - $s2Closed = true; - }); - - // Inject state, set socket to null as we are not mocking it - $reflector = new \ReflectionClass($this->transport); - - $socketProp = $reflector->getProperty('socket'); - $socketProp->setAccessible(true); - $socketProp->setValue($this->transport, null); - - $httpProp = $reflector->getProperty('http'); - $httpProp->setAccessible(true); - $httpProp->setValue($this->transport, null); - - $streamsProp = $reflector->getProperty('activeSseStreams'); - $streamsProp->setAccessible(true); - $streamsProp->setValue($this->transport, ['c1' => $sseStream1, 'c2' => $sseStream2]); - - $listeningProp = $reflector->getProperty('listening'); - $listeningProp->setAccessible(true); - $listeningProp->setValue($this->transport, true); - - $closeEmitted = false; - $this->transport->on('close', function () use (&$closeEmitted) { - $closeEmitted = true; - }); - - // Act - $this->transport->close(); - - // Assert - expect($closeEmitted)->toBeTrue(); - expect($socketProp->getValue($this->transport))->toBeNull(); - expect($streamsProp->getValue($this->transport))->toBeEmpty(); - $closingProp = $reflector->getProperty('closing'); - $closingProp->setAccessible(true); - expect($closingProp->getValue($this->transport))->toBeTrue(); - expect($listeningProp->getValue($this->transport))->toBeFalse(); - expect($s1Closed)->toBeTrue(); - expect($s2Closed)->toBeTrue(); - -})->group('usesLoop'); diff --git a/tests/Unit/Transports/StdioServerTransportTest.php b/tests/Unit/Transports/StdioServerTransportTest.php deleted file mode 100644 index 3fcd3c6..0000000 --- a/tests/Unit/Transports/StdioServerTransportTest.php +++ /dev/null @@ -1,254 +0,0 @@ -loop = Loop::get(); - /** @var LoggerInterface|MockInterface */ - $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); - - $this->transport = new StdioServerTransport(); - $this->transport->setLogger($this->logger); - $this->transport->setLoop($this->loop); - - $this->inputStreamResource = fopen('php://memory', 'r+'); - $this->outputStreamResource = fopen('php://memory', 'r+'); - - $this->transport = new StdioServerTransport($this->inputStreamResource, $this->outputStreamResource); - $this->transport->setLogger($this->logger); - $this->transport->setLoop($this->loop); -}); - -// --- Teardown --- -afterEach(function () { - if (is_resource($this->inputStreamResource)) { - fclose($this->inputStreamResource); - } - if (is_resource($this->outputStreamResource)) { - fclose($this->outputStreamResource); - } - - $reflector = new \ReflectionClass($this->transport); - $closingProp = $reflector->getProperty('closing'); - $closingProp->setAccessible(true); - if (! $closingProp->getValue($this->transport)) { - $this->transport->close(); - } - Mockery::close(); -}); - -// --- Tests --- - -test('implements correct interfaces', function () { - expect($this->transport) - ->toBeInstanceOf(ServerTransportInterface::class) - ->toBeInstanceOf(LoggerAwareInterface::class) - ->toBeInstanceOf(LoopAwareInterface::class); -}); - -test('listen() attaches listeners and emits ready/connected', function () { - $readyEmitted = false; - $connectedClientId = null; - - $this->transport->on('ready', function () use (&$readyEmitted) { - $readyEmitted = true; - }); - $this->transport->on('client_connected', function ($clientId) use (&$connectedClientId) { - $connectedClientId = $clientId; - }); - - // Act - $this->transport->listen(); - - // Assert internal state - $reflector = new \ReflectionClass($this->transport); - $listeningProp = $reflector->getProperty('listening'); - $listeningProp->setAccessible(true); - expect($listeningProp->getValue($this->transport))->toBeTrue(); - $stdinProp = $reflector->getProperty('stdin'); - $stdinProp->setAccessible(true); - expect($stdinProp->getValue($this->transport))->toBeInstanceOf(\React\Stream\ReadableResourceStream::class); - $stdoutProp = $reflector->getProperty('stdout'); - $stdoutProp->setAccessible(true); - expect($stdoutProp->getValue($this->transport))->toBeInstanceOf(\React\Stream\WritableResourceStream::class); - - // Assert events were emitted (these are synchronous in listen setup) - expect($readyEmitted)->toBeTrue(); - expect($connectedClientId)->toBe('stdio'); - - // Clean up the streams created by listen() if they haven't been closed by other means - $this->transport->close(); -}); - -test('listen() throws exception if already listening', function () { - $this->transport->listen(); - $this->transport->listen(); -})->throws(TransportException::class, 'Stdio transport is already listening.'); - -test('receiving data emits message event per line', function () { - $emittedMessages = []; - $this->transport->on('message', function ($message, $clientId) use (&$emittedMessages) { - $emittedMessages[] = ['message' => $message, 'clientId' => $clientId]; - }); - - $this->transport->listen(); - - $reflector = new \ReflectionClass($this->transport); - $stdinStreamProp = $reflector->getProperty('stdin'); - $stdinStreamProp->setAccessible(true); - $stdinStream = $stdinStreamProp->getValue($this->transport); - - // Act - $line1 = '{"jsonrpc":"2.0", "id":1, "method":"ping"}'; - $line2 = '{"jsonrpc":"2.0", "method":"notify"}'; - $stdinStream->emit('data', [$line1."\n".$line2."\n"]); - - // Assert - expect($emittedMessages)->toHaveCount(2); - expect($emittedMessages[0]['message'])->toBe($line1); - expect($emittedMessages[0]['clientId'])->toBe('stdio'); - expect($emittedMessages[1]['message'])->toBe($line2); - expect($emittedMessages[1]['clientId'])->toBe('stdio'); -}); - -test('receiving partial data does not emit message', function () { - $messageEmitted = false; - $this->transport->on('message', function () use (&$messageEmitted) { - $messageEmitted = true; - }); - - $this->transport->listen(); - - $reflector = new \ReflectionClass($this->transport); - $stdinStreamProp = $reflector->getProperty('stdin'); - $stdinStreamProp->setAccessible(true); - $stdinStream = $stdinStreamProp->getValue($this->transport); - - $stdinStream->emit('data', ['{"jsonrpc":"2.0", "id":1']); - - expect($messageEmitted)->toBeFalse(); -})->group('usesLoop'); - -test('receiving buffered data emits messages correctly', function () { - $emittedMessages = []; - $this->transport->on('message', function ($message, $clientId) use (&$emittedMessages) { - $emittedMessages[] = ['message' => $message, 'clientId' => $clientId]; - }); - - $this->transport->listen(); - - $reflector = new \ReflectionClass($this->transport); - $stdinStreamProp = $reflector->getProperty('stdin'); - $stdinStreamProp->setAccessible(true); - $stdinStream = $stdinStreamProp->getValue($this->transport); - - // Write part 1 - $stdinStream->emit('data', ["{\"id\":1}\n{\"id\":2"]); - expect($emittedMessages)->toHaveCount(1); - expect($emittedMessages[0]['message'])->toBe('{"id":1}'); - - // Write part 2 - $stdinStream->emit('data', ["}\n{\"id\":3}\n"]); - expect($emittedMessages)->toHaveCount(3); - expect($emittedMessages[1]['message'])->toBe('{"id":2}'); - expect($emittedMessages[2]['message'])->toBe('{"id":3}'); - -})->group('usesLoop'); - -test('sendToClientAsync() rejects if closed', function () { - $this->transport->listen(); - $this->transport->close(); // Close it first - - $promise = $this->transport->sendToClientAsync('stdio', "{}\n"); - await($promise); - -})->throws(TransportException::class, 'Stdio transport is closed'); - -test('sendToClientAsync() rejects for invalid client ID', function () { - $this->transport->listen(); - $promise = $this->transport->sendToClientAsync('invalid_client', "{}\n"); - await($promise); - -})->throws(TransportException::class, 'Invalid clientId'); - -test('close() closes streams and emits close event', function () { - $this->transport->listen(); // Setup streams internally - - $closeEmitted = false; - $this->transport->on('close', function () use (&$closeEmitted) { - $closeEmitted = true; - }); - - // Get stream instances after listen() - $reflector = new \ReflectionClass($this->transport); - $stdinStream = $reflector->getProperty('stdin')->getValue($this->transport); - $stdoutStream = $reflector->getProperty('stdout')->getValue($this->transport); - - $stdinClosed = false; - $stdoutClosed = false; - $stdinStream->on('close', function () use (&$stdinClosed) { - $stdinClosed = true; - }); - $stdoutStream->on('close', function () use (&$stdoutClosed) { - $stdoutClosed = true; - }); - - // Act - $this->transport->close(); - - // Assert internal state - expect($reflector->getProperty('stdin')->getValue($this->transport))->toBeNull(); - expect($reflector->getProperty('stdout')->getValue($this->transport))->toBeNull(); - expect($reflector->getProperty('closing')->getValue($this->transport))->toBeTrue(); - expect($reflector->getProperty('listening')->getValue($this->transport))->toBeFalse(); - - // Assert event emission - expect($closeEmitted)->toBeTrue(); - - // Assert streams were closed (via events) - expect($stdinClosed)->toBeTrue(); - expect($stdoutClosed)->toBeTrue(); -}); - -test('stdin close event emits client_disconnected and closes transport', function () { - $disconnectedClientId = null; - $closeEmitted = false; - - $this->transport->on('client_disconnected', function ($clientId) use (&$disconnectedClientId) { - $disconnectedClientId = $clientId; - }); - - $this->transport->on('close', function () use (&$closeEmitted) { - $closeEmitted = true; - }); - - $this->transport->listen(); - - $reflector = new \ReflectionClass($this->transport); - $stdinStream = $reflector->getProperty('stdin')->getValue($this->transport); - - $stdinStream->close(); - - $this->loop->addTimer(0.01, fn () => $this->loop->stop()); - $this->loop->run(); - - // Assert - expect($disconnectedClientId)->toBe('stdio'); - expect($closeEmitted)->toBeTrue(); - - expect($reflector->getProperty('closing')->getValue($this->transport))->toBeTrue(); - -})->group('usesLoop'); diff --git a/tests/Unit/Support/DocBlockParserTest.php b/tests/Unit/Utils/DocBlockParserTest.php similarity index 75% rename from tests/Unit/Support/DocBlockParserTest.php rename to tests/Unit/Utils/DocBlockParserTest.php index dd81c12..da36d45 100644 --- a/tests/Unit/Support/DocBlockParserTest.php +++ b/tests/Unit/Utils/DocBlockParserTest.php @@ -1,63 +1,48 @@ containerMock = Mockery::mock(ContainerInterface::class); - $this->loggerMock = Mockery::mock(LoggerInterface::class); - - $this->parser = new DocBlockParser($this->loggerMock); + $this->parser = new DocBlockParser(); }); -// Helper function to get reflection method -function getStubMethod(string $methodName): ReflectionMethod -{ - return new ReflectionMethod(DocBlockTestStub::class, $methodName); -} - -// --- Test Cases --- - test('getSummary returns correct summary', function () { - $method = getStubMethod('methodWithSummaryOnly'); + $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); expect($this->parser->getSummary($docBlock))->toBe('Simple summary line.'); - $method2 = getStubMethod('methodWithSummaryAndDescription'); + $method2 = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription'); $docComment2 = $method2->getDocComment() ?: null; $docBlock2 = $this->parser->parseDocBlock($docComment2); expect($this->parser->getSummary($docBlock2))->toBe('Summary line here.'); }); test('getDescription returns correct description', function () { - $method = getStubMethod('methodWithSummaryAndDescription'); + $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); $expectedDesc = "Summary line here.\n\nThis is a longer description spanning\nmultiple lines.\nIt might contain *markdown* or `code`."; expect($this->parser->getDescription($docBlock))->toBe($expectedDesc); - // Test method with only summary returns summary only - $method2 = getStubMethod('methodWithSummaryOnly'); + $method2 = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly'); $docComment2 = $method2->getDocComment() ?: null; $docBlock2 = $this->parser->parseDocBlock($docComment2); expect($this->parser->getDescription($docBlock2))->toBe('Simple summary line.'); }); test('getParamTags returns structured param info', function () { - $method = getStubMethod('methodWithParams'); + $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithParams'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); $params = $this->parser->getParamTags($docBlock); @@ -65,7 +50,6 @@ function getStubMethod(string $methodName): ReflectionMethod expect($params)->toBeArray()->toHaveCount(6); expect($params)->toHaveKeys(['$param1', '$param2', '$param3', '$param4', '$param5', '$param6']); - // Check structure and content using parser methods expect($params['$param1'])->toBeInstanceOf(Param::class); expect($params['$param1']->getVariableName())->toBe('param1'); expect($this->parser->getParamTypeString($params['$param1']))->toBe('string'); @@ -79,11 +63,11 @@ function getStubMethod(string $methodName): ReflectionMethod expect($params['$param3'])->toBeInstanceOf(Param::class); expect($params['$param3']->getVariableName())->toBe('param3'); expect($this->parser->getParamTypeString($params['$param3']))->toBe('bool'); - expect($this->parser->getParamDescription($params['$param3']))->toBeNull(); // No description provided + expect($this->parser->getParamDescription($params['$param3']))->toBeNull(); expect($params['$param4'])->toBeInstanceOf(Param::class); expect($params['$param4']->getVariableName())->toBe('param4'); - expect($this->parser->getParamTypeString($params['$param4']))->toBe('mixed'); // No type provided + expect($this->parser->getParamTypeString($params['$param4']))->toBe('mixed'); expect($this->parser->getParamDescription($params['$param4']))->toBe('Missing type.'); expect($params['$param5'])->toBeInstanceOf(Param::class); @@ -98,7 +82,7 @@ function getStubMethod(string $methodName): ReflectionMethod }); test('getReturnTag returns structured return info', function () { - $method = getStubMethod('methodWithReturn'); + $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithReturn'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); $returnTag = $this->parser->getReturnTag($docBlock); @@ -107,46 +91,41 @@ function getStubMethod(string $methodName): ReflectionMethod expect($this->parser->getReturnTypeString($returnTag))->toBe('string'); expect($this->parser->getReturnDescription($returnTag))->toBe('The result of the operation.'); - // Test method without return tag - $method2 = getStubMethod('methodWithSummaryOnly'); + $method2 = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly'); $docComment2 = $method2->getDocComment() ?: null; $docBlock2 = $this->parser->parseDocBlock($docComment2); expect($this->parser->getReturnTag($docBlock2))->toBeNull(); }); test('getTagsByName returns specific tags', function () { - $method = getStubMethod('methodWithMultipleTags'); + $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithMultipleTags'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); expect($docBlock)->toBeInstanceOf(DocBlock::class); - // Test finding @throws using DocBlock object $throwsTags = $docBlock->getTagsByName('throws'); expect($throwsTags)->toBeArray()->toHaveCount(1); expect($throwsTags[0])->toBeInstanceOf(Throws::class); expect((string) $throwsTags[0]->getType())->toBe('\\RuntimeException'); expect($throwsTags[0]->getDescription()->render())->toBe('If processing fails.'); - // Test finding @deprecated $deprecatedTags = $docBlock->getTagsByName('deprecated'); expect($deprecatedTags)->toBeArray()->toHaveCount(1); expect($deprecatedTags[0])->toBeInstanceOf(Deprecated::class); expect($deprecatedTags[0]->getDescription()->render())->toBe('Use newMethod() instead.'); - // Test finding @see $seeTags = $docBlock->getTagsByName('see'); expect($seeTags)->toBeArray()->toHaveCount(1); expect($seeTags[0])->toBeInstanceOf(See::class); - expect((string) $seeTags[0]->getReference())->toContain('DocBlockTestStub::newMethod()'); + expect((string) $seeTags[0]->getReference())->toContain('DocBlockTestFixture::newMethod()'); - // Test finding non-existent tag $nonExistentTags = $docBlock->getTagsByName('nosuchtag'); expect($nonExistentTags)->toBeArray()->toBeEmpty(); }); test('handles method with no docblock gracefully', function () { - $method = getStubMethod('methodWithNoDocBlock'); + $method = new ReflectionMethod(DocBlockTestFixture::class, 'methodWithNoDocBlock'); $docComment = $method->getDocComment() ?: null; $docBlock = $this->parser->parseDocBlock($docComment); @@ -157,12 +136,3 @@ function getStubMethod(string $methodName): ReflectionMethod expect($this->parser->getParamTags($docBlock))->toBeArray()->toBeEmpty(); expect($this->parser->getReturnTag($docBlock))->toBeNull(); }); - -// Optional: Test malformed docblock if specific behavior is expected -// test('handles malformed docblock', function () { -// $method = getStubMethod('methodWithMalformedDocBlock'); -// $docComment = $method->getDocComment() ?: null; -// // Add assertions based on expected outcome (parser should return null) -// $docBlock = $this->parser->parseDocBlock($docComment); -// expect($docBlock)->toBeNull(); -// }); diff --git a/tests/Unit/Support/HandlerResolverTest.php b/tests/Unit/Utils/HandlerResolverTest.php similarity index 75% rename from tests/Unit/Support/HandlerResolverTest.php rename to tests/Unit/Utils/HandlerResolverTest.php index 4e3324e..a1c20d3 100644 --- a/tests/Unit/Support/HandlerResolverTest.php +++ b/tests/Unit/Utils/HandlerResolverTest.php @@ -1,43 +1,27 @@ toBe(ValidHandlerClass::class); - expect($resolved['methodName'])->toBe('publicMethod'); - expect($resolved['reflectionMethod'])->toBeInstanceOf(ReflectionMethod::class); - expect($resolved['reflectionMethod']->getName())->toBe('publicMethod'); + expect($resolved)->toBeInstanceOf(ReflectionMethod::class); + expect($resolved->getName())->toBe('publicMethod'); + expect($resolved->getDeclaringClass()->getName())->toBe(ValidHandlerClass::class); }); it('resolves valid invokable class string handler', function () { $handler = ValidInvokableClass::class; $resolved = HandlerResolver::resolve($handler); - expect($resolved['className'])->toBe(ValidInvokableClass::class); - expect($resolved['methodName'])->toBe('__invoke'); - expect($resolved['reflectionMethod'])->toBeInstanceOf(ReflectionMethod::class); - expect($resolved['reflectionMethod']->getName())->toBe('__invoke'); + expect($resolved)->toBeInstanceOf(ReflectionMethod::class); + expect($resolved->getName())->toBe('__invoke'); + expect($resolved->getDeclaringClass()->getName())->toBe(ValidInvokableClass::class); }); it('throws for invalid array handler format (count)', function () { diff --git a/tests/Unit/Support/SchemaGeneratorTest.php b/tests/Unit/Utils/SchemaGeneratorTest.php similarity index 91% rename from tests/Unit/Support/SchemaGeneratorTest.php rename to tests/Unit/Utils/SchemaGeneratorTest.php index 6216f5a..99db202 100644 --- a/tests/Unit/Support/SchemaGeneratorTest.php +++ b/tests/Unit/Utils/SchemaGeneratorTest.php @@ -1,15 +1,15 @@ docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -60,7 +60,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema for required simple types', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'simpleRequired'); + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'simpleRequired'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -75,7 +75,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema for optional simple types with defaults', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'simpleOptionalDefaults'); + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'simpleOptionalDefaults'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -90,7 +90,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema for nullable types without explicit default', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'nullableWithoutDefault'); + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'nullableWithoutDefault'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -104,7 +104,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema for nullable types with explicit null default', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'nullableWithNullDefault'); + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'nullableWithNullDefault'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -116,7 +116,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema for union types', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'unionTypes'); + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'unionTypes'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -127,7 +127,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema for array types', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'arrayTypes'); + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypes'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -142,7 +142,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema for enum types', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'enumTypes'); + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'enumTypes'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -165,7 +165,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection })->skip(version_compare(PHP_VERSION, '8.1', '<'), 'Enums require PHP 8.1+'); test('generates schema for variadic parameters', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'variadicParam'); + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'variadicParam'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -176,7 +176,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema for mixed type', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'mixedType'); + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'mixedType'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -188,7 +188,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema using docblock type when no php type hint', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'docBlockOnly'); + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'docBlockOnly'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -199,7 +199,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema using docblock type overriding php type hint', function () { - $method = new ReflectionMethod(SchemaGeneratorTestStub::class, 'docBlockOverrides'); + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'docBlockOverrides'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -210,7 +210,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema with string format constraints from Schema attribute', function () { - $method = new ReflectionMethod(SchemaAttributeTestStub::class, 'stringConstraints'); + $method = new ReflectionMethod(SchemaAttributeFixture::class, 'stringConstraints'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -229,7 +229,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema with numeric constraints from Schema attribute', function () { - $method = new ReflectionMethod(SchemaAttributeTestStub::class, 'numericConstraints'); + $method = new ReflectionMethod(SchemaAttributeFixture::class, 'numericConstraints'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -251,7 +251,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema with array constraints from Schema attribute', function () { - $method = new ReflectionMethod(SchemaAttributeTestStub::class, 'arrayConstraints'); + $method = new ReflectionMethod(SchemaAttributeFixture::class, 'arrayConstraints'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -277,7 +277,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema with object constraints from Schema attribute', function () { - $method = new ReflectionMethod(SchemaAttributeTestStub::class, 'objectConstraints'); + $method = new ReflectionMethod(SchemaAttributeFixture::class, 'objectConstraints'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -305,7 +305,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('generates schema with nested constraints from Schema attribute', function () { - $method = new ReflectionMethod(SchemaAttributeTestStub::class, 'nestedConstraints'); + $method = new ReflectionMethod(SchemaAttributeFixture::class, 'nestedConstraints'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -339,7 +339,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('respects precedence order between PHP type, DocBlock, and Schema attributes', function () { - $method = new ReflectionMethod(SchemaAttributeTestStub::class, 'typePrecedenceTest'); + $method = new ReflectionMethod(SchemaAttributeFixture::class, 'typePrecedenceTest'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -367,7 +367,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('parses simple array[] syntax correctly', function () { - $method = new ReflectionMethod(DocBlockArrayTestStub::class, 'simpleArraySyntax'); + $method = new ReflectionMethod(DockBlockParserFixture::class, 'simpleArraySyntax'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -393,7 +393,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('parses array generic syntax correctly', function () { - $method = new ReflectionMethod(DocBlockArrayTestStub::class, 'genericArraySyntax'); + $method = new ReflectionMethod(DockBlockParserFixture::class, 'genericArraySyntax'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -419,7 +419,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('parses nested array syntax correctly', function () { - $method = new ReflectionMethod(DocBlockArrayTestStub::class, 'nestedArraySyntax'); + $method = new ReflectionMethod(DockBlockParserFixture::class, 'nestedArraySyntax'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); @@ -440,7 +440,7 @@ function setupDocBlockExpectations(Mockery\MockInterface $parserMock, Reflection }); test('parses object-like array syntax correctly', function () { - $method = new ReflectionMethod(DocBlockArrayTestStub::class, 'objectArraySyntax'); + $method = new ReflectionMethod(DockBlockParserFixture::class, 'objectArraySyntax'); setupDocBlockExpectations($this->docBlockParserMock, $method); $schema = $this->schemaGenerator->fromMethodParameters($method); diff --git a/tests/Unit/Support/SchemaValidatorTest.php b/tests/Unit/Utils/SchemaValidatorTest.php similarity index 99% rename from tests/Unit/Support/SchemaValidatorTest.php rename to tests/Unit/Utils/SchemaValidatorTest.php index 6d1bf1d..8c589fc 100644 --- a/tests/Unit/Support/SchemaValidatorTest.php +++ b/tests/Unit/Utils/SchemaValidatorTest.php @@ -1,9 +1,9 @@ Date: Wed, 18 Jun 2025 22:38:48 +0100 Subject: [PATCH 18/27] chore: use VCS repository for schema --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index da66705..6a7144c 100644 --- a/composer.json +++ b/composer.json @@ -62,8 +62,8 @@ "prefer-stable": true, "repositories": [ { - "type": "path", - "url": "../schema" + "type": "vcs", + "url": "https://github.com/php-mcp/schema" } ] } From c459d928fd9d5ddfba08ade9391b1744ce9465bd Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 18 Jun 2025 23:00:35 +0100 Subject: [PATCH 19/27] fix(tests): use dynamic port for http integration tests --- tests/Integration/HttpServerTransportTest.php | 26 +++++++++---------- .../StreamableHttpServerTransportTest.php | 18 ++++++------- tests/Pest.php | 13 ++++++++++ 3 files changed, 35 insertions(+), 22 deletions(-) diff --git a/tests/Integration/HttpServerTransportTest.php b/tests/Integration/HttpServerTransportTest.php index 38b8db2..a51df2e 100644 --- a/tests/Integration/HttpServerTransportTest.php +++ b/tests/Integration/HttpServerTransportTest.php @@ -17,11 +17,11 @@ const HTTP_SERVER_SCRIPT_PATH = __DIR__ . '/../Fixtures/ServerScripts/HttpTestServer.php'; const HTTP_PROCESS_TIMEOUT_SECONDS = 8; const HTTP_SERVER_HOST = '127.0.0.1'; -const HTTP_SERVER_PORT = 8991; const HTTP_MCP_PATH_PREFIX = 'mcp_http_integration'; beforeEach(function () { $this->loop = Loop::get(); + $this->port = findFreePort(); if (!is_file(HTTP_SERVER_SCRIPT_PATH)) { $this->markTestSkipped("Server script not found: " . HTTP_SERVER_SCRIPT_PATH); @@ -34,7 +34,7 @@ $commandPhpPath = str_contains($phpPath, ' ') ? '"' . $phpPath . '"' : $phpPath; $commandArgs = [ escapeshellarg(HTTP_SERVER_HOST), - escapeshellarg((string)HTTP_SERVER_PORT), + escapeshellarg((string)$this->port), escapeshellarg(HTTP_MCP_PATH_PREFIX) ]; $commandScriptPath = escapeshellarg(HTTP_SERVER_SCRIPT_PATH); @@ -82,7 +82,7 @@ it('starts the http server, initializes, calls a tool, and closes', function () { $this->sseClient = new MockSseClient(); - $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; // 1. Connect await($this->sseClient->connect($sseBaseUrl)); @@ -125,7 +125,7 @@ it('can handle invalid JSON from client', function () { $this->sseClient = new MockSseClient(); - $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; // 1. Connect await($this->sseClient->connect($sseBaseUrl)); @@ -159,7 +159,7 @@ it('can handle request for non-existent method after initialization', function () { $this->sseClient = new MockSseClient(); - $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; // 1. Connect await($this->sseClient->connect($sseBaseUrl)); @@ -188,7 +188,7 @@ it('can handle batch requests correctly over HTTP/SSE', function () { $this->sseClient = new MockSseClient(); - $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; // 1. Connect await($this->sseClient->connect($sseBaseUrl)); @@ -246,7 +246,7 @@ it('can handle tool list request over HTTP/SSE', function () { $this->sseClient = new MockSseClient(); - $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; await($this->sseClient->connect($sseBaseUrl)); await(delay(0.05, $this->loop)); @@ -268,7 +268,7 @@ it('can read a registered resource over HTTP/SSE', function () { $this->sseClient = new MockSseClient(); - $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; await($this->sseClient->connect($sseBaseUrl)); await(delay(0.05, $this->loop)); @@ -292,7 +292,7 @@ it('can get a registered prompt over HTTP/SSE', function () { $this->sseClient = new MockSseClient(); - $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; await($this->sseClient->connect($sseBaseUrl)); await(delay(0.05, $this->loop)); @@ -318,7 +318,7 @@ it('rejects subsequent requests if client does not send initialized notification', function () { $this->sseClient = new MockSseClient(); - $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; await($this->sseClient->connect($sseBaseUrl)); await(delay(0.05, $this->loop)); @@ -349,7 +349,7 @@ it('returns 404 for POST to /message without valid clientId in query', function () { $this->sseClient = new MockSseClient(); - $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; await($this->sseClient->connect($sseBaseUrl)); await(delay(0.05, $this->loop)); $validEndpointUrl = $this->sseClient->endpointUrl; @@ -377,7 +377,7 @@ it('returns 404 for POST to /message with clientId for a disconnected SSE stream', function () { $this->sseClient = new MockSseClient(); - $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; + $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; await($this->sseClient->connect($sseBaseUrl)); await(delay(0.05, $this->loop)); @@ -404,7 +404,7 @@ it('returns 404 for unknown paths', function () { $browser = new Browser($this->loop); - $unknownUrl = "http://" . HTTP_SERVER_HOST . ":" . HTTP_SERVER_PORT . "/unknown/path"; + $unknownUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/unknown/path"; $promise = $browser->get($unknownUrl); diff --git a/tests/Integration/StreamableHttpServerTransportTest.php b/tests/Integration/StreamableHttpServerTransportTest.php index b2abeec..8eb6309 100644 --- a/tests/Integration/StreamableHttpServerTransportTest.php +++ b/tests/Integration/StreamableHttpServerTransportTest.php @@ -16,7 +16,6 @@ const STREAMABLE_HTTP_SCRIPT_PATH = __DIR__ . '/../Fixtures/ServerScripts/StreamableHttpTestServer.php'; const STREAMABLE_HTTP_PROCESS_TIMEOUT = 9; const STREAMABLE_HTTP_HOST = '127.0.0.1'; -const STREAMABLE_HTTP_PORT = 8992; const STREAMABLE_MCP_PATH = 'mcp_streamable_json_mode'; beforeEach(function () { @@ -30,10 +29,11 @@ $phpPath = PHP_BINARY ?: 'php'; $commandPhpPath = str_contains($phpPath, ' ') ? '"' . $phpPath . '"' : $phpPath; $commandScriptPath = escapeshellarg(STREAMABLE_HTTP_SCRIPT_PATH); + $this->port = findFreePort(); $jsonModeCommandArgs = [ escapeshellarg(STREAMABLE_HTTP_HOST), - escapeshellarg((string)STREAMABLE_HTTP_PORT), + escapeshellarg((string)$this->port), escapeshellarg(STREAMABLE_MCP_PATH), escapeshellarg('true'), // enableJsonResponse = true ]; @@ -41,7 +41,7 @@ $streamModeCommandArgs = [ escapeshellarg(STREAMABLE_HTTP_HOST), - escapeshellarg((string)STREAMABLE_HTTP_PORT), + escapeshellarg((string)$this->port), escapeshellarg(STREAMABLE_MCP_PATH), escapeshellarg('false'), // enableJsonResponse = false ]; @@ -73,7 +73,7 @@ $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); $this->process->start(); - $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, STREAMABLE_HTTP_PORT, STREAMABLE_MCP_PATH); + $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); await(delay(0.2)); }); @@ -283,7 +283,7 @@ beforeEach(function () { $this->process = new Process($this->streamModeCommand, getcwd() ?: null, null, []); $this->process->start(); - $this->streamClient = new MockStreamHttpClient(STREAMABLE_HTTP_HOST, STREAMABLE_HTTP_PORT, STREAMABLE_MCP_PATH); + $this->streamClient = new MockStreamHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); await(delay(0.2)); }); afterEach(function () { @@ -482,7 +482,7 @@ it('responds to OPTIONS request with CORS headers', function () { $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); $this->process->start(); - $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, STREAMABLE_HTTP_PORT, STREAMABLE_MCP_PATH); + $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); await(delay(0.1)); $browser = new Browser(); @@ -501,11 +501,11 @@ it('returns 404 for unknown paths', function () { $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); $this->process->start(); - $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, STREAMABLE_HTTP_PORT, STREAMABLE_MCP_PATH); + $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); await(delay(0.1)); $browser = new Browser(); - $unknownUrl = "http://" . STREAMABLE_HTTP_HOST . ":" . STREAMABLE_HTTP_PORT . "/completely/unknown/path"; + $unknownUrl = "http://" . STREAMABLE_HTTP_HOST . ":" . $this->port . "/completely/unknown/path"; $promise = $browser->get($unknownUrl); @@ -522,7 +522,7 @@ it('can delete client session with DELETE request', function () { $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); $this->process->start(); - $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, STREAMABLE_HTTP_PORT, STREAMABLE_MCP_PATH); + $this->jsonClient = new MockJsonHttpClient(STREAMABLE_HTTP_HOST, $this->port, STREAMABLE_MCP_PATH); await(delay(0.1)); // 1. Initialize diff --git a/tests/Pest.php b/tests/Pest.php index 120085f..5020906 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -5,6 +5,7 @@ use React\EventLoop\TimerInterface; use React\Promise\Promise; use React\Promise\PromiseInterface; +use React\Socket\SocketServer; function getPrivateProperty(object $object, string $propertyName) { @@ -79,3 +80,15 @@ function timeout(PromiseInterface $promise, $time, ?LoopInterface $loop = null) }); }, $canceller); } + +function findFreePort() +{ + $server = new SocketServer('127.0.0.1:0'); + $address = $server->getAddress(); + $port = $address ? parse_url($address, PHP_URL_PORT) : null; + $server->close(); + if (!$port) { + throw new \RuntimeException("Could not find a free port for testing."); + } + return (int)$port; +} From 72f85f55b6192e834bc03b7317004358658dfe73 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Wed, 18 Jun 2025 23:42:09 +0100 Subject: [PATCH 20/27] refactor(Protocol): streamline capability checks for server features [skip ci] --- src/Protocol.php | 22 +++++++++++----------- tests/Unit/ConfigurationTest.php | 4 ++-- tests/Unit/ProtocolTest.php | 10 +++++----- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Protocol.php b/src/Protocol.php index f9d5685..daf2194 100644 --- a/src/Protocol.php +++ b/src/Protocol.php @@ -299,7 +299,7 @@ private function assertRequestCapability(string $method): void case 'tools/list': case 'tools/call': - if ($capabilities->tools === null) { + if (!$capabilities->tools) { throw McpServerException::methodNotFound($method, 'Tools are not enabled on this server.'); } break; @@ -307,36 +307,36 @@ private function assertRequestCapability(string $method): void case 'resources/list': case 'resources/templates/list': case 'resources/read': - if ($capabilities->resources === null) { + if (!$capabilities->resources) { throw McpServerException::methodNotFound($method, 'Resources are not enabled on this server.'); } break; case 'resources/subscribe': case 'resources/unsubscribe': - if ($capabilities->resources === null) { + if (!$capabilities->resources) { throw McpServerException::methodNotFound($method, 'Resources are not enabled on this server.'); } - if (!$capabilities->resources['subscribe']) { + if (!$capabilities->resourcesSubscribe) { throw McpServerException::methodNotFound($method, 'Resources subscription is not enabled on this server.'); } break; case 'prompts/list': case 'prompts/get': - if ($capabilities->prompts === null) { + if (!$capabilities->prompts) { throw McpServerException::methodNotFound($method, 'Prompts are not enabled on this server.'); } break; case 'logging/setLevel': - if ($capabilities->logging === null) { + if (!$capabilities->logging) { throw McpServerException::methodNotFound($method, 'Logging is not enabled on this server.'); } break; case 'completion/complete': - if ($capabilities->completions === null) { + if (!$capabilities->completions) { throw McpServerException::methodNotFound($method, 'Completions are not enabled on this server.'); } break; @@ -354,7 +354,7 @@ private function canSendNotification(string $method): bool switch ($method) { case 'notifications/message': - if ($capabilities->logging === null) { + if (!$capabilities->logging) { $this->logger->warning('Logging is not enabled on this server. Notifications/message will not be sent.'); $valid = false; } @@ -362,21 +362,21 @@ private function canSendNotification(string $method): bool case "notifications/resources/updated": case "notifications/resources/list_changed": - if ($capabilities->resources === null || !$capabilities->resources['listChanged']) { + if (!$capabilities->resources || !$capabilities->resourcesListChanged) { $this->logger->warning('Resources list changed notifications are not enabled on this server. Notifications/resources/list_changed will not be sent.'); $valid = false; } break; case "notifications/tools/list_changed": - if ($capabilities->tools === null || !$capabilities->tools['listChanged']) { + if (!$capabilities->tools || !$capabilities->toolsListChanged) { $this->logger->warning('Tools list changed notifications are not enabled on this server. Notifications/tools/list_changed will not be sent.'); $valid = false; } break; case "notifications/prompts/list_changed": - if ($capabilities->prompts === null || !$capabilities->prompts['listChanged']) { + if (!$capabilities->prompts || !$capabilities->promptsListChanged) { $this->logger->warning('Prompts list changed notifications are not enabled on this server. Notifications/prompts/list_changed will not be sent.'); $valid = false; } diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index 5ce10be..3fd1e98 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -90,7 +90,7 @@ it('constructs configuration object with specific capabilities', function () { $customCaps = ServerCapabilities::make( resourcesSubscribe: true, - loggingEnabled: true, + logging: true, ); $config = new Configuration( @@ -104,5 +104,5 @@ expect($config->capabilities)->toBe($customCaps); expect($config->capabilities->resourcesSubscribe)->toBeTrue(); - expect($config->capabilities->loggingEnabled)->toBeTrue(); + expect($config->capabilities->logging)->toBeTrue(); }); diff --git a/tests/Unit/ProtocolTest.php b/tests/Unit/ProtocolTest.php index 79de85c..3661928 100644 --- a/tests/Unit/ProtocolTest.php +++ b/tests/Unit/ProtocolTest.php @@ -271,7 +271,7 @@ function expectSuccessResponse(mixed $response, mixed $expectedResult, string|in $request = createRequest('tools/list'); $configuration = new Configuration( serverInfo: $this->configuration->serverInfo, - capabilities: ServerCapabilities::make(toolsEnabled: false), + capabilities: ServerCapabilities::make(tools: false), logger: $this->logger, loop: $this->configuration->loop, cache: $this->configuration->cache, @@ -509,10 +509,10 @@ function expectSuccessResponse(mixed $response, mixed $expectedResult, string|in it('allows initialize and ping regardless of capabilities', function (string $method) { $request = createRequest($method); $capabilities = ServerCapabilities::make( - toolsEnabled: false, - resourcesEnabled: false, - promptsEnabled: false, - loggingEnabled: false, + tools: false, + resources: false, + prompts: false, + logging: false, ); $configuration = new Configuration( serverInfo: $this->configuration->serverInfo, From 951ec4abc2152fa30210730d4991ca786e8fe24b Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Thu, 19 Jun 2025 13:48:11 +0100 Subject: [PATCH 21/27] feat: Enhance Schema attribute with comprehensive JSON Schema support --- composer.json | 1 + src/Attributes/Schema.php | 252 ++++---- src/Attributes/Schema/ArrayItems.php | 58 -- src/Attributes/Schema/Format.php | 32 - src/Attributes/Schema/Property.php | 59 -- src/Exception/DefinitionException.php | 15 - src/ServerBuilder.php | 2 +- src/Utils/Discoverer.php | 2 +- src/Utils/SchemaGenerator.php | 564 +++++++++++------- .../Fixtures/Utils/SchemaAttributeFixture.php | 124 ---- .../Fixtures/Utils/SchemaGeneratorFixture.php | 423 ++++++++++--- tests/Integration/DiscoveryTest.php | 8 +- tests/Integration/HttpServerTransportTest.php | 9 +- tests/Integration/SchemaGenerationTest.php | 346 +++++++++++ .../Integration/StdioServerTransportTest.php | 9 +- .../StreamableHttpServerTransportTest.php | 6 +- tests/Unit/ServerBuilderTest.php | 1 - tests/Unit/Utils/SchemaGeneratorTest.php | 472 --------------- tests/Unit/Utils/SchemaValidatorTest.php | 42 +- 19 files changed, 1211 insertions(+), 1214 deletions(-) delete mode 100644 src/Attributes/Schema/ArrayItems.php delete mode 100644 src/Attributes/Schema/Format.php delete mode 100644 src/Attributes/Schema/Property.php delete mode 100644 src/Exception/DefinitionException.php delete mode 100644 tests/Fixtures/Utils/SchemaAttributeFixture.php create mode 100644 tests/Integration/SchemaGenerationTest.php delete mode 100644 tests/Unit/Utils/SchemaGeneratorTest.php diff --git a/composer.json b/composer.json index 6a7144c..5f82038 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "friendsofphp/php-cs-fixer": "^3.75", "mockery/mockery": "^1.6", "pestphp/pest": "^2.36.0|^3.5.0", + "pestphp/pest-plugin-drift": "^3.0", "react/async": "^4.0", "react/child-process": "^0.6.6", "symfony/var-dumper": "^6.4.11|^7.1.5" diff --git a/src/Attributes/Schema.php b/src/Attributes/Schema.php index f5fc809..d1cce8c 100644 --- a/src/Attributes/Schema.php +++ b/src/Attributes/Schema.php @@ -5,138 +5,174 @@ namespace PhpMcp\Server\Attributes; use Attribute; -use PhpMcp\Server\Attributes\Schema\ArrayItems; -use PhpMcp\Server\Attributes\Schema\Property; -#[Attribute(Attribute::TARGET_PARAMETER)] +/** + * Defines a JSON Schema for a method's input or an individual parameter. + * + * When used at the method level, it describes an object schema where properties + * correspond to the method's parameters. + * + * When used at the parameter level, it describes the schema for that specific parameter. + * If 'type' is omitted at the parameter level, it will be inferred. + */ +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PARAMETER)] class Schema { - /** @var Property[] */ - protected array $properties = []; + /** + * The complete JSON schema array. + * If provided, it takes precedence over individual properties like $type, $properties, etc. + */ + public ?array $definition = null; + + /** + * Alternatively, provide individual top-level schema keywords. + * These are used if $definition is null. + */ + public ?string $type = null; + public ?string $description = null; + public mixed $default = null; + public ?array $enum = null; // list of allowed values + public ?string $format = null; // e.g., 'email', 'date-time' + + // Constraints for string + public ?int $minLength = null; + public ?int $maxLength = null; + public ?string $pattern = null; + + // Constraints for number/integer + public int|float|null $minimum = null; + public int|float|null $maximum = null; + public ?bool $exclusiveMinimum = null; + public ?bool $exclusiveMaximum = null; + public int|float|null $multipleOf = null; + + // Constraints for array + public ?array $items = null; // JSON schema for array items + public ?int $minItems = null; + public ?int $maxItems = null; + public ?bool $uniqueItems = null; + + // Constraints for object (primarily used when Schema is on a method or an object-typed parameter) + public ?array $properties = null; // [propertyName => [schema array], ...] + public ?array $required = null; // [propertyName, ...] + public bool|array|null $additionalProperties = null; // true, false, or a schema array /** - * @param string|null $format String format (email, date-time, uri, etc.) - * @param int|null $minLength Minimum string length - * @param int|null $maxLength Maximum string length - * @param string|null $pattern Regular expression pattern - * @param int|float|null $minimum Minimum numeric value - * @param int|float|null $maximum Maximum numeric value - * @param bool|null $exclusiveMinimum Whether minimum is exclusive - * @param bool|null $exclusiveMaximum Whether maximum is exclusive - * @param int|float|null $multipleOf Value must be multiple of this number - * @param ArrayItems|null $items Schema for array items - * @param int|null $minItems Minimum array items - * @param int|null $maxItems Maximum array items - * @param bool|null $uniqueItems Whether array items must be unique - * @param Property[] $properties Properties for object validation - * @param string[]|null $required Required properties for objects - * @param bool|Schema|null $additionalProperties Whether additional properties are allowed - * @param mixed|null $enum List of allowed values - * @param mixed|null $default Default value + * @param array|null $definition A complete JSON schema array. If provided, other parameters are ignored. + * @param Type|null $type The JSON schema type. + * @param string|null $description Description of the element. + * @param array|null $enum Allowed enum values. + * @param string|null $format String format (e.g., 'date-time', 'email'). + * @param int|null $minLength Minimum length for strings. + * @param int|null $maxLength Maximum length for strings. + * @param string|null $pattern Regex pattern for strings. + * @param int|float|null $minimum Minimum value for numbers/integers. + * @param int|float|null $maximum Maximum value for numbers/integers. + * @param bool|null $exclusiveMinimum Exclusive minimum. + * @param bool|null $exclusiveMaximum Exclusive maximum. + * @param int|float|null $multipleOf Must be a multiple of this value. + * @param array|null $items JSON Schema for items if type is 'array'. + * @param int|null $minItems Minimum items for an array. + * @param int|null $maxItems Maximum items for an array. + * @param bool|null $uniqueItems Whether array items must be unique. + * @param array|null $properties Property definitions if type is 'object'. [name => schema_array]. + * @param array|null $required List of required properties for an object. + * @param bool|array|null $additionalProperties Policy for additional properties in an object. */ public function __construct( - public ?string $format = null, - public ?int $minLength = null, - public ?int $maxLength = null, - public ?string $pattern = null, - public int|float|null $minimum = null, - public int|float|null $maximum = null, - public ?bool $exclusiveMinimum = null, - public ?bool $exclusiveMaximum = null, - public int|float|null $multipleOf = null, - public ?ArrayItems $items = null, - public ?int $minItems = null, - public ?int $maxItems = null, - public ?bool $uniqueItems = null, - array $properties = [], - public ?array $required = null, - public bool|Schema|null $additionalProperties = null, - public mixed $enum = null, - public mixed $default = null, + ?array $definition = null, + ?string $type = null, + ?string $description = null, + ?array $enum = null, + ?string $format = null, + ?int $minLength = null, + ?int $maxLength = null, + ?string $pattern = null, + int|float|null $minimum = null, + int|float|null $maximum = null, + ?bool $exclusiveMinimum = null, + ?bool $exclusiveMaximum = null, + int|float|null $multipleOf = null, + ?array $items = null, + ?int $minItems = null, + ?int $maxItems = null, + ?bool $uniqueItems = null, + ?array $properties = null, + ?array $required = null, + bool|array|null $additionalProperties = null ) { - $this->properties = $properties; + if ($definition !== null) { + $this->definition = $definition; + } else { + $this->type = $type; + $this->description = $description; + $this->enum = $enum; + $this->format = $format; + $this->minLength = $minLength; + $this->maxLength = $maxLength; + $this->pattern = $pattern; + $this->minimum = $minimum; + $this->maximum = $maximum; + $this->exclusiveMinimum = $exclusiveMinimum; + $this->exclusiveMaximum = $exclusiveMaximum; + $this->multipleOf = $multipleOf; + $this->items = $items; + $this->minItems = $minItems; + $this->maxItems = $maxItems; + $this->uniqueItems = $uniqueItems; + $this->properties = $properties; + $this->required = $required; + $this->additionalProperties = $additionalProperties; + } } /** - * Convert to JSON Schema array + * Converts the attribute's definition to a JSON schema array. */ public function toArray(): array { - $schema = []; - - // String constraints - if ($this->format !== null) { - $schema['format'] = $this->format; - } - if ($this->minLength !== null) { - $schema['minLength'] = $this->minLength; - } - if ($this->maxLength !== null) { - $schema['maxLength'] = $this->maxLength; - } - if ($this->pattern !== null) { - $schema['pattern'] = $this->pattern; + if ($this->definition !== null) { + return [ + 'definition' => $this->definition, + ]; } - // Numeric constraints - if ($this->minimum !== null) { - $schema['minimum'] = $this->minimum; - } - if ($this->maximum !== null) { - $schema['maximum'] = $this->maximum; - } - if ($this->exclusiveMinimum !== null) { - $schema['exclusiveMinimum'] = $this->exclusiveMinimum; - } - if ($this->exclusiveMaximum !== null) { - $schema['exclusiveMaximum'] = $this->exclusiveMaximum; - } - if ($this->multipleOf !== null) { - $schema['multipleOf'] = $this->multipleOf; - } - - // Array constraints - if ($this->items !== null) { - $schema['items'] = $this->items->toArray(); + $schema = []; + if ($this->type !== null) { + $schema['type'] = $this->type; } - if ($this->minItems !== null) { - $schema['minItems'] = $this->minItems; + if ($this->description !== null) { + $schema['description'] = $this->description; } - if ($this->maxItems !== null) { - $schema['maxItems'] = $this->maxItems; + if ($this->enum !== null) { + $schema['enum'] = $this->enum; } - if ($this->uniqueItems !== null) { - $schema['uniqueItems'] = $this->uniqueItems; + if ($this->format !== null) { + $schema['format'] = $this->format; } - // Object constraints - if (!empty($this->properties)) { - $props = []; - foreach ($this->properties as $property) { - $props[$property->name] = $property->toArray(); - } - $schema['properties'] = $props; - } + // String + if ($this->minLength !== null) $schema['minLength'] = $this->minLength; + if ($this->maxLength !== null) $schema['maxLength'] = $this->maxLength; + if ($this->pattern !== null) $schema['pattern'] = $this->pattern; - if ($this->required !== null) { - $schema['required'] = $this->required; - } + // Numeric + if ($this->minimum !== null) $schema['minimum'] = $this->minimum; + if ($this->maximum !== null) $schema['maximum'] = $this->maximum; + if ($this->exclusiveMinimum !== null) $schema['exclusiveMinimum'] = $this->exclusiveMinimum; + if ($this->exclusiveMaximum !== null) $schema['exclusiveMaximum'] = $this->exclusiveMaximum; + if ($this->multipleOf !== null) $schema['multipleOf'] = $this->multipleOf; - if ($this->additionalProperties !== null) { - if ($this->additionalProperties instanceof self) { - $schema['additionalProperties'] = $this->additionalProperties->toArray(); - } else { - $schema['additionalProperties'] = $this->additionalProperties; - } - } + // Array + if ($this->items !== null) $schema['items'] = $this->items; + if ($this->minItems !== null) $schema['minItems'] = $this->minItems; + if ($this->maxItems !== null) $schema['maxItems'] = $this->maxItems; + if ($this->uniqueItems !== null) $schema['uniqueItems'] = $this->uniqueItems; - // General constraints - if ($this->enum !== null) { - $schema['enum'] = $this->enum; - } - if ($this->default !== null) { - $schema['default'] = $this->default; - } + // Object + if ($this->properties !== null) $schema['properties'] = $this->properties; + if ($this->required !== null) $schema['required'] = $this->required; + if ($this->additionalProperties !== null) $schema['additionalProperties'] = $this->additionalProperties; return $schema; } diff --git a/src/Attributes/Schema/ArrayItems.php b/src/Attributes/Schema/ArrayItems.php deleted file mode 100644 index 7eeaf08..0000000 --- a/src/Attributes/Schema/ArrayItems.php +++ /dev/null @@ -1,58 +0,0 @@ -getSummary($docBlock) ?? null; - $inputSchema = $schemaGenerator->fromMethodParameters($reflectionMethod); + $inputSchema = $schemaGenerator->generate($reflectionMethod); $tool = Tool::make($name, $inputSchema, $description, $data['annotations']); $registry->registerTool($tool, $className, $methodName, true); diff --git a/src/Utils/Discoverer.php b/src/Utils/Discoverer.php index 593fde4..3913bad 100644 --- a/src/Utils/Discoverer.php +++ b/src/Utils/Discoverer.php @@ -194,7 +194,7 @@ private function processMethod(ReflectionMethod $method, array &$discoveredCount $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName); $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; - $inputSchema = $this->schemaGenerator->fromMethodParameters($method); + $inputSchema = $this->schemaGenerator->generate($method); $tool = Tool::make($name, $inputSchema, $description, $instance->annotations); $this->registry->registerTool($tool, $className, $methodName); $discoveredCount['tools']++; diff --git a/src/Utils/SchemaGenerator.php b/src/Utils/SchemaGenerator.php index c4fa287..e6d0c64 100644 --- a/src/Utils/SchemaGenerator.php +++ b/src/Utils/SchemaGenerator.php @@ -2,7 +2,6 @@ namespace PhpMcp\Server\Utils; -use phpDocumentor\Reflection\DocBlock; use phpDocumentor\Reflection\DocBlock\Tags\Param; use PhpMcp\Server\Attributes\Schema; use ReflectionEnum; @@ -15,7 +14,12 @@ use stdClass; /** - * Generates JSON Schema for method parameters. + * Generates JSON Schema for method parameters with intelligent Schema attribute handling. + * + * Priority system: + * 1. Schema attributes (method-level and parameter-level) + * 2. Reflection type information + * 3. DocBlock type information */ class SchemaGenerator { @@ -28,10 +32,71 @@ public function __construct(DocBlockParser $docBlockParser) /** * Generates a JSON Schema object (as a PHP array) for a method's parameters. - * + */ + public function generate(ReflectionMethod $method): array + { + $methodSchema = $this->extractMethodLevelSchema($method); + + if ($methodSchema && isset($methodSchema['definition'])) { + return $methodSchema['definition']; + } + + $parametersInfo = $this->parseParametersInfo($method); + + return $this->buildSchemaFromParameters($parametersInfo, $methodSchema); + } + + /** + * Extracts method-level Schema attribute. + */ + private function extractMethodLevelSchema(ReflectionMethod $method): ?array + { + $schemaAttrs = $method->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF); + if (empty($schemaAttrs)) { + return null; + } + + $schemaAttr = $schemaAttrs[0]->newInstance(); + return $schemaAttr->toArray(); + } + + /** + * Extracts parameter-level Schema attribute. + */ + private function extractParameterLevelSchema(ReflectionParameter $parameter): array + { + $schemaAttrs = $parameter->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF); + if (empty($schemaAttrs)) { + return []; + } + + $schemaAttr = $schemaAttrs[0]->newInstance(); + return $schemaAttr->toArray(); + } + + /** + * Builds the final schema from parameter information and method-level schema. + * + * @param array + * }> $parametersInfo + * + * @param array|null $methodSchema + * * @return array */ - public function fromMethodParameters(ReflectionMethod $method): array + private function buildSchemaFromParameters(array $parametersInfo, ?array $methodSchema): array { $schema = [ 'type' => 'object', @@ -39,182 +104,292 @@ public function fromMethodParameters(ReflectionMethod $method): array 'required' => [], ]; - $docComment = $method->getDocComment() ?: null; - $docBlock = $this->docBlockParser->parseDocBlock($docComment); - $parametersInfo = $this->parseParametersInfo($method, $docBlock); + // Apply method-level schema as base + if ($methodSchema) { + $schema = array_merge($schema, $methodSchema); + if (!isset($schema['type'])) { + $schema['type'] = 'object'; + } + if (!isset($schema['properties'])) { + $schema['properties'] = []; + } + if (!isset($schema['required'])) { + $schema['required'] = []; + } + } foreach ($parametersInfo as $paramInfo) { - $name = $paramInfo['name']; - $typeString = $paramInfo['type_string']; - $description = $paramInfo['description']; - $required = $paramInfo['required']; - $allowsNull = $paramInfo['allows_null']; - $defaultValue = $paramInfo['default_value']; - $hasDefault = $paramInfo['has_default']; - $reflectionType = $paramInfo['reflection_type_object']; - $isVariadic = $paramInfo['is_variadic']; - $schemaConstraints = $paramInfo['schema_constraints'] ?? []; - - $paramSchema = []; - - if ($isVariadic) { - $paramSchema['type'] = 'array'; - if ($description) { - $paramSchema['description'] = $description; - } - $itemJsonTypes = $this->mapPhpTypeToJsonSchemaType($typeString); - $nonNullItemTypes = array_filter($itemJsonTypes, fn($t) => $t !== 'null'); - if (count($nonNullItemTypes) === 1) { - $paramSchema['items'] = ['type' => $nonNullItemTypes[0]]; - } - } else { - $jsonTypes = $this->mapPhpTypeToJsonSchemaType($typeString); + $paramName = $paramInfo['name']; - if ($allowsNull && strtolower($typeString) !== 'mixed' && ! in_array('null', $jsonTypes)) { - $jsonTypes[] = 'null'; - } + $methodLevelParamSchema = $schema['properties'][$paramName] ?? null; - if (count($jsonTypes) > 1) { - sort($jsonTypes); - } + $paramSchema = $this->buildParameterSchema($paramInfo, $methodLevelParamSchema); - $nonNullTypes = array_filter($jsonTypes, fn($t) => $t !== 'null'); - if (count($jsonTypes) === 1) { - $paramSchema['type'] = $jsonTypes[0]; - } elseif (count($jsonTypes) > 1) { - $paramSchema['type'] = $jsonTypes; - } else { - // If $jsonTypes is still empty (meaning original type was 'mixed'), - // DO NOTHING - omitting 'type' implies any type in JSON Schema. - } + $schema['properties'][$paramName] = $paramSchema; - if ($description) { - $paramSchema['description'] = $description; - } + if ($paramInfo['required'] && !in_array($paramName, $schema['required'])) { + $schema['required'][] = $paramName; + } elseif (!$paramInfo['required'] && ($key = array_search($paramName, $schema['required'])) !== false) { + unset($schema['required'][$key]); + $schema['required'] = array_values($schema['required']); // Re-index + } + } - if ($hasDefault && ! $required) { - $paramSchema['default'] = $defaultValue; - } + // Clean up empty properties + if (empty($schema['properties'])) { + $schema['properties'] = new stdClass(); + } + if (empty($schema['required'])) { + unset($schema['required']); + } - // Handle enums - if ($reflectionType instanceof ReflectionNamedType && ! $reflectionType->isBuiltin() && enum_exists($reflectionType->getName())) { - $enumClass = $reflectionType->getName(); - $enumReflection = new ReflectionEnum($enumClass); - $backingTypeReflection = $enumReflection->getBackingType(); - - if ($enumReflection->isBacked() && $backingTypeReflection instanceof ReflectionNamedType) { - $paramSchema['enum'] = array_column($enumClass::cases(), 'value'); - $jsonBackingType = match ($backingTypeReflection->getName()) { - 'int' => 'integer', - 'string' => 'string', - default => null, // Should not happen for valid backed enums - }; - - if ($jsonBackingType) { - // Ensure schema type matches backing type, considering nullability - if (isset($paramSchema['type']) && is_array($paramSchema['type']) && in_array('null', $paramSchema['type'])) { - $paramSchema['type'] = [$jsonBackingType, 'null']; - } else { - $paramSchema['type'] = $jsonBackingType; - } - } - } else { - // Non-backed enum - use names as enum values - $paramSchema['enum'] = array_column($enumClass::cases(), 'name'); - // Ensure schema type is string, considering nullability - if (isset($paramSchema['type']) && is_array($paramSchema['type']) && in_array('null', $paramSchema['type'])) { - $paramSchema['type'] = ['string', 'null']; - } else { - $paramSchema['type'] = 'string'; - } - } - } + return $schema; + } - // Handle array items type if possible - if (isset($paramSchema['type'])) { - $schemaType = is_array($paramSchema['type']) ? (in_array('array', $paramSchema['type']) ? 'array' : null) : $paramSchema['type']; - - // Special handling for object-like arrays using array{} syntax - if (preg_match('/^array\s*{/i', $typeString)) { - $objectSchema = $this->inferArrayItemsType($typeString); - if (is_array($objectSchema) && isset($objectSchema['properties'])) { - // Override the type and merge in the properties - $paramSchema = array_merge($paramSchema, $objectSchema); - // Ensure type is object - $paramSchema['type'] = $allowsNull ? ['object', 'null'] : 'object'; - } - } - // Handle regular arrays - elseif (in_array('array', $this->mapPhpTypeToJsonSchemaType($typeString))) { - $itemsType = $this->inferArrayItemsType($typeString); - if ($itemsType !== 'any') { - if (is_string($itemsType)) { - $paramSchema['items'] = ['type' => $itemsType]; - } else { - // Handle complex array item types (for nested arrays and object types) - if (!isset($itemsType['type']) && isset($itemsType['properties'])) { - // This is an object schema from array{} syntax - $itemsType = array_merge(['type' => 'object'], $itemsType); - } - $paramSchema['items'] = $itemsType; - } - } - // Ensure the main type is array, potentially adding null - if ($allowsNull) { - $paramSchema['type'] = ['array', 'null']; - sort($paramSchema['type']); - } else { - $paramSchema['type'] = 'array'; // Just array if null not allowed - } - } - } + /** + * Builds the final schema for a single parameter by merging all three levels. + * + * @param array{ + * name: string, + * doc_block_tag: Param|null, + * reflection_param: ReflectionParameter, + * reflection_type_object: ReflectionType|null, + * type_string: string, + * description: string|null, + * required: bool, + * allows_null: bool, + * default_value: mixed|null, + * has_default: bool, + * is_variadic: bool, + * parameter_schema: array + * } $paramInfo + * @param array|null $methodLevelParamSchema + */ + private function buildParameterSchema(array $paramInfo, ?array $methodLevelParamSchema = null): array + { + if ($paramInfo['is_variadic']) { + return $this->buildVariadicParameterSchema($paramInfo); + } + + $inferredSchema = $this->buildInferredParameterSchema($paramInfo); + + // Method-level takes precedence over inferred schema + $mergedSchema = $inferredSchema; + if ($methodLevelParamSchema) { + $mergedSchema = $this->mergeSchemas($inferredSchema, $methodLevelParamSchema); + } + + // Parameter-level takes highest precedence + $parameterLevelSchema = $paramInfo['parameter_schema']; + if (!empty($parameterLevelSchema)) { + $mergedSchema = $this->mergeSchemas($mergedSchema, $parameterLevelSchema); + } + + return $mergedSchema; + } + + /** + * Merge two schemas where the dominant schema takes precedence over the recessive one. + * + * @param array $recessiveSchema The schema with lower precedence + * @param array $dominantSchema The schema with higher precedence + */ + private function mergeSchemas(array $recessiveSchema, array $dominantSchema): array + { + $mergedSchema = array_merge($recessiveSchema, $dominantSchema); + + return $mergedSchema; + } + + /** + * Builds parameter schema from inferred type and docblock information only. + * Returns empty array for variadic parameters (handled separately). + */ + private function buildInferredParameterSchema(array $paramInfo): array + { + $paramSchema = []; + + // Variadic parameters are handled separately + if ($paramInfo['is_variadic']) { + return []; + } + + // Infer JSON Schema types + $jsonTypes = $this->inferParameterTypes($paramInfo); + + if (count($jsonTypes) === 1) { + $paramSchema['type'] = $jsonTypes[0]; + } elseif (count($jsonTypes) > 1) { + $paramSchema['type'] = $jsonTypes; + } + + // Add description from docblock + if ($paramInfo['description']) { + $paramSchema['description'] = $paramInfo['description']; + } + + // Add default value only if parameter actually has a default + if ($paramInfo['has_default']) { + $paramSchema['default'] = $paramInfo['default_value']; + } + + // Handle enums + $paramSchema = $this->applyEnumConstraints($paramSchema, $paramInfo); + + // Handle array items + $paramSchema = $this->applyArrayConstraints($paramSchema, $paramInfo); + + return $paramSchema; + } + + /** + * Builds schema for variadic parameters. + */ + private function buildVariadicParameterSchema(array $paramInfo): array + { + $paramSchema = ['type' => 'array']; + + // Apply parameter-level Schema attributes first + if (!empty($paramInfo['parameter_schema'])) { + $paramSchema = array_merge($paramSchema, $paramInfo['parameter_schema']); + // Ensure type is always array for variadic + $paramSchema['type'] = 'array'; + } + + if ($paramInfo['description']) { + $paramSchema['description'] = $paramInfo['description']; + } + + // If no items specified by Schema attribute, infer from type + if (!isset($paramSchema['items'])) { + $itemJsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']); + $nonNullItemTypes = array_filter($itemJsonTypes, fn($t) => $t !== 'null'); + + if (count($nonNullItemTypes) === 1) { + $paramSchema['items'] = ['type' => $nonNullItemTypes[0]]; } + } - // Merge constraints from Schema attribute - if (!empty($schemaConstraints)) { - // Special handling for 'type' to avoid overriding detected type - if (isset($schemaConstraints['type']) && isset($paramSchema['type'])) { - if (is_array($paramSchema['type']) && !is_array($schemaConstraints['type'])) { - if (!in_array($schemaConstraints['type'], $paramSchema['type'])) { - $paramSchema['type'][] = $schemaConstraints['type']; - sort($paramSchema['type']); - } - } elseif (is_array($schemaConstraints['type']) && !is_array($paramSchema['type'])) { - if (!in_array($paramSchema['type'], $schemaConstraints['type'])) { - $schemaConstraints['type'][] = $paramSchema['type']; - sort($schemaConstraints['type']); - $paramSchema['type'] = $schemaConstraints['type']; - } - } - // Remove 'type' to avoid overwriting in the array_merge - unset($schemaConstraints['type']); - } + return $paramSchema; + } - // Now merge the rest of the schema constraints - $paramSchema = array_merge($paramSchema, $schemaConstraints); + /** + * Infers JSON Schema types for a parameter. + */ + private function inferParameterTypes(array $paramInfo): array + { + $jsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']); + + if ($paramInfo['allows_null'] && strtolower($paramInfo['type_string']) !== 'mixed' && !in_array('null', $jsonTypes)) { + $jsonTypes[] = 'null'; + } + + if (count($jsonTypes) > 1) { + // Sort but ensure null comes first for consistency + $nullIndex = array_search('null', $jsonTypes); + if ($nullIndex !== false) { + unset($jsonTypes[$nullIndex]); + sort($jsonTypes); + array_unshift($jsonTypes, 'null'); + } else { + sort($jsonTypes); } + } + + return $jsonTypes; + } + + /** + * Applies enum constraints to parameter schema. + */ + private function applyEnumConstraints(array $paramSchema, array $paramInfo): array + { + $reflectionType = $paramInfo['reflection_type_object']; - $schema['properties'][$name] = $paramSchema; + if (!($reflectionType instanceof ReflectionNamedType) || $reflectionType->isBuiltin() || !enum_exists($reflectionType->getName())) { + return $paramSchema; + } - if ($required) { - $schema['required'][] = $name; + $enumClass = $reflectionType->getName(); + $enumReflection = new ReflectionEnum($enumClass); + $backingTypeReflection = $enumReflection->getBackingType(); + + if ($enumReflection->isBacked() && $backingTypeReflection instanceof ReflectionNamedType) { + $paramSchema['enum'] = array_column($enumClass::cases(), 'value'); + $jsonBackingType = match ($backingTypeReflection->getName()) { + 'int' => 'integer', + 'string' => 'string', + default => null, + }; + + if ($jsonBackingType) { + if (isset($paramSchema['type']) && is_array($paramSchema['type']) && in_array('null', $paramSchema['type'])) { + $paramSchema['type'] = ['null', $jsonBackingType]; + } else { + $paramSchema['type'] = $jsonBackingType; + } + } + } else { + // Non-backed enum - use names as enum values + $paramSchema['enum'] = array_column($enumClass::cases(), 'name'); + if (isset($paramSchema['type']) && is_array($paramSchema['type']) && in_array('null', $paramSchema['type'])) { + $paramSchema['type'] = ['null', 'string']; + } else { + $paramSchema['type'] = 'string'; } } - if (empty($schema['properties'])) { - // Keep properties object even if empty, per spec - $schema['properties'] = new stdClass(); + return $paramSchema; + } + + /** + * Applies array-specific constraints to parameter schema. + */ + private function applyArrayConstraints(array $paramSchema, array $paramInfo): array + { + if (!isset($paramSchema['type'])) { + return $paramSchema; } - if (empty($schema['required'])) { - unset($schema['required']); + + $typeString = $paramInfo['type_string']; + $allowsNull = $paramInfo['allows_null']; + + // Handle object-like arrays using array{} syntax + if (preg_match('/^array\s*{/i', $typeString)) { + $objectSchema = $this->inferArrayItemsType($typeString); + if (is_array($objectSchema) && isset($objectSchema['properties'])) { + $paramSchema = array_merge($paramSchema, $objectSchema); + $paramSchema['type'] = $allowsNull ? ['object', 'null'] : 'object'; + } } + // Handle regular arrays + elseif (in_array('array', $this->mapPhpTypeToJsonSchemaType($typeString))) { + $itemsType = $this->inferArrayItemsType($typeString); + if ($itemsType !== 'any') { + if (is_string($itemsType)) { + $paramSchema['items'] = ['type' => $itemsType]; + } else { + if (!isset($itemsType['type']) && isset($itemsType['properties'])) { + $itemsType = array_merge(['type' => 'object'], $itemsType); + } + $paramSchema['items'] = $itemsType; + } + } - return $schema; + if ($allowsNull) { + $paramSchema['type'] = ['array', 'null']; + sort($paramSchema['type']); + } else { + $paramSchema['type'] = 'array'; + } + } + + return $paramSchema; } /** * Parses detailed information about a method's parameters. - * + * * @return array + * parameter_schema: array * }> */ - private function parseParametersInfo(ReflectionMethod $method, ?DocBlock $docBlock): array + private function parseParametersInfo(ReflectionMethod $method): array { + $docComment = $method->getDocComment() ?: null; + $docBlock = $this->docBlockParser->parseDocBlock($docComment); $paramTags = $this->docBlockParser->getParamTags($docBlock); $parametersInfo = []; - // Extract method-level schema constraints (for all parameters) - $methodSchemaConstraints = $this->extractSchemaConstraintsFromAttributes($method); - foreach ($method->getParameters() as $rp) { $paramName = $rp->getName(); $paramTag = $paramTags['$' . $paramName] ?? null; @@ -249,24 +423,22 @@ private function parseParametersInfo(ReflectionMethod $method, ?DocBlock $docBlo $defaultValue = $hasDefault ? $rp->getDefaultValue() : null; $isVariadic = $rp->isVariadic(); - // Extract schema constraints from parameter attributes - // Parameter attributes override method attributes - $paramSchemaConstraints = $this->extractSchemaConstraintsFromAttributes($rp); - $schemaConstraints = !empty($paramSchemaConstraints) - ? $paramSchemaConstraints - : $methodSchemaConstraints; + $parameterSchema = $this->extractParameterLevelSchema($rp); - // If the default value is a BackedEnum, use its scalar value for JSON schema - if ($hasDefault && $defaultValue instanceof \BackedEnum) { + if ($defaultValue instanceof \BackedEnum) { $defaultValue = $defaultValue->value; } + if ($defaultValue instanceof \UnitEnum) { + $defaultValue = $defaultValue->name; + } + $allowsNull = false; if ($reflectionType && $reflectionType->allowsNull()) { $allowsNull = true; } elseif ($hasDefault && $defaultValue === null) { $allowsNull = true; - } elseif (stripos($typeString, 'null') !== false || strtolower($typeString) === 'mixed') { + } elseif (str_contains($typeString, 'null') || strtolower($typeString) === 'mixed') { $allowsNull = true; } @@ -277,39 +449,18 @@ private function parseParametersInfo(ReflectionMethod $method, ?DocBlock $docBlo 'reflection_type_object' => $reflectionType, 'type_string' => $typeString, 'description' => $description, - 'required' => ! $rp->isOptional(), + 'required' => !$rp->isOptional(), 'allows_null' => $allowsNull, 'default_value' => $defaultValue, 'has_default' => $hasDefault, 'is_variadic' => $isVariadic, - 'schema_constraints' => $schemaConstraints, + 'parameter_schema' => $parameterSchema, ]; } return $parametersInfo; } - /** - * Extract schema constraints from attributes. - * - * @param ReflectionParameter|ReflectionMethod $reflection The reflection object to extract schema constraints from - * @return array The extracted schema constraints - */ - private function extractSchemaConstraintsFromAttributes(ReflectionParameter|ReflectionMethod $reflection): array - { - $constraints = []; - - if (method_exists($reflection, 'getAttributes')) { // PHP 8+ check - $schemaAttrs = $reflection->getAttributes(Schema::class, \ReflectionAttribute::IS_INSTANCEOF); - if (!empty($schemaAttrs)) { - $schemaAttr = $schemaAttrs[0]->newInstance(); - $constraints = $schemaAttr->toArray(); - } - } - - return $constraints; - } - /** * Determines the type string for a parameter, prioritizing DocBlock. */ @@ -338,9 +489,9 @@ private function getParameterTypeString(ReflectionParameter $rp, ?Param $paramTa } // Otherwise, use the DocBlock type if it was valid and non-generic - if ($docBlockType !== null && ! $isDocBlockTypeGeneric) { + if ($docBlockType !== null && !$isDocBlockTypeGeneric) { // Consider if DocBlock adds nullability missing from reflection - if (stripos($docBlockType, 'null') !== false && $reflectionTypeString && stripos($reflectionTypeString, 'null') === false && ! str_ends_with($reflectionTypeString, '|null')) { + if (stripos($docBlockType, 'null') !== false && $reflectionTypeString && stripos($reflectionTypeString, 'null') === false && !str_ends_with($reflectionTypeString, '|null')) { // If reflection didn't capture null, but docblock did, append |null (if not already mixed) if ($reflectionTypeString !== 'mixed') { return $reflectionTypeString . '|null'; @@ -365,7 +516,7 @@ private function getParameterTypeString(ReflectionParameter $rp, ?Param $paramTa private function getTypeStringFromReflection(?ReflectionType $type, bool $nativeAllowsNull): string { if ($type === null) { - return 'mixed'; // Or should it be null? MCP often uses 'mixed' for untyped. Let's stick to mixed for consistency. + return 'mixed'; } $types = []; @@ -385,7 +536,7 @@ private function getTypeStringFromReflection(?ReflectionType $type, bool $native } elseif ($type instanceof ReflectionNamedType) { $typeString = $type->getName(); } else { - return 'mixed'; // Fallback for unknown ReflectionType implementations + return 'mixed'; } $typeString = match (strtolower($typeString)) { @@ -401,7 +552,7 @@ private function getTypeStringFromReflection(?ReflectionType $type, bool $native $isNullable = true; } - if ($type instanceof ReflectionUnionType && ! $nativeAllowsNull) { + if ($type instanceof ReflectionUnionType && !$nativeAllowsNull) { foreach ($type->getTypes() as $innerType) { if ($innerType instanceof ReflectionNamedType && strtolower($innerType->getName()) === 'null') { $isNullable = true; @@ -411,7 +562,7 @@ private function getTypeStringFromReflection(?ReflectionType $type, bool $native } if ($isNullable && $typeString !== 'mixed' && stripos($typeString, 'null') === false) { - if (! str_ends_with($typeString, '|null') && ! str_ends_with($typeString, '&null')) { + if (!str_ends_with($typeString, '|null') && !str_ends_with($typeString, '&null')) { $typeString .= '|null'; } } @@ -428,8 +579,6 @@ private function getTypeStringFromReflection(?ReflectionType $type, bool $native /** * Maps a PHP type string (potentially a union) to an array of JSON Schema type names. - * - * @return list JSON schema types: "string", "integer", "number", "boolean", "array", "object", "null", "any" (custom placeholder) */ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array { @@ -470,21 +619,20 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array '?float', '?double', '?number' => ['null', 'number'], 'bool', 'boolean' => ['boolean'], '?bool', '?boolean' => ['null', 'boolean'], - 'array' => ['array'], // Catch native 'array' hint if not caught by generics/[] + 'array' => ['array'], '?array' => ['null', 'array'], - 'object', 'stdclass' => ['object'], // Catch native 'object' hint + 'object', 'stdclass' => ['object'], '?object', '?stdclass' => ['null', 'object'], 'null' => ['null'], - 'resource', 'callable' => ['object'], // Represent these complex types as object - 'mixed' => [], // Omit type for mixed - 'void', 'never' => [], // Not applicable for parameters - default => ['object'], // Fallback: Treat unknown non-namespaced words as object + 'resource', 'callable' => ['object'], + 'mixed' => [], + 'void', 'never' => [], + default => ['object'], }; } /** * Infers the 'items' schema type for an array based on DocBlock type hints. - * Returns 'any' if type cannot be determined. */ private function inferArrayItemsType(string $phpTypeString): string|array { @@ -522,7 +670,6 @@ private function inferArrayItemsType(string $phpTypeString): string|array return $this->parseObjectLikeArray($matches[2]); } - // No match or unsupported syntax return 'any'; } @@ -536,7 +683,6 @@ private function parseObjectLikeArray(string $propertiesStr): array // Parse properties from the string, handling nested structures $depth = 0; - $currentProp = ''; $buffer = ''; for ($i = 0; $i < strlen($propertiesStr); $i++) { diff --git a/tests/Fixtures/Utils/SchemaAttributeFixture.php b/tests/Fixtures/Utils/SchemaAttributeFixture.php deleted file mode 100644 index 605850e..0000000 --- a/tests/Fixtures/Utils/SchemaAttributeFixture.php +++ /dev/null @@ -1,124 +0,0 @@ - $arrayWithItems DocBlock specifies string[] but Schema overrides with number constraints - */ - public function typePrecedenceTest( - string $numericString, // PHP says string - - #[Schema(format: Format::EMAIL)] - string $stringWithFormat, // PHP + Schema - - #[Schema(items: new ArrayItems(minimum: 1, maximum: 100))] - array $arrayWithItems // Schema overrides DocBlock - ): void {} -} diff --git a/tests/Fixtures/Utils/SchemaGeneratorFixture.php b/tests/Fixtures/Utils/SchemaGeneratorFixture.php index 5e9cb1b..50544d4 100644 --- a/tests/Fixtures/Utils/SchemaGeneratorFixture.php +++ b/tests/Fixtures/Utils/SchemaGeneratorFixture.php @@ -2,139 +2,384 @@ namespace PhpMcp\Server\Tests\Fixtures\Utils; +use PhpMcp\Server\Attributes\Schema; use PhpMcp\Server\Tests\Fixtures\Enums\BackedIntEnum; use PhpMcp\Server\Tests\Fixtures\Enums\BackedStringEnum; use PhpMcp\Server\Tests\Fixtures\Enums\UnitEnum; use stdClass; /** - * Stub class containing methods with various parameter signatures for testing SchemaGenerator. + * Comprehensive fixture for testing SchemaGenerator with various scenarios. */ class SchemaGeneratorFixture { + // ===== BASIC SCENARIOS ===== + public function noParams(): void {} /** - * Method with simple required types. - * @param string $p1 String param - * @param int $p2 Int param - * @param bool $p3 Bool param - * @param float $p4 Float param - * @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 {} - - /** - * Method with simple optional types with default values. - * @param string $p1 String param - * @param int $p2 Int param - * @param bool $p3 Bool param - * @param float $p4 Float param - * @param array $p5 Array param - * @param stdClass|null $p6 Object param - */ - public function simpleOptionalDefaults( - string $p1 = 'default', - int $p2 = 123, - bool $p3 = true, - float $p4 = 1.23, - array $p5 = ['a', 'b'], - ?stdClass $p6 = null + * Type hints only - no Schema attributes. + */ + public function typeHintsOnly(string $name, int $age, bool $active, array $tags, ?stdClass $config = null): void {} + + /** + * DocBlock types only - no PHP type hints, no Schema attributes. + * @param string $username The username + * @param int $count Number of items + * @param bool $enabled Whether enabled + * @param array $data Some data + */ + public function docBlockOnly($username, $count, $enabled, $data): void {} + + /** + * Type hints with DocBlock descriptions. + * @param string $email User email address + * @param int $score User score + * @param bool $verified Whether user is verified + */ + public function typeHintsWithDocBlock(string $email, int $score, bool $verified): void {} + + // ===== METHOD-LEVEL SCHEMA SCENARIOS ===== + + /** + * Method-level Schema with complete definition. + */ + #[Schema(definition: [ + 'type' => 'object', + 'description' => 'Creates a custom filter with complete definition', + 'properties' => [ + 'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']], + 'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']], + 'value' => ['description' => 'Value to filter by, type depends on field and operator'] + ], + 'required' => ['field', 'operator', 'value'], + 'if' => [ + 'properties' => ['field' => ['const' => 'date']] + ], + 'then' => [ + 'properties' => ['value' => ['type' => 'string', 'format' => 'date']] + ] + ])] + public function methodLevelCompleteDefinition(string $field, string $operator, mixed $value): array + { + return compact('field', 'operator', 'value'); + } + + /** + * Method-level Schema defining properties. + */ + #[Schema( + description: "Creates a new user with detailed information.", + properties: [ + 'username' => ['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$'], + 'email' => ['type' => 'string', 'format' => 'email'], + 'age' => ['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.'], + 'isActive' => ['type' => 'boolean', 'default' => true] + ], + required: ['username', 'email'] + )] + public function methodLevelWithProperties(string $username, string $email, int $age, bool $isActive = true): array + { + return compact('username', 'email', 'age', 'isActive'); + } + + /** + * Method-level Schema for complex array argument. + */ + #[Schema( + properties: [ + 'profiles' => [ + 'type' => 'array', + 'description' => 'An array of user profiles to update.', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'data' => ['type' => 'object', 'additionalProperties' => true] + ], + 'required' => ['id', 'data'] + ] + ] + ], + required: ['profiles'] + )] + public function methodLevelArrayArgument(array $profiles): array + { + return ['updated_count' => count($profiles)]; + } + + // ===== PARAMETER-LEVEL SCHEMA SCENARIOS ===== + + /** + * Parameter-level Schema attributes only. + */ + public function parameterLevelOnly( + #[Schema(description: "Recipient ID", pattern: "^user_")] + string $recipientId, + + #[Schema(maxLength: 1024)] + string $messageBody, + + #[Schema(type: 'integer', enum: [1, 2, 5])] + int $priority = 1, + + #[Schema( + type: 'object', + properties: [ + 'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']], + 'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'] + ], + required: ['type'] + )] + ?array $notificationConfig = null + ): array { + return compact('recipientId', 'messageBody', 'priority', 'notificationConfig'); + } + + /** + * Parameter-level Schema with string constraints. + */ + public function parameterStringConstraints( + #[Schema(format: 'email')] + string $email, + + #[Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$')] + string $password, + + string $regularString ): void {} /** - * Method with nullable types without explicit defaults. - * @param string|null $p1 Nullable string - * @param ?int $p2 Nullable int shorthand - * @param ?bool $p3 Nullable bool + * Parameter-level Schema with numeric constraints. */ - public function nullableWithoutDefault(?string $p1, ?int $p2, ?bool $p3): void {} + public function parameterNumericConstraints( + #[Schema(minimum: 18, maximum: 120)] + int $age, + + #[Schema(minimum: 0, maximum: 5, exclusiveMaximum: true)] + float $rating, + + #[Schema(multipleOf: 10)] + int $count + ): void {} /** - * Method with nullable types WITH explicit null defaults. - * @param string|null $p1 Nullable string with default - * @param ?int $p2 Nullable int shorthand with default + * Parameter-level Schema with array constraints. */ - public function nullableWithNullDefault(?string $p1 = null, ?int $p2 = null): void {} + public function parameterArrayConstraints( + #[Schema(type: 'array', items: ['type' => 'string'], minItems: 1, uniqueItems: true)] + array $tags, + + #[Schema(type: 'array', items: ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], minItems: 1, maxItems: 5)] + array $scores + ): void {} + + // ===== COMBINED SCENARIOS ===== /** - * Method with union types. - * @param string|int $p1 String or Int - * @param bool|string|null $p2 Bool, String or Null + * Method-level + Parameter-level Schema combination. + * @param string $settingKey The key of the setting + * @param mixed $newValue The new value for the setting */ - public function unionTypes(string|int $p1, bool|null|string $p2): void {} + #[Schema( + properties: [ + 'settingKey' => ['type' => 'string', 'description' => 'The key of the setting.'], + 'newValue' => ['description' => 'The new value for the setting (any type).'] + ], + required: ['settingKey', 'newValue'] + )] + public function methodAndParameterLevel( + string $settingKey, + + #[Schema(description: "The specific new boolean value.", type: 'boolean')] + mixed $newValue + ): array { + return compact('settingKey', 'newValue'); + } + + /** + * Type hints + DocBlock + Parameter-level Schema. + * @param string $username The user's name + * @param int $priority Task priority level + */ + public function typeHintDocBlockAndParameterSchema( + #[Schema(minLength: 3, pattern: '^[a-zA-Z0-9_]+$')] + string $username, + + #[Schema(minimum: 1, maximum: 10)] + int $priority + ): void {} + + // ===== ENUM SCENARIOS ===== + + /** + * Various enum parameter types. + * @param BackedStringEnum $stringEnum Backed string enum + * @param BackedIntEnum $intEnum Backed int enum + * @param UnitEnum $unitEnum Unit enum + */ + public function enumParameters( + BackedStringEnum $stringEnum, + BackedIntEnum $intEnum, + UnitEnum $unitEnum, + ?BackedStringEnum $nullableEnum = null, + BackedIntEnum $enumWithDefault = BackedIntEnum::First + ): void {} + + // ===== ARRAY TYPE SCENARIOS ===== /** - * Method with various array types. - * @param array $p1 Generic array - * @param string[] $p2 Array of strings (docblock) - * @param int[] $p3 Array of integers (docblock) - * @param array $p4 Generic array map (docblock) - * @param BackedStringEnum[] $p5 Array of enums (docblock) - * @param ?bool[] $p6 Array of nullable booleans (docblock) + * Various array type scenarios. + * @param array $genericArray Generic array + * @param string[] $stringArray Array of strings + * @param int[] $intArray Array of integers + * @param array $mixedMap Mixed array map + * @param array{name: string, age: int} $objectLikeArray Object-like array + * @param array{user: array{id: int, name: string}, items: int[]} $nestedObjectArray Nested object array */ - public function arrayTypes( - array $p1, - array $p2, - array $p3, - array $p4, - array $p5, - array $p6 + public function arrayTypeScenarios( + array $genericArray, + array $stringArray, + array $intArray, + array $mixedMap, + array $objectLikeArray, + array $nestedObjectArray ): void {} + // ===== NULLABLE AND OPTIONAL SCENARIOS ===== + /** - * Method with various enum types. - * @param BackedStringEnum $p1 Backed string enum - * @param BackedIntEnum $p2 Backed int enum - * @param UnitEnum $p3 Unit enum - * @param ?BackedStringEnum $p4 Nullable backed string enum - * @param BackedIntEnum $p5 Optional backed int enum - * @param UnitEnum|null $p6 Optional unit enum with null default - */ - public function enumTypes( - BackedStringEnum $p1, - BackedIntEnum $p2, - UnitEnum $p3, - ?BackedStringEnum $p4, - BackedIntEnum $p5 = BackedIntEnum::First, - ?UnitEnum $p6 = null + * Nullable and optional parameter scenarios. + * @param string|null $nullableString Nullable string + * @param int|null $nullableInt Nullable integer + */ + public function nullableAndOptional( + ?string $nullableString, + ?int $nullableInt = null, + string $optionalString = 'default', + bool $optionalBool = true, + array $optionalArray = [] ): void {} + // ===== UNION TYPE SCENARIOS ===== + /** - * Method with variadic parameters. + * Union type parameters. + * @param string|int $stringOrInt String or integer + * @param bool|string|null $multiUnion Bool, string or null + */ + public function unionTypes( + string|int $stringOrInt, + bool|string|null $multiUnion + ): void {} + + // ===== VARIADIC SCENARIOS ===== + + /** + * Variadic parameter scenarios. * @param string ...$items Variadic strings */ - public function variadicParam(string ...$items): void {} + public function variadicStrings(string ...$items): void {} /** - * Method with mixed type hint. - * @param mixed $p1 Mixed type - * @param mixed $p2 Optional mixed type + * Variadic with Schema constraints. + * @param int ...$numbers Variadic integers */ - public function mixedType(mixed $p1, mixed $p2 = 'hello'): void {} + public function variadicWithConstraints( + #[Schema(items: ['type' => 'integer', 'minimum' => 0])] + int ...$numbers + ): void {} + + // ===== MIXED TYPE SCENARIOS ===== /** - * Method using only docblocks for type/description. - * @param string $p1 Only docblock type - * @param $p2 Only docblock description + * Mixed type scenarios. + * @param mixed $anyValue Any value + * @param mixed $optionalAny Optional any value */ - public function docBlockOnly($p1, $p2): void {} + public function mixedTypes( + mixed $anyValue, + mixed $optionalAny = 'default' + ): void {} + + // ===== COMPLEX NESTED SCENARIOS ===== /** - * Method with docblock type overriding PHP type hint. - * @param string $p1 Docblock overrides int + * Complex nested Schema constraints. */ - public function docBlockOverrides(int $p1): void {} + public function complexNestedSchema( + #[Schema( + type: 'object', + properties: [ + 'customer' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], + 'name' => ['type' => 'string', 'minLength' => 2], + 'email' => ['type' => 'string', 'format' => 'email'] + ], + 'required' => ['id', 'name'] + ], + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], + 'quantity' => ['type' => 'integer', 'minimum' => 1], + 'price' => ['type' => 'number', 'minimum' => 0] + ], + 'required' => ['product_id', 'quantity', 'price'] + ] + ], + 'metadata' => [ + 'type' => 'object', + 'additionalProperties' => true + ] + ], + required: ['customer', 'items'] + )] + array $order + ): array { + return ['order_id' => uniqid()]; + } + + // ===== TYPE PRECEDENCE SCENARIOS ===== /** - * Method with parameters implying formats. - * @param string $email Email address - * @param string $url URL string - * @param string $dateTime ISO Date time string + * Testing type precedence between PHP, DocBlock, and Schema. + * @param integer $numericString DocBlock says integer despite string type hint + * @param string $stringWithConstraints String with Schema constraints + * @param array $arrayWithItems Array with Schema item overrides */ - public function formatParams(string $email, string $url, string $dateTime): void {} + public function typePrecedenceTest( + string $numericString, - // Intersection types might not be directly supported by JSON schema - // public function intersectionType(MyInterface&MyOtherInterface $p1): void {} + #[Schema(format: 'email', minLength: 5)] + string $stringWithConstraints, + + #[Schema(items: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100])] + array $arrayWithItems + ): void {} + + // ===== ERROR EDGE CASES ===== + + /** + * Method with no parameters but Schema description. + */ + #[Schema(description: "Gets server status. Takes no arguments.", properties: [])] + public function noParamsWithSchema(): array + { + return ['status' => 'OK']; + } + + /** + * Parameter with Schema but inferred type. + */ + public function parameterSchemaInferredType( + #[Schema(description: "Some parameter", minLength: 3)] + $inferredParam + ): void {} } diff --git a/tests/Integration/DiscoveryTest.php b/tests/Integration/DiscoveryTest.php index 00f8404..48730eb 100644 --- a/tests/Integration/DiscoveryTest.php +++ b/tests/Integration/DiscoveryTest.php @@ -1,20 +1,17 @@ registry = new Registry($logger); @@ -127,7 +124,6 @@ expect($this->registry->getTool('hidden_subdir_tool'))->toBeNull(); }); - it('handles empty directories or directories with no PHP files', function () { $this->discoverer->discover($this->fixtureBasePath, ['EmptyDir']); expect($this->registry->getTools())->toBeEmpty(); @@ -147,4 +143,4 @@ $invokableCalc = $this->registry->getTool('InvokableCalculator'); // Name comes from Attr expect($invokableCalc->schema->name)->toBe('InvokableCalculator'); expect($invokableCalc->schema->description)->toBe('An invokable calculator tool.'); -}); +}); \ No newline at end of file diff --git a/tests/Integration/HttpServerTransportTest.php b/tests/Integration/HttpServerTransportTest.php index a51df2e..b46d864 100644 --- a/tests/Integration/HttpServerTransportTest.php +++ b/tests/Integration/HttpServerTransportTest.php @@ -1,7 +1,5 @@ sseClient = new MockSseClient(); $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; @@ -156,7 +154,6 @@ } })->group('integration', 'http_transport'); - it('can handle request for non-existent method after initialization', function () { $this->sseClient = new MockSseClient(); $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; @@ -243,7 +240,6 @@ $this->sseClient->close(); })->group('integration', 'http_transport'); - it('can handle tool list request over HTTP/SSE', function () { $this->sseClient = new MockSseClient(); $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; @@ -374,7 +370,6 @@ } })->group('integration', 'http_transport'); - it('returns 404 for POST to /message with clientId for a disconnected SSE stream', function () { $this->sseClient = new MockSseClient(); $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; @@ -418,4 +413,4 @@ } catch (\Throwable $e) { $this->fail("Request to unknown path failed with unexpected error: " . $e->getMessage()); } -})->group('integration', 'http_transport'); +})->group('integration', 'http_transport'); \ No newline at end of file diff --git a/tests/Integration/SchemaGenerationTest.php b/tests/Integration/SchemaGenerationTest.php new file mode 100644 index 0000000..1069c01 --- /dev/null +++ b/tests/Integration/SchemaGenerationTest.php @@ -0,0 +1,346 @@ +schemaGenerator = new SchemaGenerator($docBlockParser); +}); + +it('generates an empty properties object for a method with no parameters', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'noParams'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema)->toEqual([ + 'type' => 'object', + 'properties' => new stdClass() + ]); + expect($schema)->not->toHaveKey('required'); +}); + +it('infers basic types from PHP type hints when no DocBlocks or Schema attributes are present', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsOnly'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['name'])->toEqual(['type' => 'string']); + expect($schema['properties']['age'])->toEqual(['type' => 'integer']); + expect($schema['properties']['active'])->toEqual(['type' => 'boolean']); + expect($schema['properties']['tags'])->toEqual(['type' => 'array']); + expect($schema['properties']['config'])->toEqual(['type' => ['null', 'object'], 'default' => null]); + + expect($schema['required'])->toEqualCanonicalizing(['name', 'age', 'active', 'tags']); +}); + +it('infers types and descriptions from DocBlock @param tags when no PHP type hints are present', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'docBlockOnly'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['username'])->toEqual(['type' => 'string', 'description' => 'The username']); + expect($schema['properties']['count'])->toEqual(['type' => 'integer', 'description' => 'Number of items']); + expect($schema['properties']['enabled'])->toEqual(['type' => 'boolean', 'description' => 'Whether enabled']); + expect($schema['properties']['data'])->toEqual(['type' => 'array', 'description' => 'Some data']); + + expect($schema['required'])->toEqualCanonicalizing(['username', 'count', 'enabled', 'data']); +}); + +it('uses PHP type hints for type and DocBlock @param tags for descriptions when both are present', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsWithDocBlock'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['email'])->toEqual(['type' => 'string', 'description' => 'User email address']); + expect($schema['properties']['score'])->toEqual(['type' => 'integer', 'description' => 'User score']); + expect($schema['properties']['verified'])->toEqual(['type' => 'boolean', 'description' => 'Whether user is verified']); + + expect($schema['required'])->toEqualCanonicalizing(['email', 'score', 'verified']); +}); + +it('uses the complete schema definition provided by a method-level #[Schema(definition: ...)] attribute', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelCompleteDefinition'); + $schema = $this->schemaGenerator->generate($method); + + // Should return the complete definition as-is + expect($schema)->toEqual([ + 'type' => 'object', + 'description' => 'Creates a custom filter with complete definition', + 'properties' => [ + 'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']], + 'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']], + 'value' => ['description' => 'Value to filter by, type depends on field and operator'] + ], + 'required' => ['field', 'operator', 'value'], + 'if' => [ + 'properties' => ['field' => ['const' => 'date']] + ], + 'then' => [ + 'properties' => ['value' => ['type' => 'string', 'format' => 'date']] + ] + ]); +}); + +it('generates schema from a method-level #[Schema] attribute defining properties for each parameter', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelWithProperties'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['description'])->toBe("Creates a new user with detailed information."); + expect($schema['properties']['username'])->toEqual(['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$']); + expect($schema['properties']['email'])->toEqual(['type' => 'string', 'format' => 'email']); + expect($schema['properties']['age'])->toEqual(['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.']); + expect($schema['properties']['isActive'])->toEqual(['type' => 'boolean', 'default' => true]); + + expect($schema['required'])->toEqualCanonicalizing(['age', 'username', 'email']); +}); + +it('generates schema for a single array argument defined by a method-level #[Schema] attribute', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelArrayArgument'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['profiles'])->toEqual([ + 'type' => 'array', + 'description' => 'An array of user profiles to update.', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'data' => ['type' => 'object', 'additionalProperties' => true] + ], + 'required' => ['id', 'data'] + ] + ]); + + expect($schema['required'])->toEqual(['profiles']); +}); + +it('generates schema from individual parameter-level #[Schema] attributes', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterLevelOnly'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['recipientId'])->toEqual(['description' => "Recipient ID", 'pattern' => "^user_", 'type' => 'string']); + expect($schema['properties']['messageBody'])->toEqual(['maxLength' => 1024, 'type' => 'string']); + expect($schema['properties']['priority'])->toEqual(['type' => 'integer', 'enum' => [1, 2, 5], 'default' => 1]); + expect($schema['properties']['notificationConfig'])->toEqual([ + 'type' => 'object', + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']], + 'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'] + ], + 'required' => ['type'], + 'default' => null + ]); + + expect($schema['required'])->toEqualCanonicalizing(['recipientId', 'messageBody']); +}); + +it('applies string constraints from parameter-level #[Schema] attributes', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterStringConstraints'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['email'])->toEqual(['format' => 'email', 'type' => 'string']); + expect($schema['properties']['password'])->toEqual(['minLength' => 8, 'pattern' => '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$', 'type' => 'string']); + expect($schema['properties']['regularString'])->toEqual(['type' => 'string']); + + expect($schema['required'])->toEqualCanonicalizing(['email', 'password', 'regularString']); +}); + +it('applies numeric constraints from parameter-level #[Schema] attributes', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterNumericConstraints'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['age'])->toEqual(['minimum' => 18, 'maximum' => 120, 'type' => 'integer']); + expect($schema['properties']['rating'])->toEqual(['minimum' => 0, 'maximum' => 5, 'exclusiveMaximum' => true, 'type' => 'number']); + expect($schema['properties']['count'])->toEqual(['multipleOf' => 10, 'type' => 'integer']); + + expect($schema['required'])->toEqualCanonicalizing(['age', 'rating', 'count']); +}); + +it('applies array constraints (minItems, uniqueItems, items schema) from parameter-level #[Schema]', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterArrayConstraints'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['tags'])->toEqual(['type' => 'array', 'items' => ['type' => 'string'], 'minItems' => 1, 'uniqueItems' => true]); + expect($schema['properties']['scores'])->toEqual(['type' => 'array', 'items' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], 'minItems' => 1, 'maxItems' => 5]); + + expect($schema['required'])->toEqualCanonicalizing(['tags', 'scores']); +}); + +it('merges method-level and parameter-level #[Schema] attributes, with parameter-level taking precedence', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodAndParameterLevel'); + $schema = $this->schemaGenerator->generate($method); + + // Method level defines base properties + expect($schema['properties']['settingKey'])->toEqual(['type' => 'string', 'description' => 'The key of the setting.']); + + // Parameter level Schema overrides method level for newValue + expect($schema['properties']['newValue'])->toEqual(['description' => "The specific new boolean value.", 'type' => 'boolean']); + + expect($schema['required'])->toEqualCanonicalizing(['settingKey', 'newValue']); +}); + +it('combines PHP type hints, DocBlock descriptions, and parameter-level #[Schema] constraints', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintDocBlockAndParameterSchema'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['username'])->toEqual(['minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$', 'type' => 'string', 'description' => "The user's name"]); + expect($schema['properties']['priority'])->toEqual(['minimum' => 1, 'maximum' => 10, 'type' => 'integer', 'description' => 'Task priority level']); + + expect($schema['required'])->toEqualCanonicalizing(['username', 'priority']); +}); + +it('generates correct schema for backed and unit enum parameters, inferring from type hints', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['stringEnum'])->toEqual(['type' => 'string', 'description' => 'Backed string enum', 'enum' => ['A', 'B']]); + expect($schema['properties']['intEnum'])->toEqual(['type' => 'integer', 'description' => 'Backed int enum', 'enum' => [1, 2]]); + expect($schema['properties']['unitEnum'])->toEqual(['type' => 'string', 'description' => 'Unit enum', 'enum' => ['Yes', 'No']]); + expect($schema['properties']['nullableEnum'])->toEqual(['type' => ['null', 'string'], 'enum' => ['A', 'B'], 'default' => null]); + expect($schema['properties']['enumWithDefault'])->toEqual(['type' => 'integer', 'enum' => [1, 2], 'default' => 1]); + + expect($schema['required'])->toEqualCanonicalizing(['stringEnum', 'intEnum', 'unitEnum']); +}); + +it('correctly generates schemas for various array type declarations (generic, typed, shape)', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypeScenarios'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['genericArray'])->toEqual(['type' => 'array', 'description' => 'Generic array']); + expect($schema['properties']['stringArray'])->toEqual(['type' => 'array', 'description' => 'Array of strings', 'items' => ['type' => 'string']]); + expect($schema['properties']['intArray'])->toEqual(['type' => 'array', 'description' => 'Array of integers', 'items' => ['type' => 'integer']]); + expect($schema['properties']['mixedMap'])->toEqual(['type' => 'array', 'description' => 'Mixed array map']); + + // Object-like arrays should be converted to object type + expect($schema['properties']['objectLikeArray'])->toHaveKey('type'); + expect($schema['properties']['objectLikeArray']['type'])->toBe('object'); + expect($schema['properties']['objectLikeArray'])->toHaveKey('properties'); + expect($schema['properties']['objectLikeArray']['properties'])->toHaveKeys(['name', 'age']); + + expect($schema['required'])->toEqualCanonicalizing(['genericArray', 'stringArray', 'intArray', 'mixedMap', 'objectLikeArray', 'nestedObjectArray']); +}); + +it('handles nullable type hints and optional parameters with default values correctly', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndOptional'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['nullableString'])->toEqual(['type' => ['null', 'string'], 'description' => 'Nullable string']); + expect($schema['properties']['nullableInt'])->toEqual(['type' => ['null', 'integer'], 'description' => 'Nullable integer', 'default' => null]); + expect($schema['properties']['optionalString'])->toEqual(['type' => 'string', 'default' => 'default']); + expect($schema['properties']['optionalBool'])->toEqual(['type' => 'boolean', 'default' => true]); + expect($schema['properties']['optionalArray'])->toEqual(['type' => 'array', 'default' => []]); + + expect($schema['required'])->toEqualCanonicalizing(['nullableString']); +}); + +it('generates schema for PHP union types, sorting types alphabetically', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'unionTypes'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['stringOrInt'])->toEqual(['type' => ['integer', 'string'], 'description' => 'String or integer']); + expect($schema['properties']['multiUnion'])->toEqual(['type' => ['null', 'boolean', 'string'], 'description' => 'Bool, string or null']); + + expect($schema['required'])->toEqualCanonicalizing(['stringOrInt', 'multiUnion']); +}); + +it('represents variadic string parameters as an array of strings', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'variadicStrings'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['items'])->toEqual(['type' => 'array', 'description' => 'Variadic strings', 'items' => ['type' => 'string']]); + expect($schema)->not->toHaveKey('required'); + // Variadic is optional +}); + +it('applies item constraints from parameter-level #[Schema] to variadic parameters', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'variadicWithConstraints'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['numbers'])->toEqual(['items' => ['type' => 'integer', 'minimum' => 0], 'type' => 'array', 'description' => 'Variadic integers']); + expect($schema)->not->toHaveKey('required'); +}); + +it('handles mixed type hints, omitting explicit type in schema and using defaults', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'mixedTypes'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['anyValue'])->toEqual(['description' => 'Any value']); + expect($schema['properties']['optionalAny'])->toEqual(['description' => 'Optional any value', 'default' => 'default']); + + expect($schema['required'])->toEqualCanonicalizing(['anyValue']); +}); + +it('generates schema for complex nested object and array structures defined in parameter-level #[Schema]', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'complexNestedSchema'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['order'])->toEqual([ + 'type' => 'object', + 'properties' => [ + 'customer' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], + 'name' => ['type' => 'string', 'minLength' => 2], + 'email' => ['type' => 'string', 'format' => 'email'] + ], + 'required' => ['id', 'name'] + ], + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], + 'quantity' => ['type' => 'integer', 'minimum' => 1], + 'price' => ['type' => 'number', 'minimum' => 0] + ], + 'required' => ['product_id', 'quantity', 'price'] + ] + ], + 'metadata' => [ + 'type' => 'object', + 'additionalProperties' => true + ] + ], + 'required' => ['customer', 'items'] + ]); + + expect($schema['required'])->toEqual(['order']); +}); + +it('demonstrates type precedence: parameter #[Schema] overrides DocBlock, which overrides PHP type hint', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typePrecedenceTest'); + $schema = $this->schemaGenerator->generate($method); + + // DocBlock type (integer) should override PHP type (string) + expect($schema['properties']['numericString'])->toEqual(['type' => 'integer', 'description' => 'DocBlock says integer despite string type hint']); + + // Schema constraints should be applied with PHP type + expect($schema['properties']['stringWithConstraints'])->toEqual(['format' => 'email', 'minLength' => 5, 'type' => 'string', 'description' => 'String with Schema constraints']); + + // Schema should override DocBlock array item type + expect($schema['properties']['arrayWithItems'])->toEqual(['items' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], 'type' => 'array', 'description' => 'Array with Schema item overrides']); + + expect($schema['required'])->toEqualCanonicalizing(['numericString', 'stringWithConstraints', 'arrayWithItems']); +}); + +it('generates an empty properties object for a method with no parameters even if a method-level #[Schema] is present', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'noParamsWithSchema'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['description'])->toBe("Gets server status. Takes no arguments."); + expect($schema['properties'])->toBeInstanceOf(stdClass::class); + expect($schema)->not->toHaveKey('required'); +}); + +it('infers parameter type as "any" (omits type) if only constraints are given in #[Schema] without type hint or DocBlock type', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterSchemaInferredType'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['inferredParam'])->toEqual(['description' => "Some parameter", 'minLength' => 3]); + + expect($schema['required'])->toEqual(['inferredParam']); +}); diff --git a/tests/Integration/StdioServerTransportTest.php b/tests/Integration/StdioServerTransportTest.php index f7b0db5..edd310e 100644 --- a/tests/Integration/StdioServerTransportTest.php +++ b/tests/Integration/StdioServerTransportTest.php @@ -1,7 +1,5 @@ process = null; }); - it('starts the stdio server, initializes, calls a tool, and closes', function () { // 1. Initialize Request sendRequestToServer($this->process, 'init-1', 'initialize', [ @@ -152,7 +150,6 @@ function readResponseFromServer(Process $process, string $expectedRequestId, Loo $this->process->stdin->end(); })->group('integration', 'stdio_transport'); - it('can handle invalid JSON request from client', function () { $this->process->stdin->write("this is not json\n"); @@ -165,7 +162,6 @@ function readResponseFromServer(Process $process, string $expectedRequestId, Loo $this->process->stdin->end(); })->group('integration', 'stdio_transport'); - it('handles request for non-existent method', function () { sendRequestToServer($this->process, 'init-err', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); await(readResponseFromServer($this->process, 'init-err', $this->loop)); @@ -244,7 +240,6 @@ function readResponseFromServer(Process $process, string $expectedRequestId, Loo $this->process->stdin->end(); })->group('integration', 'stdio_transport'); - it('can handle tool list request', function () { sendRequestToServer($this->process, 'init-tool-list', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []]); await(readResponseFromServer($this->process, 'init-tool-list', $this->loop)); @@ -323,4 +318,4 @@ function readResponseFromServer(Process $process, string $expectedRequestId, Loo expect($toolResponse['error']['message'])->toContain('Client session not initialized'); $this->process->stdin->end(); -})->group('integration', 'stdio_transport'); +})->group('integration', 'stdio_transport'); \ No newline at end of file diff --git a/tests/Integration/StreamableHttpServerTransportTest.php b/tests/Integration/StreamableHttpServerTransportTest.php index 8eb6309..3590081 100644 --- a/tests/Integration/StreamableHttpServerTransportTest.php +++ b/tests/Integration/StreamableHttpServerTransportTest.php @@ -1,7 +1,5 @@ process = null; }); - describe('JSON MODE', function () { beforeEach(function () { $this->process = new Process($this->jsonModeCommand, getcwd() ?: null, null, []); @@ -569,4 +567,4 @@ expect($decodedBody['error']['code'])->toBe(-32600); expect($decodedBody['error']['message'])->toContain('Invalid or expired session'); } -})->group('integration', 'streamable_http_json'); +})->group('integration', 'streamable_http_json'); \ No newline at end of file diff --git a/tests/Unit/ServerBuilderTest.php b/tests/Unit/ServerBuilderTest.php index ae334fe..c184fd6 100644 --- a/tests/Unit/ServerBuilderTest.php +++ b/tests/Unit/ServerBuilderTest.php @@ -13,7 +13,6 @@ use PhpMcp\Server\Elements\RegisteredPrompt; use PhpMcp\Server\Elements\RegisteredTool; use PhpMcp\Server\Exception\ConfigurationException; -use PhpMcp\Server\Exception\DefinitionException; use PhpMcp\Server\Protocol; use PhpMcp\Server\Registry; use PhpMcp\Server\Server; diff --git a/tests/Unit/Utils/SchemaGeneratorTest.php b/tests/Unit/Utils/SchemaGeneratorTest.php deleted file mode 100644 index 99db202..0000000 --- a/tests/Unit/Utils/SchemaGeneratorTest.php +++ /dev/null @@ -1,472 +0,0 @@ -docBlockParserMock = Mockery::mock(DocBlockParser::class); - $this->schemaGenerator = new SchemaGenerator($this->docBlockParserMock); -}); - -function setupDocBlockExpectations(Mockery\MockInterface $parserMock, ReflectionMethod $method): void -{ - $docComment = $method->getDocComment() ?: ''; - $realDocBlock = $docComment ? DocBlockFactory::createInstance()->create($docComment) : null; - $parserMock->shouldReceive('parseDocBlock')->once()->with($docComment ?: null)->andReturn($realDocBlock); - - $realParamTags = []; - if ($realDocBlock) { - foreach ($realDocBlock->getTagsByName('param') as $tag) { - if ($tag instanceof Param && $tag->getVariableName()) { - $realParamTags['$' . $tag->getVariableName()] = $tag; - } - } - } - $parserMock->shouldReceive('getParamTags')->once()->with($realDocBlock)->andReturn($realParamTags); - - // Set expectations for each parameter based on whether it has a real tag - foreach ($method->getParameters() as $rp) { - $paramName = $rp->getName(); - $tagName = '$' . $paramName; - $tag = $realParamTags[$tagName] ?? null; - - // Mock the calls the generator will make for this specific parameter - $expectedType = $tag ? (string) $tag->getType() : null; - $expectedDesc = $tag ? ($tag->getDescription() ? $tag->getDescription()->render() : null) : null; - - $parserMock->shouldReceive('getParamTypeString')->with($tag)->andReturn($expectedType); - $parserMock->shouldReceive('getParamDescription')->with($tag)->andReturn($expectedDesc); - } -} - - -test('generates empty schema for method with no parameters', function () { - $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'noParams'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema)->toEqual(['type' => 'object', 'properties' => new \stdClass()]); - expect($schema)->not->toHaveKey('required'); -}); - -test('generates schema for required simple types', function () { - $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'simpleRequired'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['p1'])->toEqual(['type' => 'string', 'description' => 'String param']); - expect($schema['properties']['p2'])->toEqual(['type' => 'integer', 'description' => 'Int param']); - expect($schema['properties']['p3'])->toEqual(['type' => 'boolean', 'description' => 'Bool param']); - expect($schema['properties']['p4'])->toEqual(['type' => 'number', 'description' => 'Float param']); - expect($schema['properties']['p5'])->toEqual(['type' => 'array', 'description' => 'Array param']); - expect($schema['properties']['p6'])->toEqual(['type' => 'object', 'description' => 'Object param']); - expect($schema['required'])->toEqualCanonicalizing(['p1', 'p2', 'p3', 'p4', 'p5', 'p6']); -}); - -test('generates schema for optional simple types with defaults', function () { - $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'simpleOptionalDefaults'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['p1'])->toEqual(['type' => 'string', 'description' => 'String param', 'default' => 'default']); - expect($schema['properties']['p2'])->toEqual(['type' => 'integer', 'description' => 'Int param', 'default' => 123]); - expect($schema['properties']['p3'])->toEqual(['type' => 'boolean', 'description' => 'Bool param', 'default' => true]); - expect($schema['properties']['p4'])->toEqual(['type' => 'number', 'description' => 'Float param', 'default' => 1.23]); - expect($schema['properties']['p5'])->toEqual(['type' => 'array', 'description' => 'Array param', 'default' => ['a', 'b']]); - expect($schema['properties']['p6'])->toEqual(['type' => ['null', 'object'], 'description' => 'Object param', 'default' => null]); // Nullable type from reflection - expect($schema)->not->toHaveKey('required'); -}); - -test('generates schema for nullable types without explicit default', function () { - $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'nullableWithoutDefault'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['p1'])->toEqual(['type' => ['null', 'string'], 'description' => 'Nullable string']); - expect($schema['properties']['p2'])->toEqual(['type' => ['integer', 'null'], 'description' => 'Nullable int shorthand']); - expect($schema['properties']['p3'])->toEqual(['type' => ['boolean', 'null'], 'description' => 'Nullable bool']); - - // Required because they don't have a default value - expect($schema)->toHaveKey('required'); -}); - -test('generates schema for nullable types with explicit null default', function () { - $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'nullableWithNullDefault'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // These are optional because they have a default value (null), so not required. - expect($schema['properties']['p1'])->toEqual(['type' => ['null', 'string'], 'description' => 'Nullable string with default', 'default' => null]); - expect($schema['properties']['p2'])->toEqual(['type' => ['integer', 'null'], 'description' => 'Nullable int shorthand with default', 'default' => null]); - expect($schema)->not->toHaveKey('required'); -}); - -test('generates schema for union types', function () { - $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'unionTypes'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['p1'])->toEqual(['type' => ['integer', 'string'], 'description' => 'String or Int']); // Sorted types - expect($schema['properties']['p2'])->toEqual(['type' => ['boolean', 'null', 'string'], 'description' => 'Bool, String or Null']); // Sorted types - expect($schema['required'])->toEqualCanonicalizing(['p1', 'p2']); // Neither has default -}); - -test('generates schema for array types', function () { - $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypes'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['p1'])->toEqual(['type' => 'array', 'description' => 'Generic array']); // No items info - expect($schema['properties']['p2'])->toEqual(['type' => 'array', 'description' => 'Array of strings (docblock)', 'items' => ['type' => 'string']]); - expect($schema['properties']['p3'])->toEqual(['type' => 'array', 'description' => 'Array of integers (docblock)', 'items' => ['type' => 'integer']]); - // expect($schema['properties']['p4'])->toEqual(['type' => 'array', 'description' => 'Generic array map (docblock)', 'items' => ['type' => 'string']]); // Infers value type - // expect($schema['properties']['p5'])->toEqual(['type' => 'array', 'description' => 'Array of enums (docblock)', 'items' => ['type' => 'string']]); // Enum maps to string backing type - expect($schema['properties']['p6'])->toEqual(['type' => 'array', 'description' => 'Array of nullable booleans (docblock)', 'items' => ['type' => 'boolean']]); // Item type bool, nullability on outer? - expect($schema['required'])->toEqualCanonicalizing(['p1', 'p2', 'p3', 'p4', 'p5', 'p6']); -}); - -test('generates schema for enum types', function () { - $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'enumTypes'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Backed String Enum - expect($schema['properties']['p1'])->toEqual(['type' => 'string', 'description' => 'Backed string enum', 'enum' => ['A', 'B']]); - // Backed Int Enum - expect($schema['properties']['p2'])->toEqual(['type' => 'integer', 'description' => 'Backed int enum', 'enum' => [1, 2]]); - // Unit Enum - expect($schema['properties']['p3'])->toEqual(['type' => 'string', 'description' => 'Unit enum', 'enum' => ['Yes', 'No']]); - // Nullable Backed String Enum (No default) - expect($schema['properties']['p4'])->toEqual(['type' => ['string', 'null'], 'description' => 'Nullable backed string enum', 'enum' => ['A', 'B']]); - // Optional Backed Int Enum (With default) - expect($schema['properties']['p5'])->toEqual(['type' => 'integer', 'description' => 'Optional backed int enum', 'enum' => [1, 2], 'default' => 1]); - // Optional Unit Enum (With null default) - expect($schema['properties']['p6'])->toEqual(['type' => ['string', 'null'], 'description' => 'Optional unit enum with null default', 'enum' => ['Yes', 'No'], 'default' => null]); - - // Check required fields (p4, p5, p6 are optional) - expect($schema['required'])->toEqualCanonicalizing(['p1', 'p2', 'p3', 'p4']); -})->skip(version_compare(PHP_VERSION, '8.1', '<'), 'Enums require PHP 8.1+'); - -test('generates schema for variadic parameters', function () { - $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'variadicParam'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Variadic params are represented as arrays - expect($schema['properties']['items'])->toEqual(['type' => 'array', 'description' => 'Variadic strings', 'items' => ['type' => 'string']]); - expect($schema)->not->toHaveKey('required'); // Variadic params are inherently optional -}); - -test('generates schema for mixed type', function () { - $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'mixedType'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Mixed type results in no 'type' constraint in JSON Schema - expect($schema['properties']['p1'])->toEqual(['description' => 'Mixed type']); - expect($schema['properties']['p2'])->toEqual(['description' => 'Optional mixed type', 'default' => 'hello']); - expect($schema['required'])->toEqualCanonicalizing(['p1']); // p2 has default -}); - -test('generates schema using docblock type when no php type hint', function () { - $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'docBlockOnly'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['p1'])->toEqual(['type' => 'string', 'description' => 'Only docblock type']); // Type from docblock - expect($schema['properties']['p2'])->toEqual(['description' => 'Only docblock description']); // No type info - expect($schema['required'])->toEqualCanonicalizing(['p1', 'p2']); -}); - -test('generates schema using docblock type overriding php type hint', function () { - $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'docBlockOverrides'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Docblock type (@param string) overrides PHP type hint (int) - expect($schema['properties']['p1'])->toEqual(['type' => 'string', 'description' => 'Docblock overrides int']); - expect($schema['required'])->toEqualCanonicalizing(['p1']); -}); - -test('generates schema with string format constraints from Schema attribute', function () { - $method = new ReflectionMethod(SchemaAttributeFixture::class, 'stringConstraints'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['email'])->toHaveKey('format') - ->and($schema['properties']['email']['format'])->toBe('email'); - - expect($schema['properties']['password'])->toHaveKey('minLength') - ->and($schema['properties']['password']['minLength'])->toBe(8); - expect($schema['properties']['password'])->toHaveKey('pattern') - ->and($schema['properties']['password']['pattern'])->toBe('^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$'); - - // Regular parameter should not have format constraints - expect($schema['properties']['code'])->not->toHaveKey('format'); - expect($schema['properties']['code'])->not->toHaveKey('minLength'); -}); - -test('generates schema with numeric constraints from Schema attribute', function () { - $method = new ReflectionMethod(SchemaAttributeFixture::class, 'numericConstraints'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['age'])->toHaveKey('minimum') - ->and($schema['properties']['age']['minimum'])->toBe(18); - expect($schema['properties']['age'])->toHaveKey('maximum') - ->and($schema['properties']['age']['maximum'])->toBe(120); - - expect($schema['properties']['rating'])->toHaveKey('minimum') - ->and($schema['properties']['rating']['minimum'])->toBe(0); - expect($schema['properties']['rating'])->toHaveKey('maximum') - ->and($schema['properties']['rating']['maximum'])->toBe(5); - expect($schema['properties']['rating'])->toHaveKey('exclusiveMaximum') - ->and($schema['properties']['rating']['exclusiveMaximum'])->toBeTrue(); - - expect($schema['properties']['count'])->toHaveKey('multipleOf') - ->and($schema['properties']['count']['multipleOf'])->toBe(10); -}); - -test('generates schema with array constraints from Schema attribute', function () { - $method = new ReflectionMethod(SchemaAttributeFixture::class, 'arrayConstraints'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - expect($schema['properties']['tags'])->toHaveKey('uniqueItems') - ->and($schema['properties']['tags']['uniqueItems'])->toBeTrue(); - expect($schema['properties']['tags'])->toHaveKey('minItems') - ->and($schema['properties']['tags']['minItems'])->toBe(1); - - expect($schema['properties']['scores'])->toHaveKey('minItems') - ->and($schema['properties']['scores']['minItems'])->toBe(1); - expect($schema['properties']['scores'])->toHaveKey('maxItems') - ->and($schema['properties']['scores']['maxItems'])->toBe(5); - expect($schema['properties']['scores'])->toHaveKey('items') - ->and($schema['properties']['scores']['items'])->toHaveKey('minimum') - ->and($schema['properties']['scores']['items']['minimum'])->toBe(0); - expect($schema['properties']['scores']['items'])->toHaveKey('maximum') - ->and($schema['properties']['scores']['items']['maximum'])->toBe(100); - - // Regular array should not have constraints - expect($schema['properties']['mixed'])->not->toHaveKey('minItems'); - expect($schema['properties']['mixed'])->not->toHaveKey('uniqueItems'); -}); - -test('generates schema with object constraints from Schema attribute', function () { - $method = new ReflectionMethod(SchemaAttributeFixture::class, 'objectConstraints'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Check properties - expect($schema['properties']['user'])->toHaveKey('properties'); - $properties = $schema['properties']['user']['properties']; - - expect($properties)->toHaveKeys(['name', 'email', 'age']); - expect($properties['name'])->toHaveKey('minLength') - ->and($properties['name']['minLength'])->toBe(2); - expect($properties['email'])->toHaveKey('format') - ->and($properties['email']['format'])->toBe('email'); - expect($properties['age'])->toHaveKey('minimum') - ->and($properties['age']['minimum'])->toBe(18); - - // Check required - expect($schema['properties']['user'])->toHaveKey('required') - ->and($schema['properties']['user']['required'])->toContain('name') - ->and($schema['properties']['user']['required'])->toContain('email'); - - // Check additionalProperties - expect($schema['properties']['config'])->toHaveKey('additionalProperties') - ->and($schema['properties']['config']['additionalProperties'])->toBeTrue(); -}); - -test('generates schema with nested constraints from Schema attribute', function () { - $method = new ReflectionMethod(SchemaAttributeFixture::class, 'nestedConstraints'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Check top level properties exist - expect($schema['properties']['order'])->toHaveKey('properties'); - expect($schema['properties']['order']['properties'])->toHaveKeys(['customer', 'items']); - - // Check customer properties - $customer = $schema['properties']['order']['properties']['customer']; - expect($customer)->toHaveKey('properties'); - expect($customer['properties'])->toHaveKeys(['id', 'name']); - expect($customer['properties']['id'])->toHaveKey('pattern'); - expect($customer['required'])->toContain('id'); - - // Check items properties - $items = $schema['properties']['order']['properties']['items']; - expect($items)->toHaveKey('minItems') - ->and($items['minItems'])->toBe(1); - expect($items)->toHaveKey('items'); - - // Check items schema - $itemsSchema = $items['items']; - expect($itemsSchema)->toHaveKey('properties'); - expect($itemsSchema['properties'])->toHaveKeys(['product_id', 'quantity']); - expect($itemsSchema['required'])->toContain('product_id') - ->and($itemsSchema['required'])->toContain('quantity'); - expect($itemsSchema['properties']['product_id'])->toHaveKey('pattern'); - expect($itemsSchema['properties']['quantity'])->toHaveKey('minimum') - ->and($itemsSchema['properties']['quantity']['minimum'])->toBe(1); -}); - -test('respects precedence order between PHP type, DocBlock, and Schema attributes', function () { - $method = new ReflectionMethod(SchemaAttributeFixture::class, 'typePrecedenceTest'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Test Case 1: DocBlock type (integer) should override PHP type (string) - // but keep string characteristics (not have integer constraints) - expect($schema['properties']['numericString'])->toHaveKey('type') - ->and($schema['properties']['numericString']['type'])->toBe('integer') - ->and($schema['properties']['numericString'])->not->toHaveKey('format'); // No string format since type is now integer - - // Test Case 2: Schema format should be applied even when type is from PHP/DocBlock - expect($schema['properties']['stringWithFormat'])->toHaveKey('type') - ->and($schema['properties']['stringWithFormat']['type'])->toBe('string') - ->and($schema['properties']['stringWithFormat'])->toHaveKey('format') - ->and($schema['properties']['stringWithFormat']['format'])->toBe('email'); - - // Test Case 3: Schema items constraints should override DocBlock array hint - expect($schema['properties']['arrayWithItems'])->toHaveKey('type') - ->and($schema['properties']['arrayWithItems']['type'])->toBe('array') - ->and($schema['properties']['arrayWithItems'])->toHaveKey('items') - ->and($schema['properties']['arrayWithItems']['items'])->toHaveKey('minimum') - ->and($schema['properties']['arrayWithItems']['items']['minimum'])->toBe(1) - ->and($schema['properties']['arrayWithItems']['items'])->toHaveKey('maximum') - ->and($schema['properties']['arrayWithItems']['items']['maximum'])->toBe(100); -}); - -test('parses simple array[] syntax correctly', function () { - $method = new ReflectionMethod(DockBlockParserFixture::class, 'simpleArraySyntax'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Check each parameter type is correctly inferred - expect($schema['properties']['strings'])->toHaveKey('items') - ->and($schema['properties']['strings']['items']['type'])->toBe('string'); - - expect($schema['properties']['integers'])->toHaveKey('items') - ->and($schema['properties']['integers']['items']['type'])->toBe('integer'); - - expect($schema['properties']['booleans'])->toHaveKey('items') - ->and($schema['properties']['booleans']['items']['type'])->toBe('boolean'); - - expect($schema['properties']['floats'])->toHaveKey('items') - ->and($schema['properties']['floats']['items']['type'])->toBe('number'); - - expect($schema['properties']['objects'])->toHaveKey('items') - ->and($schema['properties']['objects']['items']['type'])->toBe('object'); - - expect($schema['properties']['dateTimeInstances'])->toHaveKey('items') - ->and($schema['properties']['dateTimeInstances']['items']['type'])->toBe('object'); -}); - -test('parses array generic syntax correctly', function () { - $method = new ReflectionMethod(DockBlockParserFixture::class, 'genericArraySyntax'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Check each parameter type is correctly inferred - expect($schema['properties']['strings'])->toHaveKey('items') - ->and($schema['properties']['strings']['items']['type'])->toBe('string'); - - expect($schema['properties']['integers'])->toHaveKey('items') - ->and($schema['properties']['integers']['items']['type'])->toBe('integer'); - - expect($schema['properties']['booleans'])->toHaveKey('items') - ->and($schema['properties']['booleans']['items']['type'])->toBe('boolean'); - - expect($schema['properties']['floats'])->toHaveKey('items') - ->and($schema['properties']['floats']['items']['type'])->toBe('number'); - - expect($schema['properties']['objects'])->toHaveKey('items') - ->and($schema['properties']['objects']['items']['type'])->toBe('object'); - - expect($schema['properties']['dateTimeInstances'])->toHaveKey('items') - ->and($schema['properties']['dateTimeInstances']['items']['type'])->toBe('object'); -}); - -test('parses nested array syntax correctly', function () { - $method = new ReflectionMethod(DockBlockParserFixture::class, 'nestedArraySyntax'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Check for nested arrays with array> syntax - expect($schema['properties']['nestedStringArrays'])->toHaveKey('items') - ->and($schema['properties']['nestedStringArrays']['items'])->toHaveKey('type') - ->and($schema['properties']['nestedStringArrays']['items']['type'])->toBe('array') - ->and($schema['properties']['nestedStringArrays']['items'])->toHaveKey('items') - ->and($schema['properties']['nestedStringArrays']['items']['items']['type'])->toBe('string'); - - // Check for nested arrays with array> syntax - expect($schema['properties']['nestedIntArrays'])->toHaveKey('items') - ->and($schema['properties']['nestedIntArrays']['items'])->toHaveKey('type') - ->and($schema['properties']['nestedIntArrays']['items']['type'])->toBe('array') - ->and($schema['properties']['nestedIntArrays']['items'])->toHaveKey('items') - ->and($schema['properties']['nestedIntArrays']['items']['items']['type'])->toBe('integer'); -}); - -test('parses object-like array syntax correctly', function () { - $method = new ReflectionMethod(DockBlockParserFixture::class, 'objectArraySyntax'); - setupDocBlockExpectations($this->docBlockParserMock, $method); - - $schema = $this->schemaGenerator->fromMethodParameters($method); - - // Simple object array - expect($schema['properties']['person'])->toHaveKey('type') - ->and($schema['properties']['person']['type'])->toBe('object'); - expect($schema['properties']['person'])->toHaveKey('properties') - ->and($schema['properties']['person']['properties'])->toHaveKeys(['name', 'age']); - expect($schema['properties']['person']['properties']['name']['type'])->toBe('string'); - expect($schema['properties']['person']['properties']['age']['type'])->toBe('integer'); - expect($schema['properties']['person'])->toHaveKey('required') - ->and($schema['properties']['person']['required'])->toContain('name') - ->and($schema['properties']['person']['required'])->toContain('age'); - - // Object with nested array property - expect($schema['properties']['article'])->toHaveKey('properties') - ->and($schema['properties']['article']['properties'])->toHaveKey('tags') - ->and($schema['properties']['article']['properties']['tags']['type'])->toBe('array') - ->and($schema['properties']['article']['properties']['tags']['items']['type'])->toBe('string'); - - // Complex object with nested object and array - expect($schema['properties']['order'])->toHaveKey('properties') - ->and($schema['properties']['order']['properties'])->toHaveKeys(['user', 'items']); - expect($schema['properties']['order']['properties']['user']['type'])->toBe('object'); - expect($schema['properties']['order']['properties']['user']['properties'])->toHaveKeys(['id', 'name']); - expect($schema['properties']['order']['properties']['items']['type'])->toBe('array') - ->and($schema['properties']['order']['properties']['items']['items']['type'])->toBe('integer'); -}); diff --git a/tests/Unit/Utils/SchemaValidatorTest.php b/tests/Unit/Utils/SchemaValidatorTest.php index 8c589fc..a32a50f 100644 --- a/tests/Unit/Utils/SchemaValidatorTest.php +++ b/tests/Unit/Utils/SchemaValidatorTest.php @@ -248,7 +248,7 @@ function getValidData(): array }); test('validates schema with string format constraints from Schema attribute', function () { - $emailSchema = (new Schema(format: Format::EMAIL))->toArray(); + $emailSchema = (new Schema(format: 'email'))->toArray(); // Valid email $validErrors = $this->validator->validateAgainstJsonSchema('user@example.com', $emailSchema); @@ -318,9 +318,9 @@ function getValidData(): array test('validates schema with object constraints from Schema attribute', function () { $userSchema = (new Schema( properties: [ - new Property('name', minLength: 2), - new Property('email', format: Format::EMAIL), - new Property('age', minimum: 18) + 'name' => ['type' => 'string', 'minLength' => 2], + 'email' => ['type' => 'string', 'format' => 'email'], + 'age' => ['type' => 'integer', 'minimum' => 18] ], required: ['name', 'email'] ))->toArray(); @@ -367,25 +367,25 @@ function getValidData(): array test('validates schema with nested constraints from Schema attribute', function () { $orderSchema = (new Schema( properties: [ - new Property( - 'customer', - properties: [ - new Property('id', pattern: '^CUS-[0-9]{6}$'), - new Property('name', minLength: 2) + 'customer' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], + 'name' => ['type' => 'string', 'minLength' => 2] ], - required: ['id'] - ), - new Property( - 'items', - minItems: 1, - items: new ArrayItems( - properties: [ - new Property('product_id', pattern: '^PRD-[0-9]{4}$'), - new Property('quantity', minimum: 1) + ], + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], + 'quantity' => ['type' => 'integer', 'minimum' => 1] ], - required: ['product_id', 'quantity'] - ) - ) + 'required' => ['product_id', 'quantity'] + ] + ] ], required: ['customer', 'items'] ))->toArray(); From d1771b9cf9fcaa93026c7d61ba9746d335d17661 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Thu, 19 Jun 2025 13:58:30 +0100 Subject: [PATCH 22/27] lint: Format code using PHP CS Fixer --- .../02-discovery-http-userprofile/server.php | 6 +- src/Attributes/CompletionProvider.php | 4 +- src/Attributes/Schema.php | 60 ++++++++++++---- src/Dispatcher.php | 2 +- src/Elements/RegisteredElement.php | 5 +- src/Elements/RegisteredResource.php | 4 +- src/Elements/RegisteredResourceTemplate.php | 4 +- src/Registry.php | 8 +-- src/Server.php | 3 +- src/ServerBuilder.php | 4 +- .../StreamableHttpServerTransport.php | 8 +-- src/Utils/SchemaGenerator.php | 20 +++--- src/Utils/SchemaValidator.php | 4 +- .../Discovery/DiscoverableResourceHandler.php | 4 +- .../Discovery/DiscoverableToolHandler.php | 12 +++- .../Discovery/NonDiscoverableClass.php | 8 ++- .../Fixtures/Discovery/SubDir/HiddenTool.php | 4 +- .../General/CompletionProviderFixture.php | 2 +- .../Fixtures/General/DocBlockTestFixture.php | 24 +++++-- tests/Fixtures/General/ToolHandlerFixture.php | 5 +- .../Fixtures/General/VariousTypesHandler.php | 4 +- .../Schema/SchemaGenerationTarget.php | 40 +++++++---- tests/Fixtures/Utils/AttributeFixtures.php | 22 ++++-- .../Fixtures/Utils/DockBlockParserFixture.php | 12 ++-- .../Fixtures/Utils/SchemaGeneratorFixture.php | 70 +++++++++++-------- tests/Integration/DiscoveryTest.php | 3 +- tests/Integration/HttpServerTransportTest.php | 11 +-- .../Integration/StdioServerTransportTest.php | 15 ++-- .../StreamableHttpServerTransportTest.php | 11 +-- tests/Unit/DispatcherTest.php | 14 ++-- .../Unit/Elements/RegisteredResourceTest.php | 22 +++--- tests/Unit/ProtocolTest.php | 13 ++-- tests/Unit/RegistryTest.php | 3 +- .../Unit/Session/CacheSessionHandlerTest.php | 1 - tests/Unit/Utils/HandlerResolverTest.php | 32 ++++++--- 35 files changed, 294 insertions(+), 170 deletions(-) diff --git a/examples/02-discovery-http-userprofile/server.php b/examples/02-discovery-http-userprofile/server.php index 4a9994e..8912623 100644 --- a/examples/02-discovery-http-userprofile/server.php +++ b/examples/02-discovery-http-userprofile/server.php @@ -67,15 +67,15 @@ public function log($level, \Stringable|string $message, array $context = []): v $server = Server::make() ->withServerInfo('HTTP User Profiles', '1.0.0') - ->withCapabilities(ServerCapabilities::make(completionsEnabled: true, loggingEnabled: true)) + ->withCapabilities(ServerCapabilities::make(completions: true, logging: true)) ->withLogger($logger) ->withContainer($container) ->build(); $server->discover(__DIR__, ['.']); - // $transport = new HttpServerTransport('127.0.0.1', 8080, 'mcp'); - $transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'); + $transport = new HttpServerTransport('127.0.0.1', 8080, 'mcp'); + // $transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'); $server->listen($transport); diff --git a/src/Attributes/CompletionProvider.php b/src/Attributes/CompletionProvider.php index fea330d..3bf5223 100644 --- a/src/Attributes/CompletionProvider.php +++ b/src/Attributes/CompletionProvider.php @@ -13,5 +13,7 @@ class CompletionProvider /** * @param class-string $providerClass FQCN of the completion provider class. */ - public function __construct(public string $providerClass) {} + public function __construct(public string $providerClass) + { + } } diff --git a/src/Attributes/Schema.php b/src/Attributes/Schema.php index d1cce8c..5573b79 100644 --- a/src/Attributes/Schema.php +++ b/src/Attributes/Schema.php @@ -152,27 +152,57 @@ public function toArray(): array } // String - if ($this->minLength !== null) $schema['minLength'] = $this->minLength; - if ($this->maxLength !== null) $schema['maxLength'] = $this->maxLength; - if ($this->pattern !== null) $schema['pattern'] = $this->pattern; + if ($this->minLength !== null) { + $schema['minLength'] = $this->minLength; + } + if ($this->maxLength !== null) { + $schema['maxLength'] = $this->maxLength; + } + if ($this->pattern !== null) { + $schema['pattern'] = $this->pattern; + } // Numeric - if ($this->minimum !== null) $schema['minimum'] = $this->minimum; - if ($this->maximum !== null) $schema['maximum'] = $this->maximum; - if ($this->exclusiveMinimum !== null) $schema['exclusiveMinimum'] = $this->exclusiveMinimum; - if ($this->exclusiveMaximum !== null) $schema['exclusiveMaximum'] = $this->exclusiveMaximum; - if ($this->multipleOf !== null) $schema['multipleOf'] = $this->multipleOf; + if ($this->minimum !== null) { + $schema['minimum'] = $this->minimum; + } + if ($this->maximum !== null) { + $schema['maximum'] = $this->maximum; + } + if ($this->exclusiveMinimum !== null) { + $schema['exclusiveMinimum'] = $this->exclusiveMinimum; + } + if ($this->exclusiveMaximum !== null) { + $schema['exclusiveMaximum'] = $this->exclusiveMaximum; + } + if ($this->multipleOf !== null) { + $schema['multipleOf'] = $this->multipleOf; + } // Array - if ($this->items !== null) $schema['items'] = $this->items; - if ($this->minItems !== null) $schema['minItems'] = $this->minItems; - if ($this->maxItems !== null) $schema['maxItems'] = $this->maxItems; - if ($this->uniqueItems !== null) $schema['uniqueItems'] = $this->uniqueItems; + if ($this->items !== null) { + $schema['items'] = $this->items; + } + if ($this->minItems !== null) { + $schema['minItems'] = $this->minItems; + } + if ($this->maxItems !== null) { + $schema['maxItems'] = $this->maxItems; + } + if ($this->uniqueItems !== null) { + $schema['uniqueItems'] = $this->uniqueItems; + } // Object - if ($this->properties !== null) $schema['properties'] = $this->properties; - if ($this->required !== null) $schema['required'] = $this->required; - if ($this->additionalProperties !== null) $schema['additionalProperties'] = $this->additionalProperties; + if ($this->properties !== null) { + $schema['properties'] = $this->properties; + } + if ($this->required !== null) { + $schema['required'] = $this->required; + } + if ($this->additionalProperties !== null) { + $schema['additionalProperties'] = $this->additionalProperties; + } return $schema; } diff --git a/src/Dispatcher.php b/src/Dispatcher.php index ca43bae..a5bab0f 100644 --- a/src/Dispatcher.php +++ b/src/Dispatcher.php @@ -342,7 +342,7 @@ public function handleCompletionComplete(CompletionCompleteRequest $request, Ses } $providerClass = $registeredPrompt->getCompletionProvider($argumentName); - } else if ($ref->type === 'ref/resource') { + } elseif ($ref->type === 'ref/resource') { $identifier = $ref->uri; $registeredResourceTemplate = $this->registry->getResourceTemplate($identifier); if (! $registeredResourceTemplate) { diff --git a/src/Elements/RegisteredElement.php b/src/Elements/RegisteredElement.php index 4ae2004..18a7846 100644 --- a/src/Elements/RegisteredElement.php +++ b/src/Elements/RegisteredElement.php @@ -21,7 +21,8 @@ public function __construct( public readonly string $handlerClass, public readonly string $handlerMethod, public readonly bool $isManual = false, - ) {} + ) { + } public function handle(ContainerInterface $container, array $arguments): mixed { @@ -117,7 +118,7 @@ private function castArgumentType(mixed $argument, ReflectionParameter $paramete return $case; } } - $validNames = array_map(fn($c) => $c->name, $typeName::cases()); + $validNames = array_map(fn ($c) => $c->name, $typeName::cases()); throw new InvalidArgumentException( "Invalid value '{$argument}' for unit enum {$typeName}. Expected one of: " . implode(', ', $validNames) . "." ); diff --git a/src/Elements/RegisteredResource.php b/src/Elements/RegisteredResource.php index 9911fac..b196e80 100644 --- a/src/Elements/RegisteredResource.php +++ b/src/Elements/RegisteredResource.php @@ -30,7 +30,7 @@ public static function make(Resource $schema, string $handlerClass, string $hand /** * Reads the resource content. - * + * * @return array Array of ResourceContents objects. */ public function read(ContainerInterface $container, string $uri): array @@ -99,7 +99,7 @@ protected function formatResult(mixed $readResult, string $uri, ?string $mimeTyp } if ($allAreEmbeddedResource && $hasEmbeddedResource) { - return array_map(fn($item) => $item->resource, $readResult); + return array_map(fn ($item) => $item->resource, $readResult); } if ($hasResourceContents || $hasEmbeddedResource) { diff --git a/src/Elements/RegisteredResourceTemplate.php b/src/Elements/RegisteredResourceTemplate.php index 1cebedd..50ebd4f 100644 --- a/src/Elements/RegisteredResourceTemplate.php +++ b/src/Elements/RegisteredResourceTemplate.php @@ -37,7 +37,7 @@ public static function make(ResourceTemplate $schema, string $handlerClass, stri /** * Gets the resource template. - * + * * @return array Array of ResourceContents objects. */ public function read(ContainerInterface $container, string $uri): array @@ -156,7 +156,7 @@ protected function formatResult(mixed $readResult, string $uri, ?string $mimeTyp } if ($allAreEmbeddedResource && $hasEmbeddedResource) { - return array_map(fn($item) => $item->resource, $readResult); + return array_map(fn ($item) => $item->resource, $readResult); } if ($hasResourceContents || $hasEmbeddedResource) { diff --git a/src/Registry.php b/src/Registry.php index 35d014a..d2826a5 100644 --- a/src/Registry.php +++ b/src/Registry.php @@ -438,24 +438,24 @@ public function getPrompt(string $name): ?RegisteredPrompt /** @return array */ public function getTools(): array { - return array_map(fn($tool) => $tool->schema, $this->tools); + return array_map(fn ($tool) => $tool->schema, $this->tools); } /** @return array */ public function getResources(): array { - return array_map(fn($resource) => $resource->schema, $this->resources); + return array_map(fn ($resource) => $resource->schema, $this->resources); } /** @return array */ public function getPrompts(): array { - return array_map(fn($prompt) => $prompt->schema, $this->prompts); + return array_map(fn ($prompt) => $prompt->schema, $this->prompts); } /** @return array */ public function getResourceTemplates(): array { - return array_map(fn($template) => $template->schema, $this->resourceTemplates); + return array_map(fn ($template) => $template->schema, $this->resourceTemplates); } } diff --git a/src/Server.php b/src/Server.php index 30a9026..9ef162d 100644 --- a/src/Server.php +++ b/src/Server.php @@ -41,7 +41,8 @@ public function __construct( protected readonly Registry $registry, protected readonly Protocol $protocol, protected readonly SessionManager $sessionManager, - ) {} + ) { + } public static function make(): ServerBuilder { diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index bdc9a91..d266dc9 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -88,7 +88,9 @@ final class ServerBuilder * > */ private array $manualPrompts = []; - public function __construct() {} + public function __construct() + { + } /** * Sets the server's identity. Required. diff --git a/src/Transports/StreamableHttpServerTransport.php b/src/Transports/StreamableHttpServerTransport.php index e932ffe..75eee9f 100644 --- a/src/Transports/StreamableHttpServerTransport.php +++ b/src/Transports/StreamableHttpServerTransport.php @@ -66,7 +66,7 @@ class StreamableHttpServerTransport implements ServerTransportInterface, LoggerA private ?ThroughStream $getStream = null; /** - * @param bool $enableJsonResponse If true, the server will return JSON responses instead of starting an SSE stream. + * @param bool $enableJsonResponse If true, the server will return JSON responses instead of starting an SSE stream. * This can be useful for simple request/response scenarios without streaming. */ public function __construct( @@ -171,9 +171,9 @@ private function createRequestHandler(): callable try { return match ($method) { - 'GET' => $this->handleGetRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), - 'POST' => $this->handlePostRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), - 'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn($e) => $addCors($this->handleRequestError($e, $request))), + 'GET' => $this->handleGetRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), + 'POST' => $this->handlePostRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), + 'DELETE' => $this->handleDeleteRequest($request)->then($addCors, fn ($e) => $addCors($this->handleRequestError($e, $request))), default => $addCors($this->handleUnsupportedRequest($request)), }; } catch (Throwable $e) { diff --git a/src/Utils/SchemaGenerator.php b/src/Utils/SchemaGenerator.php index e6d0c64..2153cc5 100644 --- a/src/Utils/SchemaGenerator.php +++ b/src/Utils/SchemaGenerator.php @@ -15,7 +15,7 @@ /** * Generates JSON Schema for method parameters with intelligent Schema attribute handling. - * + * * Priority system: * 1. Schema attributes (method-level and parameter-level) * 2. Reflection type information @@ -76,7 +76,7 @@ private function extractParameterLevelSchema(ReflectionParameter $parameter): ar /** * Builds the final schema from parameter information and method-level schema. - * + * * @param array * }> $parametersInfo - * + * * @param array|null $methodSchema - * + * * @return array */ private function buildSchemaFromParameters(array $parametersInfo, ?array $methodSchema): array @@ -148,7 +148,7 @@ private function buildSchemaFromParameters(array $parametersInfo, ?array $method /** * Builds the final schema for a single parameter by merging all three levels. - * + * * @param array{ * name: string, * doc_block_tag: Param|null, @@ -190,7 +190,7 @@ private function buildParameterSchema(array $paramInfo, ?array $methodLevelParam /** * Merge two schemas where the dominant schema takes precedence over the recessive one. - * + * * @param array $recessiveSchema The schema with lower precedence * @param array $dominantSchema The schema with higher precedence */ @@ -263,7 +263,7 @@ private function buildVariadicParameterSchema(array $paramInfo): array // If no items specified by Schema attribute, infer from type if (!isset($paramSchema['items'])) { $itemJsonTypes = $this->mapPhpTypeToJsonSchemaType($paramInfo['type_string']); - $nonNullItemTypes = array_filter($itemJsonTypes, fn($t) => $t !== 'null'); + $nonNullItemTypes = array_filter($itemJsonTypes, fn ($t) => $t !== 'null'); if (count($nonNullItemTypes) === 1) { $paramSchema['items'] = ['type' => $nonNullItemTypes[0]]; @@ -389,7 +389,7 @@ private function applyArrayConstraints(array $paramSchema, array $paramInfo): ar /** * Parses detailed information about a method's parameters. - * + * * @return arraygetTypeStringFromReflection($innerType, $innerType->allowsNull()); } if ($nativeAllowsNull) { - $types = array_filter($types, fn($t) => strtolower($t) !== 'null'); + $types = array_filter($types, fn ($t) => strtolower($t) !== 'null'); } $typeString = implode('|', array_unique(array_filter($types))); } elseif ($type instanceof ReflectionIntersectionType) { @@ -570,7 +570,7 @@ private function getTypeStringFromReflection(?ReflectionType $type, bool $native // Remove leading backslash from class names, but handle built-ins like 'int' or unions like 'int|string' if (str_contains($typeString, '\\')) { $parts = preg_split('/([|&])/', $typeString, -1, PREG_SPLIT_DELIM_CAPTURE); - $processedParts = array_map(fn($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts); + $processedParts = array_map(fn ($part) => str_starts_with($part, '\\') ? ltrim($part, '\\') : $part, $parts); $typeString = implode('', $processedParts); } diff --git a/src/Utils/SchemaValidator.php b/src/Utils/SchemaValidator.php index 06aec6d..bb7cad8 100644 --- a/src/Utils/SchemaValidator.php +++ b/src/Utils/SchemaValidator.php @@ -192,7 +192,7 @@ private function formatValidationError(ValidationError $error): string switch (strtolower($keyword)) { case 'required': $missing = $args['missing'] ?? []; - $formattedMissing = implode(', ', array_map(fn($p) => "`{$p}`", $missing)); + $formattedMissing = implode(', ', array_map(fn ($p) => "`{$p}`", $missing)); $message = "Missing required properties: {$formattedMissing}."; break; case 'type': @@ -286,7 +286,7 @@ private function formatValidationError(ValidationError $error): string break; case 'additionalProperties': // Corrected casing $unexpected = $args['properties'] ?? []; - $formattedUnexpected = implode(', ', array_map(fn($p) => "`{$p}`", $unexpected)); + $formattedUnexpected = implode(', ', array_map(fn ($p) => "`{$p}`", $unexpected)); $message = "Object contains unexpected additional properties: {$formattedUnexpected}."; break; case 'format': diff --git a/tests/Fixtures/Discovery/DiscoverableResourceHandler.php b/tests/Fixtures/Discovery/DiscoverableResourceHandler.php index 4d52dbd..8c8affa 100644 --- a/tests/Fixtures/Discovery/DiscoverableResourceHandler.php +++ b/tests/Fixtures/Discovery/DiscoverableResourceHandler.php @@ -36,5 +36,7 @@ public function getUiSettings(): array return ["theme" => "dark", "fontSize" => 14]; } - public function someOtherMethod(): void {} + public function someOtherMethod(): void + { + } } diff --git a/tests/Fixtures/Discovery/DiscoverableToolHandler.php b/tests/Fixtures/Discovery/DiscoverableToolHandler.php index 5cd7003..c449c05 100644 --- a/tests/Fixtures/Discovery/DiscoverableToolHandler.php +++ b/tests/Fixtures/Discovery/DiscoverableToolHandler.php @@ -41,11 +41,17 @@ public function internalHelperMethod(int $value): int } #[McpTool(name: "private_tool_should_be_ignored")] // On private method - private function aPrivateTool(): void {} + private function aPrivateTool(): void + { + } #[McpTool(name: "protected_tool_should_be_ignored")] // On protected method - protected function aProtectedTool(): void {} + protected function aProtectedTool(): void + { + } #[McpTool(name: "static_tool_should_be_ignored")] // On static method - public static function aStaticTool(): void {} + public static function aStaticTool(): void + { + } } diff --git a/tests/Fixtures/Discovery/NonDiscoverableClass.php b/tests/Fixtures/Discovery/NonDiscoverableClass.php index b71bce9..a0baa3e 100644 --- a/tests/Fixtures/Discovery/NonDiscoverableClass.php +++ b/tests/Fixtures/Discovery/NonDiscoverableClass.php @@ -12,11 +12,15 @@ public function someMethod(): string } } -interface MyDiscoverableInterface {} +interface MyDiscoverableInterface +{ +} trait MyDiscoverableTrait { - public function traitMethod() {} + public function traitMethod() + { + } } enum MyDiscoverableEnum diff --git a/tests/Fixtures/Discovery/SubDir/HiddenTool.php b/tests/Fixtures/Discovery/SubDir/HiddenTool.php index 6a3251a..b2f7095 100644 --- a/tests/Fixtures/Discovery/SubDir/HiddenTool.php +++ b/tests/Fixtures/Discovery/SubDir/HiddenTool.php @@ -9,5 +9,7 @@ class HiddenTool { #[McpTool(name: 'hidden_subdir_tool')] - public function run() {} + public function run() + { + } } diff --git a/tests/Fixtures/General/CompletionProviderFixture.php b/tests/Fixtures/General/CompletionProviderFixture.php index c2c5e21..1912df4 100644 --- a/tests/Fixtures/General/CompletionProviderFixture.php +++ b/tests/Fixtures/General/CompletionProviderFixture.php @@ -16,6 +16,6 @@ public function getCompletions(string $currentValue, SessionInterface $session): self::$lastCurrentValue = $currentValue; self::$lastSession = $session; - return array_filter(self::$completions, fn($item) => str_starts_with($item, $currentValue)); + return array_filter(self::$completions, fn ($item) => str_starts_with($item, $currentValue)); } } diff --git a/tests/Fixtures/General/DocBlockTestFixture.php b/tests/Fixtures/General/DocBlockTestFixture.php index cc4d66b..5245bbc 100644 --- a/tests/Fixtures/General/DocBlockTestFixture.php +++ b/tests/Fixtures/General/DocBlockTestFixture.php @@ -10,7 +10,9 @@ class DocBlockTestFixture /** * Simple summary line. */ - public function methodWithSummaryOnly(): void {} + public function methodWithSummaryOnly(): void + { + } /** * Summary line here. @@ -21,7 +23,9 @@ public function methodWithSummaryOnly(): void {} * * @since 1.0 */ - public function methodWithSummaryAndDescription(): void {} + public function methodWithSummaryAndDescription(): void + { + } /** * Method with various parameter tags. @@ -33,7 +37,9 @@ public function methodWithSummaryAndDescription(): void {} * @param array $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. @@ -62,10 +68,16 @@ public function methodWithMultipleTags(float $value): bool /** * 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/Fixtures/General/ToolHandlerFixture.php b/tests/Fixtures/General/ToolHandlerFixture.php index b7afee8..af3a9cc 100644 --- a/tests/Fixtures/General/ToolHandlerFixture.php +++ b/tests/Fixtures/General/ToolHandlerFixture.php @@ -10,8 +10,9 @@ class ToolHandlerFixture { - - public function __construct() {} + public function __construct() + { + } public function greet(string $name): string { diff --git a/tests/Fixtures/General/VariousTypesHandler.php b/tests/Fixtures/General/VariousTypesHandler.php index 9071ce3..999dda9 100644 --- a/tests/Fixtures/General/VariousTypesHandler.php +++ b/tests/Fixtures/General/VariousTypesHandler.php @@ -139,5 +139,7 @@ public function comprehensiveArgumentTest( ); } - public function methodCausesTypeError(int $mustBeInt): void {} + public function methodCausesTypeError(int $mustBeInt): void + { + } } diff --git a/tests/Fixtures/Schema/SchemaGenerationTarget.php b/tests/Fixtures/Schema/SchemaGenerationTarget.php index 734f4e9..e23c232 100644 --- a/tests/Fixtures/Schema/SchemaGenerationTarget.php +++ b/tests/Fixtures/Schema/SchemaGenerationTarget.php @@ -13,7 +13,9 @@ class SchemaGenerationTarget { - public function noParamsMethod(): void {} + public function noParamsMethod(): void + { + } /** * Method with simple required types. @@ -24,7 +26,9 @@ public function noParamsMethod(): void {} * @param array $pArray Array param * @param stdClass $pObject Object param */ - public function simpleRequiredTypes(string $pString, int $pInt, bool $pBool, float $pFloat, array $pArray, stdClass $pObject): void {} + public function simpleRequiredTypes(string $pString, int $pInt, bool $pBool, float $pFloat, array $pArray, stdClass $pObject): void + { + } /** * Method with simple optional types with default values. @@ -42,21 +46,26 @@ public function optionalTypesWithDefaults( ?float $pFloatOptNullable = 1.23, array $pArrayOpt = ['a', 'b'], ?stdClass $pObjectOptNullable = null - ): void {} + ): void { + } /** * Nullable types without explicit defaults. * @param ?string $pNullableString Nullable string * @param int|null $pUnionNullableInt Union nullable int */ - public function nullableTypes(?string $pNullableString, ?int $pUnionNullableInt, ?BackedStringEnum $pNullableEnum): void {} + public function nullableTypes(?string $pNullableString, ?int $pUnionNullableInt, ?BackedStringEnum $pNullableEnum): void + { + } /** * Union types. * @param string|int $pStringOrInt String or Int * @param bool|float|null $pBoolOrFloatOrNull Bool, Float or Null */ - public function unionTypes(string|int $pStringOrInt, $pBoolOrFloatOrNull): void {} // PHP 7.x style union in docblock usually + public function unionTypes(string|int $pStringOrInt, $pBoolOrFloatOrNull): void + { + } // PHP 7.x style union in docblock usually /** * Various array type hints. @@ -74,7 +83,8 @@ public function arrayTypes( array $pEnumArray, array $pShapeArray, array $pArrayOfShapes - ): void {} + ): void { + } /** * Enum types. @@ -82,19 +92,25 @@ public function arrayTypes( * @param BackedIntEnum $pBackedIntEnum Backed int enum * @param UnitEnum $pUnitEnum Unit enum */ - public function enumTypes(BackedStringEnum $pBackedStringEnum, BackedIntEnum $pBackedIntEnum, UnitEnum $pUnitEnum): void {} + public function enumTypes(BackedStringEnum $pBackedStringEnum, BackedIntEnum $pBackedIntEnum, UnitEnum $pUnitEnum): void + { + } /** * Variadic parameters. * @param string ...$pVariadicStrings Variadic strings */ - public function variadicParams(string ...$pVariadicStrings): void {} + public function variadicParams(string ...$pVariadicStrings): void + { + } /** * Mixed type. * @param mixed $pMixed Mixed type */ - public function mixedType(mixed $pMixed): void {} + public function mixedType(mixed $pMixed): void + { + } /** * With #[Schema] attributes for enhanced validation. @@ -106,13 +122,10 @@ public function mixedType(mixed $pMixed): void {} public function withSchemaAttributes( #[Schema(format: Format::EMAIL)] string $email, - #[Schema(minimum: 1, maximum: 100, multipleOf: 5)] int $quantity, - #[Schema(minItems: 1, maxItems: 5, uniqueItems: true, items: new ArrayItems(minLength: 3))] array $tags, - #[Schema( properties: [ new Property(name: 'id', minimum: 1), @@ -122,5 +135,6 @@ public function withSchemaAttributes( additionalProperties: false )] array $userProfile - ): void {} + ): void { + } } diff --git a/tests/Fixtures/Utils/AttributeFixtures.php b/tests/Fixtures/Utils/AttributeFixtures.php index 39b6504..c22994f 100644 --- a/tests/Fixtures/Utils/AttributeFixtures.php +++ b/tests/Fixtures/Utils/AttributeFixtures.php @@ -5,17 +5,23 @@ #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_PARAMETER)] class TestAttributeOne { - public function __construct(public string $value) {} + public function __construct(public string $value) + { + } } #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER)] class TestAttributeTwo { - public function __construct(public int $number) {} + public function __construct(public int $number) + { + } } #[\Attribute(\Attribute::TARGET_CLASS)] -class TestClassOnlyAttribute {} +class TestClassOnlyAttribute +{ +} // --- Test Class --- @@ -32,15 +38,19 @@ public function methodOne( #[TestAttributeOne(value: 'param-one')] #[TestAttributeTwo(number: 1)] string $param1 - ): void {} + ): void { + } #[TestAttributeOne(value: 'method-two')] #[TestAttributeTwo(number: 2)] public function methodTwo( #[TestAttributeTwo(number: 3)] int $paramA - ): void {} + ): void { + } // Method with no attributes - public function methodThree(string $unattributedParam): void {} + public function methodThree(string $unattributedParam): void + { + } } diff --git a/tests/Fixtures/Utils/DockBlockParserFixture.php b/tests/Fixtures/Utils/DockBlockParserFixture.php index f8512fa..82f0a29 100644 --- a/tests/Fixtures/Utils/DockBlockParserFixture.php +++ b/tests/Fixtures/Utils/DockBlockParserFixture.php @@ -24,7 +24,8 @@ public function simpleArraySyntax( array $floats, array $objects, array $dateTimeInstances - ): void {} + ): void { + } /** * Method with array generic syntax @@ -43,7 +44,8 @@ public function genericArraySyntax( array $floats, array $objects, array $dateTimeInstances - ): void {} + ): void { + } /** * Method with nested array syntax @@ -58,7 +60,8 @@ public function nestedArraySyntax( array $nestedIntArrays, array $doubleStringArrays, array $doubleIntArrays - ): void {} + ): void { + } /** * Method with object-like array syntax @@ -71,5 +74,6 @@ public function objectArraySyntax( array $person, array $article, array $order - ): void {} + ): void { + } } diff --git a/tests/Fixtures/Utils/SchemaGeneratorFixture.php b/tests/Fixtures/Utils/SchemaGeneratorFixture.php index 50544d4..98b43b9 100644 --- a/tests/Fixtures/Utils/SchemaGeneratorFixture.php +++ b/tests/Fixtures/Utils/SchemaGeneratorFixture.php @@ -15,12 +15,16 @@ class SchemaGeneratorFixture { // ===== BASIC SCENARIOS ===== - public function noParams(): void {} + public function noParams(): void + { + } /** * Type hints only - no Schema attributes. */ - public function typeHintsOnly(string $name, int $age, bool $active, array $tags, ?stdClass $config = null): void {} + public function typeHintsOnly(string $name, int $age, bool $active, array $tags, ?stdClass $config = null): void + { + } /** * DocBlock types only - no PHP type hints, no Schema attributes. @@ -29,7 +33,9 @@ public function typeHintsOnly(string $name, int $age, bool $active, array $tags, * @param bool $enabled Whether enabled * @param array $data Some data */ - public function docBlockOnly($username, $count, $enabled, $data): void {} + public function docBlockOnly($username, $count, $enabled, $data): void + { + } /** * Type hints with DocBlock descriptions. @@ -37,7 +43,9 @@ public function docBlockOnly($username, $count, $enabled, $data): void {} * @param int $score User score * @param bool $verified Whether user is verified */ - public function typeHintsWithDocBlock(string $email, int $score, bool $verified): void {} + public function typeHintsWithDocBlock(string $email, int $score, bool $verified): void + { + } // ===== METHOD-LEVEL SCHEMA SCENARIOS ===== @@ -117,13 +125,10 @@ public function methodLevelArrayArgument(array $profiles): array public function parameterLevelOnly( #[Schema(description: "Recipient ID", pattern: "^user_")] string $recipientId, - #[Schema(maxLength: 1024)] string $messageBody, - #[Schema(type: 'integer', enum: [1, 2, 5])] int $priority = 1, - #[Schema( type: 'object', properties: [ @@ -143,12 +148,11 @@ public function parameterLevelOnly( public function parameterStringConstraints( #[Schema(format: 'email')] string $email, - #[Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$')] string $password, - string $regularString - ): void {} + ): void { + } /** * Parameter-level Schema with numeric constraints. @@ -156,13 +160,12 @@ public function parameterStringConstraints( public function parameterNumericConstraints( #[Schema(minimum: 18, maximum: 120)] int $age, - #[Schema(minimum: 0, maximum: 5, exclusiveMaximum: true)] float $rating, - #[Schema(multipleOf: 10)] int $count - ): void {} + ): void { + } /** * Parameter-level Schema with array constraints. @@ -170,10 +173,10 @@ public function parameterNumericConstraints( public function parameterArrayConstraints( #[Schema(type: 'array', items: ['type' => 'string'], minItems: 1, uniqueItems: true)] array $tags, - #[Schema(type: 'array', items: ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], minItems: 1, maxItems: 5)] array $scores - ): void {} + ): void { + } // ===== COMBINED SCENARIOS ===== @@ -191,7 +194,6 @@ public function parameterArrayConstraints( )] public function methodAndParameterLevel( string $settingKey, - #[Schema(description: "The specific new boolean value.", type: 'boolean')] mixed $newValue ): array { @@ -206,10 +208,10 @@ public function methodAndParameterLevel( public function typeHintDocBlockAndParameterSchema( #[Schema(minLength: 3, pattern: '^[a-zA-Z0-9_]+$')] string $username, - #[Schema(minimum: 1, maximum: 10)] int $priority - ): void {} + ): void { + } // ===== ENUM SCENARIOS ===== @@ -225,7 +227,8 @@ public function enumParameters( UnitEnum $unitEnum, ?BackedStringEnum $nullableEnum = null, BackedIntEnum $enumWithDefault = BackedIntEnum::First - ): void {} + ): void { + } // ===== ARRAY TYPE SCENARIOS ===== @@ -245,14 +248,15 @@ public function arrayTypeScenarios( array $mixedMap, array $objectLikeArray, array $nestedObjectArray - ): void {} + ): void { + } // ===== NULLABLE AND OPTIONAL SCENARIOS ===== /** * Nullable and optional parameter scenarios. * @param string|null $nullableString Nullable string - * @param int|null $nullableInt Nullable integer + * @param int|null $nullableInt Nullable integer */ public function nullableAndOptional( ?string $nullableString, @@ -260,7 +264,8 @@ public function nullableAndOptional( string $optionalString = 'default', bool $optionalBool = true, array $optionalArray = [] - ): void {} + ): void { + } // ===== UNION TYPE SCENARIOS ===== @@ -272,7 +277,8 @@ public function nullableAndOptional( public function unionTypes( string|int $stringOrInt, bool|string|null $multiUnion - ): void {} + ): void { + } // ===== VARIADIC SCENARIOS ===== @@ -280,7 +286,9 @@ public function unionTypes( * Variadic parameter scenarios. * @param string ...$items Variadic strings */ - public function variadicStrings(string ...$items): void {} + public function variadicStrings(string ...$items): void + { + } /** * Variadic with Schema constraints. @@ -289,7 +297,8 @@ public function variadicStrings(string ...$items): void {} public function variadicWithConstraints( #[Schema(items: ['type' => 'integer', 'minimum' => 0])] int ...$numbers - ): void {} + ): void { + } // ===== MIXED TYPE SCENARIOS ===== @@ -301,7 +310,8 @@ public function variadicWithConstraints( public function mixedTypes( mixed $anyValue, mixed $optionalAny = 'default' - ): void {} + ): void { + } // ===== COMPLEX NESTED SCENARIOS ===== @@ -356,13 +366,12 @@ public function complexNestedSchema( */ public function typePrecedenceTest( string $numericString, - #[Schema(format: 'email', minLength: 5)] string $stringWithConstraints, - #[Schema(items: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100])] array $arrayWithItems - ): void {} + ): void { + } // ===== ERROR EDGE CASES ===== @@ -381,5 +390,6 @@ public function noParamsWithSchema(): array public function parameterSchemaInferredType( #[Schema(description: "Some parameter", minLength: 3)] $inferredParam - ): void {} + ): void { + } } diff --git a/tests/Integration/DiscoveryTest.php b/tests/Integration/DiscoveryTest.php index 48730eb..ec7b54a 100644 --- a/tests/Integration/DiscoveryTest.php +++ b/tests/Integration/DiscoveryTest.php @@ -11,7 +11,6 @@ use PhpMcp\Server\Tests\Fixtures\General\CompletionProviderFixture; use Psr\Log\NullLogger; - beforeEach(function () { $logger = new NullLogger(); $this->registry = new Registry($logger); @@ -143,4 +142,4 @@ $invokableCalc = $this->registry->getTool('InvokableCalculator'); // Name comes from Attr expect($invokableCalc->schema->name)->toBe('InvokableCalculator'); expect($invokableCalc->schema->description)->toBe('An invokable calculator tool.'); -}); \ No newline at end of file +}); diff --git a/tests/Integration/HttpServerTransportTest.php b/tests/Integration/HttpServerTransportTest.php index b46d864..dab32bd 100644 --- a/tests/Integration/HttpServerTransportTest.php +++ b/tests/Integration/HttpServerTransportTest.php @@ -12,7 +12,6 @@ use function React\Async\await; - const HTTP_SERVER_SCRIPT_PATH = __DIR__ . '/../Fixtures/ServerScripts/HttpTestServer.php'; const HTTP_PROCESS_TIMEOUT_SECONDS = 8; const HTTP_SERVER_HOST = '127.0.0.1'; @@ -58,8 +57,12 @@ } if ($this->process instanceof Process && $this->process->isRunning()) { - if ($this->process->stdout instanceof ReadableStreamInterface) $this->process->stdout->close(); - if ($this->process->stderr instanceof ReadableStreamInterface) $this->process->stderr->close(); + if ($this->process->stdout instanceof ReadableStreamInterface) { + $this->process->stdout->close(); + } + if ($this->process->stderr instanceof ReadableStreamInterface) { + $this->process->stderr->close(); + } $this->process->terminate(SIGTERM); try { @@ -413,4 +416,4 @@ } catch (\Throwable $e) { $this->fail("Request to unknown path failed with unexpected error: " . $e->getMessage()); } -})->group('integration', 'http_transport'); \ No newline at end of file +})->group('integration', 'http_transport'); diff --git a/tests/Integration/StdioServerTransportTest.php b/tests/Integration/StdioServerTransportTest.php index edd310e..4740ef4 100644 --- a/tests/Integration/StdioServerTransportTest.php +++ b/tests/Integration/StdioServerTransportTest.php @@ -10,7 +10,6 @@ use function React\Async\await; - const STDIO_SERVER_SCRIPT_PATH = __DIR__ . '/../Fixtures/ServerScripts/StdioTestServer.php'; const PROCESS_TIMEOUT_SECONDS = 5; @@ -48,14 +47,16 @@ function readResponseFromServer(Process $process, string $expectedRequestId, Loo $buffer = array_pop($lines); foreach ($lines as $line) { - if (empty(trim($line))) continue; + if (empty(trim($line))) { + continue; + } try { $response = json_decode(trim($line), true); if (array_key_exists('id', $response) && $response['id'] == $expectedRequestId) { $process->stdout->removeListener('data', $dataListener); $deferred->resolve($response); return; - } else if (isset($response['method']) && str_starts_with($response['method'], 'notifications/')) { + } elseif (isset($response['method']) && str_starts_with($response['method'], 'notifications/')) { // It's a notification, log it or handle if necessary for a specific test, but don't resolve } } catch (\JsonException $e) { @@ -227,9 +228,9 @@ function readResponseFromServer(Process $process, string $expectedRequestId, Loo expect($batchResponseArray)->toBeArray()->toHaveCount(3); // greet1, greet2, error - $response1 = array_values(array_filter($batchResponseArray, fn($response) => $response['id'] === 'batch-req-1'))[0] ?? null; - $response2 = array_values(array_filter($batchResponseArray, fn($response) => $response['id'] === 'batch-req-2'))[0] ?? null; - $response3 = array_values(array_filter($batchResponseArray, fn($response) => $response['id'] === 'batch-req-3'))[0] ?? null; + $response1 = array_values(array_filter($batchResponseArray, fn ($response) => $response['id'] === 'batch-req-1'))[0] ?? null; + $response2 = array_values(array_filter($batchResponseArray, fn ($response) => $response['id'] === 'batch-req-2'))[0] ?? null; + $response3 = array_values(array_filter($batchResponseArray, fn ($response) => $response['id'] === 'batch-req-3'))[0] ?? null; expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!'); expect($response2['result']['content'][0]['text'])->toBe('Hello, Batch Item 2!'); @@ -318,4 +319,4 @@ function readResponseFromServer(Process $process, string $expectedRequestId, Loo expect($toolResponse['error']['message'])->toContain('Client session not initialized'); $this->process->stdin->end(); -})->group('integration', 'stdio_transport'); \ No newline at end of file +})->group('integration', 'stdio_transport'); diff --git a/tests/Integration/StreamableHttpServerTransportTest.php b/tests/Integration/StreamableHttpServerTransportTest.php index 3590081..90e296b 100644 --- a/tests/Integration/StreamableHttpServerTransportTest.php +++ b/tests/Integration/StreamableHttpServerTransportTest.php @@ -11,7 +11,6 @@ use function React\Async\await; use function React\Promise\resolve; - const STREAMABLE_HTTP_SCRIPT_PATH = __DIR__ . '/../Fixtures/ServerScripts/StreamableHttpTestServer.php'; const STREAMABLE_HTTP_PROCESS_TIMEOUT = 9; const STREAMABLE_HTTP_HOST = '127.0.0.1'; @@ -51,8 +50,12 @@ afterEach(function () { if ($this->process instanceof Process && $this->process->isRunning()) { - if ($this->process->stdout instanceof ReadableStreamInterface) $this->process->stdout->close(); - if ($this->process->stderr instanceof ReadableStreamInterface) $this->process->stderr->close(); + if ($this->process->stdout instanceof ReadableStreamInterface) { + $this->process->stdout->close(); + } + if ($this->process->stderr instanceof ReadableStreamInterface) { + $this->process->stderr->close(); + } $this->process->terminate(SIGTERM); try { @@ -567,4 +570,4 @@ expect($decodedBody['error']['code'])->toBe(-32600); expect($decodedBody['error']['message'])->toContain('Invalid or expired session'); } -})->group('integration', 'streamable_http_json'); \ No newline at end of file +})->group('integration', 'streamable_http_json'); diff --git a/tests/Unit/DispatcherTest.php b/tests/Unit/DispatcherTest.php index 43b7a32..2c1946c 100644 --- a/tests/Unit/DispatcherTest.php +++ b/tests/Unit/DispatcherTest.php @@ -100,7 +100,7 @@ 'capabilities' => [], ] ); - $this->session->shouldReceive('set')->with('client_info', Mockery::on(fn($value) => $value->name === 'client' && $value->version === '1.0'))->once(); + $this->session->shouldReceive('set')->with('client_info', Mockery::on(fn ($value) => $value->name === 'client' && $value->version === '1.0'))->once(); $result = $this->dispatcher->handleRequest($request, $this->session); expect($result)->toBeInstanceOf(InitializeResult::class); @@ -256,14 +256,14 @@ $requestP1 = ListResourcesRequest::make(1); $resultP1 = $this->dispatcher->handleResourcesList($requestP1); expect($resultP1->resources)->toHaveCount(DISPATCHER_PAGINATION_LIMIT); - expect(array_map(fn($r) => $r->name, $resultP1->resources))->toEqual(['Resource1', 'Resource2', 'Resource3']); + expect(array_map(fn ($r) => $r->name, $resultP1->resources))->toEqual(['Resource1', 'Resource2', 'Resource3']); expect($resultP1->nextCursor)->toBe(base64_encode('offset=3')); // Page 2 $requestP2 = ListResourcesRequest::make(2, $resultP1->nextCursor); $resultP2 = $this->dispatcher->handleResourcesList($requestP2); expect($resultP2->resources)->toHaveCount(2); - expect(array_map(fn($r) => $r->name, $resultP2->resources))->toEqual(['Resource4', 'Resource5']); + expect(array_map(fn ($r) => $r->name, $resultP2->resources))->toEqual(['Resource4', 'Resource5']); expect($resultP2->nextCursor)->toBeNull(); }); @@ -288,14 +288,14 @@ $requestP1 = ListResourceTemplatesRequest::make(1); $resultP1 = $this->dispatcher->handleResourceTemplateList($requestP1); expect($resultP1->resourceTemplates)->toHaveCount(DISPATCHER_PAGINATION_LIMIT); - expect(array_map(fn($rt) => $rt->name, $resultP1->resourceTemplates))->toEqual(['Template1', 'Template2', 'Template3']); + expect(array_map(fn ($rt) => $rt->name, $resultP1->resourceTemplates))->toEqual(['Template1', 'Template2', 'Template3']); expect($resultP1->nextCursor)->toBe(base64_encode('offset=3')); // Page 2 $requestP2 = ListResourceTemplatesRequest::make(2, $resultP1->nextCursor); $resultP2 = $this->dispatcher->handleResourceTemplateList($requestP2); expect($resultP2->resourceTemplates)->toHaveCount(1); - expect(array_map(fn($rt) => $rt->name, $resultP2->resourceTemplates))->toEqual(['Template4']); + expect(array_map(fn ($rt) => $rt->name, $resultP2->resourceTemplates))->toEqual(['Template4']); expect($resultP2->nextCursor)->toBeNull(); }); @@ -345,14 +345,14 @@ $requestP1 = ListPromptsRequest::make(1); $resultP1 = $this->dispatcher->handlePromptsList($requestP1); expect($resultP1->prompts)->toHaveCount(DISPATCHER_PAGINATION_LIMIT); - expect(array_map(fn($p) => $p->name, $resultP1->prompts))->toEqual(['promptA', 'promptB', 'promptC']); + expect(array_map(fn ($p) => $p->name, $resultP1->prompts))->toEqual(['promptA', 'promptB', 'promptC']); expect($resultP1->nextCursor)->toBe(base64_encode('offset=3')); // Page 2 $requestP2 = ListPromptsRequest::make(2, $resultP1->nextCursor); $resultP2 = $this->dispatcher->handlePromptsList($requestP2); expect($resultP2->prompts)->toHaveCount(DISPATCHER_PAGINATION_LIMIT); // 3 more - expect(array_map(fn($p) => $p->name, $resultP2->prompts))->toEqual(['promptD', 'promptE', 'promptF']); + expect(array_map(fn ($p) => $p->name, $resultP2->prompts))->toEqual(['promptD', 'promptE', 'promptF']); expect($resultP2->nextCursor)->toBeNull(); // End of list }); diff --git a/tests/Unit/Elements/RegisteredResourceTest.php b/tests/Unit/Elements/RegisteredResourceTest.php index 4349a7b..5a72143 100644 --- a/tests/Unit/Elements/RegisteredResourceTest.php +++ b/tests/Unit/Elements/RegisteredResourceTest.php @@ -85,17 +85,17 @@ dataset('resource_handler_return_types', [ - 'string_text' => ['returnStringText', 'text/plain', fn($text, $uri) => expect($text)->toBe("Plain string content for {$uri}"), null], - 'string_json_guess' => ['returnStringJson', 'application/json', fn($text, $uri) => expect(json_decode($text, true)['uri_in_json'])->toBe($uri), null], - 'string_html_guess' => ['returnStringHtml', 'text/html', fn($text, $uri) => expect($text)->toContain("{$uri}"), null], - 'array_json_schema_mime' => ['returnArrayJson', 'application/json', fn($text, $uri) => expect(json_decode($text, true)['uri_in_array'])->toBe($uri), null], // schema has text/plain, overridden by array + JSON content - 'empty_array' => ['returnEmptyArray', 'application/json', fn($text) => expect($text)->toBe('[]'), null], - 'stream_octet' => ['returnStream', 'application/octet-stream', null, fn($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Streamed content for {$uri}")], - 'array_for_blob' => ['returnArrayForBlobSchema', 'application/x-custom-blob-array', null, fn($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Blob for {$uri} via array")], - 'array_for_text' => ['returnArrayForTextSchema', 'text/vnd.custom-array-text', fn($text, $uri) => expect($text)->toBe("Text from array for {$uri} via array"), null], - 'direct_TextResourceContents' => ['returnTextResourceContents', 'text/special-contents', fn($text) => expect($text)->toBe('Direct TextResourceContents'), null], - 'direct_BlobResourceContents' => ['returnBlobResourceContents', 'application/custom-blob-contents', null, fn($blob) => expect(base64_decode($blob ?? ''))->toBe('blobbycontents')], - 'direct_EmbeddedResource' => ['returnEmbeddedResource', 'application/vnd.custom-embedded', fn($text) => expect($text)->toBe('Direct EmbeddedResource content'), null], + 'string_text' => ['returnStringText', 'text/plain', fn ($text, $uri) => expect($text)->toBe("Plain string content for {$uri}"), null], + 'string_json_guess' => ['returnStringJson', 'application/json', fn ($text, $uri) => expect(json_decode($text, true)['uri_in_json'])->toBe($uri), null], + 'string_html_guess' => ['returnStringHtml', 'text/html', fn ($text, $uri) => expect($text)->toContain("{$uri}"), null], + 'array_json_schema_mime' => ['returnArrayJson', 'application/json', fn ($text, $uri) => expect(json_decode($text, true)['uri_in_array'])->toBe($uri), null], // schema has text/plain, overridden by array + JSON content + 'empty_array' => ['returnEmptyArray', 'application/json', fn ($text) => expect($text)->toBe('[]'), null], + 'stream_octet' => ['returnStream', 'application/octet-stream', null, fn ($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Streamed content for {$uri}")], + 'array_for_blob' => ['returnArrayForBlobSchema', 'application/x-custom-blob-array', null, fn ($blob, $uri) => expect(base64_decode($blob ?? ''))->toBe("Blob for {$uri} via array")], + 'array_for_text' => ['returnArrayForTextSchema', 'text/vnd.custom-array-text', fn ($text, $uri) => expect($text)->toBe("Text from array for {$uri} via array"), null], + 'direct_TextResourceContents' => ['returnTextResourceContents', 'text/special-contents', fn ($text) => expect($text)->toBe('Direct TextResourceContents'), null], + 'direct_BlobResourceContents' => ['returnBlobResourceContents', 'application/custom-blob-contents', null, fn ($blob) => expect(base64_decode($blob ?? ''))->toBe('blobbycontents')], + 'direct_EmbeddedResource' => ['returnEmbeddedResource', 'application/vnd.custom-embedded', fn ($text) => expect($text)->toBe('Direct EmbeddedResource content'), null], ]); it('formats various handler return types correctly', function (string $handlerMethod, string $expectedMime, ?callable $textAssertion, ?callable $blobAssertion) { diff --git a/tests/Unit/ProtocolTest.php b/tests/Unit/ProtocolTest.php index 3661928..138b37c 100644 --- a/tests/Unit/ProtocolTest.php +++ b/tests/Unit/ProtocolTest.php @@ -29,6 +29,7 @@ use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use React\EventLoop\LoopInterface; + use function React\Async\await; use function React\Promise\resolve; use function React\Promise\reject; @@ -170,11 +171,11 @@ function expectSuccessResponse(mixed $response, mixed $expectedResult, string|in $expectedResponse = Response::make($request->id, $result); $this->dispatcher->shouldReceive('handleRequest')->once() - ->with(Mockery::on(fn($arg) => $arg instanceof Request && $arg->method === 'test/method'), $this->session) + ->with(Mockery::on(fn ($arg) => $arg instanceof Request && $arg->method === 'test/method'), $this->session) ->andReturn($result); $this->transport->shouldReceive('sendMessage')->once() - ->with(Mockery::on(fn($arg) => $arg instanceof Response && $arg->id === $request->id && $arg->result === $result), SESSION_ID, Mockery::any()) + ->with(Mockery::on(fn ($arg) => $arg instanceof Response && $arg->id === $request->id && $arg->result === $result), SESSION_ID, Mockery::any()) ->andReturn(resolve(null)); $this->protocol->processMessage($request, SESSION_ID); @@ -185,7 +186,7 @@ function expectSuccessResponse(mixed $response, mixed $expectedResult, string|in $notification = createNotification('test/notify', ['data' => 'info']); $this->dispatcher->shouldReceive('handleNotification')->once() - ->with(Mockery::on(fn($arg) => $arg instanceof Notification && $arg->method === 'test/notify'), $this->session) + ->with(Mockery::on(fn ($arg) => $arg instanceof Notification && $arg->method === 'test/notify'), $this->session) ->andReturnNull(); $this->transport->shouldNotReceive('sendMessage'); @@ -203,9 +204,9 @@ function expectSuccessResponse(mixed $response, mixed $expectedResult, string|in $result1 = new EmptyResult(); $result2 = new EmptyResult(); - $this->dispatcher->shouldReceive('handleRequest')->once()->with(Mockery::on(fn(Request $r) => $r->id === 'batch-id-1'), $this->session)->andReturn($result1); - $this->dispatcher->shouldReceive('handleNotification')->once()->with(Mockery::on(fn(Notification $n) => $n->method === 'notif/1'), $this->session); - $this->dispatcher->shouldReceive('handleRequest')->once()->with(Mockery::on(fn(Request $r) => $r->id === 'batch-id-2'), $this->session)->andReturn($result2); + $this->dispatcher->shouldReceive('handleRequest')->once()->with(Mockery::on(fn (Request $r) => $r->id === 'batch-id-1'), $this->session)->andReturn($result1); + $this->dispatcher->shouldReceive('handleNotification')->once()->with(Mockery::on(fn (Notification $n) => $n->method === 'notif/1'), $this->session); + $this->dispatcher->shouldReceive('handleRequest')->once()->with(Mockery::on(fn (Request $r) => $r->id === 'batch-id-2'), $this->session)->andReturn($result2); $this->transport->shouldReceive('sendMessage')->once() diff --git a/tests/Unit/RegistryTest.php b/tests/Unit/RegistryTest.php index 9e9a10e..8be6ee6 100644 --- a/tests/Unit/RegistryTest.php +++ b/tests/Unit/RegistryTest.php @@ -17,7 +17,6 @@ use Psr\SimpleCache\CacheInterface; use Psr\SimpleCache\InvalidArgumentException as CacheInvalidArgumentException; - const DISCOVERED_CACHE_KEY_REG = 'mcp_server_discovered_elements'; function createTestToolSchema(string $name = 'test-tool'): Tool @@ -364,7 +363,7 @@ function getRegistryProperty(Registry $reg, string $propName) }); it('handles cache InvalidArgumentException during load gracefully', function () { - $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new class extends \Exception implements CacheInvalidArgumentException {}); + $this->cache->shouldReceive('get')->with(DISCOVERED_CACHE_KEY_REG)->once()->andThrow(new class () extends \Exception implements CacheInvalidArgumentException {}); $registry = new Registry($this->logger, $this->cache); expect($registry->hasElements())->toBeFalse(); diff --git a/tests/Unit/Session/CacheSessionHandlerTest.php b/tests/Unit/Session/CacheSessionHandlerTest.php index eac0719..e5a9507 100644 --- a/tests/Unit/Session/CacheSessionHandlerTest.php +++ b/tests/Unit/Session/CacheSessionHandlerTest.php @@ -9,7 +9,6 @@ use Psr\SimpleCache\CacheInterface; use PhpMcp\Server\Tests\Mocks\Clock\FixedClock; - const SESSION_ID_CACHE_1 = 'cache-session-id-1'; const SESSION_ID_CACHE_2 = 'cache-session-id-2'; const SESSION_ID_CACHE_3 = 'cache-session-id-3'; diff --git a/tests/Unit/Utils/HandlerResolverTest.php b/tests/Unit/Utils/HandlerResolverTest.php index a1c20d3..c0b574c 100644 --- a/tests/Unit/Utils/HandlerResolverTest.php +++ b/tests/Unit/Utils/HandlerResolverTest.php @@ -8,20 +8,36 @@ class ValidHandlerClass { - public function publicMethod() {} - protected function protectedMethod() {} - private function privateMethod() {} - public static function staticMethod() {} - public function __construct() {} - public function __destruct() {} + public function publicMethod() + { + } + protected function protectedMethod() + { + } + private function privateMethod() + { + } + public static function staticMethod() + { + } + public function __construct() + { + } + public function __destruct() + { + } } class ValidInvokableClass { - public function __invoke() {} + public function __invoke() + { + } } -class NonInvokableClass {} +class NonInvokableClass +{ +} abstract class AbstractHandlerClass { From 20b630cf8dc9020722be6fc57bee29eeeb4f93ec Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Thu, 19 Jun 2025 20:31:03 +0100 Subject: [PATCH 23/27] chore: Remove pest-plugin-drift dependency and adjust max-parallel setting in CI workflow --- .github/workflows/tests.yml | 2 +- composer.json | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b1cce97..f829a0b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: fail-fast: false matrix: php: [8.1, 8.2, 8.3, 8.4] - max-parallel: 4 + max-parallel: 2 name: Tests PHP${{ matrix.php }} diff --git a/composer.json b/composer.json index 5f82038..6a7144c 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,6 @@ "friendsofphp/php-cs-fixer": "^3.75", "mockery/mockery": "^1.6", "pestphp/pest": "^2.36.0|^3.5.0", - "pestphp/pest-plugin-drift": "^3.0", "react/async": "^4.0", "react/child-process": "^0.6.6", "symfony/var-dumper": "^6.4.11|^7.1.5" From c3ea0658c2f273d4d3ea32579dac9085c4209401 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Thu, 19 Jun 2025 20:52:39 +0100 Subject: [PATCH 24/27] chore: Add new example to showcase using the Schema Attribute [skip ci] --- .../SchemaShowcaseElements.php | 434 ++++++++++++++++++ .../08-schema-showcase-streamable/server.php | 77 ++++ 2 files changed, 511 insertions(+) create mode 100644 examples/08-schema-showcase-streamable/SchemaShowcaseElements.php create mode 100644 examples/08-schema-showcase-streamable/server.php diff --git a/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php b/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php new file mode 100644 index 0000000..899f7fd --- /dev/null +++ b/examples/08-schema-showcase-streamable/SchemaShowcaseElements.php @@ -0,0 +1,434 @@ + strtoupper($text), + 'lowercase' => strtolower($text), + 'title' => ucwords(strtolower($text)), + 'sentence' => ucfirst(strtolower($text)), + default => $text + }; + + return [ + 'original' => $text, + 'formatted' => $formatted, + 'length' => strlen($text), + 'format_applied' => $format + ]; + } + + /** + * Performs mathematical operations with numeric constraints. + * + * Demonstrates: METHOD-LEVEL Schema + */ + #[McpTool(name: 'calculate_range')] + #[Schema( + type: 'object', + properties: [ + 'first' => [ + 'type' => 'number', + 'description' => 'First number (must be between 0 and 1000)', + 'minimum' => 0, + 'maximum' => 1000 + ], + 'second' => [ + 'type' => 'number', + 'description' => 'Second number (must be between 0 and 1000)', + 'minimum' => 0, + 'maximum' => 1000 + ], + 'operation' => [ + 'type' => 'string', + 'description' => 'Operation to perform', + 'enum' => ['add', 'subtract', 'multiply', 'divide', 'power'] + ], + 'precision' => [ + 'type' => 'integer', + 'description' => 'Decimal precision (must be multiple of 2, between 0-10)', + 'minimum' => 0, + 'maximum' => 10, + 'multipleOf' => 2 + ] + ], + required: ['first', 'second', 'operation'], + )] + public function calculateRange(float $first, float $second, string $operation, int $precision = 2): array + { + fwrite(STDERR, "Calculate range tool called: $first $operation $second (precision: $precision)\n"); + + $result = match ($operation) { + 'add' => $first + $second, + 'subtract' => $first - $second, + 'multiply' => $first * $second, + 'divide' => $second != 0 ? $first / $second : null, + 'power' => pow($first, $second), + default => null + }; + + if ($result === null) { + return [ + 'error' => $operation === 'divide' ? 'Division by zero' : 'Invalid operation', + 'inputs' => compact('first', 'second', 'operation', 'precision') + ]; + } + + return [ + 'result' => round($result, $precision), + 'operation' => "$first $operation $second", + 'precision' => $precision, + 'within_bounds' => $result >= 0 && $result <= 1000000 + ]; + } + + /** + * Processes user profile data with object schema validation. + * Demonstrates: object properties, required fields, additionalProperties. + */ + #[McpTool( + name: 'validate_profile', + description: 'Validates and processes user profile data with strict schema requirements.' + )] + public function validateProfile( + #[Schema( + type: 'object', + description: 'User profile information', + properties: [ + 'name' => [ + 'type' => 'string', + 'minLength' => 2, + 'maxLength' => 50, + 'description' => 'Full name' + ], + 'email' => [ + 'type' => 'string', + 'format' => 'email', + 'description' => 'Valid email address' + ], + 'age' => [ + 'type' => 'integer', + 'minimum' => 13, + 'maximum' => 120, + 'description' => 'Age in years' + ], + 'role' => [ + 'type' => 'string', + 'enum' => ['user', 'admin', 'moderator', 'guest'], + 'description' => 'User role' + ], + 'preferences' => [ + 'type' => 'object', + 'properties' => [ + 'notifications' => ['type' => 'boolean'], + 'theme' => ['type' => 'string', 'enum' => ['light', 'dark', 'auto']] + ], + 'additionalProperties' => false + ] + ], + required: ['name', 'email', 'age'], + additionalProperties: true + )] + array $profile + ): array { + fwrite(STDERR, "Validate profile tool called with: " . json_encode($profile) . "\n"); + + $errors = []; + $warnings = []; + + // Additional business logic validation + if (isset($profile['age']) && $profile['age'] < 18 && ($profile['role'] ?? 'user') === 'admin') { + $errors[] = 'Admin role requires age 18 or older'; + } + + if (isset($profile['email']) && !filter_var($profile['email'], FILTER_VALIDATE_EMAIL)) { + $errors[] = 'Invalid email format'; + } + + if (!isset($profile['role'])) { + $warnings[] = 'No role specified, defaulting to "user"'; + $profile['role'] = 'user'; + } + + return [ + 'valid' => empty($errors), + 'profile' => $profile, + 'errors' => $errors, + 'warnings' => $warnings, + 'processed_at' => date('Y-m-d H:i:s') + ]; + } + + /** + * Manages a list of items with array constraints. + * Demonstrates: array items, minItems, maxItems, uniqueItems. + */ + #[McpTool( + name: 'manage_list', + description: 'Manages a list of items with size and uniqueness constraints.' + )] + public function manageList( + #[Schema( + type: 'array', + description: 'List of items to manage (2-10 unique strings)', + items: [ + 'type' => 'string', + 'minLength' => 1, + 'maxLength' => 30 + ], + minItems: 2, + maxItems: 10, + uniqueItems: true + )] + array $items, + + #[Schema( + type: 'string', + description: 'Action to perform on the list', + enum: ['sort', 'reverse', 'shuffle', 'deduplicate', 'filter_short', 'filter_long'] + )] + string $action = 'sort' + ): array { + fwrite(STDERR, "Manage list tool called with " . count($items) . " items, action: $action\n"); + + $original = $items; + $processed = $items; + + switch ($action) { + case 'sort': + sort($processed); + break; + case 'reverse': + $processed = array_reverse($processed); + break; + case 'shuffle': + shuffle($processed); + break; + case 'deduplicate': + $processed = array_unique($processed); + break; + case 'filter_short': + $processed = array_filter($processed, fn($item) => strlen($item) <= 10); + break; + case 'filter_long': + $processed = array_filter($processed, fn($item) => strlen($item) > 10); + break; + } + + return [ + 'original_count' => count($original), + 'processed_count' => count($processed), + 'action' => $action, + 'original' => $original, + 'processed' => array_values($processed), // Re-index array + 'stats' => [ + 'average_length' => count($processed) > 0 ? round(array_sum(array_map('strlen', $processed)) / count($processed), 2) : 0, + 'shortest' => count($processed) > 0 ? min(array_map('strlen', $processed)) : 0, + 'longest' => count($processed) > 0 ? max(array_map('strlen', $processed)) : 0, + ] + ]; + } + + /** + * Generates configuration with format validation. + * Demonstrates: format constraints (date-time, uri, etc). + */ + #[McpTool( + name: 'generate_config', + description: 'Generates configuration with format-validated inputs.' + )] + public function generateConfig( + #[Schema( + type: 'string', + description: 'Application name (alphanumeric with hyphens)', + pattern: '^[a-zA-Z0-9\-]+$', + minLength: 3, + maxLength: 20 + )] + string $appName, + + #[Schema( + type: 'string', + description: 'Valid URL for the application', + format: 'uri' + )] + string $baseUrl, + + #[Schema( + type: 'string', + description: 'Environment type', + enum: ['development', 'staging', 'production'] + )] + string $environment = 'development', + + #[Schema( + type: 'boolean', + description: 'Enable debug mode' + )] + bool $debug = true, + + #[Schema( + type: 'integer', + description: 'Port number (1024-65535)', + minimum: 1024, + maximum: 65535 + )] + int $port = 8080 + ): array { + fwrite(STDERR, "Generate config tool called for app: $appName\n"); + + $config = [ + 'app' => [ + 'name' => $appName, + 'env' => $environment, + 'debug' => $debug, + 'url' => $baseUrl, + 'port' => $port, + ], + 'generated_at' => date('c'), // ISO 8601 format + 'version' => '1.0.0', + 'features' => [ + 'logging' => $environment !== 'production' || $debug, + 'caching' => $environment === 'production', + 'analytics' => $environment === 'production', + 'rate_limiting' => $environment !== 'development', + ] + ]; + + return [ + 'success' => true, + 'config' => $config, + 'validation' => [ + 'app_name_valid' => preg_match('/^[a-zA-Z0-9\-]+$/', $appName) === 1, + 'url_valid' => filter_var($baseUrl, FILTER_VALIDATE_URL) !== false, + 'port_in_range' => $port >= 1024 && $port <= 65535, + ] + ]; + } + + /** + * Processes time-based data with date-time format validation. + * Demonstrates: date-time format, exclusiveMinimum, exclusiveMaximum. + */ + #[McpTool( + name: 'schedule_event', + description: 'Schedules an event with time validation and constraints.' + )] + public function scheduleEvent( + #[Schema( + type: 'string', + description: 'Event title (3-50 characters)', + minLength: 3, + maxLength: 50 + )] + string $title, + + #[Schema( + type: 'string', + description: 'Event start time in ISO 8601 format', + format: 'date-time' + )] + string $startTime, + + #[Schema( + type: 'number', + description: 'Duration in hours (minimum 0.5, maximum 24)', + minimum: 0.5, + maximum: 24, + multipleOf: 0.5 + )] + float $durationHours, + + #[Schema( + type: 'string', + description: 'Event priority level', + enum: ['low', 'medium', 'high', 'urgent'] + )] + string $priority = 'medium', + + #[Schema( + type: 'array', + description: 'List of attendee email addresses', + items: [ + 'type' => 'string', + 'format' => 'email' + ], + maxItems: 20 + )] + array $attendees = [] + ): array { + fwrite(STDERR, "Schedule event tool called: $title at $startTime\n"); + + $start = DateTime::createFromFormat(DateTime::ISO8601, $startTime); + if (!$start) { + $start = DateTime::createFromFormat('Y-m-d\TH:i:s\Z', $startTime); + } + + if (!$start) { + return [ + 'success' => false, + 'error' => 'Invalid date-time format. Use ISO 8601 format.', + 'example' => '2024-01-15T14:30:00Z' + ]; + } + + $end = clone $start; + $end->add(new DateInterval('PT' . ($durationHours * 60) . 'M')); + + $event = [ + 'id' => uniqid('event_'), + 'title' => $title, + 'start_time' => $start->format('c'), + 'end_time' => $end->format('c'), + 'duration_hours' => $durationHours, + 'priority' => $priority, + 'attendees' => $attendees, + 'created_at' => date('c') + ]; + + return [ + 'success' => true, + 'event' => $event, + 'info' => [ + 'attendee_count' => count($attendees), + 'is_all_day' => $durationHours >= 24, + 'is_future' => $start > new DateTime(), + 'timezone_note' => 'Times are in UTC' + ] + ]; + } +} diff --git a/examples/08-schema-showcase-streamable/server.php b/examples/08-schema-showcase-streamable/server.php new file mode 100644 index 0000000..45761d7 --- /dev/null +++ b/examples/08-schema-showcase-streamable/server.php @@ -0,0 +1,77 @@ +#!/usr/bin/env php +info('Starting MCP Schema Showcase Server...'); + + $server = Server::make() + ->withServerInfo('Schema Showcase', '1.0.0') + ->withLogger($logger) + ->build(); + + $server->discover(__DIR__, ['.']); + + $transport = new StreamableHttpServerTransport('127.0.0.1', 8080, 'mcp'); + + $server->listen($transport); + + $logger->info('Server listener stopped gracefully.'); + exit(0); +} catch (\Throwable $e) { + fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n"); + fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); + fwrite(STDERR, 'File: ' . $e->getFile() . ':' . $e->getLine() . "\n"); + fwrite(STDERR, $e->getTraceAsString() . "\n"); + exit(1); +} From ba62387597b2a3c6d0a9f629c8d3fc41682260af Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Thu, 19 Jun 2025 21:55:25 +0100 Subject: [PATCH 25/27] chore: use v1.0 for php-mcp/schema --- composer.json | 10 ++-------- tests/Mocks/Clients/MockSseClient.php | 5 ++--- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/composer.json b/composer.json index 6a7144c..4698ebb 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "require": { "php": ">=8.1", "opis/json-schema": "^2.4", - "php-mcp/schema": "dev-main", + "php-mcp/schema": "^1.0", "phpdocumentor/reflection-docblock": "^5.6", "psr/clock": "^1.0", "psr/container": "^1.0 || ^2.0", @@ -59,11 +59,5 @@ } }, "minimum-stability": "dev", - "prefer-stable": true, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/php-mcp/schema" - } - ] + "prefer-stable": true } diff --git a/tests/Mocks/Clients/MockSseClient.php b/tests/Mocks/Clients/MockSseClient.php index 4726893..ad54374 100644 --- a/tests/Mocks/Clients/MockSseClient.php +++ b/tests/Mocks/Clients/MockSseClient.php @@ -9,7 +9,6 @@ use React\Http\Browser; use React\Promise\Deferred; use React\Promise\PromiseInterface; -use React\Promise\Timer\TimeoutException; use React\Stream\ReadableStreamInterface; use function React\Promise\reject; @@ -104,7 +103,7 @@ public function getNextMessageResponse(string $expectedRequestId, int $timeoutSe } if (microtime(true) - $startTime > $timeoutSecs) { - $deferred->reject(new TimeoutException("Timeout waiting for SSE message with ID '{$expectedRequestId}'")); + $deferred->reject(new \RuntimeException("Timeout waiting for SSE message with ID '{$expectedRequestId}'")); return; } @@ -151,7 +150,7 @@ public function getNextBatchMessageResponse(int $expectedItemCount, int $timeout } if (microtime(true) - $startTime > $timeoutSecs) { - $deferred->reject(new TimeoutException("Timeout waiting for SSE Batch Response with {$expectedItemCount} items.")); + $deferred->reject(new \RuntimeException("Timeout waiting for SSE Batch Response with {$expectedItemCount} items.")); return; } From 662eb31e96e21e03dc976f31a39f95c10407a74e Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Thu, 19 Jun 2025 23:31:42 +0100 Subject: [PATCH 26/27] refactor: simplify session and cache configuration API --- src/Configuration.php | 5 +- src/ServerBuilder.php | 90 +++++++++++++++++++++-------- tests/Unit/ConfigurationTest.php | 16 ------ tests/Unit/ServerBuilderTest.php | 97 +++++++++++++++++++++++++------- 4 files changed, 145 insertions(+), 63 deletions(-) diff --git a/src/Configuration.php b/src/Configuration.php index a10a10e..60f873f 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -25,7 +25,6 @@ class Configuration * @param LoopInterface $loop ReactPHP Event Loop instance. * @param CacheInterface|null $cache Optional PSR-16 Cache instance for registry/state. * @param ContainerInterface $container PSR-11 DI Container for resolving handlers/dependencies. - * @param int $definitionCacheTtl TTL in seconds for cached definitions (if cache is provided). * @param int $paginationLimit Maximum number of items to return for list methods. */ public function __construct( @@ -35,8 +34,6 @@ public function __construct( public readonly LoopInterface $loop, public readonly ?CacheInterface $cache, public readonly ContainerInterface $container, - public readonly int $definitionCacheTtl = 3600, public readonly int $paginationLimit = 50, - ) { - } + ) {} } diff --git a/src/ServerBuilder.php b/src/ServerBuilder.php index d266dc9..041a16c 100644 --- a/src/ServerBuilder.php +++ b/src/ServerBuilder.php @@ -46,9 +46,9 @@ final class ServerBuilder private ?SessionHandlerInterface $sessionHandler = null; - private ?int $sessionTtl = 3600; + private ?string $sessionDriver = null; - private ?int $definitionCacheTtl = 3600; + private ?int $sessionTtl = 3600; private ?int $paginationLimit = 50; @@ -88,9 +88,7 @@ final class ServerBuilder * > */ private array $manualPrompts = []; - public function __construct() - { - } + public function __construct() {} /** * Sets the server's identity. Required. @@ -133,36 +131,42 @@ public function withLogger(LoggerInterface $logger): self } /** - * Provides a PSR-16 cache instance and optionally sets the TTL for definition caching. - * If no cache is provided, definition caching is disabled (uses default FileCache if possible). + * Provides a PSR-16 cache instance used for all internal caching. */ - public function withCache(CacheInterface $cache, int $definitionCacheTtl = 3600): self + public function withCache(CacheInterface $cache): self { $this->cache = $cache; - $this->definitionCacheTtl = $definitionCacheTtl > 0 ? $definitionCacheTtl : 3600; return $this; } - public function withSessionHandler(SessionHandlerInterface $sessionHandler, int $sessionTtl = 3600): self + /** + * Configures session handling with a specific driver. + * + * @param 'array' | 'cache' $driver The session driver: 'array' for in-memory sessions, 'cache' for cache-backed sessions + * @param int $ttl Session time-to-live in seconds. Defaults to 3600. + */ + public function withSession(string $driver, int $ttl = 3600): self { - $this->sessionHandler = $sessionHandler; - $this->sessionTtl = $sessionTtl; + if (!in_array($driver, ['array', 'cache'], true)) { + throw new \InvalidArgumentException( + "Unsupported session driver '{$driver}'. Only 'array' and 'cache' drivers are supported. " . + "For custom session handling, use withSessionHandler() instead." + ); + } - return $this; - } - - public function withArraySessionHandler(int $sessionTtl = 3600): self - { - $this->sessionHandler = new ArraySessionHandler($sessionTtl); - $this->sessionTtl = $sessionTtl; + $this->sessionDriver = $driver; + $this->sessionTtl = $ttl; return $this; } - public function withCacheSessionHandler(CacheInterface $cache, int $sessionTtl = 3600): self + /** + * Provides a custom session handler. + */ + public function withSessionHandler(SessionHandlerInterface $sessionHandler, int $sessionTtl = 3600): self { - $this->sessionHandler = new CacheSessionHandler($cache, $sessionTtl); + $this->sessionHandler = $sessionHandler; $this->sessionTtl = $sessionTtl; return $this; @@ -253,11 +257,10 @@ public function build(): Server loop: $loop, cache: $cache, container: $container, - definitionCacheTtl: $this->definitionCacheTtl ?? 3600, paginationLimit: $this->paginationLimit ?? 50 ); - $sessionHandler = $this->sessionHandler ?? new ArraySessionHandler(3600); + $sessionHandler = $this->createSessionHandler(); $sessionManager = new SessionManager($sessionHandler, $logger, $loop, $this->sessionTtl); $registry = new Registry($logger, $cache, $sessionManager); $protocol = new Protocol($configuration, $registry, $sessionManager); @@ -410,6 +413,47 @@ private function registerManualElements(Registry $registry, LoggerInterface $log $logger->debug('Manual element registration complete.'); } + /** + * Creates the appropriate session handler based on configuration. + * + * @throws ConfigurationException If cache driver is selected but no cache is provided + */ + private function createSessionHandler(): SessionHandlerInterface + { + // If a custom session handler was provided, use it + if ($this->sessionHandler !== null) { + return $this->sessionHandler; + } + + // If no session driver was specified, default to array + if ($this->sessionDriver === null) { + return new ArraySessionHandler($this->sessionTtl ?? 3600); + } + + // Create handler based on driver + return match ($this->sessionDriver) { + 'array' => new ArraySessionHandler($this->sessionTtl ?? 3600), + 'cache' => $this->createCacheSessionHandler(), + default => throw new ConfigurationException("Unsupported session driver: {$this->sessionDriver}") + }; + } + + /** + * Creates a cache-based session handler. + * + * @throws ConfigurationException If no cache is configured + */ + private function createCacheSessionHandler(): CacheSessionHandler + { + if ($this->cache === null) { + throw new ConfigurationException( + "Cache session driver requires a cache instance. Please configure a cache using withCache() before using withSession('cache')." + ); + } + + return new CacheSessionHandler($this->cache, $this->sessionTtl ?? 3600); + } + private function getCompletionProviders(\ReflectionMethod $reflectionMethod): array { $completionProviders = []; diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index 3fd1e98..57112e0 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -25,7 +25,6 @@ }); it('constructs configuration object with all properties', function () { - $ttl = 1800; $paginationLimit = 100; $config = new Configuration( serverInfo: $this->serverInfo, @@ -34,7 +33,6 @@ loop: $this->loop, cache: $this->cache, container: $this->container, - definitionCacheTtl: $ttl, paginationLimit: $paginationLimit ); @@ -44,23 +42,9 @@ expect($config->loop)->toBe($this->loop); expect($config->cache)->toBe($this->cache); expect($config->container)->toBe($this->container); - expect($config->definitionCacheTtl)->toBe($ttl); expect($config->paginationLimit)->toBe($paginationLimit); }); -it('constructs configuration object with default TTL', function () { - $config = new Configuration( - serverInfo: $this->serverInfo, - capabilities: $this->capabilities, - logger: $this->logger, - loop: $this->loop, - cache: $this->cache, - container: $this->container - ); - - expect($config->definitionCacheTtl)->toBe(3600); // Default value -}); - it('constructs configuration object with default pagination limit', function () { $config = new Configuration( serverInfo: $this->serverInfo, diff --git a/tests/Unit/ServerBuilderTest.php b/tests/Unit/ServerBuilderTest.php index c184fd6..d5884f5 100644 --- a/tests/Unit/ServerBuilderTest.php +++ b/tests/Unit/ServerBuilderTest.php @@ -96,17 +96,10 @@ public function getCompletions(string $currentValue, SessionInterface $session): expect(getPrivateProperty($this->builder, 'logger'))->toBe($logger); }); -it('sets cache and TTL correctly', function () { - $cache = Mockery::mock(CacheInterface::class); - $this->builder->withCache($cache, 1800); - expect(getPrivateProperty($this->builder, 'cache'))->toBe($cache); - expect(getPrivateProperty($this->builder, 'definitionCacheTtl'))->toBe(1800); -}); - -it('sets cache with default TTL if TTL not provided', function () { +it('sets cache correctly', function () { $cache = Mockery::mock(CacheInterface::class); $this->builder->withCache($cache); - expect(getPrivateProperty($this->builder, 'definitionCacheTtl'))->toBe(3600); + expect(getPrivateProperty($this->builder, 'cache'))->toBe($cache); }); it('sets session handler correctly', function () { @@ -116,22 +109,86 @@ public function getCompletions(string $currentValue, SessionInterface $session): expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(7200); }); -it('sets ArraySessionHandler correctly', function () { - $this->builder->withArraySessionHandler(1800); - expect(getPrivateProperty($this->builder, 'sessionHandler'))->toBeInstanceOf(ArraySessionHandler::class); - expect(getPrivateProperty($this->builder, 'sessionHandler')->ttl)->toBe(1800); +it('sets session driver to array correctly', function () { + $this->builder->withSession('array', 1800); + expect(getPrivateProperty($this->builder, 'sessionDriver'))->toBe('array'); expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(1800); }); -it('sets CacheSessionHandler correctly', function () { +it('sets session driver to cache correctly', function () { + $this->builder->withSession('cache', 900); + expect(getPrivateProperty($this->builder, 'sessionDriver'))->toBe('cache'); + expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(900); +}); + +it('uses default TTL when not specified for session', function () { + $this->builder->withSession('array'); + expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(3600); +}); + +it('throws exception for invalid session driver', function () { + $this->builder->withSession('redis'); +})->throws(\InvalidArgumentException::class, "Unsupported session driver 'redis'. Only 'array' and 'cache' drivers are supported."); + +it('throws exception for cache session driver without cache during build', function () { + $this->builder + ->withServerInfo('Test', '1.0') + ->withSession('cache') + ->build(); +})->throws(ConfigurationException::class, 'Cache session driver requires a cache instance'); + +it('creates ArraySessionHandler when array driver is specified', function () { + $server = $this->builder + ->withServerInfo('Test', '1.0') + ->withSession('array', 1800) + ->build(); + + $sessionManager = $server->getSessionManager(); + $smReflection = new ReflectionClass(SessionManager::class); + $handlerProp = $smReflection->getProperty('handler'); + $handlerProp->setAccessible(true); + $handler = $handlerProp->getValue($sessionManager); + + expect($handler)->toBeInstanceOf(ArraySessionHandler::class); + expect($handler->ttl)->toBe(1800); +}); + +it('creates CacheSessionHandler when cache driver is specified', function () { $cache = Mockery::mock(CacheInterface::class); $cache->shouldReceive('get')->with('mcp_session_index', [])->andReturn([]); - $this->builder->withCacheSessionHandler($cache, 900); - $sessionHandler = getPrivateProperty($this->builder, 'sessionHandler'); - expect($sessionHandler)->toBeInstanceOf(CacheSessionHandler::class); - expect($sessionHandler->cache)->toBe($cache); - expect($sessionHandler->ttl)->toBe(900); - expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(900); + + $server = $this->builder + ->withServerInfo('Test', '1.0') + ->withCache($cache) + ->withSession('cache', 900) + ->build(); + + $sessionManager = $server->getSessionManager(); + $smReflection = new ReflectionClass(SessionManager::class); + $handlerProp = $smReflection->getProperty('handler'); + $handlerProp->setAccessible(true); + $handler = $handlerProp->getValue($sessionManager); + + expect($handler)->toBeInstanceOf(CacheSessionHandler::class); + expect($handler->cache)->toBe($cache); + expect($handler->ttl)->toBe(900); +}); + +it('prefers custom session handler over session driver', function () { + $customHandler = Mockery::mock(SessionHandlerInterface::class); + + $server = $this->builder + ->withServerInfo('Test', '1.0') + ->withSession('array') + ->withSessionHandler($customHandler, 1200) + ->build(); + + $sessionManager = $server->getSessionManager(); + $smReflection = new ReflectionClass(SessionManager::class); + $handlerProp = $smReflection->getProperty('handler'); + $handlerProp->setAccessible(true); + + expect($handlerProp->getValue($sessionManager))->toBe($customHandler); }); From c9350f21a95378d586c2169a08f3ec050470dc3a Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 21 Jun 2025 19:42:01 +0100 Subject: [PATCH 27/27] docs: comprehensive README update for 2025-03-26 protocol Update documentation to reflect current architecture with proper schema generation, deployment strategies, and corrected configuration examples. --- README.md | 1399 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 1041 insertions(+), 358 deletions(-) diff --git a/README.md b/README.md index da255f1..62d2a3c 100644 --- a/README.md +++ b/README.md @@ -5,68 +5,89 @@ [![Tests](https://img.shields.io/github/actions/workflow/status/php-mcp/server/tests.yml?branch=main&style=flat-square)](https://github.com/php-mcp/server/actions/workflows/tests.yml) [![License](https://img.shields.io/packagist/l/php-mcp/server.svg?style=flat-square)](LICENSE) -**PHP MCP Server provides a robust and flexible server-side implementation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) for PHP applications.** +**A comprehensive and production-ready PHP implementation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) server. Built for PHP 8.1+ with modern architecture, extensive testing, and flexible transport options.** -Easily expose parts of your application as standardized MCP **Tools**, **Resources**, and **Prompts**, allowing AI assistants (like Anthropic's Claude, Cursor IDE, etc.) to interact with your PHP backend using the MCP standard. +The PHP MCP Server enables you to expose your PHP application's functionality as standardized MCP **Tools**, **Resources**, and **Prompts**, allowing AI assistants (like Anthropic's Claude, Cursor IDE, OpenAI's ChatGPT, etc.) to interact with your backend using the MCP standard. -This package simplifies building MCP servers through: +## ๐Ÿš€ Key Features -* **Attribute-Based Definition:** Define MCP elements using PHP 8 Attributes (`#[McpTool]`, `#[McpResource]`, `#[McpPrompt]`, `#[McpResourceTemplate]`) on your methods or invokable classes. -* **Manual Registration:** Programmatically register elements using a fluent builder API. -* **Explicit Discovery:** Trigger attribute scanning on demand via the `$server->discover()` method. -* **Metadata Inference:** Intelligently generate MCP schemas and descriptions from PHP type hints and DocBlocks. -* **Selective Caching:** Optionally cache *discovered* element definitions to speed up startup, while always preserving manually registered elements. -* **Flexible Transports:** Supports `stdio` and `http+sse`, separating core logic from network communication. -* **PSR Compliance:** Integrates with PSR-3 (Logging), PSR-11 (Container), and PSR-16 (SimpleCache). +- **๐Ÿ—๏ธ Modern Architecture**: Built with PHP 8.1+ features, PSR standards, and modular design +- **๐Ÿ“ก Multiple Transports**: Supports `stdio`, `http+sse`, and new **streamable HTTP** with resumability +- **๐ŸŽฏ Attribute-Based Definition**: Use PHP 8 Attributes (`#[McpTool]`, `#[McpResource]`, etc.) for zero-config element registration +- **๐Ÿ“ Smart Schema Generation**: Automatic JSON schema generation from method signatures with optional `#[Schema]` attribute enhancements +- **โšก Session Management**: Advanced session handling with multiple storage backends +- **๐Ÿ”„ Event-Driven**: ReactPHP-based for high concurrency and non-blocking operations +- **๐Ÿ“Š Batch Processing**: Full support for JSON-RPC batch requests +- **๐Ÿ’พ Smart Caching**: Intelligent caching of discovered elements with manual override precedence +- **๐Ÿงช Completion Providers**: Built-in support for argument completion in tools and prompts +- **๐Ÿ”Œ Dependency Injection**: Full PSR-11 container support with auto-wiring +- **๐Ÿ“‹ Comprehensive Testing**: Extensive test suite with integration tests for all transports -This package currently supports the `2024-11-05` version of the Model Context Protocol. +This package supports the **2025-03-26** version of the Model Context Protocol with backward compatibility. -## Requirements +## ๐Ÿ“‹ Requirements -* PHP >= 8.1 -* Composer -* *(For Http Transport)*: An event-driven PHP environment capable of handling concurrent requests (see [HTTP Transport](#http-transport-httpsse) section). +- **PHP** >= 8.1 +- **Composer** +- **For HTTP Transport**: An event-driven PHP environment (CLI recommended) +- **Extensions**: `json`, `mbstring`, `pcre` (typically enabled by default) -## Installation +## ๐Ÿ“ฆ Installation ```bash composer require php-mcp/server ``` -> **Note for Laravel Users:** While this package works standalone, consider using [`php-mcp/laravel`](https://github.com/php-mcp/laravel) for enhanced framework integration, configuration, and Artisan commands. +> **๐Ÿ’ก Laravel Users**: Consider using [`php-mcp/laravel`](https://github.com/php-mcp/laravel) for enhanced framework integration, configuration management, and Artisan commands. -## Quick Start: Standalone `stdio` Server with Discovery +## โšก Quick Start: Stdio Server with Discovery -This example creates a server using **attribute discovery** to find elements and runs via the `stdio` transport. +This example demonstrates the most common usage pattern - a `stdio` server using attribute discovery. -**1. Define Your MCP Element:** +**1. Define Your MCP Elements** -Create `src/MyMcpElements.php`: +Create `src/CalculatorElements.php`: ```php withServerInfo('My Discovery Server', '1.0.2') + ->withServerInfo('PHP Calculator Server', '1.0.0') ->build(); - // 2. **Explicitly run discovery** + // Discover MCP elements via attributes $server->discover( basePath: __DIR__, - scanDirs: ['src'], + scanDirs: ['src'] ); - // 3. Create the Stdio Transport + // Start listening via stdio transport $transport = new StdioServerTransport(); - - // 4. Start Listening (BLOCKING call) $server->listen($transport); - exit(0); - } catch (\Throwable $e) { - fwrite(STDERR, "[MCP SERVER CRITICAL ERROR]\n" . $e . "\n"); + fwrite(STDERR, "[CRITICAL ERROR] " . $e->getMessage() . "\n"); exit(1); } ``` -**3. Configure Your MCP Client:** +**3. Configure Your MCP Client** -Instruct your MCP client (e.g., Cursor, Claude Desktop) to use the `stdio` transport by running your script. Make sure to use the **absolute path**: +Add to your client configuration (e.g., `.cursor/mcp.json`): ```json -// Example: .cursor/mcp.json { "mcpServers": { - "my-php-stdio": { + "php-calculator": { "command": "php", - "args": ["/full/path/to/your/project/mcp-server.php"] + "args": ["/absolute/path/to/your/mcp-server.php"] } } } ``` -**Flow:** +**4. Test the Server** + +Your AI assistant can now call: +- `add_numbers` - Add two integers +- `calculate_power` - Calculate power with validation constraints + +## ๐Ÿ—๏ธ Architecture Overview + +The PHP MCP Server uses a modern, decoupled architecture: + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ MCP Client โ”‚โ—„โ”€โ”€โ–บโ”‚ Transport โ”‚โ—„โ”€โ”€โ–บโ”‚ Protocol โ”‚ +โ”‚ (Claude, etc.) โ”‚ โ”‚ (Stdio/HTTP/SSE) โ”‚ โ”‚ (JSON-RPC) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ + โ”‚ Session Manager โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค + โ”‚ (Multi-backend) โ”‚ โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ”‚ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ Dispatcher โ”‚โ—„โ”€โ”€โ”€โ”‚ Server Core โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ (Method Router) โ”‚ โ”‚ Configuration โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ”‚ โ”‚ + โ–ผ โ”‚ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ Registry โ”‚ โ”‚ Elements โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ”‚ (Element Store)โ”‚โ—„โ”€โ”€โ–บโ”‚ (Tools/Resources โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ Prompts/etc.) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Core Components + +- **`ServerBuilder`**: Fluent configuration interface (`Server::make()->...->build()`) +- **`Server`**: Central coordinator containing all configured components +- **`Protocol`**: JSON-RPC 2.0 handler bridging transports and core logic +- **`SessionManager`**: Multi-backend session storage (array, cache, custom) +- **`Dispatcher`**: Method routing and request processing +- **`Registry`**: Element storage with smart caching and precedence rules +- **`Elements`**: Registered MCP components (Tools, Resources, Prompts, Templates) + +### Transport Options + +1. **`StdioServerTransport`**: Standard I/O for direct client launches +2. **`HttpServerTransport`**: HTTP + Server-Sent Events for web integration +3. **`StreamableHttpServerTransport`**: Enhanced HTTP with resumability and event sourcing + +## โš™๏ธ Server Configuration + +### Basic Configuration + +```php +use PhpMcp\Server\Server; +use PhpMcp\Schema\ServerCapabilities; + +$server = Server::make() + ->withServerInfo('My App Server', '2.1.0') + ->withCapabilities(ServerCapabilities::make( + resources: true, + resourcesSubscribe: true, + prompts: true, + tools: true + )) + ->withPaginationLimit(100) + ->build(); +``` + +### Advanced Configuration with Dependencies -1. `Server::make()->...->build()`: Creates the `Server` instance, resolves dependencies, performs *manual* registrations (if any), and implicitly attempts to load *discovered* elements from cache (if configured and cache exists). -2. `$server->discover(__DIR__, ['src'])`: Explicitly triggers a filesystem scan within `src/`. Clears previously discovered/cached elements from the registry, finds `MyMcpElements::add`, creates its `ToolDefinition`, and registers it. If caching is enabled and `saveToCache` is true, saves this discovered definition to the cache. -3. `$server->listen($transport)`: Binds the transport, checks if *any* elements are registered (in this case, yes), starts the transport listener, and runs the event loop. +```php +use Psr\Log\Logger; +use Psr\SimpleCache\CacheInterface; +use Psr\Container\ContainerInterface; -## Core Architecture +$server = Server::make() + ->withServerInfo('Production Server', '1.0.0') + ->withLogger($myPsrLogger) // PSR-3 Logger + ->withCache($myPsrCache) // PSR-16 Cache + ->withContainer($myPsrContainer) // PSR-11 Container + ->withSession('cache', 7200) // Cache-backed sessions, 2hr TTL + ->withPaginationLimit(50) // Limit list responses + ->build(); +``` -The server uses a decoupled architecture: +### Session Management Options -* **`ServerBuilder`:** Fluent interface (`Server::make()->...`) for configuration. Collects server identity, dependencies (Logger, Cache, Container, Loop), capabilities, and **manual** element registrations. Calls `build()` to create the `Server` instance. -* **`Configuration`:** A value object holding the resolved configuration and dependencies. -* **`Server`:** The central object holding the configured state and core logic components (`Registry`, `Protocol`, `Configuration`). It's transport-agnostic. Provides methods to `discover()` elements and `listen()` via a specific transport. -* **`Protocol`:** Internal bridge listening to transport events and processes JSON-RPC messages from the transport. -* **`Registry`:** Stores MCP element definitions. **Distinguishes between manually registered and discovered elements.** Handles optional caching of *discovered* elements only. Loads cached discovered elements upon instantiation if available. -* **`ServerTransportInterface`:** Event-driven interface for server-side transports (`StdioServerTransport`, `HttpServerTransport`). Handles communication, emits events. +```php +// In-memory sessions (default, not persistent) +->withSession('array', 3600) + +// Cache-backed sessions (persistent across restarts) +->withSession('cache', 7200) + +// Custom session handler (implement SessionHandlerInterface) +->withSessionHandler(new MyCustomSessionHandler(), 1800) +``` + +## ๐ŸŽฏ Defining MCP Elements -## Defining MCP Elements +The server provides two powerful ways to define MCP elements: **Attribute-Based Discovery** (recommended) and **Manual Registration**. Both can be combined, with manual registrations taking precedence. -Expose your application's functionality as MCP Tools, Resources, or Prompts using attributes or manual registration. +### Element Types -### 1. Using Attributes (`#[Mcp*]`) +- **๐Ÿ”ง Tools**: Executable functions/actions (e.g., `calculate`, `send_email`, `query_database`) +- **๐Ÿ“„ Resources**: Static content/data (e.g., `config://settings`, `file://readme.txt`) +- **๐Ÿ“‹ Resource Templates**: Dynamic resources with URI patterns (e.g., `user://{id}/profile`) +- **๐Ÿ’ฌ Prompts**: Conversation starters/templates (e.g., `summarize`, `translate`) -Decorate public, non-static methods or invokable classes with `#[Mcp*]` attributes to mark them as MCP Elements. After building the server, you **must** call `$server->discover(...)` at least once with the correct paths to find and register these elements. It will also cache the discovered elements if set, so that you can skip discovery on subsequent runs. +### 1. ๐Ÿท๏ธ Attribute-Based Discovery (Recommended) + +Use PHP 8 attributes to mark methods or invokable classes as MCP elements. The server will discover them via filesystem scanning. ```php -$server = ServerBuilder::make()->...->build(); -// Scan 'src/Handlers' relative to the project root -$server->discover(basePath: __DIR__, scanDirs: ['src/Handlers']); +use PhpMcp\Server\Attributes\{McpTool, McpResource, McpResourceTemplate, McpPrompt}; + +class UserManager +{ + /** + * Creates a new user account. + */ + #[McpTool(name: 'create_user')] + public function createUser(string $email, string $password, string $role = 'user'): array + { + // Create user logic + return ['id' => 123, 'email' => $email, 'role' => $role]; + } + + /** + * Get user configuration. + */ + #[McpResource( + uri: 'config://user/settings', + mimeType: 'application/json' + )] + public function getUserConfig(): array + { + return ['theme' => 'dark', 'notifications' => true]; + } + + /** + * Get user profile by ID. + */ + #[McpResourceTemplate( + uriTemplate: 'user://{userId}/profile', + mimeType: 'application/json' + )] + public function getUserProfile(string $userId): array + { + return ['id' => $userId, 'name' => 'John Doe']; + } + + /** + * Generate welcome message prompt. + */ + #[McpPrompt(name: 'welcome_user')] + public function welcomeUserPrompt(string $username, string $role): array + { + return [ + ['role' => 'user', 'content' => "Create a welcome message for {$username} with role {$role}"] + ]; + } +} ``` -Attributes: +**Discovery Process:** + +```php +// Build server first +$server = Server::make() + ->withServerInfo('My App Server', '1.0.0') + ->build(); + +// Then discover elements +$server->discover( + basePath: __DIR__, + scanDirs: ['src/Handlers', 'src/Services'], // Directories to scan + excludeDirs: ['src/Tests'], // Directories to skip + saveToCache: true // Cache results (default: true) +); +``` -* **`#[McpTool(name?, description?)`**: Defines an action. Parameters/return types/DocBlocks define the MCP schema. Use on public, non-static methods or invokable classes. -* **`#[McpResource(uri, name?, description?, mimeType?, size?, annotations?)]`**: Defines a static resource instance. Use on public, non-static methods or invokable classes. Method returns resource content. -* **`#[McpResourceTemplate(uriTemplate, name?, description?, mimeType?, annotations?)]`**: Defines a handler for templated URIs (e.g., `item://{id}`). Use on public, non-static methods or invokable classes. Method parameters must match template variables. Method returns content for the resolved instance. -* **`#[McpPrompt(name?, description?)`**: Defines a prompt generator. Use on public, non-static methods or invokable classes. Method parameters are prompt arguments. Method returns prompt messages. +**Available Attributes:** -*(See [Attribute Details](#attribute-details-and-return-formatting) below for more on parameters and return value formatting)* +- **`#[McpTool]`**: Executable actions +- **`#[McpResource]`**: Static content accessible via URI +- **`#[McpResourceTemplate]`**: Dynamic resources with URI templates +- **`#[McpPrompt]`**: Conversation templates and prompt generators -### 2. Manual Registration (`ServerBuilder->with*`) +### 2. ๐Ÿ”ง Manual Registration -Use `withTool`, `withResource`, `withResourceTemplate`, `withPrompt` on the `ServerBuilder` *before* calling `build()`. +Register elements programmatically using the `ServerBuilder` before calling `build()`. Useful for dynamic registration or when you prefer explicit control. ```php -use App\Handlers\MyToolHandler; -use App\Handlers\MyResourceHandler; +use App\Handlers\{EmailHandler, ConfigHandler, UserHandler, PromptHandler}; +use PhpMcp\Schema\{ToolAnnotations, Annotations}; $server = Server::make() - ->withServerInfo(...) + ->withServerInfo('Manual Registration Server', '1.0.0') + + // Register a tool with handler method ->withTool( - [MyToolHandler::class, 'processData'], // Handler: [class, method] - 'data_processor' // MCP Name (Optional) + [EmailHandler::class, 'sendEmail'], // Handler: [class, method] + name: 'send_email', // Tool name (optional) + description: 'Send email to user', // Description (optional) + annotations: ToolAnnotations::make( // Annotations (optional) + title: 'Send Email Tool' + ) ) + + // Register invokable class as tool + ->withTool(UserHandler::class) // Handler: Invokable class + + // Register a resource ->withResource( - MyResourceHandler::class, // Handler: Invokable class - 'config://app/name' // URI (Required) + [ConfigHandler::class, 'getConfig'], + uri: 'config://app/settings', // URI (required) + mimeType: 'application/json' // MIME type (optional) + ) + + // Register a resource template + ->withResourceTemplate( + [UserHandler::class, 'getUserProfile'], + uriTemplate: 'user://{userId}/profile' // URI template (required) ) - // ->withResourceTemplate(...) - // ->withPrompt(...) + + // Register a prompt + ->withPrompt( + [PromptHandler::class, 'generateSummary'], + name: 'summarize_text' // Prompt name (optional) + ) + ->build(); ``` -* **Handlers:** Can be `[ClassName::class, 'methodName']` or `InvokableHandler::class`. Dependencies are resolved via the configured PSR-11 Container. -* Metadata (name, description) is inferred from the handler if not provided explicitly. -* These elements are registered **immediately** when `build()` is called. -* They are **never cached** by the Registry's caching mechanism. -* They are **not removed** when `$registry->clearDiscoveredElements()` is called (e.g., at the start of `$server->discover()`). - -### Precedence: Manual vs. Discovered/Cached - -If an element is registered both manually (via the builder) and is also found via attribute discovery (or loaded from cache) with the **same identifying key** (tool name, resource URI, prompt name, template URI): +**Key Features:** -* **The manually registered element always takes precedence.** -* The discovered/cached version will be ignored, and a debug message will be logged. +- **Handler Formats**: Use `[ClassName::class, 'methodName']` or `InvokableClass::class` +- **Dependency Injection**: Handlers resolved via configured PSR-11 container +- **Immediate Registration**: Elements registered when `build()` is called +- **No Caching**: Manual elements are never cached (always fresh) +- **Precedence**: Manual registrations override discovered elements with same identifier -This ensures explicit manual configuration overrides any potentially outdated discovered or cached definitions. +### ๐Ÿ† Element Precedence & Discovery -## Discovery and Caching +**Precedence Rules:** +- Manual registrations **always** override discovered/cached elements with the same identifier +- Discovered elements are cached for performance (configurable) +- Cache is automatically invalidated on fresh discovery runs -Attribute discovery is an **explicit step** performed on a built `Server` instance. +**Discovery Process:** -* **`$server->discover(string $basePath, array $scanDirs = [...], array $excludeDirs = [...], bool $force = false, bool $saveToCache = true)`** - * `$basePath`, `$scanDirs`, `$excludeDirs`: Define where to scan. - * `$force`: If `true`, forces a re-scan even if discovery ran earlier in the same script execution. Default is `false`. - * `$saveToCache`: If `true` (default) and a PSR-16 cache was provided to the builder, the results of *this scan* (discovered elements only) will be saved to the cache, overwriting previous cache content. If `false` or no cache is configured, results are not saved. -* **Default Behavior:** Calling `discover()` performs a fresh scan. It first clears previously discovered items from the cache `$saveToCache` is true), then scans the filesystem, registers found elements (marking them as discovered), and finally saves the newly discovered elements to cache if `$saveToCache` is true. -* **Implicit Cache Loading:** When `ServerBuilder::build()` creates the `Registry`, the `Registry` constructor automatically attempts to load *discovered* elements from the cache (if a cache was configured and the cache key exists). Manually registered elements are added *after* this potential cache load. -* **Cache Content:** Only elements found via discovery are stored in the cache. Manually registered elements are never cached. - -## Configuration (`ServerBuilder`) - -You can get a server builder instance by either calling `new ServerBuilder` or more conveniently using `Server::make()`. The available methods for configuring your server instance include: +```php +$server->discover( + basePath: __DIR__, + scanDirs: ['src/Handlers', 'src/Services'], // Scan these directories + excludeDirs: ['tests', 'vendor'], // Skip these directories + force: false, // Force re-scan (default: false) + saveToCache: true // Save to cache (default: true) +); +``` -* **`withServerInfo(string $name, string $version)`**: **Required.** Server identity. -* **`withLogger(LoggerInterface $logger)`**: Optional. PSR-3 logger. Defaults to `NullLogger`. -* **`withCache(CacheInterface $cache, int $ttl = 3600)`**: Optional. PSR-16 cache for registry and client state. Defaults to `ArrayCache` only for the client state manager. -* **`withContainer(ContainerInterface $container)`**: Optional. PSR-11 container for resolving *your handler classes*. Defaults to `BasicContainer`. -* **`withLoop(LoopInterface $loop)`**: Optional. ReactPHP event loop. Defaults to `Loop::get()`. -* **`withCapabilities(Capabilities $capabilities)`**: Optional. Configure advertised capabilities (e.g., resource subscriptions). Use `Capabilities::forServer(...)`. -* **`withPaginationLimit(int $paginationLimit)`: Optional. Configures the server's pagination limit for list requests. -* `withTool(...)`, `withResource(...)`, etc.: Optional manual registration. +**Caching Behavior:** +- Only **discovered** elements are cached (never manual registrations) +- Cache loaded automatically during `build()` if available +- Fresh `discover()` calls clear and rebuild cache +- Use `force: true` to bypass discovery-already-ran check -## Running the Server (Transports) +## ๐Ÿš€ Running the Server (Transports) -The core `Server` object doesn't handle network I/O directly. You activate it using a specific transport implementation passed to `$server->listen($transport)`. +The server core is transport-agnostic. Choose a transport based on your deployment needs: -### Stdio Transport +### 1. ๐Ÿ“Ÿ Stdio Transport -Handles communication over Standard Input/Output. Ideal for servers launched directly by an MCP client (like Cursor). +**Best for**: Direct client execution, command-line tools, simple deployments ```php use PhpMcp\Server\Transports\StdioServerTransport; -// ... build $server ... +$server = Server::make() + ->withServerInfo('Stdio Server', '1.0.0') + ->build(); + +$server->discover(__DIR__, ['src']); +// Create stdio transport (uses STDIN/STDOUT by default) $transport = new StdioServerTransport(); -// This blocks until the transport is closed (e.g., SIGINT/SIGTERM) +// Start listening (blocking call) $server->listen($transport); ``` -> **Warning:** When using `StdioServerTransport`, your application code (including tool/resource handlers) **MUST NOT** write arbitrary output to `STDOUT` (using `echo`, `print`, `var_dump`, etc.). `STDOUT` is reserved for sending framed JSON-RPC messages back to the client. Use `STDERR` for logging or debugging output: -> ```php -> fwrite(STDERR, "Debug: Processing tool X\n"); -> // Or use a PSR-3 logger configured to write to STDERR: -> // $logger->debug("Processing tool X", ['param' => $value]); -> ``` +**Client Configuration:** +```json +{ + "mcpServers": { + "my-php-server": { + "command": "php", + "args": ["/absolute/path/to/server.php"] + } + } +} +``` -### HTTP Transport (HTTP+SSE) +> โš ๏ธ **Important**: When using stdio transport, **never** write to `STDOUT` in your handlers (use `STDERR` for debugging). `STDOUT` is reserved for JSON-RPC communication. -Listens for HTTP connections, handling client messages via POST and sending server messages/notifications via Server-Sent Events (SSE). +### 2. ๐ŸŒ HTTP + Server-Sent Events Transport (Deprecated) + +> โš ๏ธ **Note**: This transport is deprecated in the latest MCP protocol version but remains available for backwards compatibility. For new projects, use the [StreamableHttpServerTransport](#3--streamable-http-transport-new) which provides enhanced features and better protocol compliance. + +**Best for**: Legacy applications requiring backwards compatibility ```php use PhpMcp\Server\Transports\HttpServerTransport; -// ... build $server ... +$server = Server::make() + ->withServerInfo('HTTP Server', '1.0.0') + ->withLogger($logger) // Recommended for HTTP + ->build(); + +$server->discover(__DIR__, ['src']); +// Create HTTP transport $transport = new HttpServerTransport( - host: '127.0.0.1', // Listen on all interfaces - port: 8080, // Port to listen on - mcpPathPrefix: 'mcp' // Base path for endpoints (/mcp/sse, /mcp/message) - // sslContext: [...] // Optional: ReactPHP socket context for HTTPS + host: '127.0.0.1', // MCP protocol prohibits 0.0.0.0 + port: 8080, // Port number + mcpPathPrefix: 'mcp' // URL prefix (/mcp/sse, /mcp/message) ); -// This blocks, starting the HTTP server and running the event loop $server->listen($transport); ``` -**Concurrency Requirement:** The `HttpServerTransport` relies on ReactPHP's non-blocking I/O model. It's designed to handle multiple concurrent SSE connections efficiently. Running this transport requires a PHP environment that supports an event loop and non-blocking operations. **It will generally NOT work correctly with traditional synchronous web servers like Apache+mod_php or the built-in PHP development server.** You should run the `listen()` command using the PHP CLI in a persistent process (potentially managed by Supervisor, Docker, etc.). +**Client Configuration:** +```json +{ + "mcpServers": { + "my-http-server": { + "url": "http://localhost:8080/mcp/sse" + } + } +} +``` **Endpoints:** -* **SSE:** `GET /{mcpPathPrefix}/sse` (e.g., `GET /mcp/sse`) - Client connects here. -* **Messages:** `POST /{mcpPathPrefix}/message?clientId={clientId}` (e.g., `POST /mcp/message?clientId=sse_abc123`) - Client sends requests here. The `clientId` query parameter is essential for the server to route the message correctly to the state associated with the SSE connection. The server sends the POST path (including the generated `clientId`) via the initial `endpoint` SSE event to the client, so you will never have to manually handle this. +- **SSE Connection**: `GET /mcp/sse` +- **Message Sending**: `POST /mcp/message?clientId={clientId}` -## Connecting MCP Clients +### 3. ๐Ÿ”„ Streamable HTTP Transport (Recommended) -Instruct clients how to connect to your server: +**Best for**: Production deployments, remote MCP servers, multiple clients, resumable connections -* **`stdio`:** Provide the full command to execute your server script (e.g., `php /path/to/mcp-server.php`). The client needs execute permissions. -* **`http`:** Provide the full URL to your SSE endpoint (e.g., `http://your.domain:8080/mcp/sse`). Ensure the server process running `listen()` is accessible. +```php +use PhpMcp\Server\Transports\StreamableHttpServerTransport; -Refer to specific client documentation (Cursor, Claude Desktop, etc.) for their configuration format. +$server = Server::make() + ->withServerInfo('Streamable Server', '1.0.0') + ->withLogger($logger) + ->withCache($cache) // Required for resumability + ->build(); -## Attribute Details & Return Formatting {#attribute-details-and-return-formatting} +$server->discover(__DIR__, ['src']); -These attributes mark classes or methods to be found by the `->discover()` process. +// Create streamable transport with resumability +$transport = new StreamableHttpServerTransport( + host: '127.0.0.1', // MCP protocol prohibits 0.0.0.0 + port: 8080, + mcpPathPrefix: 'mcp', + enableJsonResponse: false // Use SSE streaming (default) +); -#### `#[McpTool]` +$server->listen($transport); +``` -Marks a method **or an invokable class** as an MCP Tool. Tools represent actions or functions the client can invoke, often with parameters. +**JSON Response Mode:** -**Usage:** +The `enableJsonResponse` option controls how responses are delivered: -* **On a Method:** Place the attribute directly above a public, non-static method. -* **On an Invokable Class:** Place the attribute directly above a class definition that contains a public `__invoke` method. The `__invoke` method will be treated as the tool's handler. +- **`false` (default)**: Uses Server-Sent Events (SSE) streams for responses. Best for tools that may take time to process. +- **`true`**: Returns immediate JSON responses without opening SSE streams. Use this when your tools execute quickly and don't need streaming. -The attribute accepts the following parameters: +```php +// For fast-executing tools, enable JSON mode +$transport = new StreamableHttpServerTransport( + host: '127.0.0.1', + port: 8080, + enableJsonResponse: true // Immediate JSON responses +); +``` -* `name` (optional): The name of the tool exposed to the client. - * When on a method, defaults to the method name (e.g., `addNumbers` becomes `addNumbers`). - * When on an invokable class, defaults to the class's short name (e.g., `class AdderTool` becomes `AdderTool`). -* `description` (optional): A description for the tool. Defaults to the method's DocBlock summary (or the `__invoke` method's summary if on a class). +**Features:** +- **Resumable connections** - clients can reconnect and replay missed events +- **Event sourcing** - all events are stored for replay +- **JSON mode** - optional JSON-only responses for fast tools +- **Enhanced session management** - persistent session state +- **Multiple client support** - designed for concurrent clients -The parameters (including name, type hints, and defaults) of the target method (or `__invoke`) define the tool's input schema. The return type hint defines the output schema. DocBlock `@param` and `@return` descriptions are used for parameter/output descriptions. +## ๐Ÿ“‹ Schema Generation and Validation -**Return Value Formatting** +The server automatically generates JSON schemas for tool parameters using a sophisticated priority system that combines PHP type hints, docblock information, and the optional `#[Schema]` attribute. These generated schemas are used both for input validation and for providing schema information to MCP clients. -The value returned by your method determines the content sent back to the client. The library automatically formats common types: +### Schema Generation Priority -* `null`: Returns empty content (if return type hint is `void`) or `TextContent` with `(null)`. -* `string`, `int`, `float`, `bool`: Automatically wrapped in `PhpMcp\Server\JsonRpc\Contents\TextContent`. -* `array`, `object`: Automatically JSON-encoded (pretty-printed) and wrapped in `TextContent`. -* `PhpMcp\Server\JsonRpc\Contents\Content` object(s): If you return an instance of `Content` (e.g., `TextContent`, `ImageContent`, `AudioContent`, `ResourceContent`) or an array of `Content` objects, they are used directly. This gives you full control over the output format. *Example:* `return TextContent::code('echo \'Hello\';', 'php');` -* Exceptions: If your method throws an exception, a `TextContent` containing the error message and type is returned. +The server follows this order of precedence when generating schemas: -The method's return type hint (`@return` tag in DocBlock) is used to generate the tool's output schema, but the actual formatting depends on the *value* returned at runtime. +1. **`#[Schema]` attribute with `definition`** - Complete schema override (highest precedence) +2. **Parameter-level `#[Schema]` attribute** - Parameter-specific schema enhancements +3. **Method-level `#[Schema]` attribute** - Method-wide schema configuration +4. **PHP type hints + docblocks** - Automatic inference from code (lowest precedence) -**Schema Generation** +When a `definition` is provided in the Schema attribute, all automatic inference is bypassed and the complete definition is used as-is. -The server automatically generates JSON Schema for tool parameters based on: +### Parameter-Level Schema Attributes -1. PHP type hints -2. DocBlock annotations -3. Schema attributes (for enhanced validation) +```php +use PhpMcp\Server\Attributes\{McpTool, Schema}; + +#[McpTool(name: 'validate_user')] +public function validateUser( + #[Schema(format: 'email')] // PHP already knows it's string + string $email, + + #[Schema( + pattern: '^[A-Z][a-z]+$', + description: 'Capitalized name' + )] + string $name, + + #[Schema(minimum: 18, maximum: 120)] // PHP already knows it's integer + int $age +): bool { + return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; +} +``` -**Examples:** +### Method-Level Schema ```php /** - * Fetches user details by ID. - * - * @param int $userId The ID of the user to fetch. - * @param bool $includeEmail Include the email address? - * @return array{id: int, name: string, email?: string} User details. + * Process user data with nested validation. */ -#[McpTool(name: 'get_user')] -public function getUserById(int $userId, bool $includeEmail = false): array +#[McpTool(name: 'create_user')] +#[Schema( + properties: [ + 'profile' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'minLength' => 2], + 'age' => ['type' => 'integer', 'minimum' => 18], + 'email' => ['type' => 'string', 'format' => 'email'] + ], + 'required' => ['name', 'email'] + ] + ], + required: ['profile'] +)] +public function createUser(array $userData): array +{ + // PHP type hint provides base 'array' type + // Method-level Schema adds object structure validation + return ['id' => 123, 'status' => 'created']; +} +``` + +### Complete Schema Override (Method-Level Only) + +```php +#[McpTool(name: 'process_api_request')] +#[Schema(definition: [ + 'type' => 'object', + 'properties' => [ + 'endpoint' => ['type' => 'string', 'format' => 'uri'], + 'method' => ['type' => 'string', 'enum' => ['GET', 'POST', 'PUT', 'DELETE']], + 'headers' => [ + 'type' => 'object', + 'patternProperties' => [ + '^[A-Za-z0-9-]+$' => ['type' => 'string'] + ] + ] + ], + 'required' => ['endpoint', 'method'] +])] +public function processApiRequest(string $endpoint, string $method, array $headers): array { - // ... implementation returning an array ... + // PHP type hints are completely ignored when definition is provided + // The schema definition above takes full precedence + return ['status' => 'processed', 'endpoint' => $endpoint]; } +``` -/** - * Process user data with nested structures. - * - * @param array{name: string, contact: array{email: string, phone?: string}} $userData - * @param string[] $tags Tags associated with the user - * @return array{success: bool, message: string} - */ -#[McpTool] -public function processUserData(array $userData, array $tags): array { - // Implementation +> โš ๏ธ **Important**: Complete schema definition override should rarely be used. It bypasses all automatic schema inference and requires you to define the entire JSON schema manually. Only use this if you're well-versed with JSON Schema specification and have complex validation requirements that cannot be achieved through the priority system. In most cases, parameter-level and method-level `#[Schema]` attributes provide sufficient flexibility. + +## ๐ŸŽจ Return Value Formatting + +The server automatically formats return values from your handlers into appropriate MCP content types: + +### Automatic Formatting + +```php +// Simple values are auto-wrapped in TextContent +public function getString(): string { return "Hello World"; } // โ†’ TextContent +public function getNumber(): int { return 42; } // โ†’ TextContent +public function getBool(): bool { return true; } // โ†’ TextContent +public function getArray(): array { return ['key' => 'value']; } // โ†’ TextContent (JSON) + +// Null handling +public function getNull(): ?string { return null; } // โ†’ TextContent("(null)") +public function returnVoid(): void { /* no return */ } // โ†’ Empty content +``` + +### Advanced Content Types + +```php +use PhpMcp\Schema\Content\{TextContent, ImageContent, AudioContent, ResourceContent}; + +public function getFormattedCode(): TextContent +{ + return TextContent::code(' 'File content here', + 'mimeType' => 'text/plain' + ]; - #[Schema(minItems: 2, uniqueItems: true)] - array $tags -): bool { - // Implementation + // Or for binary data: + // return [ + // 'blob' => base64_encode($binaryData), + // 'mimeType' => 'application/octet-stream' + // ]; } ``` -The Schema attribute adds JSON Schema constraints like string formats, numeric ranges, array constraints, and object property validations. +## ๐Ÿ”„ Batch Processing -#### `#[McpResource]` +The server automatically handles JSON-RPC batch requests: -Marks a method **or an invokable class** as representing a specific, static MCP Resource instance. Resources represent pieces of content or data identified by a URI. The target method (or `__invoke`) will typically be called when a client performs a `resources/read` for the specified URI. +```php +// Client can send multiple requests in a single HTTP call: +[ + {"jsonrpc": "2.0", "id": "1", "method": "tools/call", "params": {...}}, + {"jsonrpc": "2.0", "method": "notifications/ping"}, // notification + {"jsonrpc": "2.0", "id": "2", "method": "tools/call", "params": {...}} +] + +// Server returns batch response (excluding notifications): +[ + {"jsonrpc": "2.0", "id": "1", "result": {...}}, + {"jsonrpc": "2.0", "id": "2", "result": {...}} +] +``` + +## ๐Ÿ”ง Advanced Features -**Usage:** +### Completion Providers -* **On a Method:** Place the attribute directly above a public, non-static method. -* **On an Invokable Class:** Place the attribute directly above a class definition that contains a public `__invoke` method. The `__invoke` method will be treated as the resource handler. +Completion providers enable MCP clients to offer auto-completion suggestions in their user interfaces. They are specifically designed for **Resource Templates** and **Prompts** to help users discover available options for dynamic parts like template variables or prompt arguments. -The attribute accepts the following parameters: +> **Note**: Tools and resources can be discovered via standard MCP commands (`tools/list`, `resources/list`), so completion providers are not needed for them. Completion providers are used only for resource templates (URI variables) and prompt arguments. -* `uri` (required): The unique URI for this resource instance (e.g., `config://app/settings`, `file:///data/status.txt`). Must conform to [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). -* `name` (optional): Human-readable name. Defaults inferred from method name or class short name. -* `description` (optional): Description. Defaults to DocBlock summary of the method or `__invoke`. -* `mimeType` (optional): The resource's MIME type (e.g., `text/plain`, `application/json`). -* `size` (optional): Resource size in bytes, if known and static. -* `annotations` (optional): Array of MCP annotations (e.g., `['audience' => ['user']]`). +Completion providers must implement the `CompletionProviderInterface`: + +```php +use PhpMcp\Server\Contracts\CompletionProviderInterface; +use PhpMcp\Server\Contracts\SessionInterface; +use PhpMcp\Server\Attributes\{McpResourceTemplate, CompletionProvider}; + +class UserIdCompletionProvider implements CompletionProviderInterface +{ + public function getCompletions(string $currentValue, SessionInterface $session): array + { + // Return completion suggestions based on current input + $allUsers = ['user_1', 'user_2', 'user_3', 'admin_user']; + + // Filter based on what user has typed so far + return array_filter($allUsers, fn($user) => str_starts_with($user, $currentValue)); + } +} + +class UserService +{ + #[McpResourceTemplate(uriTemplate: 'user://{userId}/profile')] + public function getUserProfile( + #[CompletionProvider(UserIdCompletionProvider::class)] + string $userId + ): array { + // Always validate input even with completion providers + // Users can still pass any value regardless of completion suggestions + if (!$this->isValidUserId($userId)) { + throw new \InvalidArgumentException('Invalid user ID provided'); + } + + return ['id' => $userId, 'name' => 'John Doe']; + } +} +``` -The target method (or `__invoke`) should return the content of the resource. +> **Important**: Completion providers only offer suggestions to users in the MCP client interface. Users can still input any value, so always validate parameters in your handlers regardless of completion provider constraints. -**Return Value Formatting** +### Custom Dependency Injection -The return value determines the resource content: +Your MCP element handlers can use constructor dependency injection to access services like databases, APIs, or other business logic. When handlers have constructor dependencies, you must provide a pre-configured PSR-11 container that contains those dependencies. -* `string`: Treated as text content. MIME type is taken from the attribute or guessed (`text/plain`, `application/json`, `text/html`). -* `array`: If the attribute's `mimeType` is `application/json` (or contains `json`), the array is JSON-encoded. Otherwise, it attempts JSON encoding with a warning. -* `stream resource`: Content is read from the stream. `mimeType` must be provided in the attribute or defaults to `application/octet-stream`. -* `SplFileInfo` object: Content is read from the file. `mimeType` is taken from the attribute or guessed. -* `PhpMcp\Server\JsonRpc\Contents\EmbeddedResource`: Used directly. Gives full control over URI, MIME type, text/blob content. -* `PhpMcp\Server\JsonRpc\Contents\ResourceContent`: The inner `EmbeddedResource` is extracted and used. -* `array{'blob': string, 'mimeType'?: string}`: Creates a blob resource. -* `array{'text': string, 'mimeType'?: string}`: Creates a text resource. +By default, the server uses a `BasicContainer` - a simple implementation that attempts to auto-wire dependencies by instantiating classes with parameterless constructors. For dependencies that require configuration (like database connections), you can either manually add them to the BasicContainer or use a more advanced PSR-11 container like PHP-DI or Laravel's container. ```php -#[McpResource(uri: 'status://system/load', mimeType: 'text/plain')] -public function getSystemLoad(): string +use Psr\Container\ContainerInterface; + +class DatabaseService { - return file_get_contents('/proc/loadavg'); + public function __construct(private \PDO $pdo) {} + + #[McpTool(name: 'query_users')] + public function queryUsers(): array + { + $stmt = $this->pdo->query('SELECT * FROM users'); + return $stmt->fetchAll(); + } } -/** - * An invokable class providing system load resource. - */ -#[McpResource(uri: 'status://system/load/invokable', mimeType: 'text/plain')] -class SystemLoadResource { - public function __invoke(): string { - return file_get_contents('/proc/loadavg'); - } +// Option 1: Use the basic container and manually add dependencies +$basicContainer = new \PhpMcp\Server\Defaults\BasicContainer(); +$basicContainer->set(\PDO::class, new \PDO('sqlite::memory:')); + +// Option 2: Use any PSR-11 compatible container (PHP-DI, Laravel, etc.) +$container = new \DI\Container(); +$container->set(\PDO::class, new \PDO('mysql:host=localhost;dbname=app', $user, $pass)); + +$server = Server::make() + ->withContainer($basicContainer) // Handlers get dependencies auto-injected + ->build(); +``` + +### Resource Subscriptions + +```php +use PhpMcp\Schema\ServerCapabilities; + +$server = Server::make() + ->withCapabilities(ServerCapabilities::make( + resourcesSubscribe: true, // Enable resource subscriptions + prompts: true, + tools: true + )) + ->build(); + +// In your resource handler, you can notify clients of changes: +#[McpResource(uri: 'file://config.json')] +public function getConfig(): array +{ + // When config changes, notify subscribers + $this->notifyResourceChange('file://config.json'); + return ['setting' => 'value']; } ``` -#### `#[McpResourceTemplate]` +### Resumability and Event Store -Marks a method **or an invokable class** that can generate resource instances based on a template URI. This is useful for resources whose URI contains variable parts (like user IDs or document IDs). The target method (or `__invoke`) will be called when a client performs a `resources/read` matching the template. +For production deployments using `StreamableHttpServerTransport`, you can implement resumability with event sourcing by providing a custom event store: -**Usage:** +```php +use PhpMcp\Server\Contracts\EventStoreInterface; +use PhpMcp\Server\Defaults\InMemoryEventStore; +use PhpMcp\Server\Transports\StreamableHttpServerTransport; -* **On a Method:** Place the attribute directly above a public, non-static method. -* **On an Invokable Class:** Place the attribute directly above a class definition that contains a public `__invoke` method. +// Use the built-in in-memory event store (for development/testing) +$eventStore = new InMemoryEventStore(); -The attribute accepts the following parameters: +// Or implement your own persistent event store +class DatabaseEventStore implements EventStoreInterface +{ + public function storeEvent(string $streamId, string $message): string + { + // Store event in database and return unique event ID + return $this->database->insert('events', [ + 'stream_id' => $streamId, + 'message' => $message, + 'created_at' => now() + ]); + } -* `uriTemplate` (required): The URI template string, conforming to [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) (e.g., `user://{userId}/profile`, `document://{docId}?format={fmt}`). -* `name`, `description`, `mimeType`, `annotations` (optional): Similar to `#[McpResource]`, but describe the template itself. Defaults inferred from method/class name and DocBlocks. + public function replayEventsAfter(string $lastEventId, callable $sendCallback): void + { + // Replay events for resumability + $events = $this->database->getEventsAfter($lastEventId); + foreach ($events as $event) { + $sendCallback($event['id'], $event['message']); + } + } +} -The parameters of the target method (or `__invoke`) *must* match the variables defined in the `uriTemplate`. The method should return the content for the resolved resource instance. +// Configure transport with event store +$transport = new StreamableHttpServerTransport( + host: '127.0.0.1', + port: 8080, + eventStore: new DatabaseEventStore() // Enable resumability +); +``` -**Return Value Formatting** +### Custom Session Handlers -Same as `#[McpResource]` (see above). The returned value represents the content of the *resolved* resource instance. +Implement custom session storage by creating a class that implements `SessionHandlerInterface`: ```php -/** - * Gets a user's profile data. - * - * @param string $userId The user ID from the URI. - * @return array The user profile. - */ -#[McpResourceTemplate(uriTemplate: 'user://{userId}/profile', name: 'user_profile', mimeType: 'application/json')] -public function getUserProfile(string $userId): array +use PhpMcp\Server\Contracts\SessionHandlerInterface; + +class DatabaseSessionHandler implements SessionHandlerInterface { - // Fetch user profile for $userId - return ['id' => $userId, /* ... */ ]; -} + public function __construct(private \PDO $db) {} -/** - * An invokable class providing user profiles via template. - */ -#[McpResourceTemplate(uriTemplate: 'user://{userId}/profile/invokable', name: 'user_profile_invokable', mimeType: 'application/json')] -class UserProfileTemplate { - /** - * Gets a user's profile data. - * @param string $userId The user ID from the URI. - * @return array The user profile. - */ - public function __invoke(string $userId): array { - // Fetch user profile for $userId - return ['id' => $userId, 'source' => 'invokable', /* ... */ ]; + public function read(string $id): string|false + { + $stmt = $this->db->prepare('SELECT data FROM sessions WHERE id = ?'); + $stmt->execute([$id]); + $session = $stmt->fetch(\PDO::FETCH_ASSOC); + return $session ? $session['data'] : false; + } + + public function write(string $id, string $data): bool + { + $stmt = $this->db->prepare( + 'INSERT OR REPLACE INTO sessions (id, data, updated_at) VALUES (?, ?, ?)' + ); + return $stmt->execute([$id, $data, time()]); + } + + public function destroy(string $id): bool + { + $stmt = $this->db->prepare('DELETE FROM sessions WHERE id = ?'); + return $stmt->execute([$id]); + } + + public function gc(int $maxLifetime): array + { + $cutoff = time() - $maxLifetime; + $stmt = $this->db->prepare('DELETE FROM sessions WHERE updated_at < ?'); + $stmt->execute([$cutoff]); + return []; // Return array of cleaned session IDs if needed } } + +// Use custom session handler +$server = Server::make() + ->withSessionHandler(new DatabaseSessionHandler(), 3600) + ->build(); ``` -#### `#[McpPrompt]` +### SSL Context Configuration -Marks a method **or an invokable class** as an MCP Prompt generator. Prompts are pre-defined templates or functions that generate conversational messages (like user or assistant turns) based on input parameters. +For HTTPS deployments of `StreamableHttpServerTransport`, configure SSL context options: -**Usage:** +```php +$sslContext = [ + 'ssl' => [ + 'local_cert' => '/path/to/certificate.pem', + 'local_pk' => '/path/to/private-key.pem', + 'verify_peer' => false, + 'allow_self_signed' => true, + ] +]; + +$transport = new StreamableHttpServerTransport( + host: '0.0.0.0', + port: 8443, + sslContext: $sslContext +); +``` -* **On a Method:** Place the attribute directly above a public, non-static method. -* **On an Invokable Class:** Place the attribute directly above a class definition that contains a public `__invoke` method. +> **SSL Context Reference**: For complete SSL context options, see the [PHP SSL Context Options documentation](https://www.php.net/manual/en/context.ssl.php). +## ๐Ÿ” Error Handling & Debugging -The attribute accepts the following parameters: +The server provides comprehensive error handling and debugging capabilities: -* `name` (optional): The prompt name. Defaults to method name or class short name. -* `description` (optional): Description. Defaults to DocBlock summary of the method or `__invoke`. +### Exception Handling -Method parameters (or `__invoke` parameters) define the prompt's input arguments. The method should return the prompt content, typically an array conforming to the MCP message structure. +Tool handlers can throw any PHP exception when errors occur. The server automatically converts these exceptions into proper JSON-RPC error responses for MCP clients. -**Return Value Formatting** +```php +#[McpTool(name: 'divide_numbers')] +public function divideNumbers(float $dividend, float $divisor): float +{ + if ($divisor === 0.0) { + // Any exception with descriptive message will be sent to client + throw new \InvalidArgumentException('Division by zero is not allowed'); + } + + return $dividend / $divisor; +} -Your method should return the prompt messages in one of these formats: +#[McpTool(name: 'calculate_factorial')] +public function calculateFactorial(int $number): int +{ + if ($number < 0) { + throw new \InvalidArgumentException('Factorial is not defined for negative numbers'); + } + + if ($number > 20) { + throw new \OverflowException('Number too large, factorial would cause overflow'); + } + + // Implementation continues... + return $this->factorial($number); +} +``` + +The server will convert these exceptions into appropriate JSON-RPC error responses that MCP clients can understand and display to users. -* **Array of `PhpMcp\Server\JsonRpc\Contents\PromptMessage` objects**: The recommended way for full control. - * `PromptMessage::user(string|Content $content)` - * `PromptMessage::assistant(string|Content $content)` - * The `$content` can be a simple string (becomes `TextContent`) or any `Content` object (`TextContent`, `ImageContent`, `ResourceContent`, etc.). -* **Simple list array:** `[['role' => 'user', 'content' => 'Some text'], ['role' => 'assistant', 'content' => $someContentObject]]` - * `role` must be `'user'` or `'assistant'`. - * `content` can be a string (becomes `TextContent`) or a `Content` object. - * `content` can also be an array structure like `['type' => 'image', 'data' => '...', 'mimeType' => '...']`, `['type' => 'text', 'text' => '...']`, or `['type' => 'resource', 'resource' => ['uri' => '...', 'text|blob' => '...']]`. -* **Simple associative array:** `['user' => 'User prompt text', 'assistant' => 'Optional assistant prefix']` (converted to one or two `PromptMessage`s with `TextContent`). +### Logging and Debugging ```php -/** - * Generates a prompt to summarize text. - * - * @param string $textToSummarize The text to summarize. - * @return array The prompt messages. - */ -#[McpPrompt(name: 'summarize')] -public function generateSummaryPrompt(string $textToSummarize): array +use Psr\Log\LoggerInterface; + +class DebugAwareHandler { - return [ - ['role' => 'user', 'content' => "Summarize the following text:\n\n{$textToSummarize}"], - ]; + public function __construct(private LoggerInterface $logger) {} + + #[McpTool(name: 'debug_tool')] + public function debugTool(string $data): array + { + $this->logger->info('Processing debug tool', ['input' => $data]); + + // For stdio transport, use STDERR for debug output + fwrite(STDERR, "Debug: Processing data length: " . strlen($data) . "\n"); + + return ['processed' => true]; + } } +``` -/** - * An invokable class generating a summary prompt. - */ -#[McpPrompt(name: 'summarize_invokable')] -class SummarizePrompt { - /** - * Generates a prompt to summarize text. - * @param string $textToSummarize The text to summarize. - * @return array The prompt messages. - */ - public function __invoke(string $textToSummarize): array { - return [ - ['role' => 'user', 'content' => "[Invokable] Summarize: +## ๐Ÿš€ Production Deployment -{$textToSummarize}"], - ]; +Since `$server->listen()` runs a persistent process, you can deploy it using any strategy that suits your infrastructure needs. The server can be deployed on VPS, cloud instances, containers, or any environment that supports long-running processes. + +Here are two popular deployment approaches to consider: + +### Option 1: VPS with Supervisor + Nginx (Recommended) + +**Best for**: Most production deployments, cost-effective, full control + +```bash +# 1. Install your application on VPS +git clone https://github.com/yourorg/your-mcp-server.git /var/www/mcp-server +cd /var/www/mcp-server +composer install --no-dev --optimize-autoloader + +# 2. Install Supervisor +sudo apt-get install supervisor + +# 3. Create Supervisor configuration +sudo nano /etc/supervisor/conf.d/mcp-server.conf +``` + +**Supervisor Configuration:** +```ini +[program:mcp-server] +process_name=%(program_name)s_%(process_num)02d +command=php /var/www/mcp-server/server.php --transport=http --host=127.0.0.1 --port=8080 +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +user=www-data +numprocs=1 +redirect_stderr=true +stdout_logfile=/var/log/mcp-server.log +stdout_logfile_maxbytes=10MB +stdout_logfile_backups=3 +``` + +**Nginx Configuration with SSL:** +```nginx +# /etc/nginx/sites-available/mcp-server +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name mcp.yourdomain.com; + + # SSL configuration + ssl_certificate /etc/letsencrypt/live/mcp.yourdomain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/mcp.yourdomain.com/privkey.pem; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # MCP Server proxy + location / { + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Important for SSE connections + proxy_buffering off; + proxy_cache off; + + proxy_pass http://127.0.0.1:8080/; } } + +# Redirect HTTP to HTTPS +server { + listen 80; + listen [::]:80; + server_name mcp.yourdomain.com; + return 301 https://$server_name$request_uri; +} ``` -## Error Handling +**Start Services:** +```bash +# Enable and start supervisor +sudo supervisorctl reread +sudo supervisorctl update +sudo supervisorctl start mcp-server:* + +# Enable and start nginx +sudo systemctl enable nginx +sudo systemctl restart nginx + +# Check status +sudo supervisorctl status +``` -The server uses specific exceptions inheriting from `PhpMcp\Server\Exception\McpServerException`. The `Protocol` catches these and `Throwable` during message processing, converting them to appropriate JSON-RPC error responses. Transport-level errors are emitted via the transport's `error` event. +**Client Configuration:** +```json +{ + "mcpServers": { + "my-server": { + "url": "https://mcp.yourdomain.com/mcp" + } + } +} +``` -## Examples +### Option 2: Docker Deployment -See the [`examples/`](./examples/) directory: +**Best for**: Containerized environments, Kubernetes, cloud platforms -* **`01-discovery-stdio-calculator/`**: Basic `stdio` server demonstrating attribute discovery for a simple calculator. -* **`02-discovery-http-userprofile/`**: `http+sse` server using discovery for a user profile service. -* **`03-manual-registration-stdio/`**: `stdio` server showcasing only manual element registration. -* **`04-combined-registration-http/`**: `http+sse` server combining manual and discovered elements, demonstrating precedence. -* **`05-stdio-env-variables/`**: `stdio` server with a tool that uses environment variables passed by the MCP client. -* **`06-custom-dependencies-stdio/`**: `stdio` server showing DI container usage for injecting services into MCP handlers (Task Manager example). -* **`07-complex-tool-schema-http/`**: `http+sse` server with a tool demonstrating complex input schemas (optionals, defaults, enums). +**Production Dockerfile:** +```dockerfile +FROM php:8.3-fpm-alpine -## Testing +# Install system dependencies +RUN apk --no-cache add \ + nginx \ + supervisor \ + && docker-php-ext-enable opcache +# Install PHP extensions for MCP +RUN docker-php-ext-install pdo_mysql pdo_sqlite opcache + +# Create application directory +WORKDIR /var/www/mcp + +# Copy application code +COPY . /var/www/mcp +COPY docker/nginx.conf /etc/nginx/nginx.conf +COPY docker/supervisord.conf /etc/supervisord.conf +COPY docker/php.ini /usr/local/etc/php/conf.d/production.ini + +# Install Composer dependencies +RUN composer install --no-dev --optimize-autoloader --no-interaction + +# Set permissions +RUN chown -R www-data:www-data /var/www/mcp + +# Expose port +EXPOSE 80 + +# Start supervisor +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] +``` + +**docker-compose.yml:** +```yaml +services: + mcp-server: + build: . + ports: + - "8080:80" + environment: + - MCP_ENV=production + - MCP_LOG_LEVEL=info + volumes: + - ./storage:/var/www/mcp/storage + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + + # Optional: Add database if needed + database: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: secure_password + MYSQL_DATABASE: mcp_server + volumes: + - mysql_data:/var/lib/mysql + restart: unless-stopped + +volumes: + mysql_data: +``` + +### Security Best Practices + +1. **Firewall Configuration:** ```bash +# Only allow necessary ports +sudo ufw allow ssh +sudo ufw allow 80 +sudo ufw allow 443 +sudo ufw deny 8080 # MCP port should not be publicly accessible +sudo ufw enable +``` + +2. **SSL/TLS Setup:** +```bash +# Install Certbot for Let's Encrypt +sudo apt install certbot python3-certbot-nginx + +# Generate SSL certificate +sudo certbot --nginx -d mcp.yourdomain.com +``` + +## ๐Ÿ“š Examples & Use Cases + +Explore comprehensive examples in the [`examples/`](./examples/) directory: + +### Available Examples + +- **`01-discovery-stdio-calculator/`** - Basic stdio calculator with attribute discovery +- **`02-discovery-http-userprofile/`** - HTTP server with user profile management +- **`03-manual-registration-stdio/`** - Manual element registration patterns +- **`04-combined-registration-http/`** - Combining manual and discovered elements +- **`05-stdio-env-variables/`** - Environment variable handling +- **`06-custom-dependencies-stdio/`** - Dependency injection with task management +- **`07-complex-tool-schema-http/`** - Advanced schema validation examples +- **`08-schema-showcase-streamable/`** - Comprehensive schema feature showcase + +### Running Examples + +```bash +# Navigate to an example directory +cd examples/01-discovery-stdio-calculator/ + +# Make the server executable +chmod +x server.php + +# Run the server (or configure it in your MCP client) +./server.php +``` + +## ๐Ÿšง Migration from v2.x + +If migrating from version 2.x, note these key changes: + +### Schema Updates +- Uses `php-mcp/schema` package for DTOs instead of internal classes +- Content types moved to `PhpMcp\Schema\Content\*` namespace +- Updated method signatures for better type safety + +### Session Management +- New session management with multiple backends +- Use `->withSession()` or `->withSessionHandler()` for configuration +- Sessions are now persistent across reconnections (with cache backend) + +### Transport Changes +- New `StreamableHttpServerTransport` with resumability +- Enhanced error handling and event sourcing +- Better batch request processing + +## ๐Ÿงช Testing + +```bash +# Install development dependencies composer install --dev + +# Run the test suite composer test -composer test:coverage # Requires Xdebug + +# Run tests with coverage (requires Xdebug) +composer test:coverage + +# Run code style checks +composer lint ``` -## Contributing +## ๐Ÿค Contributing + +We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +## ๐Ÿ“„ License + +The MIT License (MIT). See [LICENSE](LICENSE) for details. + +## ๐Ÿ™ Acknowledgments -Please see [CONTRIBUTING.md](CONTRIBUTING.md). +- Built on the [Model Context Protocol](https://modelcontextprotocol.io/) specification +- Powered by [ReactPHP](https://reactphp.org/) for async operations +- Uses [PSR standards](https://www.php-fig.org/) for maximum interoperability -## License +--- -The MIT License (MIT). See [LICENSE](LICENSE). +**Ready to build powerful MCP servers with PHP?** Start with our [Quick Start](#-quick-start-stdio-server-with-discovery) guide! ๐Ÿš€