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