From c615f95f9a1b4095aa6a92b2bb7fc8dbf10bfff6 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 20 Dec 2025 10:14:09 +0100 Subject: [PATCH 01/21] feat(client): add complete MCP client SDK with STDIO and HTTP transports Add Client, Builder, ClientProtocol, ClientSession, and transport implementations for communicating with MCP servers. Supports tools, resources, prompts, and real-time progress/logging notifications. --- composer.json | 2 + examples/client/README.md | 27 ++ examples/client/http_client_communication.php | 71 ++++ examples/client/http_discovery_calculator.php | 69 ++++ .../client/stdio_client_communication.php | 70 ++++ .../client/stdio_discovery_calculator.php | 80 +++++ .../server/client-communication/server.php | 4 +- src/Client/Builder.php | 187 +++++++++++ src/Client/Client.php | 290 ++++++++++++++++ src/Client/Configuration.php | 33 ++ .../Handler/InternalProgressHandler.php | 54 +++ .../Handler/LoggingNotificationHandler.php | 42 +++ .../Handler/ProgressNotificationHandler.php | 42 +++ src/Client/Protocol.php | 294 ++++++++++++++++ src/Client/Session/ClientSession.php | 153 +++++++++ src/Client/Session/ClientSessionInterface.php | 127 +++++++ src/Client/Transport/BaseClientTransport.php | 126 +++++++ .../Transport/ClientTransportInterface.php | 115 +++++++ src/Client/Transport/HttpClientTransport.php | 313 ++++++++++++++++++ src/Client/Transport/StdioClientTransport.php | 300 +++++++++++++++++ src/Exception/ConnectionException.php | 21 ++ src/Exception/RequestException.php | 40 +++ src/Exception/TimeoutException.php | 21 ++ src/Handler/NotificationHandlerInterface.php | 35 ++ src/Handler/RequestHandlerInterface.php | 39 +++ src/Server/Transport/StdioTransport.php | 11 +- 26 files changed, 2563 insertions(+), 3 deletions(-) create mode 100644 examples/client/README.md create mode 100644 examples/client/http_client_communication.php create mode 100644 examples/client/http_discovery_calculator.php create mode 100644 examples/client/stdio_client_communication.php create mode 100644 examples/client/stdio_discovery_calculator.php create mode 100644 src/Client/Builder.php create mode 100644 src/Client/Client.php create mode 100644 src/Client/Configuration.php create mode 100644 src/Client/Handler/InternalProgressHandler.php create mode 100644 src/Client/Handler/LoggingNotificationHandler.php create mode 100644 src/Client/Handler/ProgressNotificationHandler.php create mode 100644 src/Client/Protocol.php create mode 100644 src/Client/Session/ClientSession.php create mode 100644 src/Client/Session/ClientSessionInterface.php create mode 100644 src/Client/Transport/BaseClientTransport.php create mode 100644 src/Client/Transport/ClientTransportInterface.php create mode 100644 src/Client/Transport/HttpClientTransport.php create mode 100644 src/Client/Transport/StdioClientTransport.php create mode 100644 src/Exception/ConnectionException.php create mode 100644 src/Exception/RequestException.php create mode 100644 src/Exception/TimeoutException.php create mode 100644 src/Handler/NotificationHandlerInterface.php create mode 100644 src/Handler/RequestHandlerInterface.php diff --git a/composer.json b/composer.json index 83a08f39..b4ea9c34 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "psr/clock": "^1.0", "psr/container": "^1.0 || ^2.0", "psr/event-dispatcher": "^1.0", + "psr/http-client": "^1.0", "psr/http-factory": "^1.1", "psr/http-message": "^1.1 || ^2.0", "psr/log": "^1.0 || ^2.0 || ^3.0", @@ -42,6 +43,7 @@ "psr/simple-cache": "^2.0 || ^3.0", "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/http-client": "^7.4", "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "autoload": { diff --git a/examples/client/README.md b/examples/client/README.md new file mode 100644 index 00000000..0e64f63e --- /dev/null +++ b/examples/client/README.md @@ -0,0 +1,27 @@ +# Client Examples + +These examples demonstrate how to use the MCP PHP Client SDK. + +## STDIO Client + +Connects to an MCP server running as a child process: + +```bash +php examples/client/stdio_example.php +``` + +## HTTP Client + +Connects to an MCP server over HTTP: + +```bash +# First, start an HTTP server +php -S localhost:8080 examples/http-discovery-userprofile/server.php + +# Then run the client +php examples/client/http_example.php +``` + +## Requirements + +Both examples require the server examples to be available. The STDIO example spawns the discovery-calculator server, while the HTTP example connects to a running HTTP server. diff --git a/examples/client/http_client_communication.php b/examples/client/http_client_communication.php new file mode 100644 index 00000000..59b12e27 --- /dev/null +++ b/examples/client/http_client_communication.php @@ -0,0 +1,71 @@ +setClientInfo('HTTP Client Communication Test', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(60) + ->addNotificationHandler(new LoggingNotificationHandler(function (LoggingMessageNotification $n) { + echo "[LOG {$n->level->value}] {$n->data}\n"; + })) + ->build(); + +$transport = new HttpClientTransport(endpoint: $endpoint); + +try { + echo "Connecting to MCP server at {$endpoint}...\n"; + $client->connect($transport); + + $serverInfo = $client->getServerInfo(); + echo "Connected to: " . ($serverInfo['serverInfo']['name'] ?? 'unknown') . "\n\n"; + + echo "Available tools:\n"; + $toolsResult = $client->listTools(); + foreach ($toolsResult->tools as $tool) { + echo " - {$tool->name}\n"; + } + echo "\n"; + + echo "Calling 'run_dataset_quality_checks'...\n\n"; + $result = $client->callTool( + name: 'run_dataset_quality_checks', + arguments: ['dataset' => 'sales_transactions_q4'], + onProgress: function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; + echo "[PROGRESS {$percent}%] {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text . "\n"; + } + } +} catch (\Throwable $e) { + echo "Error: {$e->getMessage()}\n"; +} finally { + $client->disconnect(); +} diff --git a/examples/client/http_discovery_calculator.php b/examples/client/http_discovery_calculator.php new file mode 100644 index 00000000..0c97e785 --- /dev/null +++ b/examples/client/http_discovery_calculator.php @@ -0,0 +1,69 @@ +setClientInfo('HTTP Example Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(60) + ->build(); + +$transport = new HttpClientTransport($endpoint); + +try { + echo "Connecting to MCP server at {$endpoint}...\n"; + $client->connect($transport); + + echo "Connected! Server info:\n"; + $serverInfo = $client->getServerInfo(); + echo " Name: " . ($serverInfo['serverInfo']['name'] ?? 'unknown') . "\n"; + echo " Version: " . ($serverInfo['serverInfo']['version'] ?? 'unknown') . "\n\n"; + + echo "Available tools:\n"; + $toolsResult = $client->listTools(); + foreach ($toolsResult->tools as $tool) { + echo " - {$tool->name}: {$tool->description}\n"; + } + echo "\n"; + + echo "Available resources:\n"; + $resourcesResult = $client->listResources(); + foreach ($resourcesResult->resources as $resource) { + echo " - {$resource->uri}: {$resource->name}\n"; + } + echo "\n"; + + echo "Available prompts:\n"; + $promptsResult = $client->listPrompts(); + foreach ($promptsResult->prompts as $prompt) { + echo " - {$prompt->name}: {$prompt->description}\n"; + } + echo "\n"; + +} catch (\Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString() . "\n"; +} finally { + echo "Disconnecting...\n"; + $client->disconnect(); + echo "Done.\n"; +} diff --git a/examples/client/stdio_client_communication.php b/examples/client/stdio_client_communication.php new file mode 100644 index 00000000..51dd3c1c --- /dev/null +++ b/examples/client/stdio_client_communication.php @@ -0,0 +1,70 @@ +setClientInfo('STDIO Client Communication Test', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(60) + ->addNotificationHandler(new LoggingNotificationHandler(function (LoggingMessageNotification $n) { + echo "[LOG {$n->level->value}] {$n->data}\n"; + })) + ->build(); + +$transport = new StdioClientTransport( + command: 'php', + args: [__DIR__ . '/../client-communication/server.php'], +); + +try { + echo "Connecting to MCP server...\n"; + $client->connect($transport); + + $serverInfo = $client->getServerInfo(); + echo "Connected to: " . ($serverInfo['serverInfo']['name'] ?? 'unknown') . "\n\n"; + + echo "Available tools:\n"; + $toolsResult = $client->listTools(); + foreach ($toolsResult->tools as $tool) { + echo " - {$tool->name}\n"; + } + echo "\n"; + + echo "Calling 'run_dataset_quality_checks'...\n\n"; + $result = $client->callTool( + name: 'run_dataset_quality_checks', + arguments: ['dataset' => 'customer_orders_2024'], + onProgress: function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; + echo "[PROGRESS {$percent}%] {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text . "\n"; + } + } +} catch (\Throwable $e) { + echo "Error: {$e->getMessage()}\n"; +} finally { + $client->disconnect(); +} diff --git a/examples/client/stdio_discovery_calculator.php b/examples/client/stdio_discovery_calculator.php new file mode 100644 index 00000000..4e9b5d94 --- /dev/null +++ b/examples/client/stdio_discovery_calculator.php @@ -0,0 +1,80 @@ +setClientInfo('STDIO Example Client', '1.0.0') + ->setInitTimeout(30) + ->setRequestTimeout(60) + ->build(); + +$transport = new StdioClientTransport( + command: 'php', + args: [__DIR__ . '/../discovery-calculator/server.php'], +); + +try { + echo "Connecting to MCP server...\n"; + $client->connect($transport); + + echo "Connected! Server info:\n"; + $serverInfo = $client->getServerInfo(); + echo " Name: " . ($serverInfo['serverInfo']['name'] ?? 'unknown') . "\n"; + echo " Version: " . ($serverInfo['serverInfo']['version'] ?? 'unknown') . "\n\n"; + + echo "Available tools:\n"; + $toolsResult = $client->listTools(); + foreach ($toolsResult->tools as $tool) { + echo " - {$tool->name}: {$tool->description}\n"; + } + echo "\n"; + + echo "Calling 'calculate' tool with a=5, b=3, operation='add'...\n"; + $result = $client->callTool('calculate', ['a' => 5, 'b' => 3, 'operation' => 'add']); + echo "Result: "; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text; + } + } + echo "\n\n"; + + echo "Available resources:\n"; + $resourcesResult = $client->listResources(); + foreach ($resourcesResult->resources as $resource) { + echo " - {$resource->uri}: {$resource->name}\n"; + } + echo "\n"; + + echo "Reading resource 'config://calculator/settings'...\n"; + $resourceContent = $client->readResource('config://calculator/settings'); + foreach ($resourceContent->contents as $content) { + if ($content instanceof TextResourceContents) { + echo " Content: " . $content->text . "\n"; + echo " Mimetype: " . $content->mimeType . "\n"; + } + } +} catch (\Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString() . "\n"; +} finally { + echo "Disconnecting...\n"; + $client->disconnect(); + echo "Done.\n"; +} diff --git a/examples/server/client-communication/server.php b/examples/server/client-communication/server.php index 42e5058b..cc8c2f37 100644 --- a/examples/server/client-communication/server.php +++ b/examples/server/client-communication/server.php @@ -10,7 +10,7 @@ * file that was distributed with this source code. */ -require_once dirname(__DIR__).'/bootstrap.php'; +require_once dirname(__DIR__) . '/bootstrap.php'; chdir(__DIR__); use Mcp\Schema\Enum\LoggingLevel; @@ -23,7 +23,7 @@ ->setServerInfo('Client Communication Demo', '1.0.0') ->setLogger(logger()) ->setContainer(container()) - ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setSession(new FileSessionStore(__DIR__ . '/sessions')) ->setCapabilities(new ServerCapabilities(logging: true, tools: true)) ->setDiscovery(__DIR__) ->addTool( diff --git a/src/Client/Builder.php b/src/Client/Builder.php new file mode 100644 index 00000000..99c801d8 --- /dev/null +++ b/src/Client/Builder.php @@ -0,0 +1,187 @@ + + */ +class Builder +{ + private string $name = 'mcp-php-client'; + private string $version = '1.0.0'; + private ?string $description = null; + private ?string $protocolVersion = null; + private ?ClientCapabilities $capabilities = null; + private int $initTimeout = 30; + private int $requestTimeout = 120; + private int $maxRetries = 3; + private ?LoggerInterface $logger = null; + + /** @var NotificationHandlerInterface[] */ + private array $notificationHandlers = []; + + /** @var RequestHandlerInterface[] */ + private array $requestHandlers = []; + + /** + * Set the client name and version. + */ + public function setClientInfo(string $name, string $version, ?string $description = null): self + { + $this->name = $name; + $this->version = $version; + $this->description = $description; + + return $this; + } + + /** + * Set the protocol version to use. + */ + public function setProtocolVersion(string $version): self + { + $this->protocolVersion = $version; + + return $this; + } + + /** + * Set client capabilities. + */ + public function setCapabilities(ClientCapabilities $capabilities): self + { + $this->capabilities = $capabilities; + + return $this; + } + + /** + * Enable roots capability. + */ + public function withRoots(bool $listChanged = false): self + { + $this->capabilities = new ClientCapabilities( + roots: true, + rootsListChanged: $listChanged, + sampling: $this->capabilities?->sampling, + experimental: $this->capabilities?->experimental, + ); + + return $this; + } + + /** + * Enable sampling capability. + */ + public function withSampling(): self + { + $this->capabilities = new ClientCapabilities( + roots: $this->capabilities?->roots, + rootsListChanged: $this->capabilities?->rootsListChanged, + sampling: true, + experimental: $this->capabilities?->experimental, + ); + + return $this; + } + + /** + * Set initialization timeout in seconds. + */ + public function setInitTimeout(int $seconds): self + { + $this->initTimeout = $seconds; + + return $this; + } + + /** + * Set request timeout in seconds. + */ + public function setRequestTimeout(int $seconds): self + { + $this->requestTimeout = $seconds; + + return $this; + } + + /** + * Set maximum retry attempts for failed connections. + */ + public function setMaxRetries(int $retries): self + { + $this->maxRetries = $retries; + + return $this; + } + + /** + * Set the logger. + */ + public function setLogger(LoggerInterface $logger): self + { + $this->logger = $logger; + + return $this; + } + + /** + * Add a notification handler for server notifications. + */ + public function addNotificationHandler(NotificationHandlerInterface $handler): self + { + $this->notificationHandlers[] = $handler; + + return $this; + } + + /** + * Add a request handler for server requests (e.g., sampling). + */ + public function addRequestHandler(RequestHandlerInterface $handler): self + { + $this->requestHandlers[] = $handler; + + return $this; + } + + /** + * Build the client instance. + */ + public function build(): Client + { + $clientInfo = new Implementation( + $this->name, + $this->version, + $this->description, + ); + + $config = new Configuration( + clientInfo: $clientInfo, + capabilities: $this->capabilities ?? new ClientCapabilities(), + protocolVersion: $this->protocolVersion ?? '2025-06-18', + initTimeout: $this->initTimeout, + requestTimeout: $this->requestTimeout, + maxRetries: $this->maxRetries, + ); + + return new Client($config, $this->notificationHandlers, $this->requestHandlers, $this->logger); + } +} diff --git a/src/Client/Client.php b/src/Client/Client.php new file mode 100644 index 00000000..46c342ce --- /dev/null +++ b/src/Client/Client.php @@ -0,0 +1,290 @@ + + */ +class Client +{ + private Protocol $protocol; + private ClientSessionInterface $session; + private ?ClientTransportInterface $transport = null; + private int $progressTokenCounter = 0; + + /** + * @param NotificationHandlerInterface[] $notificationHandlers + * @param RequestHandlerInterface[] $requestHandlers + */ + public function __construct( + private readonly Configuration $config, + array $notificationHandlers = [], + array $requestHandlers = [], + ?LoggerInterface $logger = null, + ) { + $this->session = new ClientSession(); + + // Auto-register internal progress handler to dispatch per-request callbacks + $allNotificationHandlers = [ + new InternalProgressHandler($this->session), + ...$notificationHandlers, + ]; + + $this->protocol = new Protocol( + $this->session, + $config, + $allNotificationHandlers, + $requestHandlers, + null, + $logger + ); + } + + /** + * 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. + * + * This method blocks until initialization completes or times out. + * The transport handles all blocking operations internally. + * + * @throws ConnectionException If connection or initialization fails + */ + public function connect(ClientTransportInterface $transport): void + { + $this->transport = $transport; + $this->protocol->connect($transport); + + $transport->connectAndInitialize($this->config->initTimeout); + } + + /** + * Check if connected and initialized. + */ + public function isConnected(): bool + { + return null !== $this->transport && $this->protocol->getSession()->isInitialized(); + } + + /** + * Ping the server. + */ + public function ping(): void + { + $this->ensureConnected(); + $this->doRequest(new PingRequest()); + } + + /** + * List available tools from the server. + */ + public function listTools(?string $cursor = null): ListToolsResult + { + $this->ensureConnected(); + + return $this->doRequest(new ListToolsRequest($cursor), ListToolsResult::class); + } + + /** + * 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. If provided, a progress token + * is automatically generated and attached to the request. + */ + public function callTool( + string $name, + array $arguments = [], + ?callable $onProgress = null, + ): CallToolResult { + $this->ensureConnected(); + + $request = new CallToolRequest($name, $arguments); + + return $this->doRequest($request, CallToolResult::class, $onProgress); + } + + /** + * List available resources. + */ + public function listResources(?string $cursor = null): ListResourcesResult + { + $this->ensureConnected(); + + return $this->doRequest(new ListResourcesRequest($cursor), ListResourcesResult::class); + } + + /** + * List available resource templates. + */ + public function listResourceTemplates(?string $cursor = null): ListResourceTemplatesResult + { + $this->ensureConnected(); + + return $this->doRequest(new ListResourceTemplatesRequest($cursor), ListResourceTemplatesResult::class); + } + + /** + * 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 + { + $this->ensureConnected(); + + $request = new ReadResourceRequest($uri); + + return $this->doRequest($request, ReadResourceResult::class, $onProgress); + } + + /** + * List available prompts. + */ + public function listPrompts(?string $cursor = null): ListPromptsResult + { + $this->ensureConnected(); + + return $this->doRequest(new ListPromptsRequest($cursor), ListPromptsResult::class); + } + + /** + * Get a prompt by name. + * + * @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 { + $this->ensureConnected(); + + $request = new GetPromptRequest($name, $arguments); + + return $this->doRequest($request, GetPromptResult::class, $onProgress); + } + + /** + * Get the server info received during initialization. + * + * @return array|null + */ + public function getServerInfo(): ?array + { + return $this->protocol->getSession()->getServerInfo(); + } + + /** + * Disconnect from the server. + */ + public function disconnect(): void + { + $this->transport?->close(); + $this->transport = null; + } + + /** + * Execute a request and return the typed result. + * + * @template T + * + * @param class-string|null $resultClass + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + * + * @return T|Response> + * + * @throws RequestException + */ + private function doRequest(object $request, ?string $resultClass = null, ?callable $onProgress = null): mixed + { + if (null !== $onProgress && $request instanceof Request) { + $progressToken = $this->generateProgressToken(); + $request = $request->withMeta(['progressToken' => $progressToken]); + } + + $fiber = new \Fiber(fn() => $this->protocol->request($request, $this->config->requestTimeout)); + + $response = $this->transport->runRequest($fiber, $onProgress); + + if ($response instanceof Error) { + throw RequestException::fromError($response); + } + + if (null === $resultClass) { + return $response; + } + + return $resultClass::fromArray($response->result); + } + + /** + * Generate a unique progress token for a request. + */ + private function generateProgressToken(): string + { + return 'prog-' . (++$this->progressTokenCounter); + } + + private function ensureConnected(): void + { + if (!$this->isConnected()) { + throw new ConnectionException('Client is not connected. Call connect() first.'); + } + } +} diff --git a/src/Client/Configuration.php b/src/Client/Configuration.php new file mode 100644 index 00000000..c328ba76 --- /dev/null +++ b/src/Client/Configuration.php @@ -0,0 +1,33 @@ + + */ +class Configuration +{ + public function __construct( + public readonly Implementation $clientInfo, + public readonly ClientCapabilities $capabilities, + public readonly string $protocolVersion = '2025-06-18', + public readonly int $initTimeout = 30, + public readonly int $requestTimeout = 120, + public readonly int $maxRetries = 3, + ) { + } +} diff --git a/src/Client/Handler/InternalProgressHandler.php b/src/Client/Handler/InternalProgressHandler.php new file mode 100644 index 00000000..0b9e4b33 --- /dev/null +++ b/src/Client/Handler/InternalProgressHandler.php @@ -0,0 +1,54 @@ + + * + * @internal + */ +class InternalProgressHandler implements NotificationHandlerInterface +{ + public function __construct( + private readonly ClientSessionInterface $session, + ) { + } + + public function supports(Notification $notification): bool + { + return $notification instanceof ProgressNotification; + } + + public function handle(Notification $notification): void + { + if (!$notification instanceof ProgressNotification) { + return; + } + + // Store progress data in session for transport to consume + $this->session->storeProgress( + (string) $notification->progressToken, + $notification->progress, + $notification->total, + $notification->message, + ); + } +} diff --git a/src/Client/Handler/LoggingNotificationHandler.php b/src/Client/Handler/LoggingNotificationHandler.php new file mode 100644 index 00000000..4e206c71 --- /dev/null +++ b/src/Client/Handler/LoggingNotificationHandler.php @@ -0,0 +1,42 @@ + + */ +class LoggingNotificationHandler implements NotificationHandlerInterface +{ + /** + * @param callable(LoggingMessageNotification): void $callback + */ + public function __construct( + private readonly mixed $callback, + ) { + } + + public function supports(Notification $notification): bool + { + return $notification instanceof LoggingMessageNotification; + } + + public function handle(Notification $notification): void + { + ($this->callback)($notification); + } +} diff --git a/src/Client/Handler/ProgressNotificationHandler.php b/src/Client/Handler/ProgressNotificationHandler.php new file mode 100644 index 00000000..16ca29c2 --- /dev/null +++ b/src/Client/Handler/ProgressNotificationHandler.php @@ -0,0 +1,42 @@ + + */ +class ProgressNotificationHandler implements NotificationHandlerInterface +{ + /** + * @param callable(ProgressNotification): void $callback + */ + public function __construct( + private readonly mixed $callback, + ) { + } + + public function supports(Notification $notification): bool + { + return $notification instanceof ProgressNotification; + } + + public function handle(Notification $notification): void + { + ($this->callback)($notification); + } +} diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php new file mode 100644 index 00000000..ef0b9a1b --- /dev/null +++ b/src/Client/Protocol.php @@ -0,0 +1,294 @@ + + */ +class Protocol +{ + private ?ClientTransportInterface $transport = null; + private MessageFactory $messageFactory; + private LoggerInterface $logger; + + /** + * @param NotificationHandlerInterface[] $notificationHandlers + * @param RequestHandlerInterface[] $requestHandlers + */ + public function __construct( + private readonly ClientSessionInterface $session, + private readonly Configuration $config, + private readonly array $notificationHandlers = [], + private readonly array $requestHandlers = [], + ?MessageFactory $messageFactory = null, + ?LoggerInterface $logger = null, + ) { + $this->messageFactory = $messageFactory ?? MessageFactory::make(); + $this->logger = $logger ?? new NullLogger(); + } + + /** + * Connect this protocol to a transport. + * + * Sets up message handling callbacks. + */ + public function connect(ClientTransportInterface $transport): void + { + $this->transport = $transport; + $transport->setSession($this->session); + $transport->onInitialize(fn() => $this->performInitialize()); + $transport->onMessage($this->processMessage(...)); + $transport->onError(fn(\Throwable $e) => $this->logger->error('Transport error', ['exception' => $e])); + + $this->logger->info('Protocol connected to transport', ['transport' => $transport::class]); + } + + /** + * Perform the MCP initialization handshake. + * + * Sends InitializeRequest and waits for response, then sends InitializedNotification. + * + * @return Response>|Error + */ + public function performInitialize(): Response|Error + { + $request = new InitializeRequest( + $this->config->protocolVersion, + $this->config->capabilities, + $this->config->clientInfo, + ); + + $response = $this->request($request, $this->config->initTimeout); + + if ($response instanceof Response) { + $this->session->setServerInfo($response->result); + $this->session->setInitialized(true); + + $this->sendNotification(new InitializedNotification()); + + $this->logger->info('Initialization complete', [ + 'server' => $response->result['serverInfo'] ?? null, + ]); + } + + return $response; + } + + /** + * Send a request to the server. + * + * If a response is immediately available (sync HTTP), returns it. + * Otherwise, suspends the Fiber and waits for the transport to resume it. + * + * @return Response>|Error + */ + public function request(Request $request, int $timeout): Response|Error + { + $requestId = $this->session->nextRequestId(); + $requestWithId = $request->withId($requestId); + + $this->logger->debug('Sending request', [ + 'id' => $requestId, + 'method' => $request::getMethod(), + ]); + + $encoded = json_encode($requestWithId, \JSON_THROW_ON_ERROR); + $this->session->queueOutgoing($encoded, ['type' => 'request']); + $this->session->addPendingRequest($requestId, $timeout); + + $this->flushOutgoing(); + + $immediate = $this->session->consumeResponse($requestId); + if (null !== $immediate) { + $this->logger->debug('Received immediate response', ['id' => $requestId]); + + return $immediate; + } + + $this->logger->debug('Suspending fiber for response', ['id' => $requestId]); + + return \Fiber::suspend([ + 'type' => 'await_response', + 'request_id' => $requestId, + 'timeout' => $timeout, + ]); + } + + /** + * Send a notification to the server (fire and forget). + */ + public function sendNotification(Notification $notification): void + { + $this->logger->debug('Sending notification', ['method' => $notification::getMethod()]); + + $encoded = json_encode($notification, \JSON_THROW_ON_ERROR); + $this->session->queueOutgoing($encoded, ['type' => 'notification']); + $this->flushOutgoing(); + } + + /** + * Process an incoming message from the server. + * + * Routes to appropriate handler based on message type. + */ + public function processMessage(string $input): void + { + $this->logger->debug('Received message', ['input' => $input]); + + try { + $messages = $this->messageFactory->create($input); + } catch (\JsonException $e) { + $this->logger->warning('Failed to parse message', ['exception' => $e]); + + return; + } + + foreach ($messages as $message) { + if ($message instanceof Response || $message instanceof Error) { + $this->handleResponse($message); + } elseif ($message instanceof Request) { + $this->handleServerRequest($message); + } elseif ($message instanceof Notification) { + $this->handleServerNotification($message); + } + } + } + + /** + * Handle a response from the server. + * + * This stores it in session. The transport will pick it up and resume the Fiber. + */ + private function handleResponse(Response|Error $response): void + { + $requestId = $response->getId(); + + $this->logger->debug('Handling response', ['id' => $requestId]); + + if ($response instanceof Response) { + $this->session->storeResponse($requestId, $response->jsonSerialize()); + } else { + $this->session->storeResponse($requestId, $response->jsonSerialize()); + } + } + + /** + * Handle a request from the server (e.g., sampling request). + */ + private function handleServerRequest(Request $request): void + { + $method = $request::getMethod(); + + $this->logger->debug('Received server request', [ + 'method' => $method, + 'id' => $request->getId(), + ]); + + foreach ($this->requestHandlers as $handler) { + if ($handler->supports($request)) { + try { + $result = $handler->handle($request); + + $response = new Response($request->getId(), $result); + $encoded = json_encode($response, \JSON_THROW_ON_ERROR); + $this->session->queueOutgoing($encoded, ['type' => 'response']); + $this->flushOutgoing(); + + return; + } catch (\Throwable $e) { + $this->logger->warning('Request handler failed', ['exception' => $e]); + + $error = Error::forInternalError($e->getMessage(), $request->getId()); + $encoded = json_encode($error, \JSON_THROW_ON_ERROR); + $this->session->queueOutgoing($encoded, ['type' => 'error']); + $this->flushOutgoing(); + + return; + } + } + } + + $error = Error::forMethodNotFound( + \sprintf('Client does not handle "%s" requests.', $method), + $request->getId() + ); + + $encoded = json_encode($error, \JSON_THROW_ON_ERROR); + $this->session->queueOutgoing($encoded, ['type' => 'error']); + $this->flushOutgoing(); + } + + /** + * Handle a notification from the server. + */ + private function handleServerNotification(Notification $notification): void + { + $method = $notification::getMethod(); + + $this->logger->debug('Received server notification', [ + 'method' => $method, + ]); + + foreach ($this->notificationHandlers as $handler) { + if ($handler->supports($notification)) { + try { + $handler->handle($notification); + } catch (\Throwable $e) { + $this->logger->warning('Notification handler failed', ['exception' => $e]); + } + + return; + } + } + } + + /** + * Flush any queued outgoing messages. + */ + private function flushOutgoing(): void + { + if (null === $this->transport) { + return; + } + + $messages = $this->session->consumeOutgoingMessages(); + foreach ($messages as $item) { + $this->transport->send($item['message'], $item['context']); + } + } + + public function getSession(): ClientSessionInterface + { + return $this->session; + } +} diff --git a/src/Client/Session/ClientSession.php b/src/Client/Session/ClientSession.php new file mode 100644 index 00000000..cbaf27b4 --- /dev/null +++ b/src/Client/Session/ClientSession.php @@ -0,0 +1,153 @@ + + */ +class ClientSession implements ClientSessionInterface +{ + private Uuid $id; + private int $requestIdCounter = 1; + private bool $initialized = false; + + /** @var array|null */ + private ?array $serverInfo = null; + + /** @var array */ + private array $pendingRequests = []; + + /** @var array> */ + private array $responses = []; + + /** @var array}> */ + private array $outgoingQueue = []; + + /** @var array */ + private array $progressUpdates = []; + + public function __construct(?Uuid $id = null) + { + $this->id = $id ?? Uuid::v4(); + } + + public function getId(): Uuid + { + return $this->id; + } + + public function nextRequestId(): int + { + return $this->requestIdCounter++; + } + + public function addPendingRequest(int $requestId, int $timeout): void + { + $this->pendingRequests[$requestId] = [ + 'request_id' => $requestId, + 'timestamp' => time(), + 'timeout' => $timeout, + ]; + } + + public function removePendingRequest(int $requestId): void + { + unset($this->pendingRequests[$requestId]); + } + + public function getPendingRequests(): array + { + return $this->pendingRequests; + } + + public function storeResponse(int $requestId, array $responseData): void + { + $this->responses[$requestId] = $responseData; + } + + public function consumeResponse(int $requestId): Response|Error|null + { + if (!isset($this->responses[$requestId])) { + return null; + } + + $data = $this->responses[$requestId]; + unset($this->responses[$requestId]); + $this->removePendingRequest($requestId); + + if (isset($data['error'])) { + return Error::fromArray($data); + } + + return Response::fromArray($data); + } + + public function queueOutgoing(string $message, array $context): void + { + $this->outgoingQueue[] = [ + 'message' => $message, + 'context' => $context, + ]; + } + + public function consumeOutgoingMessages(): array + { + $messages = $this->outgoingQueue; + $this->outgoingQueue = []; + + return $messages; + } + + public function setInitialized(bool $initialized): void + { + $this->initialized = $initialized; + } + + public function isInitialized(): bool + { + return $this->initialized; + } + + public function setServerInfo(array $serverInfo): void + { + $this->serverInfo = $serverInfo; + } + + public function getServerInfo(): ?array + { + return $this->serverInfo; + } + + public function storeProgress(string $token, float $progress, ?float $total, ?string $message): void + { + $this->progressUpdates[] = [ + 'token' => $token, + 'progress' => $progress, + 'total' => $total, + 'message' => $message, + ]; + } + + public function consumeProgressUpdates(): array + { + $updates = $this->progressUpdates; + $this->progressUpdates = []; + + return $updates; + } +} diff --git a/src/Client/Session/ClientSessionInterface.php b/src/Client/Session/ClientSessionInterface.php new file mode 100644 index 00000000..c926e3fa --- /dev/null +++ b/src/Client/Session/ClientSessionInterface.php @@ -0,0 +1,127 @@ + + */ +interface ClientSessionInterface +{ + /** + * Get the session ID. + */ + public function getId(): Uuid; + + /** + * Get the next request ID for outgoing requests. + */ + public function nextRequestId(): int; + + /** + * Add a pending request to track. + * + * @param int $requestId The request ID + * @param int $timeout Timeout in seconds + */ + public function addPendingRequest(int $requestId, int $timeout): void; + + /** + * Remove a pending request. + */ + public function removePendingRequest(int $requestId): void; + + /** + * Get all pending requests. + * + * @return array + */ + public function getPendingRequests(): array; + + /** + * Store a received response. + * + * @param int $requestId The request ID + * @param array $responseData The raw response data + */ + public function storeResponse(int $requestId, array $responseData): void; + + /** + * Check and consume a response for a request ID. + * + * @return Response>|Error|null + */ + public function consumeResponse(int $requestId): Response|Error|null; + + /** + * Queue an outgoing message. + * + * @param string $message JSON-encoded message + * @param array $context Message context + */ + public function queueOutgoing(string $message, array $context): void; + + /** + * Get and clear all queued outgoing messages. + * + * @return array}> + */ + public function consumeOutgoingMessages(): array; + + /** + * Set initialization state. + */ + public function setInitialized(bool $initialized): void; + + /** + * Check if session is initialized. + */ + public function isInitialized(): bool; + + /** + * Store server capabilities and info from initialization. + * + * @param array $serverInfo + */ + public function setServerInfo(array $serverInfo): void; + + /** + * Get stored server info. + * + * @return array|null + */ + public function getServerInfo(): ?array; + + /** + * Store progress data received from a notification. + * + * @param string $token The progress token + * @param float $progress Current progress value + * @param float|null $total Total progress value (if known) + * @param string|null $message Progress message + */ + public function storeProgress(string $token, float $progress, ?float $total, ?string $message): void; + + /** + * Consume all pending progress updates. + * + * @return array + */ + public function consumeProgressUpdates(): array; +} diff --git a/src/Client/Transport/BaseClientTransport.php b/src/Client/Transport/BaseClientTransport.php new file mode 100644 index 00000000..5229ed9c --- /dev/null +++ b/src/Client/Transport/BaseClientTransport.php @@ -0,0 +1,126 @@ + + */ +abstract class BaseClientTransport implements ClientTransportInterface +{ + /** @var callable(): mixed|null */ + protected $initializeCallback = null; + + /** @var callable(string): void|null */ + protected $messageCallback = null; + + /** @var callable(\Throwable): void|null */ + protected $errorCallback = null; + + /** @var callable(string): void|null */ + protected $closeCallback = null; + + + protected ?ClientSessionInterface $session = null; + protected LoggerInterface $logger; + + public function __construct(?LoggerInterface $logger = null) + { + $this->logger = $logger ?? new NullLogger(); + } + + public function onInitialize(callable $listener): void + { + $this->initializeCallback = $listener; + } + + public function onMessage(callable $listener): void + { + $this->messageCallback = $listener; + } + + public function onError(callable $listener): void + { + $this->errorCallback = $listener; + } + + public function onClose(callable $listener): void + { + $this->closeCallback = $listener; + } + + public function setSession(ClientSessionInterface $session): void + { + $this->session = $session; + } + + /** + * Perform initialization via the registered callback. + * + * @return mixed The result from the initialization callback + * + * @throws \RuntimeException If no initialize listener is registered + */ + protected function handleInitialize(): mixed + { + if (!\is_callable($this->initializeCallback)) { + throw new \RuntimeException('No initialize listener registered'); + } + + return ($this->initializeCallback)(); + } + + /** + * Handle an incoming message from the server. + */ + protected function handleMessage(string $message): void + { + if (\is_callable($this->messageCallback)) { + try { + ($this->messageCallback)($message); + } catch (\Throwable $e) { + $this->handleError($e); + } + } + } + + /** + * Handle a transport error. + */ + protected function handleError(\Throwable $error): void + { + $this->logger->error('Transport error', ['exception' => $error]); + + if (\is_callable($this->errorCallback)) { + ($this->errorCallback)($error); + } + } + + /** + * Handle connection close. + */ + protected function handleClose(string $reason): void + { + $this->logger->info('Transport closed', ['reason' => $reason]); + + if (\is_callable($this->closeCallback)) { + ($this->closeCallback)($reason); + } + } +} diff --git a/src/Client/Transport/ClientTransportInterface.php b/src/Client/Transport/ClientTransportInterface.php new file mode 100644 index 00000000..f7618a42 --- /dev/null +++ b/src/Client/Transport/ClientTransportInterface.php @@ -0,0 +1,115 @@ +|Error) + * @phpstan-type FiberResume (FiberReturn|null) + * @phpstan-type FiberSuspend array{type: 'await_response', request_id: int, timeout: int} + * @phpstan-type McpFiber \Fiber + * + * @author Kyrian Obikwelu + */ +interface ClientTransportInterface +{ + /** + * Connect to the MCP server and perform initialization handshake. + * + * This method blocks until: + * - Initialization completes successfully + * - Timeout is reached (throws TimeoutException) + * - Connection fails (throws ConnectionException) + * + * @param int $timeout Maximum time to wait for initialization (seconds) + * + * @throws \Mcp\Exception\TimeoutException + * @throws \Mcp\Exception\ConnectionException + */ + public function connectAndInitialize(int $timeout): void; + + /** + * Send a message to the server immediately. + * + * @param string $data JSON-encoded message + * @param array $context Message context (type, etc.) + */ + public function send(string $data, array $context): void; + + /** + * Run a request fiber to completion. + * + * The transport starts the fiber, runs its internal loop, and resumes + * the fiber when a response arrives or timeout occurs. + * + * During the loop, the transport checks session for progress data and + * executes the callback if provided. + * + * @param McpFiber $fiber The fiber to execute + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + * Optional callback for progress updates + * + * @return Response>|Error The response or error + */ + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error; + + /** + * Close the transport and clean up resources. + */ + public function close(): void; + + /** + * Register callback for initialization handshake. + * + * The callback should return a Fiber that performs the initialization. + * + * @param callable(): mixed $callback + */ + public function onInitialize(callable $callback): void; + + /** + * Register callback for incoming messages from server. + * + * @param callable(string $message): void $callback + */ + public function onMessage(callable $callback): void; + + /** + * Register callback for transport errors. + * + * @param callable(\Throwable $error): void $callback + */ + public function onError(callable $callback): void; + + /** + * Register callback for when connection closes. + * + * @param callable(string $reason): void $callback + */ + public function onClose(callable $callback): void; + + /** + * Set the client session for state management. + */ + public function setSession(ClientSessionInterface $session): void; + +} diff --git a/src/Client/Transport/HttpClientTransport.php b/src/Client/Transport/HttpClientTransport.php new file mode 100644 index 00000000..7080cc75 --- /dev/null +++ b/src/Client/Transport/HttpClientTransport.php @@ -0,0 +1,313 @@ + + */ +class HttpClientTransport extends BaseClientTransport +{ + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + private StreamFactoryInterface $streamFactory; + + private ?string $sessionId = null; + private bool $running = false; + + private ?\Fiber $activeFiber = null; + + /** @var (callable(float, ?float, ?string): void)|null */ + private $activeProgressCallback = null; + + /** @var StreamInterface|null Active SSE stream being read */ + private ?StreamInterface $activeStream = null; + + /** @var string Buffer for incomplete SSE data */ + private string $sseBuffer = ''; + + /** + * @param string $endpoint The MCP server endpoint URL + * @param array $headers Additional headers to send + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + */ + public function __construct( + private readonly string $endpoint, + private readonly array $headers = [], + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ?LoggerInterface $logger = null, + ) { + parent::__construct($logger); + + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function connectAndInitialize(int $timeout): void + { + $this->running = true; + + $this->activeFiber = new \Fiber(fn() => $this->handleInitialize()); + + $deadline = time() + $timeout; + $this->activeFiber->start(); + + while (!$this->activeFiber->isTerminated()) { + if (time() >= $deadline) { + $this->running = false; + throw new TimeoutException('Initialization timed out after ' . $timeout . ' seconds'); + } + $this->tick(); + } + + $result = $this->activeFiber->getReturn(); + $this->activeFiber = null; + + if ($result instanceof Error) { + $this->running = false; + throw new ConnectionException('Initialization failed: ' . $result->message); + } + + $this->logger->info('HTTP client connected and initialized', ['endpoint' => $this->endpoint]); + } + + public function send(string $data, array $context): void + { + $request = $this->requestFactory->createRequest('POST', $this->endpoint) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Accept', 'application/json, text/event-stream') + ->withBody($this->streamFactory->createStream($data)); + + if (null !== $this->sessionId) { + $request = $request->withHeader('Mcp-Session-Id', $this->sessionId); + } + + foreach ($this->headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + $this->logger->debug('Sending HTTP request', ['data' => $data]); + + try { + $response = $this->httpClient->sendRequest($request); + } catch (\Throwable $e) { + $this->handleError($e); + throw new ConnectionException('HTTP request failed: ' . $e->getMessage(), 0, $e); + } + + if ($response->hasHeader('Mcp-Session-Id')) { + $this->sessionId = $response->getHeaderLine('Mcp-Session-Id'); + $this->logger->debug('Received session ID', ['session_id' => $this->sessionId]); + } + + $contentType = $response->getHeaderLine('Content-Type'); + + if (str_contains($contentType, 'text/event-stream')) { + $this->activeStream = $response->getBody(); + $this->sseBuffer = ''; + } elseif (str_contains($contentType, 'application/json')) { + $body = $response->getBody()->getContents(); + if (!empty($body)) { + $this->handleMessage($body); + } + } + } + + /** + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + */ + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error + { + $this->activeFiber = $fiber; + $this->activeProgressCallback = $onProgress; + $fiber->start(); + + if ($fiber->isTerminated()) { + $this->activeFiber = null; + $this->activeProgressCallback = null; + $this->activeStream = null; + + return $fiber->getReturn(); + } + + while (!$fiber->isTerminated()) { + $this->tick(); + } + + $this->activeFiber = null; + $this->activeProgressCallback = null; + $this->activeStream = null; + + return $fiber->getReturn(); + } + + public function close(): void + { + $this->running = false; + + if (null !== $this->sessionId) { + try { + $request = $this->requestFactory->createRequest('DELETE', $this->endpoint) + ->withHeader('Mcp-Session-Id', $this->sessionId); + + foreach ($this->headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + $this->httpClient->sendRequest($request); + $this->logger->info('Session closed', ['session_id' => $this->sessionId]); + } catch (\Throwable $e) { + $this->logger->warning('Failed to close session', ['error' => $e->getMessage()]); + } + } + + $this->sessionId = null; + $this->activeStream = null; + $this->handleClose('Transport closed'); + } + + private function tick(): void + { + $this->processSSEStream(); + $this->processProgress(); + $this->tryResumeFiber(); + usleep(1000); // 1ms + } + + /** + * Read SSE data incrementally from active stream. + */ + private function processSSEStream(): void + { + if (null === $this->activeStream) { + return; + } + + if (!$this->activeStream->eof()) { + $chunk = $this->activeStream->read(4096); + if ('' !== $chunk) { + $this->sseBuffer .= $chunk; + } + } + + while (false !== ($pos = strpos($this->sseBuffer, "\n\n"))) { + $event = substr($this->sseBuffer, 0, $pos); + $this->sseBuffer = substr($this->sseBuffer, $pos + 2); + + if (!empty(trim($event))) { + $this->processSSEEvent($event); + } + } + + if ($this->activeStream->eof() && empty($this->sseBuffer)) { + $this->activeStream = null; + } + } + + /** + * Parse a single SSE event and handle the message. + */ + private function processSSEEvent(string $event): void + { + $data = ''; + + foreach (explode("\n", $event) as $line) { + if (str_starts_with($line, 'data:')) { + $data .= trim(substr($line, 5)); + } + } + + if (!empty($data)) { + $this->handleMessage($data); + } + } + + /** + * Process pending progress updates from session and execute callback. + */ + private function processProgress(): void + { + if (null === $this->activeProgressCallback || null === $this->session) { + return; + } + + $updates = $this->session->consumeProgressUpdates(); + + foreach ($updates as $update) { + try { + ($this->activeProgressCallback)( + $update['progress'], + $update['total'], + $update['message'], + ); + } catch (\Throwable $e) { + $this->logger->warning('Progress callback failed', ['exception' => $e]); + } + } + } + + private function tryResumeFiber(): void + { + if (null === $this->activeFiber || !$this->activeFiber->isSuspended()) { + return; + } + + if (null === $this->session) { + return; + } + + $pendingRequests = $this->session->getPendingRequests(); + + foreach ($pendingRequests as $pending) { + $requestId = $pending['request_id']; + $timestamp = $pending['timestamp']; + $timeout = $pending['timeout']; + + $response = $this->session->consumeResponse($requestId); + + if (null !== $response) { + $this->logger->debug('Resuming fiber with response', ['request_id' => $requestId]); + $this->activeFiber->resume($response); + + return; + } + + if (time() - $timestamp >= $timeout) { + $this->logger->warning('Request timed out', ['request_id' => $requestId]); + $error = Error::forInternalError('Request timed out', $requestId); + $this->activeFiber->resume($error); + + return; + } + } + } +} diff --git a/src/Client/Transport/StdioClientTransport.php b/src/Client/Transport/StdioClientTransport.php new file mode 100644 index 00000000..583cefc7 --- /dev/null +++ b/src/Client/Transport/StdioClientTransport.php @@ -0,0 +1,300 @@ + + */ +class StdioClientTransport extends BaseClientTransport +{ + /** @var resource|null */ + private $process = null; + + /** @var resource|null */ + private $stdin = null; + + /** @var resource|null */ + private $stdout = null; + + /** @var resource|null */ + private $stderr = null; + + private string $inputBuffer = ''; + private bool $running = false; + + private ?\Fiber $activeFiber = null; + + /** @var (callable(float, ?float, ?string): void)|null */ + private $activeProgressCallback = null; + + /** + * @param string $command The command to run + * @param array $args Command arguments + * @param string|null $cwd Working directory + * @param array|null $env Environment variables + */ + public function __construct( + private readonly string $command, + private readonly array $args = [], + private readonly ?string $cwd = null, + private readonly ?array $env = null, + ?LoggerInterface $logger = null, + ) { + parent::__construct($logger); + } + + public function connectAndInitialize(int $timeout): void + { + $this->spawnProcess(); + + $this->activeFiber = new \Fiber(fn() => $this->handleInitialize()); + + $deadline = time() + $timeout; + $this->activeFiber->start(); + + while (!$this->activeFiber->isTerminated()) { + if (time() >= $deadline) { + $this->close(); + throw new TimeoutException('Initialization timed out after ' . $timeout . ' seconds'); + } + $this->tick(); + } + + $result = $this->activeFiber->getReturn(); + $this->activeFiber = null; + + if ($result instanceof Error) { + $this->close(); + throw new ConnectionException('Initialization failed: ' . $result->message); + } + + $this->logger->info('Client connected and initialized'); + } + + public function send(string $data, array $context): void + { + if (null === $this->stdin || !\is_resource($this->stdin)) { + throw new ConnectionException('Process stdin not available'); + } + + fwrite($this->stdin, $data . "\n"); + fflush($this->stdin); + + $this->logger->debug('Sent message to server', ['data' => $data]); + } + + /** + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + */ + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error + { + $this->activeFiber = $fiber; + $this->activeProgressCallback = $onProgress; + $fiber->start(); + + while (!$fiber->isTerminated()) { + $this->tick(); + } + + $this->activeFiber = null; + $this->activeProgressCallback = null; + + return $fiber->getReturn(); + } + + public function close(): void + { + $this->running = false; + + if (\is_resource($this->stdin)) { + fclose($this->stdin); + $this->stdin = null; + } + if (\is_resource($this->stdout)) { + fclose($this->stdout); + $this->stdout = null; + } + if (\is_resource($this->stderr)) { + fclose($this->stderr); + $this->stderr = null; + } + if (\is_resource($this->process)) { + proc_terminate($this->process, 15); // SIGTERM + proc_close($this->process); + $this->process = null; + } + + $this->handleClose('Transport closed'); + } + + private function spawnProcess(): void + { + $descriptors = [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ]; + + $cmd = escapeshellcmd($this->command); + foreach ($this->args as $arg) { + $cmd .= ' ' . escapeshellarg($arg); + } + + $this->process = proc_open( + $cmd, + $descriptors, + $pipes, + $this->cwd, + $this->env + ); + + if (!\is_resource($this->process)) { + throw new ConnectionException('Failed to start process: ' . $cmd); + } + + $this->stdin = $pipes[0]; + $this->stdout = $pipes[1]; + $this->stderr = $pipes[2]; + + // Set non-blocking mode for reading + stream_set_blocking($this->stdout, false); + stream_set_blocking($this->stderr, false); + + $this->running = true; + $this->logger->info('Started MCP server process', ['command' => $cmd]); + } + + private function tick(): void + { + $this->processInput(); + + $this->processProgress(); + + $this->tryResumeFiber(); + + $this->processStderr(); + + usleep(1000); // 1ms + } + + /** + * Process pending progress updates from session and execute callback. + */ + private function processProgress(): void + { + if (null === $this->activeProgressCallback || null === $this->session) { + return; + } + + $updates = $this->session->consumeProgressUpdates(); + + foreach ($updates as $update) { + try { + ($this->activeProgressCallback)( + $update['progress'], + $update['total'], + $update['message'], + ); + } catch (\Throwable $e) { + $this->logger->warning('Progress callback failed', ['exception' => $e]); + } + } + } + + private function processInput(): void + { + if (null === $this->stdout || !\is_resource($this->stdout)) { + return; + } + + $data = fread($this->stdout, 8192); + if (false !== $data && '' !== $data) { + $this->inputBuffer .= $data; + } + + while (false !== ($pos = strpos($this->inputBuffer, "\n"))) { + $line = substr($this->inputBuffer, 0, $pos); + $this->inputBuffer = substr($this->inputBuffer, $pos + 1); + + $trimmed = trim($line); + if (!empty($trimmed)) { + $this->handleMessage($trimmed); + } + } + } + + private function tryResumeFiber(): void + { + if (null === $this->activeFiber || !$this->activeFiber->isSuspended()) { + return; + } + + if (null === $this->session) { + return; + } + + $pendingRequests = $this->session->getPendingRequests(); + + foreach ($pendingRequests as $pending) { + $requestId = $pending['request_id']; + $timestamp = $pending['timestamp']; + $timeout = $pending['timeout']; + + // Check if response arrived + $response = $this->session->consumeResponse($requestId); + + if (null !== $response) { + $this->logger->debug('Resuming fiber with response', ['request_id' => $requestId]); + $this->activeFiber->resume($response); + + return; + } + + // Check timeout + if (time() - $timestamp >= $timeout) { + $this->logger->warning('Request timed out', ['request_id' => $requestId]); + $error = Error::forInternalError('Request timed out', $requestId); + $this->activeFiber->resume($error); + + return; + } + } + } + + private function processStderr(): void + { + if (null === $this->stderr || !\is_resource($this->stderr)) { + return; + } + + $stderr = fread($this->stderr, 8192); + if (false !== $stderr && '' !== $stderr) { + $this->logger->debug('Server stderr', ['output' => trim($stderr)]); + } + } +} diff --git a/src/Exception/ConnectionException.php b/src/Exception/ConnectionException.php new file mode 100644 index 00000000..4e4527f5 --- /dev/null +++ b/src/Exception/ConnectionException.php @@ -0,0 +1,21 @@ + + */ +class ConnectionException extends Exception +{ +} diff --git a/src/Exception/RequestException.php b/src/Exception/RequestException.php new file mode 100644 index 00000000..44b8a91f --- /dev/null +++ b/src/Exception/RequestException.php @@ -0,0 +1,40 @@ + + */ +class RequestException extends Exception +{ + private ?Error $error; + + public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null, ?Error $error = null) + { + parent::__construct($message, $code, $previous); + $this->error = $error; + } + + public static function fromError(Error $error): self + { + return new self($error->message, $error->code, null, $error); + } + + public function getError(): ?Error + { + return $this->error; + } +} diff --git a/src/Exception/TimeoutException.php b/src/Exception/TimeoutException.php new file mode 100644 index 00000000..8fa5c74a --- /dev/null +++ b/src/Exception/TimeoutException.php @@ -0,0 +1,21 @@ + + */ +class TimeoutException extends Exception +{ +} diff --git a/src/Handler/NotificationHandlerInterface.php b/src/Handler/NotificationHandlerInterface.php new file mode 100644 index 00000000..c9cc7483 --- /dev/null +++ b/src/Handler/NotificationHandlerInterface.php @@ -0,0 +1,35 @@ + + */ +interface NotificationHandlerInterface +{ + /** + * Check if this handler supports the given notification. + */ + public function supports(Notification $notification): bool; + + /** + * Handle the notification. + */ + public function handle(Notification $notification): void; +} diff --git a/src/Handler/RequestHandlerInterface.php b/src/Handler/RequestHandlerInterface.php new file mode 100644 index 00000000..58c9d841 --- /dev/null +++ b/src/Handler/RequestHandlerInterface.php @@ -0,0 +1,39 @@ + + */ +interface RequestHandlerInterface +{ + /** + * Check if this handler supports the given request. + */ + public function supports(Request $request): bool; + + /** + * Handle the request and return the result. + * + * @return TResult + */ + public function handle(Request $request): mixed; +} diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index ff83bbec..f85f4788 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -105,9 +105,15 @@ private function processFiber(): void $pendingRequests = $this->getPendingRequests($this->sessionId); if (empty($pendingRequests)) { + // Flush any queued messages before resuming (e.g., notifications from previous yield) + $this->flushOutgoingMessages(); + $yielded = $this->sessionFiber->resume(); $this->handleFiberYield($yielded, $this->sessionId); + // Flush newly queued messages (like notifications) before returning + $this->flushOutgoingMessages(); + return; } @@ -121,6 +127,7 @@ private function processFiber(): void if (null !== $response) { $yielded = $this->sessionFiber->resume($response); $this->handleFiberYield($yielded, $this->sessionId); + $this->flushOutgoingMessages(); return; } @@ -129,6 +136,7 @@ private function processFiber(): void $error = Error::forInternalError('Request timed out', $requestId); $yielded = $this->sessionFiber->resume($error); $this->handleFiberYield($yielded, $this->sessionId); + $this->flushOutgoingMessages(); return; } @@ -162,7 +170,8 @@ private function flushOutgoingMessages(): void private function writeLine(string $payload): void { - fwrite($this->output, $payload.\PHP_EOL); + fwrite($this->output, $payload . \PHP_EOL); + fflush($this->output); } public function close(): void From a65e37c024dd36ab85ce96a8ef91f15380189f2a Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 20 Dec 2025 10:38:26 +0100 Subject: [PATCH 02/21] refactor(examples): reorganize examples into server/ and client/ folders --- composer.json | 2 +- examples/client/README.md | 6 +++--- examples/client/http_client_communication.php | 2 +- examples/client/http_discovery_calculator.php | 2 +- examples/client/stdio_client_communication.php | 2 +- examples/client/stdio_discovery_calculator.php | 2 +- examples/server/README.md | 8 ++++---- examples/server/bootstrap.php | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/composer.json b/composer.json index b4ea9c34..eb66fcf0 100644 --- a/composer.json +++ b/composer.json @@ -75,4 +75,4 @@ }, "sort-packages": true } -} +} \ No newline at end of file diff --git a/examples/client/README.md b/examples/client/README.md index 0e64f63e..afdefb32 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -7,7 +7,7 @@ These examples demonstrate how to use the MCP PHP Client SDK. Connects to an MCP server running as a child process: ```bash -php examples/client/stdio_example.php +php examples/client/stdio_discovery_calculator.php ``` ## HTTP Client @@ -19,9 +19,9 @@ Connects to an MCP server over HTTP: php -S localhost:8080 examples/http-discovery-userprofile/server.php # Then run the client -php examples/client/http_example.php +php examples/client/http_discovery_userprofile.php ``` ## Requirements -Both examples require the server examples to be available. The STDIO example spawns the discovery-calculator server, while the HTTP example connects to a running HTTP server. +All examples require the server examples to be available. The STDIO examples spawn the server process, while the HTTP examples connect to a running HTTP server. diff --git a/examples/client/http_client_communication.php b/examples/client/http_client_communication.php index 59b12e27..d286e7b3 100644 --- a/examples/client/http_client_communication.php +++ b/examples/client/http_client_communication.php @@ -7,7 +7,7 @@ * (logging and progress notifications) via HTTP transport. * * Usage: - * 1. Start the server: php -S localhost:8000 examples/client-communication/server.php + * 1. Start the server: php -S localhost:8000 examples/server/client-communication/server.php * 2. Run this script: php examples/client/http_client_communication.php */ diff --git a/examples/client/http_discovery_calculator.php b/examples/client/http_discovery_calculator.php index 0c97e785..2c91d6c5 100644 --- a/examples/client/http_discovery_calculator.php +++ b/examples/client/http_discovery_calculator.php @@ -9,7 +9,7 @@ * Usage: php examples/client/http_discovery_calculator.php * * Before running, start an HTTP MCP server: - * php -S localhost:8080 examples/http-discovery-calculator/server.php + * php -S localhost:8080 examples/server/http-discovery-calculator/server.php */ declare(strict_types=1); diff --git a/examples/client/stdio_client_communication.php b/examples/client/stdio_client_communication.php index 51dd3c1c..b7088864 100644 --- a/examples/client/stdio_client_communication.php +++ b/examples/client/stdio_client_communication.php @@ -30,7 +30,7 @@ $transport = new StdioClientTransport( command: 'php', - args: [__DIR__ . '/../client-communication/server.php'], + args: [__DIR__ . '/../server/client-communication/server.php'], ); try { diff --git a/examples/client/stdio_discovery_calculator.php b/examples/client/stdio_discovery_calculator.php index 4e9b5d94..0ccc614e 100644 --- a/examples/client/stdio_discovery_calculator.php +++ b/examples/client/stdio_discovery_calculator.php @@ -26,7 +26,7 @@ $transport = new StdioClientTransport( command: 'php', - args: [__DIR__ . '/../discovery-calculator/server.php'], + args: [__DIR__ . '/../server/discovery-calculator/server.php'], ); try { diff --git a/examples/server/README.md b/examples/server/README.md index 27874d71..a9326395 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -8,10 +8,10 @@ The bootstrapping of the example will choose the used transport based on the SAP For running an example, you execute the `server.php` like this: ```bash # For using the STDIO transport: -php examples/discovery-calculator/server.php +php examples/server/discovery-calculator/server.php # For using the Streamable HTTP transport: -php -S localhost:8000 examples/discovery-userprofile/server.php +php -S localhost:8000 examples/server/discovery-userprofile/server.php ``` You will see debug outputs to help you understand what is happening. @@ -19,7 +19,7 @@ You will see debug outputs to help you understand what is happening. Run with Inspector: ```bash -npx @modelcontextprotocol/inspector php examples/discovery-calculator/server.php +npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php ``` ## Debugging @@ -30,5 +30,5 @@ directory. With the Inspector you can set the environment variables like this: ```bash -npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/discovery-calculator/server.php +npx @modelcontextprotocol/inspector -e DEBUG=1 -e FILE_LOG=1 php examples/server/discovery-calculator/server.php ``` diff --git a/examples/server/bootstrap.php b/examples/server/bootstrap.php index a9110317..c4dead70 100644 --- a/examples/server/bootstrap.php +++ b/examples/server/bootstrap.php @@ -55,7 +55,7 @@ function shutdown(ResponseInterface|int $result): never function logger(): LoggerInterface { return new class extends AbstractLogger { - public function log($level, Stringable|string $message, array $context = []): void + public function log($level, $message, array $context = []): void { $debug = $_SERVER['DEBUG'] ?? false; From dac56bfcca636b1c658b19c94a4f325c5810c3bc Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 20 Dec 2025 22:03:16 +0100 Subject: [PATCH 03/21] feat: add SamplingRequestHandler for handling server sampling requests --- examples/client/http_client_communication.php | 58 ++++++++++++++++--- .../client/stdio_client_communication.php | 56 ++++++++++++++++-- .../server/client-communication/server.php | 1 - src/Client/Builder.php | 30 ---------- src/Client/Client.php | 5 +- .../Handler/InternalProgressHandler.php | 54 ----------------- .../Handler/ProgressNotificationHandler.php | 23 ++++++-- src/Client/Handler/SamplingRequestHandler.php | 55 ++++++++++++++++++ .../Request/CreateSamplingMessageRequest.php | 20 ++++++- 9 files changed, 193 insertions(+), 109 deletions(-) delete mode 100644 src/Client/Handler/InternalProgressHandler.php create mode 100644 src/Client/Handler/SamplingRequestHandler.php diff --git a/examples/client/http_client_communication.php b/examples/client/http_client_communication.php index d286e7b3..8d6f80ac 100644 --- a/examples/client/http_client_communication.php +++ b/examples/client/http_client_communication.php @@ -3,8 +3,10 @@ /** * HTTP Client Communication Example * - * This example demonstrates SSE streaming with server-to-client communication - * (logging and progress notifications) via HTTP transport. + * This example demonstrates server-to-client communication over HTTP: + * - Logging notifications + * - Progress notifications (via SSE streaming) + * - Sampling requests (mocked LLM response) * * Usage: * 1. Start the server: php -S localhost:8000 examples/server/client-communication/server.php @@ -17,19 +19,42 @@ use Mcp\Client\Client; use Mcp\Client\Handler\LoggingNotificationHandler; +use Mcp\Client\Handler\SamplingRequestHandler; use Mcp\Client\Transport\HttpClientTransport; +use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\Role; use Mcp\Schema\Notification\LoggingMessageNotification; +use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Result\CreateSamplingMessageResult; -$endpoint = 'http://localhost:8000'; +$endpoint = 'http://127.0.0.1:8000'; + +$loggingNotificationHandler = new LoggingNotificationHandler(function (LoggingMessageNotification $n) { + echo "[LOG {$n->level->value}] {$n->data}\n"; +}); + +$samplingRequestHandler = new SamplingRequestHandler(function (CreateSamplingMessageRequest $request): CreateSamplingMessageResult { + echo "[SAMPLING] Server requested LLM sampling (max {$request->maxTokens} tokens)\n"; + + $mockResponse = "Based on the incident analysis, I recommend: 1) Activate the on-call team, " . + "2) Isolate affected systems, 3) Begin root cause analysis, 4) Prepare stakeholder communication."; + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($mockResponse), + model: 'mock-gpt-4', + stopReason: 'end_turn', + ); +}); $client = Client::builder() ->setClientInfo('HTTP Client Communication Test', '1.0.0') ->setInitTimeout(30) - ->setRequestTimeout(60) - ->addNotificationHandler(new LoggingNotificationHandler(function (LoggingMessageNotification $n) { - echo "[LOG {$n->level->value}] {$n->data}\n"; - })) + ->setRequestTimeout(120) + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addNotificationHandler($loggingNotificationHandler) + ->addRequestHandler($samplingRequestHandler) ->build(); $transport = new HttpClientTransport(endpoint: $endpoint); @@ -64,8 +89,27 @@ echo $content->text . "\n"; } } + + echo "\nCalling 'coordinate_incident_response'...\n\n"; + $result = $client->callTool( + name: 'coordinate_incident_response', + arguments: ['incidentTitle' => 'Database connection pool exhausted'], + onProgress: function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; + echo "[PROGRESS {$percent}%] {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text . "\n"; + } + } + } catch (\Throwable $e) { echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString() . "\n"; } finally { $client->disconnect(); } diff --git a/examples/client/stdio_client_communication.php b/examples/client/stdio_client_communication.php index b7088864..47115994 100644 --- a/examples/client/stdio_client_communication.php +++ b/examples/client/stdio_client_communication.php @@ -3,8 +3,10 @@ /** * STDIO Client Communication Example * - * This example demonstrates server-to-client communication (logging and progress - * notifications) via STDIO transport, with per-request progress callbacks. + * This example demonstrates server-to-client communication: + * - Logging notifications + * - Progress notifications + * - Sampling requests (mocked LLM response) * * Usage: php examples/client/stdio_client_communication.php */ @@ -15,17 +17,40 @@ use Mcp\Client\Client; use Mcp\Client\Handler\LoggingNotificationHandler; +use Mcp\Client\Handler\SamplingRequestHandler; use Mcp\Client\Transport\StdioClientTransport; +use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\Role; use Mcp\Schema\Notification\LoggingMessageNotification; +use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Result\CreateSamplingMessageResult; + +$loggingNotificationHandler = new LoggingNotificationHandler(function (LoggingMessageNotification $n) { + echo "[LOG {$n->level->value}] {$n->data}\n"; +}); + +$samplingRequestHandler = new SamplingRequestHandler(function (CreateSamplingMessageRequest $request): CreateSamplingMessageResult { + echo "[SAMPLING] Server requested LLM sampling (max {$request->maxTokens} tokens)\n"; + + $mockResponse = "Based on the incident analysis, I recommend: 1) Activate the on-call team, " . + "2) Isolate affected systems, 3) Begin root cause analysis, 4) Prepare stakeholder communication."; + + return new CreateSamplingMessageResult( + role: Role::Assistant, + content: new TextContent($mockResponse), + model: 'mock-gpt-4', + stopReason: 'end_turn', + ); +}); $client = Client::builder() ->setClientInfo('STDIO Client Communication Test', '1.0.0') ->setInitTimeout(30) - ->setRequestTimeout(60) - ->addNotificationHandler(new LoggingNotificationHandler(function (LoggingMessageNotification $n) { - echo "[LOG {$n->level->value}] {$n->data}\n"; - })) + ->setRequestTimeout(120) + ->setCapabilities(new ClientCapabilities(sampling: true)) + ->addNotificationHandler($loggingNotificationHandler) + ->addRequestHandler($samplingRequestHandler) ->build(); $transport = new StdioClientTransport( @@ -63,8 +88,27 @@ echo $content->text . "\n"; } } + + echo "\nCalling 'coordinate_incident_response'...\n\n"; + $result = $client->callTool( + name: 'coordinate_incident_response', + arguments: ['incidentTitle' => 'Database connection pool exhausted'], + onProgress: function (float $progress, ?float $total, ?string $message) { + $percent = $total > 0 ? round(($progress / $total) * 100) : '?'; + echo "[PROGRESS {$percent}%] {$message}\n"; + } + ); + + echo "\nResult:\n"; + foreach ($result->content as $content) { + if ($content instanceof TextContent) { + echo $content->text . "\n"; + } + } + } catch (\Throwable $e) { echo "Error: {$e->getMessage()}\n"; + echo $e->getTraceAsString() . "\n"; } finally { $client->disconnect(); } diff --git a/examples/server/client-communication/server.php b/examples/server/client-communication/server.php index cc8c2f37..29d45eb4 100644 --- a/examples/server/client-communication/server.php +++ b/examples/server/client-communication/server.php @@ -1,4 +1,3 @@ -#!/usr/bin/env php capabilities = new ClientCapabilities( - roots: true, - rootsListChanged: $listChanged, - sampling: $this->capabilities?->sampling, - experimental: $this->capabilities?->experimental, - ); - - return $this; - } - - /** - * Enable sampling capability. - */ - public function withSampling(): self - { - $this->capabilities = new ClientCapabilities( - roots: $this->capabilities?->roots, - rootsListChanged: $this->capabilities?->rootsListChanged, - sampling: true, - experimental: $this->capabilities?->experimental, - ); - - return $this; - } - /** * Set initialization timeout in seconds. */ diff --git a/src/Client/Client.php b/src/Client/Client.php index 46c342ce..2b4d80fb 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -11,7 +11,7 @@ namespace Mcp\Client; -use Mcp\Client\Handler\InternalProgressHandler; +use Mcp\Client\Handler\ProgressNotificationHandler; use Mcp\Client\Session\ClientSession; use Mcp\Client\Session\ClientSessionInterface; use Mcp\Client\Transport\ClientTransportInterface; @@ -66,9 +66,8 @@ public function __construct( ) { $this->session = new ClientSession(); - // Auto-register internal progress handler to dispatch per-request callbacks $allNotificationHandlers = [ - new InternalProgressHandler($this->session), + new ProgressNotificationHandler($this->session), ...$notificationHandlers, ]; diff --git a/src/Client/Handler/InternalProgressHandler.php b/src/Client/Handler/InternalProgressHandler.php deleted file mode 100644 index 0b9e4b33..00000000 --- a/src/Client/Handler/InternalProgressHandler.php +++ /dev/null @@ -1,54 +0,0 @@ - - * - * @internal - */ -class InternalProgressHandler implements NotificationHandlerInterface -{ - public function __construct( - private readonly ClientSessionInterface $session, - ) { - } - - public function supports(Notification $notification): bool - { - return $notification instanceof ProgressNotification; - } - - public function handle(Notification $notification): void - { - if (!$notification instanceof ProgressNotification) { - return; - } - - // Store progress data in session for transport to consume - $this->session->storeProgress( - (string) $notification->progressToken, - $notification->progress, - $notification->total, - $notification->message, - ); - } -} diff --git a/src/Client/Handler/ProgressNotificationHandler.php b/src/Client/Handler/ProgressNotificationHandler.php index 16ca29c2..bda2285c 100644 --- a/src/Client/Handler/ProgressNotificationHandler.php +++ b/src/Client/Handler/ProgressNotificationHandler.php @@ -11,22 +11,24 @@ namespace Mcp\Client\Handler; +use Mcp\Client\Session\ClientSessionInterface; use Mcp\Handler\NotificationHandlerInterface; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\Notification\ProgressNotification; /** - * Handler for progress notifications from the server. + * Internal handlerc for progress notifications. + * + * Writes progress data to session for transport to consume and execute callbacks. * * @author Kyrian Obikwelu + * + * @internal */ class ProgressNotificationHandler implements NotificationHandlerInterface { - /** - * @param callable(ProgressNotification): void $callback - */ public function __construct( - private readonly mixed $callback, + private readonly ClientSessionInterface $session, ) { } @@ -37,6 +39,15 @@ public function supports(Notification $notification): bool public function handle(Notification $notification): void { - ($this->callback)($notification); + if (!$notification instanceof ProgressNotification) { + return; + } + + $this->session->storeProgress( + (string) $notification->progressToken, + $notification->progress, + $notification->total, + $notification->message, + ); } } diff --git a/src/Client/Handler/SamplingRequestHandler.php b/src/Client/Handler/SamplingRequestHandler.php new file mode 100644 index 00000000..d3a592e1 --- /dev/null +++ b/src/Client/Handler/SamplingRequestHandler.php @@ -0,0 +1,55 @@ +> + * + * @author Kyrian Obikwelu + */ +class SamplingRequestHandler implements RequestHandlerInterface +{ + /** + * @param callable(CreateSamplingMessageRequest): CreateSamplingMessageResult $callback + */ + public function __construct( + private readonly mixed $callback, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof CreateSamplingMessageRequest; + } + + /** + * @return array + */ + public function handle(Request $request): array + { + assert($request instanceof CreateSamplingMessageRequest); + + $result = ($this->callback)($request); + + return $result->jsonSerialize(); + } +} diff --git a/src/Schema/Request/CreateSamplingMessageRequest.php b/src/Schema/Request/CreateSamplingMessageRequest.php index 99aae118..3014405e 100644 --- a/src/Schema/Request/CreateSamplingMessageRequest.php +++ b/src/Schema/Request/CreateSamplingMessageRequest.php @@ -74,17 +74,33 @@ protected static function fromParams(?array $params): static throw new InvalidArgumentException('Missing or invalid "maxTokens" parameter for sampling/createMessage.'); } + $messages = []; + foreach ($params['messages'] as $messageData) { + if ($messageData instanceof SamplingMessage) { + $messages[] = $messageData; + } elseif (\is_array($messageData)) { + $messages[] = SamplingMessage::fromArray($messageData); + } else { + throw new InvalidArgumentException('Invalid message format in sampling/createMessage.'); + } + } + $preferences = null; if (isset($params['preferences'])) { $preferences = ModelPreferences::fromArray($params['preferences']); } + $includeContext = null; + if (isset($params['includeContext']) && \is_string($params['includeContext'])) { + $includeContext = SamplingContext::tryFrom($params['includeContext']); + } + return new self( - $params['messages'], + $messages, $params['maxTokens'], $preferences, $params['systemPrompt'] ?? null, - $params['includeContext'] ?? null, + $includeContext, $params['temperature'] ?? null, $params['stopSequences'] ?? null, $params['metadata'] ?? null, From accfaf6e7169aef3269142ba4e4690b38d07464f Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 20 Dec 2025 22:11:00 +0100 Subject: [PATCH 04/21] feat: add setLoggingLevel method to control server logging verbosity --- src/Client/Client.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Client/Client.php b/src/Client/Client.php index 2b4d80fb..b918ad29 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -19,6 +19,7 @@ use Mcp\Exception\RequestException; use Mcp\Handler\NotificationHandlerInterface; use Mcp\Handler\RequestHandlerInterface; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; @@ -30,6 +31,7 @@ use Mcp\Schema\Request\ListToolsRequest; use Mcp\Schema\Request\PingRequest; use Mcp\Schema\Request\ReadResourceRequest; +use Mcp\Schema\Request\SetLogLevelRequest; use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Result\GetPromptResult; use Mcp\Schema\Result\ListPromptsResult; @@ -219,6 +221,15 @@ public function getPrompt( return $this->doRequest($request, GetPromptResult::class, $onProgress); } + /** + * Set the minimum logging level for server notifications. + */ + public function setLoggingLevel(LoggingLevel $level): void + { + $this->ensureConnected(); + $this->doRequest(new SetLogLevelRequest($level)); + } + /** * Get the server info received during initialization. * From cb02c2e9238f1416fcc537f657e725958b0b6bac Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 20 Dec 2025 22:36:22 +0100 Subject: [PATCH 05/21] refactor: make progress token dependent on request ID --- src/Client/Client.php | 32 ++++++++++---------------------- src/Client/Protocol.php | 12 +++++++----- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/src/Client/Client.php b/src/Client/Client.php index b918ad29..283852fb 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -54,7 +54,6 @@ class Client private Protocol $protocol; private ClientSessionInterface $session; private ?ClientTransportInterface $transport = null; - private int $progressTokenCounter = 0; /** * @param NotificationHandlerInterface[] $notificationHandlers @@ -143,11 +142,8 @@ public function listTools(?string $cursor = null): ListToolsResult * Optional callback for progress updates. If provided, a progress token * is automatically generated and attached to the request. */ - public function callTool( - string $name, - array $arguments = [], - ?callable $onProgress = null, - ): CallToolResult { + public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult + { $this->ensureConnected(); $request = new CallToolRequest($name, $arguments); @@ -209,11 +205,8 @@ public function listPrompts(?string $cursor = null): ListPromptsResult * @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 { + public function getPrompt(string $name, array $arguments = [], ?callable $onProgress = null): GetPromptResult + { $this->ensureConnected(); $request = new GetPromptRequest($name, $arguments); @@ -261,10 +254,13 @@ public function disconnect(): void * * @throws RequestException */ - private function doRequest(object $request, ?string $resultClass = null, ?callable $onProgress = null): mixed + private function doRequest(Request $request, ?string $resultClass = null, ?callable $onProgress = null): mixed { - if (null !== $onProgress && $request instanceof Request) { - $progressToken = $this->generateProgressToken(); + $requestId = $this->session->nextRequestId(); + $request = $request->withId($requestId); + + if (null !== $onProgress) { + $progressToken = 'prog-' . $requestId; $request = $request->withMeta(['progressToken' => $progressToken]); } @@ -283,14 +279,6 @@ private function doRequest(object $request, ?string $resultClass = null, ?callab return $resultClass::fromArray($response->result); } - /** - * Generate a unique progress token for a request. - */ - private function generateProgressToken(): string - { - return 'prog-' . (++$this->progressTokenCounter); - } - private function ensureConnected(): void { if (!$this->isConnected()) { diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index ef0b9a1b..d1cf409a 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -66,7 +66,7 @@ public function connect(ClientTransportInterface $transport): void { $this->transport = $transport; $transport->setSession($this->session); - $transport->onInitialize(fn() => $this->performInitialize()); + $transport->onInitialize($this->initialize(...)); $transport->onMessage($this->processMessage(...)); $transport->onError(fn(\Throwable $e) => $this->logger->error('Transport error', ['exception' => $e])); @@ -80,7 +80,7 @@ public function connect(ClientTransportInterface $transport): void * * @return Response>|Error */ - public function performInitialize(): Response|Error + public function initialize(): Response|Error { $request = new InitializeRequest( $this->config->protocolVersion, @@ -88,6 +88,9 @@ public function performInitialize(): Response|Error $this->config->clientInfo, ); + $requestId = $this->session->nextRequestId(); + $request = $request->withId($requestId); + $response = $this->request($request, $this->config->initTimeout); if ($response instanceof Response) { @@ -114,15 +117,14 @@ public function performInitialize(): Response|Error */ public function request(Request $request, int $timeout): Response|Error { - $requestId = $this->session->nextRequestId(); - $requestWithId = $request->withId($requestId); + $requestId = $request->getId(); $this->logger->debug('Sending request', [ 'id' => $requestId, 'method' => $request::getMethod(), ]); - $encoded = json_encode($requestWithId, \JSON_THROW_ON_ERROR); + $encoded = json_encode($request, \JSON_THROW_ON_ERROR); $this->session->queueOutgoing($encoded, ['type' => 'request']); $this->session->addPendingRequest($requestId, $timeout); From 393737ae0b0b8def01b94e1061d7656088fb0b38 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 20 Dec 2025 22:42:31 +0100 Subject: [PATCH 06/21] docs: Update server IP in usage instructions and add notes on sampling server requirements. --- examples/client/http_client_communication.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/examples/client/http_client_communication.php b/examples/client/http_client_communication.php index 8d6f80ac..73ece1c7 100644 --- a/examples/client/http_client_communication.php +++ b/examples/client/http_client_communication.php @@ -9,8 +9,15 @@ * - Sampling requests (mocked LLM response) * * Usage: - * 1. Start the server: php -S localhost:8000 examples/server/client-communication/server.php + * 1. Start the server: php -S 127.0.0.1:8000 examples/server/client-communication/server.php * 2. Run this script: php examples/client/http_client_communication.php + * + * Note: PHP's built-in server works for listing tools, calling tools, and receiving + * progress/logging notifications. However, sampling requires a concurrent-capable server + * (e.g., Symfony CLI, PHP-FPM) since the server must process the client's sampling + * response while the original tool request is still pending. + * + * Eg. symfony serve --passthru=examples/server/client-communication/server.php --no-tls */ declare(strict_types=1); From 68b2ecd4f60a73a2d872e950d6b94235fa1f668b Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 20 Dec 2025 22:52:19 +0100 Subject: [PATCH 07/21] feat: add complete() method for completion/complete requests --- src/Client/Client.php | 20 +++++++++++++++++++ .../Result/CompletionCompleteResult.php | 14 +++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/Client/Client.php b/src/Client/Client.php index 283852fb..f915e524 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -23,7 +23,9 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\PromptReference; use Mcp\Schema\Request\CallToolRequest; +use Mcp\Schema\Request\CompletionCompleteRequest; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Request\ListPromptsRequest; use Mcp\Schema\Request\ListResourcesRequest; @@ -32,7 +34,9 @@ use Mcp\Schema\Request\PingRequest; use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Schema\Request\SetLogLevelRequest; +use Mcp\Schema\ResourceReference; use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\CompletionCompleteResult; use Mcp\Schema\Result\GetPromptResult; use Mcp\Schema\Result\ListPromptsResult; use Mcp\Schema\Result\ListResourcesResult; @@ -223,6 +227,22 @@ public function setLoggingLevel(LoggingLevel $level): void $this->doRequest(new SetLogLevelRequest($level)); } + /** + * 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 + { + $this->ensureConnected(); + + return $this->doRequest( + new CompletionCompleteRequest($ref, $argument), + CompletionCompleteResult::class, + ); + } + /** * Get the server info received during initialization. * diff --git a/src/Schema/Result/CompletionCompleteResult.php b/src/Schema/Result/CompletionCompleteResult.php index 813e8abd..d7fa4c4b 100644 --- a/src/Schema/Result/CompletionCompleteResult.php +++ b/src/Schema/Result/CompletionCompleteResult.php @@ -60,4 +60,18 @@ public function jsonSerialize(): array return ['completion' => $completion]; } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $completion = $data['completion'] ?? []; + + return new self( + $completion['values'] ?? [], + $completion['total'] ?? null, + $completion['hasMore'] ?? null, + ); + } } From a9e33f3828834cb28bbe6549dac401d4fcc33cc5 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 21 Dec 2025 20:11:25 +0100 Subject: [PATCH 08/21] refactor: properly parse and type initialize result --- examples/client/http_client_communication.php | 2 +- examples/client/http_discovery_calculator.php | 6 ++--- .../client/stdio_client_communication.php | 2 +- .../client/stdio_discovery_calculator.php | 4 ++-- src/Client/Client.php | 16 ++++++++++--- src/Client/Protocol.php | 7 ++++-- src/Client/Session/ClientSession.php | 20 ++++++++++++---- src/Client/Session/ClientSessionInterface.php | 23 ++++++++++++------- 8 files changed, 55 insertions(+), 25 deletions(-) diff --git a/examples/client/http_client_communication.php b/examples/client/http_client_communication.php index 73ece1c7..c9894025 100644 --- a/examples/client/http_client_communication.php +++ b/examples/client/http_client_communication.php @@ -71,7 +71,7 @@ $client->connect($transport); $serverInfo = $client->getServerInfo(); - echo "Connected to: " . ($serverInfo['serverInfo']['name'] ?? 'unknown') . "\n\n"; + echo "Connected to: " . ($serverInfo?->name ?? 'unknown') . "\n\n"; echo "Available tools:\n"; $toolsResult = $client->listTools(); diff --git a/examples/client/http_discovery_calculator.php b/examples/client/http_discovery_calculator.php index 2c91d6c5..283c1cdc 100644 --- a/examples/client/http_discovery_calculator.php +++ b/examples/client/http_discovery_calculator.php @@ -35,8 +35,8 @@ echo "Connected! Server info:\n"; $serverInfo = $client->getServerInfo(); - echo " Name: " . ($serverInfo['serverInfo']['name'] ?? 'unknown') . "\n"; - echo " Version: " . ($serverInfo['serverInfo']['version'] ?? 'unknown') . "\n\n"; + echo " Name: " . ($serverInfo?->name ?? 'unknown') . "\n"; + echo " Version: " . ($serverInfo?->version ?? 'unknown') . "\n\n"; echo "Available tools:\n"; $toolsResult = $client->listTools(); @@ -58,7 +58,7 @@ echo " - {$prompt->name}: {$prompt->description}\n"; } echo "\n"; - + } catch (\Throwable $e) { echo "Error: {$e->getMessage()}\n"; echo $e->getTraceAsString() . "\n"; diff --git a/examples/client/stdio_client_communication.php b/examples/client/stdio_client_communication.php index 47115994..ba6ed872 100644 --- a/examples/client/stdio_client_communication.php +++ b/examples/client/stdio_client_communication.php @@ -63,7 +63,7 @@ $client->connect($transport); $serverInfo = $client->getServerInfo(); - echo "Connected to: " . ($serverInfo['serverInfo']['name'] ?? 'unknown') . "\n\n"; + echo "Connected to: " . ($serverInfo?->name ?? 'unknown') . "\n\n"; echo "Available tools:\n"; $toolsResult = $client->listTools(); diff --git a/examples/client/stdio_discovery_calculator.php b/examples/client/stdio_discovery_calculator.php index 0ccc614e..935575c0 100644 --- a/examples/client/stdio_discovery_calculator.php +++ b/examples/client/stdio_discovery_calculator.php @@ -35,8 +35,8 @@ echo "Connected! Server info:\n"; $serverInfo = $client->getServerInfo(); - echo " Name: " . ($serverInfo['serverInfo']['name'] ?? 'unknown') . "\n"; - echo " Version: " . ($serverInfo['serverInfo']['version'] ?? 'unknown') . "\n\n"; + echo " Name: " . ($serverInfo?->name ?? 'unknown') . "\n"; + echo " Version: " . ($serverInfo?->version ?? 'unknown') . "\n\n"; echo "Available tools:\n"; $toolsResult = $client->listTools(); diff --git a/src/Client/Client.php b/src/Client/Client.php index f915e524..5844065f 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -20,6 +20,7 @@ use Mcp\Handler\NotificationHandlerInterface; use Mcp\Handler\RequestHandlerInterface; use Mcp\Schema\Enum\LoggingLevel; +use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; @@ -245,14 +246,23 @@ public function complete(PromptReference|ResourceReference $ref, array $argument /** * Get the server info received during initialization. - * - * @return array|null */ - public function getServerInfo(): ?array + public function getServerInfo(): ?Implementation { return $this->protocol->getSession()->getServerInfo(); } + /** + * Get the server instructions received during initialization. + * + * Instructions describe how to use the server and its features. + * This can be used to improve the LLM's understanding of available tools and resources. + */ + public function getInstructions(): ?string + { + return $this->protocol->getSession()->getInstructions(); + } + /** * Disconnect from the server. */ diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index d1cf409a..cc0865fe 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -22,6 +22,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\InitializedNotification; use Mcp\Schema\Request\InitializeRequest; +use Mcp\Schema\Result\InitializeResult; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -94,13 +95,15 @@ public function initialize(): Response|Error $response = $this->request($request, $this->config->initTimeout); if ($response instanceof Response) { - $this->session->setServerInfo($response->result); + $initResult = InitializeResult::fromArray($response->result); + $this->session->setServerInfo($initResult->serverInfo); + $this->session->setInstructions($initResult->instructions); $this->session->setInitialized(true); $this->sendNotification(new InitializedNotification()); $this->logger->info('Initialization complete', [ - 'server' => $response->result['serverInfo'] ?? null, + 'server' => $initResult->serverInfo->name, ]); } diff --git a/src/Client/Session/ClientSession.php b/src/Client/Session/ClientSession.php index cbaf27b4..f048c5f2 100644 --- a/src/Client/Session/ClientSession.php +++ b/src/Client/Session/ClientSession.php @@ -11,6 +11,7 @@ namespace Mcp\Client\Session; +use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Symfony\Component\Uid\Uuid; @@ -25,9 +26,8 @@ class ClientSession implements ClientSessionInterface private Uuid $id; private int $requestIdCounter = 1; private bool $initialized = false; - - /** @var array|null */ - private ?array $serverInfo = null; + private ?Implementation $serverInfo = null; + private ?string $instructions = null; /** @var array */ private array $pendingRequests = []; @@ -123,16 +123,26 @@ public function isInitialized(): bool return $this->initialized; } - public function setServerInfo(array $serverInfo): void + public function setServerInfo(Implementation $serverInfo): void { $this->serverInfo = $serverInfo; } - public function getServerInfo(): ?array + public function getServerInfo(): ?Implementation { return $this->serverInfo; } + public function setInstructions(?string $instructions): void + { + $this->instructions = $instructions; + } + + public function getInstructions(): ?string + { + return $this->instructions; + } + public function storeProgress(string $token, float $progress, ?float $total, ?string $message): void { $this->progressUpdates[] = [ diff --git a/src/Client/Session/ClientSessionInterface.php b/src/Client/Session/ClientSessionInterface.php index c926e3fa..00106ac3 100644 --- a/src/Client/Session/ClientSessionInterface.php +++ b/src/Client/Session/ClientSessionInterface.php @@ -11,6 +11,7 @@ namespace Mcp\Client\Session; +use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Symfony\Component\Uid\Uuid; @@ -95,18 +96,24 @@ public function setInitialized(bool $initialized): void; public function isInitialized(): bool; /** - * Store server capabilities and info from initialization. - * - * @param array $serverInfo + * Store the server info from initialization. */ - public function setServerInfo(array $serverInfo): void; + public function setServerInfo(Implementation $serverInfo): void; /** - * Get stored server info. - * - * @return array|null + * Get the server info from initialization. + */ + public function getServerInfo(): ?Implementation; + + /** + * Store the server instructions from initialization. + */ + public function setInstructions(?string $instructions): void; + + /** + * Get the server instructions from initialization. */ - public function getServerInfo(): ?array; + public function getInstructions(): ?string; /** * Store progress data received from a notification. From 5ed9c533a77089f6c643bd0ad0232853a06b1626 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 21 Dec 2025 22:21:45 +0100 Subject: [PATCH 09/21] refactor: standardize method names --- src/Client/Transport/HttpClientTransport.php | 5 +++-- src/Client/Transport/StdioClientTransport.php | 7 ++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Client/Transport/HttpClientTransport.php b/src/Client/Transport/HttpClientTransport.php index 7080cc75..ec7ad009 100644 --- a/src/Client/Transport/HttpClientTransport.php +++ b/src/Client/Transport/HttpClientTransport.php @@ -199,7 +199,8 @@ private function tick(): void { $this->processSSEStream(); $this->processProgress(); - $this->tryResumeFiber(); + $this->processFiber(); + usleep(1000); // 1ms } @@ -275,7 +276,7 @@ private function processProgress(): void } } - private function tryResumeFiber(): void + private function processFiber(): void { if (null === $this->activeFiber || !$this->activeFiber->isSuspended()) { return; diff --git a/src/Client/Transport/StdioClientTransport.php b/src/Client/Transport/StdioClientTransport.php index 583cefc7..3f3d643f 100644 --- a/src/Client/Transport/StdioClientTransport.php +++ b/src/Client/Transport/StdioClientTransport.php @@ -192,11 +192,8 @@ private function spawnProcess(): void private function tick(): void { $this->processInput(); - $this->processProgress(); - - $this->tryResumeFiber(); - + $this->processFiber(); $this->processStderr(); usleep(1000); // 1ms @@ -248,7 +245,7 @@ private function processInput(): void } } - private function tryResumeFiber(): void + private function processFiber(): void { if (null === $this->activeFiber || !$this->activeFiber->isSuspended()) { return; From c86a9e2fccf7ca1cc1da28a3ef775bc52153ac38 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 27 Dec 2025 15:45:58 +0100 Subject: [PATCH 10/21] fix: revert changes to server related components --- examples/server/client-communication/server.php | 4 ++-- src/Server/Transport/StdioTransport.php | 9 --------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/examples/server/client-communication/server.php b/examples/server/client-communication/server.php index 29d45eb4..f5e76fc2 100644 --- a/examples/server/client-communication/server.php +++ b/examples/server/client-communication/server.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -require_once dirname(__DIR__) . '/bootstrap.php'; +require_once dirname(__DIR__).'/bootstrap.php'; chdir(__DIR__); use Mcp\Schema\Enum\LoggingLevel; @@ -22,7 +22,7 @@ ->setServerInfo('Client Communication Demo', '1.0.0') ->setLogger(logger()) ->setContainer(container()) - ->setSession(new FileSessionStore(__DIR__ . '/sessions')) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) ->setCapabilities(new ServerCapabilities(logging: true, tools: true)) ->setDiscovery(__DIR__) ->addTool( diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index f85f4788..12177450 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -105,15 +105,9 @@ private function processFiber(): void $pendingRequests = $this->getPendingRequests($this->sessionId); if (empty($pendingRequests)) { - // Flush any queued messages before resuming (e.g., notifications from previous yield) - $this->flushOutgoingMessages(); - $yielded = $this->sessionFiber->resume(); $this->handleFiberYield($yielded, $this->sessionId); - // Flush newly queued messages (like notifications) before returning - $this->flushOutgoingMessages(); - return; } @@ -127,7 +121,6 @@ private function processFiber(): void if (null !== $response) { $yielded = $this->sessionFiber->resume($response); $this->handleFiberYield($yielded, $this->sessionId); - $this->flushOutgoingMessages(); return; } @@ -136,7 +129,6 @@ private function processFiber(): void $error = Error::forInternalError('Request timed out', $requestId); $yielded = $this->sessionFiber->resume($error); $this->handleFiberYield($yielded, $this->sessionId); - $this->flushOutgoingMessages(); return; } @@ -171,7 +163,6 @@ private function flushOutgoingMessages(): void private function writeLine(string $payload): void { fwrite($this->output, $payload . \PHP_EOL); - fflush($this->output); } public function close(): void From 404b27ab68f3262867e7c3a4b1891c1401ecc628 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sat, 27 Dec 2025 15:52:35 +0100 Subject: [PATCH 11/21] fix: revert changes to server related components --- src/Server/Transport/StdioTransport.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 12177450..ff83bbec 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -162,7 +162,7 @@ private function flushOutgoingMessages(): void private function writeLine(string $payload): void { - fwrite($this->output, $payload . \PHP_EOL); + fwrite($this->output, $payload.\PHP_EOL); } public function close(): void From 6a1266026941b4e13fa1e69d648604f9e11ebdd0 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 28 Dec 2025 16:07:44 +0100 Subject: [PATCH 12/21] refactor: Restructure client transports and handlers into dedicated namespaces. --- examples/client/http_client_communication.php | 10 +- examples/client/http_discovery_calculator.php | 6 +- .../client/stdio_client_communication.php | 10 +- .../client/stdio_discovery_calculator.php | 6 +- src/{Client => }/Client.php | 192 +++++++----------- src/Client/Builder.php | 25 ++- src/Client/Configuration.php | 3 +- .../LoggingNotificationHandler.php | 3 +- .../NotificationHandlerInterface.php | 7 +- .../ProgressNotificationHandler.php | 5 +- .../Request}/RequestHandlerInterface.php | 7 +- .../{ => Request}/SamplingRequestHandler.php | 3 +- src/Client/Protocol.php | 56 +++-- ...eClientTransport.php => BaseTransport.php} | 2 +- ...pClientTransport.php => HttpTransport.php} | 4 +- ...ClientTransport.php => StdioTransport.php} | 4 +- 16 files changed, 165 insertions(+), 178 deletions(-) rename src/{Client => }/Client.php (60%) rename src/Client/Handler/{ => Notification}/LoggingNotificationHandler.php (92%) rename src/{Handler => Client/Handler/Notification}/NotificationHandlerInterface.php (74%) rename src/Client/Handler/{ => Notification}/ProgressNotificationHandler.php (90%) rename src/{Handler => Client/Handler/Request}/RequestHandlerInterface.php (77%) rename src/Client/Handler/{ => Request}/SamplingRequestHandler.php (95%) rename src/Client/Transport/{BaseClientTransport.php => BaseTransport.php} (97%) rename src/Client/Transport/{HttpClientTransport.php => HttpTransport.php} (99%) rename src/Client/Transport/{StdioClientTransport.php => StdioTransport.php} (98%) diff --git a/examples/client/http_client_communication.php b/examples/client/http_client_communication.php index c9894025..aeceb1f1 100644 --- a/examples/client/http_client_communication.php +++ b/examples/client/http_client_communication.php @@ -24,10 +24,10 @@ require_once __DIR__ . '/../../vendor/autoload.php'; -use Mcp\Client\Client; -use Mcp\Client\Handler\LoggingNotificationHandler; -use Mcp\Client\Handler\SamplingRequestHandler; -use Mcp\Client\Transport\HttpClientTransport; +use Mcp\Client; +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Client\Handler\Request\SamplingRequestHandler; +use Mcp\Client\Transport\HttpTransport; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\Role; @@ -64,7 +64,7 @@ ->addRequestHandler($samplingRequestHandler) ->build(); -$transport = new HttpClientTransport(endpoint: $endpoint); +$transport = new HttpTransport(endpoint: $endpoint); try { echo "Connecting to MCP server at {$endpoint}...\n"; diff --git a/examples/client/http_discovery_calculator.php b/examples/client/http_discovery_calculator.php index 283c1cdc..933d5a02 100644 --- a/examples/client/http_discovery_calculator.php +++ b/examples/client/http_discovery_calculator.php @@ -16,8 +16,8 @@ require_once __DIR__ . '/../../vendor/autoload.php'; -use Mcp\Client\Client; -use Mcp\Client\Transport\HttpClientTransport; +use Mcp\Client; +use Mcp\Client\Transport\HttpTransport; $endpoint = 'http://localhost:8000'; @@ -27,7 +27,7 @@ ->setRequestTimeout(60) ->build(); -$transport = new HttpClientTransport($endpoint); +$transport = new HttpTransport($endpoint); try { echo "Connecting to MCP server at {$endpoint}...\n"; diff --git a/examples/client/stdio_client_communication.php b/examples/client/stdio_client_communication.php index ba6ed872..bcbd167b 100644 --- a/examples/client/stdio_client_communication.php +++ b/examples/client/stdio_client_communication.php @@ -15,10 +15,10 @@ require_once __DIR__ . '/../../vendor/autoload.php'; -use Mcp\Client\Client; -use Mcp\Client\Handler\LoggingNotificationHandler; -use Mcp\Client\Handler\SamplingRequestHandler; -use Mcp\Client\Transport\StdioClientTransport; +use Mcp\Client; +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Client\Handler\Request\SamplingRequestHandler; +use Mcp\Client\Transport\StdioTransport; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\Role; @@ -53,7 +53,7 @@ ->addRequestHandler($samplingRequestHandler) ->build(); -$transport = new StdioClientTransport( +$transport = new StdioTransport( command: 'php', args: [__DIR__ . '/../server/client-communication/server.php'], ); diff --git a/examples/client/stdio_discovery_calculator.php b/examples/client/stdio_discovery_calculator.php index 935575c0..8a7ee52a 100644 --- a/examples/client/stdio_discovery_calculator.php +++ b/examples/client/stdio_discovery_calculator.php @@ -13,8 +13,8 @@ require_once __DIR__ . '/../../vendor/autoload.php'; -use Mcp\Client\Client; -use Mcp\Client\Transport\StdioClientTransport; +use Mcp\Client; +use Mcp\Client\Transport\StdioTransport; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Content\TextResourceContents; @@ -24,7 +24,7 @@ ->setRequestTimeout(60) ->build(); -$transport = new StdioClientTransport( +$transport = new StdioTransport( command: 'php', args: [__DIR__ . '/../server/discovery-calculator/server.php'], ); diff --git a/src/Client/Client.php b/src/Client.php similarity index 60% rename from src/Client/Client.php rename to src/Client.php index 5844065f..3ea51a17 100644 --- a/src/Client/Client.php +++ b/src/Client.php @@ -9,21 +9,20 @@ * file that was distributed with this source code. */ -namespace Mcp\Client; +namespace Mcp; -use Mcp\Client\Handler\ProgressNotificationHandler; -use Mcp\Client\Session\ClientSession; -use Mcp\Client\Session\ClientSessionInterface; +use Mcp\Client\Builder; +use Mcp\Client\Configuration; +use Mcp\Client\Protocol; use Mcp\Client\Transport\ClientTransportInterface; use Mcp\Exception\ConnectionException; use Mcp\Exception\RequestException; -use Mcp\Handler\NotificationHandlerInterface; -use Mcp\Handler\RequestHandlerInterface; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\PromptReference; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Request\CompletionCompleteRequest; @@ -45,6 +44,7 @@ use Mcp\Schema\Result\ListToolsResult; use Mcp\Schema\Result\ReadResourceResult; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * Main MCP Client facade. @@ -56,35 +56,13 @@ */ class Client { - private Protocol $protocol; - private ClientSessionInterface $session; private ?ClientTransportInterface $transport = null; - /** - * @param NotificationHandlerInterface[] $notificationHandlers - * @param RequestHandlerInterface[] $requestHandlers - */ public function __construct( + private readonly Protocol $protocol, private readonly Configuration $config, - array $notificationHandlers = [], - array $requestHandlers = [], - ?LoggerInterface $logger = null, + private readonly LoggerInterface $logger = new NullLogger(), ) { - $this->session = new ClientSession(); - - $allNotificationHandlers = [ - new ProgressNotificationHandler($this->session), - ...$notificationHandlers, - ]; - - $this->protocol = new Protocol( - $this->session, - $config, - $allNotificationHandlers, - $requestHandlers, - null, - $logger - ); } /** @@ -98,17 +76,16 @@ public static function builder(): Builder /** * Connect to an MCP server using the provided transport. * - * This method blocks until initialization completes or times out. - * The transport handles all blocking operations internally. - * * @throws ConnectionException If connection or initialization fails */ public function connect(ClientTransportInterface $transport): void { $this->transport = $transport; - $this->protocol->connect($transport); + $this->protocol->connect($transport, $this->config); $transport->connectAndInitialize($this->config->initTimeout); + + $this->logger->info('Client connected and initialized'); } /** @@ -120,12 +97,29 @@ public function isConnected(): bool } /** - * Ping the server. + * Get server information from initialization. + */ + public function getServerInfo(): ?Implementation + { + return $this->protocol->getSession()->getServerInfo(); + } + + /** + * Get server instructions. + */ + public function getInstructions(): ?string + { + return $this->protocol->getSession()->getInstructions(); + } + + /** + * Send a ping request to the server. */ public function ping(): void { - $this->ensureConnected(); - $this->doRequest(new PingRequest()); + $request = new PingRequest(); + + $this->sendRequest($request); } /** @@ -133,9 +127,9 @@ public function ping(): void */ public function listTools(?string $cursor = null): ListToolsResult { - $this->ensureConnected(); + $request = new ListToolsRequest($cursor); - return $this->doRequest(new ListToolsRequest($cursor), ListToolsResult::class); + return $this->sendRequest($request, ListToolsResult::class); } /** @@ -144,36 +138,33 @@ public function listTools(?string $cursor = null): ListToolsResult * @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. If provided, a progress token - * is automatically generated and attached to the request. + * Optional callback for progress updates. */ public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult { - $this->ensureConnected(); - $request = new CallToolRequest($name, $arguments); - return $this->doRequest($request, CallToolResult::class, $onProgress); + return $this->sendRequest($request, CallToolResult::class, $onProgress); } /** - * List available resources. + * List available resources from the server. */ public function listResources(?string $cursor = null): ListResourcesResult { - $this->ensureConnected(); + $request = new ListResourcesRequest($cursor); - return $this->doRequest(new ListResourcesRequest($cursor), ListResourcesResult::class); + return $this->sendRequest($request, ListResourcesResult::class); } /** - * List available resource templates. + * List available resource templates from the server. */ public function listResourceTemplates(?string $cursor = null): ListResourceTemplatesResult { - $this->ensureConnected(); + $request = new ListResourceTemplatesRequest($cursor); - return $this->doRequest(new ListResourceTemplatesRequest($cursor), ListResourceTemplatesResult::class); + return $this->sendRequest($request, ListResourceTemplatesResult::class); } /** @@ -185,25 +176,23 @@ public function listResourceTemplates(?string $cursor = null): ListResourceTempl */ public function readResource(string $uri, ?callable $onProgress = null): ReadResourceResult { - $this->ensureConnected(); - $request = new ReadResourceRequest($uri); - return $this->doRequest($request, ReadResourceResult::class, $onProgress); + return $this->sendRequest($request, ReadResourceResult::class, $onProgress); } /** - * List available prompts. + * List available prompts from the server. */ public function listPrompts(?string $cursor = null): ListPromptsResult { - $this->ensureConnected(); + $request = new ListPromptsRequest($cursor); - return $this->doRequest(new ListPromptsRequest($cursor), ListPromptsResult::class); + return $this->sendRequest($request, ListPromptsResult::class); } /** - * Get a prompt by name. + * Get a prompt from the server. * * @param string $name Prompt name * @param array $arguments Prompt arguments @@ -212,20 +201,9 @@ public function listPrompts(?string $cursor = null): ListPromptsResult */ public function getPrompt(string $name, array $arguments = [], ?callable $onProgress = null): GetPromptResult { - $this->ensureConnected(); - $request = new GetPromptRequest($name, $arguments); - return $this->doRequest($request, GetPromptResult::class, $onProgress); - } - - /** - * Set the minimum logging level for server notifications. - */ - public function setLoggingLevel(LoggingLevel $level): void - { - $this->ensureConnected(); - $this->doRequest(new SetLogLevelRequest($level)); + return $this->sendRequest($request, GetPromptResult::class, $onProgress); } /** @@ -234,85 +212,71 @@ public function setLoggingLevel(LoggingLevel $level): void * @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 + public function complete(PromptReference|ResourceReference $ref, array $argument = []): CompletionCompleteResult { - $this->ensureConnected(); + $request = new CompletionCompleteRequest($ref, $argument); - return $this->doRequest( - new CompletionCompleteRequest($ref, $argument), - CompletionCompleteResult::class, - ); + return $this->sendRequest($request, CompletionCompleteResult::class); } /** - * Get the server info received during initialization. - */ - public function getServerInfo(): ?Implementation - { - return $this->protocol->getSession()->getServerInfo(); - } - - /** - * Get the server instructions received during initialization. + * Set the minimum logging level for server log messages. * - * Instructions describe how to use the server and its features. - * This can be used to improve the LLM's understanding of available tools and resources. + * @return array */ - public function getInstructions(): ?string + public function setLoggingLevel(LoggingLevel $level): array { - return $this->protocol->getSession()->getInstructions(); - } + $request = new SetLogLevelRequest($level); - /** - * Disconnect from the server. - */ - public function disconnect(): void - { - $this->transport?->close(); - $this->transport = null; + return $this->sendRequest($request); } /** - * Execute a request and return the typed result. + * Send a request to the server and wait for response. * - * @template T + * @template T of ResultInterface * - * @param class-string|null $resultClass + * @param class-string|null $resultClass * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress * - * @return T|Response> + * @return T|array * - * @throws RequestException + * @throws RequestException|ConnectionException */ - private function doRequest(Request $request, ?string $resultClass = null, ?callable $onProgress = null): mixed + private function sendRequest(Request $request, ?string $resultClass = null, ?callable $onProgress = null): mixed { - $requestId = $this->session->nextRequestId(); - $request = $request->withId($requestId); - - if (null !== $onProgress) { - $progressToken = 'prog-' . $requestId; - $request = $request->withMeta(['progressToken' => $progressToken]); + if (!$this->isConnected()) { + throw new ConnectionException('Client is not connected. Call connect() first.'); } - $fiber = new \Fiber(fn() => $this->protocol->request($request, $this->config->requestTimeout)); - + $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); } + if (!$response instanceof Response) { + throw new RequestException('Unexpected response type'); + } + if (null === $resultClass) { - return $response; + return $response->result; } return $resultClass::fromArray($response->result); } - private function ensureConnected(): void + /** + * Disconnect from the server. + */ + public function disconnect(): void { - if (!$this->isConnected()) { - throw new ConnectionException('Client is not connected. Call connect() first.'); + if (null !== $this->transport) { + $this->transport->close(); + $this->transport = null; + $this->logger->info('Client disconnected'); } } } diff --git a/src/Client/Builder.php b/src/Client/Builder.php index 79def324..7804b61a 100644 --- a/src/Client/Builder.php +++ b/src/Client/Builder.php @@ -11,11 +11,14 @@ namespace Mcp\Client; -use Mcp\Handler\NotificationHandlerInterface; -use Mcp\Handler\RequestHandlerInterface; +use Mcp\Client; +use Mcp\Client\Handler\Notification\NotificationHandlerInterface; +use Mcp\Client\Handler\Request\RequestHandlerInterface; use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * Fluent builder for creating Client instances. @@ -27,7 +30,7 @@ class Builder private string $name = 'mcp-php-client'; private string $version = '1.0.0'; private ?string $description = null; - private ?string $protocolVersion = null; + private ?ProtocolVersion $protocolVersion = null; private ?ClientCapabilities $capabilities = null; private int $initTimeout = 30; private int $requestTimeout = 120; @@ -55,9 +58,9 @@ public function setClientInfo(string $name, string $version, ?string $descriptio /** * Set the protocol version to use. */ - public function setProtocolVersion(string $version): self + public function setProtocolVersion(ProtocolVersion $protocolVersion): self { - $this->protocolVersion = $version; + $this->protocolVersion = $protocolVersion; return $this; } @@ -137,6 +140,8 @@ public function addRequestHandler(RequestHandlerInterface $handler): self */ public function build(): Client { + $logger = $this->logger ?? new NullLogger(); + $clientInfo = new Implementation( $this->name, $this->version, @@ -146,12 +151,18 @@ public function build(): Client $config = new Configuration( clientInfo: $clientInfo, capabilities: $this->capabilities ?? new ClientCapabilities(), - protocolVersion: $this->protocolVersion ?? '2025-06-18', + protocolVersion: $this->protocolVersion ?? ProtocolVersion::V2025_06_18, initTimeout: $this->initTimeout, requestTimeout: $this->requestTimeout, maxRetries: $this->maxRetries, ); - return new Client($config, $this->notificationHandlers, $this->requestHandlers, $this->logger); + $protocol = new Protocol( + requestHandlers: $this->requestHandlers, + notificationHandlers: $this->notificationHandlers, + logger: $logger, + ); + + return new Client($protocol, $config, $logger); } } diff --git a/src/Client/Configuration.php b/src/Client/Configuration.php index c328ba76..4dce6349 100644 --- a/src/Client/Configuration.php +++ b/src/Client/Configuration.php @@ -12,6 +12,7 @@ namespace Mcp\Client; use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; /** @@ -24,7 +25,7 @@ class Configuration public function __construct( public readonly Implementation $clientInfo, public readonly ClientCapabilities $capabilities, - public readonly string $protocolVersion = '2025-06-18', + public readonly ProtocolVersion $protocolVersion = ProtocolVersion::V2025_06_18, public readonly int $initTimeout = 30, public readonly int $requestTimeout = 120, public readonly int $maxRetries = 3, diff --git a/src/Client/Handler/LoggingNotificationHandler.php b/src/Client/Handler/Notification/LoggingNotificationHandler.php similarity index 92% rename from src/Client/Handler/LoggingNotificationHandler.php rename to src/Client/Handler/Notification/LoggingNotificationHandler.php index 4e206c71..bb58d4ba 100644 --- a/src/Client/Handler/LoggingNotificationHandler.php +++ b/src/Client/Handler/Notification/LoggingNotificationHandler.php @@ -9,9 +9,8 @@ * file that was distributed with this source code. */ -namespace Mcp\Client\Handler; +namespace Mcp\Client\Handler\Notification; -use Mcp\Handler\NotificationHandlerInterface; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\Notification\LoggingMessageNotification; diff --git a/src/Handler/NotificationHandlerInterface.php b/src/Client/Handler/Notification/NotificationHandlerInterface.php similarity index 74% rename from src/Handler/NotificationHandlerInterface.php rename to src/Client/Handler/Notification/NotificationHandlerInterface.php index c9cc7483..82092aa2 100644 --- a/src/Handler/NotificationHandlerInterface.php +++ b/src/Client/Handler/Notification/NotificationHandlerInterface.php @@ -9,15 +9,12 @@ * file that was distributed with this source code. */ -namespace Mcp\Handler; +namespace Mcp\Client\Handler\Notification; use Mcp\Schema\JsonRpc\Notification; /** - * Interface for handling notifications from the other party. - * - * Used by both client (for server notifications like logging/progress) - * and server (for client notifications like initialized/cancelled). + * Interface for handling notifications from the server. * * @author Kyrian Obikwelu */ diff --git a/src/Client/Handler/ProgressNotificationHandler.php b/src/Client/Handler/Notification/ProgressNotificationHandler.php similarity index 90% rename from src/Client/Handler/ProgressNotificationHandler.php rename to src/Client/Handler/Notification/ProgressNotificationHandler.php index bda2285c..e131614b 100644 --- a/src/Client/Handler/ProgressNotificationHandler.php +++ b/src/Client/Handler/Notification/ProgressNotificationHandler.php @@ -9,15 +9,14 @@ * file that was distributed with this source code. */ -namespace Mcp\Client\Handler; +namespace Mcp\Client\Handler\Notification; use Mcp\Client\Session\ClientSessionInterface; -use Mcp\Handler\NotificationHandlerInterface; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\Notification\ProgressNotification; /** - * Internal handlerc for progress notifications. + * Internal handler for progress notifications. * * Writes progress data to session for transport to consume and execute callbacks. * diff --git a/src/Handler/RequestHandlerInterface.php b/src/Client/Handler/Request/RequestHandlerInterface.php similarity index 77% rename from src/Handler/RequestHandlerInterface.php rename to src/Client/Handler/Request/RequestHandlerInterface.php index 58c9d841..39d6581a 100644 --- a/src/Handler/RequestHandlerInterface.php +++ b/src/Client/Handler/Request/RequestHandlerInterface.php @@ -9,15 +9,12 @@ * file that was distributed with this source code. */ -namespace Mcp\Handler; +namespace Mcp\Client\Handler\Request; use Mcp\Schema\JsonRpc\Request; /** - * Interface for handling requests from the other party. - * - * Used by both client (for server requests like sampling) - * and server (for client requests like tools/call). + * Interface for handling requests from the server. * * @template TResult * diff --git a/src/Client/Handler/SamplingRequestHandler.php b/src/Client/Handler/Request/SamplingRequestHandler.php similarity index 95% rename from src/Client/Handler/SamplingRequestHandler.php rename to src/Client/Handler/Request/SamplingRequestHandler.php index d3a592e1..0c3b5272 100644 --- a/src/Client/Handler/SamplingRequestHandler.php +++ b/src/Client/Handler/Request/SamplingRequestHandler.php @@ -9,9 +9,8 @@ * file that was distributed with this source code. */ -namespace Mcp\Client\Handler; +namespace Mcp\Client\Handler\Request; -use Mcp\Handler\RequestHandlerInterface; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\Request\CreateSamplingMessageRequest; use Mcp\Schema\Result\CreateSamplingMessageResult; diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index cc0865fe..3be08efd 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -11,10 +11,12 @@ namespace Mcp\Client; +use Mcp\Client\Handler\Notification\NotificationHandlerInterface; +use Mcp\Client\Handler\Notification\ProgressNotificationHandler; +use Mcp\Client\Handler\Request\RequestHandlerInterface; +use Mcp\Client\Session\ClientSession; use Mcp\Client\Session\ClientSessionInterface; use Mcp\Client\Transport\ClientTransportInterface; -use Mcp\Handler\NotificationHandlerInterface; -use Mcp\Handler\RequestHandlerInterface; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Notification; @@ -39,35 +41,46 @@ class Protocol { private ?ClientTransportInterface $transport = null; + private ClientSessionInterface $session; private MessageFactory $messageFactory; private LoggerInterface $logger; + /** @var NotificationHandlerInterface[] */ + private array $notificationHandlers; + /** * @param NotificationHandlerInterface[] $notificationHandlers * @param RequestHandlerInterface[] $requestHandlers */ public function __construct( - private readonly ClientSessionInterface $session, - private readonly Configuration $config, - private readonly array $notificationHandlers = [], private readonly array $requestHandlers = [], + array $notificationHandlers = [], ?MessageFactory $messageFactory = null, ?LoggerInterface $logger = null, ) { + $this->session = new ClientSession(); $this->messageFactory = $messageFactory ?? MessageFactory::make(); $this->logger = $logger ?? new NullLogger(); + + $this->notificationHandlers = [ + new ProgressNotificationHandler($this->session), + ...$notificationHandlers, + ]; } /** * Connect this protocol to a transport. * * Sets up message handling callbacks. + * + * @param ClientTransportInterface $transport The transport to connect + * @param Configuration $config The client configuration for initialization */ - public function connect(ClientTransportInterface $transport): void + public function connect(ClientTransportInterface $transport, Configuration $config): void { $this->transport = $transport; $transport->setSession($this->session); - $transport->onInitialize($this->initialize(...)); + $transport->onInitialize(fn() => $this->initialize($config)); $transport->onMessage($this->processMessage(...)); $transport->onError(fn(\Throwable $e) => $this->logger->error('Transport error', ['exception' => $e])); @@ -79,20 +92,19 @@ public function connect(ClientTransportInterface $transport): void * * Sends InitializeRequest and waits for response, then sends InitializedNotification. * + * @param Configuration $config The client configuration + * * @return Response>|Error */ - public function initialize(): Response|Error + public function initialize(Configuration $config): Response|Error { $request = new InitializeRequest( - $this->config->protocolVersion, - $this->config->capabilities, - $this->config->clientInfo, + $config->protocolVersion->value, + $config->capabilities, + $config->clientInfo, ); - $requestId = $this->session->nextRequestId(); - $request = $request->withId($requestId); - - $response = $this->request($request, $this->config->initTimeout); + $response = $this->request($request, $config->initTimeout); if ($response instanceof Response) { $initResult = InitializeResult::fromArray($response->result); @@ -116,11 +128,21 @@ public function initialize(): Response|Error * If a response is immediately available (sync HTTP), returns it. * Otherwise, suspends the Fiber and waits for the transport to resume it. * + * @param Request $request The request to send + * @param int $timeout The timeout in seconds + * @param bool $withProgress Whether to attach a progress token to the request + * * @return Response>|Error */ - public function request(Request $request, int $timeout): Response|Error + public function request(Request $request, int $timeout, bool $withProgress = false): Response|Error { - $requestId = $request->getId(); + $requestId = $this->session->nextRequestId(); + $request = $request->withId($requestId); + + if ($withProgress) { + $progressToken = "prog-{$requestId}"; + $request = $request->withMeta(['progressToken' => $progressToken]); + } $this->logger->debug('Sending request', [ 'id' => $requestId, diff --git a/src/Client/Transport/BaseClientTransport.php b/src/Client/Transport/BaseTransport.php similarity index 97% rename from src/Client/Transport/BaseClientTransport.php rename to src/Client/Transport/BaseTransport.php index 5229ed9c..b497ce38 100644 --- a/src/Client/Transport/BaseClientTransport.php +++ b/src/Client/Transport/BaseTransport.php @@ -22,7 +22,7 @@ * * @author Kyrian Obikwelu */ -abstract class BaseClientTransport implements ClientTransportInterface +abstract class BaseTransport implements ClientTransportInterface { /** @var callable(): mixed|null */ protected $initializeCallback = null; diff --git a/src/Client/Transport/HttpClientTransport.php b/src/Client/Transport/HttpTransport.php similarity index 99% rename from src/Client/Transport/HttpClientTransport.php rename to src/Client/Transport/HttpTransport.php index ec7ad009..3698d189 100644 --- a/src/Client/Transport/HttpClientTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -30,7 +30,7 @@ * * @author Kyrian Obikwelu */ -class HttpClientTransport extends BaseClientTransport +class HttpTransport extends BaseTransport { private ClientInterface $httpClient; private RequestFactoryInterface $requestFactory; @@ -200,7 +200,7 @@ private function tick(): void $this->processSSEStream(); $this->processProgress(); $this->processFiber(); - + usleep(1000); // 1ms } diff --git a/src/Client/Transport/StdioClientTransport.php b/src/Client/Transport/StdioTransport.php similarity index 98% rename from src/Client/Transport/StdioClientTransport.php rename to src/Client/Transport/StdioTransport.php index 3f3d643f..a374023b 100644 --- a/src/Client/Transport/StdioClientTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -26,11 +26,9 @@ * - Writing to stdin * - Managing Fibers waiting for responses * - * @extends BaseClientTransport - * * @author Kyrian Obikwelu */ -class StdioClientTransport extends BaseClientTransport +class StdioTransport extends BaseTransport { /** @var resource|null */ private $process = null; From e536b11407e11b3e41a50dec53591ffadfafb682 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 28 Dec 2025 16:33:28 +0100 Subject: [PATCH 13/21] fix: cs and php-stan errors fixes(partial) --- examples/client/http_client_communication.php | 32 +++++++++++------- examples/client/http_discovery_calculator.php | 22 +++++++++---- .../client/stdio_client_communication.php | 30 ++++++++++------- .../client/stdio_discovery_calculator.php | 29 ++++++++++------ examples/server/bootstrap.php | 2 +- src/Client.php | 33 +++++++++---------- .../LoggingNotificationHandler.php | 2 ++ .../Request/SamplingRequestHandler.php | 2 +- src/Client/Protocol.php | 14 ++++---- src/Client/Transport/BaseTransport.php | 9 +++-- .../Transport/ClientTransportInterface.php | 7 ++-- src/Client/Transport/HttpTransport.php | 20 +++++------ src/Client/Transport/StdioTransport.php | 30 ++++++++--------- src/Schema/ClientCapabilities.php | 4 +-- 14 files changed, 132 insertions(+), 104 deletions(-) diff --git a/examples/client/http_client_communication.php b/examples/client/http_client_communication.php index aeceb1f1..0cc2b251 100644 --- a/examples/client/http_client_communication.php +++ b/examples/client/http_client_communication.php @@ -1,7 +1,7 @@ maxTokens} tokens)\n"; - $mockResponse = "Based on the incident analysis, I recommend: 1) Activate the on-call team, " . - "2) Isolate affected systems, 3) Begin root cause analysis, 4) Prepare stakeholder communication."; + $mockResponse = 'Based on the incident analysis, I recommend: 1) Activate the on-call team, '. + '2) Isolate affected systems, 3) Begin root cause analysis, 4) Prepare stakeholder communication.'; return new CreateSamplingMessageResult( role: Role::Assistant, @@ -71,7 +80,7 @@ $client->connect($transport); $serverInfo = $client->getServerInfo(); - echo "Connected to: " . ($serverInfo?->name ?? 'unknown') . "\n\n"; + echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n"; echo "Available tools:\n"; $toolsResult = $client->listTools(); @@ -93,7 +102,7 @@ echo "\nResult:\n"; foreach ($result->content as $content) { if ($content instanceof TextContent) { - echo $content->text . "\n"; + echo $content->text."\n"; } } @@ -110,13 +119,12 @@ echo "\nResult:\n"; foreach ($result->content as $content) { if ($content instanceof TextContent) { - echo $content->text . "\n"; + echo $content->text."\n"; } } - -} catch (\Throwable $e) { +} catch (Throwable $e) { echo "Error: {$e->getMessage()}\n"; - echo $e->getTraceAsString() . "\n"; + echo $e->getTraceAsString()."\n"; } finally { $client->disconnect(); } diff --git a/examples/client/http_discovery_calculator.php b/examples/client/http_discovery_calculator.php index 933d5a02..ae8babdd 100644 --- a/examples/client/http_discovery_calculator.php +++ b/examples/client/http_discovery_calculator.php @@ -1,7 +1,7 @@ getServerInfo(); - echo " Name: " . ($serverInfo?->name ?? 'unknown') . "\n"; - echo " Version: " . ($serverInfo?->version ?? 'unknown') . "\n\n"; + echo ' Name: '.($serverInfo->name ?? 'unknown')."\n"; + echo ' Version: '.($serverInfo->version ?? 'unknown')."\n\n"; echo "Available tools:\n"; $toolsResult = $client->listTools(); @@ -58,10 +67,9 @@ echo " - {$prompt->name}: {$prompt->description}\n"; } echo "\n"; - -} catch (\Throwable $e) { +} catch (Throwable $e) { echo "Error: {$e->getMessage()}\n"; - echo $e->getTraceAsString() . "\n"; + echo $e->getTraceAsString()."\n"; } finally { echo "Disconnecting...\n"; $client->disconnect(); diff --git a/examples/client/stdio_client_communication.php b/examples/client/stdio_client_communication.php index bcbd167b..fdf8de82 100644 --- a/examples/client/stdio_client_communication.php +++ b/examples/client/stdio_client_communication.php @@ -1,7 +1,7 @@ maxTokens} tokens)\n"; - $mockResponse = "Based on the incident analysis, I recommend: 1) Activate the on-call team, " . - "2) Isolate affected systems, 3) Begin root cause analysis, 4) Prepare stakeholder communication."; + $mockResponse = 'Based on the incident analysis, I recommend: 1) Activate the on-call team, '. + '2) Isolate affected systems, 3) Begin root cause analysis, 4) Prepare stakeholder communication.'; return new CreateSamplingMessageResult( role: Role::Assistant, @@ -55,7 +64,7 @@ $transport = new StdioTransport( command: 'php', - args: [__DIR__ . '/../server/client-communication/server.php'], + args: [__DIR__.'/../server/client-communication/server.php'], ); try { @@ -63,7 +72,7 @@ $client->connect($transport); $serverInfo = $client->getServerInfo(); - echo "Connected to: " . ($serverInfo?->name ?? 'unknown') . "\n\n"; + echo 'Connected to: '.($serverInfo->name ?? 'unknown')."\n\n"; echo "Available tools:\n"; $toolsResult = $client->listTools(); @@ -85,7 +94,7 @@ echo "\nResult:\n"; foreach ($result->content as $content) { if ($content instanceof TextContent) { - echo $content->text . "\n"; + echo $content->text."\n"; } } @@ -102,13 +111,12 @@ echo "\nResult:\n"; foreach ($result->content as $content) { if ($content instanceof TextContent) { - echo $content->text . "\n"; + echo $content->text."\n"; } } - -} catch (\Throwable $e) { +} catch (Throwable $e) { echo "Error: {$e->getMessage()}\n"; - echo $e->getTraceAsString() . "\n"; + echo $e->getTraceAsString()."\n"; } finally { $client->disconnect(); } diff --git a/examples/client/stdio_discovery_calculator.php b/examples/client/stdio_discovery_calculator.php index 8a7ee52a..7d25a807 100644 --- a/examples/client/stdio_discovery_calculator.php +++ b/examples/client/stdio_discovery_calculator.php @@ -1,7 +1,7 @@ getServerInfo(); - echo " Name: " . ($serverInfo?->name ?? 'unknown') . "\n"; - echo " Version: " . ($serverInfo?->version ?? 'unknown') . "\n\n"; + echo ' Name: '.($serverInfo->name ?? 'unknown')."\n"; + echo ' Version: '.($serverInfo->version ?? 'unknown')."\n\n"; echo "Available tools:\n"; $toolsResult = $client->listTools(); @@ -47,7 +56,7 @@ echo "Calling 'calculate' tool with a=5, b=3, operation='add'...\n"; $result = $client->callTool('calculate', ['a' => 5, 'b' => 3, 'operation' => 'add']); - echo "Result: "; + echo 'Result: '; foreach ($result->content as $content) { if ($content instanceof TextContent) { echo $content->text; @@ -66,13 +75,13 @@ $resourceContent = $client->readResource('config://calculator/settings'); foreach ($resourceContent->contents as $content) { if ($content instanceof TextResourceContents) { - echo " Content: " . $content->text . "\n"; - echo " Mimetype: " . $content->mimeType . "\n"; + echo ' Content: '.$content->text."\n"; + echo ' Mimetype: '.$content->mimeType."\n"; } } -} catch (\Throwable $e) { +} catch (Throwable $e) { echo "Error: {$e->getMessage()}\n"; - echo $e->getTraceAsString() . "\n"; + echo $e->getTraceAsString()."\n"; } finally { echo "Disconnecting...\n"; $client->disconnect(); diff --git a/examples/server/bootstrap.php b/examples/server/bootstrap.php index c4dead70..28186335 100644 --- a/examples/server/bootstrap.php +++ b/examples/server/bootstrap.php @@ -55,7 +55,7 @@ function shutdown(ResponseInterface|int $result): never function logger(): LoggerInterface { return new class extends AbstractLogger { - public function log($level, $message, array $context = []): void + public function log($level, string|Stringable $message, array $context = []): void { $debug = $_SERVER['DEBUG'] ?? false; diff --git a/src/Client.php b/src/Client.php index 3ea51a17..f9ac3ec0 100644 --- a/src/Client.php +++ b/src/Client.php @@ -37,6 +37,7 @@ use Mcp\Schema\ResourceReference; use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Result\CompletionCompleteResult; +use Mcp\Schema\Result\EmptyResult; use Mcp\Schema\Result\GetPromptResult; use Mcp\Schema\Result\ListPromptsResult; use Mcp\Schema\Result\ListResourcesResult; @@ -119,7 +120,7 @@ public function ping(): void { $request = new PingRequest(); - $this->sendRequest($request); + $this->sendRequest($request, EmptyResult::class); } /** @@ -135,10 +136,10 @@ public function listTools(?string $cursor = null): ListToolsResult /** * Call a tool on the server. * - * @param string $name Tool name - * @param array $arguments Tool arguments + * @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. + * Optional callback for progress updates */ public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult { @@ -170,9 +171,9 @@ public function listResourceTemplates(?string $cursor = null): ListResourceTempl /** * Read a resource by URI. * - * @param string $uri The resource URI + * @param string $uri The resource URI * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress - * Optional callback for progress updates. + * Optional callback for progress updates */ public function readResource(string $uri, ?callable $onProgress = null): ReadResourceResult { @@ -194,10 +195,10 @@ public function listPrompts(?string $cursor = null): ListPromptsResult /** * Get a prompt from the server. * - * @param string $name Prompt name - * @param array $arguments Prompt arguments + * @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. + * Optional callback for progress updates */ public function getPrompt(string $name, array $arguments = [], ?callable $onProgress = null): GetPromptResult { @@ -209,10 +210,10 @@ public function getPrompt(string $name, array $arguments = [], ?callable $onProg /** * Request completion suggestions for a prompt or resource argument. * - * @param PromptReference|ResourceReference $ref The prompt or resource reference + * @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 + public function complete(PromptReference|ResourceReference $ref, array $argument): CompletionCompleteResult { $request = new CompletionCompleteRequest($ref, $argument); @@ -228,7 +229,7 @@ public function setLoggingLevel(LoggingLevel $level): array { $request = new SetLogLevelRequest($level); - return $this->sendRequest($request); + return $this->sendRequest($request, EmptyResult::class); } /** @@ -236,7 +237,7 @@ public function setLoggingLevel(LoggingLevel $level): array * * @template T of ResultInterface * - * @param class-string|null $resultClass + * @param class-string|null $resultClass * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress * * @return T|array @@ -250,17 +251,13 @@ private function sendRequest(Request $request, ?string $resultClass = null, ?cal } $withProgress = null !== $onProgress; - $fiber = new \Fiber(fn() => $this->protocol->request($request, $this->config->requestTimeout, $withProgress)); + $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); } - if (!$response instanceof Response) { - throw new RequestException('Unexpected response type'); - } - if (null === $resultClass) { return $response->result; } diff --git a/src/Client/Handler/Notification/LoggingNotificationHandler.php b/src/Client/Handler/Notification/LoggingNotificationHandler.php index bb58d4ba..c160ccd0 100644 --- a/src/Client/Handler/Notification/LoggingNotificationHandler.php +++ b/src/Client/Handler/Notification/LoggingNotificationHandler.php @@ -36,6 +36,8 @@ public function supports(Notification $notification): bool public function handle(Notification $notification): void { + \assert($notification instanceof LoggingMessageNotification); + ($this->callback)($notification); } } diff --git a/src/Client/Handler/Request/SamplingRequestHandler.php b/src/Client/Handler/Request/SamplingRequestHandler.php index 0c3b5272..c80b7551 100644 --- a/src/Client/Handler/Request/SamplingRequestHandler.php +++ b/src/Client/Handler/Request/SamplingRequestHandler.php @@ -45,7 +45,7 @@ public function supports(Request $request): bool */ public function handle(Request $request): array { - assert($request instanceof CreateSamplingMessageRequest); + \assert($request instanceof CreateSamplingMessageRequest); $result = ($this->callback)($request); diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 3be08efd..0b169fcd 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -74,15 +74,15 @@ public function __construct( * Sets up message handling callbacks. * * @param ClientTransportInterface $transport The transport to connect - * @param Configuration $config The client configuration for initialization + * @param Configuration $config The client configuration for initialization */ public function connect(ClientTransportInterface $transport, Configuration $config): void { $this->transport = $transport; $transport->setSession($this->session); - $transport->onInitialize(fn() => $this->initialize($config)); + $transport->onInitialize(fn () => $this->initialize($config)); $transport->onMessage($this->processMessage(...)); - $transport->onError(fn(\Throwable $e) => $this->logger->error('Transport error', ['exception' => $e])); + $transport->onError(fn (\Throwable $e) => $this->logger->error('Transport error', ['exception' => $e])); $this->logger->info('Protocol connected to transport', ['transport' => $transport::class]); } @@ -128,9 +128,9 @@ public function initialize(Configuration $config): Response|Error * If a response is immediately available (sync HTTP), returns it. * Otherwise, suspends the Fiber and waits for the transport to resume it. * - * @param Request $request The request to send - * @param int $timeout The timeout in seconds - * @param bool $withProgress Whether to attach a progress token to the request + * @param Request $request The request to send + * @param int $timeout The timeout in seconds + * @param bool $withProgress Whether to attach a progress token to the request * * @return Response>|Error */ @@ -213,7 +213,7 @@ public function processMessage(string $input): void /** * Handle a response from the server. - * + * * This stores it in session. The transport will pick it up and resume the Fiber. */ private function handleResponse(Response|Error $response): void diff --git a/src/Client/Transport/BaseTransport.php b/src/Client/Transport/BaseTransport.php index b497ce38..d1aff8b5 100644 --- a/src/Client/Transport/BaseTransport.php +++ b/src/Client/Transport/BaseTransport.php @@ -25,17 +25,16 @@ abstract class BaseTransport implements ClientTransportInterface { /** @var callable(): mixed|null */ - protected $initializeCallback = null; + protected $initializeCallback; /** @var callable(string): void|null */ - protected $messageCallback = null; + protected $messageCallback; /** @var callable(\Throwable): void|null */ - protected $errorCallback = null; + protected $errorCallback; /** @var callable(string): void|null */ - protected $closeCallback = null; - + protected $closeCallback; protected ?ClientSessionInterface $session = null; protected LoggerInterface $logger; diff --git a/src/Client/Transport/ClientTransportInterface.php b/src/Client/Transport/ClientTransportInterface.php index f7618a42..8bf297d3 100644 --- a/src/Client/Transport/ClientTransportInterface.php +++ b/src/Client/Transport/ClientTransportInterface.php @@ -21,8 +21,6 @@ * The transport owns its execution loop and manages all blocking operations. * The client delegates completely to the transport for I/O. * - * @template-covariant TResult - * * @phpstan-type FiberReturn (Response|Error) * @phpstan-type FiberResume (FiberReturn|null) * @phpstan-type FiberSuspend array{type: 'await_response', request_id: int, timeout: int} @@ -64,9 +62,9 @@ public function send(string $data, array $context): void; * During the loop, the transport checks session for progress data and * executes the callback if provided. * - * @param McpFiber $fiber The fiber to execute + * @param McpFiber $fiber The fiber to execute * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress - * Optional callback for progress updates + * Optional callback for progress updates * * @return Response>|Error The response or error */ @@ -111,5 +109,4 @@ public function onClose(callable $callback): void; * Set the client session for state management. */ public function setSession(ClientSessionInterface $session): void; - } diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 3698d189..118aee3a 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -42,7 +42,7 @@ class HttpTransport extends BaseTransport private ?\Fiber $activeFiber = null; /** @var (callable(float, ?float, ?string): void)|null */ - private $activeProgressCallback = null; + private $activeProgressCallback; /** @var StreamInterface|null Active SSE stream being read */ private ?StreamInterface $activeStream = null; @@ -51,11 +51,11 @@ class HttpTransport extends BaseTransport private string $sseBuffer = ''; /** - * @param string $endpoint The MCP server endpoint URL - * @param array $headers Additional headers to send - * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) - * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) - * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) + * @param string $endpoint The MCP server endpoint URL + * @param array $headers Additional headers to send + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (auto-discovered if null) */ public function __construct( private readonly string $endpoint, @@ -76,7 +76,7 @@ public function connectAndInitialize(int $timeout): void { $this->running = true; - $this->activeFiber = new \Fiber(fn() => $this->handleInitialize()); + $this->activeFiber = new \Fiber(fn () => $this->handleInitialize()); $deadline = time() + $timeout; $this->activeFiber->start(); @@ -84,7 +84,7 @@ public function connectAndInitialize(int $timeout): void while (!$this->activeFiber->isTerminated()) { if (time() >= $deadline) { $this->running = false; - throw new TimeoutException('Initialization timed out after ' . $timeout . ' seconds'); + throw new TimeoutException('Initialization timed out after '.$timeout.' seconds'); } $this->tick(); } @@ -94,7 +94,7 @@ public function connectAndInitialize(int $timeout): void if ($result instanceof Error) { $this->running = false; - throw new ConnectionException('Initialization failed: ' . $result->message); + throw new ConnectionException('Initialization failed: '.$result->message); } $this->logger->info('HTTP client connected and initialized', ['endpoint' => $this->endpoint]); @@ -121,7 +121,7 @@ public function send(string $data, array $context): void $response = $this->httpClient->sendRequest($request); } catch (\Throwable $e) { $this->handleError($e); - throw new ConnectionException('HTTP request failed: ' . $e->getMessage(), 0, $e); + throw new ConnectionException('HTTP request failed: '.$e->getMessage(), 0, $e); } if ($response->hasHeader('Mcp-Session-Id')) { diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index a374023b..439485e7 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -31,16 +31,16 @@ class StdioTransport extends BaseTransport { /** @var resource|null */ - private $process = null; + private $process; /** @var resource|null */ - private $stdin = null; + private $stdin; /** @var resource|null */ - private $stdout = null; + private $stdout; /** @var resource|null */ - private $stderr = null; + private $stderr; private string $inputBuffer = ''; private bool $running = false; @@ -48,13 +48,13 @@ class StdioTransport extends BaseTransport private ?\Fiber $activeFiber = null; /** @var (callable(float, ?float, ?string): void)|null */ - private $activeProgressCallback = null; + private $activeProgressCallback; /** - * @param string $command The command to run - * @param array $args Command arguments - * @param string|null $cwd Working directory - * @param array|null $env Environment variables + * @param string $command The command to run + * @param array $args Command arguments + * @param string|null $cwd Working directory + * @param array|null $env Environment variables */ public function __construct( private readonly string $command, @@ -70,7 +70,7 @@ public function connectAndInitialize(int $timeout): void { $this->spawnProcess(); - $this->activeFiber = new \Fiber(fn() => $this->handleInitialize()); + $this->activeFiber = new \Fiber(fn () => $this->handleInitialize()); $deadline = time() + $timeout; $this->activeFiber->start(); @@ -78,7 +78,7 @@ public function connectAndInitialize(int $timeout): void while (!$this->activeFiber->isTerminated()) { if (time() >= $deadline) { $this->close(); - throw new TimeoutException('Initialization timed out after ' . $timeout . ' seconds'); + throw new TimeoutException('Initialization timed out after '.$timeout.' seconds'); } $this->tick(); } @@ -88,7 +88,7 @@ public function connectAndInitialize(int $timeout): void if ($result instanceof Error) { $this->close(); - throw new ConnectionException('Initialization failed: ' . $result->message); + throw new ConnectionException('Initialization failed: '.$result->message); } $this->logger->info('Client connected and initialized'); @@ -100,7 +100,7 @@ public function send(string $data, array $context): void throw new ConnectionException('Process stdin not available'); } - fwrite($this->stdin, $data . "\n"); + fwrite($this->stdin, $data."\n"); fflush($this->stdin); $this->logger->debug('Sent message to server', ['data' => $data]); @@ -160,7 +160,7 @@ private function spawnProcess(): void $cmd = escapeshellcmd($this->command); foreach ($this->args as $arg) { - $cmd .= ' ' . escapeshellarg($arg); + $cmd .= ' '.escapeshellarg($arg); } $this->process = proc_open( @@ -172,7 +172,7 @@ private function spawnProcess(): void ); if (!\is_resource($this->process)) { - throw new ConnectionException('Failed to start process: ' . $cmd); + throw new ConnectionException('Failed to start process: '.$cmd); } $this->stdin = $pipes[0]; diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php index 0428c2eb..44fe9592 100644 --- a/src/Schema/ClientCapabilities.php +++ b/src/Schema/ClientCapabilities.php @@ -34,8 +34,8 @@ public function __construct( * @param array{ * roots?: array{ * listChanged?: bool, - * }, - * sampling?: bool, + * }|object, + * sampling?: object|bool, * experimental?: array, * } $data */ From 9403ace9fb99a29c71157e29991b13f99d1b9ff3 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 28 Dec 2025 16:46:18 +0100 Subject: [PATCH 14/21] feat: Refactor request handlers to return `Response` or `Error` objects directly, adding a `SamplingException` and error logging for sampling requests. --- .../Request/RequestHandlerInterface.php | 8 +++-- .../Request/SamplingRequestHandler.php | 29 ++++++++++++++---- src/Client/Protocol.php | 30 +++++++++---------- src/Exception/SamplingException.php | 24 +++++++++++++++ 4 files changed, 68 insertions(+), 23 deletions(-) create mode 100644 src/Exception/SamplingException.php diff --git a/src/Client/Handler/Request/RequestHandlerInterface.php b/src/Client/Handler/Request/RequestHandlerInterface.php index 39d6581a..1c050181 100644 --- a/src/Client/Handler/Request/RequestHandlerInterface.php +++ b/src/Client/Handler/Request/RequestHandlerInterface.php @@ -11,7 +11,9 @@ namespace Mcp\Client\Handler\Request; +use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\JsonRpc\Response; /** * Interface for handling requests from the server. @@ -28,9 +30,9 @@ interface RequestHandlerInterface public function supports(Request $request): bool; /** - * Handle the request and return the result. + * Handle the request and return a response or error. * - * @return TResult + * @return Response|Error */ - public function handle(Request $request): mixed; + public function handle(Request $request): Response|Error; } diff --git a/src/Client/Handler/Request/SamplingRequestHandler.php b/src/Client/Handler/Request/SamplingRequestHandler.php index c80b7551..750f7f10 100644 --- a/src/Client/Handler/Request/SamplingRequestHandler.php +++ b/src/Client/Handler/Request/SamplingRequestHandler.php @@ -11,9 +11,14 @@ namespace Mcp\Client\Handler\Request; +use Mcp\Exception\SamplingException; +use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CreateSamplingMessageRequest; use Mcp\Schema\Result\CreateSamplingMessageResult; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * Handler for sampling requests from the server. @@ -21,18 +26,22 @@ * The MCP server may request the client to sample an LLM during tool execution. * This handler wraps a user-provided callback that performs the actual LLM call. * - * @implements RequestHandlerInterface> + * @implements RequestHandlerInterface * * @author Kyrian Obikwelu */ class SamplingRequestHandler implements RequestHandlerInterface { + private readonly LoggerInterface $logger; + /** * @param callable(CreateSamplingMessageRequest): CreateSamplingMessageResult $callback */ public function __construct( private readonly mixed $callback, + ?LoggerInterface $logger = null, ) { + $this->logger = $logger ?? new NullLogger(); } public function supports(Request $request): bool @@ -41,14 +50,24 @@ public function supports(Request $request): bool } /** - * @return array + * @return Response|Error */ - public function handle(Request $request): array + public function handle(Request $request): Response|Error { \assert($request instanceof CreateSamplingMessageRequest); - $result = ($this->callback)($request); + try { + $result = ($this->callback)($request); + + return new Response($request->getId(), $result); + } catch (SamplingException $e) { + $this->logger->error('Sampling failed: '.$e->getMessage()); + + return Error::forInternalError($e->getMessage(), $request->getId()); + } catch (\Throwable $e) { + $this->logger->error('Unexpected error during sampling', ['exception' => $e]); - return $result->jsonSerialize(); + return Error::forInternalError('Error while sampling LLM', $request->getId()); + } } } diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 0b169fcd..00269096 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -244,24 +244,24 @@ private function handleServerRequest(Request $request): void foreach ($this->requestHandlers as $handler) { if ($handler->supports($request)) { try { - $result = $handler->handle($request); - - $response = new Response($request->getId(), $result); - $encoded = json_encode($response, \JSON_THROW_ON_ERROR); - $this->session->queueOutgoing($encoded, ['type' => 'response']); - $this->flushOutgoing(); - - return; + $response = $handler->handle($request); } catch (\Throwable $e) { - $this->logger->warning('Request handler failed', ['exception' => $e]); + $this->logger->error('Unexpected error while handling request', [ + 'method' => $method, + 'exception' => $e, + ]); + + $response = Error::forInternalError( + \sprintf('Unexpected error while handling "%s" request', $method), + $request->getId() + ); + } - $error = Error::forInternalError($e->getMessage(), $request->getId()); - $encoded = json_encode($error, \JSON_THROW_ON_ERROR); - $this->session->queueOutgoing($encoded, ['type' => 'error']); - $this->flushOutgoing(); + $encoded = json_encode($response, \JSON_THROW_ON_ERROR); + $this->session->queueOutgoing($encoded, ['type' => $response instanceof Response ? 'response' : 'error']); + $this->flushOutgoing(); - return; - } + return; } } diff --git a/src/Exception/SamplingException.php b/src/Exception/SamplingException.php new file mode 100644 index 00000000..17abcebc --- /dev/null +++ b/src/Exception/SamplingException.php @@ -0,0 +1,24 @@ + + */ +final class SamplingException extends \RuntimeException implements ExceptionInterface +{ +} From 7aaad757defba5a7a57e619edcd4a9c3516eb176 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 28 Dec 2025 17:48:42 +0100 Subject: [PATCH 15/21] feat: Replace `ClientTransportInterface` with `TransportInterface` and move response result deserialization to client methods. --- src/Client.php | 61 ++++++++++--------- src/Client/Builder.php | 4 +- src/Client/Protocol.php | 18 +++--- src/Client/Transport/BaseTransport.php | 2 +- src/Client/Transport/HttpTransport.php | 19 ++---- src/Client/Transport/StdioTransport.php | 8 +-- ...rtInterface.php => TransportInterface.php} | 4 +- 7 files changed, 57 insertions(+), 59 deletions(-) rename src/Client/Transport/{ClientTransportInterface.php => TransportInterface.php} (97%) diff --git a/src/Client.php b/src/Client.php index f9ac3ec0..31ebc4de 100644 --- a/src/Client.php +++ b/src/Client.php @@ -14,7 +14,7 @@ use Mcp\Client\Builder; use Mcp\Client\Configuration; use Mcp\Client\Protocol; -use Mcp\Client\Transport\ClientTransportInterface; +use Mcp\Client\Transport\TransportInterface; use Mcp\Exception\ConnectionException; use Mcp\Exception\RequestException; use Mcp\Schema\Enum\LoggingLevel; @@ -22,7 +22,6 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; -use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\PromptReference; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Request\CompletionCompleteRequest; @@ -37,7 +36,6 @@ use Mcp\Schema\ResourceReference; use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Result\CompletionCompleteResult; -use Mcp\Schema\Result\EmptyResult; use Mcp\Schema\Result\GetPromptResult; use Mcp\Schema\Result\ListPromptsResult; use Mcp\Schema\Result\ListResourcesResult; @@ -57,7 +55,7 @@ */ class Client { - private ?ClientTransportInterface $transport = null; + private ?TransportInterface $transport = null; public function __construct( private readonly Protocol $protocol, @@ -79,7 +77,7 @@ public static function builder(): Builder * * @throws ConnectionException If connection or initialization fails */ - public function connect(ClientTransportInterface $transport): void + public function connect(TransportInterface $transport): void { $this->transport = $transport; $this->protocol->connect($transport, $this->config); @@ -120,7 +118,7 @@ public function ping(): void { $request = new PingRequest(); - $this->sendRequest($request, EmptyResult::class); + $this->sendRequest($request); } /** @@ -130,7 +128,9 @@ public function listTools(?string $cursor = null): ListToolsResult { $request = new ListToolsRequest($cursor); - return $this->sendRequest($request, ListToolsResult::class); + $response = $this->sendRequest($request); + + return ListToolsResult::fromArray($response->result); } /** @@ -145,7 +145,9 @@ public function callTool(string $name, array $arguments = [], ?callable $onProgr { $request = new CallToolRequest($name, $arguments); - return $this->sendRequest($request, CallToolResult::class, $onProgress); + $response = $this->sendRequest($request, $onProgress); + + return CallToolResult::fromArray($response->result); } /** @@ -155,7 +157,9 @@ public function listResources(?string $cursor = null): ListResourcesResult { $request = new ListResourcesRequest($cursor); - return $this->sendRequest($request, ListResourcesResult::class); + $response = $this->sendRequest($request); + + return ListResourcesResult::fromArray($response->result); } /** @@ -165,7 +169,9 @@ public function listResourceTemplates(?string $cursor = null): ListResourceTempl { $request = new ListResourceTemplatesRequest($cursor); - return $this->sendRequest($request, ListResourceTemplatesResult::class); + $response = $this->sendRequest($request); + + return ListResourceTemplatesResult::fromArray($response->result); } /** @@ -179,7 +185,9 @@ public function readResource(string $uri, ?callable $onProgress = null): ReadRes { $request = new ReadResourceRequest($uri); - return $this->sendRequest($request, ReadResourceResult::class, $onProgress); + $response = $this->sendRequest($request, $onProgress); + + return ReadResourceResult::fromArray($response->result); } /** @@ -189,7 +197,9 @@ public function listPrompts(?string $cursor = null): ListPromptsResult { $request = new ListPromptsRequest($cursor); - return $this->sendRequest($request, ListPromptsResult::class); + $response = $this->sendRequest($request); + + return ListPromptsResult::fromArray($response->result); } /** @@ -204,7 +214,9 @@ public function getPrompt(string $name, array $arguments = [], ?callable $onProg { $request = new GetPromptRequest($name, $arguments); - return $this->sendRequest($request, GetPromptResult::class, $onProgress); + $response = $this->sendRequest($request, $onProgress); + + return GetPromptResult::fromArray($response->result); } /** @@ -217,34 +229,31 @@ public function complete(PromptReference|ResourceReference $ref, array $argument { $request = new CompletionCompleteRequest($ref, $argument); - return $this->sendRequest($request, CompletionCompleteResult::class); + $response = $this->sendRequest($request); + + return CompletionCompleteResult::fromArray($response->result); } /** * Set the minimum logging level for server log messages. - * - * @return array */ - public function setLoggingLevel(LoggingLevel $level): array + public function setLoggingLevel(LoggingLevel $level): void { $request = new SetLogLevelRequest($level); - return $this->sendRequest($request, EmptyResult::class); + $this->sendRequest($request); } /** * Send a request to the server and wait for response. * - * @template T of ResultInterface - * - * @param class-string|null $resultClass * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress * - * @return T|array + * @return Response * * @throws RequestException|ConnectionException */ - private function sendRequest(Request $request, ?string $resultClass = null, ?callable $onProgress = null): mixed + private function sendRequest(Request $request, ?callable $onProgress = null): Response { if (!$this->isConnected()) { throw new ConnectionException('Client is not connected. Call connect() first.'); @@ -258,11 +267,7 @@ private function sendRequest(Request $request, ?string $resultClass = null, ?cal throw RequestException::fromError($response); } - if (null === $resultClass) { - return $response->result; - } - - return $resultClass::fromArray($response->result); + return $response; } /** diff --git a/src/Client/Builder.php b/src/Client/Builder.php index 7804b61a..120ae19c 100644 --- a/src/Client/Builder.php +++ b/src/Client/Builder.php @@ -40,7 +40,7 @@ class Builder /** @var NotificationHandlerInterface[] */ private array $notificationHandlers = []; - /** @var RequestHandlerInterface[] */ + /** @var RequestHandlerInterface[] */ private array $requestHandlers = []; /** @@ -127,6 +127,8 @@ public function addNotificationHandler(NotificationHandlerInterface $handler): s /** * Add a request handler for server requests (e.g., sampling). + * + * @param RequestHandlerInterface $handler */ public function addRequestHandler(RequestHandlerInterface $handler): self { diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 00269096..bf4d7cc0 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -16,7 +16,7 @@ use Mcp\Client\Handler\Request\RequestHandlerInterface; use Mcp\Client\Session\ClientSession; use Mcp\Client\Session\ClientSessionInterface; -use Mcp\Client\Transport\ClientTransportInterface; +use Mcp\Client\Transport\TransportInterface; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Notification; @@ -34,13 +34,13 @@ * Handles message routing, request/response correlation, and the initialization handshake. * All blocking operations are delegated to the transport. * - * @phpstan-import-type FiberSuspend from ClientTransportInterface + * @phpstan-import-type FiberSuspend from TransportInterface * * @author Kyrian Obikwelu */ class Protocol { - private ?ClientTransportInterface $transport = null; + private ?TransportInterface $transport = null; private ClientSessionInterface $session; private MessageFactory $messageFactory; private LoggerInterface $logger; @@ -49,8 +49,8 @@ class Protocol private array $notificationHandlers; /** - * @param NotificationHandlerInterface[] $notificationHandlers - * @param RequestHandlerInterface[] $requestHandlers + * @param RequestHandlerInterface[] $requestHandlers + * @param NotificationHandlerInterface[] $notificationHandlers */ public function __construct( private readonly array $requestHandlers = [], @@ -73,10 +73,10 @@ public function __construct( * * Sets up message handling callbacks. * - * @param ClientTransportInterface $transport The transport to connect - * @param Configuration $config The client configuration for initialization + * @param TransportInterface $transport The transport to connect + * @param Configuration $config The client configuration for initialization */ - public function connect(ClientTransportInterface $transport, Configuration $config): void + public function connect(TransportInterface $transport, Configuration $config): void { $this->transport = $transport; $transport->setSession($this->session); @@ -215,6 +215,8 @@ public function processMessage(string $input): void * Handle a response from the server. * * This stores it in session. The transport will pick it up and resume the Fiber. + * + * @param Response|Error $response */ private function handleResponse(Response|Error $response): void { diff --git a/src/Client/Transport/BaseTransport.php b/src/Client/Transport/BaseTransport.php index d1aff8b5..bdb52599 100644 --- a/src/Client/Transport/BaseTransport.php +++ b/src/Client/Transport/BaseTransport.php @@ -22,7 +22,7 @@ * * @author Kyrian Obikwelu */ -abstract class BaseTransport implements ClientTransportInterface +abstract class BaseTransport implements TransportInterface { /** @var callable(): mixed|null */ protected $initializeCallback; diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 118aee3a..9967f832 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -28,6 +28,8 @@ * * PSR-18 HTTP clients are auto-discovered if not provided. * + * @phpstan-import-type McpFiber from TransportInterface + * * @author Kyrian Obikwelu */ class HttpTransport extends BaseTransport @@ -37,8 +39,8 @@ class HttpTransport extends BaseTransport private StreamFactoryInterface $streamFactory; private ?string $sessionId = null; - private bool $running = false; + /** @var McpFiber|null */ private ?\Fiber $activeFiber = null; /** @var (callable(float, ?float, ?string): void)|null */ @@ -74,8 +76,6 @@ public function __construct( public function connectAndInitialize(int $timeout): void { - $this->running = true; - $this->activeFiber = new \Fiber(fn () => $this->handleInitialize()); $deadline = time() + $timeout; @@ -83,7 +83,6 @@ public function connectAndInitialize(int $timeout): void while (!$this->activeFiber->isTerminated()) { if (time() >= $deadline) { - $this->running = false; throw new TimeoutException('Initialization timed out after '.$timeout.' seconds'); } $this->tick(); @@ -93,7 +92,6 @@ public function connectAndInitialize(int $timeout): void $this->activeFiber = null; if ($result instanceof Error) { - $this->running = false; throw new ConnectionException('Initialization failed: '.$result->message); } @@ -143,6 +141,7 @@ public function send(string $data, array $context): void } /** + * @param McpFiber $fiber * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress */ public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error @@ -151,14 +150,6 @@ public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Respons $this->activeProgressCallback = $onProgress; $fiber->start(); - if ($fiber->isTerminated()) { - $this->activeFiber = null; - $this->activeProgressCallback = null; - $this->activeStream = null; - - return $fiber->getReturn(); - } - while (!$fiber->isTerminated()) { $this->tick(); } @@ -172,8 +163,6 @@ public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Respons public function close(): void { - $this->running = false; - if (null !== $this->sessionId) { try { $request = $this->requestFactory->createRequest('DELETE', $this->endpoint) diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index 439485e7..7e8f1b2d 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -26,6 +26,8 @@ * - Writing to stdin * - Managing Fibers waiting for responses * + * @phpstan-import-type McpFiber from TransportInterface + * * @author Kyrian Obikwelu */ class StdioTransport extends BaseTransport @@ -43,8 +45,8 @@ class StdioTransport extends BaseTransport private $stderr; private string $inputBuffer = ''; - private bool $running = false; + /** @var McpFiber|null */ private ?\Fiber $activeFiber = null; /** @var (callable(float, ?float, ?string): void)|null */ @@ -107,6 +109,7 @@ public function send(string $data, array $context): void } /** + * @param McpFiber $fiber * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress */ public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error @@ -127,8 +130,6 @@ public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Respons public function close(): void { - $this->running = false; - if (\is_resource($this->stdin)) { fclose($this->stdin); $this->stdin = null; @@ -183,7 +184,6 @@ private function spawnProcess(): void stream_set_blocking($this->stdout, false); stream_set_blocking($this->stderr, false); - $this->running = true; $this->logger->info('Started MCP server process', ['command' => $cmd]); } diff --git a/src/Client/Transport/ClientTransportInterface.php b/src/Client/Transport/TransportInterface.php similarity index 97% rename from src/Client/Transport/ClientTransportInterface.php rename to src/Client/Transport/TransportInterface.php index 8bf297d3..72df14b7 100644 --- a/src/Client/Transport/ClientTransportInterface.php +++ b/src/Client/Transport/TransportInterface.php @@ -22,13 +22,13 @@ * The client delegates completely to the transport for I/O. * * @phpstan-type FiberReturn (Response|Error) - * @phpstan-type FiberResume (FiberReturn|null) + * @phpstan-type FiberResume (Response|Error) * @phpstan-type FiberSuspend array{type: 'await_response', request_id: int, timeout: int} * @phpstan-type McpFiber \Fiber * * @author Kyrian Obikwelu */ -interface ClientTransportInterface +interface TransportInterface { /** * Connect to the MCP server and perform initialization handshake. From 6df7d15dee30c1ad9a7a146ac2104727490adc48 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 28 Dec 2025 18:02:46 +0100 Subject: [PATCH 16/21] refactor: remove redundant timeout check for intialize on transports The processFiber that executes per tick while fiber is suspended already handles timeouts while waiting --- src/Client.php | 2 +- src/Client/Transport/HttpTransport.php | 7 +------ src/Client/Transport/StdioTransport.php | 8 +------- src/Client/Transport/TransportInterface.php | 6 +----- 4 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/Client.php b/src/Client.php index 31ebc4de..7665c6c3 100644 --- a/src/Client.php +++ b/src/Client.php @@ -82,7 +82,7 @@ public function connect(TransportInterface $transport): void $this->transport = $transport; $this->protocol->connect($transport, $this->config); - $transport->connectAndInitialize($this->config->initTimeout); + $transport->connectAndInitialize(); $this->logger->info('Client connected and initialized'); } diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 9967f832..0dcc1b0d 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -14,7 +14,6 @@ use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; use Mcp\Exception\ConnectionException; -use Mcp\Exception\TimeoutException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Psr\Http\Client\ClientInterface; @@ -74,17 +73,13 @@ public function __construct( $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); } - public function connectAndInitialize(int $timeout): void + public function connectAndInitialize(): void { $this->activeFiber = new \Fiber(fn () => $this->handleInitialize()); - $deadline = time() + $timeout; $this->activeFiber->start(); while (!$this->activeFiber->isTerminated()) { - if (time() >= $deadline) { - throw new TimeoutException('Initialization timed out after '.$timeout.' seconds'); - } $this->tick(); } diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index 7e8f1b2d..17f1320f 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -12,7 +12,6 @@ namespace Mcp\Client\Transport; use Mcp\Exception\ConnectionException; -use Mcp\Exception\TimeoutException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Psr\Log\LoggerInterface; @@ -68,20 +67,15 @@ public function __construct( parent::__construct($logger); } - public function connectAndInitialize(int $timeout): void + public function connectAndInitialize(): void { $this->spawnProcess(); $this->activeFiber = new \Fiber(fn () => $this->handleInitialize()); - $deadline = time() + $timeout; $this->activeFiber->start(); while (!$this->activeFiber->isTerminated()) { - if (time() >= $deadline) { - $this->close(); - throw new TimeoutException('Initialization timed out after '.$timeout.' seconds'); - } $this->tick(); } diff --git a/src/Client/Transport/TransportInterface.php b/src/Client/Transport/TransportInterface.php index 72df14b7..1862ae80 100644 --- a/src/Client/Transport/TransportInterface.php +++ b/src/Client/Transport/TransportInterface.php @@ -35,15 +35,11 @@ interface TransportInterface * * This method blocks until: * - Initialization completes successfully - * - Timeout is reached (throws TimeoutException) * - Connection fails (throws ConnectionException) * - * @param int $timeout Maximum time to wait for initialization (seconds) - * - * @throws \Mcp\Exception\TimeoutException * @throws \Mcp\Exception\ConnectionException */ - public function connectAndInitialize(int $timeout): void; + public function connectAndInitialize(): void; /** * Send a message to the server immediately. From adf36a089dcc9ddef8487333c089d548c67e937a Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 28 Dec 2025 18:14:30 +0100 Subject: [PATCH 17/21] feat: broaden symfony/http-client dependency to support Symfony 5.4 to 8.0 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index eb66fcf0..4afcfac6 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,7 @@ "psr/simple-cache": "^2.0 || ^3.0", "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0", - "symfony/http-client": "^7.4", + "symfony/http-client": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "autoload": { From cd53aa2f5e3014a6adde1e6db58106d9998f1e98 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 28 Dec 2025 18:27:09 +0100 Subject: [PATCH 18/21] Update examples/client/README.md Co-authored-by: Christopher Hertel --- examples/client/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/client/README.md b/examples/client/README.md index afdefb32..7de1c6e2 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -16,7 +16,7 @@ Connects to an MCP server over HTTP: ```bash # First, start an HTTP server -php -S localhost:8080 examples/http-discovery-userprofile/server.php +php -S localhost:8000 examples/server/discovery-calculator/server.php # Then run the client php examples/client/http_discovery_userprofile.php From 6f386f27c4004b23878a52215be9413219b9082d Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 28 Dec 2025 18:29:00 +0100 Subject: [PATCH 19/21] Update examples/client/README.md Co-authored-by: Christopher Hertel --- examples/client/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/client/README.md b/examples/client/README.md index 7de1c6e2..3e3bc092 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -19,7 +19,7 @@ Connects to an MCP server over HTTP: php -S localhost:8000 examples/server/discovery-calculator/server.php # Then run the client -php examples/client/http_discovery_userprofile.php +php examples/client/http_discovery_calculator.php ``` ## Requirements From c29023812f646d9ffac4ef42ae8ebdbf756ed273 Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 28 Dec 2025 19:15:13 +0100 Subject: [PATCH 20/21] refactor: remove outgoing message queue from session and simplify transport send method to directly dispatch messages. --- src/Client/Protocol.php | 79 +++++++++---------- src/Client/Session/ClientSession.php | 19 ----- src/Client/Session/ClientSessionInterface.php | 15 ---- src/Client/Transport/HttpTransport.php | 2 +- src/Client/Transport/StdioTransport.php | 2 +- src/Client/Transport/TransportInterface.php | 5 +- 6 files changed, 41 insertions(+), 81 deletions(-) diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index bf4d7cc0..4225f6a0 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -123,7 +123,7 @@ public function initialize(Configuration $config): Response|Error } /** - * Send a request to the server. + * Send a request to the server and wait for response. * * If a response is immediately available (sync HTTP), returns it. * Otherwise, suspends the Fiber and waits for the transport to resume it. @@ -144,16 +144,8 @@ public function request(Request $request, int $timeout, bool $withProgress = fal $request = $request->withMeta(['progressToken' => $progressToken]); } - $this->logger->debug('Sending request', [ - 'id' => $requestId, - 'method' => $request::getMethod(), - ]); - - $encoded = json_encode($request, \JSON_THROW_ON_ERROR); - $this->session->queueOutgoing($encoded, ['type' => 'request']); $this->session->addPendingRequest($requestId, $timeout); - - $this->flushOutgoing(); + $this->sendRequest($request); $immediate = $this->session->consumeResponse($requestId); if (null !== $immediate) { @@ -171,6 +163,20 @@ public function request(Request $request, int $timeout, bool $withProgress = fal ]); } + /** + * Send a request to the server. + */ + private function sendRequest(Request $request): void + { + $this->logger->debug('Sending request', [ + 'id' => $request->getId(), + 'method' => $request::getMethod(), + ]); + + $encoded = json_encode($request, \JSON_THROW_ON_ERROR); + $this->transport?->send($encoded); + } + /** * Send a notification to the server (fire and forget). */ @@ -179,8 +185,20 @@ public function sendNotification(Notification $notification): void $this->logger->debug('Sending notification', ['method' => $notification::getMethod()]); $encoded = json_encode($notification, \JSON_THROW_ON_ERROR); - $this->session->queueOutgoing($encoded, ['type' => 'notification']); - $this->flushOutgoing(); + $this->transport?->send($encoded); + } + + /** + * Send a response back to the server (for server-initiated requests). + * + * @param Response|Error $response + */ + private function sendResponse(Response|Error $response): void + { + $this->logger->debug('Sending response', ['id' => $response->getId()]); + + $encoded = json_encode($response, \JSON_THROW_ON_ERROR); + $this->transport?->send($encoded); } /** @@ -204,9 +222,9 @@ public function processMessage(string $input): void if ($message instanceof Response || $message instanceof Error) { $this->handleResponse($message); } elseif ($message instanceof Request) { - $this->handleServerRequest($message); + $this->handleRequest($message); } elseif ($message instanceof Notification) { - $this->handleServerNotification($message); + $this->handleNotification($message); } } } @@ -224,17 +242,13 @@ private function handleResponse(Response|Error $response): void $this->logger->debug('Handling response', ['id' => $requestId]); - if ($response instanceof Response) { - $this->session->storeResponse($requestId, $response->jsonSerialize()); - } else { - $this->session->storeResponse($requestId, $response->jsonSerialize()); - } + $this->session->storeResponse($requestId, $response->jsonSerialize()); } /** * Handle a request from the server (e.g., sampling request). */ - private function handleServerRequest(Request $request): void + private function handleRequest(Request $request): void { $method = $request::getMethod(); @@ -259,9 +273,7 @@ private function handleServerRequest(Request $request): void ); } - $encoded = json_encode($response, \JSON_THROW_ON_ERROR); - $this->session->queueOutgoing($encoded, ['type' => $response instanceof Response ? 'response' : 'error']); - $this->flushOutgoing(); + $this->sendResponse($response); return; } @@ -272,15 +284,13 @@ private function handleServerRequest(Request $request): void $request->getId() ); - $encoded = json_encode($error, \JSON_THROW_ON_ERROR); - $this->session->queueOutgoing($encoded, ['type' => 'error']); - $this->flushOutgoing(); + $this->sendResponse($error); } /** * Handle a notification from the server. */ - private function handleServerNotification(Notification $notification): void + private function handleNotification(Notification $notification): void { $method = $notification::getMethod(); @@ -301,21 +311,6 @@ private function handleServerNotification(Notification $notification): void } } - /** - * Flush any queued outgoing messages. - */ - private function flushOutgoing(): void - { - if (null === $this->transport) { - return; - } - - $messages = $this->session->consumeOutgoingMessages(); - foreach ($messages as $item) { - $this->transport->send($item['message'], $item['context']); - } - } - public function getSession(): ClientSessionInterface { return $this->session; diff --git a/src/Client/Session/ClientSession.php b/src/Client/Session/ClientSession.php index f048c5f2..e7e2a006 100644 --- a/src/Client/Session/ClientSession.php +++ b/src/Client/Session/ClientSession.php @@ -35,9 +35,6 @@ class ClientSession implements ClientSessionInterface /** @var array> */ private array $responses = []; - /** @var array}> */ - private array $outgoingQueue = []; - /** @var array */ private array $progressUpdates = []; @@ -97,22 +94,6 @@ public function consumeResponse(int $requestId): Response|Error|null return Response::fromArray($data); } - public function queueOutgoing(string $message, array $context): void - { - $this->outgoingQueue[] = [ - 'message' => $message, - 'context' => $context, - ]; - } - - public function consumeOutgoingMessages(): array - { - $messages = $this->outgoingQueue; - $this->outgoingQueue = []; - - return $messages; - } - public function setInitialized(bool $initialized): void { $this->initialized = $initialized; diff --git a/src/Client/Session/ClientSessionInterface.php b/src/Client/Session/ClientSessionInterface.php index 00106ac3..b72a0927 100644 --- a/src/Client/Session/ClientSessionInterface.php +++ b/src/Client/Session/ClientSessionInterface.php @@ -70,21 +70,6 @@ public function storeResponse(int $requestId, array $responseData): void; */ public function consumeResponse(int $requestId): Response|Error|null; - /** - * Queue an outgoing message. - * - * @param string $message JSON-encoded message - * @param array $context Message context - */ - public function queueOutgoing(string $message, array $context): void; - - /** - * Get and clear all queued outgoing messages. - * - * @return array}> - */ - public function consumeOutgoingMessages(): array; - /** * Set initialization state. */ diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 0dcc1b0d..5ffa8c24 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -93,7 +93,7 @@ public function connectAndInitialize(): void $this->logger->info('HTTP client connected and initialized', ['endpoint' => $this->endpoint]); } - public function send(string $data, array $context): void + public function send(string $data): void { $request = $this->requestFactory->createRequest('POST', $this->endpoint) ->withHeader('Content-Type', 'application/json') diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index 17f1320f..afd42eb9 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -90,7 +90,7 @@ public function connectAndInitialize(): void $this->logger->info('Client connected and initialized'); } - public function send(string $data, array $context): void + public function send(string $data): void { if (null === $this->stdin || !\is_resource($this->stdin)) { throw new ConnectionException('Process stdin not available'); diff --git a/src/Client/Transport/TransportInterface.php b/src/Client/Transport/TransportInterface.php index 1862ae80..467eecdf 100644 --- a/src/Client/Transport/TransportInterface.php +++ b/src/Client/Transport/TransportInterface.php @@ -44,10 +44,9 @@ public function connectAndInitialize(): void; /** * Send a message to the server immediately. * - * @param string $data JSON-encoded message - * @param array $context Message context (type, etc.) + * @param string $data JSON-encoded message */ - public function send(string $data, array $context): void; + public function send(string $data): void; /** * Run a request fiber to completion. From 931ab2bfae66ca6b63b72ebd6d495619986be0eb Mon Sep 17 00:00:00 2001 From: Kyrian Obikwelu Date: Sun, 28 Dec 2025 20:22:48 +0100 Subject: [PATCH 21/21] refactor: Replace ClientSession with ClientState for clearer intent on runtime state management --- src/Client.php | 6 ++-- .../ProgressNotificationHandler.php | 8 ++--- src/Client/Protocol.php | 30 +++++++++---------- .../ClientState.php} | 22 +++++--------- .../ClientStateInterface.php} | 18 +++++------ src/Client/Transport/BaseTransport.php | 8 ++--- src/Client/Transport/HttpTransport.php | 10 +++---- src/Client/Transport/StdioTransport.php | 10 +++---- src/Client/Transport/TransportInterface.php | 6 ++-- 9 files changed, 53 insertions(+), 65 deletions(-) rename src/Client/{Session/ClientSession.php => State/ClientState.php} (90%) rename src/Client/{Session/ClientSessionInterface.php => State/ClientStateInterface.php} (89%) diff --git a/src/Client.php b/src/Client.php index 7665c6c3..b1e51b9a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -92,7 +92,7 @@ public function connect(TransportInterface $transport): void */ public function isConnected(): bool { - return null !== $this->transport && $this->protocol->getSession()->isInitialized(); + return null !== $this->transport && $this->protocol->getState()->isInitialized(); } /** @@ -100,7 +100,7 @@ public function isConnected(): bool */ public function getServerInfo(): ?Implementation { - return $this->protocol->getSession()->getServerInfo(); + return $this->protocol->getState()->getServerInfo(); } /** @@ -108,7 +108,7 @@ public function getServerInfo(): ?Implementation */ public function getInstructions(): ?string { - return $this->protocol->getSession()->getInstructions(); + return $this->protocol->getState()->getInstructions(); } /** diff --git a/src/Client/Handler/Notification/ProgressNotificationHandler.php b/src/Client/Handler/Notification/ProgressNotificationHandler.php index e131614b..3c489bf0 100644 --- a/src/Client/Handler/Notification/ProgressNotificationHandler.php +++ b/src/Client/Handler/Notification/ProgressNotificationHandler.php @@ -11,14 +11,14 @@ namespace Mcp\Client\Handler\Notification; -use Mcp\Client\Session\ClientSessionInterface; +use Mcp\Client\State\ClientStateInterface; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\Notification\ProgressNotification; /** * Internal handler for progress notifications. * - * Writes progress data to session for transport to consume and execute callbacks. + * Writes progress data to state for transport to consume and execute callbacks. * * @author Kyrian Obikwelu * @@ -27,7 +27,7 @@ class ProgressNotificationHandler implements NotificationHandlerInterface { public function __construct( - private readonly ClientSessionInterface $session, + private readonly ClientStateInterface $state, ) { } @@ -42,7 +42,7 @@ public function handle(Notification $notification): void return; } - $this->session->storeProgress( + $this->state->storeProgress( (string) $notification->progressToken, $notification->progress, $notification->total, diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 4225f6a0..75648456 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -14,8 +14,8 @@ use Mcp\Client\Handler\Notification\NotificationHandlerInterface; use Mcp\Client\Handler\Notification\ProgressNotificationHandler; use Mcp\Client\Handler\Request\RequestHandlerInterface; -use Mcp\Client\Session\ClientSession; -use Mcp\Client\Session\ClientSessionInterface; +use Mcp\Client\State\ClientState; +use Mcp\Client\State\ClientStateInterface; use Mcp\Client\Transport\TransportInterface; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\JsonRpc\Error; @@ -41,7 +41,7 @@ class Protocol { private ?TransportInterface $transport = null; - private ClientSessionInterface $session; + private ClientStateInterface $state; private MessageFactory $messageFactory; private LoggerInterface $logger; @@ -58,12 +58,12 @@ public function __construct( ?MessageFactory $messageFactory = null, ?LoggerInterface $logger = null, ) { - $this->session = new ClientSession(); + $this->state = new ClientState(); $this->messageFactory = $messageFactory ?? MessageFactory::make(); $this->logger = $logger ?? new NullLogger(); $this->notificationHandlers = [ - new ProgressNotificationHandler($this->session), + new ProgressNotificationHandler($this->state), ...$notificationHandlers, ]; } @@ -79,7 +79,7 @@ public function __construct( public function connect(TransportInterface $transport, Configuration $config): void { $this->transport = $transport; - $transport->setSession($this->session); + $transport->setState($this->state); $transport->onInitialize(fn () => $this->initialize($config)); $transport->onMessage($this->processMessage(...)); $transport->onError(fn (\Throwable $e) => $this->logger->error('Transport error', ['exception' => $e])); @@ -108,9 +108,9 @@ public function initialize(Configuration $config): Response|Error if ($response instanceof Response) { $initResult = InitializeResult::fromArray($response->result); - $this->session->setServerInfo($initResult->serverInfo); - $this->session->setInstructions($initResult->instructions); - $this->session->setInitialized(true); + $this->state->setServerInfo($initResult->serverInfo); + $this->state->setInstructions($initResult->instructions); + $this->state->setInitialized(true); $this->sendNotification(new InitializedNotification()); @@ -136,7 +136,7 @@ public function initialize(Configuration $config): Response|Error */ public function request(Request $request, int $timeout, bool $withProgress = false): Response|Error { - $requestId = $this->session->nextRequestId(); + $requestId = $this->state->nextRequestId(); $request = $request->withId($requestId); if ($withProgress) { @@ -144,10 +144,10 @@ public function request(Request $request, int $timeout, bool $withProgress = fal $request = $request->withMeta(['progressToken' => $progressToken]); } - $this->session->addPendingRequest($requestId, $timeout); + $this->state->addPendingRequest($requestId, $timeout); $this->sendRequest($request); - $immediate = $this->session->consumeResponse($requestId); + $immediate = $this->state->consumeResponse($requestId); if (null !== $immediate) { $this->logger->debug('Received immediate response', ['id' => $requestId]); @@ -242,7 +242,7 @@ private function handleResponse(Response|Error $response): void $this->logger->debug('Handling response', ['id' => $requestId]); - $this->session->storeResponse($requestId, $response->jsonSerialize()); + $this->state->storeResponse($requestId, $response->jsonSerialize()); } /** @@ -311,8 +311,8 @@ private function handleNotification(Notification $notification): void } } - public function getSession(): ClientSessionInterface + public function getState(): ClientStateInterface { - return $this->session; + return $this->state; } } diff --git a/src/Client/Session/ClientSession.php b/src/Client/State/ClientState.php similarity index 90% rename from src/Client/Session/ClientSession.php rename to src/Client/State/ClientState.php index e7e2a006..241a4450 100644 --- a/src/Client/Session/ClientSession.php +++ b/src/Client/State/ClientState.php @@ -9,21 +9,23 @@ * file that was distributed with this source code. */ -namespace Mcp\Client\Session; +namespace Mcp\Client\State; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; -use Symfony\Component\Uid\Uuid; /** - * In-memory client session implementation. + * In-memory client state implementation. + * + * Stores ephemeral runtime state for the client's connection to a server. + * This includes pending requests, responses, progress updates, and + * negotiated parameters from initialization. * * @author Kyrian Obikwelu */ -class ClientSession implements ClientSessionInterface +class ClientState implements ClientStateInterface { - private Uuid $id; private int $requestIdCounter = 1; private bool $initialized = false; private ?Implementation $serverInfo = null; @@ -38,16 +40,6 @@ class ClientSession implements ClientSessionInterface /** @var array */ private array $progressUpdates = []; - public function __construct(?Uuid $id = null) - { - $this->id = $id ?? Uuid::v4(); - } - - public function getId(): Uuid - { - return $this->id; - } - public function nextRequestId(): int { return $this->requestIdCounter++; diff --git a/src/Client/Session/ClientSessionInterface.php b/src/Client/State/ClientStateInterface.php similarity index 89% rename from src/Client/Session/ClientSessionInterface.php rename to src/Client/State/ClientStateInterface.php index b72a0927..af697d4e 100644 --- a/src/Client/Session/ClientSessionInterface.php +++ b/src/Client/State/ClientStateInterface.php @@ -9,27 +9,23 @@ * file that was distributed with this source code. */ -namespace Mcp\Client\Session; +namespace Mcp\Client\State; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; -use Symfony\Component\Uid\Uuid; /** - * Interface for client session state management. + * Interface for client state management. * - * Tracks pending requests, stores responses, and manages message queues. + * Tracks pending requests, stores responses, and manages runtime state + * for the client's connection to a server. This is ephemeral state that + * exists only for the lifetime of the connection. * * @author Kyrian Obikwelu */ -interface ClientSessionInterface +interface ClientStateInterface { - /** - * Get the session ID. - */ - public function getId(): Uuid; - /** * Get the next request ID for outgoing requests. */ @@ -76,7 +72,7 @@ public function consumeResponse(int $requestId): Response|Error|null; public function setInitialized(bool $initialized): void; /** - * Check if session is initialized. + * Check if connection is initialized. */ public function isInitialized(): bool; diff --git a/src/Client/Transport/BaseTransport.php b/src/Client/Transport/BaseTransport.php index bdb52599..fd70803b 100644 --- a/src/Client/Transport/BaseTransport.php +++ b/src/Client/Transport/BaseTransport.php @@ -11,7 +11,7 @@ namespace Mcp\Client\Transport; -use Mcp\Client\Session\ClientSessionInterface; +use Mcp\Client\State\ClientStateInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -36,7 +36,7 @@ abstract class BaseTransport implements TransportInterface /** @var callable(string): void|null */ protected $closeCallback; - protected ?ClientSessionInterface $session = null; + protected ?ClientStateInterface $state = null; protected LoggerInterface $logger; public function __construct(?LoggerInterface $logger = null) @@ -64,9 +64,9 @@ public function onClose(callable $listener): void $this->closeCallback = $listener; } - public function setSession(ClientSessionInterface $session): void + public function setState(ClientStateInterface $state): void { - $this->session = $session; + $this->state = $state; } /** diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 5ffa8c24..51083af4 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -241,11 +241,11 @@ private function processSSEEvent(string $event): void */ private function processProgress(): void { - if (null === $this->activeProgressCallback || null === $this->session) { + if (null === $this->activeProgressCallback || null === $this->state) { return; } - $updates = $this->session->consumeProgressUpdates(); + $updates = $this->state->consumeProgressUpdates(); foreach ($updates as $update) { try { @@ -266,18 +266,18 @@ private function processFiber(): void return; } - if (null === $this->session) { + if (null === $this->state) { return; } - $pendingRequests = $this->session->getPendingRequests(); + $pendingRequests = $this->state->getPendingRequests(); foreach ($pendingRequests as $pending) { $requestId = $pending['request_id']; $timestamp = $pending['timestamp']; $timeout = $pending['timeout']; - $response = $this->session->consumeResponse($requestId); + $response = $this->state->consumeResponse($requestId); if (null !== $response) { $this->logger->debug('Resuming fiber with response', ['request_id' => $requestId]); diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index afd42eb9..b580b047 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -196,11 +196,11 @@ private function tick(): void */ private function processProgress(): void { - if (null === $this->activeProgressCallback || null === $this->session) { + if (null === $this->activeProgressCallback || null === $this->state) { return; } - $updates = $this->session->consumeProgressUpdates(); + $updates = $this->state->consumeProgressUpdates(); foreach ($updates as $update) { try { @@ -243,11 +243,11 @@ private function processFiber(): void return; } - if (null === $this->session) { + if (null === $this->state) { return; } - $pendingRequests = $this->session->getPendingRequests(); + $pendingRequests = $this->state->getPendingRequests(); foreach ($pendingRequests as $pending) { $requestId = $pending['request_id']; @@ -255,7 +255,7 @@ private function processFiber(): void $timeout = $pending['timeout']; // Check if response arrived - $response = $this->session->consumeResponse($requestId); + $response = $this->state->consumeResponse($requestId); if (null !== $response) { $this->logger->debug('Resuming fiber with response', ['request_id' => $requestId]); diff --git a/src/Client/Transport/TransportInterface.php b/src/Client/Transport/TransportInterface.php index 467eecdf..84e15d4f 100644 --- a/src/Client/Transport/TransportInterface.php +++ b/src/Client/Transport/TransportInterface.php @@ -11,7 +11,7 @@ namespace Mcp\Client\Transport; -use Mcp\Client\Session\ClientSessionInterface; +use Mcp\Client\State\ClientStateInterface; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; @@ -101,7 +101,7 @@ public function onError(callable $callback): void; public function onClose(callable $callback): void; /** - * Set the client session for state management. + * Set the client state for runtime state management. */ - public function setSession(ClientSessionInterface $session): void; + public function setState(ClientStateInterface $state): void; }