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\Exception\LogicException;
19: use Nexus\Mcp\Core\Extension\ExtensionCollection;
20: use Nexus\Mcp\Core\Handler\HandlerRegistry;
21: use Nexus\Mcp\Core\Handler\Notification\CancelledNotificationHandler;
22: use Nexus\Mcp\Core\Handler\NotificationHandlerInterface;
23: use Nexus\Mcp\Core\Handler\RequestHandlerInterface;
24: use Nexus\Mcp\Core\JsonRpc\JsonRpcMessageParser;
25: use Nexus\Mcp\Core\JsonRpc\JsonRpcMethodRegistry;
26: use Nexus\Mcp\Core\Schema\Enum\CacheScope;
27: use Nexus\Mcp\Core\Schema\Icon;
28: use Nexus\Mcp\Core\Schema\Implementation;
29: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcNotification;
30: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcRequest;
31: use Nexus\Mcp\Core\Schema\Notification\CancelledNotification;
32: use Nexus\Mcp\Core\Schema\Prompt\Prompt;
33: use Nexus\Mcp\Core\Schema\Request\CallToolRequest;
34: use Nexus\Mcp\Core\Schema\Request\ClientRequest;
35: use Nexus\Mcp\Core\Schema\Request\CompleteRequest;
36: use Nexus\Mcp\Core\Schema\Request\DiscoverRequest;
37: use Nexus\Mcp\Core\Schema\Request\GetPromptRequest;
38: use Nexus\Mcp\Core\Schema\Request\ListPromptsRequest;
39: use Nexus\Mcp\Core\Schema\Request\ListResourcesRequest;
40: use Nexus\Mcp\Core\Schema\Request\ListResourceTemplatesRequest;
41: use Nexus\Mcp\Core\Schema\Request\ListToolsRequest;
42: use Nexus\Mcp\Core\Schema\Request\ReadResourceRequest;
43: use Nexus\Mcp\Core\Schema\Request\SubscriptionsListenRequest;
44: use Nexus\Mcp\Core\Schema\Resource\Resource;
45: use Nexus\Mcp\Core\Schema\Resource\ResourceTemplate;
46: use Nexus\Mcp\Core\Schema\Result;
47: use Nexus\Mcp\Core\Schema\Result\CallToolResult;
48: use Nexus\Mcp\Core\Schema\Result\CompleteResult;
49: use Nexus\Mcp\Core\Schema\Result\GetPromptResult;
50: use Nexus\Mcp\Core\Schema\Result\InputRequiredResult;
51: use Nexus\Mcp\Core\Schema\Result\ReadResourceResult;
52: use Nexus\Mcp\Core\Schema\ServerCapabilities;
53: use Nexus\Mcp\Core\Schema\SubscriptionFilter;
54: use Nexus\Mcp\Core\Schema\Tool\Tool;
55: use Nexus\Mcp\Core\UriTemplate\Validator;
56: use Nexus\Mcp\Core\Validation\IconSrcValidator;
57: use Nexus\Mcp\Core\Validation\IdentifierNameValidator;
58: use Nexus\Mcp\Server\Attribute\AsServer;
59: use Nexus\Mcp\Server\Completion\ClosureCompletionProvider;
60: use Nexus\Mcp\Server\Completion\CompletionProviderInterface;
61: use Nexus\Mcp\Server\Completion\CompletionStore;
62: use Nexus\Mcp\Server\Completion\CompletionStoreInterface;
63: use Nexus\Mcp\Server\Completion\PromptCompletionEntry;
64: use Nexus\Mcp\Server\Discovery\AttributeScanner;
65: use Nexus\Mcp\Server\Dispatch\ServerMessageDispatcher;
66: use Nexus\Mcp\Server\Extension\RequestHandlerDecoratorInterface;
67: use Nexus\Mcp\Server\Extension\ServerExtensionInterface;
68: use Nexus\Mcp\Server\Handler\Request\CallToolRequestHandler;
69: use Nexus\Mcp\Server\Handler\Request\CompleteRequestHandler;
70: use Nexus\Mcp\Server\Handler\Request\DiscoverRequestHandler;
71: use Nexus\Mcp\Server\Handler\Request\ExtensionGateRequestHandler;
72: use Nexus\Mcp\Server\Handler\Request\GetPromptRequestHandler;
73: use Nexus\Mcp\Server\Handler\Request\ListPromptsRequestHandler;
74: use Nexus\Mcp\Server\Handler\Request\ListResourcesRequestHandler;
75: use Nexus\Mcp\Server\Handler\Request\ListResourceTemplatesRequestHandler;
76: use Nexus\Mcp\Server\Handler\Request\ListToolsRequestHandler;
77: use Nexus\Mcp\Server\Handler\Request\ReadResourceRequestHandler;
78: use Nexus\Mcp\Server\Handler\Request\SubscriptionsListenRequestHandler;
79: use Nexus\Mcp\Server\Prompt\ClosurePromptRenderer;
80: use Nexus\Mcp\Server\Prompt\PromptEntry;
81: use Nexus\Mcp\Server\Prompt\PromptRendererInterface;
82: use Nexus\Mcp\Server\Prompt\PromptStore;
83: use Nexus\Mcp\Server\Prompt\PromptStoreInterface;
84: use Nexus\Mcp\Server\Resource\ClosureResourceReader;
85: use Nexus\Mcp\Server\Resource\ClosureTemplatedResourceReader;
86: use Nexus\Mcp\Server\Resource\CompositeResourceStore;
87: use Nexus\Mcp\Server\Resource\ResourceEntry;
88: use Nexus\Mcp\Server\Resource\ResourceReaderInterface;
89: use Nexus\Mcp\Server\Resource\ResourceStore;
90: use Nexus\Mcp\Server\Resource\ResourceStoreInterface;
91: use Nexus\Mcp\Server\Resource\ResourceTemplateEntry;
92: use Nexus\Mcp\Server\Resource\ResourceTemplateStore;
93: use Nexus\Mcp\Server\Resource\ResourceTemplateStoreInterface;
94: use Nexus\Mcp\Server\Resource\TemplatedResourceReaderInterface;
95: use Nexus\Mcp\Server\Subscription\SubscriptionStoreInterface;
96: use Nexus\Mcp\Server\Tool\ClosureToolExecutor;
97: use Nexus\Mcp\Server\Tool\ToolEntry;
98: use Nexus\Mcp\Server\Tool\ToolExecutorInterface;
99: use Nexus\Mcp\Server\Tool\ToolStore;
100: use Nexus\Mcp\Server\Tool\ToolStoreInterface;
101: use Nexus\Mcp\Server\Validation\OpisSchemaValidator;
102: use Nexus\Mcp\Server\Validation\SchemaValidatorInterface;
103: use Psr\Log\LoggerInterface;
104: use Psr\Log\NullLogger;
105:
106: /**
107: * Fluent builder for a runnable `Server` instance.
108: *
109: * @phpstan-import-type RequestHandlerDecorator from RequestHandlerDecoratorInterface
110: */
111: final class ServerBuilder
112: {
113: public const int DEFAULT_MAX_IN_FLIGHT = 1_024;
114:
115: private ?Implementation $serverInfo = null;
116:
117: /**
118: * @var null|non-empty-string
119: */
120: private ?string $instructions = null;
121:
122: private ?AsServer $serverMetadata = null;
123: private ServerInfoDisclosure $serverInfoDisclosure = ServerInfoDisclosure::Full;
124:
125: /**
126: * @var null|int<1, max>
127: */
128: private ?int $maxInFlight = self::DEFAULT_MAX_IN_FLIGHT;
129:
130: private LoggerInterface $logger;
131: private SchemaValidatorInterface $schemaValidator;
132:
133: /**
134: * @var array<non-empty-string, ToolEntry>
135: */
136: private array $tools = [];
137:
138: /**
139: * @var array<non-empty-string, PromptEntry>
140: */
141: private array $prompts = [];
142:
143: /**
144: * @var array<non-empty-string, ResourceEntry>
145: */
146: private array $resources = [];
147:
148: /**
149: * @var array<non-empty-string, ResourceTemplateEntry>
150: */
151: private array $resourceTemplates = [];
152:
153: /**
154: * @var array<int|non-empty-string, array<int|non-empty-string, CompletionProviderInterface>>
155: */
156: private array $promptCompletions = [];
157:
158: /**
159: * @var array<int|non-empty-string, array<int|non-empty-string, CompletionProviderInterface>>
160: */
161: private array $templateCompletions = [];
162:
163: /**
164: * @var array{
165: * tools: array<non-empty-string, class-string>,
166: * prompts: array<non-empty-string, class-string>,
167: * resources: array<non-empty-string, class-string>,
168: * resource-templates: array<non-empty-string, class-string>,
169: * completions-prompt: array<non-empty-string, class-string>,
170: * completions-template: array<non-empty-string, class-string>,
171: * }
172: */
173: private array $discoveredFeatures = [
174: 'tools' => [],
175: 'prompts' => [],
176: 'resources' => [],
177: 'resource-templates' => [],
178: 'completions-prompt' => [],
179: 'completions-template' => [],
180: ];
181:
182: private int $pageSize = CursorPaginator::DEFAULT_PAGE_SIZE;
183: private int $ttlMs = 0;
184: private CacheScope $cacheScope = CacheScope::Private;
185: private ?ToolStoreInterface $toolStore = null;
186: private ?PromptStoreInterface $promptStore = null;
187: private ?ResourceStoreInterface $resourceStore = null;
188: private ?ResourceTemplateStoreInterface $resourceTemplateStore = null;
189: private ?CompletionStoreInterface $completionStore = null;
190: private ?SubscriptionStoreInterface $subscriptionStore = null;
191: private bool $built = false;
192:
193: /**
194: * @var array<non-empty-string, RequestHandlerInterface<non-empty-string, Result, ServerContext>>
195: */
196: private array $customRequestHandlers = [];
197:
198: /**
199: * @var array<non-empty-string, NotificationHandlerInterface<non-empty-string>>
200: */
201: private array $customNotificationHandlers = [];
202:
203: /**
204: * @var array<non-empty-string, class-string<JsonRpcRequest<non-empty-string>>>
205: */
206: private array $customRequestClasses = [];
207:
208: /**
209: * @var array<non-empty-string, class-string<JsonRpcNotification<non-empty-string>>>
210: */
211: private array $customNotificationClasses = [];
212:
213: /**
214: * @var ExtensionCollection<ServerContext>
215: */
216: private readonly ExtensionCollection $extensions;
217:
218: public function __construct()
219: {
220: $this->logger = new NullLogger();
221: $this->schemaValidator = new OpisSchemaValidator();
222: $this->extensions = new ExtensionCollection();
223: }
224:
225: /**
226: * @param non-empty-string $name
227: * @param non-empty-string $version
228: * @param null|non-empty-string $title
229: * @param null|non-empty-string $description
230: * @param null|non-empty-string $websiteUrl
231: * @param null|list<Icon> $icons
232: */
233: public function setServerInfo(
234: string $name,
235: string $version,
236: ?string $title = null,
237: ?string $description = null,
238: ?string $websiteUrl = null,
239: ?array $icons = null,
240: ): self {
241: $this->assertNotBuilt();
242: IconSrcValidator::validate($icons, 'serverInfo');
243:
244: $this->serverInfo = new Implementation(
245: name: $name,
246: version: $version,
247: title: $title,
248: description: $description,
249: websiteUrl: $websiteUrl,
250: icons: $icons,
251: );
252:
253: return $this;
254: }
255:
256: /**
257: * Caps how many inbound messages the server processes at once, defaulting to `DEFAULT_MAX_IN_FLIGHT`, with
258: * null lifting the cap.
259: */
260: public function setMaxInFlightDispatches(?int $max): self
261: {
262: $this->assertNotBuilt();
263:
264: Assert::that($max)->nullOr()->isPositiveInt('Maximum in-flight dispatches must be a positive integer or null, {value} given.');
265:
266: $this->maxInFlight = $max;
267:
268: return $this;
269: }
270:
271: public function setServerInfoDisclosure(ServerInfoDisclosure $disclosure): self
272: {
273: $this->assertNotBuilt();
274:
275: $this->serverInfoDisclosure = $disclosure;
276:
277: return $this;
278: }
279:
280: public function setInstructions(?string $instructions): self
281: {
282: $this->assertNotBuilt();
283:
284: Assert::that($instructions)
285: ->nullOr()
286: ->isNonEmptyString('Server instructions must be a non-empty string or null.')
287: ;
288:
289: $this->instructions = $instructions;
290:
291: return $this;
292: }
293:
294: public function setLogger(LoggerInterface $logger): self
295: {
296: $this->assertNotBuilt();
297:
298: $this->logger = $logger;
299:
300: return $this;
301: }
302:
303: public function setSchemaValidator(SchemaValidatorInterface $validator): self
304: {
305: $this->assertNotBuilt();
306:
307: $this->schemaValidator = $validator;
308:
309: return $this;
310: }
311:
312: /**
313: * Sets how many entries one page of a list result carries, for every store the builder assembles itself.
314: */
315: public function setPageSize(int $pageSize): self
316: {
317: $this->assertNotBuilt();
318:
319: Assert::that($pageSize)->isPositiveInt('Store page size must be a positive integer, {value} given.');
320:
321: $this->pageSize = $pageSize;
322:
323: return $this;
324: }
325:
326: /**
327: * Sets how many milliseconds a client may treat a list result as fresh (zero re-fetches every time),
328: * for every store the builder assembles itself.
329: */
330: public function setTtlMs(int $ttlMs): self
331: {
332: $this->assertNotBuilt();
333:
334: Assert::that($ttlMs)->isNaturalInt('Store TTL must be a non-negative integer, {value} given.');
335:
336: $this->ttlMs = $ttlMs;
337:
338: return $this;
339: }
340:
341: /**
342: * Sets which caches may serve a list result, for every store the builder assembles itself.
343: */
344: public function setCacheScope(CacheScope $cacheScope): self
345: {
346: $this->assertNotBuilt();
347:
348: $this->cacheScope = $cacheScope;
349:
350: return $this;
351: }
352:
353: /**
354: * @param (\Closure(?array<array-key, mixed>, ServerContext): (CallToolResult|InputRequiredResult))|ToolExecutorInterface $executor
355: */
356: public function addTool(Tool $tool, \Closure|ToolExecutorInterface $executor): self
357: {
358: $this->assertNotBuilt();
359:
360: IdentifierNameValidator::validate($tool->name, 'tool "name"');
361: IconSrcValidator::validate($tool->icons, 'tool');
362:
363: $this->tools[$tool->name] = new ToolEntry(
364: $tool,
365: $executor instanceof ToolExecutorInterface ? $executor : new ClosureToolExecutor($executor),
366: );
367:
368: return $this;
369: }
370:
371: /**
372: * @param (\Closure(?array<array-key, string>, ServerContext): (GetPromptResult|InputRequiredResult))|PromptRendererInterface $renderer
373: */
374: public function addPrompt(Prompt $prompt, \Closure|PromptRendererInterface $renderer): self
375: {
376: $this->assertNotBuilt();
377:
378: IdentifierNameValidator::validate($prompt->name, 'prompt "name"');
379: IconSrcValidator::validate($prompt->icons, 'prompt');
380:
381: $this->prompts[$prompt->name] = new PromptEntry(
382: $prompt,
383: $renderer instanceof PromptRendererInterface ? $renderer : new ClosurePromptRenderer($renderer),
384: );
385:
386: return $this;
387: }
388:
389: /**
390: * @param (\Closure(non-empty-string, ServerContext): (InputRequiredResult|ReadResourceResult))|ResourceReaderInterface $reader
391: */
392: public function addResource(Resource $resource, \Closure|ResourceReaderInterface $reader): self
393: {
394: $this->assertNotBuilt();
395:
396: IdentifierNameValidator::validate($resource->name, 'resource "name"');
397: IconSrcValidator::validate($resource->icons, 'resource');
398:
399: $this->resources[$resource->uri] = new ResourceEntry(
400: $resource,
401: $reader instanceof ResourceReaderInterface ? $reader : new ClosureResourceReader($reader),
402: );
403:
404: return $this;
405: }
406:
407: /**
408: * @param (\Closure(non-empty-string, array<string, string>, ServerContext): (InputRequiredResult|ReadResourceResult))|TemplatedResourceReaderInterface $reader
409: */
410: public function addResourceTemplate(ResourceTemplate $template, \Closure|TemplatedResourceReaderInterface $reader): self
411: {
412: $this->assertNotBuilt();
413:
414: IdentifierNameValidator::validate($template->name, 'resource template "name"');
415: IconSrcValidator::validate($template->icons, 'resource template');
416: Validator::validate($template->uriTemplate, 'ResourceTemplate');
417:
418: $this->resourceTemplates[$template->uriTemplate] = new ResourceTemplateEntry(
419: $template,
420: $reader instanceof TemplatedResourceReaderInterface ? $reader : new ClosureTemplatedResourceReader($reader),
421: );
422:
423: return $this;
424: }
425:
426: public function setToolStore(ToolStoreInterface $store): self
427: {
428: $this->assertNotBuilt();
429:
430: $this->toolStore = $store;
431:
432: return $this;
433: }
434:
435: /**
436: * The tool store the built server serves, or null when it exposes no tools. Call it once every tool is
437: * registered, since it holds the store it returns.
438: */
439: public function getToolStore(): ?ToolStoreInterface
440: {
441: if (null === $this->toolStore && [] === $this->tools) {
442: return null;
443: }
444:
445: $this->toolStore ??= new ToolStore(
446: entries: $this->tools,
447: pageSize: $this->pageSize,
448: validator: $this->schemaValidator,
449: ttlMs: $this->ttlMs,
450: cacheScope: $this->cacheScope,
451: );
452:
453: return $this->toolStore;
454: }
455:
456: public function setPromptStore(PromptStoreInterface $store): self
457: {
458: $this->assertNotBuilt();
459:
460: $this->promptStore = $store;
461:
462: return $this;
463: }
464:
465: /**
466: * The prompt store the built server serves, or null when it exposes no prompts. Call it once every
467: * prompt is registered, since it holds the store it returns.
468: */
469: public function getPromptStore(): ?PromptStoreInterface
470: {
471: if (null === $this->promptStore && [] === $this->prompts) {
472: return null;
473: }
474:
475: $this->promptStore ??= new PromptStore(
476: entries: $this->prompts,
477: pageSize: $this->pageSize,
478: ttlMs: $this->ttlMs,
479: cacheScope: $this->cacheScope,
480: );
481:
482: return $this->promptStore;
483: }
484:
485: public function setResourceStore(ResourceStoreInterface $store): self
486: {
487: $this->assertNotBuilt();
488:
489: $this->resourceStore = $store;
490:
491: return $this;
492: }
493:
494: /**
495: * The resource store the built server serves, or null when it exposes neither resources nor templates.
496: * Call it once every resource is registered, since it holds the store it returns.
497: */
498: public function getResourceStore(): ?ResourceStoreInterface
499: {
500: $templateStore = $this->getResourceTemplateStore();
501:
502: if (null === $this->resourceStore && [] === $this->resources && null === $templateStore) {
503: return null;
504: }
505:
506: $this->resourceStore ??= new ResourceStore(
507: entries: $this->resources,
508: pageSize: $this->pageSize,
509: ttlMs: $this->ttlMs,
510: cacheScope: $this->cacheScope,
511: );
512:
513: return $this->resourceStore;
514: }
515:
516: public function setResourceTemplateStore(ResourceTemplateStoreInterface $store): self
517: {
518: $this->assertNotBuilt();
519:
520: $this->resourceTemplateStore = $store;
521:
522: return $this;
523: }
524:
525: /**
526: * The resource template store the built server serves, or null when it exposes no templates. Call it
527: * once every template is registered, since it holds the store it returns.
528: */
529: public function getResourceTemplateStore(): ?ResourceTemplateStoreInterface
530: {
531: if (null === $this->resourceTemplateStore && [] === $this->resourceTemplates) {
532: return null;
533: }
534:
535: $this->resourceTemplateStore ??= new ResourceTemplateStore(
536: entries: $this->resourceTemplates,
537: pageSize: $this->pageSize,
538: ttlMs: $this->ttlMs,
539: cacheScope: $this->cacheScope,
540: );
541:
542: return $this->resourceTemplateStore;
543: }
544:
545: /**
546: * Serves `subscriptions/listen` from `$store`, and lights up the `listChanged` capability of every
547: * feature whose store can report its changes.
548: */
549: public function setSubscriptionStore(SubscriptionStoreInterface $store): self
550: {
551: $this->assertNotBuilt();
552:
553: $this->subscriptionStore = $store;
554:
555: return $this;
556: }
557:
558: public function setCompletionStore(CompletionStoreInterface $store): self
559: {
560: $this->assertNotBuilt();
561:
562: $this->completionStore = $store;
563:
564: return $this;
565: }
566:
567: /**
568: * @param (\Closure(string, ?array<array-key, string>, ServerContext): CompleteResult)|CompletionProviderInterface $provider
569: */
570: public function addPromptCompletion(string $prompt, string $argument, \Closure|CompletionProviderInterface $provider): self
571: {
572: $this->assertNotBuilt();
573:
574: Assert::that($prompt)->isNonEmptyString('Completion prompt name must be a non-empty string.');
575: Assert::that($argument)->isNonEmptyString('Completion argument name must be a non-empty string.');
576:
577: $this->promptCompletions[$prompt][$argument] = $provider instanceof CompletionProviderInterface
578: ? $provider
579: : new ClosureCompletionProvider($provider);
580:
581: return $this;
582: }
583:
584: /**
585: * @param (\Closure(string, ?array<array-key, string>, ServerContext): CompleteResult)|CompletionProviderInterface $provider
586: */
587: public function addResourceTemplateCompletion(string $uriTemplate, string $argument, \Closure|CompletionProviderInterface $provider): self
588: {
589: $this->assertNotBuilt();
590:
591: Assert::that($uriTemplate)->isNonEmptyString('Completion URI template must be a non-empty string.');
592: Assert::that($argument)->isNonEmptyString('Completion argument name must be a non-empty string.');
593:
594: $this->templateCompletions[$uriTemplate][$argument] = $provider instanceof CompletionProviderInterface
595: ? $provider
596: : new ClosureCompletionProvider($provider);
597:
598: return $this;
599: }
600:
601: /**
602: * The completion store the built server serves, or null when it serves no completions. Call it once
603: * every completion is registered, since it holds the store it returns.
604: */
605: public function getCompletionStore(): ?CompletionStoreInterface
606: {
607: if (null === $this->completionStore && [] === $this->promptCompletions && [] === $this->templateCompletions) {
608: return null;
609: }
610:
611: $this->completionStore ??= new CompletionStore($this->promptCompletions, $this->templateCompletions);
612:
613: return $this->completionStore;
614: }
615:
616: /**
617: * Registers each source object's `#[AsServer]` identity, which an explicit `setServerInfo()` or
618: * `setInstructions()` call overrides per field, plus its `#[AsTool]`, `#[AsPrompt]`, `#[AsResource]`,
619: * `#[AsResourceTemplate]`, and `#[AsCompletion]` methods.
620: *
621: * @throws LogicException
622: */
623: public function register(object ...$sources): self
624: {
625: $this->assertNotBuilt();
626:
627: $scanner = new AttributeScanner();
628:
629: foreach ($sources as $source) {
630: $contributed = false;
631: $metadata = $this->findServerMetadata($source);
632:
633: if (null !== $metadata) {
634: if (null !== $this->serverMetadata) {
635: throw new LogicException(\sprintf(
636: 'A class-level #[AsServer] is already declared by an earlier registered source. "%s" must not declare another.',
637: $source::class,
638: ));
639: }
640:
641: $this->serverMetadata = $metadata;
642: $contributed = true;
643: }
644:
645: foreach ($scanner->scan($source) as $entry) {
646: $contributed = true;
647:
648: if ($entry instanceof ToolEntry) {
649: if (\array_key_exists($entry->tool->name, $this->discoveredFeatures['tools'])) {
650: $this->refuseDuplicateEntry('tool', $entry->tool->name, $source::class, $this->discoveredFeatures['tools'][$entry->tool->name]);
651: }
652:
653: $this->discoveredFeatures['tools'][$entry->tool->name] = $source::class;
654: $this->addTool($entry->tool, $entry->executor);
655:
656: continue;
657: }
658:
659: if ($entry instanceof PromptEntry) {
660: if (\array_key_exists($entry->prompt->name, $this->discoveredFeatures['prompts'])) {
661: $this->refuseDuplicateEntry('prompt', $entry->prompt->name, $source::class, $this->discoveredFeatures['prompts'][$entry->prompt->name]);
662: }
663:
664: $this->discoveredFeatures['prompts'][$entry->prompt->name] = $source::class;
665: $this->addPrompt($entry->prompt, $entry->renderer);
666:
667: continue;
668: }
669:
670: if ($entry instanceof ResourceEntry) {
671: if (\array_key_exists($entry->resource->uri, $this->discoveredFeatures['resources'])) {
672: $this->refuseDuplicateEntry('resource', $entry->resource->uri, $source::class, $this->discoveredFeatures['resources'][$entry->resource->uri]);
673: }
674:
675: $this->discoveredFeatures['resources'][$entry->resource->uri] = $source::class;
676: $this->addResource($entry->resource, $entry->reader);
677:
678: continue;
679: }
680:
681: if ($entry instanceof ResourceTemplateEntry) {
682: if (\array_key_exists($entry->template->uriTemplate, $this->discoveredFeatures['resource-templates'])) {
683: $this->refuseDuplicateEntry('resource template', $entry->template->uriTemplate, $source::class, $this->discoveredFeatures['resource-templates'][$entry->template->uriTemplate]);
684: }
685:
686: $this->discoveredFeatures['resource-templates'][$entry->template->uriTemplate] = $source::class;
687: $this->addResourceTemplate($entry->template, $entry->reader);
688:
689: continue;
690: }
691:
692: if ($entry instanceof PromptCompletionEntry) {
693: $promptKey = \sprintf('%s:%s', $entry->prompt, $entry->argument);
694:
695: if (\array_key_exists($promptKey, $this->discoveredFeatures['completions-prompt'])) {
696: $this->refuseDuplicateEntry('prompt completion', $promptKey, $source::class, $this->discoveredFeatures['completions-prompt'][$promptKey]);
697: }
698:
699: $this->discoveredFeatures['completions-prompt'][$promptKey] = $source::class;
700: $this->addPromptCompletion($entry->prompt, $entry->argument, $entry->provider);
701:
702: continue;
703: }
704:
705: $completionKey = \sprintf('%s:%s', $entry->uriTemplate, $entry->argument);
706:
707: if (\array_key_exists($completionKey, $this->discoveredFeatures['completions-template'])) {
708: $this->refuseDuplicateEntry('resource template completion', $completionKey, $source::class, $this->discoveredFeatures['completions-template'][$completionKey]);
709: }
710:
711: $this->discoveredFeatures['completions-template'][$completionKey] = $source::class;
712: $this->addResourceTemplateCompletion($entry->uriTemplate, $entry->argument, $entry->provider);
713: }
714:
715: if (! $contributed) {
716: throw new LogicException(\sprintf(
717: 'The registered source "%s" declares no #[AsServer] and no #[AsTool], #[AsPrompt], #[AsResource], or #[AsResourceTemplate] method.',
718: $source::class,
719: ));
720: }
721: }
722:
723: return $this;
724: }
725:
726: /**
727: * Enables `$extension`, advertising its capability identifier and serving its methods
728: * behind the per-request declared-capability gate.
729: *
730: * @throws LogicException
731: */
732: public function enableExtension(ServerExtensionInterface $extension): self
733: {
734: $this->assertNotBuilt();
735:
736: $this->extensions->add(
737: $extension,
738: claimedRequests: array_keys($this->customRequestHandlers),
739: claimedNotifications: array_keys($this->customNotificationHandlers),
740: requireClientRequests: true,
741: requestDecorators: $extension instanceof RequestHandlerDecoratorInterface
742: ? $extension->getRequestHandlerDecorators()
743: : [],
744: );
745:
746: return $this;
747: }
748:
749: /**
750: * Registers a handler for a vendor-extension request method.
751: *
752: * @param class-string<JsonRpcRequest<non-empty-string>> $request
753: * @param RequestHandlerInterface<non-empty-string, Result, ServerContext> $handler
754: *
755: * @throws LogicException
756: *
757: * @see self::replaceRequestHandler()
758: */
759: public function addRequestHandler(string $request, RequestHandlerInterface $handler): self
760: {
761: $this->assertNotBuilt();
762: $method = $request::getMethod();
763:
764: if (\array_key_exists($method, JsonRpcMethodRegistry::requests())) {
765: $this->refuseReservedMethod($method, isNotification: false);
766: }
767:
768: $this->extensions->assertNotOwned($method);
769:
770: Assert::that($request)->isSubclassOf(ClientRequest::class, \sprintf(
771: 'Request class "%s" must implement "%s" for the server to dispatch it.',
772: $request,
773: ClientRequest::class,
774: ));
775:
776: $this->customRequestHandlers[$method] = $handler;
777: $this->customRequestClasses[$method] = $request;
778:
779: return $this;
780: }
781:
782: /**
783: * Overrides the SDK's built-in handler for `$method`.
784: *
785: * @param non-empty-string $method
786: * @param RequestHandlerInterface<non-empty-string, Result, ServerContext> $handler
787: *
788: * @throws LogicException
789: *
790: * @see self::addRequestHandler()
791: */
792: public function replaceRequestHandler(string $method, RequestHandlerInterface $handler): self
793: {
794: $this->assertNotBuilt();
795:
796: if (! \array_key_exists($method, JsonRpcMethodRegistry::requests())) {
797: $this->refuseUnreservedMethod($method, isNotification: false);
798: }
799:
800: $this->customRequestHandlers[$method] = $handler;
801:
802: return $this;
803: }
804:
805: /**
806: * Registers a handler for a vendor-extension notification method.
807: *
808: * @param class-string<JsonRpcNotification<non-empty-string>> $notification
809: * @param NotificationHandlerInterface<non-empty-string> $handler
810: *
811: * @throws LogicException
812: *
813: * @see self::replaceNotificationHandler()
814: */
815: public function addNotificationHandler(string $notification, NotificationHandlerInterface $handler): self
816: {
817: $this->assertNotBuilt();
818: $method = $notification::getMethod();
819:
820: if (\array_key_exists($method, JsonRpcMethodRegistry::notifications())) {
821: $this->refuseReservedMethod($method, isNotification: true);
822: }
823:
824: $this->extensions->assertNotOwned($method, isNotification: true);
825:
826: $this->customNotificationHandlers[$method] = $handler;
827: $this->customNotificationClasses[$method] = $notification;
828:
829: return $this;
830: }
831:
832: /**
833: * Overrides any built-in handler for `$method`, including spec notifications.
834: *
835: * @param non-empty-string $method
836: * @param NotificationHandlerInterface<non-empty-string> $handler
837: *
838: * @throws LogicException
839: *
840: * @see self::addNotificationHandler()
841: */
842: public function replaceNotificationHandler(string $method, NotificationHandlerInterface $handler): self
843: {
844: $this->assertNotBuilt();
845:
846: if (! \array_key_exists($method, JsonRpcMethodRegistry::notifications())) {
847: $this->refuseUnreservedMethod($method, isNotification: true);
848: }
849:
850: $this->customNotificationHandlers[$method] = $handler;
851:
852: return $this;
853: }
854:
855: public function build(): Server
856: {
857: $this->assertNotBuilt();
858: $this->built = true;
859: $serverInfo = $this->resolveServerInfo();
860:
861: Assert::that($serverInfo)->isInstanceOf(
862: Implementation::class,
863: 'Server information must be set before build() via setServerInfo() or a class-level #[AsServer].',
864: );
865:
866: $capabilities = $this->deriveCapabilities();
867:
868: $requestHandlers = $this->buildRequestHandlers(
869: $capabilities,
870: ServerInfoDisclosure::None === $this->serverInfoDisclosure ? null : $serverInfo,
871: );
872:
873: $this->routeListChanges();
874:
875: $inboundRequests = new PendingInboundRequests();
876:
877: return new Server(
878: new ServerMessageDispatcher(
879: new HandlerRegistry($requestHandlers, RequestHandlerInterface::class, 'Request handler'),
880: new HandlerRegistry(
881: $this->buildNotificationHandlers($inboundRequests),
882: NotificationHandlerInterface::class,
883: 'Notification handler',
884: ),
885: logger: $this->logger,
886: parser: new JsonRpcMessageParser(
887: [...$this->extensions->buildRequestClasses(), ...$this->customRequestClasses],
888: [...$this->extensions->buildNotificationClasses(), ...$this->customNotificationClasses],
889: ),
890: serverInfo: $this->serverInfoDisclosure->project($serverInfo),
891: maxInFlight: $this->maxInFlight,
892: inboundRequests: $inboundRequests,
893: ),
894: $this->logger,
895: $this->subscriptionStore,
896: );
897: }
898:
899: private function routeListChanges(): void
900: {
901: $subscriptionStore = $this->subscriptionStore;
902:
903: if (null === $subscriptionStore) {
904: return;
905: }
906:
907: $toolStore = $this->getToolStore();
908:
909: if ($toolStore instanceof ListChangeSourceInterface) {
910: $toolStore->onListChanged($subscriptionStore->emitToolListChanged(...));
911: }
912:
913: $promptStore = $this->getPromptStore();
914:
915: if ($promptStore instanceof ListChangeSourceInterface) {
916: $promptStore->onListChanged($subscriptionStore->emitPromptListChanged(...));
917: }
918:
919: $resourceStore = $this->getResourceStore();
920:
921: if ($resourceStore instanceof ListChangeSourceInterface) {
922: $resourceStore->onListChanged($subscriptionStore->emitResourceListChanged(...));
923: }
924:
925: $templateStore = $this->getResourceTemplateStore();
926:
927: if ($templateStore instanceof ListChangeSourceInterface) {
928: $templateStore->onListChanged($subscriptionStore->emitResourceListChanged(...));
929: }
930: }
931:
932: /**
933: * @return array<non-empty-string, NotificationHandlerInterface<non-empty-string>>
934: */
935: private function buildNotificationHandlers(PendingInboundRequests $inboundRequests): array
936: {
937: $defaults = [
938: CancelledNotification::getMethod() => new CancelledNotificationHandler($inboundRequests, $this->logger),
939: ];
940:
941: return [...$defaults, ...$this->extensions->buildNotificationHandlers(), ...$this->customNotificationHandlers];
942: }
943:
944: /**
945: * Merges the explicit `setServerInfo()` values over the `#[AsServer]` fields, the attribute filling only the gaps.
946: */
947: private function resolveServerInfo(): ?Implementation
948: {
949: $metadata = $this->serverMetadata;
950:
951: if (null === $metadata) {
952: return $this->serverInfo;
953: }
954:
955: IconSrcValidator::validate($metadata->icons, 'serverInfo');
956:
957: if (null === $this->serverInfo) {
958: return new Implementation(
959: name: $metadata->name,
960: version: $metadata->version,
961: title: $metadata->title,
962: description: $metadata->description,
963: websiteUrl: $metadata->websiteUrl,
964: icons: $metadata->icons,
965: );
966: }
967:
968: return new Implementation(
969: name: $this->serverInfo->name,
970: version: $this->serverInfo->version,
971: title: $this->serverInfo->title ?? $metadata->title,
972: description: $this->serverInfo->description ?? $metadata->description,
973: websiteUrl: $this->serverInfo->websiteUrl ?? $metadata->websiteUrl,
974: icons: $this->serverInfo->icons ?? $metadata->icons,
975: );
976: }
977:
978: /**
979: * @return null|non-empty-string
980: */
981: private function resolveInstructions(): ?string
982: {
983: $instructions = $this->instructions ?? $this->serverMetadata?->instructions;
984:
985: Assert::that($instructions)
986: ->nullOr()
987: ->isNonEmptyString('Server instructions must be a non-empty string or null.')
988: ;
989:
990: return $instructions;
991: }
992:
993: private function findServerMetadata(object $source): ?AsServer
994: {
995: $attributes = (new \ReflectionObject($source))->getAttributes(AsServer::class);
996:
997: return [] === $attributes ? null : $attributes[0]->newInstance();
998: }
999:
1000: /**
1001: * @throws LogicException
1002: */
1003: private function assertNotBuilt(): void
1004: {
1005: if ($this->built) {
1006: throw new LogicException('This builder has already been built. Construct a new ServerBuilder for another server.');
1007: }
1008: }
1009:
1010: private function deriveCapabilities(): ServerCapabilities
1011: {
1012: $honoured = $this->resolveHonouredNotifications();
1013:
1014: return new ServerCapabilities(
1015: completions: $this->hasCompletionsCapability() ? [] : null,
1016: extensions: $this->extensions->buildCapabilitySlot(),
1017: prompts: $this->hasPromptsCapability()
1018: ? $this->listChangedFlag($this->getPromptStore() instanceof ListChangeSourceInterface, $honoured?->promptsListChanged)
1019: : null,
1020: resources: $this->buildResourcesCapability($honoured),
1021: tools: $this->hasToolsCapability()
1022: ? $this->listChangedFlag($this->getToolStore() instanceof ListChangeSourceInterface, $honoured?->toolsListChanged)
1023: : null,
1024: );
1025: }
1026:
1027: /**
1028: * The `listChanged` types a registered change-reporting store stands behind, matching what
1029: * `deriveCapabilities()` advertises.
1030: */
1031: private function resolveDeliverableNotifications(): SubscriptionFilter
1032: {
1033: return new SubscriptionFilter(
1034: toolsListChanged: $this->getToolStore() instanceof ListChangeSourceInterface ? true : null,
1035: promptsListChanged: $this->getPromptStore() instanceof ListChangeSourceInterface ? true : null,
1036: resourcesListChanged: $this->getResourceStore() instanceof ListChangeSourceInterface
1037: || $this->getResourceTemplateStore() instanceof ListChangeSourceInterface ? true : null,
1038: );
1039: }
1040:
1041: /**
1042: * What the registered subscription store will deliver when asked for everything, or null when none is
1043: * registered.
1044: */
1045: private function resolveHonouredNotifications(): ?SubscriptionFilter
1046: {
1047: return $this->subscriptionStore?->honour(new SubscriptionFilter(
1048: toolsListChanged: true,
1049: promptsListChanged: true,
1050: resourcesListChanged: true,
1051: resourceSubscriptions: [],
1052: ));
1053: }
1054:
1055: /**
1056: * `listChanged` is a promise to deliver, so it is only set when the store behind the feature can report
1057: * a change and the subscription store honours that notification type.
1058: *
1059: * @return array{listChanged?: bool}
1060: */
1061: private function listChangedFlag(bool $reportsChanges, ?bool $honoured): array
1062: {
1063: if (true !== $honoured || ! $reportsChanges) {
1064: return [];
1065: }
1066:
1067: return ['listChanged' => true];
1068: }
1069:
1070: /**
1071: * @return null|array{listChanged?: bool, subscribe?: bool}
1072: */
1073: private function buildResourcesCapability(?SubscriptionFilter $honoured): ?array
1074: {
1075: if (! $this->hasResourcesCapability()) {
1076: return null;
1077: }
1078:
1079: $reportsChanges = $this->getResourceStore() instanceof ListChangeSourceInterface
1080: || $this->getResourceTemplateStore() instanceof ListChangeSourceInterface;
1081:
1082: $capability = $this->listChangedFlag($reportsChanges, $honoured?->resourcesListChanged);
1083:
1084: if (null !== $honoured?->resourceSubscriptions) {
1085: $capability['subscribe'] = true;
1086: }
1087:
1088: return $capability;
1089: }
1090:
1091: private function hasCompletionsCapability(): bool
1092: {
1093: return $this->getCompletionStore() !== null
1094: || isset($this->customRequestHandlers[CompleteRequest::getMethod()]);
1095: }
1096:
1097: private function hasPromptsCapability(): bool
1098: {
1099: return $this->getPromptStore() !== null
1100: || isset(
1101: $this->customRequestHandlers[GetPromptRequest::getMethod()],
1102: $this->customRequestHandlers[ListPromptsRequest::getMethod()],
1103: );
1104: }
1105:
1106: private function hasResourcesCapability(): bool
1107: {
1108: return $this->getResourceStore() !== null
1109: || isset(
1110: $this->customRequestHandlers[ListResourcesRequest::getMethod()],
1111: $this->customRequestHandlers[ReadResourceRequest::getMethod()],
1112: );
1113: }
1114:
1115: private function hasToolsCapability(): bool
1116: {
1117: return $this->getToolStore() !== null
1118: || isset(
1119: $this->customRequestHandlers[CallToolRequest::getMethod()],
1120: $this->customRequestHandlers[ListToolsRequest::getMethod()],
1121: );
1122: }
1123:
1124: /**
1125: * @return array<non-empty-string, RequestHandlerInterface<non-empty-string, Result, ServerContext>>
1126: */
1127: private function buildRequestHandlers(ServerCapabilities $capabilities, ?Implementation $serverInfo): array
1128: {
1129: $defaults = [
1130: DiscoverRequest::getMethod() => new DiscoverRequestHandler(
1131: $capabilities,
1132: $this->resolveInstructions(),
1133: serverInfo: $serverInfo,
1134: ),
1135: ];
1136:
1137: $toolStore = $this->getToolStore();
1138:
1139: if (null !== $toolStore) {
1140: $defaults[ListToolsRequest::getMethod()] = new ListToolsRequestHandler($toolStore);
1141: $defaults[CallToolRequest::getMethod()] = new CallToolRequestHandler($toolStore, $this->logger);
1142: }
1143:
1144: $promptStore = $this->getPromptStore();
1145:
1146: if (null !== $promptStore) {
1147: $defaults[ListPromptsRequest::getMethod()] = new ListPromptsRequestHandler($promptStore);
1148: $defaults[GetPromptRequest::getMethod()] = new GetPromptRequestHandler($promptStore);
1149: }
1150:
1151: $resourceTemplateStore = $this->getResourceTemplateStore();
1152:
1153: if (null !== $resourceTemplateStore) {
1154: $defaults[ListResourceTemplatesRequest::getMethod()] = new ListResourceTemplatesRequestHandler($resourceTemplateStore);
1155: }
1156:
1157: $resourceStore = $this->getResourceStore();
1158:
1159: if (null !== $resourceStore) {
1160: $defaults[ListResourcesRequest::getMethod()] = new ListResourcesRequestHandler($resourceStore);
1161: $defaults[ReadResourceRequest::getMethod()] = new ReadResourceRequestHandler(
1162: null !== $resourceTemplateStore ? new CompositeResourceStore($resourceStore, $resourceTemplateStore) : $resourceStore,
1163: );
1164: }
1165:
1166: $completionStore = $this->getCompletionStore();
1167:
1168: if (null !== $completionStore) {
1169: $defaults[CompleteRequest::getMethod()] = new CompleteRequestHandler($completionStore);
1170: }
1171:
1172: if (null !== $this->subscriptionStore) {
1173: $defaults[SubscriptionsListenRequest::getMethod()] = new SubscriptionsListenRequestHandler(
1174: $this->subscriptionStore,
1175: $this->resolveDeliverableNotifications(),
1176: );
1177: }
1178:
1179: return $this->applyRequestDecorators([...$defaults, ...$this->buildExtensionRequestHandlers(), ...$this->customRequestHandlers]);
1180: }
1181:
1182: /**
1183: * Wraps each enabled extension's request handlers in the declared-capability gate.
1184: *
1185: * @return array<non-empty-string, RequestHandlerInterface<non-empty-string, Result, ServerContext>>
1186: */
1187: private function buildExtensionRequestHandlers(): array
1188: {
1189: $handlers = [];
1190:
1191: foreach ($this->extensions->getRequestHandlerGroups() as $identifier => $group) {
1192: foreach ($group as $method => $handler) {
1193: $handlers[$method] = new ExtensionGateRequestHandler($identifier, $handler);
1194: }
1195: }
1196:
1197: return $handlers;
1198: }
1199:
1200: /**
1201: * Wraps each decorated method's effective handler with the enabled extensions' decorators, in enable order.
1202: *
1203: * @param array<non-empty-string, RequestHandlerInterface<non-empty-string, Result, ServerContext>> $handlers
1204: *
1205: * @return array<non-empty-string, RequestHandlerInterface<non-empty-string, Result, ServerContext>>
1206: */
1207: private function applyRequestDecorators(array $handlers): array
1208: {
1209: /** @var array<non-empty-string, array<non-empty-string, RequestHandlerDecorator>> $groups */
1210: $groups = $this->extensions->getRequestDecoratorGroups();
1211:
1212: foreach ($groups as $identifier => $decorators) {
1213: foreach ($decorators as $method => $decorate) {
1214: Assert::that($handlers)->hasOffset($method, \sprintf(
1215: 'Extension "%s" decorates "%s", but no handler serves that method.',
1216: $identifier,
1217: $method,
1218: ));
1219:
1220: $decorated = $decorate($handlers[$method]);
1221: Assert::that($decorated)->isInstanceOf(RequestHandlerInterface::class, \sprintf(
1222: 'Extension "%s" decorator for "%s" must return a request handler, {type} given.',
1223: $identifier,
1224: $method,
1225: ));
1226:
1227: $handlers[$method] = $decorated;
1228: }
1229: }
1230:
1231: return $handlers;
1232: }
1233:
1234: private function refuseDuplicateEntry(string $kind, string $key, string $source, string $owner): never
1235: {
1236: throw new LogicException(\sprintf('"%s" declares %s "%s", which "%s" already declares.', $source, $kind, $key, $owner));
1237: }
1238:
1239: private function refuseReservedMethod(string $method, bool $isNotification): never
1240: {
1241: throw new LogicException(\sprintf(
1242: '%s method "%s" is reserved by the MCP specification. Use %s() to attach a handler to it.',
1243: $isNotification ? 'Notification' : 'Request',
1244: $method,
1245: $isNotification ? 'replaceNotificationHandler' : 'replaceRequestHandler',
1246: ));
1247: }
1248:
1249: private function refuseUnreservedMethod(string $method, bool $isNotification): never
1250: {
1251: throw new LogicException(\sprintf(
1252: '%s method "%s" is not reserved by the MCP specification. Use %s() to register a vendor extension.',
1253: $isNotification ? 'Notification' : 'Request',
1254: $method,
1255: $isNotification ? 'addNotificationHandler' : 'addRequestHandler',
1256: ));
1257: }
1258: }
1259: