*/ class Client { private const RETRY_BASE_DELAY_MS = 100; private ?TransportInterface $transport = null; public function __construct( private readonly Protocol $protocol, private readonly Configuration $config, private readonly LoggerInterface $logger = new NullLogger(), ) { } /** * Create a new client builder for fluent configuration. */ public static function builder(): Builder { return new Builder(); } /** * Connect to an MCP server using the provided transport. * * A failed attempt is closed and retried, see {@see Builder::setMaxRetries()}. * * @throws ConnectionException If connection or initialization fails on every attempt */ public function connect(TransportInterface $transport): void { $this->transport = $transport; $this->protocol->connect($transport, $this->config); $maxAttempts = $this->config->maxRetries + 1; for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) { try { $transport->connect(); $this->logger->info('Client connected and initialized', ['attempt' => $attempt]); return; } catch (ConnectionException $e) { // initialize() flags the session before sending the initialized // notification, so a failure in between leaves the flag set. $this->protocol->getState()->setInitialized(false); $transport->close(); if ($attempt === $maxAttempts) { throw $e; } $this->logger->warning('Connection attempt failed, retrying', [ 'attempt' => $attempt, 'max_attempts' => $maxAttempts, 'exception' => $e, ]); usleep($attempt * self::RETRY_BASE_DELAY_MS * 1000); } } } /** * Check if connected and initialized. */ public function isConnected(): bool { return null !== $this->transport && $this->protocol->getState()->isInitialized(); } /** * Get server information from initialization. */ public function getServerInfo(): ?Implementation { return $this->protocol->getState()->getServerInfo(); } /** * Get server instructions. */ public function getInstructions(): ?string { return $this->protocol->getState()->getInstructions(); } /** * Protocol revision negotiated during the handshake. * * This is the version the server answered with, which is not necessarily the * one configured on the builder: a server that cannot speak the requested * revision counter-offers one it supports. Null until the handshake completed. */ public function getProtocolVersion(): ?ProtocolVersion { return $this->protocol->getState()->getProtocolVersion(); } /** * Send a ping request to the server. */ public function ping(): void { $request = new PingRequest(); $this->sendRequest($request); } /** * List available tools from the server. */ public function listTools(?string $cursor = null): ListToolsResult { $request = new ListToolsRequest($cursor); $response = $this->sendRequest($request); return ListToolsResult::fromArray($response->result); } /** * Call a tool on the server. * * @param string $name Tool name * @param array $arguments Tool arguments * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress * Optional callback for progress updates */ public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult { $request = new CallToolRequest($name, $arguments); $response = $this->sendRequest($request, $onProgress); return CallToolResult::fromArray($response->result); } /** * List available resources from the server. */ public function listResources(?string $cursor = null): ListResourcesResult { $request = new ListResourcesRequest($cursor); $response = $this->sendRequest($request); return ListResourcesResult::fromArray($response->result); } /** * List available resource templates from the server. */ public function listResourceTemplates(?string $cursor = null): ListResourceTemplatesResult { $request = new ListResourceTemplatesRequest($cursor); $response = $this->sendRequest($request); return ListResourceTemplatesResult::fromArray($response->result); } /** * Read a resource by URI. * * @param string $uri The resource URI * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress * Optional callback for progress updates */ public function readResource(string $uri, ?callable $onProgress = null): ReadResourceResult { $request = new ReadResourceRequest($uri); $response = $this->sendRequest($request, $onProgress); return ReadResourceResult::fromArray($response->result); } /** * List available prompts from the server. */ public function listPrompts(?string $cursor = null): ListPromptsResult { $request = new ListPromptsRequest($cursor); $response = $this->sendRequest($request); return ListPromptsResult::fromArray($response->result); } /** * Get a prompt from the server. * * @param string $name Prompt name * @param array $arguments Prompt arguments * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress * Optional callback for progress updates */ public function getPrompt(string $name, array $arguments = [], ?callable $onProgress = null): GetPromptResult { $request = new GetPromptRequest($name, $arguments); $response = $this->sendRequest($request, $onProgress); return GetPromptResult::fromArray($response->result); } /** * Request completion suggestions for a prompt or resource argument. * * @param PromptReference|ResourceReference $ref The prompt or resource reference * @param array{name: string, value: string} $argument The argument to complete */ public function complete(PromptReference|ResourceReference $ref, array $argument): CompletionCompleteResult { $request = new CompletionCompleteRequest($ref, $argument); $response = $this->sendRequest($request); return CompletionCompleteResult::fromArray($response->result); } /** * Set the minimum logging level for server log messages. */ public function setLoggingLevel(LoggingLevel $level): void { $request = new SetLogLevelRequest($level); $this->sendRequest($request); } /** * Notify the server that the client's list of roots has changed. * * The server should react by requesting an updated list via roots/list. * * @throws RuntimeException if the client did not advertise the `roots.listChanged` capability * @throws ConnectionException if the client is not connected */ public function sendRootsListChanged(): void { if (true !== $this->config->capabilities->rootsListChanged) { throw new RuntimeException('Cannot send a "roots/list_changed" notification without advertising the "roots.listChanged" capability. Build the client with new ClientCapabilities(roots: true, rootsListChanged: true).'); } if (!$this->isConnected()) { throw new ConnectionException('Client is not connected. Call connect() first.'); } $this->protocol->sendNotification(new RootsListChangedNotification()); } /** * Send a request to the server and wait for response. * * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress * * @return Response * * @throws RequestException|ConnectionException */ private function sendRequest(Request $request, ?callable $onProgress = null): Response { if (!$this->isConnected()) { throw new ConnectionException('Client is not connected. Call connect() first.'); } $withProgress = null !== $onProgress; $fiber = new \Fiber(fn () => $this->protocol->request($request, $this->config->requestTimeout, $withProgress)); $response = $this->transport->runRequest($fiber, $onProgress); if ($response instanceof Error) { throw RequestException::fromError($response); } return $response; } /** * Disconnect from the server. */ public function disconnect(): void { if (null !== $this->transport) { $this->transport->close(); $this->transport = null; $this->logger->info('Client disconnected'); } } }