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