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\Handler\HandlerRegistry;
18: use Nexus\Mcp\Core\Handler\NotificationHandlerInterface;
19: use Nexus\Mcp\Core\Handler\RequestHandlerInterface;
20: use Nexus\Mcp\Core\JsonRpc\JsonRpcMethodRegistry;
21: use Nexus\Mcp\Core\Schema\Icon;
22: use Nexus\Mcp\Core\Schema\Implementation;
23: use Nexus\Mcp\Core\Schema\Prompt\Prompt;
24: use Nexus\Mcp\Core\Schema\Request\CallToolRequest;
25: use Nexus\Mcp\Core\Schema\Request\CompleteRequest;
26: use Nexus\Mcp\Core\Schema\Request\DiscoverRequest;
27: use Nexus\Mcp\Core\Schema\Request\GetPromptRequest;
28: use Nexus\Mcp\Core\Schema\Request\ListPromptsRequest;
29: use Nexus\Mcp\Core\Schema\Request\ListResourcesRequest;
30: use Nexus\Mcp\Core\Schema\Request\ListResourceTemplatesRequest;
31: use Nexus\Mcp\Core\Schema\Request\ListToolsRequest;
32: use Nexus\Mcp\Core\Schema\Request\ReadResourceRequest;
33: use Nexus\Mcp\Core\Schema\Resource\Resource;
34: use Nexus\Mcp\Core\Schema\Resource\ResourceTemplate;
35: use Nexus\Mcp\Core\Schema\Result;
36: use Nexus\Mcp\Core\Schema\Result\CallToolResult;
37: use Nexus\Mcp\Core\Schema\Result\GetPromptResult;
38: use Nexus\Mcp\Core\Schema\Result\ReadResourceResult;
39: use Nexus\Mcp\Core\Schema\ServerCapabilities;
40: use Nexus\Mcp\Core\Schema\Tool\Tool;
41: use Nexus\Mcp\Core\UriTemplate\Validator;
42: use Nexus\Mcp\Server\Attribute\AsServer;
43: use Nexus\Mcp\Server\Completion\CompletionStoreInterface;
44: use Nexus\Mcp\Server\Discovery\AttributeScanner;
45: use Nexus\Mcp\Server\Dispatch\ServerMessageDispatcher;
46: use Nexus\Mcp\Server\Exception\DuplicateServerMetadataException;
47: use Nexus\Mcp\Server\Exception\MissingDiscoveryAttributeException;
48: use Nexus\Mcp\Server\Exception\ReservedMethodException;
49: use Nexus\Mcp\Server\Exception\UnreservedMethodException;
50: use Nexus\Mcp\Server\Handler\Request\CallToolRequestHandler;
51: use Nexus\Mcp\Server\Handler\Request\CompleteRequestHandler;
52: use Nexus\Mcp\Server\Handler\Request\DiscoverRequestHandler;
53: use Nexus\Mcp\Server\Handler\Request\GetPromptRequestHandler;
54: use Nexus\Mcp\Server\Handler\Request\ListPromptsRequestHandler;
55: use Nexus\Mcp\Server\Handler\Request\ListResourcesRequestHandler;
56: use Nexus\Mcp\Server\Handler\Request\ListResourceTemplatesRequestHandler;
57: use Nexus\Mcp\Server\Handler\Request\ListToolsRequestHandler;
58: use Nexus\Mcp\Server\Handler\Request\ReadResourceRequestHandler;
59: use Nexus\Mcp\Server\Prompt\ClosurePromptRenderer;
60: use Nexus\Mcp\Server\Prompt\PromptEntry;
61: use Nexus\Mcp\Server\Prompt\PromptRendererInterface;
62: use Nexus\Mcp\Server\Prompt\PromptStore;
63: use Nexus\Mcp\Server\Prompt\PromptStoreInterface;
64: use Nexus\Mcp\Server\Resource\ClosureResourceReader;
65: use Nexus\Mcp\Server\Resource\ClosureTemplatedResourceReader;
66: use Nexus\Mcp\Server\Resource\CompositeResourceStore;
67: use Nexus\Mcp\Server\Resource\ResourceEntry;
68: use Nexus\Mcp\Server\Resource\ResourceReaderInterface;
69: use Nexus\Mcp\Server\Resource\ResourceStore;
70: use Nexus\Mcp\Server\Resource\ResourceStoreInterface;
71: use Nexus\Mcp\Server\Resource\ResourceTemplateEntry;
72: use Nexus\Mcp\Server\Resource\ResourceTemplateStore;
73: use Nexus\Mcp\Server\Resource\ResourceTemplateStoreInterface;
74: use Nexus\Mcp\Server\Resource\TemplatedResourceReaderInterface;
75: use Nexus\Mcp\Server\Tool\ClosureToolExecutor;
76: use Nexus\Mcp\Server\Tool\ToolEntry;
77: use Nexus\Mcp\Server\Tool\ToolExecutorInterface;
78: use Nexus\Mcp\Server\Tool\ToolStore;
79: use Nexus\Mcp\Server\Tool\ToolStoreInterface;
80: use Nexus\Mcp\Server\Validation\OpisSchemaValidator;
81: use Nexus\Mcp\Server\Validation\SchemaValidatorInterface;
82: use Psr\Log\LoggerInterface;
83: use Psr\Log\NullLogger;
84:
85: /**
86: * Fluent builder that wires the per-feature stores, the dispatch kernel, and
87: * the lifecycle shell into a runnable `Server` instance.
88: */
89: final class ServerBuilder
90: {
91: private ?Implementation $serverInfo = null;
92:
93: /**
94: * @var null|non-empty-string
95: */
96: private ?string $instructions = null;
97:
98: private ?AsServer $serverMetadata = null;
99: private LoggerInterface $logger;
100: private SchemaValidatorInterface $schemaValidator;
101:
102: /**
103: * @var array<non-empty-string, ToolEntry>
104: */
105: private array $tools = [];
106:
107: /**
108: * @var array<non-empty-string, PromptEntry>
109: */
110: private array $prompts = [];
111:
112: /**
113: * @var array<non-empty-string, ResourceEntry>
114: */
115: private array $resources = [];
116:
117: /**
118: * @var array<non-empty-string, ResourceTemplateEntry>
119: */
120: private array $resourceTemplates = [];
121:
122: private ?ToolStoreInterface $toolStore = null;
123: private ?PromptStoreInterface $promptStore = null;
124: private ?ResourceStoreInterface $resourceStore = null;
125: private ?ResourceTemplateStoreInterface $resourceTemplateStore = null;
126: private ?CompletionStoreInterface $completionStore = null;
127:
128: /**
129: * @var array<non-empty-string, RequestHandlerInterface<non-empty-string, Result, ServerContext>>
130: */
131: private array $customRequestHandlers = [];
132:
133: /**
134: * @var array<non-empty-string, NotificationHandlerInterface<non-empty-string>>
135: */
136: private array $customNotificationHandlers = [];
137:
138: public function __construct()
139: {
140: $this->logger = new NullLogger();
141: $this->schemaValidator = new OpisSchemaValidator();
142: }
143:
144: /**
145: * @param null|list<Icon> $icons
146: */
147: public function setServerInfo(
148: string $name,
149: string $version,
150: ?string $title = null,
151: ?string $description = null,
152: ?string $websiteUrl = null,
153: ?array $icons = null,
154: ): self {
155: $this->serverInfo = new Implementation(
156: name: $name,
157: version: $version,
158: title: $title,
159: description: $description,
160: websiteUrl: $websiteUrl,
161: icons: $icons,
162: );
163:
164: return $this;
165: }
166:
167: public function setInstructions(?string $instructions): self
168: {
169: Assert::that($instructions)
170: ->nullOr()
171: ->isNonEmptyString('Server instructions must be a non-empty string or null.')
172: ;
173:
174: $this->instructions = $instructions;
175:
176: return $this;
177: }
178:
179: public function setLogger(LoggerInterface $logger): self
180: {
181: $this->logger = $logger;
182:
183: return $this;
184: }
185:
186: public function setSchemaValidator(SchemaValidatorInterface $validator): self
187: {
188: $this->schemaValidator = $validator;
189:
190: return $this;
191: }
192:
193: /**
194: * @param (\Closure(?array<string, mixed>, ServerContext): CallToolResult)|ToolExecutorInterface $executor
195: */
196: public function addTool(Tool $tool, \Closure|ToolExecutorInterface $executor): self
197: {
198: $this->tools[$tool->name] = new ToolEntry(
199: $tool,
200: $executor instanceof ToolExecutorInterface ? $executor : new ClosureToolExecutor($executor),
201: );
202:
203: return $this;
204: }
205:
206: /**
207: * @param (\Closure(?array<string, string>, ServerContext): GetPromptResult)|PromptRendererInterface $renderer
208: */
209: public function addPrompt(Prompt $prompt, \Closure|PromptRendererInterface $renderer): self
210: {
211: $this->prompts[$prompt->name] = new PromptEntry(
212: $prompt,
213: $renderer instanceof PromptRendererInterface ? $renderer : new ClosurePromptRenderer($renderer),
214: );
215:
216: return $this;
217: }
218:
219: /**
220: * @param (\Closure(string, ServerContext): ReadResourceResult)|ResourceReaderInterface $reader
221: */
222: public function addResource(Resource $resource, \Closure|ResourceReaderInterface $reader): self
223: {
224: $this->resources[$resource->uri] = new ResourceEntry(
225: $resource,
226: $reader instanceof ResourceReaderInterface ? $reader : new ClosureResourceReader($reader),
227: );
228:
229: return $this;
230: }
231:
232: /**
233: * @param (\Closure(string, array<string, string>, ServerContext): ReadResourceResult)|TemplatedResourceReaderInterface $reader
234: */
235: public function addResourceTemplate(
236: ResourceTemplate $template,
237: \Closure|TemplatedResourceReaderInterface $reader,
238: ): self {
239: Validator::validate($template->uriTemplate, 'ResourceTemplate');
240:
241: $this->resourceTemplates[$template->uriTemplate] = new ResourceTemplateEntry(
242: $template,
243: $reader instanceof TemplatedResourceReaderInterface ? $reader : new ClosureTemplatedResourceReader($reader),
244: );
245:
246: return $this;
247: }
248:
249: public function setToolStore(ToolStoreInterface $store): self
250: {
251: $this->toolStore = $store;
252:
253: return $this;
254: }
255:
256: public function setPromptStore(PromptStoreInterface $store): self
257: {
258: $this->promptStore = $store;
259:
260: return $this;
261: }
262:
263: public function setResourceStore(ResourceStoreInterface $store): self
264: {
265: $this->resourceStore = $store;
266:
267: return $this;
268: }
269:
270: public function setResourceTemplateStore(ResourceTemplateStoreInterface $store): self
271: {
272: $this->resourceTemplateStore = $store;
273:
274: return $this;
275: }
276:
277: public function setCompletionStore(CompletionStoreInterface $store): self
278: {
279: $this->completionStore = $store;
280:
281: return $this;
282: }
283:
284: /**
285: * Registers the server identity (`#[AsServer]`) plus the tools, prompts, resources, and
286: * resource templates discovered from `#[AsTool]`, `#[AsPrompt]`, `#[AsResource]`, and
287: * `#[AsResourceTemplate]` methods on each source object. An explicit `setServerInfo()` or
288: * `setInstructions()` call takes precedence over the matching `#[AsServer]` field, and at
289: * most one registered source may declare `#[AsServer]`.
290: *
291: * @throws DuplicateServerMetadataException
292: * @throws MissingDiscoveryAttributeException
293: */
294: public function register(object ...$sources): self
295: {
296: $scanner = new AttributeScanner();
297:
298: foreach ($sources as $source) {
299: $contributed = false;
300: $metadata = self::findServerMetadata($source);
301:
302: if (null !== $metadata) {
303: if (null !== $this->serverMetadata) {
304: throw new DuplicateServerMetadataException($source::class);
305: }
306:
307: $this->serverMetadata = $metadata;
308: $contributed = true;
309: }
310:
311: foreach ($scanner->scan($source) as $entry) {
312: $contributed = true;
313:
314: if ($entry instanceof ToolEntry) {
315: $this->addTool($entry->tool, $entry->executor);
316: } elseif ($entry instanceof PromptEntry) {
317: $this->addPrompt($entry->prompt, $entry->renderer);
318: } elseif ($entry instanceof ResourceEntry) {
319: $this->addResource($entry->resource, $entry->reader);
320: } else {
321: $this->addResourceTemplate($entry->template, $entry->reader);
322: }
323: }
324:
325: if (! $contributed) {
326: throw new MissingDiscoveryAttributeException($source::class);
327: }
328: }
329:
330: return $this;
331: }
332:
333: /**
334: * Registers a handler for a vendor-extension request method.
335: *
336: * @param non-empty-string $method
337: * @param RequestHandlerInterface<non-empty-string, Result, ServerContext> $handler
338: *
339: * @throws ReservedMethodException
340: *
341: * @see self::replaceRequestHandler()
342: */
343: public function addRequestHandler(string $method, RequestHandlerInterface $handler): self
344: {
345: if (\array_key_exists($method, JsonRpcMethodRegistry::requests())) {
346: throw new ReservedMethodException($method);
347: }
348:
349: $this->customRequestHandlers[$method] = $handler;
350:
351: return $this;
352: }
353:
354: /**
355: * Overrides the SDK's built-in handler for `$method`.
356: *
357: * @param non-empty-string $method
358: * @param RequestHandlerInterface<non-empty-string, Result, ServerContext> $handler
359: *
360: * @throws UnreservedMethodException
361: *
362: * @see self::addRequestHandler()
363: */
364: public function replaceRequestHandler(string $method, RequestHandlerInterface $handler): self
365: {
366: if (! \array_key_exists($method, JsonRpcMethodRegistry::requests())) {
367: throw new UnreservedMethodException($method);
368: }
369:
370: $this->customRequestHandlers[$method] = $handler;
371:
372: return $this;
373: }
374:
375: /**
376: * Registers a handler for a vendor-extension notification method.
377: *
378: * @param non-empty-string $method
379: * @param NotificationHandlerInterface<non-empty-string> $handler
380: *
381: * @throws ReservedMethodException
382: *
383: * @see self::replaceNotificationHandler()
384: */
385: public function addNotificationHandler(string $method, NotificationHandlerInterface $handler): self
386: {
387: if (\array_key_exists($method, JsonRpcMethodRegistry::notifications())) {
388: throw new ReservedMethodException($method, isNotification: true);
389: }
390:
391: $this->customNotificationHandlers[$method] = $handler;
392:
393: return $this;
394: }
395:
396: /**
397: * Overrides any built-in handler for `$method`, including spec notifications.
398: *
399: * @param non-empty-string $method
400: * @param NotificationHandlerInterface<non-empty-string> $handler
401: *
402: * @throws UnreservedMethodException
403: *
404: * @see self::addNotificationHandler()
405: */
406: public function replaceNotificationHandler(string $method, NotificationHandlerInterface $handler): self
407: {
408: if (! \array_key_exists($method, JsonRpcMethodRegistry::notifications())) {
409: throw new UnreservedMethodException($method, isNotification: true);
410: }
411:
412: $this->customNotificationHandlers[$method] = $handler;
413:
414: return $this;
415: }
416:
417: public function build(): Server
418: {
419: $serverInfo = $this->resolveServerInfo();
420:
421: Assert::that($serverInfo)->isInstanceOf(
422: Implementation::class,
423: 'Server information must be set before build() via setServerInfo() or a class-level #[AsServer].',
424: );
425:
426: $capabilities = $this->deriveCapabilities();
427:
428: $requestHandlers = $this->buildRequestHandlers($serverInfo, $capabilities);
429:
430: return new Server(
431: new ServerMessageDispatcher(
432: new HandlerRegistry($requestHandlers, RequestHandlerInterface::class, 'Request handler'),
433: new HandlerRegistry($this->customNotificationHandlers, NotificationHandlerInterface::class, 'Notification handler'),
434: logger: $this->logger,
435: ),
436: $this->logger,
437: );
438: }
439:
440: /**
441: * Merges the explicit `setServerInfo()` values over the `#[AsServer]` fields, with the
442: * attribute filling only the gaps the setter left null.
443: */
444: private function resolveServerInfo(): ?Implementation
445: {
446: $metadata = $this->serverMetadata;
447:
448: if (null === $metadata) {
449: return $this->serverInfo;
450: }
451:
452: if (null === $this->serverInfo) {
453: return new Implementation(
454: name: $metadata->name,
455: version: $metadata->version,
456: title: $metadata->title,
457: description: $metadata->description,
458: websiteUrl: $metadata->websiteUrl,
459: icons: $metadata->icons,
460: );
461: }
462:
463: return new Implementation(
464: name: $this->serverInfo->name,
465: version: $this->serverInfo->version,
466: title: $this->serverInfo->title ?? $metadata->title,
467: description: $this->serverInfo->description ?? $metadata->description,
468: websiteUrl: $this->serverInfo->websiteUrl ?? $metadata->websiteUrl,
469: icons: $this->serverInfo->icons ?? $metadata->icons,
470: );
471: }
472:
473: /**
474: * @return null|non-empty-string
475: */
476: private function resolveInstructions(): ?string
477: {
478: $instructions = $this->instructions ?? $this->serverMetadata?->instructions;
479:
480: Assert::that($instructions)
481: ->nullOr()
482: ->isNonEmptyString('Server instructions must be a non-empty string or null.')
483: ;
484:
485: return $instructions;
486: }
487:
488: private static function findServerMetadata(object $source): ?AsServer
489: {
490: $attributes = new \ReflectionObject($source)->getAttributes(AsServer::class);
491:
492: return [] === $attributes ? null : $attributes[0]->newInstance();
493: }
494:
495: private function deriveCapabilities(): ServerCapabilities
496: {
497: return new ServerCapabilities(
498: completions: $this->hasCompletionsCapability() ? [] : null,
499: prompts: $this->hasPromptsCapability() ? [] : null,
500: resources: $this->hasResourcesCapability() ? [] : null,
501: tools: $this->hasToolsCapability() ? [] : null,
502: );
503: }
504:
505: private function hasCompletionsCapability(): bool
506: {
507: return null !== $this->completionStore
508: || isset($this->customRequestHandlers[CompleteRequest::getMethod()]);
509: }
510:
511: private function hasPromptsCapability(): bool
512: {
513: if (null !== $this->promptStore || [] !== $this->prompts) {
514: return true;
515: }
516:
517: return isset($this->customRequestHandlers[GetPromptRequest::getMethod()])
518: && isset($this->customRequestHandlers[ListPromptsRequest::getMethod()]);
519: }
520:
521: private function hasResourcesCapability(): bool
522: {
523: if (
524: [] !== $this->resources
525: || null !== $this->resourceStore
526: || [] !== $this->resourceTemplates
527: || null !== $this->resourceTemplateStore
528: ) {
529: return true;
530: }
531:
532: return isset($this->customRequestHandlers[ListResourcesRequest::getMethod()])
533: && isset($this->customRequestHandlers[ReadResourceRequest::getMethod()]);
534: }
535:
536: private function hasToolsCapability(): bool
537: {
538: if (null !== $this->toolStore || [] !== $this->tools) {
539: return true;
540: }
541:
542: return isset($this->customRequestHandlers[CallToolRequest::getMethod()])
543: && isset($this->customRequestHandlers[ListToolsRequest::getMethod()]);
544: }
545:
546: /**
547: * @return array<non-empty-string, RequestHandlerInterface<non-empty-string, Result, ServerContext>>
548: */
549: private function buildRequestHandlers(Implementation $serverInfo, ServerCapabilities $capabilities): array
550: {
551: $defaults = [
552: DiscoverRequest::getMethod() => new DiscoverRequestHandler($serverInfo, $capabilities, $this->resolveInstructions()),
553: ];
554:
555: if (null !== $this->toolStore || [] !== $this->tools) {
556: $toolStore = $this->toolStore ?? new ToolStore($this->tools, validator: $this->schemaValidator);
557: $defaults[ListToolsRequest::getMethod()] = new ListToolsRequestHandler($toolStore);
558: $defaults[CallToolRequest::getMethod()] = new CallToolRequestHandler($toolStore, $this->logger);
559: }
560:
561: if (null !== $this->promptStore || [] !== $this->prompts) {
562: $promptStore = $this->promptStore ?? new PromptStore($this->prompts);
563: $defaults[ListPromptsRequest::getMethod()] = new ListPromptsRequestHandler($promptStore);
564: $defaults[GetPromptRequest::getMethod()] = new GetPromptRequestHandler($promptStore);
565: }
566:
567: $resourceTemplateStore = null;
568:
569: if (null !== $this->resourceTemplateStore || [] !== $this->resourceTemplates) {
570: $resourceTemplateStore = $this->resourceTemplateStore ?? new ResourceTemplateStore($this->resourceTemplates);
571:
572: $defaults[ListResourceTemplatesRequest::getMethod()] = new ListResourceTemplatesRequestHandler($resourceTemplateStore);
573: }
574:
575: if (null !== $this->resourceStore || [] !== $this->resources || null !== $resourceTemplateStore) {
576: $resourceStore = $this->resourceStore ?? new ResourceStore($this->resources);
577:
578: $defaults[ListResourcesRequest::getMethod()] = new ListResourcesRequestHandler($resourceStore);
579: $defaults[ReadResourceRequest::getMethod()] = new ReadResourceRequestHandler(
580: null !== $resourceTemplateStore ? new CompositeResourceStore($resourceStore, $resourceTemplateStore) : $resourceStore,
581: );
582: }
583:
584: if (null !== $this->completionStore) {
585: $defaults[CompleteRequest::getMethod()] = new CompleteRequestHandler($this->completionStore);
586: }
587:
588: return [...$defaults, ...$this->customRequestHandlers];
589: }
590: }
591: