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