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\Mcp\Client\Dispatch\ClientInitializationGate;
17: use Nexus\Mcp\Client\Dispatch\ProgressListenerRegistry;
18: use Nexus\Mcp\Client\Exception\ClientAlreadyConnectedException;
19: use Nexus\Mcp\Client\Exception\ClientAlreadyInitializedException;
20: use Nexus\Mcp\Client\Exception\ClientNotConnectedException;
21: use Nexus\Mcp\Client\Exception\ClientNotInitializedException;
22: use Nexus\Mcp\Client\Exception\ServerCapabilityNotSupportedException;
23: use Nexus\Mcp\Client\Exception\UnsupportedProtocolVersionException;
24: use Nexus\Mcp\Core\Dispatch\MessageDispatcherInterface;
25: use Nexus\Mcp\Core\Dispatch\PendingOutboundRequests;
26: use Nexus\Mcp\Core\Exception\TransportAlreadyClosedException;
27: use Nexus\Mcp\Core\Schema\ClientCapabilities;
28: use Nexus\Mcp\Core\Schema\Cursor;
29: use Nexus\Mcp\Core\Schema\Enum\LoggingLevel;
30: use Nexus\Mcp\Core\Schema\Implementation;
31: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcRequest;
32: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcResultResponse;
33: use Nexus\Mcp\Core\Schema\Notification\InitializedNotification;
34: use Nexus\Mcp\Core\Schema\ProgressToken;
35: use Nexus\Mcp\Core\Schema\Prompt\PromptReference;
36: use Nexus\Mcp\Core\Schema\ProtocolVersion;
37: use Nexus\Mcp\Core\Schema\Request\CallToolRequest;
38: use Nexus\Mcp\Core\Schema\Request\CompleteRequest;
39: use Nexus\Mcp\Core\Schema\Request\GetPromptRequest;
40: use Nexus\Mcp\Core\Schema\Request\InitializeRequest;
41: use Nexus\Mcp\Core\Schema\Request\ListPromptsRequest;
42: use Nexus\Mcp\Core\Schema\Request\ListResourcesRequest;
43: use Nexus\Mcp\Core\Schema\Request\ListResourceTemplatesRequest;
44: use Nexus\Mcp\Core\Schema\Request\ListToolsRequest;
45: use Nexus\Mcp\Core\Schema\Request\PingRequest;
46: use Nexus\Mcp\Core\Schema\Request\ReadResourceRequest;
47: use Nexus\Mcp\Core\Schema\Request\SetLevelRequest;
48: use Nexus\Mcp\Core\Schema\RequestId;
49: use Nexus\Mcp\Core\Schema\RequestMetaObject;
50: use Nexus\Mcp\Core\Schema\RequestParams\CallToolRequestParams;
51: use Nexus\Mcp\Core\Schema\RequestParams\CompleteRequestParams;
52: use Nexus\Mcp\Core\Schema\RequestParams\EmptyRequestParams;
53: use Nexus\Mcp\Core\Schema\RequestParams\GetPromptRequestParams;
54: use Nexus\Mcp\Core\Schema\RequestParams\InitializeRequestParams;
55: use Nexus\Mcp\Core\Schema\RequestParams\PaginatedRequestParams;
56: use Nexus\Mcp\Core\Schema\RequestParams\ReadResourceRequestParams;
57: use Nexus\Mcp\Core\Schema\RequestParams\SetLevelRequestParams;
58: use Nexus\Mcp\Core\Schema\Resource\ResourceTemplateReference;
59: use Nexus\Mcp\Core\Schema\Result;
60: use Nexus\Mcp\Core\Schema\Result\CallToolResult;
61: use Nexus\Mcp\Core\Schema\Result\CompleteResult;
62: use Nexus\Mcp\Core\Schema\Result\EmptyResult;
63: use Nexus\Mcp\Core\Schema\Result\GetPromptResult;
64: use Nexus\Mcp\Core\Schema\Result\InitializeResult;
65: use Nexus\Mcp\Core\Schema\Result\ListPromptsResult;
66: use Nexus\Mcp\Core\Schema\Result\ListResourcesResult;
67: use Nexus\Mcp\Core\Schema\Result\ListResourceTemplatesResult;
68: use Nexus\Mcp\Core\Schema\Result\ListToolsResult;
69: use Nexus\Mcp\Core\Schema\Result\ReadResourceResult;
70: use Nexus\Mcp\Core\Schema\ServerCapabilities;
71: use Nexus\Mcp\Core\Transport\TransportInterface;
72: use Psr\Log\LoggerInterface;
73: use Psr\Log\NullLogger;
74:
75: /**
76: * Client-side entry point: drives the transport lifecycle, runs the
77: * `initialize` handshake, and exposes the typed JSON-RPC operations a client
78: * issues against an MCP server.
79: */
80: final class Client
81: {
82: private ?TransportInterface $transport = null;
83: private ?Implementation $serverInfo = null;
84: private ?ServerCapabilities $serverCapabilities = null;
85:
86: /**
87: * @param \Closure(): (int|non-empty-string) $requestIdFactory
88: * @param \Closure(): (int|non-empty-string) $progressTokenFactory
89: */
90: public function __construct(
91: private readonly Implementation $clientInfo,
92: private readonly MessageDispatcherInterface $dispatcher,
93: private readonly PendingOutboundRequests $outboundRequests,
94: private readonly ClientInitializationGate $initializationGate,
95: private readonly \Closure $requestIdFactory,
96: private readonly \Closure $progressTokenFactory,
97: private readonly ProgressListenerRegistry $progressListeners = new ProgressListenerRegistry(),
98: private readonly LoggerInterface $logger = new NullLogger(),
99: ) {
100: }
101:
102: /**
103: * Non-blocking connect to the transport.
104: *
105: * @throws ClientAlreadyConnectedException
106: */
107: public function connect(TransportInterface $transport): void
108: {
109: if (null !== $this->transport) {
110: // Reject reentry to avoid orphaning the previous transport.
111: throw new ClientAlreadyConnectedException();
112: }
113:
114: $this->logger->info('Starting MCP client.');
115:
116: $this->transport = $transport;
117:
118: $transport->onMessage(function (array $envelope) use ($transport): void {
119: $this->dispatcher->dispatch($envelope, $transport);
120: });
121: $transport->onError(function (\Throwable $e): void {
122: $this->logger->error('Transport error.', ['exception' => $e]);
123: });
124: $transport->onDrain(function (): void {
125: $this->dispatcher->flushPending();
126: });
127: $transport->onClose(function (): void {
128: $this->outboundRequests->cancelAll(
129: new TransportAlreadyClosedException(operation: 'await-response'),
130: );
131: });
132:
133: $transport->start();
134: }
135:
136: /**
137: * Closes the transport and detaches it so a fresh `connect()` can run.
138: * A no-op when the client is not connected.
139: */
140: public function disconnect(): void
141: {
142: $transport = $this->transport;
143: $this->transport = null;
144: $transport?->close();
145: }
146:
147: /**
148: * Server `Implementation` block captured from the initialize response, or
149: * `null` if the handshake has not completed yet.
150: */
151: public function getServerInfo(): ?Implementation
152: {
153: return $this->serverInfo;
154: }
155:
156: /**
157: * Server capabilities negotiated during the initialize response, or `null` if
158: * the handshake has not completed yet.
159: */
160: public function getServerCapabilities(): ?ServerCapabilities
161: {
162: return $this->serverCapabilities;
163: }
164:
165: /**
166: * Sends a `ping` and awaits the peer's empty acknowledgement. Permitted
167: * before the handshake completes.
168: *
169: * @throws ClientNotConnectedException
170: * @throws TransportAlreadyClosedException
171: */
172: public function ping(): void
173: {
174: $this->sendRequest(
175: new PingRequest($this->mintRequestId(), new EmptyRequestParams()),
176: EmptyResult::class,
177: );
178: }
179:
180: /**
181: * Sends `initialize`, awaits the result, then sends `notifications/initialized`.
182: *
183: * @throws ClientAlreadyInitializedException
184: * @throws ClientNotConnectedException
185: * @throws TransportAlreadyClosedException
186: */
187: public function initialize(
188: ClientCapabilities $capabilities = new ClientCapabilities(),
189: ?ProtocolVersion $protocolVersion = null,
190: ): InitializeResult {
191: if (null === $this->transport) {
192: throw new ClientNotConnectedException();
193: }
194:
195: if (! $this->initializationGate->markInitializeInFlight()) {
196: throw new ClientAlreadyInitializedException();
197: }
198:
199: try {
200: $protocolVersion ??= new ProtocolVersion(ProtocolVersion::LATEST_VERSION);
201: $request = new InitializeRequest(
202: $this->mintRequestId(),
203: new InitializeRequestParams($protocolVersion, $capabilities, $this->clientInfo),
204: );
205:
206: $future = $this->outboundRequests->register($request->id, InitializeResult::class);
207:
208: try {
209: $this->transport->send($request);
210: } catch (\Throwable $e) {
211: // A failed send leaves the registration with no awaiter and no
212: // response to correlate, so free the slot before propagating.
213: $this->outboundRequests->forget($request->id);
214:
215: throw $e;
216: }
217:
218: $response = $future->await();
219: $result = $response->result;
220:
221: // Spec: when the negotiated version is one the client cannot speak, it
222: // must not confirm the handshake and SHOULD disconnect.
223: if (ProtocolVersion::LATEST_VERSION !== $result->protocolVersion->version) {
224: $this->disconnect();
225:
226: throw new UnsupportedProtocolVersionException($result->protocolVersion);
227: }
228:
229: $this->transport->send(new InitializedNotification());
230: $this->initializationGate->markInitialized();
231:
232: $this->serverInfo = $result->serverInfo;
233: $this->serverCapabilities = $result->capabilities;
234:
235: return $result;
236: } catch (\Throwable $e) {
237: $this->initializationGate->revertInitializeInFlight();
238:
239: throw $e;
240: }
241: }
242:
243: /**
244: * @throws ClientNotConnectedException
245: * @throws ClientNotInitializedException
246: * @throws ServerCapabilityNotSupportedException
247: * @throws TransportAlreadyClosedException
248: */
249: public function listTools(?Cursor $cursor = null): ListToolsResult
250: {
251: return $this->sendRequest(
252: new ListToolsRequest($this->mintRequestId(), new PaginatedRequestParams($cursor)),
253: ListToolsResult::class,
254: )->result;
255: }
256:
257: /**
258: * @throws ClientNotConnectedException
259: * @throws ClientNotInitializedException
260: * @throws ServerCapabilityNotSupportedException
261: * @throws TransportAlreadyClosedException
262: */
263: public function listResources(?Cursor $cursor = null): ListResourcesResult
264: {
265: return $this->sendRequest(
266: new ListResourcesRequest($this->mintRequestId(), new PaginatedRequestParams($cursor)),
267: ListResourcesResult::class,
268: )->result;
269: }
270:
271: /**
272: * @throws ClientNotConnectedException
273: * @throws ClientNotInitializedException
274: * @throws ServerCapabilityNotSupportedException
275: * @throws TransportAlreadyClosedException
276: */
277: public function listResourceTemplates(?Cursor $cursor = null): ListResourceTemplatesResult
278: {
279: return $this->sendRequest(
280: new ListResourceTemplatesRequest($this->mintRequestId(), new PaginatedRequestParams($cursor)),
281: ListResourceTemplatesResult::class,
282: )->result;
283: }
284:
285: /**
286: * @throws ClientNotConnectedException
287: * @throws ClientNotInitializedException
288: * @throws ServerCapabilityNotSupportedException
289: * @throws TransportAlreadyClosedException
290: */
291: public function listPrompts(?Cursor $cursor = null): ListPromptsResult
292: {
293: return $this->sendRequest(
294: new ListPromptsRequest($this->mintRequestId(), new PaginatedRequestParams($cursor)),
295: ListPromptsResult::class,
296: )->result;
297: }
298:
299: /**
300: * @throws ClientNotConnectedException
301: * @throws ClientNotInitializedException
302: * @throws ServerCapabilityNotSupportedException
303: * @throws TransportAlreadyClosedException
304: */
305: public function readResource(string $uri): ReadResourceResult
306: {
307: return $this->sendRequest(
308: new ReadResourceRequest($this->mintRequestId(), new ReadResourceRequestParams($uri)),
309: ReadResourceResult::class,
310: )->result;
311: }
312:
313: /**
314: * @param null|array<string, string> $arguments
315: *
316: * @throws ClientNotConnectedException
317: * @throws ClientNotInitializedException
318: * @throws ServerCapabilityNotSupportedException
319: * @throws TransportAlreadyClosedException
320: */
321: public function getPrompt(string $name, ?array $arguments = null): GetPromptResult
322: {
323: return $this->sendRequest(
324: new GetPromptRequest($this->mintRequestId(), new GetPromptRequestParams($name, $arguments)),
325: GetPromptResult::class,
326: )->result;
327: }
328:
329: /**
330: * @param array{name: string, value: string} $argument
331: * @param null|array{arguments?: array<string, string>} $context
332: *
333: * @throws ClientNotConnectedException
334: * @throws ClientNotInitializedException
335: * @throws ServerCapabilityNotSupportedException
336: * @throws TransportAlreadyClosedException
337: */
338: public function complete(
339: PromptReference|ResourceTemplateReference $ref,
340: array $argument,
341: ?array $context = null,
342: ): CompleteResult {
343: return $this->sendRequest(
344: new CompleteRequest(
345: $this->mintRequestId(),
346: new CompleteRequestParams($ref, $argument, $context),
347: ),
348: CompleteResult::class,
349: )->result;
350: }
351:
352: /**
353: * Invokes a tool. When `$onProgress` is given, a fresh `progressToken` is
354: * minted into the request's `_meta` and the callback receives every
355: * matching `notifications/progress` for the duration of the call.
356: *
357: * @param null|array<string, mixed> $arguments
358: * @param null|\Closure(float $progress, ?float $total, ?string $message): void $onProgress
359: *
360: * @throws ClientNotConnectedException
361: * @throws ClientNotInitializedException
362: * @throws ServerCapabilityNotSupportedException
363: * @throws TransportAlreadyClosedException
364: */
365: public function callTool(string $name, ?array $arguments = null, ?\Closure $onProgress = null): CallToolResult
366: {
367: if (null === $onProgress) {
368: return $this->sendRequest(
369: new CallToolRequest($this->mintRequestId(), new CallToolRequestParams($name, $arguments)),
370: CallToolResult::class,
371: )->result;
372: }
373:
374: $progressToken = $this->mintProgressToken();
375: $this->progressListeners->register($progressToken, $onProgress);
376:
377: try {
378: return $this->sendRequest(
379: new CallToolRequest(
380: $this->mintRequestId(),
381: new CallToolRequestParams(
382: $name,
383: $arguments,
384: meta: new RequestMetaObject(progressToken: $progressToken),
385: ),
386: ),
387: CallToolResult::class,
388: )->result;
389: } finally {
390: $this->progressListeners->unregister($progressToken);
391: }
392: }
393:
394: /**
395: * Sets the minimum severity the server should emit via `logging/setLevel`.
396: *
397: * @throws ClientNotConnectedException
398: * @throws ClientNotInitializedException
399: * @throws ServerCapabilityNotSupportedException
400: * @throws TransportAlreadyClosedException
401: */
402: public function setLoggingLevel(LoggingLevel $level): void
403: {
404: $this->sendRequest(
405: new SetLevelRequest($this->mintRequestId(), new SetLevelRequestParams($level)),
406: EmptyResult::class,
407: );
408: }
409:
410: /**
411: * Sends an outbound JSON-RPC request and awaits the correlated response.
412: *
413: * @template T of Result
414: *
415: * @param JsonRpcRequest<non-empty-string> $request
416: * @param class-string<T> $result
417: *
418: * @return JsonRpcResultResponse<T>
419: *
420: * @throws ClientNotConnectedException
421: * @throws ClientNotInitializedException
422: * @throws ServerCapabilityNotSupportedException
423: * @throws TransportAlreadyClosedException
424: */
425: public function sendRequest(JsonRpcRequest $request, string $result): JsonRpcResultResponse
426: {
427: $transport = $this->transport;
428:
429: if (null === $transport) {
430: throw new ClientNotConnectedException();
431: }
432:
433: $method = $request::getMethod();
434:
435: if (! $this->initializationGate->allowsRequest($method)) {
436: throw new ClientNotInitializedException($method);
437: }
438:
439: $this->assertServerSupports($method);
440:
441: $future = $this->outboundRequests->register($request->id, $result);
442:
443: try {
444: $transport->send($request);
445: } catch (\Throwable $e) {
446: // A failed send leaves the registration with no awaiter and no
447: // response to correlate, so free the slot before propagating.
448: $this->outboundRequests->forget($request->id);
449:
450: throw $e;
451: }
452:
453: return $future->await();
454: }
455:
456: /**
457: * @throws ServerCapabilityNotSupportedException
458: */
459: private function assertServerSupports(string $method): void
460: {
461: $capabilities = $this->serverCapabilities;
462:
463: if (null === $capabilities) {
464: // Before the handshake there are no negotiated capabilities to enforce.
465: return;
466: }
467:
468: $supported = match ($method) {
469: ListToolsRequest::getMethod(), CallToolRequest::getMethod() => null !== $capabilities->tools,
470: ListResourcesRequest::getMethod(),
471: ListResourceTemplatesRequest::getMethod(),
472: ReadResourceRequest::getMethod() => null !== $capabilities->resources,
473: ListPromptsRequest::getMethod(), GetPromptRequest::getMethod() => null !== $capabilities->prompts,
474: CompleteRequest::getMethod() => null !== $capabilities->completions,
475: SetLevelRequest::getMethod() => null !== $capabilities->logging,
476: default => true,
477: };
478:
479: if (! $supported) {
480: throw new ServerCapabilityNotSupportedException($method);
481: }
482: }
483:
484: private function mintRequestId(): RequestId
485: {
486: return new RequestId(($this->requestIdFactory)());
487: }
488:
489: private function mintProgressToken(): ProgressToken
490: {
491: return new ProgressToken(($this->progressTokenFactory)());
492: }
493: }
494: