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 |
```