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\Client;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Mcp\Client\Dispatch\ClientMessageDispatcher;
18: use Nexus\Mcp\Client\Dispatch\DiscoveredServerCapabilities;
19: use Nexus\Mcp\Client\Dispatch\ProgressListenerRegistry;
20: use Nexus\Mcp\Client\Extension\ClientExtensionInterface;
21: use Nexus\Mcp\Client\Handler\Notification\ExtensionGateNotificationHandler;
22: use Nexus\Mcp\Client\Handler\Notification\RoutingProgressNotificationHandler;
23: use Nexus\Mcp\Client\Handler\Request\ExtensionGateRequestHandler;
24: use Nexus\Mcp\Client\Subscription\SubscriptionRegistry;
25: use Nexus\Mcp\Core\Dispatch\PendingInboundRequests;
26: use Nexus\Mcp\Core\Dispatch\PendingOutboundRequests;
27: use Nexus\Mcp\Core\Exception\LogicException;
28: use Nexus\Mcp\Core\Extension\ExtensionCollection;
29: use Nexus\Mcp\Core\Handler\HandlerRegistry;
30: use Nexus\Mcp\Core\Handler\Notification\CancelledNotificationHandler;
31: use Nexus\Mcp\Core\Handler\NotificationHandlerInterface;
32: use Nexus\Mcp\Core\Handler\RequestHandlerInterface;
33: use Nexus\Mcp\Core\JsonRpc\JsonRpcMessageParser;
34: use Nexus\Mcp\Core\JsonRpc\JsonRpcMethodRegistry;
35: use Nexus\Mcp\Core\Schema\ClientCapabilities;
36: use Nexus\Mcp\Core\Schema\Icon;
37: use Nexus\Mcp\Core\Schema\Implementation;
38: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcNotification;
39: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcRequest;
40: use Nexus\Mcp\Core\Schema\Notification\CancelledNotification;
41: use Nexus\Mcp\Core\Schema\Notification\ProgressNotification;
42: use Nexus\Mcp\Core\Schema\Result;
43: use Nexus\Mcp\Core\Validation\IconSrcValidator;
44: use Psr\Log\LoggerInterface;
45: use Psr\Log\NullLogger;
46:
47: /**
48: * Fluent builder for a runnable `Client` instance.
49: */
50: final class ClientBuilder
51: {
52: public const int DEFAULT_MAX_IN_FLIGHT = 1_024;
53:
54: private bool $built = false;
55: private ?Implementation $clientInfo = null;
56: private ClientCapabilities $clientCapabilities;
57: private LoggerInterface $logger;
58: private ?float $requestTimeout = Client::DEFAULT_REQUEST_TIMEOUT;
59: private ?float $maxRequestTimeout = Client::DEFAULT_MAX_REQUEST_TIMEOUT;
60: private bool $retryLostRequests = false;
61:
62: /**
63: * @var null|int<1, max>
64: */
65: private ?int $maxInFlight = self::DEFAULT_MAX_IN_FLIGHT;
66:
67: /**
68: * @var array<non-empty-string, RequestHandlerInterface<non-empty-string, Result, ClientContext>>
69: */
70: private array $requestHandlers = [];
71:
72: /**
73: * @var array<non-empty-string, NotificationHandlerInterface<non-empty-string>>
74: */
75: private array $notificationHandlers = [];
76:
77: /**
78: * @var array<non-empty-string, class-string<JsonRpcRequest<non-empty-string>>>
79: */
80: private array $requestClasses = [];
81:
82: /**
83: * @var array<non-empty-string, class-string<JsonRpcNotification<non-empty-string>>>
84: */
85: private array $notificationClasses = [];
86:
87: /**
88: * @var ExtensionCollection<ClientContext>
89: */
90: private readonly ExtensionCollection $extensions;
91:
92: /**
93: * @var null|\Closure(): (int|non-empty-string)
94: */
95: private ?\Closure $requestIdFactory = null;
96:
97: /**
98: * @var null|\Closure(): (int|non-empty-string)
99: */
100: private ?\Closure $progressTokenFactory = null;
101:
102: /**
103: * @var null|\Closure(): array<non-empty-string, mixed>
104: */
105: private ?\Closure $metaExtrasFactory = null;
106:
107: public function __construct()
108: {
109: $this->clientCapabilities = new ClientCapabilities();
110: $this->logger = new NullLogger();
111: $this->extensions = new ExtensionCollection();
112: }
113:
114: /**
115: * @throws LogicException
116: */
117: public function enableExtension(ClientExtensionInterface $extension): self
118: {
119: $this->assertNotBuilt();
120: $this->extensions->add(
121: $extension,
122: claimedRequests: array_keys($this->requestHandlers),
123: claimedNotifications: array_keys($this->notificationHandlers),
124: outboundRequests: $extension->getOutboundRequests(),
125: );
126:
127: return $this;
128: }
129:
130: /**
131: * @param non-empty-string $name
132: * @param non-empty-string $version
133: * @param null|non-empty-string $title
134: * @param null|non-empty-string $description
135: * @param null|non-empty-string $websiteUrl
136: * @param null|list<Icon> $icons
137: */
138: public function setClientInfo(
139: string $name,
140: string $version,
141: ?string $title = null,
142: ?string $description = null,
143: ?string $websiteUrl = null,
144: ?array $icons = null,
145: ): self {
146: $this->assertNotBuilt();
147: IconSrcValidator::validate($icons, 'clientInfo');
148:
149: $this->clientInfo = new Implementation(
150: name: $name,
151: version: $version,
152: title: $title,
153: description: $description,
154: websiteUrl: $websiteUrl,
155: icons: $icons,
156: );
157:
158: return $this;
159: }
160:
161: public function setClientCapabilities(ClientCapabilities $capabilities): self
162: {
163: $this->assertNotBuilt();
164: $this->clientCapabilities = $capabilities;
165:
166: return $this;
167: }
168:
169: public function setLogger(LoggerInterface $logger): self
170: {
171: $this->assertNotBuilt();
172: $this->logger = $logger;
173:
174: return $this;
175: }
176:
177: /**
178: * Seconds a request may go unanswered before it is abandoned, or `null` to wait indefinitely.
179: */
180: public function setRequestTimeout(?float $seconds): self
181: {
182: $this->assertNotBuilt();
183:
184: if (null !== $seconds && $seconds <= 0.0) {
185: throw new \InvalidArgumentException(\sprintf('The request timeout must be positive or null, %s given.', $seconds));
186: }
187:
188: $this->requestTimeout = $seconds;
189:
190: return $this;
191: }
192:
193: /**
194: * Seconds a request may run in total however much progress arrives, or `null` to leave it unbounded.
195: */
196: public function setMaxRequestTimeout(?float $seconds): self
197: {
198: $this->assertNotBuilt();
199:
200: if (null !== $seconds && $seconds <= 0.0) {
201: throw new \InvalidArgumentException(\sprintf('The maximum request timeout must be positive or null, %s given.', $seconds));
202: }
203:
204: $this->maxRequestTimeout = $seconds;
205:
206: return $this;
207: }
208:
209: /**
210: * Sends a state-reading request again when the peer carrying it is replaced, which is an at-least-once
211: * retry.
212: */
213: public function setRetryLostRequests(bool $retry): self
214: {
215: $this->assertNotBuilt();
216: $this->retryLostRequests = $retry;
217:
218: return $this;
219: }
220:
221: /**
222: * Caps how many inbound messages the client processes at once, defaulting to `DEFAULT_MAX_IN_FLIGHT`, with
223: * null lifting the cap.
224: */
225: public function setMaxInFlightDispatches(?int $max): self
226: {
227: $this->assertNotBuilt();
228:
229: Assert::that($max)->nullOr()->isPositiveInt('Maximum in-flight dispatches must be a positive integer or null, {value} given.');
230:
231: $this->maxInFlight = $max;
232:
233: return $this;
234: }
235:
236: /**
237: * @param \Closure(): (int|non-empty-string) $factory
238: */
239: public function setRequestIdFactory(\Closure $factory): self
240: {
241: $this->assertNotBuilt();
242: $this->requestIdFactory = $factory;
243:
244: return $this;
245: }
246:
247: /**
248: * @param \Closure(): (int|non-empty-string) $factory
249: */
250: public function setProgressTokenFactory(\Closure $factory): self
251: {
252: $this->assertNotBuilt();
253: $this->progressTokenFactory = $factory;
254:
255: return $this;
256: }
257:
258: /**
259: * Sets the factory called once per outbound request for the extra `_meta` keys it carries, such as the W3C
260: * `traceparent`. A lifecycle key it returns is ignored.
261: *
262: * @param \Closure(): array<non-empty-string, mixed> $factory
263: */
264: public function setMetaExtrasFactory(\Closure $factory): self
265: {
266: $this->assertNotBuilt();
267: $this->metaExtrasFactory = $factory;
268:
269: return $this;
270: }
271:
272: /**
273: * @param class-string<JsonRpcRequest<non-empty-string>> $request
274: * @param RequestHandlerInterface<non-empty-string, Result, ClientContext> $handler
275: */
276: public function addRequestHandler(string $request, RequestHandlerInterface $handler): self
277: {
278: $this->assertNotBuilt();
279: $method = $request::getMethod();
280: $this->extensions->assertNotOwned($method);
281:
282: $registry = JsonRpcMethodRegistry::requests();
283:
284: if (\array_key_exists($method, $registry)) {
285: Assert::that($request)->isIdentical($registry[$method], \sprintf(
286: 'Request method "%s" is defined by the MCP specification and keeps its registry envelope class, {value} given.',
287: $method,
288: ));
289: } else {
290: $this->requestClasses[$method] = $request;
291: }
292:
293: $this->requestHandlers[$method] = $handler;
294:
295: return $this;
296: }
297:
298: /**
299: * @param class-string<JsonRpcNotification<non-empty-string>> $notification
300: * @param NotificationHandlerInterface<non-empty-string> $handler
301: *
302: * @throws LogicException
303: */
304: public function addNotificationHandler(string $notification, NotificationHandlerInterface $handler): self
305: {
306: $this->assertNotBuilt();
307: $method = $notification::getMethod();
308: $this->extensions->assertNotOwned($method, isNotification: true);
309: $registryClass = JsonRpcMethodRegistry::notifications()[$method] ?? null;
310:
311: if (null === $registryClass) {
312: $this->notificationClasses[$method] = $notification;
313: } else {
314: Assert::that($notification)->isIdentical($registryClass, \sprintf(
315: 'Notification method "%s" is defined by the MCP specification and keeps its registry envelope class, {value} given.',
316: $method,
317: ));
318: }
319:
320: $this->notificationHandlers[$method] = $handler;
321:
322: return $this;
323: }
324:
325: public function build(): Client
326: {
327: $this->assertNotBuilt();
328: $this->built = true;
329:
330: Assert::that($this->clientInfo)->isInstanceOf(
331: Implementation::class,
332: 'Client information must be set before build() via setClientInfo().',
333: );
334:
335: $outboundRequests = new PendingOutboundRequests();
336: $progressListeners = new ProgressListenerRegistry();
337: $subscriptions = new SubscriptionRegistry();
338: $inboundRequests = new PendingInboundRequests();
339:
340: $discoveredCapabilities = new DiscoveredServerCapabilities();
341: $extensionRequestHandlers = [];
342:
343: foreach ($this->extensions->getRequestHandlerGroups() as $identifier => $group) {
344: foreach ($group as $extensionMethod => $extensionHandler) {
345: $extensionRequestHandlers[$extensionMethod] = new ExtensionGateRequestHandler($identifier, $extensionHandler, $discoveredCapabilities);
346: }
347: }
348:
349: $requestHandlers = [...$extensionRequestHandlers, ...$this->requestHandlers];
350:
351: $extensionNotificationHandlers = [];
352:
353: foreach ($this->extensions->buildNotificationHandlers() as $extensionMethod => $extensionHandler) {
354: $owner = $this->extensions->findNotificationOwner($extensionMethod);
355: \assert(\is_string($owner));
356: $extensionNotificationHandlers[$extensionMethod] = new ExtensionGateNotificationHandler($owner, $extensionHandler, $discoveredCapabilities, $this->logger);
357: }
358:
359: $notificationHandlers = [
360: CancelledNotification::getMethod() => new CancelledNotificationHandler($inboundRequests, $this->logger),
361: ...$extensionNotificationHandlers,
362: ...$this->notificationHandlers,
363: ];
364: $notificationHandlers[ProgressNotification::getMethod()] = new RoutingProgressNotificationHandler(
365: $progressListeners,
366: $notificationHandlers[ProgressNotification::getMethod()] ?? null,
367: );
368:
369: return new Client(
370: $this->clientInfo,
371: $this->buildClientCapabilities(),
372: new ClientMessageDispatcher(
373: new HandlerRegistry($requestHandlers, RequestHandlerInterface::class, 'Request handler'),
374: new HandlerRegistry($notificationHandlers, NotificationHandlerInterface::class, 'Notification handler'),
375: $outboundRequests,
376: logger: $this->logger,
377: parser: new JsonRpcMessageParser(
378: [...$this->extensions->buildRequestClasses(), ...$this->requestClasses],
379: [...$this->extensions->buildNotificationClasses(), ...$this->notificationClasses],
380: ),
381: inboundRequests: $inboundRequests,
382: subscriptions: $subscriptions,
383: maxInFlight: $this->maxInFlight,
384: ),
385: $outboundRequests,
386: $this->requestIdFactory ?? $this->buildDefaultRequestIdFactory(),
387: $this->progressTokenFactory ?? $this->buildDefaultProgressTokenFactory(),
388: progressListeners: $progressListeners,
389: subscriptions: $subscriptions,
390: logger: $this->logger,
391: requestTimeout: $this->requestTimeout,
392: maxRequestTimeout: $this->maxRequestTimeout,
393: retryLostRequests: $this->retryLostRequests,
394: extensionMethods: $this->extensions->getOutboundOwners(),
395: serverCapabilities: $discoveredCapabilities,
396: metaExtrasFactory: $this->metaExtrasFactory,
397: );
398: }
399:
400: /**
401: * @throws LogicException
402: */
403: private function assertNotBuilt(): void
404: {
405: if ($this->built) {
406: throw new LogicException('This builder has already been built. Construct a new ClientBuilder for another client.');
407: }
408: }
409:
410: /**
411: * @throws LogicException
412: */
413: private function buildClientCapabilities(): ClientCapabilities
414: {
415: $slot = $this->extensions->buildCapabilitySlot();
416:
417: if (null === $slot) {
418: return $this->clientCapabilities;
419: }
420:
421: $base = $this->clientCapabilities;
422: $declared = $base->extensions ?? [];
423:
424: foreach (array_keys($slot) as $identifier) {
425: if (\array_key_exists($identifier, $declared)) {
426: ExtensionCollection::refuseDuplicateDeclaration($identifier);
427: }
428: }
429:
430: return new ClientCapabilities(
431: elicitation: $base->elicitation,
432: experimental: $base->experimental,
433: extensions: [...$declared, ...$slot],
434: extras: $base->extras,
435: );
436: }
437:
438: /**
439: * @return \Closure(): int
440: */
441: private function buildDefaultRequestIdFactory(): \Closure
442: {
443: $counter = 0;
444:
445: return static function () use (&$counter): int {
446: return ++$counter;
447: };
448: }
449:
450: /**
451: * @return \Closure(): non-empty-string
452: */
453: private function buildDefaultProgressTokenFactory(): \Closure
454: {
455: $counter = 0;
456:
457: return static function () use (&$counter): string {
458: return \sprintf('progress-%d', ++$counter);
459: };
460: }
461: }
462: