#
tokens: 42571/50000 7/154 files (page 5/7)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 5/7FirstPrevNextLast