This is page 5 of 7. Use http://codebase.md/php-mcp/server?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .github │ └── workflows │ ├── changelog.yml │ └── tests.yml ├── .gitignore ├── .php-cs-fixer.php ├── CHANGELOG.md ├── composer.json ├── CONTRIBUTING.md ├── examples │ ├── 01-discovery-stdio-calculator │ │ ├── McpElements.php │ │ └── server.php │ ├── 02-discovery-http-userprofile │ │ ├── McpElements.php │ │ ├── server.php │ │ └── UserIdCompletionProvider.php │ ├── 03-manual-registration-stdio │ │ ├── server.php │ │ └── SimpleHandlers.php │ ├── 04-combined-registration-http │ │ ├── DiscoveredElements.php │ │ ├── ManualHandlers.php │ │ └── server.php │ ├── 05-stdio-env-variables │ │ ├── EnvToolHandler.php │ │ └── server.php │ ├── 06-custom-dependencies-stdio │ │ ├── McpTaskHandlers.php │ │ ├── server.php │ │ └── Services.php │ ├── 07-complex-tool-schema-http │ │ ├── EventTypes.php │ │ ├── McpEventScheduler.php │ │ └── server.php │ └── 08-schema-showcase-streamable │ ├── SchemaShowcaseElements.php │ └── server.php ├── LICENSE ├── phpunit.xml ├── README.md ├── src │ ├── Attributes │ │ ├── CompletionProvider.php │ │ ├── McpPrompt.php │ │ ├── McpResource.php │ │ ├── McpResourceTemplate.php │ │ ├── McpTool.php │ │ └── Schema.php │ ├── Configuration.php │ ├── Context.php │ ├── Contracts │ │ ├── CompletionProviderInterface.php │ │ ├── EventStoreInterface.php │ │ ├── LoggerAwareInterface.php │ │ ├── LoopAwareInterface.php │ │ ├── ServerTransportInterface.php │ │ ├── SessionHandlerInterface.php │ │ └── SessionInterface.php │ ├── Defaults │ │ ├── ArrayCache.php │ │ ├── BasicContainer.php │ │ ├── DefaultUuidSessionIdGenerator.php │ │ ├── EnumCompletionProvider.php │ │ ├── FileCache.php │ │ ├── InMemoryEventStore.php │ │ ├── ListCompletionProvider.php │ │ └── SystemClock.php │ ├── Dispatcher.php │ ├── Elements │ │ ├── RegisteredElement.php │ │ ├── RegisteredPrompt.php │ │ ├── RegisteredResource.php │ │ ├── RegisteredResourceTemplate.php │ │ └── RegisteredTool.php │ ├── Exception │ │ ├── ConfigurationException.php │ │ ├── DiscoveryException.php │ │ ├── McpServerException.php │ │ ├── ProtocolException.php │ │ └── TransportException.php │ ├── Protocol.php │ ├── Registry.php │ ├── Server.php │ ├── ServerBuilder.php │ ├── Session │ │ ├── ArraySessionHandler.php │ │ ├── CacheSessionHandler.php │ │ ├── Session.php │ │ ├── SessionManager.php │ │ └── SubscriptionManager.php │ ├── Transports │ │ ├── HttpServerTransport.php │ │ ├── StdioServerTransport.php │ │ └── StreamableHttpServerTransport.php │ └── Utils │ ├── Discoverer.php │ ├── DocBlockParser.php │ ├── HandlerResolver.php │ ├── SchemaGenerator.php │ └── SchemaValidator.php └── tests ├── Fixtures │ ├── Discovery │ │ ├── DiscoverablePromptHandler.php │ │ ├── DiscoverableResourceHandler.php │ │ ├── DiscoverableTemplateHandler.php │ │ ├── DiscoverableToolHandler.php │ │ ├── EnhancedCompletionHandler.php │ │ ├── InvocablePromptFixture.php │ │ ├── InvocableResourceFixture.php │ │ ├── InvocableResourceTemplateFixture.php │ │ ├── InvocableToolFixture.php │ │ ├── NonDiscoverableClass.php │ │ └── SubDir │ │ └── HiddenTool.php │ ├── Enums │ │ ├── BackedIntEnum.php │ │ ├── BackedStringEnum.php │ │ ├── PriorityEnum.php │ │ ├── StatusEnum.php │ │ └── UnitEnum.php │ ├── General │ │ ├── CompletionProviderFixture.php │ │ ├── DocBlockTestFixture.php │ │ ├── InvokableHandlerFixture.php │ │ ├── PromptHandlerFixture.php │ │ ├── RequestAttributeChecker.php │ │ ├── ResourceHandlerFixture.php │ │ ├── ToolHandlerFixture.php │ │ └── VariousTypesHandler.php │ ├── Middlewares │ │ ├── ErrorMiddleware.php │ │ ├── FirstMiddleware.php │ │ ├── HeaderMiddleware.php │ │ ├── RequestAttributeMiddleware.php │ │ ├── SecondMiddleware.php │ │ ├── ShortCircuitMiddleware.php │ │ └── ThirdMiddleware.php │ ├── Schema │ │ └── SchemaGenerationTarget.php │ ├── ServerScripts │ │ ├── HttpTestServer.php │ │ ├── StdioTestServer.php │ │ └── StreamableHttpTestServer.php │ └── Utils │ ├── AttributeFixtures.php │ ├── DockBlockParserFixture.php │ └── SchemaGeneratorFixture.php ├── Integration │ ├── DiscoveryTest.php │ ├── HttpServerTransportTest.php │ ├── SchemaGenerationTest.php │ ├── StdioServerTransportTest.php │ └── StreamableHttpServerTransportTest.php ├── Mocks │ ├── Clients │ │ ├── MockJsonHttpClient.php │ │ ├── MockSseClient.php │ │ └── MockStreamHttpClient.php │ └── Clock │ └── FixedClock.php ├── Pest.php ├── TestCase.php └── Unit ├── Attributes │ ├── CompletionProviderTest.php │ ├── McpPromptTest.php │ ├── McpResourceTemplateTest.php │ ├── McpResourceTest.php │ └── McpToolTest.php ├── ConfigurationTest.php ├── Defaults │ ├── EnumCompletionProviderTest.php │ └── ListCompletionProviderTest.php ├── DispatcherTest.php ├── Elements │ ├── RegisteredElementTest.php │ ├── RegisteredPromptTest.php │ ├── RegisteredResourceTemplateTest.php │ ├── RegisteredResourceTest.php │ └── RegisteredToolTest.php ├── ProtocolTest.php ├── RegistryTest.php ├── ServerBuilderTest.php ├── ServerTest.php ├── Session │ ├── ArraySessionHandlerTest.php │ ├── CacheSessionHandlerTest.php │ ├── SessionManagerTest.php │ └── SessionTest.php └── Utils ├── DocBlockParserTest.php ├── HandlerResolverTest.php └── SchemaValidatorTest.php ``` # Files -------------------------------------------------------------------------------- /src/Protocol.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace PhpMcp\Server; 6 | 7 | use PhpMcp\Schema\Constants; 8 | use PhpMcp\Server\Contracts\ServerTransportInterface; 9 | use PhpMcp\Server\Contracts\SessionInterface; 10 | use PhpMcp\Server\Exception\McpServerException; 11 | use PhpMcp\Schema\JsonRpc\BatchRequest; 12 | use PhpMcp\Schema\JsonRpc\BatchResponse; 13 | use PhpMcp\Schema\JsonRpc\Error; 14 | use PhpMcp\Schema\JsonRpc\Notification; 15 | use PhpMcp\Schema\JsonRpc\Request; 16 | use PhpMcp\Schema\JsonRpc\Response; 17 | use PhpMcp\Schema\Notification\PromptListChangedNotification; 18 | use PhpMcp\Schema\Notification\ResourceListChangedNotification; 19 | use PhpMcp\Schema\Notification\ResourceUpdatedNotification; 20 | use PhpMcp\Schema\Notification\RootsListChangedNotification; 21 | use PhpMcp\Schema\Notification\ToolListChangedNotification; 22 | use PhpMcp\Server\Session\SessionManager; 23 | use PhpMcp\Server\Session\SubscriptionManager; 24 | use Psr\Log\LoggerInterface; 25 | use React\Promise\PromiseInterface; 26 | use Throwable; 27 | 28 | use function React\Promise\reject; 29 | use function React\Promise\resolve; 30 | 31 | /** 32 | * Bridges the core MCP Processor logic with a ServerTransportInterface 33 | * by listening to transport events and processing incoming messages. 34 | * 35 | * This handler manages the JSON-RPC parsing, processing delegation, and response sending 36 | * based on events received from the transport layer. 37 | */ 38 | class Protocol 39 | { 40 | public const LATEST_PROTOCOL_VERSION = '2025-03-26'; 41 | public const SUPPORTED_PROTOCOL_VERSIONS = [self::LATEST_PROTOCOL_VERSION, '2024-11-05']; 42 | 43 | protected ?ServerTransportInterface $transport = null; 44 | 45 | protected LoggerInterface $logger; 46 | 47 | /** Stores listener references for proper removal */ 48 | protected array $listeners = []; 49 | 50 | public function __construct( 51 | protected Configuration $configuration, 52 | protected Registry $registry, 53 | protected SessionManager $sessionManager, 54 | protected ?Dispatcher $dispatcher = null, 55 | protected ?SubscriptionManager $subscriptionManager = null, 56 | ) { 57 | $this->logger = $this->configuration->logger; 58 | $this->subscriptionManager ??= new SubscriptionManager($this->logger); 59 | $this->dispatcher ??= new Dispatcher($this->configuration, $this->registry, $this->subscriptionManager); 60 | 61 | $this->sessionManager->on('session_deleted', function (string $sessionId) { 62 | $this->subscriptionManager->cleanupSession($sessionId); 63 | }); 64 | 65 | $this->registry->on('list_changed', function (string $listType) { 66 | $this->handleListChanged($listType); 67 | }); 68 | } 69 | 70 | /** 71 | * Binds this handler to a transport instance by attaching event listeners. 72 | * Does NOT start the transport's listening process itself. 73 | */ 74 | public function bindTransport(ServerTransportInterface $transport): void 75 | { 76 | if ($this->transport !== null) { 77 | $this->unbindTransport(); 78 | } 79 | 80 | $this->transport = $transport; 81 | 82 | $this->listeners = [ 83 | 'message' => [$this, 'processMessage'], 84 | 'client_connected' => [$this, 'handleClientConnected'], 85 | 'client_disconnected' => [$this, 'handleClientDisconnected'], 86 | 'error' => [$this, 'handleTransportError'], 87 | ]; 88 | 89 | $this->transport->on('message', $this->listeners['message']); 90 | $this->transport->on('client_connected', $this->listeners['client_connected']); 91 | $this->transport->on('client_disconnected', $this->listeners['client_disconnected']); 92 | $this->transport->on('error', $this->listeners['error']); 93 | } 94 | 95 | /** 96 | * Detaches listeners from the current transport. 97 | */ 98 | public function unbindTransport(): void 99 | { 100 | if ($this->transport && ! empty($this->listeners)) { 101 | $this->transport->removeListener('message', $this->listeners['message']); 102 | $this->transport->removeListener('client_connected', $this->listeners['client_connected']); 103 | $this->transport->removeListener('client_disconnected', $this->listeners['client_disconnected']); 104 | $this->transport->removeListener('error', $this->listeners['error']); 105 | } 106 | 107 | $this->transport = null; 108 | $this->listeners = []; 109 | } 110 | 111 | /** 112 | * Handles a message received from the transport. 113 | * 114 | * Processes via Processor, sends Response/Error. 115 | */ 116 | public function processMessage(Request|Notification|BatchRequest $message, string $sessionId, array $messageContext = []): void 117 | { 118 | $this->logger->debug('Message received.', ['sessionId' => $sessionId, 'message' => $message]); 119 | 120 | $session = $this->sessionManager->getSession($sessionId); 121 | 122 | if ($session === null) { 123 | $error = Error::forInvalidRequest('Invalid or expired session. Please re-initialize the session.', $message->id); 124 | $messageContext['status_code'] = 404; 125 | 126 | $this->transport->sendMessage($error, $sessionId, $messageContext) 127 | ->then(function () use ($sessionId, $error, $messageContext) { 128 | $this->logger->debug('Response sent.', ['sessionId' => $sessionId, 'payload' => $error, 'context' => $messageContext]); 129 | }) 130 | ->catch(function (Throwable $e) use ($sessionId) { 131 | $this->logger->error('Failed to send response.', ['sessionId' => $sessionId, 'error' => $e->getMessage()]); 132 | }); 133 | 134 | return; 135 | } 136 | 137 | if ($messageContext['stateless'] ?? false) { 138 | $session->set('initialized', true); 139 | $session->set('protocol_version', self::LATEST_PROTOCOL_VERSION); 140 | $session->set('client_info', ['name' => 'stateless-client', 'version' => '1.0.0']); 141 | } 142 | 143 | $context = new Context( 144 | $session, 145 | $messageContext['request'] ?? null, 146 | ); 147 | 148 | $response = null; 149 | 150 | if ($message instanceof BatchRequest) { 151 | $response = $this->processBatchRequest($message, $session, $context); 152 | } elseif ($message instanceof Request) { 153 | $response = $this->processRequest($message, $session, $context); 154 | } elseif ($message instanceof Notification) { 155 | $this->processNotification($message, $session); 156 | } 157 | 158 | $session->save(); 159 | 160 | if ($response === null) { 161 | return; 162 | } 163 | 164 | $this->transport->sendMessage($response, $sessionId, $messageContext) 165 | ->then(function () use ($sessionId, $response) { 166 | $this->logger->debug('Response sent.', ['sessionId' => $sessionId, 'payload' => $response]); 167 | }) 168 | ->catch(function (Throwable $e) use ($sessionId) { 169 | $this->logger->error('Failed to send response.', ['sessionId' => $sessionId, 'error' => $e->getMessage()]); 170 | }); 171 | } 172 | 173 | /** 174 | * Process a batch message 175 | */ 176 | private function processBatchRequest(BatchRequest $batch, SessionInterface $session, Context $context): ?BatchResponse 177 | { 178 | $items = []; 179 | 180 | foreach ($batch->getNotifications() as $notification) { 181 | $this->processNotification($notification, $session); 182 | } 183 | 184 | foreach ($batch->getRequests() as $request) { 185 | $items[] = $this->processRequest($request, $session, $context); 186 | } 187 | 188 | return empty($items) ? null : new BatchResponse($items); 189 | } 190 | 191 | /** 192 | * Process a request message 193 | */ 194 | private function processRequest(Request $request, SessionInterface $session, Context $context): Response|Error 195 | { 196 | try { 197 | if ($request->method !== 'initialize') { 198 | $this->assertSessionInitialized($session); 199 | } 200 | 201 | $this->assertRequestCapability($request->method); 202 | 203 | $result = $this->dispatcher->handleRequest($request, $context); 204 | 205 | return Response::make($request->id, $result); 206 | } catch (McpServerException $e) { 207 | $this->logger->debug('MCP Processor caught McpServerException', ['method' => $request->method, 'code' => $e->getCode(), 'message' => $e->getMessage(), 'data' => $e->getData()]); 208 | 209 | return $e->toJsonRpcError($request->id); 210 | } catch (Throwable $e) { 211 | $this->logger->error('MCP Processor caught unexpected error', [ 212 | 'method' => $request->method, 213 | 'exception' => $e->getMessage(), 214 | 'trace' => $e->getTraceAsString(), 215 | ]); 216 | 217 | return new Error( 218 | jsonrpc: '2.0', 219 | id: $request->id, 220 | code: Constants::INTERNAL_ERROR, 221 | message: 'Internal error processing method ' . $request->method, 222 | data: $e->getMessage() 223 | ); 224 | } 225 | } 226 | 227 | /** 228 | * Process a notification message 229 | */ 230 | private function processNotification(Notification $notification, SessionInterface $session): void 231 | { 232 | $method = $notification->method; 233 | $params = $notification->params; 234 | 235 | try { 236 | $this->dispatcher->handleNotification($notification, $session); 237 | } catch (Throwable $e) { 238 | $this->logger->error('Error while processing notification', ['method' => $method, 'exception' => $e->getMessage()]); 239 | return; 240 | } 241 | } 242 | 243 | /** 244 | * Send a notification to a session 245 | */ 246 | public function sendNotification(Notification $notification, string $sessionId): PromiseInterface 247 | { 248 | if ($this->transport === null) { 249 | $this->logger->error('Cannot send notification, transport not bound', [ 250 | 'sessionId' => $sessionId, 251 | 'method' => $notification->method 252 | ]); 253 | return reject(new McpServerException('Transport not bound')); 254 | } 255 | 256 | return $this->transport->sendMessage($notification, $sessionId, []) 257 | ->then(function () { 258 | return resolve(null); 259 | }) 260 | ->catch(function (Throwable $e) { 261 | return reject(new McpServerException('Failed to send notification: ' . $e->getMessage(), previous: $e)); 262 | }); 263 | } 264 | 265 | /** 266 | * Notify subscribers about resource content change 267 | */ 268 | public function notifyResourceUpdated(string $uri): void 269 | { 270 | $subscribers = $this->subscriptionManager->getSubscribers($uri); 271 | 272 | if (empty($subscribers)) { 273 | return; 274 | } 275 | 276 | $notification = ResourceUpdatedNotification::make($uri); 277 | 278 | foreach ($subscribers as $sessionId) { 279 | $this->sendNotification($notification, $sessionId); 280 | } 281 | 282 | $this->logger->debug("Sent resource change notification", [ 283 | 'uri' => $uri, 284 | 'subscriber_count' => count($subscribers) 285 | ]); 286 | } 287 | 288 | /** 289 | * Validate that a session is initialized 290 | */ 291 | private function assertSessionInitialized(SessionInterface $session): void 292 | { 293 | if (!$session->get('initialized', false)) { 294 | throw McpServerException::invalidRequest('Client session not initialized.'); 295 | } 296 | } 297 | 298 | /** 299 | * Assert that a request method is enabled 300 | */ 301 | private function assertRequestCapability(string $method): void 302 | { 303 | $capabilities = $this->configuration->capabilities; 304 | 305 | switch ($method) { 306 | case "ping": 307 | case "initialize": 308 | // No specific capability required for these methods 309 | break; 310 | 311 | case 'tools/list': 312 | case 'tools/call': 313 | if (!$capabilities->tools) { 314 | throw McpServerException::methodNotFound($method, 'Tools are not enabled on this server.'); 315 | } 316 | break; 317 | 318 | case 'resources/list': 319 | case 'resources/templates/list': 320 | case 'resources/read': 321 | if (!$capabilities->resources) { 322 | throw McpServerException::methodNotFound($method, 'Resources are not enabled on this server.'); 323 | } 324 | break; 325 | 326 | case 'resources/subscribe': 327 | case 'resources/unsubscribe': 328 | if (!$capabilities->resources) { 329 | throw McpServerException::methodNotFound($method, 'Resources are not enabled on this server.'); 330 | } 331 | if (!$capabilities->resourcesSubscribe) { 332 | throw McpServerException::methodNotFound($method, 'Resources subscription is not enabled on this server.'); 333 | } 334 | break; 335 | 336 | case 'prompts/list': 337 | case 'prompts/get': 338 | if (!$capabilities->prompts) { 339 | throw McpServerException::methodNotFound($method, 'Prompts are not enabled on this server.'); 340 | } 341 | break; 342 | 343 | case 'logging/setLevel': 344 | if (!$capabilities->logging) { 345 | throw McpServerException::methodNotFound($method, 'Logging is not enabled on this server.'); 346 | } 347 | break; 348 | 349 | case 'completion/complete': 350 | if (!$capabilities->completions) { 351 | throw McpServerException::methodNotFound($method, 'Completions are not enabled on this server.'); 352 | } 353 | break; 354 | 355 | default: 356 | break; 357 | } 358 | } 359 | 360 | private function canSendNotification(string $method): bool 361 | { 362 | $capabilities = $this->configuration->capabilities; 363 | 364 | $valid = true; 365 | 366 | switch ($method) { 367 | case 'notifications/message': 368 | if (!$capabilities->logging) { 369 | $this->logger->warning('Logging is not enabled on this server. Notifications/message will not be sent.'); 370 | $valid = false; 371 | } 372 | break; 373 | 374 | case "notifications/resources/updated": 375 | case "notifications/resources/list_changed": 376 | if (!$capabilities->resources || !$capabilities->resourcesListChanged) { 377 | $this->logger->warning('Resources list changed notifications are not enabled on this server. Notifications/resources/list_changed will not be sent.'); 378 | $valid = false; 379 | } 380 | break; 381 | 382 | case "notifications/tools/list_changed": 383 | if (!$capabilities->tools || !$capabilities->toolsListChanged) { 384 | $this->logger->warning('Tools list changed notifications are not enabled on this server. Notifications/tools/list_changed will not be sent.'); 385 | $valid = false; 386 | } 387 | break; 388 | 389 | case "notifications/prompts/list_changed": 390 | if (!$capabilities->prompts || !$capabilities->promptsListChanged) { 391 | $this->logger->warning('Prompts list changed notifications are not enabled on this server. Notifications/prompts/list_changed will not be sent.'); 392 | $valid = false; 393 | } 394 | break; 395 | 396 | case "notifications/cancelled": 397 | // Cancellation notifications are always allowed 398 | break; 399 | 400 | case "notifications/progress": 401 | // Progress notifications are always allowed 402 | break; 403 | 404 | default: 405 | break; 406 | } 407 | 408 | return $valid; 409 | } 410 | 411 | /** 412 | * Handles 'client_connected' event from the transport 413 | */ 414 | public function handleClientConnected(string $sessionId): void 415 | { 416 | $this->logger->info('Client connected', ['sessionId' => $sessionId]); 417 | 418 | $this->sessionManager->createSession($sessionId); 419 | } 420 | 421 | /** 422 | * Handles 'client_disconnected' event from the transport 423 | */ 424 | public function handleClientDisconnected(string $sessionId, ?string $reason = null): void 425 | { 426 | $this->logger->info('Client disconnected', ['clientId' => $sessionId, 'reason' => $reason ?? 'N/A']); 427 | 428 | $this->sessionManager->deleteSession($sessionId); 429 | } 430 | 431 | /** 432 | * Handle list changed event from registry 433 | */ 434 | public function handleListChanged(string $listType): void 435 | { 436 | $listChangeUri = "mcp://changes/{$listType}"; 437 | 438 | $subscribers = $this->subscriptionManager->getSubscribers($listChangeUri); 439 | if (empty($subscribers)) { 440 | return; 441 | } 442 | 443 | $notification = match ($listType) { 444 | 'resources' => ResourceListChangedNotification::make(), 445 | 'tools' => ToolListChangedNotification::make(), 446 | 'prompts' => PromptListChangedNotification::make(), 447 | 'roots' => RootsListChangedNotification::make(), 448 | default => throw new \InvalidArgumentException("Invalid list type: {$listType}"), 449 | }; 450 | 451 | if (!$this->canSendNotification($notification->method)) { 452 | return; 453 | } 454 | 455 | foreach ($subscribers as $sessionId) { 456 | $this->sendNotification($notification, $sessionId); 457 | } 458 | 459 | $this->logger->debug("Sent list change notification", [ 460 | 'list_type' => $listType, 461 | 'subscriber_count' => count($subscribers) 462 | ]); 463 | } 464 | 465 | /** 466 | * Handles 'error' event from the transport 467 | */ 468 | public function handleTransportError(Throwable $error, ?string $clientId = null): void 469 | { 470 | $context = ['error' => $error->getMessage(), 'exception_class' => get_class($error)]; 471 | 472 | if ($clientId) { 473 | $context['clientId'] = $clientId; 474 | $this->logger->error('Transport error for client', $context); 475 | } else { 476 | $this->logger->error('General transport error', $context); 477 | } 478 | } 479 | } 480 | ``` -------------------------------------------------------------------------------- /src/Utils/Discoverer.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace PhpMcp\Server\Utils; 6 | 7 | use PhpMcp\Schema\Prompt; 8 | use PhpMcp\Schema\PromptArgument; 9 | use PhpMcp\Schema\Resource; 10 | use PhpMcp\Schema\ResourceTemplate; 11 | use PhpMcp\Schema\Tool; 12 | use PhpMcp\Server\Attributes\CompletionProvider; 13 | use PhpMcp\Server\Attributes\McpPrompt; 14 | use PhpMcp\Server\Attributes\McpResource; 15 | use PhpMcp\Server\Attributes\McpResourceTemplate; 16 | use PhpMcp\Server\Attributes\McpTool; 17 | use PhpMcp\Server\Defaults\EnumCompletionProvider; 18 | use PhpMcp\Server\Defaults\ListCompletionProvider; 19 | use PhpMcp\Server\Exception\McpServerException; 20 | use PhpMcp\Server\Registry; 21 | use Psr\Log\LoggerInterface; 22 | use ReflectionAttribute; 23 | use ReflectionClass; 24 | use ReflectionException; 25 | use ReflectionMethod; 26 | use Symfony\Component\Finder\Finder; 27 | use Symfony\Component\Finder\SplFileInfo; 28 | use Throwable; 29 | 30 | class Discoverer 31 | { 32 | private DocBlockParser $docBlockParser; 33 | 34 | private SchemaGenerator $schemaGenerator; 35 | 36 | public function __construct( 37 | private Registry $registry, 38 | private LoggerInterface $logger, 39 | ?DocBlockParser $docBlockParser = null, 40 | ?SchemaGenerator $schemaGenerator = null, 41 | ) { 42 | $this->docBlockParser = $docBlockParser ?? new DocBlockParser($this->logger); 43 | $this->schemaGenerator = $schemaGenerator ?? new SchemaGenerator($this->docBlockParser); 44 | } 45 | 46 | /** 47 | * Discover MCP elements in the specified directories. 48 | * 49 | * @param string $basePath The base path for resolving directories. 50 | * @param array<string> $directories List of directories (relative to base path) to scan. 51 | * @param array<string> $excludeDirs List of directories (relative to base path) to exclude from the scan. 52 | */ 53 | public function discover(string $basePath, array $directories, array $excludeDirs = []): void 54 | { 55 | $startTime = microtime(true); 56 | $discoveredCount = [ 57 | 'tools' => 0, 58 | 'resources' => 0, 59 | 'prompts' => 0, 60 | 'resourceTemplates' => 0, 61 | ]; 62 | 63 | try { 64 | $finder = new Finder(); 65 | $absolutePaths = []; 66 | foreach ($directories as $dir) { 67 | $path = rtrim($basePath, '/') . '/' . ltrim($dir, '/'); 68 | if (is_dir($path)) { 69 | $absolutePaths[] = $path; 70 | } 71 | } 72 | 73 | if (empty($absolutePaths)) { 74 | $this->logger->warning('No valid discovery directories found to scan.', ['configured_paths' => $directories, 'base_path' => $basePath]); 75 | 76 | return; 77 | } 78 | 79 | $finder->files() 80 | ->in($absolutePaths) 81 | ->exclude($excludeDirs) 82 | ->name('*.php'); 83 | 84 | foreach ($finder as $file) { 85 | $this->processFile($file, $discoveredCount); 86 | } 87 | } catch (Throwable $e) { 88 | $this->logger->error('Error during file finding process for MCP discovery', [ 89 | 'exception' => $e->getMessage(), 90 | 'trace' => $e->getTraceAsString(), 91 | ]); 92 | } 93 | 94 | $duration = microtime(true) - $startTime; 95 | $this->logger->info('Attribute discovery finished.', [ 96 | 'duration_sec' => round($duration, 3), 97 | 'tools' => $discoveredCount['tools'], 98 | 'resources' => $discoveredCount['resources'], 99 | 'prompts' => $discoveredCount['prompts'], 100 | 'resourceTemplates' => $discoveredCount['resourceTemplates'], 101 | ]); 102 | } 103 | 104 | /** 105 | * Process a single PHP file for MCP elements on classes or methods. 106 | */ 107 | private function processFile(SplFileInfo $file, array &$discoveredCount): void 108 | { 109 | $filePath = $file->getRealPath(); 110 | if ($filePath === false) { 111 | $this->logger->warning('Could not get real path for file', ['path' => $file->getPathname()]); 112 | 113 | return; 114 | } 115 | 116 | $className = $this->getClassFromFile($filePath); 117 | if (! $className) { 118 | $this->logger->warning('No valid class found in file', ['file' => $filePath]); 119 | 120 | return; 121 | } 122 | 123 | try { 124 | $reflectionClass = new ReflectionClass($className); 125 | 126 | if ($reflectionClass->isAbstract() || $reflectionClass->isInterface() || $reflectionClass->isTrait() || $reflectionClass->isEnum()) { 127 | return; 128 | } 129 | 130 | $processedViaClassAttribute = false; 131 | if ($reflectionClass->hasMethod('__invoke')) { 132 | $invokeMethod = $reflectionClass->getMethod('__invoke'); 133 | if ($invokeMethod->isPublic() && ! $invokeMethod->isStatic()) { 134 | $attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class]; 135 | foreach ($attributeTypes as $attributeType) { 136 | $classAttribute = $reflectionClass->getAttributes($attributeType, ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; 137 | if ($classAttribute) { 138 | $this->processMethod($invokeMethod, $discoveredCount, $classAttribute); 139 | $processedViaClassAttribute = true; 140 | break; 141 | } 142 | } 143 | } 144 | } 145 | 146 | if (! $processedViaClassAttribute) { 147 | foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { 148 | if ( 149 | $method->getDeclaringClass()->getName() !== $reflectionClass->getName() || 150 | $method->isStatic() || $method->isAbstract() || $method->isConstructor() || $method->isDestructor() || $method->getName() === '__invoke' 151 | ) { 152 | continue; 153 | } 154 | $attributeTypes = [McpTool::class, McpResource::class, McpPrompt::class, McpResourceTemplate::class]; 155 | foreach ($attributeTypes as $attributeType) { 156 | $methodAttribute = $method->getAttributes($attributeType, ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; 157 | if ($methodAttribute) { 158 | $this->processMethod($method, $discoveredCount, $methodAttribute); 159 | break; 160 | } 161 | } 162 | } 163 | } 164 | } catch (ReflectionException $e) { 165 | $this->logger->error('Reflection error processing file for MCP discovery', ['file' => $filePath, 'class' => $className, 'exception' => $e->getMessage()]); 166 | } catch (Throwable $e) { 167 | $this->logger->error('Unexpected error processing file for MCP discovery', [ 168 | 'file' => $filePath, 169 | 'class' => $className, 170 | 'exception' => $e->getMessage(), 171 | 'trace' => $e->getTraceAsString(), 172 | ]); 173 | } 174 | } 175 | 176 | /** 177 | * Process a method with a given MCP attribute instance. 178 | * Can be called for regular methods or the __invoke method of an invokable class. 179 | * 180 | * @param ReflectionMethod $method The target method (e.g., regular method or __invoke). 181 | * @param array $discoveredCount Pass by reference to update counts. 182 | * @param ReflectionAttribute<McpTool|McpResource|McpPrompt|McpResourceTemplate> $attribute The ReflectionAttribute instance found (on method or class). 183 | */ 184 | private function processMethod(ReflectionMethod $method, array &$discoveredCount, ReflectionAttribute $attribute): void 185 | { 186 | $className = $method->getDeclaringClass()->getName(); 187 | $classShortName = $method->getDeclaringClass()->getShortName(); 188 | $methodName = $method->getName(); 189 | $attributeClassName = $attribute->getName(); 190 | 191 | try { 192 | $instance = $attribute->newInstance(); 193 | 194 | switch ($attributeClassName) { 195 | case McpTool::class: 196 | $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); 197 | $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName); 198 | $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; 199 | $inputSchema = $this->schemaGenerator->generate($method); 200 | $tool = Tool::make($name, $inputSchema, $description, $instance->annotations); 201 | $this->registry->registerTool($tool, [$className, $methodName]); 202 | $discoveredCount['tools']++; 203 | break; 204 | 205 | case McpResource::class: 206 | $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); 207 | $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName); 208 | $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; 209 | $mimeType = $instance->mimeType; 210 | $size = $instance->size; 211 | $annotations = $instance->annotations; 212 | $resource = Resource::make($instance->uri, $name, $description, $mimeType, $annotations, $size); 213 | $this->registry->registerResource($resource, [$className, $methodName]); 214 | $discoveredCount['resources']++; 215 | break; 216 | 217 | case McpPrompt::class: 218 | $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); 219 | $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName); 220 | $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; 221 | $arguments = []; 222 | $paramTags = $this->docBlockParser->getParamTags($docBlock); 223 | foreach ($method->getParameters() as $param) { 224 | $reflectionType = $param->getType(); 225 | if ($reflectionType instanceof \ReflectionNamedType && ! $reflectionType->isBuiltin()) { 226 | continue; 227 | } 228 | $paramTag = $paramTags['$' . $param->getName()] ?? null; 229 | $arguments[] = PromptArgument::make($param->getName(), $paramTag ? trim((string) $paramTag->getDescription()) : null, ! $param->isOptional() && ! $param->isDefaultValueAvailable()); 230 | } 231 | $prompt = Prompt::make($name, $description, $arguments); 232 | $completionProviders = $this->getCompletionProviders($method); 233 | $this->registry->registerPrompt($prompt, [$className, $methodName], $completionProviders); 234 | $discoveredCount['prompts']++; 235 | break; 236 | 237 | case McpResourceTemplate::class: 238 | $docBlock = $this->docBlockParser->parseDocBlock($method->getDocComment() ?? null); 239 | $name = $instance->name ?? ($methodName === '__invoke' ? $classShortName : $methodName); 240 | $description = $instance->description ?? $this->docBlockParser->getSummary($docBlock) ?? null; 241 | $mimeType = $instance->mimeType; 242 | $annotations = $instance->annotations; 243 | $resourceTemplate = ResourceTemplate::make($instance->uriTemplate, $name, $description, $mimeType, $annotations); 244 | $completionProviders = $this->getCompletionProviders($method); 245 | $this->registry->registerResourceTemplate($resourceTemplate, [$className, $methodName], $completionProviders); 246 | $discoveredCount['resourceTemplates']++; 247 | break; 248 | } 249 | } catch (McpServerException $e) { 250 | $this->logger->error("Failed to process MCP attribute on {$className}::{$methodName}", ['attribute' => $attributeClassName, 'exception' => $e->getMessage(), 'trace' => $e->getPrevious() ? $e->getPrevious()->getTraceAsString() : $e->getTraceAsString()]); 251 | } catch (Throwable $e) { 252 | $this->logger->error("Unexpected error processing attribute on {$className}::{$methodName}", ['attribute' => $attributeClassName, 'exception' => $e->getMessage(), 'trace' => $e->getTraceAsString()]); 253 | } 254 | } 255 | 256 | private function getCompletionProviders(\ReflectionMethod $reflectionMethod): array 257 | { 258 | $completionProviders = []; 259 | foreach ($reflectionMethod->getParameters() as $param) { 260 | $reflectionType = $param->getType(); 261 | if ($reflectionType instanceof \ReflectionNamedType && ! $reflectionType->isBuiltin()) { 262 | continue; 263 | } 264 | 265 | $completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF); 266 | if (!empty($completionAttributes)) { 267 | $attributeInstance = $completionAttributes[0]->newInstance(); 268 | 269 | if ($attributeInstance->provider) { 270 | $completionProviders[$param->getName()] = $attributeInstance->provider; 271 | } elseif ($attributeInstance->providerClass) { 272 | $completionProviders[$param->getName()] = $attributeInstance->provider; 273 | } elseif ($attributeInstance->values) { 274 | $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values); 275 | } elseif ($attributeInstance->enum) { 276 | $completionProviders[$param->getName()] = new EnumCompletionProvider($attributeInstance->enum); 277 | } 278 | } 279 | } 280 | 281 | return $completionProviders; 282 | } 283 | 284 | /** 285 | * Attempt to determine the FQCN from a PHP file path. 286 | * Uses tokenization to extract namespace and class name. 287 | * 288 | * @param string $filePath Absolute path to the PHP file. 289 | * @return class-string|null The FQCN or null if not found/determinable. 290 | */ 291 | private function getClassFromFile(string $filePath): ?string 292 | { 293 | if (! file_exists($filePath) || ! is_readable($filePath)) { 294 | $this->logger->warning('File does not exist or is not readable.', ['file' => $filePath]); 295 | 296 | return null; 297 | } 298 | 299 | try { 300 | $content = file_get_contents($filePath); 301 | if ($content === false) { 302 | $this->logger->warning('Failed to read file content.', ['file' => $filePath]); 303 | 304 | return null; 305 | } 306 | if (strlen($content) > 500 * 1024) { 307 | $this->logger->debug('Skipping large file during class discovery.', ['file' => $filePath]); 308 | 309 | return null; 310 | } 311 | 312 | $tokens = token_get_all($content); 313 | } catch (Throwable $e) { 314 | $this->logger->warning("Failed to read or tokenize file during class discovery: {$filePath}", ['exception' => $e->getMessage()]); 315 | 316 | return null; 317 | } 318 | 319 | $namespace = ''; 320 | $namespaceFound = false; 321 | $level = 0; 322 | $potentialClasses = []; 323 | 324 | $tokenCount = count($tokens); 325 | for ($i = 0; $i < $tokenCount; $i++) { 326 | if (is_array($tokens[$i]) && $tokens[$i][0] === T_NAMESPACE) { 327 | $namespace = ''; 328 | for ($j = $i + 1; $j < $tokenCount; $j++) { 329 | if ($tokens[$j] === ';' || $tokens[$j] === '{') { 330 | $namespaceFound = true; 331 | $i = $j; 332 | break; 333 | } 334 | if (is_array($tokens[$j]) && in_array($tokens[$j][0], [T_STRING, T_NAME_QUALIFIED])) { 335 | $namespace .= $tokens[$j][1]; 336 | } elseif ($tokens[$j][0] === T_NS_SEPARATOR) { 337 | $namespace .= '\\'; 338 | } 339 | } 340 | if ($namespaceFound) { 341 | break; 342 | } 343 | } 344 | } 345 | $namespace = trim($namespace, '\\'); 346 | 347 | for ($i = 0; $i < $tokenCount; $i++) { 348 | $token = $tokens[$i]; 349 | if ($token === '{') { 350 | $level++; 351 | 352 | continue; 353 | } 354 | if ($token === '}') { 355 | $level--; 356 | 357 | continue; 358 | } 359 | 360 | if ($level === ($namespaceFound && str_contains($content, "namespace {$namespace} {") ? 1 : 0)) { 361 | if (is_array($token) && in_array($token[0], [T_CLASS, T_INTERFACE, T_TRAIT, defined('T_ENUM') ? T_ENUM : -1])) { 362 | for ($j = $i + 1; $j < $tokenCount; $j++) { 363 | if (is_array($tokens[$j]) && $tokens[$j][0] === T_STRING) { 364 | $className = $tokens[$j][1]; 365 | $potentialClasses[] = $namespace ? $namespace . '\\' . $className : $className; 366 | $i = $j; 367 | break; 368 | } 369 | if ($tokens[$j] === ';' || $tokens[$j] === '{' || $tokens[$j] === ')') { 370 | break; 371 | } 372 | } 373 | } 374 | } 375 | } 376 | 377 | foreach ($potentialClasses as $potentialClass) { 378 | if (class_exists($potentialClass, true)) { 379 | return $potentialClass; 380 | } 381 | } 382 | 383 | if (! empty($potentialClasses)) { 384 | if (! class_exists($potentialClasses[0], false)) { 385 | $this->logger->debug('getClassFromFile returning potential non-class type. Are you sure this class has been autoloaded?', ['file' => $filePath, 'type' => $potentialClasses[0]]); 386 | } 387 | 388 | return $potentialClasses[0]; 389 | } 390 | 391 | return null; 392 | } 393 | } 394 | ``` -------------------------------------------------------------------------------- /tests/Integration/SchemaGenerationTest.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | uses(\PhpMcp\Server\Tests\TestCase::class); 4 | 5 | use PhpMcp\Server\Utils\DocBlockParser; 6 | use PhpMcp\Server\Utils\SchemaGenerator; 7 | use PhpMcp\Server\Tests\Fixtures\Utils\SchemaGeneratorFixture; 8 | 9 | beforeEach(function () { 10 | $docBlockParser = new DocBlockParser(); 11 | $this->schemaGenerator = new SchemaGenerator($docBlockParser); 12 | }); 13 | 14 | it('generates an empty properties object for a method with no parameters', function () { 15 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'noParams'); 16 | $schema = $this->schemaGenerator->generate($method); 17 | 18 | expect($schema)->toEqual([ 19 | 'type' => 'object', 20 | 'properties' => new stdClass() 21 | ]); 22 | expect($schema)->not->toHaveKey('required'); 23 | }); 24 | 25 | it('infers basic types from PHP type hints when no DocBlocks or Schema attributes are present', function () { 26 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsOnly'); 27 | $schema = $this->schemaGenerator->generate($method); 28 | 29 | expect($schema['properties']['name'])->toEqual(['type' => 'string']); 30 | expect($schema['properties']['age'])->toEqual(['type' => 'integer']); 31 | expect($schema['properties']['active'])->toEqual(['type' => 'boolean']); 32 | expect($schema['properties']['tags'])->toEqual(['type' => 'array']); 33 | expect($schema['properties']['config'])->toEqual(['type' => ['null', 'object'], 'default' => null]); 34 | 35 | expect($schema['required'])->toEqualCanonicalizing(['name', 'age', 'active', 'tags']); 36 | }); 37 | 38 | it('infers types and descriptions from DocBlock @param tags when no PHP type hints are present', function () { 39 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'docBlockOnly'); 40 | $schema = $this->schemaGenerator->generate($method); 41 | 42 | expect($schema['properties']['username'])->toEqual(['type' => 'string', 'description' => 'The username']); 43 | expect($schema['properties']['count'])->toEqual(['type' => 'integer', 'description' => 'Number of items']); 44 | expect($schema['properties']['enabled'])->toEqual(['type' => 'boolean', 'description' => 'Whether enabled']); 45 | expect($schema['properties']['data'])->toEqual(['type' => 'array', 'description' => 'Some data']); 46 | 47 | expect($schema['required'])->toEqualCanonicalizing(['username', 'count', 'enabled', 'data']); 48 | }); 49 | 50 | it('uses PHP type hints for type and DocBlock @param tags for descriptions when both are present', function () { 51 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsWithDocBlock'); 52 | $schema = $this->schemaGenerator->generate($method); 53 | 54 | expect($schema['properties']['email'])->toEqual(['type' => 'string', 'description' => 'User email address']); 55 | expect($schema['properties']['score'])->toEqual(['type' => 'integer', 'description' => 'User score']); 56 | expect($schema['properties']['verified'])->toEqual(['type' => 'boolean', 'description' => 'Whether user is verified']); 57 | 58 | expect($schema['required'])->toEqualCanonicalizing(['email', 'score', 'verified']); 59 | }); 60 | 61 | it('ignores Context parameter for schema', function () { 62 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'contextParameter'); 63 | $schema = $this->schemaGenerator->generate($method); 64 | 65 | expect($schema)->toEqual([ 66 | 'type' => 'object', 67 | 'properties' => new stdClass() 68 | ]); 69 | }); 70 | 71 | it('uses the complete schema definition provided by a method-level #[Schema(definition: ...)] attribute', function () { 72 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelCompleteDefinition'); 73 | $schema = $this->schemaGenerator->generate($method); 74 | 75 | // Should return the complete definition as-is 76 | expect($schema)->toEqual([ 77 | 'type' => 'object', 78 | 'description' => 'Creates a custom filter with complete definition', 79 | 'properties' => [ 80 | 'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']], 81 | 'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']], 82 | 'value' => ['description' => 'Value to filter by, type depends on field and operator'] 83 | ], 84 | 'required' => ['field', 'operator', 'value'], 85 | 'if' => [ 86 | 'properties' => ['field' => ['const' => 'date']] 87 | ], 88 | 'then' => [ 89 | 'properties' => ['value' => ['type' => 'string', 'format' => 'date']] 90 | ] 91 | ]); 92 | }); 93 | 94 | it('generates schema from a method-level #[Schema] attribute defining properties for each parameter', function () { 95 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelWithProperties'); 96 | $schema = $this->schemaGenerator->generate($method); 97 | 98 | expect($schema['description'])->toBe("Creates a new user with detailed information."); 99 | expect($schema['properties']['username'])->toEqual(['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$']); 100 | expect($schema['properties']['email'])->toEqual(['type' => 'string', 'format' => 'email']); 101 | expect($schema['properties']['age'])->toEqual(['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.']); 102 | expect($schema['properties']['isActive'])->toEqual(['type' => 'boolean', 'default' => true]); 103 | 104 | expect($schema['required'])->toEqualCanonicalizing(['age', 'username', 'email']); 105 | }); 106 | 107 | it('generates schema for a single array argument defined by a method-level #[Schema] attribute', function () { 108 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelArrayArgument'); 109 | $schema = $this->schemaGenerator->generate($method); 110 | 111 | expect($schema['properties']['profiles'])->toEqual([ 112 | 'type' => 'array', 113 | 'description' => 'An array of user profiles to update.', 114 | 'minItems' => 1, 115 | 'items' => [ 116 | 'type' => 'object', 117 | 'properties' => [ 118 | 'id' => ['type' => 'integer'], 119 | 'data' => ['type' => 'object', 'additionalProperties' => true] 120 | ], 121 | 'required' => ['id', 'data'] 122 | ] 123 | ]); 124 | 125 | expect($schema['required'])->toEqual(['profiles']); 126 | }); 127 | 128 | it('generates schema from individual parameter-level #[Schema] attributes', function () { 129 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterLevelOnly'); 130 | $schema = $this->schemaGenerator->generate($method); 131 | 132 | expect($schema['properties']['recipientId'])->toEqual(['description' => "Recipient ID", 'pattern' => "^user_", 'type' => 'string']); 133 | expect($schema['properties']['messageBody'])->toEqual(['maxLength' => 1024, 'type' => 'string']); 134 | expect($schema['properties']['priority'])->toEqual(['type' => 'integer', 'enum' => [1, 2, 5], 'default' => 1]); 135 | expect($schema['properties']['notificationConfig'])->toEqual([ 136 | 'type' => 'object', 137 | 'properties' => [ 138 | 'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']], 139 | 'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'] 140 | ], 141 | 'required' => ['type'], 142 | 'default' => null 143 | ]); 144 | 145 | expect($schema['required'])->toEqualCanonicalizing(['recipientId', 'messageBody']); 146 | }); 147 | 148 | it('applies string constraints from parameter-level #[Schema] attributes', function () { 149 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterStringConstraints'); 150 | $schema = $this->schemaGenerator->generate($method); 151 | 152 | expect($schema['properties']['email'])->toEqual(['format' => 'email', 'type' => 'string']); 153 | expect($schema['properties']['password'])->toEqual(['minLength' => 8, 'pattern' => '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$', 'type' => 'string']); 154 | expect($schema['properties']['regularString'])->toEqual(['type' => 'string']); 155 | 156 | expect($schema['required'])->toEqualCanonicalizing(['email', 'password', 'regularString']); 157 | }); 158 | 159 | it('applies numeric constraints from parameter-level #[Schema] attributes', function () { 160 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterNumericConstraints'); 161 | $schema = $this->schemaGenerator->generate($method); 162 | 163 | expect($schema['properties']['age'])->toEqual(['minimum' => 18, 'maximum' => 120, 'type' => 'integer']); 164 | expect($schema['properties']['rating'])->toEqual(['minimum' => 0, 'maximum' => 5, 'exclusiveMaximum' => true, 'type' => 'number']); 165 | expect($schema['properties']['count'])->toEqual(['multipleOf' => 10, 'type' => 'integer']); 166 | 167 | expect($schema['required'])->toEqualCanonicalizing(['age', 'rating', 'count']); 168 | }); 169 | 170 | it('applies array constraints (minItems, uniqueItems, items schema) from parameter-level #[Schema]', function () { 171 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterArrayConstraints'); 172 | $schema = $this->schemaGenerator->generate($method); 173 | 174 | expect($schema['properties']['tags'])->toEqual(['type' => 'array', 'items' => ['type' => 'string'], 'minItems' => 1, 'uniqueItems' => true]); 175 | expect($schema['properties']['scores'])->toEqual(['type' => 'array', 'items' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], 'minItems' => 1, 'maxItems' => 5]); 176 | 177 | expect($schema['required'])->toEqualCanonicalizing(['tags', 'scores']); 178 | }); 179 | 180 | it('merges method-level and parameter-level #[Schema] attributes, with parameter-level taking precedence', function () { 181 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodAndParameterLevel'); 182 | $schema = $this->schemaGenerator->generate($method); 183 | 184 | // Method level defines base properties 185 | expect($schema['properties']['settingKey'])->toEqual(['type' => 'string', 'description' => 'The key of the setting.']); 186 | 187 | // Parameter level Schema overrides method level for newValue 188 | expect($schema['properties']['newValue'])->toEqual(['description' => "The specific new boolean value.", 'type' => 'boolean']); 189 | 190 | expect($schema['required'])->toEqualCanonicalizing(['settingKey', 'newValue']); 191 | }); 192 | 193 | it('combines PHP type hints, DocBlock descriptions, and parameter-level #[Schema] constraints', function () { 194 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintDocBlockAndParameterSchema'); 195 | $schema = $this->schemaGenerator->generate($method); 196 | 197 | expect($schema['properties']['username'])->toEqual(['minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$', 'type' => 'string', 'description' => "The user's name"]); 198 | expect($schema['properties']['priority'])->toEqual(['minimum' => 1, 'maximum' => 10, 'type' => 'integer', 'description' => 'Task priority level']); 199 | 200 | expect($schema['required'])->toEqualCanonicalizing(['username', 'priority']); 201 | }); 202 | 203 | it('generates correct schema for backed and unit enum parameters, inferring from type hints', function () { 204 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters'); 205 | $schema = $this->schemaGenerator->generate($method); 206 | 207 | expect($schema['properties']['stringEnum'])->toEqual(['type' => 'string', 'description' => 'Backed string enum', 'enum' => ['A', 'B']]); 208 | expect($schema['properties']['intEnum'])->toEqual(['type' => 'integer', 'description' => 'Backed int enum', 'enum' => [1, 2]]); 209 | expect($schema['properties']['unitEnum'])->toEqual(['type' => 'string', 'description' => 'Unit enum', 'enum' => ['Yes', 'No']]); 210 | expect($schema['properties']['nullableEnum'])->toEqual(['type' => ['null', 'string'], 'enum' => ['A', 'B'], 'default' => null]); 211 | expect($schema['properties']['enumWithDefault'])->toEqual(['type' => 'integer', 'enum' => [1, 2], 'default' => 1]); 212 | 213 | expect($schema['required'])->toEqualCanonicalizing(['stringEnum', 'intEnum', 'unitEnum']); 214 | }); 215 | 216 | it('correctly generates schemas for various array type declarations (generic, typed, shape)', function () { 217 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypeScenarios'); 218 | $schema = $this->schemaGenerator->generate($method); 219 | 220 | expect($schema['properties']['genericArray'])->toEqual(['type' => 'array', 'description' => 'Generic array']); 221 | expect($schema['properties']['stringArray'])->toEqual(['type' => 'array', 'description' => 'Array of strings', 'items' => ['type' => 'string']]); 222 | expect($schema['properties']['intArray'])->toEqual(['type' => 'array', 'description' => 'Array of integers', 'items' => ['type' => 'integer']]); 223 | expect($schema['properties']['mixedMap'])->toEqual(['type' => 'array', 'description' => 'Mixed array map']); 224 | 225 | // Object-like arrays should be converted to object type 226 | expect($schema['properties']['objectLikeArray'])->toHaveKey('type'); 227 | expect($schema['properties']['objectLikeArray']['type'])->toBe('object'); 228 | expect($schema['properties']['objectLikeArray'])->toHaveKey('properties'); 229 | expect($schema['properties']['objectLikeArray']['properties'])->toHaveKeys(['name', 'age']); 230 | 231 | expect($schema['required'])->toEqualCanonicalizing(['genericArray', 'stringArray', 'intArray', 'mixedMap', 'objectLikeArray', 'nestedObjectArray']); 232 | }); 233 | 234 | it('handles nullable type hints and optional parameters with default values correctly', function () { 235 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndOptional'); 236 | $schema = $this->schemaGenerator->generate($method); 237 | 238 | expect($schema['properties']['nullableString'])->toEqual(['type' => ['null', 'string'], 'description' => 'Nullable string']); 239 | expect($schema['properties']['nullableInt'])->toEqual(['type' => ['null', 'integer'], 'description' => 'Nullable integer', 'default' => null]); 240 | expect($schema['properties']['optionalString'])->toEqual(['type' => 'string', 'default' => 'default']); 241 | expect($schema['properties']['optionalBool'])->toEqual(['type' => 'boolean', 'default' => true]); 242 | expect($schema['properties']['optionalArray'])->toEqual(['type' => 'array', 'default' => []]); 243 | 244 | expect($schema['required'])->toEqualCanonicalizing(['nullableString']); 245 | }); 246 | 247 | it('generates schema for PHP union types, sorting types alphabetically', function () { 248 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'unionTypes'); 249 | $schema = $this->schemaGenerator->generate($method); 250 | 251 | expect($schema['properties']['stringOrInt'])->toEqual(['type' => ['integer', 'string'], 'description' => 'String or integer']); 252 | expect($schema['properties']['multiUnion'])->toEqual(['type' => ['null', 'boolean', 'string'], 'description' => 'Bool, string or null']); 253 | 254 | expect($schema['required'])->toEqualCanonicalizing(['stringOrInt', 'multiUnion']); 255 | }); 256 | 257 | it('represents variadic string parameters as an array of strings', function () { 258 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'variadicStrings'); 259 | $schema = $this->schemaGenerator->generate($method); 260 | 261 | expect($schema['properties']['items'])->toEqual(['type' => 'array', 'description' => 'Variadic strings', 'items' => ['type' => 'string']]); 262 | expect($schema)->not->toHaveKey('required'); 263 | // Variadic is optional 264 | }); 265 | 266 | it('applies item constraints from parameter-level #[Schema] to variadic parameters', function () { 267 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'variadicWithConstraints'); 268 | $schema = $this->schemaGenerator->generate($method); 269 | 270 | expect($schema['properties']['numbers'])->toEqual(['items' => ['type' => 'integer', 'minimum' => 0], 'type' => 'array', 'description' => 'Variadic integers']); 271 | expect($schema)->not->toHaveKey('required'); 272 | }); 273 | 274 | it('handles mixed type hints, omitting explicit type in schema and using defaults', function () { 275 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'mixedTypes'); 276 | $schema = $this->schemaGenerator->generate($method); 277 | 278 | expect($schema['properties']['anyValue'])->toEqual(['description' => 'Any value']); 279 | expect($schema['properties']['optionalAny'])->toEqual(['description' => 'Optional any value', 'default' => 'default']); 280 | 281 | expect($schema['required'])->toEqualCanonicalizing(['anyValue']); 282 | }); 283 | 284 | it('generates schema for complex nested object and array structures defined in parameter-level #[Schema]', function () { 285 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'complexNestedSchema'); 286 | $schema = $this->schemaGenerator->generate($method); 287 | 288 | expect($schema['properties']['order'])->toEqual([ 289 | 'type' => 'object', 290 | 'properties' => [ 291 | 'customer' => [ 292 | 'type' => 'object', 293 | 'properties' => [ 294 | 'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'], 295 | 'name' => ['type' => 'string', 'minLength' => 2], 296 | 'email' => ['type' => 'string', 'format' => 'email'] 297 | ], 298 | 'required' => ['id', 'name'] 299 | ], 300 | 'items' => [ 301 | 'type' => 'array', 302 | 'minItems' => 1, 303 | 'items' => [ 304 | 'type' => 'object', 305 | 'properties' => [ 306 | 'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'], 307 | 'quantity' => ['type' => 'integer', 'minimum' => 1], 308 | 'price' => ['type' => 'number', 'minimum' => 0] 309 | ], 310 | 'required' => ['product_id', 'quantity', 'price'] 311 | ] 312 | ], 313 | 'metadata' => [ 314 | 'type' => 'object', 315 | 'additionalProperties' => true 316 | ] 317 | ], 318 | 'required' => ['customer', 'items'] 319 | ]); 320 | 321 | expect($schema['required'])->toEqual(['order']); 322 | }); 323 | 324 | it('demonstrates type precedence: parameter #[Schema] overrides DocBlock, which overrides PHP type hint', function () { 325 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'typePrecedenceTest'); 326 | $schema = $this->schemaGenerator->generate($method); 327 | 328 | // DocBlock type (integer) should override PHP type (string) 329 | expect($schema['properties']['numericString'])->toEqual(['type' => 'integer', 'description' => 'DocBlock says integer despite string type hint']); 330 | 331 | // Schema constraints should be applied with PHP type 332 | expect($schema['properties']['stringWithConstraints'])->toEqual(['format' => 'email', 'minLength' => 5, 'type' => 'string', 'description' => 'String with Schema constraints']); 333 | 334 | // Schema should override DocBlock array item type 335 | expect($schema['properties']['arrayWithItems'])->toEqual(['items' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], 'type' => 'array', 'description' => 'Array with Schema item overrides']); 336 | 337 | expect($schema['required'])->toEqualCanonicalizing(['numericString', 'stringWithConstraints', 'arrayWithItems']); 338 | }); 339 | 340 | it('generates an empty properties object for a method with no parameters even if a method-level #[Schema] is present', function () { 341 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'noParamsWithSchema'); 342 | $schema = $this->schemaGenerator->generate($method); 343 | 344 | expect($schema['description'])->toBe("Gets server status. Takes no arguments."); 345 | expect($schema['properties'])->toBeInstanceOf(stdClass::class); 346 | expect($schema)->not->toHaveKey('required'); 347 | }); 348 | 349 | it('infers parameter type as "any" (omits type) if only constraints are given in #[Schema] without type hint or DocBlock type', function () { 350 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterSchemaInferredType'); 351 | $schema = $this->schemaGenerator->generate($method); 352 | 353 | expect($schema['properties']['inferredParam'])->toEqual(['description' => "Some parameter", 'minLength' => 3]); 354 | 355 | expect($schema['required'])->toEqual(['inferredParam']); 356 | }); 357 | 358 | it('uses raw parameter-level schema definition as-is', function () { 359 | $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterWithRawDefinition'); 360 | $schema = $this->schemaGenerator->generate($method); 361 | 362 | expect($schema['properties']['custom'])->toEqual([ 363 | 'description' => 'Custom-defined schema', 364 | 'type' => 'string', 365 | 'format' => 'uuid' 366 | ]); 367 | 368 | expect($schema['required'])->toEqual(['custom']); 369 | }); 370 | ``` -------------------------------------------------------------------------------- /src/ServerBuilder.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | declare(strict_types=1); 4 | 5 | namespace PhpMcp\Server; 6 | 7 | use Closure; 8 | use PhpMcp\Schema\Annotations; 9 | use PhpMcp\Schema\Implementation; 10 | use PhpMcp\Schema\Prompt; 11 | use PhpMcp\Schema\PromptArgument; 12 | use PhpMcp\Schema\Resource; 13 | use PhpMcp\Schema\ResourceTemplate; 14 | use PhpMcp\Schema\ServerCapabilities; 15 | use PhpMcp\Schema\Tool; 16 | use PhpMcp\Schema\ToolAnnotations; 17 | use PhpMcp\Server\Attributes\CompletionProvider; 18 | use PhpMcp\Server\Contracts\SessionHandlerInterface; 19 | use PhpMcp\Server\Defaults\BasicContainer; 20 | use PhpMcp\Server\Defaults\EnumCompletionProvider; 21 | use PhpMcp\Server\Defaults\ListCompletionProvider; 22 | use PhpMcp\Server\Exception\ConfigurationException; 23 | 24 | use PhpMcp\Server\Session\ArraySessionHandler; 25 | use PhpMcp\Server\Session\CacheSessionHandler; 26 | use PhpMcp\Server\Session\SessionManager; 27 | use PhpMcp\Server\Utils\HandlerResolver; 28 | use Psr\Container\ContainerInterface; 29 | use Psr\Log\LoggerInterface; 30 | use Psr\Log\NullLogger; 31 | use Psr\SimpleCache\CacheInterface; 32 | use React\EventLoop\Loop; 33 | use React\EventLoop\LoopInterface; 34 | use Throwable; 35 | 36 | final class ServerBuilder 37 | { 38 | private ?Implementation $serverInfo = null; 39 | 40 | private ?ServerCapabilities $capabilities = null; 41 | 42 | private ?LoggerInterface $logger = null; 43 | 44 | private ?CacheInterface $cache = null; 45 | 46 | private ?ContainerInterface $container = null; 47 | 48 | private ?LoopInterface $loop = null; 49 | 50 | private ?SessionHandlerInterface $sessionHandler = null; 51 | 52 | private ?string $sessionDriver = null; 53 | 54 | private ?int $sessionTtl = 3600; 55 | 56 | private ?int $paginationLimit = 50; 57 | 58 | private ?string $instructions = null; 59 | 60 | /** @var array< 61 | * array{handler: array|string|Closure, 62 | * name: string|null, 63 | * description: string|null, 64 | * annotations: ToolAnnotations|null} 65 | * > */ 66 | private array $manualTools = []; 67 | 68 | /** @var array< 69 | * array{handler: array|string|Closure, 70 | * uri: string, 71 | * name: string|null, 72 | * description: string|null, 73 | * mimeType: string|null, 74 | * size: int|null, 75 | * annotations: Annotations|null} 76 | * > */ 77 | private array $manualResources = []; 78 | 79 | /** @var array< 80 | * array{handler: array|string|Closure, 81 | * uriTemplate: string, 82 | * name: string|null, 83 | * description: string|null, 84 | * mimeType: string|null, 85 | * annotations: Annotations|null} 86 | * > */ 87 | private array $manualResourceTemplates = []; 88 | 89 | /** @var array< 90 | * array{handler: array|string|Closure, 91 | * name: string|null, 92 | * description: string|null} 93 | * > */ 94 | private array $manualPrompts = []; 95 | 96 | public function __construct() {} 97 | 98 | /** 99 | * Sets the server's identity. Required. 100 | */ 101 | public function withServerInfo(string $name, string $version): self 102 | { 103 | $this->serverInfo = Implementation::make(name: trim($name), version: trim($version)); 104 | 105 | return $this; 106 | } 107 | 108 | /** 109 | * Configures the server's declared capabilities. 110 | */ 111 | public function withCapabilities(ServerCapabilities $capabilities): self 112 | { 113 | $this->capabilities = $capabilities; 114 | 115 | return $this; 116 | } 117 | 118 | /** 119 | * Configures the server's pagination limit. 120 | */ 121 | public function withPaginationLimit(int $paginationLimit): self 122 | { 123 | $this->paginationLimit = $paginationLimit; 124 | 125 | return $this; 126 | } 127 | 128 | /** 129 | * Configures the instructions describing how to use the server and its features. 130 | * 131 | * This can be used by clients to improve the LLM's understanding of available tools, resources, 132 | * etc. It can be thought of like a "hint" to the model. For example, this information MAY 133 | * be added to the system prompt. 134 | */ 135 | public function withInstructions(?string $instructions): self 136 | { 137 | $this->instructions = $instructions; 138 | 139 | return $this; 140 | } 141 | 142 | /** 143 | * Provides a PSR-3 logger instance. Defaults to NullLogger. 144 | */ 145 | public function withLogger(LoggerInterface $logger): self 146 | { 147 | $this->logger = $logger; 148 | 149 | return $this; 150 | } 151 | 152 | /** 153 | * Provides a PSR-16 cache instance used for all internal caching. 154 | */ 155 | public function withCache(CacheInterface $cache): self 156 | { 157 | $this->cache = $cache; 158 | 159 | return $this; 160 | } 161 | 162 | /** 163 | * Configures session handling with a specific driver. 164 | * 165 | * @param 'array' | 'cache' $driver The session driver: 'array' for in-memory sessions, 'cache' for cache-backed sessions 166 | * @param int $ttl Session time-to-live in seconds. Defaults to 3600. 167 | */ 168 | public function withSession(string $driver, int $ttl = 3600): self 169 | { 170 | if (!in_array($driver, ['array', 'cache'], true)) { 171 | throw new \InvalidArgumentException( 172 | "Unsupported session driver '{$driver}'. Only 'array' and 'cache' drivers are supported. " . 173 | "For custom session handling, use withSessionHandler() instead." 174 | ); 175 | } 176 | 177 | $this->sessionDriver = $driver; 178 | $this->sessionTtl = $ttl; 179 | 180 | return $this; 181 | } 182 | 183 | /** 184 | * Provides a custom session handler. 185 | */ 186 | public function withSessionHandler(SessionHandlerInterface $sessionHandler, int $sessionTtl = 3600): self 187 | { 188 | $this->sessionHandler = $sessionHandler; 189 | $this->sessionTtl = $sessionTtl; 190 | 191 | return $this; 192 | } 193 | 194 | /** 195 | * Provides a PSR-11 DI container, primarily for resolving user-defined handler classes. 196 | * Defaults to a basic internal container. 197 | */ 198 | public function withContainer(ContainerInterface $container): self 199 | { 200 | $this->container = $container; 201 | 202 | return $this; 203 | } 204 | 205 | /** 206 | * Provides a ReactPHP Event Loop instance. Defaults to Loop::get(). 207 | */ 208 | public function withLoop(LoopInterface $loop): self 209 | { 210 | $this->loop = $loop; 211 | 212 | return $this; 213 | } 214 | 215 | /** 216 | * Manually registers a tool handler. 217 | */ 218 | public function withTool(callable|array|string $handler, ?string $name = null, ?string $description = null, ?ToolAnnotations $annotations = null, ?array $inputSchema = null): self 219 | { 220 | $this->manualTools[] = compact('handler', 'name', 'description', 'annotations', 'inputSchema'); 221 | 222 | return $this; 223 | } 224 | 225 | /** 226 | * Manually registers a resource handler. 227 | */ 228 | public function withResource(callable|array|string $handler, string $uri, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?int $size = null, ?Annotations $annotations = null): self 229 | { 230 | $this->manualResources[] = compact('handler', 'uri', 'name', 'description', 'mimeType', 'size', 'annotations'); 231 | 232 | return $this; 233 | } 234 | 235 | /** 236 | * Manually registers a resource template handler. 237 | */ 238 | public function withResourceTemplate(callable|array|string $handler, string $uriTemplate, ?string $name = null, ?string $description = null, ?string $mimeType = null, ?Annotations $annotations = null): self 239 | { 240 | $this->manualResourceTemplates[] = compact('handler', 'uriTemplate', 'name', 'description', 'mimeType', 'annotations'); 241 | 242 | return $this; 243 | } 244 | 245 | /** 246 | * Manually registers a prompt handler. 247 | */ 248 | public function withPrompt(callable|array|string $handler, ?string $name = null, ?string $description = null): self 249 | { 250 | $this->manualPrompts[] = compact('handler', 'name', 'description'); 251 | 252 | return $this; 253 | } 254 | 255 | /** 256 | * Builds the fully configured Server instance. 257 | * 258 | * @throws ConfigurationException If required configuration is missing. 259 | */ 260 | public function build(): Server 261 | { 262 | if ($this->serverInfo === null) { 263 | throw new ConfigurationException('Server name and version must be provided using withServerInfo().'); 264 | } 265 | 266 | $loop = $this->loop ?? Loop::get(); 267 | $cache = $this->cache; 268 | $logger = $this->logger ?? new NullLogger(); 269 | $container = $this->container ?? new BasicContainer(); 270 | $capabilities = $this->capabilities ?? ServerCapabilities::make(); 271 | 272 | $configuration = new Configuration( 273 | serverInfo: $this->serverInfo, 274 | capabilities: $capabilities, 275 | logger: $logger, 276 | loop: $loop, 277 | cache: $cache, 278 | container: $container, 279 | paginationLimit: $this->paginationLimit ?? 50, 280 | instructions: $this->instructions, 281 | ); 282 | 283 | $sessionHandler = $this->createSessionHandler(); 284 | $sessionManager = new SessionManager($sessionHandler, $logger, $loop, $this->sessionTtl); 285 | $registry = new Registry($logger, $cache, $sessionManager); 286 | $protocol = new Protocol($configuration, $registry, $sessionManager); 287 | 288 | $registry->disableNotifications(); 289 | 290 | $this->registerManualElements($registry, $logger); 291 | 292 | $registry->enableNotifications(); 293 | 294 | $server = new Server($configuration, $registry, $protocol, $sessionManager); 295 | 296 | return $server; 297 | } 298 | 299 | /** 300 | * Helper to perform the actual registration based on stored data. 301 | * Moved into the builder. 302 | */ 303 | private function registerManualElements(Registry $registry, LoggerInterface $logger): void 304 | { 305 | if (empty($this->manualTools) && empty($this->manualResources) && empty($this->manualResourceTemplates) && empty($this->manualPrompts)) { 306 | return; 307 | } 308 | 309 | $docBlockParser = new Utils\DocBlockParser($logger); 310 | $schemaGenerator = new Utils\SchemaGenerator($docBlockParser); 311 | 312 | // Register Tools 313 | foreach ($this->manualTools as $data) { 314 | try { 315 | $reflection = HandlerResolver::resolve($data['handler']); 316 | 317 | if ($reflection instanceof \ReflectionFunction) { 318 | $name = $data['name'] ?? 'closure_tool_' . spl_object_id($data['handler']); 319 | $description = $data['description'] ?? null; 320 | } else { 321 | $classShortName = $reflection->getDeclaringClass()->getShortName(); 322 | $methodName = $reflection->getName(); 323 | $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); 324 | 325 | $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); 326 | $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; 327 | } 328 | 329 | $inputSchema = $data['inputSchema'] ?? $schemaGenerator->generate($reflection); 330 | 331 | $tool = Tool::make($name, $inputSchema, $description, $data['annotations']); 332 | $registry->registerTool($tool, $data['handler'], true); 333 | 334 | $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); 335 | $logger->debug("Registered manual tool {$name} from handler {$handlerDesc}"); 336 | } catch (Throwable $e) { 337 | $logger->error('Failed to register manual tool', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); 338 | throw new ConfigurationException("Error registering manual tool '{$data['name']}': {$e->getMessage()}", 0, $e); 339 | } 340 | } 341 | 342 | // Register Resources 343 | foreach ($this->manualResources as $data) { 344 | try { 345 | $reflection = HandlerResolver::resolve($data['handler']); 346 | 347 | if ($reflection instanceof \ReflectionFunction) { 348 | $name = $data['name'] ?? 'closure_resource_' . spl_object_id($data['handler']); 349 | $description = $data['description'] ?? null; 350 | } else { 351 | $classShortName = $reflection->getDeclaringClass()->getShortName(); 352 | $methodName = $reflection->getName(); 353 | $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); 354 | 355 | $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); 356 | $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; 357 | } 358 | 359 | $uri = $data['uri']; 360 | $mimeType = $data['mimeType']; 361 | $size = $data['size']; 362 | $annotations = $data['annotations']; 363 | 364 | $resource = Resource::make($uri, $name, $description, $mimeType, $annotations, $size); 365 | $registry->registerResource($resource, $data['handler'], true); 366 | 367 | $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); 368 | $logger->debug("Registered manual resource {$name} from handler {$handlerDesc}"); 369 | } catch (Throwable $e) { 370 | $logger->error('Failed to register manual resource', ['handler' => $data['handler'], 'uri' => $data['uri'], 'exception' => $e]); 371 | throw new ConfigurationException("Error registering manual resource '{$data['uri']}': {$e->getMessage()}", 0, $e); 372 | } 373 | } 374 | 375 | // Register Templates 376 | foreach ($this->manualResourceTemplates as $data) { 377 | try { 378 | $reflection = HandlerResolver::resolve($data['handler']); 379 | 380 | if ($reflection instanceof \ReflectionFunction) { 381 | $name = $data['name'] ?? 'closure_template_' . spl_object_id($data['handler']); 382 | $description = $data['description'] ?? null; 383 | } else { 384 | $classShortName = $reflection->getDeclaringClass()->getShortName(); 385 | $methodName = $reflection->getName(); 386 | $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); 387 | 388 | $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); 389 | $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; 390 | } 391 | 392 | $uriTemplate = $data['uriTemplate']; 393 | $mimeType = $data['mimeType']; 394 | $annotations = $data['annotations']; 395 | 396 | $template = ResourceTemplate::make($uriTemplate, $name, $description, $mimeType, $annotations); 397 | $completionProviders = $this->getCompletionProviders($reflection); 398 | $registry->registerResourceTemplate($template, $data['handler'], $completionProviders, true); 399 | 400 | $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); 401 | $logger->debug("Registered manual template {$name} from handler {$handlerDesc}"); 402 | } catch (Throwable $e) { 403 | $logger->error('Failed to register manual template', ['handler' => $data['handler'], 'uriTemplate' => $data['uriTemplate'], 'exception' => $e]); 404 | throw new ConfigurationException("Error registering manual resource template '{$data['uriTemplate']}': {$e->getMessage()}", 0, $e); 405 | } 406 | } 407 | 408 | // Register Prompts 409 | foreach ($this->manualPrompts as $data) { 410 | try { 411 | $reflection = HandlerResolver::resolve($data['handler']); 412 | 413 | if ($reflection instanceof \ReflectionFunction) { 414 | $name = $data['name'] ?? 'closure_prompt_' . spl_object_id($data['handler']); 415 | $description = $data['description'] ?? null; 416 | } else { 417 | $classShortName = $reflection->getDeclaringClass()->getShortName(); 418 | $methodName = $reflection->getName(); 419 | $docBlock = $docBlockParser->parseDocBlock($reflection->getDocComment() ?? null); 420 | 421 | $name = $data['name'] ?? ($methodName === '__invoke' ? $classShortName : $methodName); 422 | $description = $data['description'] ?? $docBlockParser->getSummary($docBlock) ?? null; 423 | } 424 | 425 | $arguments = []; 426 | $paramTags = $reflection instanceof \ReflectionMethod ? $docBlockParser->getParamTags($docBlockParser->parseDocBlock($reflection->getDocComment() ?? null)) : []; 427 | foreach ($reflection->getParameters() as $param) { 428 | $reflectionType = $param->getType(); 429 | 430 | // Basic DI check (heuristic) 431 | if ($reflectionType instanceof \ReflectionNamedType && ! $reflectionType->isBuiltin()) { 432 | continue; 433 | } 434 | 435 | $paramTag = $paramTags['$' . $param->getName()] ?? null; 436 | $arguments[] = PromptArgument::make( 437 | name: $param->getName(), 438 | description: $paramTag ? trim((string) $paramTag->getDescription()) : null, 439 | required: ! $param->isOptional() && ! $param->isDefaultValueAvailable() 440 | ); 441 | } 442 | 443 | $prompt = Prompt::make($name, $description, $arguments); 444 | $completionProviders = $this->getCompletionProviders($reflection); 445 | $registry->registerPrompt($prompt, $data['handler'], $completionProviders, true); 446 | 447 | $handlerDesc = $data['handler'] instanceof \Closure ? 'Closure' : (is_array($data['handler']) ? implode('::', $data['handler']) : $data['handler']); 448 | $logger->debug("Registered manual prompt {$name} from handler {$handlerDesc}"); 449 | } catch (Throwable $e) { 450 | $logger->error('Failed to register manual prompt', ['handler' => $data['handler'], 'name' => $data['name'], 'exception' => $e]); 451 | throw new ConfigurationException("Error registering manual prompt '{$data['name']}': {$e->getMessage()}", 0, $e); 452 | } 453 | } 454 | 455 | $logger->debug('Manual element registration complete.'); 456 | } 457 | 458 | /** 459 | * Creates the appropriate session handler based on configuration. 460 | * 461 | * @throws ConfigurationException If cache driver is selected but no cache is provided 462 | */ 463 | private function createSessionHandler(): SessionHandlerInterface 464 | { 465 | // If a custom session handler was provided, use it 466 | if ($this->sessionHandler !== null) { 467 | return $this->sessionHandler; 468 | } 469 | 470 | // If no session driver was specified, default to array 471 | if ($this->sessionDriver === null) { 472 | return new ArraySessionHandler($this->sessionTtl ?? 3600); 473 | } 474 | 475 | // Create handler based on driver 476 | return match ($this->sessionDriver) { 477 | 'array' => new ArraySessionHandler($this->sessionTtl ?? 3600), 478 | 'cache' => $this->createCacheSessionHandler(), 479 | default => throw new ConfigurationException("Unsupported session driver: {$this->sessionDriver}") 480 | }; 481 | } 482 | 483 | /** 484 | * Creates a cache-based session handler. 485 | * 486 | * @throws ConfigurationException If no cache is configured 487 | */ 488 | private function createCacheSessionHandler(): CacheSessionHandler 489 | { 490 | if ($this->cache === null) { 491 | throw new ConfigurationException( 492 | "Cache session driver requires a cache instance. Please configure a cache using withCache() before using withSession('cache')." 493 | ); 494 | } 495 | 496 | return new CacheSessionHandler($this->cache, $this->sessionTtl ?? 3600); 497 | } 498 | 499 | private function getCompletionProviders(\ReflectionMethod|\ReflectionFunction $reflection): array 500 | { 501 | $completionProviders = []; 502 | foreach ($reflection->getParameters() as $param) { 503 | $reflectionType = $param->getType(); 504 | if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) { 505 | continue; 506 | } 507 | 508 | $completionAttributes = $param->getAttributes(CompletionProvider::class, \ReflectionAttribute::IS_INSTANCEOF); 509 | if (!empty($completionAttributes)) { 510 | $attributeInstance = $completionAttributes[0]->newInstance(); 511 | 512 | if ($attributeInstance->provider) { 513 | $completionProviders[$param->getName()] = $attributeInstance->provider; 514 | } elseif ($attributeInstance->providerClass) { 515 | $completionProviders[$param->getName()] = $attributeInstance->providerClass; 516 | } elseif ($attributeInstance->values) { 517 | $completionProviders[$param->getName()] = new ListCompletionProvider($attributeInstance->values); 518 | } elseif ($attributeInstance->enum) { 519 | $completionProviders[$param->getName()] = new EnumCompletionProvider($attributeInstance->enum); 520 | } 521 | } 522 | } 523 | 524 | return $completionProviders; 525 | } 526 | } 527 | ``` -------------------------------------------------------------------------------- /tests/Unit/ServerBuilderTest.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Tests\Unit; 4 | 5 | use Mockery; 6 | use PhpMcp\Schema\Implementation; 7 | use PhpMcp\Schema\ServerCapabilities; 8 | use PhpMcp\Server\Attributes\CompletionProvider; 9 | use PhpMcp\Server\Contracts\CompletionProviderInterface; 10 | use PhpMcp\Server\Contracts\SessionHandlerInterface; 11 | use PhpMcp\Server\Contracts\SessionInterface; 12 | use PhpMcp\Server\Defaults\BasicContainer; 13 | use PhpMcp\Server\Elements\RegisteredPrompt; 14 | use PhpMcp\Server\Elements\RegisteredTool; 15 | use PhpMcp\Server\Exception\ConfigurationException; 16 | use PhpMcp\Server\Protocol; 17 | use PhpMcp\Server\Registry; 18 | use PhpMcp\Server\Server; 19 | use PhpMcp\Server\ServerBuilder; 20 | use PhpMcp\Server\Session\ArraySessionHandler; 21 | use PhpMcp\Server\Session\CacheSessionHandler; 22 | use PhpMcp\Server\Session\SessionManager; 23 | use Psr\Container\ContainerInterface; 24 | use Psr\Log\LoggerInterface; 25 | use Psr\Log\NullLogger; 26 | use Psr\SimpleCache\CacheInterface; 27 | use React\EventLoop\LoopInterface; 28 | use React\EventLoop\TimerInterface; 29 | use ReflectionClass; 30 | 31 | class SB_DummyHandlerClass 32 | { 33 | public function handle(string $arg): string 34 | { 35 | return "handled: {$arg}"; 36 | } 37 | 38 | public function noArgsHandler(): string 39 | { 40 | return "no-args"; 41 | } 42 | 43 | public function handlerWithCompletion( 44 | string $name, 45 | #[CompletionProvider(provider: SB_DummyCompletionProvider::class)] 46 | string $uriParam 47 | ): array { 48 | return []; 49 | } 50 | } 51 | 52 | class SB_DummyInvokableClass 53 | { 54 | public function __invoke(int $id): array 55 | { 56 | return ['id' => $id]; 57 | } 58 | } 59 | 60 | class SB_DummyCompletionProvider implements CompletionProviderInterface 61 | { 62 | public function getCompletions(string $currentValue, SessionInterface $session): array 63 | { 64 | return []; 65 | } 66 | } 67 | 68 | 69 | beforeEach(function () { 70 | $this->builder = new ServerBuilder(); 71 | }); 72 | 73 | 74 | it('sets server info correctly', function () { 75 | $this->builder->withServerInfo('MyServer', '1.2.3'); 76 | $serverInfo = getPrivateProperty($this->builder, 'serverInfo'); 77 | expect($serverInfo)->toBeInstanceOf(Implementation::class) 78 | ->and($serverInfo->name)->toBe('MyServer') 79 | ->and($serverInfo->version)->toBe('1.2.3'); 80 | }); 81 | 82 | it('sets capabilities correctly', function () { 83 | $capabilities = ServerCapabilities::make(toolsListChanged: true); 84 | $this->builder->withCapabilities($capabilities); 85 | expect(getPrivateProperty($this->builder, 'capabilities'))->toBe($capabilities); 86 | }); 87 | 88 | it('sets pagination limit correctly', function () { 89 | $this->builder->withPaginationLimit(100); 90 | expect(getPrivateProperty($this->builder, 'paginationLimit'))->toBe(100); 91 | }); 92 | 93 | it('sets logger correctly', function () { 94 | $logger = Mockery::mock(LoggerInterface::class); 95 | $this->builder->withLogger($logger); 96 | expect(getPrivateProperty($this->builder, 'logger'))->toBe($logger); 97 | }); 98 | 99 | it('sets cache correctly', function () { 100 | $cache = Mockery::mock(CacheInterface::class); 101 | $this->builder->withCache($cache); 102 | expect(getPrivateProperty($this->builder, 'cache'))->toBe($cache); 103 | }); 104 | 105 | it('sets session handler correctly', function () { 106 | $handler = Mockery::mock(SessionHandlerInterface::class); 107 | $this->builder->withSessionHandler($handler, 7200); 108 | expect(getPrivateProperty($this->builder, 'sessionHandler'))->toBe($handler); 109 | expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(7200); 110 | }); 111 | 112 | it('sets session driver to array correctly', function () { 113 | $this->builder->withSession('array', 1800); 114 | expect(getPrivateProperty($this->builder, 'sessionDriver'))->toBe('array'); 115 | expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(1800); 116 | }); 117 | 118 | it('sets session driver to cache correctly', function () { 119 | $this->builder->withSession('cache', 900); 120 | expect(getPrivateProperty($this->builder, 'sessionDriver'))->toBe('cache'); 121 | expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(900); 122 | }); 123 | 124 | it('uses default TTL when not specified for session', function () { 125 | $this->builder->withSession('array'); 126 | expect(getPrivateProperty($this->builder, 'sessionTtl'))->toBe(3600); 127 | }); 128 | 129 | it('throws exception for invalid session driver', function () { 130 | $this->builder->withSession('redis'); 131 | })->throws(\InvalidArgumentException::class, "Unsupported session driver 'redis'. Only 'array' and 'cache' drivers are supported."); 132 | 133 | it('throws exception for cache session driver without cache during build', function () { 134 | $this->builder 135 | ->withServerInfo('Test', '1.0') 136 | ->withSession('cache') 137 | ->build(); 138 | })->throws(ConfigurationException::class, 'Cache session driver requires a cache instance'); 139 | 140 | it('creates ArraySessionHandler when array driver is specified', function () { 141 | $server = $this->builder 142 | ->withServerInfo('Test', '1.0') 143 | ->withSession('array', 1800) 144 | ->build(); 145 | 146 | $sessionManager = $server->getSessionManager(); 147 | $smReflection = new ReflectionClass(SessionManager::class); 148 | $handlerProp = $smReflection->getProperty('handler'); 149 | $handlerProp->setAccessible(true); 150 | $handler = $handlerProp->getValue($sessionManager); 151 | 152 | expect($handler)->toBeInstanceOf(ArraySessionHandler::class); 153 | expect($handler->ttl)->toBe(1800); 154 | }); 155 | 156 | it('creates CacheSessionHandler when cache driver is specified', function () { 157 | $cache = Mockery::mock(CacheInterface::class); 158 | $cache->shouldReceive('get')->with('mcp_session_index', [])->andReturn([]); 159 | 160 | $server = $this->builder 161 | ->withServerInfo('Test', '1.0') 162 | ->withCache($cache) 163 | ->withSession('cache', 900) 164 | ->build(); 165 | 166 | $sessionManager = $server->getSessionManager(); 167 | $smReflection = new ReflectionClass(SessionManager::class); 168 | $handlerProp = $smReflection->getProperty('handler'); 169 | $handlerProp->setAccessible(true); 170 | $handler = $handlerProp->getValue($sessionManager); 171 | 172 | expect($handler)->toBeInstanceOf(CacheSessionHandler::class); 173 | expect($handler->cache)->toBe($cache); 174 | expect($handler->ttl)->toBe(900); 175 | }); 176 | 177 | it('prefers custom session handler over session driver', function () { 178 | $customHandler = Mockery::mock(SessionHandlerInterface::class); 179 | 180 | $server = $this->builder 181 | ->withServerInfo('Test', '1.0') 182 | ->withSession('array') 183 | ->withSessionHandler($customHandler, 1200) 184 | ->build(); 185 | 186 | $sessionManager = $server->getSessionManager(); 187 | $smReflection = new ReflectionClass(SessionManager::class); 188 | $handlerProp = $smReflection->getProperty('handler'); 189 | $handlerProp->setAccessible(true); 190 | 191 | expect($handlerProp->getValue($sessionManager))->toBe($customHandler); 192 | }); 193 | 194 | 195 | it('sets container correctly', function () { 196 | $container = Mockery::mock(ContainerInterface::class); 197 | $this->builder->withContainer($container); 198 | expect(getPrivateProperty($this->builder, 'container'))->toBe($container); 199 | }); 200 | 201 | it('sets loop correctly', function () { 202 | $loop = Mockery::mock(LoopInterface::class); 203 | $this->builder->withLoop($loop); 204 | expect(getPrivateProperty($this->builder, 'loop'))->toBe($loop); 205 | }); 206 | 207 | it('stores manual tool registration data', function () { 208 | $handler = [SB_DummyHandlerClass::class, 'handle']; 209 | $this->builder->withTool($handler, 'my-tool', 'Tool desc'); 210 | $manualTools = getPrivateProperty($this->builder, 'manualTools'); 211 | expect($manualTools[0]['handler'])->toBe($handler) 212 | ->and($manualTools[0]['name'])->toBe('my-tool') 213 | ->and($manualTools[0]['description'])->toBe('Tool desc'); 214 | }); 215 | 216 | it('stores manual resource registration data', function () { 217 | $handler = [SB_DummyHandlerClass::class, 'handle']; 218 | $this->builder->withResource($handler, 'res://resource', 'Resource name'); 219 | $manualResources = getPrivateProperty($this->builder, 'manualResources'); 220 | expect($manualResources[0]['handler'])->toBe($handler) 221 | ->and($manualResources[0]['uri'])->toBe('res://resource') 222 | ->and($manualResources[0]['name'])->toBe('Resource name'); 223 | }); 224 | 225 | it('stores manual resource template registration data', function () { 226 | $handler = [SB_DummyHandlerClass::class, 'handle']; 227 | $this->builder->withResourceTemplate($handler, 'res://resource', 'Resource name'); 228 | $manualResourceTemplates = getPrivateProperty($this->builder, 'manualResourceTemplates'); 229 | expect($manualResourceTemplates[0]['handler'])->toBe($handler) 230 | ->and($manualResourceTemplates[0]['uriTemplate'])->toBe('res://resource') 231 | ->and($manualResourceTemplates[0]['name'])->toBe('Resource name'); 232 | }); 233 | 234 | it('stores manual prompt registration data', function () { 235 | $handler = [SB_DummyHandlerClass::class, 'handle']; 236 | $this->builder->withPrompt($handler, 'my-prompt', 'Prompt desc'); 237 | $manualPrompts = getPrivateProperty($this->builder, 'manualPrompts'); 238 | expect($manualPrompts[0]['handler'])->toBe($handler) 239 | ->and($manualPrompts[0]['name'])->toBe('my-prompt') 240 | ->and($manualPrompts[0]['description'])->toBe('Prompt desc'); 241 | }); 242 | 243 | it('throws ConfigurationException if server info not provided', function () { 244 | $this->builder->build(); 245 | })->throws(ConfigurationException::class, 'Server name and version must be provided'); 246 | 247 | 248 | it('resolves default Logger, Loop, Container, SessionHandler if not provided', function () { 249 | $server = $this->builder->withServerInfo('Test', '1.0')->build(); 250 | $config = $server->getConfiguration(); 251 | 252 | expect($config->logger)->toBeInstanceOf(NullLogger::class); 253 | expect($config->loop)->toBeInstanceOf(LoopInterface::class); 254 | expect($config->container)->toBeInstanceOf(BasicContainer::class); 255 | 256 | $sessionManager = $server->getSessionManager(); 257 | $smReflection = new ReflectionClass(SessionManager::class); 258 | $handlerProp = $smReflection->getProperty('handler'); 259 | $handlerProp->setAccessible(true); 260 | expect($handlerProp->getValue($sessionManager))->toBeInstanceOf(ArraySessionHandler::class); 261 | }); 262 | 263 | it('builds Server with correct Configuration, Registry, Protocol, SessionManager', function () { 264 | $logger = new NullLogger(); 265 | $loop = Mockery::mock(LoopInterface::class)->shouldIgnoreMissing(); 266 | $cache = Mockery::mock(CacheInterface::class); 267 | $container = Mockery::mock(ContainerInterface::class); 268 | $sessionHandler = Mockery::mock(SessionHandlerInterface::class); 269 | $capabilities = ServerCapabilities::make(promptsListChanged: true, resourcesListChanged: true); 270 | 271 | $loop->shouldReceive('addPeriodicTimer')->with(300, Mockery::type('callable'))->andReturn(Mockery::mock(TimerInterface::class)); 272 | 273 | $server = $this->builder 274 | ->withServerInfo('FullBuild', '3.0') 275 | ->withLogger($logger) 276 | ->withLoop($loop) 277 | ->withCache($cache) 278 | ->withContainer($container) 279 | ->withSessionHandler($sessionHandler) 280 | ->withCapabilities($capabilities) 281 | ->withPaginationLimit(75) 282 | ->build(); 283 | 284 | expect($server)->toBeInstanceOf(Server::class); 285 | 286 | $config = $server->getConfiguration(); 287 | expect($config->serverInfo->name)->toBe('FullBuild'); 288 | expect($config->serverInfo->version)->toBe('3.0'); 289 | expect($config->capabilities)->toBe($capabilities); 290 | expect($config->logger)->toBe($logger); 291 | expect($config->loop)->toBe($loop); 292 | expect($config->cache)->toBe($cache); 293 | expect($config->container)->toBe($container); 294 | expect($config->paginationLimit)->toBe(75); 295 | 296 | expect($server->getRegistry())->toBeInstanceOf(Registry::class); 297 | expect($server->getProtocol())->toBeInstanceOf(Protocol::class); 298 | expect($server->getSessionManager())->toBeInstanceOf(SessionManager::class); 299 | $smReflection = new ReflectionClass($server->getSessionManager()); 300 | $handlerProp = $smReflection->getProperty('handler'); 301 | $handlerProp->setAccessible(true); 302 | expect($handlerProp->getValue($server->getSessionManager()))->toBe($sessionHandler); 303 | }); 304 | 305 | it('registers manual tool successfully during build', function () { 306 | $handler = [SB_DummyHandlerClass::class, 'handle']; 307 | 308 | $server = $this->builder 309 | ->withServerInfo('ManualToolTest', '1.0') 310 | ->withTool($handler, 'test-manual-tool', 'A test tool') 311 | ->build(); 312 | 313 | $registry = $server->getRegistry(); 314 | $tool = $registry->getTool('test-manual-tool'); 315 | 316 | expect($tool)->toBeInstanceOf(RegisteredTool::class); 317 | expect($tool->isManual)->toBeTrue(); 318 | expect($tool->schema->name)->toBe('test-manual-tool'); 319 | expect($tool->schema->description)->toBe('A test tool'); 320 | expect($tool->schema->inputSchema)->toEqual(['type' => 'object', 'properties' => ['arg' => ['type' => 'string']], 'required' => ['arg']]); 321 | expect($tool->handler)->toBe($handler); 322 | }); 323 | 324 | it('infers tool name from invokable class if not provided', function () { 325 | $handler = SB_DummyInvokableClass::class; 326 | 327 | $server = $this->builder 328 | ->withServerInfo('Test', '1.0') 329 | ->withTool($handler) 330 | ->build(); 331 | 332 | $tool = $server->getRegistry()->getTool('SB_DummyInvokableClass'); 333 | expect($tool)->not->toBeNull(); 334 | expect($tool->schema->name)->toBe('SB_DummyInvokableClass'); 335 | }); 336 | 337 | it('registers tool with closure handler', function () { 338 | $closure = function (string $message): string { 339 | return "Hello, $message!"; 340 | }; 341 | 342 | $server = $this->builder 343 | ->withServerInfo('ClosureTest', '1.0') 344 | ->withTool($closure, 'greet-tool', 'A greeting tool') 345 | ->build(); 346 | 347 | $tool = $server->getRegistry()->getTool('greet-tool'); 348 | expect($tool)->toBeInstanceOf(RegisteredTool::class); 349 | expect($tool->isManual)->toBeTrue(); 350 | expect($tool->schema->name)->toBe('greet-tool'); 351 | expect($tool->schema->description)->toBe('A greeting tool'); 352 | expect($tool->handler)->toBe($closure); 353 | expect($tool->schema->inputSchema)->toEqual([ 354 | 'type' => 'object', 355 | 'properties' => ['message' => ['type' => 'string']], 356 | 'required' => ['message'] 357 | ]); 358 | }); 359 | 360 | it('registers tool with static method handler', function () { 361 | $handler = [SB_DummyHandlerClass::class, 'handle']; 362 | 363 | $server = $this->builder 364 | ->withServerInfo('StaticTest', '1.0') 365 | ->withTool($handler, 'static-tool', 'A static method tool') 366 | ->build(); 367 | 368 | $tool = $server->getRegistry()->getTool('static-tool'); 369 | expect($tool)->toBeInstanceOf(RegisteredTool::class); 370 | expect($tool->isManual)->toBeTrue(); 371 | expect($tool->schema->name)->toBe('static-tool'); 372 | expect($tool->handler)->toBe($handler); 373 | }); 374 | 375 | it('registers resource with closure handler', function () { 376 | $closure = function (string $id): array { 377 | return [ 378 | 'uri' => "res://item/$id", 379 | 'name' => "Item $id", 380 | 'mimeType' => 'application/json' 381 | ]; 382 | }; 383 | 384 | $server = $this->builder 385 | ->withServerInfo('ResourceTest', '1.0') 386 | ->withResource($closure, 'res://items/{id}', 'dynamic_resource') 387 | ->build(); 388 | 389 | $resource = $server->getRegistry()->getResource('res://items/{id}'); 390 | expect($resource)->not->toBeNull(); 391 | expect($resource->handler)->toBe($closure); 392 | expect($resource->isManual)->toBeTrue(); 393 | }); 394 | 395 | it('registers prompt with closure handler', function () { 396 | $closure = function (string $topic): array { 397 | return [ 398 | 'role' => 'user', 399 | 'content' => ['type' => 'text', 'text' => "Tell me about $topic"] 400 | ]; 401 | }; 402 | 403 | $server = $this->builder 404 | ->withServerInfo('PromptTest', '1.0') 405 | ->withPrompt($closure, 'topic-prompt', 'A topic-based prompt') 406 | ->build(); 407 | 408 | $prompt = $server->getRegistry()->getPrompt('topic-prompt'); 409 | expect($prompt)->not->toBeNull(); 410 | expect($prompt->handler)->toBe($closure); 411 | expect($prompt->isManual)->toBeTrue(); 412 | }); 413 | 414 | it('infers closure tool name automatically', function () { 415 | $closure = function (int $count): array { 416 | return ['count' => $count]; 417 | }; 418 | 419 | $server = $this->builder 420 | ->withServerInfo('AutoNameTest', '1.0') 421 | ->withTool($closure) 422 | ->build(); 423 | 424 | $tools = $server->getRegistry()->getTools(); 425 | expect($tools)->toHaveCount(1); 426 | 427 | $toolName = array_keys($tools)[0]; 428 | expect($toolName)->toStartWith('closure_tool_'); 429 | 430 | $tool = $server->getRegistry()->getTool($toolName); 431 | expect($tool->handler)->toBe($closure); 432 | }); 433 | 434 | it('generates unique names for multiple closures', function () { 435 | $closure1 = function (string $a): string { 436 | return $a; 437 | }; 438 | $closure2 = function (int $b): int { 439 | return $b; 440 | }; 441 | 442 | $server = $this->builder 443 | ->withServerInfo('MultiClosureTest', '1.0') 444 | ->withTool($closure1) 445 | ->withTool($closure2) 446 | ->build(); 447 | 448 | $tools = $server->getRegistry()->getTools(); 449 | expect($tools)->toHaveCount(2); 450 | 451 | $toolNames = array_keys($tools); 452 | expect($toolNames[0])->toStartWith('closure_tool_'); 453 | expect($toolNames[1])->toStartWith('closure_tool_'); 454 | expect($toolNames[0])->not->toBe($toolNames[1]); 455 | }); 456 | 457 | it('infers prompt arguments and completion providers for manual prompt', function () { 458 | $handler = [SB_DummyHandlerClass::class, 'handlerWithCompletion']; 459 | 460 | $server = $this->builder 461 | ->withServerInfo('Test', '1.0') 462 | ->withPrompt($handler, 'myPrompt') 463 | ->build(); 464 | 465 | $prompt = $server->getRegistry()->getPrompt('myPrompt'); 466 | expect($prompt)->toBeInstanceOf(RegisteredPrompt::class); 467 | expect($prompt->schema->arguments)->toHaveCount(2); 468 | expect($prompt->schema->arguments[0]->name)->toBe('name'); 469 | expect($prompt->schema->arguments[1]->name)->toBe('uriParam'); 470 | expect($prompt->completionProviders['uriParam'])->toBe(SB_DummyCompletionProvider::class); 471 | }); 472 | 473 | // Add test fixtures for enhanced completion providers 474 | class SB_DummyHandlerWithEnhancedCompletion 475 | { 476 | public function handleWithListCompletion( 477 | #[CompletionProvider(values: ['option1', 'option2', 'option3'])] 478 | string $choice 479 | ): array { 480 | return [['role' => 'user', 'content' => "Selected: {$choice}"]]; 481 | } 482 | 483 | public function handleWithEnumCompletion( 484 | #[CompletionProvider(enum: SB_TestEnum::class)] 485 | string $status 486 | ): array { 487 | return [['role' => 'user', 'content' => "Status: {$status}"]]; 488 | } 489 | } 490 | 491 | enum SB_TestEnum: string 492 | { 493 | case PENDING = 'pending'; 494 | case ACTIVE = 'active'; 495 | case INACTIVE = 'inactive'; 496 | } 497 | 498 | it('creates ListCompletionProvider for values attribute in manual registration', function () { 499 | $handler = [SB_DummyHandlerWithEnhancedCompletion::class, 'handleWithListCompletion']; 500 | 501 | $server = $this->builder 502 | ->withServerInfo('Test', '1.0') 503 | ->withPrompt($handler, 'listPrompt') 504 | ->build(); 505 | 506 | $prompt = $server->getRegistry()->getPrompt('listPrompt'); 507 | expect($prompt->completionProviders['choice'])->toBeInstanceOf(\PhpMcp\Server\Defaults\ListCompletionProvider::class); 508 | }); 509 | 510 | it('creates EnumCompletionProvider for enum attribute in manual registration', function () { 511 | $handler = [SB_DummyHandlerWithEnhancedCompletion::class, 'handleWithEnumCompletion']; 512 | 513 | $server = $this->builder 514 | ->withServerInfo('Test', '1.0') 515 | ->withPrompt($handler, 'enumPrompt') 516 | ->build(); 517 | 518 | $prompt = $server->getRegistry()->getPrompt('enumPrompt'); 519 | expect($prompt->completionProviders['status'])->toBeInstanceOf(\PhpMcp\Server\Defaults\EnumCompletionProvider::class); 520 | }); 521 | 522 | // it('throws DefinitionException if HandlerResolver fails for a manual element', function () { 523 | // $handler = ['NonExistentClass', 'method']; 524 | 525 | // $server = $this->builder 526 | // ->withServerInfo('Test', '1.0') 527 | // ->withTool($handler, 'badTool') 528 | // ->build(); 529 | // })->throws(DefinitionException::class, '1 error(s) occurred during manual element registration'); 530 | 531 | 532 | it('builds successfully with minimal valid config', function () { 533 | $server = $this->builder 534 | ->withServerInfo('TS-Compatible', '0.1') 535 | ->build(); 536 | expect($server)->toBeInstanceOf(Server::class); 537 | }); 538 | 539 | it('can be built multiple times with different configurations', function () { 540 | $builder = new ServerBuilder(); 541 | 542 | $server1 = $builder 543 | ->withServerInfo('ServerOne', '1.0') 544 | ->withTool([SB_DummyHandlerClass::class, 'handle'], 'toolOne') 545 | ->build(); 546 | 547 | $server2 = $builder 548 | ->withServerInfo('ServerTwo', '2.0') 549 | ->withTool([SB_DummyHandlerClass::class, 'noArgsHandler'], 'toolTwo') 550 | ->build(); 551 | 552 | expect($server1->getConfiguration()->serverInfo->name)->toBe('ServerOne'); 553 | $registry1 = $server1->getRegistry(); 554 | expect($registry1->getTool('toolOne'))->not->toBeNull(); 555 | expect($registry1->getTool('toolTwo'))->toBeNull(); 556 | 557 | expect($server2->getConfiguration()->serverInfo->name)->toBe('ServerTwo'); 558 | $registry2 = $server2->getRegistry(); 559 | expect($registry2->getTool('toolOne'))->not->toBeNull(); 560 | expect($registry2->getTool('toolTwo'))->not->toBeNull(); 561 | 562 | $builder3 = new ServerBuilder(); 563 | $server3 = $builder3->withServerInfo('ServerThree', '3.0')->build(); 564 | expect($server3->getRegistry()->hasElements())->toBeFalse(); 565 | }); 566 | ``` -------------------------------------------------------------------------------- /tests/Integration/HttpServerTransportTest.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | use PhpMcp\Server\Protocol; 4 | use PhpMcp\Server\Tests\Fixtures\General\ResourceHandlerFixture; 5 | use PhpMcp\Server\Tests\Mocks\Clients\MockSseClient; 6 | use React\ChildProcess\Process; 7 | use React\EventLoop\Loop; 8 | use React\Http\Browser; 9 | use React\Http\Message\ResponseException; 10 | use React\Http\Message\Uri; 11 | use React\Stream\ReadableStreamInterface; 12 | 13 | use function React\Async\await; 14 | 15 | const HTTP_SERVER_SCRIPT_PATH = __DIR__ . '/../Fixtures/ServerScripts/HttpTestServer.php'; 16 | const HTTP_PROCESS_TIMEOUT_SECONDS = 8; 17 | const HTTP_SERVER_HOST = '127.0.0.1'; 18 | const HTTP_MCP_PATH_PREFIX = 'mcp_http_integration'; 19 | 20 | beforeEach(function () { 21 | $this->loop = Loop::get(); 22 | $this->port = findFreePort(); 23 | 24 | if (!is_file(HTTP_SERVER_SCRIPT_PATH)) { 25 | $this->markTestSkipped("Server script not found: " . HTTP_SERVER_SCRIPT_PATH); 26 | } 27 | if (!is_executable(HTTP_SERVER_SCRIPT_PATH)) { 28 | chmod(HTTP_SERVER_SCRIPT_PATH, 0755); 29 | } 30 | 31 | $phpPath = PHP_BINARY ?: 'php'; 32 | $commandPhpPath = str_contains($phpPath, ' ') ? '"' . $phpPath . '"' : $phpPath; 33 | $commandArgs = [ 34 | escapeshellarg(HTTP_SERVER_HOST), 35 | escapeshellarg((string)$this->port), 36 | escapeshellarg(HTTP_MCP_PATH_PREFIX) 37 | ]; 38 | $commandScriptPath = escapeshellarg(HTTP_SERVER_SCRIPT_PATH); 39 | $command = $commandPhpPath . ' ' . $commandScriptPath . ' ' . implode(' ', $commandArgs); 40 | 41 | $this->process = new Process($command, getcwd() ?: null, null, []); 42 | $this->process->start($this->loop); 43 | 44 | $this->processErrorOutput = ''; 45 | if ($this->process->stderr instanceof ReadableStreamInterface) { 46 | $this->process->stderr->on('data', function ($chunk) { 47 | $this->processErrorOutput .= $chunk; 48 | }); 49 | } 50 | 51 | return await(delay(0.2, $this->loop)); 52 | }); 53 | 54 | afterEach(function () { 55 | if ($this->sseClient ?? null) { 56 | $this->sseClient->close(); 57 | } 58 | 59 | if ($this->process instanceof Process && $this->process->isRunning()) { 60 | if ($this->process->stdout instanceof ReadableStreamInterface) { 61 | $this->process->stdout->close(); 62 | } 63 | if ($this->process->stderr instanceof ReadableStreamInterface) { 64 | $this->process->stderr->close(); 65 | } 66 | 67 | $this->process->terminate(SIGTERM); 68 | try { 69 | await(delay(0.02, $this->loop)); 70 | } catch (\Throwable $e) { 71 | } 72 | 73 | if ($this->process->isRunning()) { 74 | $this->process->terminate(SIGKILL); 75 | } 76 | } 77 | $this->process = null; 78 | }); 79 | 80 | afterAll(function () { 81 | // Loop::stop(); 82 | }); 83 | 84 | it('starts the http server, initializes, calls a tool, and closes', function () { 85 | $this->sseClient = new MockSseClient(); 86 | $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; 87 | 88 | // 1. Connect 89 | await($this->sseClient->connect($sseBaseUrl)); 90 | await(delay(0.05, $this->loop)); 91 | expect($this->sseClient->endpointUrl)->toBeString(); 92 | expect($this->sseClient->clientId)->toBeString(); 93 | 94 | // 2. Initialize Request 95 | await($this->sseClient->sendHttpRequest('init-http-1', 'initialize', [ 96 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 97 | 'clientInfo' => ['name' => 'HttpPestClient', 'version' => '1.0'], 98 | 'capabilities' => [] 99 | ])); 100 | $initResponse = await($this->sseClient->getNextMessageResponse('init-http-1')); 101 | 102 | expect($initResponse['id'])->toBe('init-http-1'); 103 | expect($initResponse)->not->toHaveKey('error'); 104 | expect($initResponse['result']['protocolVersion'])->toBe(Protocol::LATEST_PROTOCOL_VERSION); 105 | expect($initResponse['result']['serverInfo']['name'])->toBe('HttpIntegrationTestServer'); 106 | 107 | // 3. Initialized Notification 108 | await($this->sseClient->sendHttpNotification('notifications/initialized', ['messageQueueSupported' => true])); 109 | await(delay(0.05, $this->loop)); 110 | 111 | // 4. Call a tool 112 | await($this->sseClient->sendHttpRequest('tool-http-1', 'tools/call', [ 113 | 'name' => 'greet_http_tool', 114 | 'arguments' => ['name' => 'HTTP Integration User'] 115 | ])); 116 | $toolResponse = await($this->sseClient->getNextMessageResponse('tool-http-1')); 117 | 118 | expect($toolResponse['id'])->toBe('tool-http-1'); 119 | expect($toolResponse)->not->toHaveKey('error'); 120 | expect($toolResponse['result']['content'][0]['text'])->toBe('Hello, HTTP Integration User!'); 121 | expect($toolResponse['result']['isError'])->toBeFalse(); 122 | 123 | // 5. Close 124 | $this->sseClient->close(); 125 | })->group('integration', 'http_transport'); 126 | 127 | it('can handle invalid JSON from client', function () { 128 | $this->sseClient = new MockSseClient(); 129 | $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; 130 | 131 | // 1. Connect 132 | await($this->sseClient->connect($sseBaseUrl)); 133 | await(delay(0.05, $this->loop)); 134 | 135 | expect($this->sseClient->endpointUrl)->toBeString(); 136 | 137 | $malformedJson = '{"jsonrpc":"2.0", "id": "bad-json-1", "method": "tools/list", "params": {"broken"}'; 138 | 139 | // 2. Send invalid JSON 140 | $postPromise = $this->sseClient->browser->post( 141 | $this->sseClient->endpointUrl, 142 | ['Content-Type' => 'application/json'], 143 | $malformedJson 144 | ); 145 | 146 | // 3. Expect error response 147 | try { 148 | await(timeout($postPromise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop)); 149 | } catch (ResponseException $e) { 150 | expect($e->getResponse()->getStatusCode())->toBe(400); 151 | 152 | $errorResponse = json_decode($e->getResponse()->getBody(), true); 153 | expect($errorResponse['jsonrpc'])->toBe('2.0'); 154 | expect($errorResponse['id'])->toBe(''); 155 | expect($errorResponse['error']['code'])->toBe(-32700); 156 | expect($errorResponse['error']['message'])->toContain('Invalid JSON-RPC message'); 157 | } 158 | })->group('integration', 'http_transport'); 159 | 160 | it('can handle request for non-existent method after initialization', function () { 161 | $this->sseClient = new MockSseClient(); 162 | $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; 163 | 164 | // 1. Connect 165 | await($this->sseClient->connect($sseBaseUrl)); 166 | await(delay(0.05, $this->loop)); 167 | expect($this->sseClient->endpointUrl)->toBeString(); 168 | 169 | // 2. Initialize Request 170 | await($this->sseClient->sendHttpRequest('init-http-nonexist', 'initialize', [ 171 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 172 | 'clientInfo' => ['name' => 'Test'], 173 | 'capabilities' => [] 174 | ])); 175 | await($this->sseClient->getNextMessageResponse('init-http-nonexist')); 176 | await($this->sseClient->sendHttpNotification('notifications/initialized', ['messageQueueSupported' => true])); 177 | await(delay(0.05, $this->loop)); 178 | 179 | // 3. Send request for non-existent method 180 | await($this->sseClient->sendHttpRequest('err-meth-http-1', 'non/existentHttpTool', [])); 181 | $errorResponse = await($this->sseClient->getNextMessageResponse('err-meth-http-1')); 182 | 183 | // 4. Expect error response 184 | expect($errorResponse['id'])->toBe('err-meth-http-1'); 185 | expect($errorResponse['error']['code'])->toBe(-32601); 186 | expect($errorResponse['error']['message'])->toContain("Method 'non/existentHttpTool' not found"); 187 | })->group('integration', 'http_transport'); 188 | 189 | it('can handle batch requests correctly over HTTP/SSE', function () { 190 | $this->sseClient = new MockSseClient(); 191 | $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; 192 | 193 | // 1. Connect 194 | await($this->sseClient->connect($sseBaseUrl)); 195 | await(delay(0.05, $this->loop)); 196 | expect($this->sseClient->endpointUrl)->toBeString(); 197 | 198 | // 2. Initialize Request 199 | await($this->sseClient->sendHttpRequest('init-batch-http', 'initialize', [ 200 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 201 | 'clientInfo' => ['name' => 'HttpBatchClient', 'version' => '1.0'], 202 | 'capabilities' => [] 203 | ])); 204 | await($this->sseClient->getNextMessageResponse('init-batch-http')); 205 | 206 | // 3. Initialized notification 207 | await($this->sseClient->sendHttpNotification('notifications/initialized', ['messageQueueSupported' => true])); 208 | await(delay(0.05, $this->loop)); 209 | 210 | // 4. Send Batch Request 211 | $batchRequests = [ 212 | ['jsonrpc' => '2.0', 'id' => 'batch-req-1', 'method' => 'tools/call', 'params' => ['name' => 'greet_http_tool', 'arguments' => ['name' => 'Batch Item 1']]], 213 | ['jsonrpc' => '2.0', 'method' => 'notifications/something'], 214 | ['jsonrpc' => '2.0', 'id' => 'batch-req-2', 'method' => 'tools/call', 'params' => ['name' => 'greet_http_tool', 'arguments' => ['name' => 'Batch Item 2']]], 215 | ['jsonrpc' => '2.0', 'id' => 'batch-req-3', 'method' => 'nonexistent/method'] 216 | ]; 217 | 218 | await($this->sseClient->sendHttpBatchRequest($batchRequests)); 219 | 220 | // 5. Read Batch Response 221 | $batchResponseArray = await($this->sseClient->getNextBatchMessageResponse(3)); 222 | 223 | expect($batchResponseArray)->toBeArray()->toHaveCount(3); 224 | 225 | $findResponseById = function (array $batch, $id) { 226 | foreach ($batch as $item) { 227 | if (isset($item['id']) && $item['id'] === $id) { 228 | return $item; 229 | } 230 | } 231 | return null; 232 | }; 233 | 234 | $response1 = $findResponseById($batchResponseArray, 'batch-req-1'); 235 | $response2 = $findResponseById($batchResponseArray, 'batch-req-2'); 236 | $response3 = $findResponseById($batchResponseArray, 'batch-req-3'); 237 | 238 | expect($response1['result']['content'][0]['text'])->toBe('Hello, Batch Item 1!'); 239 | expect($response2['result']['content'][0]['text'])->toBe('Hello, Batch Item 2!'); 240 | expect($response3['error']['code'])->toBe(-32601); 241 | expect($response3['error']['message'])->toContain("Method 'nonexistent/method' not found"); 242 | 243 | $this->sseClient->close(); 244 | })->group('integration', 'http_transport'); 245 | 246 | it('can handle tool list request over HTTP/SSE', function () { 247 | $this->sseClient = new MockSseClient(); 248 | $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; 249 | await($this->sseClient->connect($sseBaseUrl)); 250 | await(delay(0.05, $this->loop)); 251 | 252 | await($this->sseClient->sendHttpRequest('init-http-tools', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []])); 253 | await($this->sseClient->getNextMessageResponse('init-http-tools')); 254 | await($this->sseClient->sendHttpNotification('notifications/initialized')); 255 | await(delay(0.1, $this->loop)); 256 | 257 | await($this->sseClient->sendHttpRequest('tool-list-http-1', 'tools/list', [])); 258 | $toolListResponse = await($this->sseClient->getNextMessageResponse('tool-list-http-1')); 259 | 260 | expect($toolListResponse['id'])->toBe('tool-list-http-1'); 261 | expect($toolListResponse)->not->toHaveKey('error'); 262 | expect($toolListResponse['result']['tools'])->toBeArray()->toHaveCount(2); 263 | expect($toolListResponse['result']['tools'][0]['name'])->toBe('greet_http_tool'); 264 | 265 | $this->sseClient->close(); 266 | })->group('integration', 'http_transport'); 267 | 268 | it('can read a registered resource over HTTP/SSE', function () { 269 | $this->sseClient = new MockSseClient(); 270 | $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; 271 | await($this->sseClient->connect($sseBaseUrl)); 272 | await(delay(0.05, $this->loop)); 273 | 274 | await($this->sseClient->sendHttpRequest('init-http-res', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []])); 275 | await($this->sseClient->getNextMessageResponse('init-http-res')); 276 | await($this->sseClient->sendHttpNotification('notifications/initialized')); 277 | await(delay(0.1, $this->loop)); 278 | 279 | await($this->sseClient->sendHttpRequest('res-read-http-1', 'resources/read', ['uri' => 'test://http/static'])); 280 | $resourceResponse = await($this->sseClient->getNextMessageResponse('res-read-http-1')); 281 | 282 | expect($resourceResponse['id'])->toBe('res-read-http-1'); 283 | expect($resourceResponse)->not->toHaveKey('error'); 284 | expect($resourceResponse['result']['contents'])->toBeArray()->toHaveCount(1); 285 | expect($resourceResponse['result']['contents'][0]['uri'])->toBe('test://http/static'); 286 | expect($resourceResponse['result']['contents'][0]['text'])->toBe(ResourceHandlerFixture::$staticTextContent); 287 | expect($resourceResponse['result']['contents'][0]['mimeType'])->toBe('text/plain'); 288 | 289 | $this->sseClient->close(); 290 | })->group('integration', 'http_transport'); 291 | 292 | it('can get a registered prompt over HTTP/SSE', function () { 293 | $this->sseClient = new MockSseClient(); 294 | $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; 295 | await($this->sseClient->connect($sseBaseUrl)); 296 | await(delay(0.05, $this->loop)); 297 | 298 | await($this->sseClient->sendHttpRequest('init-http-prompt', 'initialize', ['protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 'clientInfo' => [], 'capabilities' => []])); 299 | await($this->sseClient->getNextMessageResponse('init-http-prompt')); 300 | await($this->sseClient->sendHttpNotification('notifications/initialized')); 301 | await(delay(0.1, $this->loop)); 302 | 303 | await($this->sseClient->sendHttpRequest('prompt-get-http-1', 'prompts/get', [ 304 | 'name' => 'simple_http_prompt', 305 | 'arguments' => ['name' => 'HttpPromptUser', 'style' => 'polite'] 306 | ])); 307 | $promptResponse = await($this->sseClient->getNextMessageResponse('prompt-get-http-1')); 308 | 309 | expect($promptResponse['id'])->toBe('prompt-get-http-1'); 310 | expect($promptResponse)->not->toHaveKey('error'); 311 | expect($promptResponse['result']['messages'])->toBeArray()->toHaveCount(1); 312 | expect($promptResponse['result']['messages'][0]['role'])->toBe('user'); 313 | expect($promptResponse['result']['messages'][0]['content']['text'])->toBe('Craft a polite greeting for HttpPromptUser.'); 314 | 315 | $this->sseClient->close(); 316 | })->group('integration', 'http_transport'); 317 | 318 | it('rejects subsequent requests if client does not send initialized notification', function () { 319 | $this->sseClient = new MockSseClient(); 320 | $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; 321 | await($this->sseClient->connect($sseBaseUrl)); 322 | await(delay(0.05, $this->loop)); 323 | 324 | // 1. Send Initialize 325 | await($this->sseClient->sendHttpRequest('init-http-no-ack', 'initialize', [ 326 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 327 | 'clientInfo' => ['name' => 'HttpForgetfulClient', 'version' => '1.0'], 328 | 'capabilities' => [] 329 | ])); 330 | await($this->sseClient->getNextMessageResponse('init-http-no-ack')); 331 | // Client "forgets" to send notifications/initialized back 332 | 333 | await(delay(0.1, $this->loop)); 334 | 335 | // 2. Attempt to Call a tool 336 | await($this->sseClient->sendHttpRequest('tool-call-http-no-ack', 'tools/call', [ 337 | 'name' => 'greet_http_tool', 338 | 'arguments' => ['name' => 'NoAckHttpUser'] 339 | ])); 340 | $toolResponse = await($this->sseClient->getNextMessageResponse('tool-call-http-no-ack')); 341 | 342 | expect($toolResponse['id'])->toBe('tool-call-http-no-ack'); 343 | expect($toolResponse['error']['code'])->toBe(-32600); // Invalid Request 344 | expect($toolResponse['error']['message'])->toContain('Client session not initialized'); 345 | 346 | $this->sseClient->close(); 347 | })->group('integration', 'http_transport'); 348 | 349 | it('returns 404 for POST to /message without valid clientId in query', function () { 350 | $this->sseClient = new MockSseClient(); 351 | $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; 352 | await($this->sseClient->connect($sseBaseUrl)); 353 | await(delay(0.05, $this->loop)); 354 | $validEndpointUrl = $this->sseClient->endpointUrl; 355 | $this->sseClient->close(); 356 | 357 | $malformedEndpoint = (string) (new Uri($validEndpointUrl))->withQuery(''); 358 | 359 | $payload = ['jsonrpc' => '2.0', 'id' => 'post-no-clientid', 'method' => 'ping', 'params' => []]; 360 | $postPromise = $this->sseClient->browser->post( 361 | $malformedEndpoint, 362 | ['Content-Type' => 'application/json'], 363 | json_encode($payload) 364 | ); 365 | 366 | try { 367 | await(timeout($postPromise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop)); 368 | } catch (ResponseException $e) { 369 | expect($e->getResponse()->getStatusCode())->toBe(400); 370 | $bodyContent = (string) $e->getResponse()->getBody(); 371 | $errorData = json_decode($bodyContent, true); 372 | expect($errorData['error']['message'])->toContain('Missing or invalid clientId'); 373 | } 374 | })->group('integration', 'http_transport'); 375 | 376 | it('returns 404 for POST to /message with clientId for a disconnected SSE stream', function () { 377 | $this->sseClient = new MockSseClient(); 378 | $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; 379 | 380 | await($this->sseClient->connect($sseBaseUrl)); 381 | await(delay(0.05, $this->loop)); 382 | $originalEndpointUrl = $this->sseClient->endpointUrl; 383 | $this->sseClient->close(); 384 | 385 | await(delay(0.1, $this->loop)); 386 | 387 | $payload = ['jsonrpc' => '2.0', 'id' => 'post-stale-clientid', 'method' => 'ping', 'params' => []]; 388 | $postPromise = $this->sseClient->browser->post( 389 | $originalEndpointUrl, 390 | ['Content-Type' => 'application/json'], 391 | json_encode($payload) 392 | ); 393 | 394 | try { 395 | await(timeout($postPromise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop)); 396 | } catch (ResponseException $e) { 397 | $bodyContent = (string) $e->getResponse()->getBody(); 398 | $errorData = json_decode($bodyContent, true); 399 | expect($errorData['error']['message'])->toContain('Session ID not found or disconnected'); 400 | } 401 | })->group('integration', 'http_transport'); 402 | 403 | it('returns 404 for unknown paths', function () { 404 | $browser = new Browser($this->loop); 405 | $unknownUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/unknown/path"; 406 | 407 | $promise = $browser->get($unknownUrl); 408 | 409 | try { 410 | await(timeout($promise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop)); 411 | $this->fail("Request to unknown path should have failed with 404."); 412 | } catch (ResponseException $e) { 413 | expect($e->getResponse()->getStatusCode())->toBe(404); 414 | $body = (string) $e->getResponse()->getBody(); 415 | expect($body)->toContain("Not Found"); 416 | } catch (\Throwable $e) { 417 | $this->fail("Request to unknown path failed with unexpected error: " . $e->getMessage()); 418 | } 419 | })->group('integration', 'http_transport'); 420 | 421 | it('executes middleware that adds headers to response', function () { 422 | $this->sseClient = new MockSseClient(); 423 | $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; 424 | 425 | // 1. Connect 426 | await($this->sseClient->connect($sseBaseUrl)); 427 | await(delay(0.05, $this->loop)); 428 | 429 | // 2. Check that the middleware-added header is present in the response 430 | expect($this->sseClient->lastConnectResponse->getHeaderLine('X-Test-Middleware'))->toBe('header-added'); 431 | 432 | $this->sseClient->close(); 433 | })->group('integration', 'http_transport', 'middleware'); 434 | 435 | it('executes middleware that modifies request attributes', function () { 436 | $this->sseClient = new MockSseClient(); 437 | $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; 438 | 439 | // 1. Connect 440 | await($this->sseClient->connect($sseBaseUrl)); 441 | await(delay(0.05, $this->loop)); 442 | 443 | // 2. Initialize 444 | await($this->sseClient->sendHttpRequest('init-middleware-attr', 'initialize', [ 445 | 'protocolVersion' => Protocol::LATEST_PROTOCOL_VERSION, 446 | 'clientInfo' => ['name' => 'MiddlewareTestClient'], 447 | 'capabilities' => [] 448 | ])); 449 | await($this->sseClient->getNextMessageResponse('init-middleware-attr')); 450 | await($this->sseClient->sendHttpNotification('notifications/initialized')); 451 | await(delay(0.05, $this->loop)); 452 | 453 | // 3. Call tool that checks for middleware-added attribute 454 | await($this->sseClient->sendHttpRequest('tool-attr-check', 'tools/call', [ 455 | 'name' => 'check_request_attribute_tool', 456 | 'arguments' => [] 457 | ])); 458 | $toolResponse = await($this->sseClient->getNextMessageResponse('tool-attr-check')); 459 | 460 | expect($toolResponse['result']['content'][0]['text'])->toBe('middleware-value-found: middleware-value'); 461 | 462 | $this->sseClient->close(); 463 | })->group('integration', 'http_transport', 'middleware'); 464 | 465 | it('executes middleware that can short-circuit request processing', function () { 466 | $browser = new Browser($this->loop); 467 | $shortCircuitUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/short-circuit"; 468 | 469 | $promise = $browser->get($shortCircuitUrl); 470 | 471 | try { 472 | $response = await(timeout($promise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop)); 473 | $this->fail("Expected a 418 status code response, but request succeeded"); 474 | } catch (ResponseException $e) { 475 | expect($e->getResponse()->getStatusCode())->toBe(418); 476 | $body = (string) $e->getResponse()->getBody(); 477 | expect($body)->toBe('Short-circuited by middleware'); 478 | } catch (\Throwable $e) { 479 | $this->fail("Short-circuit middleware test failed: " . $e->getMessage()); 480 | } 481 | })->group('integration', 'http_transport', 'middleware'); 482 | 483 | it('executes multiple middlewares in correct order', function () { 484 | $this->sseClient = new MockSseClient(); 485 | $sseBaseUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/sse"; 486 | 487 | // 1. Connect 488 | await($this->sseClient->connect($sseBaseUrl)); 489 | await(delay(0.05, $this->loop)); 490 | 491 | // 2. Check that headers from multiple middlewares are present in correct order 492 | expect($this->sseClient->lastConnectResponse->getHeaderLine('X-Middleware-Order'))->toBe('third,second,first'); 493 | 494 | $this->sseClient->close(); 495 | })->group('integration', 'http_transport', 'middleware'); 496 | 497 | it('handles middleware that throws exceptions gracefully', function () { 498 | $browser = new Browser($this->loop); 499 | $errorUrl = "http://" . HTTP_SERVER_HOST . ":" . $this->port . "/" . HTTP_MCP_PATH_PREFIX . "/error-middleware"; 500 | 501 | $promise = $browser->get($errorUrl); 502 | 503 | try { 504 | await(timeout($promise, HTTP_PROCESS_TIMEOUT_SECONDS - 2, $this->loop)); 505 | $this->fail("Error middleware should have thrown an exception."); 506 | } catch (ResponseException $e) { 507 | expect($e->getResponse()->getStatusCode())->toBe(500); 508 | $body = (string) $e->getResponse()->getBody(); 509 | // ReactPHP handles exceptions and returns a generic error message 510 | expect($body)->toContain('Internal Server Error'); 511 | } 512 | })->group('integration', 'http_transport', 'middleware'); 513 | ``` -------------------------------------------------------------------------------- /tests/Unit/ProtocolTest.php: -------------------------------------------------------------------------------- ```php 1 | <?php 2 | 3 | namespace PhpMcp\Server\Tests\Unit; 4 | 5 | use Mockery; 6 | use Mockery\MockInterface; 7 | use PhpMcp\Schema\Implementation; 8 | use PhpMcp\Server\Context; 9 | use PhpMcp\Server\Configuration; 10 | use PhpMcp\Server\Contracts\ServerTransportInterface; 11 | use PhpMcp\Server\Dispatcher; 12 | use PhpMcp\Server\Exception\McpServerException; 13 | use PhpMcp\Schema\JsonRpc\BatchRequest; 14 | use PhpMcp\Schema\JsonRpc\BatchResponse; 15 | use PhpMcp\Schema\JsonRpc\Error; 16 | use PhpMcp\Schema\JsonRpc\Notification; 17 | use PhpMcp\Schema\JsonRpc\Request; 18 | use PhpMcp\Schema\JsonRpc\Response; 19 | use PhpMcp\Schema\Notification\ResourceListChangedNotification; 20 | use PhpMcp\Schema\Notification\ResourceUpdatedNotification; 21 | use PhpMcp\Schema\Notification\ToolListChangedNotification; 22 | use PhpMcp\Schema\Result\EmptyResult; 23 | use PhpMcp\Schema\ServerCapabilities; 24 | use PhpMcp\Server\Protocol; 25 | use PhpMcp\Server\Registry; 26 | use PhpMcp\Server\Session\SessionManager; 27 | use PhpMcp\Server\Contracts\SessionInterface; 28 | use PhpMcp\Server\Session\SubscriptionManager; 29 | use Psr\Container\ContainerInterface; 30 | use Psr\Log\LoggerInterface; 31 | use Psr\SimpleCache\CacheInterface; 32 | use React\EventLoop\LoopInterface; 33 | 34 | use function React\Async\await; 35 | use function React\Promise\resolve; 36 | use function React\Promise\reject; 37 | 38 | const SESSION_ID = 'session-test-789'; 39 | const SUPPORTED_VERSION_PROTO = Protocol::LATEST_PROTOCOL_VERSION; 40 | const SERVER_NAME_PROTO = 'Test Protocol Server'; 41 | const SERVER_VERSION_PROTO = '0.3.0'; 42 | 43 | function createRequest(string $method, array $params = [], string|int $id = 'req-proto-1'): Request 44 | { 45 | return new Request('2.0', $id, $method, $params); 46 | } 47 | 48 | function createNotification(string $method, array $params = []): Notification 49 | { 50 | return new Notification('2.0', $method, $params); 51 | } 52 | 53 | function expectErrorResponse(mixed $response, int $expectedCode, string|int|null $expectedId = 'req-proto-1'): void 54 | { 55 | test()->expect($response)->toBeInstanceOf(Error::class); 56 | test()->expect($response->id)->toBe($expectedId); 57 | test()->expect($response->code)->toBe($expectedCode); 58 | test()->expect($response->jsonrpc)->toBe('2.0'); 59 | } 60 | 61 | function expectSuccessResponse(mixed $response, mixed $expectedResult, string|int|null $expectedId = 'req-proto-1'): void 62 | { 63 | test()->expect($response)->toBeInstanceOf(Response::class); 64 | test()->expect($response->id)->toBe($expectedId); 65 | test()->expect($response->jsonrpc)->toBe('2.0'); 66 | test()->expect($response->result)->toBe($expectedResult); 67 | } 68 | 69 | 70 | beforeEach(function () { 71 | /** @var MockInterface&Registry $registry */ 72 | $this->registry = Mockery::mock(Registry::class); 73 | /** @var MockInterface&SessionManager $sessionManager */ 74 | $this->sessionManager = Mockery::mock(SessionManager::class); 75 | /** @var MockInterface&Dispatcher $dispatcher */ 76 | $this->dispatcher = Mockery::mock(Dispatcher::class); 77 | /** @var MockInterface&SubscriptionManager $subscriptionManager */ 78 | $this->subscriptionManager = Mockery::mock(SubscriptionManager::class); 79 | /** @var MockInterface&LoggerInterface $logger */ 80 | $this->logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); 81 | /** @var MockInterface&ServerTransportInterface $transport */ 82 | $this->transport = Mockery::mock(ServerTransportInterface::class); 83 | /** @var MockInterface&SessionInterface $session */ 84 | $this->session = Mockery::mock(SessionInterface::class); 85 | 86 | /** @var MockInterface&LoopInterface $loop */ 87 | $loop = Mockery::mock(LoopInterface::class); 88 | /** @var MockInterface&CacheInterface $cache */ 89 | $cache = Mockery::mock(CacheInterface::class); 90 | /** @var MockInterface&ContainerInterface $container */ 91 | $container = Mockery::mock(ContainerInterface::class); 92 | 93 | $this->configuration = new Configuration( 94 | serverInfo: Implementation::make(SERVER_NAME_PROTO, SERVER_VERSION_PROTO), 95 | capabilities: ServerCapabilities::make(), 96 | logger: $this->logger, 97 | loop: $loop, 98 | cache: $cache, 99 | container: $container 100 | ); 101 | 102 | $this->sessionManager->shouldReceive('getSession')->with(SESSION_ID)->andReturn($this->session)->byDefault(); 103 | $this->sessionManager->shouldReceive('on')->withAnyArgs()->byDefault(); 104 | 105 | $this->registry->shouldReceive('on')->withAnyArgs()->byDefault(); 106 | 107 | $this->session->shouldReceive('get')->with('initialized', false)->andReturn(true)->byDefault(); 108 | $this->session->shouldReceive('save')->byDefault(); 109 | 110 | $this->transport->shouldReceive('on')->withAnyArgs()->byDefault(); 111 | $this->transport->shouldReceive('removeListener')->withAnyArgs()->byDefault(); 112 | $this->transport->shouldReceive('sendMessage') 113 | ->withAnyArgs() 114 | ->andReturn(resolve(null)) 115 | ->byDefault(); 116 | 117 | $this->protocol = new Protocol( 118 | $this->configuration, 119 | $this->registry, 120 | $this->sessionManager, 121 | $this->dispatcher, 122 | $this->subscriptionManager 123 | ); 124 | 125 | $this->protocol->bindTransport($this->transport); 126 | }); 127 | 128 | it('listens to SessionManager events on construction', function () { 129 | $this->sessionManager->shouldHaveReceived('on')->with('session_deleted', Mockery::type('callable')); 130 | }); 131 | 132 | it('listens to Registry events on construction', function () { 133 | $this->registry->shouldHaveReceived('on')->with('list_changed', Mockery::type('callable')); 134 | }); 135 | 136 | it('binds to a transport and attaches listeners', function () { 137 | $newTransport = Mockery::mock(ServerTransportInterface::class); 138 | $newTransport->shouldReceive('on')->with('message', Mockery::type('callable'))->once(); 139 | $newTransport->shouldReceive('on')->with('client_connected', Mockery::type('callable'))->once(); 140 | $newTransport->shouldReceive('on')->with('client_disconnected', Mockery::type('callable'))->once(); 141 | $newTransport->shouldReceive('on')->with('error', Mockery::type('callable'))->once(); 142 | 143 | $this->protocol->bindTransport($newTransport); 144 | }); 145 | 146 | it('unbinds from a previous transport when binding a new one', function () { 147 | $this->transport->shouldReceive('removeListener')->times(4); 148 | 149 | $newTransport = Mockery::mock(ServerTransportInterface::class); 150 | $newTransport->shouldReceive('on')->times(4); 151 | 152 | $this->protocol->bindTransport($newTransport); 153 | }); 154 | 155 | it('unbinds transport and removes listeners', function () { 156 | $this->transport->shouldReceive('removeListener')->with('message', Mockery::type('callable'))->once(); 157 | $this->transport->shouldReceive('removeListener')->with('client_connected', Mockery::type('callable'))->once(); 158 | $this->transport->shouldReceive('removeListener')->with('client_disconnected', Mockery::type('callable'))->once(); 159 | $this->transport->shouldReceive('removeListener')->with('error', Mockery::type('callable'))->once(); 160 | 161 | $this->protocol->unbindTransport(); 162 | 163 | $reflection = new \ReflectionClass($this->protocol); 164 | $transportProp = $reflection->getProperty('transport'); 165 | $transportProp->setAccessible(true); 166 | expect($transportProp->getValue($this->protocol))->toBeNull(); 167 | }); 168 | 169 | it('processes a valid Request message', function () { 170 | $request = createRequest('test/method', ['param' => 1]); 171 | $result = new EmptyResult(); 172 | $expectedResponse = Response::make($request->id, $result); 173 | 174 | $this->dispatcher->shouldReceive('handleRequest')->once() 175 | ->with( 176 | Mockery::on(fn ($arg) => $arg instanceof Request && $arg->method === 'test/method'), 177 | Mockery::on(fn ($arg) => $arg instanceof Context && $arg->session === $this->session), 178 | ) 179 | ->andReturn($result); 180 | 181 | $this->transport->shouldReceive('sendMessage')->once() 182 | ->with(Mockery::on(fn ($arg) => $arg instanceof Response && $arg->id === $request->id && $arg->result === $result), SESSION_ID, Mockery::any()) 183 | ->andReturn(resolve(null)); 184 | 185 | $this->protocol->processMessage($request, SESSION_ID); 186 | $this->session->shouldHaveReceived('save'); 187 | }); 188 | 189 | it('processes a valid Notification message', function () { 190 | $notification = createNotification('test/notify', ['data' => 'info']); 191 | 192 | $this->dispatcher->shouldReceive('handleNotification')->once() 193 | ->with(Mockery::on(fn ($arg) => $arg instanceof Notification && $arg->method === 'test/notify'), $this->session) 194 | ->andReturnNull(); 195 | 196 | $this->transport->shouldNotReceive('sendMessage'); 197 | 198 | $this->protocol->processMessage($notification, SESSION_ID); 199 | $this->session->shouldHaveReceived('save'); 200 | }); 201 | 202 | it('processes a BatchRequest with mixed requests and notifications', function () { 203 | $req1 = createRequest('req/1', [], 'batch-id-1'); 204 | $notif1 = createNotification('notif/1'); 205 | $req2 = createRequest('req/2', [], 'batch-id-2'); 206 | $batchRequest = new BatchRequest([$req1, $notif1, $req2]); 207 | 208 | $result1 = new EmptyResult(); 209 | $result2 = new EmptyResult(); 210 | 211 | $this->dispatcher->shouldReceive('handleRequest') 212 | ->once() 213 | ->with( 214 | Mockery::on(fn (Request $r) => $r->id === 'batch-id-1'), 215 | Mockery::on(fn ($arg) => $arg instanceof Context && $arg->session === $this->session), 216 | ) 217 | ->andReturn($result1); 218 | $this->dispatcher->shouldReceive('handleNotification') 219 | ->once() 220 | ->with(Mockery::on(fn (Notification $n) => $n->method === 'notif/1'), $this->session); 221 | $this->dispatcher->shouldReceive('handleRequest') 222 | ->once() 223 | ->with( 224 | Mockery::on(fn (Request $r) => $r->id === 'batch-id-2'), 225 | Mockery::on(fn ($arg) => $arg instanceof Context && $arg->session === $this->session) 226 | ) 227 | ->andReturn($result2); 228 | 229 | 230 | $this->transport->shouldReceive('sendMessage')->once() 231 | ->with(Mockery::on(function (BatchResponse $response) use ($req1, $req2, $result1, $result2) { 232 | expect(count($response->items))->toBe(2); 233 | expect($response->items[0]->id)->toBe($req1->id); 234 | expect($response->items[0]->result)->toBe($result1); 235 | expect($response->items[1]->id)->toBe($req2->id); 236 | expect($response->items[1]->result)->toBe($result2); 237 | return true; 238 | }), SESSION_ID, Mockery::any()) 239 | ->andReturn(resolve(null)); 240 | 241 | $this->protocol->processMessage($batchRequest, SESSION_ID); 242 | $this->session->shouldHaveReceived('save'); 243 | }); 244 | 245 | it('processes a BatchRequest with only notifications and sends no response', function () { 246 | $notif1 = createNotification('notif/only1'); 247 | $notif2 = createNotification('notif/only2'); 248 | $batchRequest = new BatchRequest([$notif1, $notif2]); 249 | 250 | $this->dispatcher->shouldReceive('handleNotification')->twice(); 251 | $this->transport->shouldNotReceive('sendMessage'); 252 | 253 | $this->protocol->processMessage($batchRequest, SESSION_ID); 254 | $this->session->shouldHaveReceived('save'); 255 | }); 256 | 257 | 258 | it('sends error response if session is not found', function () { 259 | $request = createRequest('test/method'); 260 | $this->sessionManager->shouldReceive('getSession')->with('unknown-client')->andReturn(null); 261 | 262 | $this->transport->shouldReceive('sendMessage')->once() 263 | ->with(Mockery::on(function (Error $error) use ($request) { 264 | expectErrorResponse($error, \PhpMcp\Schema\Constants::INVALID_REQUEST, $request->id); 265 | expect($error->message)->toContain('Invalid or expired session'); 266 | return true; 267 | }), 'unknown-client', ['status_code' => 404, 'is_initialize_request' => false]) 268 | ->andReturn(resolve(null)); 269 | 270 | $this->protocol->processMessage($request, 'unknown-client', ['is_initialize_request' => false]); 271 | $this->session->shouldNotHaveReceived('save'); 272 | }); 273 | 274 | it('sends error response if session is not initialized for non-initialize request', function () { 275 | $request = createRequest('tools/list'); 276 | $this->session->shouldReceive('get')->with('initialized', false)->andReturn(false); 277 | 278 | $this->transport->shouldReceive('sendMessage')->once() 279 | ->with(Mockery::on(function (Error $error) use ($request) { 280 | expectErrorResponse($error, \PhpMcp\Schema\Constants::INVALID_REQUEST, $request->id); 281 | expect($error->message)->toContain('Client session not initialized'); 282 | return true; 283 | }), SESSION_ID, Mockery::any()) 284 | ->andReturn(resolve(null)); 285 | 286 | $this->protocol->processMessage($request, SESSION_ID); 287 | }); 288 | 289 | it('sends error response if capability for request method is disabled', function () { 290 | $request = createRequest('tools/list'); 291 | $configuration = new Configuration( 292 | serverInfo: $this->configuration->serverInfo, 293 | capabilities: ServerCapabilities::make(tools: false), 294 | logger: $this->logger, 295 | loop: $this->configuration->loop, 296 | cache: $this->configuration->cache, 297 | container: $this->configuration->container, 298 | ); 299 | 300 | $protocol = new Protocol( 301 | $configuration, 302 | $this->registry, 303 | $this->sessionManager, 304 | $this->dispatcher, 305 | $this->subscriptionManager 306 | ); 307 | 308 | $protocol->bindTransport($this->transport); 309 | 310 | $this->transport->shouldReceive('sendMessage')->once() 311 | ->with(Mockery::on(function (Error $error) use ($request) { 312 | expectErrorResponse($error, \PhpMcp\Schema\Constants::METHOD_NOT_FOUND, $request->id); 313 | expect($error->message)->toContain('Tools are not enabled'); 314 | return true; 315 | }), SESSION_ID, Mockery::any()) 316 | ->andReturn(resolve(null)); 317 | 318 | $protocol->processMessage($request, SESSION_ID); 319 | }); 320 | 321 | it('sends exceptions thrown while handling request as JSON-RPC error', function () { 322 | $request = createRequest('fail/method'); 323 | $exception = McpServerException::methodNotFound('fail/method'); 324 | 325 | $this->dispatcher->shouldReceive('handleRequest')->once()->andThrow($exception); 326 | 327 | $this->transport->shouldReceive('sendMessage')->once() 328 | ->with(Mockery::on(function (Error $error) use ($request) { 329 | expectErrorResponse($error, \PhpMcp\Schema\Constants::METHOD_NOT_FOUND, $request->id); 330 | expect($error->message)->toContain('Method not found'); 331 | return true; 332 | }), SESSION_ID, Mockery::any()) 333 | ->andReturn(resolve(null)); 334 | 335 | $this->protocol->processMessage($request, SESSION_ID); 336 | 337 | 338 | $request = createRequest('explode/method'); 339 | $exception = new \RuntimeException('Something bad happened'); 340 | 341 | $this->dispatcher->shouldReceive('handleRequest')->once()->andThrow($exception); 342 | 343 | $this->transport->shouldReceive('sendMessage')->once() 344 | ->with(Mockery::on(function (Error $error) use ($request) { 345 | expectErrorResponse($error, \PhpMcp\Schema\Constants::INTERNAL_ERROR, $request->id); 346 | expect($error->message)->toContain('Internal error processing method explode/method'); 347 | expect($error->data)->toBe('Something bad happened'); 348 | return true; 349 | }), SESSION_ID, Mockery::any()) 350 | ->andReturn(resolve(null)); 351 | 352 | $this->protocol->processMessage($request, SESSION_ID); 353 | }); 354 | 355 | it('sends a notification successfully', function () { 356 | $notification = createNotification('event/occurred', ['value' => true]); 357 | 358 | $this->transport->shouldReceive('sendMessage')->once() 359 | ->with($notification, SESSION_ID, []) 360 | ->andReturn(resolve(null)); 361 | 362 | $promise = $this->protocol->sendNotification($notification, SESSION_ID); 363 | await($promise); 364 | }); 365 | 366 | it('rejects sending notification if transport not bound', function () { 367 | $this->protocol->unbindTransport(); 368 | $notification = createNotification('event/occurred'); 369 | 370 | $promise = $this->protocol->sendNotification($notification, SESSION_ID); 371 | 372 | await($promise->then(null, function (McpServerException $e) { 373 | expect($e->getMessage())->toContain('Transport not bound'); 374 | })); 375 | }); 376 | 377 | it('rejects sending notification if transport send fails', function () { 378 | $notification = createNotification('event/occurred'); 379 | $transportException = new \PhpMcp\Server\Exception\TransportException('Send failed'); 380 | $this->transport->shouldReceive('sendMessage')->once()->andReturn(reject($transportException)); 381 | 382 | $promise = $this->protocol->sendNotification($notification, SESSION_ID); 383 | await($promise->then(null, function (McpServerException $e) use ($transportException) { 384 | expect($e->getMessage())->toContain('Failed to send notification: Send failed'); 385 | expect($e->getPrevious())->toBe($transportException); 386 | })); 387 | }); 388 | 389 | it('notifies resource updated to subscribers', function () { 390 | $uri = 'test://resource/123'; 391 | $subscribers = ['client-sub-1', 'client-sub-2']; 392 | $this->subscriptionManager->shouldReceive('getSubscribers')->with($uri)->andReturn($subscribers); 393 | 394 | $expectedNotification = ResourceUpdatedNotification::make($uri); 395 | 396 | $this->transport->shouldReceive('sendMessage')->twice() 397 | ->with(Mockery::on(function (Notification $notification) use ($expectedNotification) { 398 | expect($notification->method)->toBe($expectedNotification->method); 399 | expect($notification->params)->toBe($expectedNotification->params); 400 | return true; 401 | }), Mockery::anyOf(...$subscribers), []) 402 | ->andReturn(resolve(null)); 403 | 404 | $this->protocol->notifyResourceUpdated($uri); 405 | }); 406 | 407 | it('handles client connected event', function () { 408 | $this->logger->shouldReceive('info')->with('Client connected', ['sessionId' => SESSION_ID])->once(); 409 | $this->sessionManager->shouldReceive('createSession')->with(SESSION_ID)->once(); 410 | 411 | $this->protocol->handleClientConnected(SESSION_ID); 412 | }); 413 | 414 | it('handles client disconnected event', function () { 415 | $reason = 'Connection closed'; 416 | $this->logger->shouldReceive('info')->with('Client disconnected', ['clientId' => SESSION_ID, 'reason' => $reason])->once(); 417 | $this->sessionManager->shouldReceive('deleteSession')->with(SESSION_ID)->once(); 418 | 419 | $this->protocol->handleClientDisconnected(SESSION_ID, $reason); 420 | }); 421 | 422 | it('handles transport error event with client ID', function () { 423 | $error = new \RuntimeException('Socket error'); 424 | $this->logger->shouldReceive('error') 425 | ->with('Transport error for client', ['error' => 'Socket error', 'exception_class' => \RuntimeException::class, 'clientId' => SESSION_ID]) 426 | ->once(); 427 | 428 | $this->protocol->handleTransportError($error, SESSION_ID); 429 | }); 430 | 431 | it('handles transport error event without client ID', function () { 432 | $error = new \RuntimeException('Listener setup failed'); 433 | $this->logger->shouldReceive('error') 434 | ->with('General transport error', ['error' => 'Listener setup failed', 'exception_class' => \RuntimeException::class]) 435 | ->once(); 436 | 437 | $this->protocol->handleTransportError($error, null); 438 | }); 439 | 440 | it('handles list changed event from registry and notifies subscribers', function (string $listType, string $expectedNotificationClass) { 441 | $listChangeUri = "mcp://changes/{$listType}"; 442 | $subscribers = ['client-sub-A', 'client-sub-B']; 443 | 444 | $this->subscriptionManager->shouldReceive('getSubscribers')->with($listChangeUri)->andReturn($subscribers); 445 | $capabilities = ServerCapabilities::make( 446 | toolsListChanged: true, 447 | resourcesListChanged: true, 448 | promptsListChanged: true, 449 | ); 450 | 451 | $configuration = new Configuration( 452 | serverInfo: $this->configuration->serverInfo, 453 | capabilities: $capabilities, 454 | logger: $this->logger, 455 | loop: $this->configuration->loop, 456 | cache: $this->configuration->cache, 457 | container: $this->configuration->container, 458 | ); 459 | 460 | $protocol = new Protocol( 461 | $configuration, 462 | $this->registry, 463 | $this->sessionManager, 464 | $this->dispatcher, 465 | $this->subscriptionManager 466 | ); 467 | 468 | $protocol->bindTransport($this->transport); 469 | 470 | $this->transport->shouldReceive('sendMessage') 471 | ->with(Mockery::type($expectedNotificationClass), Mockery::anyOf(...$subscribers), []) 472 | ->times(count($subscribers)) 473 | ->andReturn(resolve(null)); 474 | 475 | $protocol->handleListChanged($listType); 476 | })->with([ 477 | 'tools' => ['tools', ToolListChangedNotification::class], 478 | 'resources' => ['resources', ResourceListChangedNotification::class], 479 | ]); 480 | 481 | it('does not send list changed notification if capability is disabled', function (string $listType) { 482 | $listChangeUri = "mcp://changes/{$listType}"; 483 | $subscribers = ['client-sub-A']; 484 | $this->subscriptionManager->shouldReceive('getSubscribers')->with($listChangeUri)->andReturn($subscribers); 485 | 486 | $caps = ServerCapabilities::make( 487 | toolsListChanged: $listType !== 'tools', 488 | resourcesListChanged: $listType !== 'resources', 489 | promptsListChanged: $listType !== 'prompts', 490 | ); 491 | 492 | $configuration = new Configuration( 493 | serverInfo: $this->configuration->serverInfo, 494 | capabilities: $caps, 495 | logger: $this->logger, 496 | loop: $this->configuration->loop, 497 | cache: $this->configuration->cache, 498 | container: $this->configuration->container, 499 | ); 500 | 501 | $protocol = new Protocol( 502 | $configuration, 503 | $this->registry, 504 | $this->sessionManager, 505 | $this->dispatcher, 506 | $this->subscriptionManager 507 | ); 508 | 509 | $protocol->bindTransport($this->transport); 510 | $this->transport->shouldNotReceive('sendMessage'); 511 | })->with(['tools', 'resources', 'prompts',]); 512 | 513 | it('allows initialize request when session not initialized', function () { 514 | $request = createRequest('initialize', ['protocolVersion' => SUPPORTED_VERSION_PROTO]); 515 | $this->session->shouldReceive('get')->with('initialized', false)->andReturn(false); 516 | 517 | $this->dispatcher->shouldReceive('handleRequest')->once() 518 | ->with( 519 | Mockery::type(Request::class), 520 | Mockery::on(fn ($arg) => $arg instanceof Context && $arg->session === $this->session) 521 | ) 522 | ->andReturn(new EmptyResult()); 523 | 524 | $this->transport->shouldReceive('sendMessage')->once() 525 | ->andReturn(resolve(null)); 526 | 527 | $this->protocol->processMessage($request, SESSION_ID); 528 | }); 529 | 530 | it('allows initialize and ping regardless of capabilities', function (string $method) { 531 | $request = createRequest($method); 532 | $capabilities = ServerCapabilities::make( 533 | tools: false, 534 | resources: false, 535 | prompts: false, 536 | logging: false, 537 | ); 538 | $configuration = new Configuration( 539 | serverInfo: $this->configuration->serverInfo, 540 | capabilities: $capabilities, 541 | logger: $this->logger, 542 | loop: $this->configuration->loop, 543 | cache: $this->configuration->cache, 544 | container: $this->configuration->container, 545 | ); 546 | 547 | $protocol = new Protocol( 548 | $configuration, 549 | $this->registry, 550 | $this->sessionManager, 551 | $this->dispatcher, 552 | $this->subscriptionManager 553 | ); 554 | 555 | $protocol->bindTransport($this->transport); 556 | 557 | $this->dispatcher->shouldReceive('handleRequest')->once()->andReturn(new EmptyResult()); 558 | $this->transport->shouldReceive('sendMessage')->once() 559 | ->andReturn(resolve(null)); 560 | 561 | $protocol->processMessage($request, SESSION_ID); 562 | })->with(['initialize', 'ping']); 563 | ```