1: <?php
2:
3: declare(strict_types=1);
4:
5: /**
6: * This file is part of the Nexus MCP SDK package.
7: *
8: * (c) 2026 John Paul E. Balandan, CPA <paulbalandan@gmail.com>
9: *
10: * For the full copyright and license information, please view
11: * the LICENSE file that was distributed with this source code.
12: */
13:
14: namespace Nexus\Mcp\Server;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Mcp\Core\Dispatch\PendingInboundRequests;
18: use Nexus\Mcp\Core\Handler\HandlerRegistry;
19: use Nexus\Mcp\Core\Handler\Notification\CancelledNotificationHandler;
20: use Nexus\Mcp\Core\Handler\NotificationHandlerInterface;
21: use Nexus\Mcp\Core\Handler\RequestHandlerInterface;
22: use Nexus\Mcp\Core\JsonRpc\JsonRpcMethodRegistry;
23: use Nexus\Mcp\Core\Schema\Enum\CacheScope;
24: use Nexus\Mcp\Core\Schema\Icon;
25: use Nexus\Mcp\Core\Schema\Implementation;
26: use Nexus\Mcp\Core\Schema\Notification\CancelledNotification;
27: use Nexus\Mcp\Core\Schema\Prompt\Prompt;
28: use Nexus\Mcp\Core\Schema\Request\CallToolRequest;
29: use Nexus\Mcp\Core\Schema\Request\CompleteRequest;
30: use Nexus\Mcp\Core\Schema\Request\DiscoverRequest;
31: use Nexus\Mcp\Core\Schema\Request\GetPromptRequest;
32: use Nexus\Mcp\Core\Schema\Request\ListPromptsRequest;
33: use Nexus\Mcp\Core\Schema\Request\ListResourcesRequest;
34: use Nexus\Mcp\Core\Schema\Request\ListResourceTemplatesRequest;
35: use Nexus\Mcp\Core\Schema\Request\ListToolsRequest;
36: use Nexus\Mcp\Core\Schema\Request\ReadResourceRequest;
37: use Nexus\Mcp\Core\Schema\Request\SubscriptionsListenRequest;
38: use Nexus\Mcp\Core\Schema\Resource\Resource;
39: use Nexus\Mcp\Core\Schema\Resource\ResourceTemplate;
40: use Nexus\Mcp\Core\Schema\Result;
41: use Nexus\Mcp\Core\Schema\Result\CallToolResult;
42: use Nexus\Mcp\Core\Schema\Result\GetPromptResult;
43: use Nexus\Mcp\Core\Schema\Result\InputRequiredResult;
44: use Nexus\Mcp\Core\Schema\Result\ReadResourceResult;
45: use Nexus\Mcp\Core\Schema\ServerCapabilities;
46: use Nexus\Mcp\Core\Schema\SubscriptionFilter;
47: use Nexus\Mcp\Core\Schema\Tool\Tool;
48: use Nexus\Mcp\Core\UriTemplate\Validator;
49: use Nexus\Mcp\Server\Attribute\AsServer;
50: use Nexus\Mcp\Server\Completion\CompletionStoreInterface;
51: use Nexus\Mcp\Server\Discovery\AttributeScanner;
52: use Nexus\Mcp\Server\Dispatch\ServerMessageDispatcher;
53: use Nexus\Mcp\Server\Exception\BuilderAlreadyBuiltException;
54: use Nexus\Mcp\Server\Exception\DuplicateServerMetadataException;
55: use Nexus\Mcp\Server\Exception\MissingDiscoveryAttributeException;
56: use Nexus\Mcp\Server\Exception\ReservedMethodException;
57: use Nexus\Mcp\Server\Exception\UnreservedMethodException;
58: use Nexus\Mcp\Server\Handler\Request\CallToolRequestHandler;
59: use Nexus\Mcp\Server\Handler\Request\CompleteRequestHandler;
60: use Nexus\Mcp\Server\Handler\Request\DiscoverRequestHandler;
61: use Nexus\Mcp\Server\Handler\Request\GetPromptRequestHandler;
62: use Nexus\Mcp\Server\Handler\Request\ListPromptsRequestHandler;
63: use Nexus\Mcp\Server\Handler\Request\ListResourcesRequestHandler;
64: use Nexus\Mcp\Server\Handler\Request\ListResourceTemplatesRequestHandler;
65: use Nexus\Mcp\Server\Handler\Request\ListToolsRequestHandler;
66: use Nexus\Mcp\Server\Handler\Request\ReadResourceRequestHandler;
67: use Nexus\Mcp\Server\Handler\Request\SubscriptionsListenRequestHandler;
68: use Nexus\Mcp\Server\Prompt\ClosurePromptRenderer;
69: use Nexus\Mcp\Server\Prompt\PromptEntry;
70: use Nexus\Mcp\Server\Prompt\PromptRendererInterface;
71: use Nexus\Mcp\Server\Prompt\PromptStore;
72: use Nexus\Mcp\Server\Prompt\PromptStoreInterface;
73: use Nexus\Mcp\Server\Resource\ClosureResourceReader;
74: use Nexus\Mcp\Server\Resource\ClosureTemplatedResourceReader;
75: use Nexus\Mcp\Server\Resource\CompositeResourceStore;
76: use Nexus\Mcp\Server\Resource\ResourceEntry;
77: use Nexus\Mcp\Server\Resource\ResourceReaderInterface;
78: use Nexus\Mcp\Server\Resource\ResourceStore;
79: use Nexus\Mcp\Server\Resource\ResourceStoreInterface;
80: use Nexus\Mcp\Server\Resource\ResourceTemplateEntry;
81: use Nexus\Mcp\Server\Resource\ResourceTemplateStore;
82: use Nexus\Mcp\Server\Resource\ResourceTemplateStoreInterface;
83: use Nexus\Mcp\Server\Resource\TemplatedResourceReaderInterface;
84: use Nexus\Mcp\Server\Subscription\SubscriptionStoreInterface;
85: use Nexus\Mcp\Server\Tool\ClosureToolExecutor;
86: use Nexus\Mcp\Server\Tool\ToolEntry;
87: use Nexus\Mcp\Server\Tool\ToolExecutorInterface;
88: use Nexus\Mcp\Server\Tool\ToolStore;
89: use Nexus\Mcp\Server\Tool\ToolStoreInterface;
90: use Nexus\Mcp\Server\Validation\OpisSchemaValidator;
91: use Nexus\Mcp\Server\Validation\SchemaValidatorInterface;
92: use Psr\Log\LoggerInterface;
93: use Psr\Log\NullLogger;
94:
95: /**
96: * Fluent builder that wires the per-feature stores, the dispatch kernel, and
97: * the lifecycle shell into a runnable `Server` instance.
98: */
99: final class ServerBuilder
100: {
101: private ?Implementation $serverInfo = null;
102:
103: /**
104: * @var null|non-empty-string
105: */
106: private ?string $instructions = null;
107:
108: private ?AsServer $serverMetadata = null;
109: private ServerInfoDisclosure $serverInfoDisclosure = ServerInfoDisclosure::Full;
110:
111: /**
112: * @var null|int<1, max>
113: */
114: private ?int $maxInFlight = null;
115:
116: private LoggerInterface $logger;
117: private SchemaValidatorInterface $schemaValidator;
118:
119: /**
120: * @var array<non-empty-string, ToolEntry>
121: */
122: private array $tools = [];
123:
124: /**
125: * @var array<non-empty-string, PromptEntry>
126: */
127: private array $prompts = [];
128:
129: /**
130: * @var array<non-empty-string, ResourceEntry>
131: */
132: private array $resources = [];
133:
134: /**
135: * @var array<non-empty-string, ResourceTemplateEntry>
136: */
137: private array $resourceTemplates = [];
138:
139: private int $pageSize = CursorPaginator::DEFAULT_PAGE_SIZE;
140: private int $ttlMs = 0;
141: private CacheScope $cacheScope = CacheScope::Private;
142: private ?ToolStoreInterface $toolStore = null;
143: private ?PromptStoreInterface $promptStore = null;
144: private ?ResourceStoreInterface $resourceStore = null;
145: private ?ResourceTemplateStoreInterface $resourceTemplateStore = null;
146: private ?CompletionStoreInterface $completionStore = null;
147: private ?SubscriptionStoreInterface $subscriptionStore = null;
148: private bool $built = false;
149:
150: /**
151: * @var array<non-empty-string, RequestHandlerInterface<non-empty-string, Result, ServerContext>>
152: */
153: private array $customRequestHandlers = [];
154:
155: /**
156: * @var array<non-empty-string, NotificationHandlerInterface<non-empty-string>>
157: */
158: private array $customNotificationHandlers = [];
159:
160: public function __construct()
161: {
162: $this->logger = new NullLogger();
163: $this->schemaValidator = new OpisSchemaValidator();
164: }
165:
166: /**
167: * @param null|list<Icon> $icons
168: */
169: public function setServerInfo(
170: string $name,
171: string $version,
172: ?string $title = null,
173: ?string $description = null,
174: ?string $websiteUrl = null,
175: ?array $icons = null,
176: ): self {
177: $this->assertNotBuilt();
178:
179: $this->serverInfo = new Implementation(
180: name: $name,
181: version: $version,
182: title: $title,
183: description: $description,
184: websiteUrl: $websiteUrl,
185: icons: $icons,
186: );
187:
188: return $this;
189: }
190:
191: /**
192: * Caps how many inbound messages the server processes at once. Past the cap a request is
193: * answered `-32000` and a notification is dropped, until running handlers finish. Null lifts
194: * the cap, which is the default. A `subscriptions/listen` is exempt: it opens a subscription rather
195: * than being processed, and the subscription store bounds how many streams may be open.
196: */
197: public function setMaxInFlightDispatches(?int $max): self
198: {
199: $this->assertNotBuilt();
200:
201: Assert::that($max)->nullOr()->isPositiveInt('Maximum in-flight dispatches must be a positive integer or null, {value} given.');
202:
203: $this->maxInFlight = $max;
204:
205: return $this;
206: }
207:
208: /**
209: * Controls how much of the server's identity rides the `_meta` of the results it sends.
210: */
211: public function setServerInfoDisclosure(ServerInfoDisclosure $disclosure): self
212: {
213: $this->assertNotBuilt();
214:
215: $this->serverInfoDisclosure = $disclosure;
216:
217: return $this;
218: }
219:
220: public function setInstructions(?string $instructions): self
221: {
222: $this->assertNotBuilt();
223:
224: Assert::that($instructions)
225: ->nullOr()
226: ->isNonEmptyString('Server instructions must be a non-empty string or null.')
227: ;
228:
229: $this->instructions = $instructions;
230:
231: return $this;
232: }
233:
234: public function setLogger(LoggerInterface $logger): self
235: {
236: $this->assertNotBuilt();
237:
238: $this->logger = $logger;
239:
240: return $this;
241: }
242:
243: public function setSchemaValidator(SchemaValidatorInterface $validator): self
244: {
245: $this->assertNotBuilt();
246:
247: $this->schemaValidator = $validator;
248:
249: return $this;
250: }
251:
252: /**
253: * Sets how many entries one page of a list result carries, for every store the builder assembles
254: * from its `add*()` entries. A store supplied through `setToolStore()` and its siblings keeps its own.
255: */
256: public function setPageSize(int $pageSize): self
257: {
258: $this->assertNotBuilt();
259:
260: Assert::that($pageSize)->isPositiveInt('Store page size must be a positive integer, {value} given.');
261:
262: $this->pageSize = $pageSize;
263:
264: return $this;
265: }
266:
267: /**
268: * Sets how many milliseconds a client may treat a list result as fresh, for every store the builder
269: * assembles from its `add*()` entries. Zero asks the client to re-fetch every time.
270: */
271: public function setTtlMs(int $ttlMs): self
272: {
273: $this->assertNotBuilt();
274:
275: Assert::that($ttlMs)->isNaturalInt('Store TTL must be a non-negative integer, {value} given.');
276:
277: $this->ttlMs = $ttlMs;
278:
279: return $this;
280: }
281:
282: /**
283: * Sets which caches may serve a list result, for every store the builder assembles from its
284: * `add*()` entries.
285: */
286: public function setCacheScope(CacheScope $cacheScope): self
287: {
288: $this->assertNotBuilt();
289:
290: $this->cacheScope = $cacheScope;
291:
292: return $this;
293: }
294:
295: /**
296: * @param (\Closure(?array<string, mixed>, ServerContext): (CallToolResult|InputRequiredResult))|ToolExecutorInterface $executor
297: */
298: public function addTool(Tool $tool, \Closure|ToolExecutorInterface $executor): self
299: {
300: $this->assertNotBuilt();
301:
302: $this->tools[$tool->name] = new ToolEntry(
303: $tool,
304: $executor instanceof ToolExecutorInterface ? $executor : new ClosureToolExecutor($executor),
305: );
306:
307: return $this;
308: }
309:
310: /**
311: * @param (\Closure(?array<string, string>, ServerContext): (GetPromptResult|InputRequiredResult))|PromptRendererInterface $renderer
312: */
313: public function addPrompt(Prompt $prompt, \Closure|PromptRendererInterface $renderer): self
314: {
315: $this->assertNotBuilt();
316:
317: $this->prompts[$prompt->name] = new PromptEntry(
318: $prompt,
319: $renderer instanceof PromptRendererInterface ? $renderer : new ClosurePromptRenderer($renderer),
320: );
321:
322: return $this;
323: }
324:
325: /**
326: * @param (\Closure(string, ServerContext): (InputRequiredResult|ReadResourceResult))|ResourceReaderInterface $reader
327: */
328: public function addResource(Resource $resource, \Closure|ResourceReaderInterface $reader): self
329: {
330: $this->assertNotBuilt();
331:
332: $this->resources[$resource->uri] = new ResourceEntry(
333: $resource,
334: $reader instanceof ResourceReaderInterface ? $reader : new ClosureResourceReader($reader),
335: );
336:
337: return $this;
338: }
339:
340: /**
341: * @param (\Closure(string, array<string, string>, ServerContext): (InputRequiredResult|ReadResourceResult))|TemplatedResourceReaderInterface $reader
342: */
343: public function addResourceTemplate(
344: ResourceTemplate $template,
345: \Closure|TemplatedResourceReaderInterface $reader,
346: ): self {
347: $this->assertNotBuilt();
348:
349: Validator::validate($template->uriTemplate, 'ResourceTemplate');
350:
351: $this->resourceTemplates[$template->uriTemplate] = new ResourceTemplateEntry(
352: $template,
353: $reader instanceof TemplatedResourceReaderInterface ? $reader : new ClosureTemplatedResourceReader($reader),
354: );
355:
356: return $this;
357: }
358:
359: public function setToolStore(ToolStoreInterface $store): self
360: {
361: $this->assertNotBuilt();
362:
363: $this->toolStore = $store;
364:
365: return $this;
366: }
367:
368: /**
369: * The tool store the built server serves, or null when it exposes no tools. Assembled from the
370: * `addTool()` and `register()` entries unless `setToolStore()` supplied one.
371: *
372: * Call it once every tool is registered, since it holds the store it returns. Pass the result to
373: * `SecuredHttpEndpoint` so `Mcp-Param-{Name}` validation reads the same tools the handlers serve.
374: */
375: public function getToolStore(): ?ToolStoreInterface
376: {
377: if (null === $this->toolStore && [] === $this->tools) {
378: return null;
379: }
380:
381: $this->toolStore ??= new ToolStore(
382: entries: $this->tools,
383: pageSize: $this->pageSize,
384: validator: $this->schemaValidator,
385: ttlMs: $this->ttlMs,
386: cacheScope: $this->cacheScope,
387: );
388:
389: return $this->toolStore;
390: }
391:
392: public function setPromptStore(PromptStoreInterface $store): self
393: {
394: $this->assertNotBuilt();
395:
396: $this->promptStore = $store;
397:
398: return $this;
399: }
400:
401: /**
402: * The prompt store the built server serves, or null when it exposes no prompts. Assembled from the
403: * `addPrompt()` and `register()` entries unless `setPromptStore()` supplied one.
404: *
405: * Call it once every prompt is registered, since it holds the store it returns.
406: */
407: public function getPromptStore(): ?PromptStoreInterface
408: {
409: if (null === $this->promptStore && [] === $this->prompts) {
410: return null;
411: }
412:
413: $this->promptStore ??= new PromptStore(
414: entries: $this->prompts,
415: pageSize: $this->pageSize,
416: ttlMs: $this->ttlMs,
417: cacheScope: $this->cacheScope,
418: );
419:
420: return $this->promptStore;
421: }
422:
423: public function setResourceStore(ResourceStoreInterface $store): self
424: {
425: $this->assertNotBuilt();
426:
427: $this->resourceStore = $store;
428:
429: return $this;
430: }
431:
432: /**
433: * The resource store the built server serves, or null when it exposes neither resources nor resource
434: * templates. Assembled from the `addResource()` and `register()` entries unless `setResourceStore()`
435: * supplied one.
436: *
437: * Call it once every resource is registered, since it holds the store it returns.
438: */
439: public function getResourceStore(): ?ResourceStoreInterface
440: {
441: // A template store alone still needs a resource store, since `resources/read` composes the two.
442: $templateStore = $this->getResourceTemplateStore();
443:
444: if (null === $this->resourceStore && [] === $this->resources && null === $templateStore) {
445: return null;
446: }
447:
448: $this->resourceStore ??= new ResourceStore(
449: entries: $this->resources,
450: pageSize: $this->pageSize,
451: ttlMs: $this->ttlMs,
452: cacheScope: $this->cacheScope,
453: );
454:
455: return $this->resourceStore;
456: }
457:
458: public function setResourceTemplateStore(ResourceTemplateStoreInterface $store): self
459: {
460: $this->assertNotBuilt();
461:
462: $this->resourceTemplateStore = $store;
463:
464: return $this;
465: }
466:
467: /**
468: * The resource template store the built server serves, or null when it exposes no templates. Assembled
469: * from the `addResourceTemplate()` and `register()` entries unless `setResourceTemplateStore()` supplied
470: * one.
471: *
472: * Call it once every template is registered, since it holds the store it returns.
473: */
474: public function getResourceTemplateStore(): ?ResourceTemplateStoreInterface
475: {
476: if (null === $this->resourceTemplateStore && [] === $this->resourceTemplates) {
477: return null;
478: }
479:
480: $this->resourceTemplateStore ??= new ResourceTemplateStore(
481: entries: $this->resourceTemplates,
482: pageSize: $this->pageSize,
483: ttlMs: $this->ttlMs,
484: cacheScope: $this->cacheScope,
485: );
486:
487: return $this->resourceTemplateStore;
488: }
489:
490: /**
491: * Serves `subscriptions/listen` from `$store`, and lights up the `listChanged` capability of every
492: * feature whose store can report its changes.
493: */
494: public function setSubscriptionStore(SubscriptionStoreInterface $store): self
495: {
496: $this->assertNotBuilt();
497:
498: $this->subscriptionStore = $store;
499:
500: return $this;
501: }
502:
503: public function setCompletionStore(CompletionStoreInterface $store): self
504: {
505: $this->assertNotBuilt();
506:
507: $this->completionStore = $store;
508:
509: return $this;
510: }
511:
512: /**
513: * Registers the server identity (`#[AsServer]`) plus the tools, prompts, resources, and
514: * resource templates discovered from `#[AsTool]`, `#[AsPrompt]`, `#[AsResource]`, and
515: * `#[AsResourceTemplate]` methods on each source object. An explicit `setServerInfo()` or
516: * `setInstructions()` call takes precedence over the matching `#[AsServer]` field, and at
517: * most one registered source may declare `#[AsServer]`.
518: *
519: * @throws DuplicateServerMetadataException
520: * @throws MissingDiscoveryAttributeException
521: */
522: public function register(object ...$sources): self
523: {
524: $this->assertNotBuilt();
525:
526: $scanner = new AttributeScanner();
527:
528: foreach ($sources as $source) {
529: $contributed = false;
530: $metadata = self::findServerMetadata($source);
531:
532: if (null !== $metadata) {
533: if (null !== $this->serverMetadata) {
534: throw new DuplicateServerMetadataException($source::class);
535: }
536:
537: $this->serverMetadata = $metadata;
538: $contributed = true;
539: }
540:
541: foreach ($scanner->scan($source) as $entry) {
542: $contributed = true;
543:
544: if ($entry instanceof ToolEntry) {
545: $this->addTool($entry->tool, $entry->executor);
546: } elseif ($entry instanceof PromptEntry) {
547: $this->addPrompt($entry->prompt, $entry->renderer);
548: } elseif ($entry instanceof ResourceEntry) {
549: $this->addResource($entry->resource, $entry->reader);
550: } else {
551: $this->addResourceTemplate($entry->template, $entry->reader);
552: }
553: }
554:
555: if (! $contributed) {
556: throw new MissingDiscoveryAttributeException($source::class);
557: }
558: }
559:
560: return $this;
561: }
562:
563: /**
564: * Registers a handler for a vendor-extension request method.
565: *
566: * @param non-empty-string $method
567: * @param RequestHandlerInterface<non-empty-string, Result, ServerContext> $handler
568: *
569: * @throws ReservedMethodException
570: *
571: * @see self::replaceRequestHandler()
572: */
573: public function addRequestHandler(string $method, RequestHandlerInterface $handler): self
574: {
575: $this->assertNotBuilt();
576:
577: if (\array_key_exists($method, JsonRpcMethodRegistry::requests())) {
578: throw new ReservedMethodException($method);
579: }
580:
581: $this->customRequestHandlers[$method] = $handler;
582:
583: return $this;
584: }
585:
586: /**
587: * Overrides the SDK's built-in handler for `$method`.
588: *
589: * @param non-empty-string $method
590: * @param RequestHandlerInterface<non-empty-string, Result, ServerContext> $handler
591: *
592: * @throws UnreservedMethodException
593: *
594: * @see self::addRequestHandler()
595: */
596: public function replaceRequestHandler(string $method, RequestHandlerInterface $handler): self
597: {
598: $this->assertNotBuilt();
599:
600: if (! \array_key_exists($method, JsonRpcMethodRegistry::requests())) {
601: throw new UnreservedMethodException($method);
602: }
603:
604: $this->customRequestHandlers[$method] = $handler;
605:
606: return $this;
607: }
608:
609: /**
610: * Registers a handler for a vendor-extension notification method.
611: *
612: * @param non-empty-string $method
613: * @param NotificationHandlerInterface<non-empty-string> $handler
614: *
615: * @throws ReservedMethodException
616: *
617: * @see self::replaceNotificationHandler()
618: */
619: public function addNotificationHandler(string $method, NotificationHandlerInterface $handler): self
620: {
621: $this->assertNotBuilt();
622:
623: if (\array_key_exists($method, JsonRpcMethodRegistry::notifications())) {
624: throw new ReservedMethodException($method, isNotification: true);
625: }
626:
627: $this->customNotificationHandlers[$method] = $handler;
628:
629: return $this;
630: }
631:
632: /**
633: * Overrides any built-in handler for `$method`, including spec notifications.
634: *
635: * @param non-empty-string $method
636: * @param NotificationHandlerInterface<non-empty-string> $handler
637: *
638: * @throws UnreservedMethodException
639: *
640: * @see self::addNotificationHandler()
641: */
642: public function replaceNotificationHandler(string $method, NotificationHandlerInterface $handler): self
643: {
644: $this->assertNotBuilt();
645:
646: if (! \array_key_exists($method, JsonRpcMethodRegistry::notifications())) {
647: throw new UnreservedMethodException($method, isNotification: true);
648: }
649:
650: $this->customNotificationHandlers[$method] = $handler;
651:
652: return $this;
653: }
654:
655: public function build(): Server
656: {
657: $this->assertNotBuilt();
658: $this->built = true;
659: $serverInfo = $this->resolveServerInfo();
660:
661: Assert::that($serverInfo)->isInstanceOf(
662: Implementation::class,
663: 'Server information must be set before build() via setServerInfo() or a class-level #[AsServer].',
664: );
665:
666: $capabilities = $this->deriveCapabilities();
667:
668: $requestHandlers = $this->buildRequestHandlers(
669: $capabilities,
670: ServerInfoDisclosure::None === $this->serverInfoDisclosure ? null : $serverInfo,
671: );
672:
673: $this->routeListChanges();
674:
675: $inboundRequests = new PendingInboundRequests();
676:
677: return new Server(
678: new ServerMessageDispatcher(
679: new HandlerRegistry($requestHandlers, RequestHandlerInterface::class, 'Request handler'),
680: new HandlerRegistry(
681: $this->buildNotificationHandlers($inboundRequests),
682: NotificationHandlerInterface::class,
683: 'Notification handler',
684: ),
685: logger: $this->logger,
686: serverInfo: $this->serverInfoDisclosure->project($serverInfo),
687: maxInFlight: $this->maxInFlight,
688: inboundRequests: $inboundRequests,
689: ),
690: $this->logger,
691: $this->subscriptionStore,
692: );
693: }
694:
695: /**
696: * Turns each store's change signal into the notification its own feature owns.
697: */
698: private function routeListChanges(): void
699: {
700: $subscriptionStore = $this->subscriptionStore;
701:
702: if (null === $subscriptionStore) {
703: return;
704: }
705:
706: $toolStore = $this->getToolStore();
707:
708: if ($toolStore instanceof ListChangeSourceInterface) {
709: $toolStore->onListChanged($subscriptionStore->emitToolListChanged(...));
710: }
711:
712: $promptStore = $this->getPromptStore();
713:
714: if ($promptStore instanceof ListChangeSourceInterface) {
715: $promptStore->onListChanged($subscriptionStore->emitPromptListChanged(...));
716: }
717:
718: $resourceStore = $this->getResourceStore();
719:
720: if ($resourceStore instanceof ListChangeSourceInterface) {
721: $resourceStore->onListChanged($subscriptionStore->emitResourceListChanged(...));
722: }
723:
724: $templateStore = $this->getResourceTemplateStore();
725:
726: if ($templateStore instanceof ListChangeSourceInterface) {
727: $templateStore->onListChanged($subscriptionStore->emitResourceListChanged(...));
728: }
729: }
730:
731: /**
732: * @return array<non-empty-string, NotificationHandlerInterface<non-empty-string>>
733: */
734: private function buildNotificationHandlers(PendingInboundRequests $inboundRequests): array
735: {
736: $defaults = [
737: CancelledNotification::getMethod() => new CancelledNotificationHandler($inboundRequests, $this->logger),
738: ];
739:
740: return [...$defaults, ...$this->customNotificationHandlers];
741: }
742:
743: /**
744: * Merges the explicit `setServerInfo()` values over the `#[AsServer]` fields, with the
745: * attribute filling only the gaps the setter left null.
746: */
747: private function resolveServerInfo(): ?Implementation
748: {
749: $metadata = $this->serverMetadata;
750:
751: if (null === $metadata) {
752: return $this->serverInfo;
753: }
754:
755: if (null === $this->serverInfo) {
756: return new Implementation(
757: name: $metadata->name,
758: version: $metadata->version,
759: title: $metadata->title,
760: description: $metadata->description,
761: websiteUrl: $metadata->websiteUrl,
762: icons: $metadata->icons,
763: );
764: }
765:
766: return new Implementation(
767: name: $this->serverInfo->name,
768: version: $this->serverInfo->version,
769: title: $this->serverInfo->title ?? $metadata->title,
770: description: $this->serverInfo->description ?? $metadata->description,
771: websiteUrl: $this->serverInfo->websiteUrl ?? $metadata->websiteUrl,
772: icons: $this->serverInfo->icons ?? $metadata->icons,
773: );
774: }
775:
776: /**
777: * @return null|non-empty-string
778: */
779: private function resolveInstructions(): ?string
780: {
781: $instructions = $this->instructions ?? $this->serverMetadata?->instructions;
782:
783: Assert::that($instructions)
784: ->nullOr()
785: ->isNonEmptyString('Server instructions must be a non-empty string or null.')
786: ;
787:
788: return $instructions;
789: }
790:
791: private static function findServerMetadata(object $source): ?AsServer
792: {
793: $attributes = new \ReflectionObject($source)->getAttributes(AsServer::class);
794:
795: return [] === $attributes ? null : $attributes[0]->newInstance();
796: }
797:
798: /**
799: * @throws BuilderAlreadyBuiltException
800: */
801: private function assertNotBuilt(): void
802: {
803: if ($this->built) {
804: // The built server holds the stores and the list-change listeners, so a later registration would
805: // be dropped without a trace.
806: throw new BuilderAlreadyBuiltException();
807: }
808: }
809:
810: private function deriveCapabilities(): ServerCapabilities
811: {
812: $honoured = $this->resolveHonouredNotifications();
813:
814: return new ServerCapabilities(
815: completions: $this->hasCompletionsCapability() ? [] : null,
816: prompts: $this->hasPromptsCapability()
817: ? self::listChangedFlag($this->getPromptStore() instanceof ListChangeSourceInterface, $honoured?->promptsListChanged)
818: : null,
819: resources: $this->resourcesCapability($honoured),
820: tools: $this->hasToolsCapability()
821: ? self::listChangedFlag($this->getToolStore() instanceof ListChangeSourceInterface, $honoured?->toolsListChanged)
822: : null,
823: );
824: }
825:
826: /**
827: * What the registered subscription store will deliver, or null when none is registered. Asking for
828: * everything makes the answer the store's own declaration, so no capability can promise more.
829: */
830: private function resolveHonouredNotifications(): ?SubscriptionFilter
831: {
832: return $this->subscriptionStore?->honour(new SubscriptionFilter(
833: toolsListChanged: true,
834: promptsListChanged: true,
835: resourcesListChanged: true,
836: resourceSubscriptions: [],
837: ));
838: }
839:
840: /**
841: * `listChanged` is a promise to deliver, so it is only set when the store behind the feature can report
842: * a change and the subscription store honours that notification type.
843: *
844: * @return array{listChanged?: bool}
845: */
846: private static function listChangedFlag(bool $reportsChanges, ?bool $honoured): array
847: {
848: if (true !== $honoured || ! $reportsChanges) {
849: return [];
850: }
851:
852: return ['listChanged' => true];
853: }
854:
855: /**
856: * @return null|array{listChanged?: bool, subscribe?: bool}
857: */
858: private function resourcesCapability(?SubscriptionFilter $honoured): ?array
859: {
860: if (! $this->hasResourcesCapability()) {
861: return null;
862: }
863:
864: // Both stores fan into `notifications/resources/list_changed`, so either one reporting is a promise
865: // the server can keep.
866: $reportsChanges = $this->getResourceStore() instanceof ListChangeSourceInterface
867: || $this->getResourceTemplateStore() instanceof ListChangeSourceInterface;
868:
869: $capability = self::listChangedFlag($reportsChanges, $honoured?->resourcesListChanged);
870:
871: if (null !== $honoured?->resourceSubscriptions) {
872: // `subscribe` means the server honours `resourceSubscriptions` on a listen filter.
873: $capability['subscribe'] = true;
874: }
875:
876: return $capability;
877: }
878:
879: private function hasCompletionsCapability(): bool
880: {
881: return null !== $this->completionStore
882: || isset($this->customRequestHandlers[CompleteRequest::getMethod()]);
883: }
884:
885: private function hasPromptsCapability(): bool
886: {
887: $store = $this->getPromptStore();
888:
889: if (null !== $store) {
890: return true;
891: }
892:
893: return isset($this->customRequestHandlers[GetPromptRequest::getMethod()])
894: && isset($this->customRequestHandlers[ListPromptsRequest::getMethod()]);
895: }
896:
897: private function hasResourcesCapability(): bool
898: {
899: $store = $this->getResourceStore();
900:
901: if (null !== $store) {
902: return true;
903: }
904:
905: return isset($this->customRequestHandlers[ListResourcesRequest::getMethod()])
906: && isset($this->customRequestHandlers[ReadResourceRequest::getMethod()]);
907: }
908:
909: private function hasToolsCapability(): bool
910: {
911: $store = $this->getToolStore();
912:
913: if (null !== $store) {
914: return true;
915: }
916:
917: return isset($this->customRequestHandlers[CallToolRequest::getMethod()])
918: && isset($this->customRequestHandlers[ListToolsRequest::getMethod()]);
919: }
920:
921: /**
922: * @return array<non-empty-string, RequestHandlerInterface<non-empty-string, Result, ServerContext>>
923: */
924: private function buildRequestHandlers(ServerCapabilities $capabilities, ?Implementation $serverInfo): array
925: {
926: $defaults = [
927: DiscoverRequest::getMethod() => new DiscoverRequestHandler(
928: $capabilities,
929: $this->resolveInstructions(),
930: serverInfo: $serverInfo,
931: ),
932: ];
933:
934: $toolStore = $this->getToolStore();
935:
936: if (null !== $toolStore) {
937: $defaults[ListToolsRequest::getMethod()] = new ListToolsRequestHandler($toolStore);
938: $defaults[CallToolRequest::getMethod()] = new CallToolRequestHandler($toolStore, $this->logger);
939: }
940:
941: $promptStore = $this->getPromptStore();
942:
943: if (null !== $promptStore) {
944: $defaults[ListPromptsRequest::getMethod()] = new ListPromptsRequestHandler($promptStore);
945: $defaults[GetPromptRequest::getMethod()] = new GetPromptRequestHandler($promptStore);
946: }
947:
948: $resourceTemplateStore = $this->getResourceTemplateStore();
949:
950: if (null !== $resourceTemplateStore) {
951: $defaults[ListResourceTemplatesRequest::getMethod()] = new ListResourceTemplatesRequestHandler($resourceTemplateStore);
952: }
953:
954: $resourceStore = $this->getResourceStore();
955:
956: if (null !== $resourceStore) {
957: $defaults[ListResourcesRequest::getMethod()] = new ListResourcesRequestHandler($resourceStore);
958: $defaults[ReadResourceRequest::getMethod()] = new ReadResourceRequestHandler(
959: null !== $resourceTemplateStore ? new CompositeResourceStore($resourceStore, $resourceTemplateStore) : $resourceStore,
960: );
961: }
962:
963: if (null !== $this->completionStore) {
964: $defaults[CompleteRequest::getMethod()] = new CompleteRequestHandler($this->completionStore);
965: }
966:
967: if (null !== $this->subscriptionStore) {
968: $defaults[SubscriptionsListenRequest::getMethod()] = new SubscriptionsListenRequestHandler($this->subscriptionStore);
969: }
970:
971: return [...$defaults, ...$this->customRequestHandlers];
972: }
973: }
974: