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\ProgressListenerRegistry;
19: use Nexus\Mcp\Client\Handler\Notification\RoutingProgressNotificationHandler;
20: use Nexus\Mcp\Client\Subscription\SubscriptionRegistry;
21: use Nexus\Mcp\Core\Dispatch\PendingInboundRequests;
22: use Nexus\Mcp\Core\Dispatch\PendingOutboundRequests;
23: use Nexus\Mcp\Core\Handler\HandlerRegistry;
24: use Nexus\Mcp\Core\Handler\Notification\CancelledNotificationHandler;
25: use Nexus\Mcp\Core\Handler\NotificationHandlerInterface;
26: use Nexus\Mcp\Core\Handler\RequestHandlerInterface;
27: use Nexus\Mcp\Core\Schema\ClientCapabilities;
28: use Nexus\Mcp\Core\Schema\Icon;
29: use Nexus\Mcp\Core\Schema\Implementation;
30: use Nexus\Mcp\Core\Schema\Notification\CancelledNotification;
31: use Nexus\Mcp\Core\Schema\Notification\ProgressNotification;
32: use Nexus\Mcp\Core\Schema\Result;
33: use Psr\Log\LoggerInterface;
34: use Psr\Log\NullLogger;
35:
36: /**
37: * Fluent builder that assembles the per-feature handler registries, the
38: * client-side dispatch kernel, and the outbound-request correlator into a
39: * runnable `Client` instance.
40: */
41: final class ClientBuilder
42: {
43: private ?Implementation $clientInfo = null;
44: private ClientCapabilities $clientCapabilities;
45: private LoggerInterface $logger;
46: private ?float $requestTimeout = Client::DEFAULT_REQUEST_TIMEOUT;
47: private ?float $maxRequestTimeout = Client::DEFAULT_MAX_REQUEST_TIMEOUT;
48: private bool $retryLostRequests = false;
49:
50: /**
51: * @var array<non-empty-string, RequestHandlerInterface<non-empty-string, Result, ClientContext>>
52: */
53: private array $requestHandlers = [];
54:
55: /**
56: * @var array<non-empty-string, NotificationHandlerInterface<non-empty-string>>
57: */
58: private array $notificationHandlers = [];
59:
60: /**
61: * @var null|\Closure(): (int|non-empty-string)
62: */
63: private ?\Closure $requestIdFactory = null;
64:
65: /**
66: * @var null|\Closure(): (int|non-empty-string)
67: */
68: private ?\Closure $progressTokenFactory = null;
69:
70: public function __construct()
71: {
72: $this->clientCapabilities = new ClientCapabilities();
73: $this->logger = new NullLogger();
74: }
75:
76: /**
77: * @param null|list<Icon> $icons
78: */
79: public function setClientInfo(
80: string $name,
81: string $version,
82: ?string $title = null,
83: ?string $description = null,
84: ?string $websiteUrl = null,
85: ?array $icons = null,
86: ): self {
87: $this->clientInfo = new Implementation(
88: name: $name,
89: version: $version,
90: title: $title,
91: description: $description,
92: websiteUrl: $websiteUrl,
93: icons: $icons,
94: );
95:
96: return $this;
97: }
98:
99: /**
100: * Declares the capabilities advertised in every request's `_meta` envelope.
101: */
102: public function setClientCapabilities(ClientCapabilities $capabilities): self
103: {
104: $this->clientCapabilities = $capabilities;
105:
106: return $this;
107: }
108:
109: public function setLogger(LoggerInterface $logger): self
110: {
111: $this->logger = $logger;
112:
113: return $this;
114: }
115:
116: /**
117: * Seconds a request may go unanswered before it is abandoned, or `null` to wait indefinitely. Each
118: * progress notification for the request restarts it.
119: */
120: public function setRequestTimeout(?float $seconds): self
121: {
122: if (null !== $seconds && $seconds <= 0.0) {
123: throw new \InvalidArgumentException(\sprintf('The request timeout must be positive or null, %s given.', $seconds));
124: }
125:
126: $this->requestTimeout = $seconds;
127:
128: return $this;
129: }
130:
131: /**
132: * Seconds a request may run in total however much progress arrives, or `null` to leave it unbounded.
133: */
134: public function setMaxRequestTimeout(?float $seconds): self
135: {
136: if (null !== $seconds && $seconds <= 0.0) {
137: throw new \InvalidArgumentException(\sprintf('The maximum request timeout must be positive or null, %s given.', $seconds));
138: }
139:
140: $this->maxRequestTimeout = $seconds;
141:
142: return $this;
143: }
144:
145: /**
146: * Sends a state-reading request again when the peer carrying it is replaced, instead of failing it.
147: * Off by default: a retry is at-least-once, so the peer may have served the request before it died.
148: *
149: * @see Client for the requests this covers. `tools/call` and vendor methods are never among them.
150: */
151: public function setRetryLostRequests(bool $retry): self
152: {
153: $this->retryLostRequests = $retry;
154:
155: return $this;
156: }
157:
158: /**
159: * Overrides the default monotonically-incrementing integer factory.
160: *
161: * @param \Closure(): (int|non-empty-string) $factory
162: */
163: public function setRequestIdFactory(\Closure $factory): self
164: {
165: $this->requestIdFactory = $factory;
166:
167: return $this;
168: }
169:
170: /**
171: * Overrides the default progress-token factory used by `Client::callTool()`
172: * when an `onProgress` callback is supplied.
173: *
174: * @param \Closure(): (int|non-empty-string) $factory
175: */
176: public function setProgressTokenFactory(\Closure $factory): self
177: {
178: $this->progressTokenFactory = $factory;
179:
180: return $this;
181: }
182:
183: /**
184: * Registers a handler for an inbound request method the peer may send to the client.
185: *
186: * @param non-empty-string $method
187: * @param RequestHandlerInterface<non-empty-string, Result, ClientContext> $handler
188: */
189: public function addRequestHandler(string $method, RequestHandlerInterface $handler): self
190: {
191: $this->requestHandlers[$method] = $handler;
192:
193: return $this;
194: }
195:
196: /**
197: * Registers a handler for an inbound notification method.
198: *
199: * @param non-empty-string $method
200: * @param NotificationHandlerInterface<non-empty-string> $handler
201: */
202: public function addNotificationHandler(string $method, NotificationHandlerInterface $handler): self
203: {
204: $this->notificationHandlers[$method] = $handler;
205:
206: return $this;
207: }
208:
209: public function build(): Client
210: {
211: Assert::that($this->clientInfo)->isInstanceOf(
212: Implementation::class,
213: 'Client information must be set before build() via setClientInfo().',
214: );
215:
216: $outboundRequests = new PendingOutboundRequests();
217: $progressListeners = new ProgressListenerRegistry();
218: $subscriptions = new SubscriptionRegistry();
219: $inboundRequests = new PendingInboundRequests();
220:
221: $requestHandlers = $this->requestHandlers;
222:
223: $notificationHandlers = [
224: CancelledNotification::getMethod() => new CancelledNotificationHandler($inboundRequests, $this->logger),
225: ...$this->notificationHandlers,
226: ];
227: $notificationHandlers[ProgressNotification::getMethod()] = new RoutingProgressNotificationHandler(
228: $progressListeners,
229: // register the custom progress handler as fallback
230: $notificationHandlers[ProgressNotification::getMethod()] ?? null,
231: );
232:
233: return new Client(
234: $this->clientInfo,
235: $this->clientCapabilities,
236: new ClientMessageDispatcher(
237: new HandlerRegistry($requestHandlers, RequestHandlerInterface::class, 'Request handler'),
238: new HandlerRegistry($notificationHandlers, NotificationHandlerInterface::class, 'Notification handler'),
239: $outboundRequests,
240: logger: $this->logger,
241: inboundRequests: $inboundRequests,
242: subscriptions: $subscriptions,
243: ),
244: $outboundRequests,
245: $this->requestIdFactory ?? self::buildDefaultRequestIdFactory(),
246: $this->progressTokenFactory ?? self::buildDefaultProgressTokenFactory(),
247: progressListeners: $progressListeners,
248: subscriptions: $subscriptions,
249: logger: $this->logger,
250: requestTimeout: $this->requestTimeout,
251: maxRequestTimeout: $this->maxRequestTimeout,
252: retryLostRequests: $this->retryLostRequests,
253: );
254: }
255:
256: /**
257: * @return \Closure(): int
258: */
259: private static function buildDefaultRequestIdFactory(): \Closure
260: {
261: $counter = 0;
262:
263: return static function () use (&$counter): int {
264: return ++$counter;
265: };
266: }
267:
268: /**
269: * @return \Closure(): non-empty-string
270: */
271: private static function buildDefaultProgressTokenFactory(): \Closure
272: {
273: $counter = 0;
274:
275: return static function () use (&$counter): string {
276: return \sprintf('progress-%d', ++$counter);
277: };
278: }
279: }
280: