From c8f4c9fdd1a197884c8a2a990af7e2151b56283f Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 10 Feb 2026 19:27:54 +0530 Subject: [PATCH 1/3] Add MCP Client --- config/mcp.php | 41 ++++ src/Client/Client.php | 150 ++++++++++++ src/Client/ClientContext.php | 62 +++++ src/Client/ClientManager.php | 85 +++++++ src/Client/ClientTool.php | 81 +++++++ src/Client/Contracts/ClientMethod.php | 16 ++ src/Client/Contracts/ClientTransport.php | 18 ++ src/Client/Exceptions/ClientException.php | 12 + src/Client/Exceptions/ConnectionException.php | 10 + src/Client/Methods/CallTool.php | 22 ++ src/Client/Methods/Initialize.php | 31 +++ src/Client/Methods/ListTools.php | 22 ++ src/Client/Transport/HttpClientTransport.php | 106 +++++++++ src/Client/Transport/StdioClientTransport.php | 113 +++++++++ src/Facades/Mcp.php | 2 + src/Server/McpServiceProvider.php | 13 ++ src/Server/Registrar.php | 7 + src/Server/Transport/JsonRpcNotification.php | 13 +- src/Server/Transport/JsonRpcRequest.php | 39 +--- src/Server/Transport/JsonRpcResponse.php | 70 +----- src/Transport/JsonRpcNotification.php | 35 +++ src/Transport/JsonRpcRequest.php | 63 +++++ src/Transport/JsonRpcResponse.php | 84 +++++++ tests/Fixtures/FakeClientTransport.php | 80 +++++++ tests/Unit/Client/ClientContextTest.php | 97 ++++++++ tests/Unit/Client/ClientManagerTest.php | 148 ++++++++++++ tests/Unit/Client/ClientTest.php | 219 ++++++++++++++++++ tests/Unit/Client/ClientToolTest.php | 112 +++++++++ tests/Unit/Client/ExceptionsTest.php | 20 ++ tests/Unit/Client/Methods/CallToolTest.php | 56 +++++ tests/Unit/Client/Methods/InitializeTest.php | 87 +++++++ tests/Unit/Client/Methods/ListToolsTest.php | 53 +++++ .../Transport/FakeClientTransportTest.php | 65 ++++++ .../Transport/HttpClientTransportTest.php | 97 ++++++++ .../Transport/StdioClientTransportTest.php | 69 ++++++ .../Transport/BaseJsonRpcNotificationTest.php | 41 ++++ .../Unit/Transport/BaseJsonRpcRequestTest.php | 69 ++++++ .../Transport/BaseJsonRpcResponseTest.php | 58 +++++ 38 files changed, 2251 insertions(+), 115 deletions(-) create mode 100644 src/Client/Client.php create mode 100644 src/Client/ClientContext.php create mode 100644 src/Client/ClientManager.php create mode 100644 src/Client/ClientTool.php create mode 100644 src/Client/Contracts/ClientMethod.php create mode 100644 src/Client/Contracts/ClientTransport.php create mode 100644 src/Client/Exceptions/ClientException.php create mode 100644 src/Client/Exceptions/ConnectionException.php create mode 100644 src/Client/Methods/CallTool.php create mode 100644 src/Client/Methods/Initialize.php create mode 100644 src/Client/Methods/ListTools.php create mode 100644 src/Client/Transport/HttpClientTransport.php create mode 100644 src/Client/Transport/StdioClientTransport.php create mode 100644 src/Transport/JsonRpcNotification.php create mode 100644 src/Transport/JsonRpcRequest.php create mode 100644 src/Transport/JsonRpcResponse.php create mode 100644 tests/Fixtures/FakeClientTransport.php create mode 100644 tests/Unit/Client/ClientContextTest.php create mode 100644 tests/Unit/Client/ClientManagerTest.php create mode 100644 tests/Unit/Client/ClientTest.php create mode 100644 tests/Unit/Client/ClientToolTest.php create mode 100644 tests/Unit/Client/ExceptionsTest.php create mode 100644 tests/Unit/Client/Methods/CallToolTest.php create mode 100644 tests/Unit/Client/Methods/InitializeTest.php create mode 100644 tests/Unit/Client/Methods/ListToolsTest.php create mode 100644 tests/Unit/Client/Transport/FakeClientTransportTest.php create mode 100644 tests/Unit/Client/Transport/HttpClientTransportTest.php create mode 100644 tests/Unit/Client/Transport/StdioClientTransportTest.php create mode 100644 tests/Unit/Transport/BaseJsonRpcNotificationTest.php create mode 100644 tests/Unit/Transport/BaseJsonRpcRequestTest.php create mode 100644 tests/Unit/Transport/BaseJsonRpcResponseTest.php diff --git a/config/mcp.php b/config/mcp.php index 5165ce1ae..4f2d3468e 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -2,6 +2,19 @@ return [ + /* + |-------------------------------------------------------------------------- + | Protocol Version + |-------------------------------------------------------------------------- + | + | The MCP protocol version that the client will use when connecting + | to external MCP servers. This should match a version supported + | by the servers you are connecting to. + | + */ + + 'protocol_version' => '2025-11-25', + /* |-------------------------------------------------------------------------- | Redirect Domains @@ -20,4 +33,32 @@ // 'https://example.com', ], + /* + |-------------------------------------------------------------------------- + | MCP Servers (Client Connections) + |-------------------------------------------------------------------------- + | + | Define external MCP servers that your application can connect to as + | a client. Each entry configures a named connection with its transport + | type, connection details, and optional caching. + | + */ + + 'servers' => [ + // 'example' => [ + // 'transport' => 'stdio', + // 'command' => 'php', + // 'args' => ['artisan', 'mcp:start', 'example'], + // 'timeout' => 30, + // 'cache_ttl' => 300, + // ], + // 'remote' => [ + // 'transport' => 'http', + // 'url' => 'https://example.com/mcp', + // 'headers' => [], + // 'timeout' => 30, + // 'cache_ttl' => 300, + // ], + ], + ]; diff --git a/src/Client/Client.php b/src/Client/Client.php new file mode 100644 index 000000000..990e440a3 --- /dev/null +++ b/src/Client/Client.php @@ -0,0 +1,150 @@ +|null */ + protected ?array $serverInfo = null; + + /** @var array|null */ + protected ?array $serverCapabilities = null; + + protected bool $initialized = false; + + protected ClientContext $context; + + /** @var array> */ + protected array $methods = [ + 'initialize' => Initialize::class, + 'tools/list' => ListTools::class, + 'tools/call' => CallTool::class, + ]; + + public function __construct( + protected ClientTransport $transport, + protected string $name = 'laravel-mcp-client', + protected ?int $cacheTtl = null, + protected string $protocolVersion = '2025-11-25', + ) { + $this->context = new ClientContext($transport, $this->name, $this->protocolVersion); + } + + public function connect(): static + { + $this->transport->connect(); + + $this->initialize(); + + return $this; + } + + public function disconnect(): void + { + if ($this->initialized) { + $this->initialized = false; + $this->serverInfo = null; + $this->serverCapabilities = null; + $this->context->resetRequestId(); + } + + $this->transport->disconnect(); + } + + /** + * @return Collection + */ + public function tools(): Collection + { + $cacheKey = "mcp-client:{$this->name}:tools"; + + if ($this->cacheTtl !== null && Cache::has($cacheKey)) { + /** @var Collection */ + return Cache::get($cacheKey); + } + + $result = $this->callMethod('tools/list'); + + /** @var array> $toolDefinitions */ + $toolDefinitions = $result['tools'] ?? []; + + /** @var Collection $tools */ + $tools = collect($toolDefinitions)->map( + fn (array $definition): ClientTool => ClientTool::fromArray($definition, $this) + ); + + if ($this->cacheTtl !== null) { + Cache::put($cacheKey, $tools, $this->cacheTtl); + } + + return $tools; + } + + /** + * @param array $arguments + * @return array + */ + public function callTool(string $name, array $arguments = []): array + { + return $this->callMethod('tools/call', [ + 'name' => $name, + 'arguments' => $arguments, + ]); + } + + /** + * @return array|null + */ + public function serverInfo(): ?array + { + return $this->serverInfo; + } + + /** + * @return array|null + */ + public function serverCapabilities(): ?array + { + return $this->serverCapabilities; + } + + public function isConnected(): bool + { + return $this->initialized && $this->transport->isConnected(); + } + + public function clearCache(): void + { + Cache::forget("mcp-client:{$this->name}:tools"); + } + + protected function initialize(): void + { + $result = $this->callMethod('initialize'); + + $this->serverInfo = $result['serverInfo'] ?? null; + $this->serverCapabilities = $result['capabilities'] ?? null; + + $this->initialized = true; + } + + /** + * @param array $params + * @return array + */ + protected function callMethod(string $method, array $params = []): array + { + $handler = new $this->methods[$method]; + + return $handler->handle($this->context, $params); + } +} diff --git a/src/Client/ClientContext.php b/src/Client/ClientContext.php new file mode 100644 index 000000000..247d2b393 --- /dev/null +++ b/src/Client/ClientContext.php @@ -0,0 +1,62 @@ + $params + * @return array + */ + public function sendRequest(string $method, array $params = []): array + { + $request = new JsonRpcRequest( + id: ++$this->requestId, + method: $method, + params: $params, + ); + + $responseJson = $this->transport->send($request->toJson()); + + $response = JsonRpcResponse::fromJson($responseJson); + + if (isset($response['error'])) { + throw new ClientException( + $response['error']['message'] ?? 'Unknown error', + (int) ($response['error']['code'] ?? 0), + ); + } + + return $response; + } + + /** + * @param array $params + */ + public function notify(string $method, array $params = []): void + { + $notification = new JsonRpcNotification($method, $params); + $this->transport->notify($notification->toJson()); + } + + public function resetRequestId(): void + { + $this->requestId = 0; + } +} diff --git a/src/Client/ClientManager.php b/src/Client/ClientManager.php new file mode 100644 index 000000000..44b0fb594 --- /dev/null +++ b/src/Client/ClientManager.php @@ -0,0 +1,85 @@ + */ + protected array $clients = []; + + public function client(string $name): Client + { + if (isset($this->clients[$name])) { + return $this->clients[$name]; + } + + /** @var array $config */ + $config = config("mcp.servers.{$name}"); + + if (empty($config)) { + throw new InvalidArgumentException("MCP server [{$name}] is not configured."); + } + + $transport = $this->createTransport($config); + + $client = new Client( + transport: $transport, + name: $name, + cacheTtl: isset($config['cache_ttl']) ? (int) $config['cache_ttl'] : null, + protocolVersion: (string) config('mcp.protocol_version', '2025-11-25'), + ); + + $client->connect(); + + return $this->clients[$name] = $client; + } + + public function purge(?string $name = null): void + { + if ($name !== null) { + if (isset($this->clients[$name])) { + $this->clients[$name]->disconnect(); + unset($this->clients[$name]); + } + + return; + } + + foreach ($this->clients as $client) { + $client->disconnect(); + } + + $this->clients = []; + } + + /** + * @param array $config + */ + public function createTransport(array $config): ClientTransport + { + $transport = $config['transport'] ?? 'stdio'; + + return match ($transport) { + 'stdio' => new StdioClientTransport( + command: (string) ($config['command'] ?? ''), + args: (array) ($config['args'] ?? []), + workingDirectory: isset($config['working_directory']) ? (string) $config['working_directory'] : null, + env: (array) ($config['env'] ?? []), + timeout: (float) ($config['timeout'] ?? 30), + ), + 'http' => new HttpClientTransport( + url: (string) ($config['url'] ?? ''), + headers: (array) ($config['headers'] ?? []), + timeout: (float) ($config['timeout'] ?? 30), + ), + default => throw new InvalidArgumentException("Unsupported MCP transport [{$transport}]."), + }; + } +} diff --git a/src/Client/ClientTool.php b/src/Client/ClientTool.php new file mode 100644 index 000000000..84bf5dbde --- /dev/null +++ b/src/Client/ClientTool.php @@ -0,0 +1,81 @@ + */ + protected array $inputSchema = []; + + protected Client $client; + + /** + * @param array $definition + */ + public static function fromArray(array $definition, Client $client): static + { + $tool = new static; + + $tool->name = $definition['name'] ?? ''; + $tool->description = $definition['description'] ?? ''; + $tool->title = $definition['title'] ?? ''; + $tool->inputSchema = $definition['inputSchema'] ?? []; + $tool->client = $client; + + return $tool; + } + + public function handle(Request $request): Response + { + $result = $this->client->callTool($this->name(), $request->all()); + + return Response::text(json_encode($result) ?: '{}'); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return []; + } + + /** + * @return array{ + * name: string, + * title?: string|null, + * description?: string|null, + * inputSchema?: array, + * annotations?: array|object, + * _meta?: array + * } + */ + public function toArray(): array + { + $result = [ + 'name' => $this->name(), + 'title' => $this->title(), + 'description' => $this->description(), + 'inputSchema' => $this->inputSchema !== [] ? $this->inputSchema : (object) [], + 'annotations' => (object) [], + ]; + + // @phpstan-ignore return.type + return $this->mergeMeta($result); + } + + /** + * @return array + */ + public function remoteInputSchema(): array + { + return $this->inputSchema; + } +} diff --git a/src/Client/Contracts/ClientMethod.php b/src/Client/Contracts/ClientMethod.php new file mode 100644 index 000000000..89c6442e7 --- /dev/null +++ b/src/Client/Contracts/ClientMethod.php @@ -0,0 +1,16 @@ + $params + * @return array + */ + public function handle(ClientContext $context, array $params = []): array; +} diff --git a/src/Client/Contracts/ClientTransport.php b/src/Client/Contracts/ClientTransport.php new file mode 100644 index 000000000..bf777fc4c --- /dev/null +++ b/src/Client/Contracts/ClientTransport.php @@ -0,0 +1,18 @@ + $params + * @return array + */ + public function handle(ClientContext $context, array $params = []): array + { + $response = $context->sendRequest('tools/call', $params); + + return $response['result'] ?? []; + } +} diff --git a/src/Client/Methods/Initialize.php b/src/Client/Methods/Initialize.php new file mode 100644 index 000000000..4ec85d059 --- /dev/null +++ b/src/Client/Methods/Initialize.php @@ -0,0 +1,31 @@ + $params + * @return array + */ + public function handle(ClientContext $context, array $params = []): array + { + $response = $context->sendRequest('initialize', [ + 'protocolVersion' => $context->protocolVersion, + 'capabilities' => (object) [], + 'clientInfo' => [ + 'name' => $context->clientName, + 'version' => '1.0.0', + ], + ]); + + $context->notify('notifications/initialized'); + + return $response['result'] ?? []; + } +} diff --git a/src/Client/Methods/ListTools.php b/src/Client/Methods/ListTools.php new file mode 100644 index 000000000..2ddf0aa73 --- /dev/null +++ b/src/Client/Methods/ListTools.php @@ -0,0 +1,22 @@ + $params + * @return array + */ + public function handle(ClientContext $context, array $params = []): array + { + $response = $context->sendRequest('tools/list'); + + return $response['result'] ?? []; + } +} diff --git a/src/Client/Transport/HttpClientTransport.php b/src/Client/Transport/HttpClientTransport.php new file mode 100644 index 000000000..8d3222ee8 --- /dev/null +++ b/src/Client/Transport/HttpClientTransport.php @@ -0,0 +1,106 @@ + $headers + */ + public function __construct( + protected string $url, + protected array $headers = [], + protected float $timeout = 30, + ?Factory $httpFactory = null, + ) { + $this->http = $httpFactory ?? new Factory; + } + + public function connect(): void + { + $this->connected = true; + } + + public function send(string $message): string + { + $this->ensureConnected(); + + $response = $this->http + ->timeout((int) $this->timeout) + ->withHeaders($this->buildHeaders()) + ->withBody($message, 'application/json') + ->post($this->url); + + if ($response->header('MCP-Session-Id')) { + $this->sessionId = $response->header('MCP-Session-Id'); + } + + if (! $response->successful()) { + throw new ClientException("HTTP request failed with status {$response->status()}."); + } + + return $response->body(); + } + + public function notify(string $message): void + { + $this->ensureConnected(); + + $response = $this->http + ->timeout((int) $this->timeout) + ->withHeaders($this->buildHeaders()) + ->withBody($message, 'application/json') + ->post($this->url); + + if ($response->header('MCP-Session-Id')) { + $this->sessionId = $response->header('MCP-Session-Id'); + } + } + + public function disconnect(): void + { + $this->connected = false; + $this->sessionId = null; + } + + public function isConnected(): bool + { + return $this->connected; + } + + /** + * @return array + */ + protected function buildHeaders(): array + { + $headers = array_merge($this->headers, [ + 'Accept' => 'application/json, text/event-stream', + ]); + + if ($this->sessionId !== null) { + $headers['MCP-Session-Id'] = $this->sessionId; + } + + return $headers; + } + + protected function ensureConnected(): void + { + if (! $this->connected) { + throw new ConnectionException('Not connected.'); + } + } +} diff --git a/src/Client/Transport/StdioClientTransport.php b/src/Client/Transport/StdioClientTransport.php new file mode 100644 index 000000000..1738b8772 --- /dev/null +++ b/src/Client/Transport/StdioClientTransport.php @@ -0,0 +1,113 @@ + */ + protected array $pipes = []; + + /** + * @param array $args + * @param array $env + */ + public function __construct( + protected string $command, + protected array $args = [], + protected ?string $workingDirectory = null, + protected array $env = [], + protected float $timeout = 30, + ) { + // + } + + public function connect(): void + { + $command = implode(' ', array_map(escapeshellarg(...), [$this->command, ...$this->args])); + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $env = $this->env !== [] ? $this->env : null; + + $process = proc_open($command, $descriptors, $this->pipes, $this->workingDirectory, $env); + + if (! is_resource($process)) { + throw new ConnectionException("Failed to start process: {$command}"); + } + + $this->process = $process; + + stream_set_blocking($this->pipes[1], true); + } + + public function send(string $message): string + { + $this->ensureConnected(); + + fwrite($this->pipes[0], $message."\n"); + fflush($this->pipes[0]); + + $response = fgets($this->pipes[1]); + + if ($response === false) { + throw new ConnectionException('Failed to read response from process.'); + } + + return trim($response); + } + + public function notify(string $message): void + { + $this->ensureConnected(); + + fwrite($this->pipes[0], $message."\n"); + fflush($this->pipes[0]); + } + + public function disconnect(): void + { + foreach ($this->pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + + $this->pipes = []; + + if (is_resource($this->process)) { + proc_close($this->process); + } + + $this->process = null; + } + + public function isConnected(): bool + { + if (! is_resource($this->process)) { + return false; + } + + $status = proc_get_status($this->process); + + return $status['running']; + } + + protected function ensureConnected(): void + { + if (! $this->isConnected()) { + throw new ConnectionException('Not connected to process.'); + } + } +} diff --git a/src/Facades/Mcp.php b/src/Facades/Mcp.php index 610f5af0f..5a7562588 100644 --- a/src/Facades/Mcp.php +++ b/src/Facades/Mcp.php @@ -6,6 +6,7 @@ use Illuminate\Routing\Route; use Illuminate\Support\Facades\Facade; +use Laravel\Mcp\Client\Client; use Laravel\Mcp\Server\Registrar; /** @@ -13,6 +14,7 @@ * @method static Route web(string $handle, string $serverClass) * @method static callable|null getLocalServer(string $handle) * @method static string|null getWebServer(string $handle) + * @method static Client client(string $name) * * @see Registrar */ diff --git a/src/Server/McpServiceProvider.php b/src/Server/McpServiceProvider.php index 6bf49a866..a29f61332 100644 --- a/src/Server/McpServiceProvider.php +++ b/src/Server/McpServiceProvider.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Laravel\Mcp\Client\ClientManager; use Laravel\Mcp\Console\Commands\InspectorCommand; use Laravel\Mcp\Console\Commands\MakePromptCommand; use Laravel\Mcp\Console\Commands\MakeResourceCommand; @@ -20,6 +21,8 @@ public function register(): void { $this->app->singleton(Registrar::class, fn (): Registrar => new Registrar); + $this->app->singleton(ClientManager::class, fn (): ClientManager => new ClientManager); + $this->mergeConfigFrom(__DIR__.'/../../config/mcp.php', 'mcp'); } @@ -28,6 +31,7 @@ public function boot(): void $this->registerMcpScope(); $this->registerRoutes(); $this->registerContainerCallbacks(); + $this->registerTerminatingCallback(); if ($this->app->runningInConsole()) { $this->registerCommands(); @@ -86,6 +90,15 @@ protected function registerContainerCallbacks(): void }); } + protected function registerTerminatingCallback(): void + { + $this->app->terminating(function (): void { + if ($this->app->bound(ClientManager::class)) { + $this->app->make(ClientManager::class)->purge(); + } + }); + } + protected function registerCommands(): void { $this->commands([ diff --git a/src/Server/Registrar.php b/src/Server/Registrar.php index b51138bd3..96f435c7a 100644 --- a/src/Server/Registrar.php +++ b/src/Server/Registrar.php @@ -8,6 +8,8 @@ use Illuminate\Routing\Route; use Illuminate\Support\Facades\Route as Router; use Illuminate\Support\Str; +use Laravel\Mcp\Client\Client; +use Laravel\Mcp\Client\ClientManager; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Contracts\Transport; use Laravel\Mcp\Server\Http\Controllers\OAuthRegisterController; @@ -124,6 +126,11 @@ public static function ensureMcpScope(): array return $current; } + public function client(string $name): Client + { + return Container::getInstance()->make(ClientManager::class)->client($name); + } + /** * @param class-string $serverClass * @param callable(): Transport $transportFactory diff --git a/src/Server/Transport/JsonRpcNotification.php b/src/Server/Transport/JsonRpcNotification.php index cbe4a7a4f..471de0c00 100644 --- a/src/Server/Transport/JsonRpcNotification.php +++ b/src/Server/Transport/JsonRpcNotification.php @@ -5,19 +5,10 @@ namespace Laravel\Mcp\Server\Transport; use Laravel\Mcp\Server\Exceptions\JsonRpcException; +use Laravel\Mcp\Transport\JsonRpcNotification as BaseJsonRpcNotification; -class JsonRpcNotification +class JsonRpcNotification extends BaseJsonRpcNotification { - /** - * @param array $params - */ - public function __construct( - public string $method, - public array $params, - ) { - // - } - /** * @param array{jsonrpc?: mixed, method?: mixed, params?: array} $jsonRequest * diff --git a/src/Server/Transport/JsonRpcRequest.php b/src/Server/Transport/JsonRpcRequest.php index 9d1ebe435..5cba0e444 100644 --- a/src/Server/Transport/JsonRpcRequest.php +++ b/src/Server/Transport/JsonRpcRequest.php @@ -4,23 +4,11 @@ namespace Laravel\Mcp\Server\Transport; -use Laravel\Mcp\Request; use Laravel\Mcp\Server\Exceptions\JsonRpcException; +use Laravel\Mcp\Transport\JsonRpcRequest as BaseJsonRpcRequest; -class JsonRpcRequest +class JsonRpcRequest extends BaseJsonRpcRequest { - /** - * @param array $params - */ - public function __construct( - public int|string $id, - public string $method, - public array $params, - public ?string $sessionId = null - ) { - // - } - /** * @param array{id: mixed, jsonrpc?: mixed, method?: mixed, params?: array} $jsonRequest * @@ -49,27 +37,4 @@ public static function from(array $jsonRequest, ?string $sessionId = null): stat sessionId: $sessionId, ); } - - public function cursor(): ?string - { - return $this->get('cursor'); - } - - public function get(string $key, mixed $default = null): mixed - { - return $this->params[$key] ?? $default; - } - - /** - * @return array|null - */ - public function meta(): ?array - { - return isset($this->params['_meta']) && is_array($this->params['_meta']) ? $this->params['_meta'] : null; - } - - public function toRequest(): Request - { - return new Request($this->params['arguments'] ?? [], $this->sessionId, $this->meta()); - } } diff --git a/src/Server/Transport/JsonRpcResponse.php b/src/Server/Transport/JsonRpcResponse.php index 86be882ad..7b7189d01 100644 --- a/src/Server/Transport/JsonRpcResponse.php +++ b/src/Server/Transport/JsonRpcResponse.php @@ -4,73 +4,9 @@ namespace Laravel\Mcp\Server\Transport; -use Illuminate\Contracts\Support\Arrayable; +use Laravel\Mcp\Transport\JsonRpcResponse as BaseJsonRpcResponse; -/** - * @implements Arrayable - */ -class JsonRpcResponse implements Arrayable +class JsonRpcResponse extends BaseJsonRpcResponse { - /** - * @param array $content - */ - public function __construct(protected array $content = []) {} - - /** - * @param array $result - */ - public static function result(int|string $id, array $result): static - { - return new static([ - 'id' => $id, - 'result' => $result === [] ? (object) [] : $result, - ]); - } - - /** - * @param array $params - */ - public static function notification(string $method, array $params): static - { - return new static([ - 'method' => $method, - 'params' => $params === [] ? (object) [] : $params, - ]); - } - - /** - * @param array|null $data - */ - public static function error(string|int|null $id, int $code, string $message, ?array $data = null): static - { - $error = [ - 'code' => $code, - 'message' => $message, - ]; - - if ($data !== null) { - $error['data'] = $data; - } - - return new static([ - ...$id === null ? [] : ['id' => $id], - 'error' => $error, - ]); - } - - /** - * @return array - */ - public function toArray(): array - { - return [ - 'jsonrpc' => '2.0', - ...$this->content, - ]; - } - - public function toJson(int $options = 0): string - { - return json_encode($this->toArray(), $options) ?: ''; - } + // } diff --git a/src/Transport/JsonRpcNotification.php b/src/Transport/JsonRpcNotification.php new file mode 100644 index 000000000..20bac7d5a --- /dev/null +++ b/src/Transport/JsonRpcNotification.php @@ -0,0 +1,35 @@ + $params + */ + public function __construct( + public string $method, + public array $params, + ) { + // + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'jsonrpc' => '2.0', + 'method' => $this->method, + ...($this->params !== [] ? ['params' => $this->params] : []), + ]; + } + + public function toJson(): string + { + return json_encode($this->toArray()) ?: ''; + } +} diff --git a/src/Transport/JsonRpcRequest.php b/src/Transport/JsonRpcRequest.php new file mode 100644 index 000000000..bbd5894ca --- /dev/null +++ b/src/Transport/JsonRpcRequest.php @@ -0,0 +1,63 @@ + $params + */ + public function __construct( + public int|string $id, + public string $method, + public array $params, + public ?string $sessionId = null + ) { + // + } + + public function cursor(): ?string + { + return $this->get('cursor'); + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->params[$key] ?? $default; + } + + /** + * @return array|null + */ + public function meta(): ?array + { + return isset($this->params['_meta']) && is_array($this->params['_meta']) ? $this->params['_meta'] : null; + } + + public function toRequest(): Request + { + return new Request($this->params['arguments'] ?? [], $this->sessionId, $this->meta()); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'jsonrpc' => '2.0', + 'id' => $this->id, + 'method' => $this->method, + ...($this->params !== [] ? ['params' => $this->params] : []), + ]; + } + + public function toJson(): string + { + return json_encode($this->toArray()) ?: ''; + } +} diff --git a/src/Transport/JsonRpcResponse.php b/src/Transport/JsonRpcResponse.php new file mode 100644 index 000000000..af9acd1c7 --- /dev/null +++ b/src/Transport/JsonRpcResponse.php @@ -0,0 +1,84 @@ + + */ +class JsonRpcResponse implements Arrayable +{ + /** + * @param array $content + */ + public function __construct(protected array $content = []) {} + + /** + * @param array $result + */ + public static function result(int|string $id, array $result): static + { + return new static([ + 'id' => $id, + 'result' => $result === [] ? (object) [] : $result, + ]); + } + + /** + * @param array $params + */ + public static function notification(string $method, array $params): static + { + return new static([ + 'method' => $method, + 'params' => $params === [] ? (object) [] : $params, + ]); + } + + /** + * @param array|null $data + */ + public static function error(string|int|null $id, int $code, string $message, ?array $data = null): static + { + $error = [ + 'code' => $code, + 'message' => $message, + ]; + + if ($data !== null) { + $error['data'] = $data; + } + + return new static([ + ...$id === null ? [] : ['id' => $id], + 'error' => $error, + ]); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'jsonrpc' => '2.0', + ...$this->content, + ]; + } + + public function toJson(int $options = 0): string + { + return json_encode($this->toArray(), $options) ?: ''; + } + + /** + * @return array + */ + public static function fromJson(string $json): array + { + return json_decode($json, true) ?: []; + } +} diff --git a/tests/Fixtures/FakeClientTransport.php b/tests/Fixtures/FakeClientTransport.php new file mode 100644 index 000000000..4067b34b1 --- /dev/null +++ b/tests/Fixtures/FakeClientTransport.php @@ -0,0 +1,80 @@ + */ + protected array $sentMessages = []; + + /** @var array */ + protected array $notifications = []; + + protected bool $connected = false; + + /** + * @param array $responses + */ + public function __construct(protected array $responses = []) {} + + public function queueResponse(string $response): static + { + $this->responses[] = $response; + + return $this; + } + + public function connect(): void + { + $this->connected = true; + } + + public function send(string $message): string + { + if (! $this->connected) { + throw new ConnectionException('Not connected.'); + } + + $this->sentMessages[] = $message; + + return array_shift($this->responses) ?? '{}'; + } + + public function notify(string $message): void + { + if (! $this->connected) { + throw new ConnectionException('Not connected.'); + } + + $this->notifications[] = $message; + } + + public function disconnect(): void + { + $this->connected = false; + } + + public function isConnected(): bool + { + return $this->connected; + } + + /** + * @return array + */ + public function sentMessages(): array + { + return $this->sentMessages; + } + + /** + * @return array + */ + public function notifications(): array + { + return $this->notifications; + } +} diff --git a/tests/Unit/Client/ClientContextTest.php b/tests/Unit/Client/ClientContextTest.php new file mode 100644 index 000000000..443a5e40a --- /dev/null +++ b/tests/Unit/Client/ClientContextTest.php @@ -0,0 +1,97 @@ + '2.0', 'id' => 1, 'result' => ['data' => 'first']]), + json_encode(['jsonrpc' => '2.0', 'id' => 2, 'result' => ['data' => 'second']]), + ]); + + $transport->connect(); + + $context = new ClientContext($transport, 'test-client'); + + $context->sendRequest('tools/list'); + $context->sendRequest('tools/call', ['name' => 'test']); + + $sent = $transport->sentMessages(); + expect($sent)->toHaveCount(2); + + $first = json_decode($sent[0], true); + $second = json_decode($sent[1], true); + + expect($first['id'])->toBe(1) + ->and($second['id'])->toBe(2); +}); + +it('throws client exception on error response', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'error' => ['code' => -32602, 'message' => 'Invalid params'], + ]), + ]); + + $transport->connect(); + + $context = new ClientContext($transport, 'test-client'); + + $context->sendRequest('initialize'); +})->throws(ClientException::class, 'Invalid params'); + +it('sends notifications via transport', function (): void { + $transport = new FakeClientTransport; + $transport->connect(); + + $context = new ClientContext($transport, 'test-client'); + + $context->notify('notifications/initialized'); + + $notifications = $transport->notifications(); + expect($notifications)->toHaveCount(1); + + $notification = json_decode($notifications[0], true); + expect($notification['method'])->toBe('notifications/initialized') + ->and($notification['jsonrpc'])->toBe('2.0'); +}); + +it('sends notifications with params', function (): void { + $transport = new FakeClientTransport; + $transport->connect(); + + $context = new ClientContext($transport, 'test-client'); + + $context->notify('notifications/progress', ['token' => 'abc', 'progress' => 50]); + + $notifications = $transport->notifications(); + $notification = json_decode($notifications[0], true); + + expect($notification['params'])->toBe(['token' => 'abc', 'progress' => 50]); +}); + +it('resets request id counter', function (): void { + $transport = new FakeClientTransport([ + json_encode(['jsonrpc' => '2.0', 'id' => 1, 'result' => []]), + json_encode(['jsonrpc' => '2.0', 'id' => 1, 'result' => []]), + ]); + + $transport->connect(); + + $context = new ClientContext($transport, 'test-client'); + + $context->sendRequest('initialize'); + + $first = json_decode($transport->sentMessages()[0], true); + expect($first['id'])->toBe(1); + + $context->resetRequestId(); + + $context->sendRequest('initialize'); + + $second = json_decode($transport->sentMessages()[1], true); + expect($second['id'])->toBe(1); +}); diff --git a/tests/Unit/Client/ClientManagerTest.php b/tests/Unit/Client/ClientManagerTest.php new file mode 100644 index 000000000..755dfea86 --- /dev/null +++ b/tests/Unit/Client/ClientManagerTest.php @@ -0,0 +1,148 @@ +createTransport([ + 'transport' => 'stdio', + 'command' => 'php', + 'args' => ['artisan', 'mcp:start', 'example'], + 'timeout' => 15, + ]); + + expect($transport)->toBeInstanceOf(StdioClientTransport::class); +}); + +it('creates http transport from config', function (): void { + $manager = new ClientManager; + + $transport = $manager->createTransport([ + 'transport' => 'http', + 'url' => 'https://example.com/mcp', + 'headers' => ['Authorization' => 'Bearer token'], + 'timeout' => 60, + ]); + + expect($transport)->toBeInstanceOf(HttpClientTransport::class); +}); + +it('throws for unsupported transport', function (): void { + $manager = new ClientManager; + + $manager->createTransport([ + 'transport' => 'websocket', + ]); +})->throws(InvalidArgumentException::class, 'Unsupported MCP transport [websocket].'); + +it('throws for unconfigured server', function (): void { + $manager = new ClientManager; + + $manager->client('nonexistent'); +})->throws(InvalidArgumentException::class, 'MCP server [nonexistent] is not configured.'); + +it('defaults to stdio transport', function (): void { + $manager = new ClientManager; + + $transport = $manager->createTransport([ + 'command' => 'php', + ]); + + expect($transport)->toBeInstanceOf(StdioClientTransport::class); +}); + +it('purges all clients', function (): void { + $manager = new ClientManager; + + $initResponse = json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => [], + 'serverInfo' => ['name' => 'test', 'version' => '1.0.0'], + ], + ]); + + $transport1 = new FakeClientTransport([$initResponse]); + $client1 = new Client($transport1, 'server-a'); + $client1->connect(); + + $transport2 = new FakeClientTransport([$initResponse]); + $client2 = new Client($transport2, 'server-b'); + $client2->connect(); + + $reflection = new ReflectionClass($manager); + $property = $reflection->getProperty('clients'); + $property->setValue($manager, ['server-a' => $client1, 'server-b' => $client2]); + + $manager->purge(); + + expect($client1->isConnected())->toBeFalse() + ->and($client2->isConnected())->toBeFalse(); +}); + +it('purges a specific client', function (): void { + $manager = new ClientManager; + + $initResponse = json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => [], + 'serverInfo' => ['name' => 'test', 'version' => '1.0.0'], + ], + ]); + + $transport = new FakeClientTransport([$initResponse]); + $client = new Client($transport, 'server-a'); + $client->connect(); + + $reflection = new ReflectionClass($manager); + $property = $reflection->getProperty('clients'); + $property->setValue($manager, ['server-a' => $client]); + + $manager->purge('server-a'); + + expect($client->isConnected())->toBeFalse(); +}); + +it('purge ignores nonexistent named client', function (): void { + $manager = new ClientManager; + + $manager->purge('nonexistent'); + + expect(true)->toBeTrue(); +}); + +it('returns cached client on second call', function (): void { + $manager = new ClientManager; + + $initResponse = json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => [], + 'serverInfo' => ['name' => 'test', 'version' => '1.0.0'], + ], + ]); + + $transport = new FakeClientTransport([$initResponse]); + $client = new Client($transport, 'test-server'); + $client->connect(); + + $reflection = new ReflectionClass($manager); + $property = $reflection->getProperty('clients'); + $property->setValue($manager, ['test-server' => $client]); + + $result = $manager->client('test-server'); + + expect($result)->toBe($client); +}); diff --git a/tests/Unit/Client/ClientTest.php b/tests/Unit/Client/ClientTest.php new file mode 100644 index 000000000..2317e3217 --- /dev/null +++ b/tests/Unit/Client/ClientTest.php @@ -0,0 +1,219 @@ + '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => ['tools' => []], + 'serverInfo' => ['name' => 'test', 'version' => '1.0.0'], + ], + ]), + ]); + + $client = new Client($transport, 'test-client'); + $client->connect(); + + expect($client->isConnected())->toBeTrue() + ->and($client->serverInfo())->toBe(['name' => 'test', 'version' => '1.0.0']) + ->and($client->serverCapabilities())->toBe(['tools' => []]); + + $sent = $transport->sentMessages(); + expect($sent)->toHaveCount(1); + + $initRequest = json_decode($sent[0], true); + expect($initRequest['method'])->toBe('initialize') + ->and($initRequest['params']['clientInfo']['name'])->toBe('test-client'); + + $notifications = $transport->notifications(); + expect($notifications)->toHaveCount(1); + + $initNotification = json_decode($notifications[0], true); + expect($initNotification['method'])->toBe('notifications/initialized'); +}); + +it('lists tools', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => ['tools' => []], + 'serverInfo' => ['name' => 'test', 'version' => '1.0.0'], + ], + ]), + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 2, + 'result' => [ + 'tools' => [ + ['name' => 'say-hi', 'description' => 'Says hello', 'inputSchema' => ['type' => 'object']], + ['name' => 'ping', 'description' => 'Pings', 'inputSchema' => ['type' => 'object']], + ], + ], + ]), + ]); + + $client = new Client($transport, 'test-client'); + $client->connect(); + + $tools = $client->tools(); + + expect($tools)->toHaveCount(2) + ->and($tools->first())->toBeInstanceOf(ClientTool::class) + ->and($tools->first()->name())->toBe('say-hi') + ->and($tools->first()->description())->toBe('Says hello') + ->and($tools->last()->name())->toBe('ping'); +}); + +it('calls a tool', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => [], + 'serverInfo' => ['name' => 'test', 'version' => '1.0.0'], + ], + ]), + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 2, + 'result' => [ + 'content' => [['type' => 'text', 'text' => 'Hello, World!']], + 'isError' => false, + ], + ]), + ]); + + $client = new Client($transport, 'test-client'); + $client->connect(); + + $result = $client->callTool('say-hi', ['name' => 'World']); + + expect($result)->toHaveKey('content') + ->and($result['content'][0]['text'])->toBe('Hello, World!'); + + $sent = $transport->sentMessages(); + $callRequest = json_decode($sent[1], true); + expect($callRequest['method'])->toBe('tools/call') + ->and($callRequest['params']['name'])->toBe('say-hi') + ->and($callRequest['params']['arguments'])->toBe(['name' => 'World']); +}); + +it('throws exception on error response', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'error' => [ + 'code' => -32602, + 'message' => 'Tool not found.', + ], + ]), + ]); + + $client = new Client($transport, 'test-client'); + + $client->connect(); +})->throws(ClientException::class, 'Tool not found.'); + +it('disconnects and resets state', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => [], + 'serverInfo' => ['name' => 'test', 'version' => '1.0.0'], + ], + ]), + ]); + + $client = new Client($transport, 'test-client'); + $client->connect(); + + expect($client->isConnected())->toBeTrue(); + + $client->disconnect(); + + expect($client->isConnected())->toBeFalse() + ->and($client->serverInfo())->toBeNull() + ->and($client->serverCapabilities())->toBeNull(); +}); + +it('reports not connected before connect is called', function (): void { + $transport = new FakeClientTransport; + + $client = new Client($transport, 'test-client'); + + expect($client->isConnected())->toBeFalse(); +}); + +it('caches tools when cache ttl is set', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => [], + 'serverInfo' => ['name' => 'test', 'version' => '1.0.0'], + ], + ]), + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 2, + 'result' => [ + 'tools' => [ + ['name' => 'cached-tool', 'description' => 'A cached tool'], + ], + ], + ]), + ]); + + $client = new Client($transport, 'cache-test', cacheTtl: 300); + $client->connect(); + + $tools = $client->tools(); + expect($tools)->toHaveCount(1) + ->and($tools->first()->name())->toBe('cached-tool'); + + $toolsAgain = $client->tools(); + expect($toolsAgain)->toHaveCount(1); + + expect($transport->sentMessages())->toHaveCount(2); + + $client->clearCache(); +}); + +it('clears cache', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => [], + 'serverInfo' => ['name' => 'test', 'version' => '1.0.0'], + ], + ]), + ]); + + $client = new Client($transport, 'clear-test', cacheTtl: 300); + $client->connect(); + + $client->clearCache(); + + expect(true)->toBeTrue(); +}); diff --git a/tests/Unit/Client/ClientToolTest.php b/tests/Unit/Client/ClientToolTest.php new file mode 100644 index 000000000..865d1d4b2 --- /dev/null +++ b/tests/Unit/Client/ClientToolTest.php @@ -0,0 +1,112 @@ + 'say-hi', + 'description' => 'Says hello', + 'title' => 'Say Hi', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + ], + ], + ], $client); + + expect($tool->name())->toBe('say-hi') + ->and($tool->description())->toBe('Says hello') + ->and($tool->title())->toBe('Say Hi'); +}); + +it('converts to array with remote schema', function (): void { + $transport = new FakeClientTransport; + $client = new Client($transport, 'test'); + + $inputSchema = [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'description' => 'The name'], + ], + 'required' => ['name'], + ]; + + $tool = ClientTool::fromArray([ + 'name' => 'say-hi', + 'description' => 'Says hello', + 'title' => 'Say Hi', + 'inputSchema' => $inputSchema, + ], $client); + + $array = $tool->toArray(); + + expect($array['name'])->toBe('say-hi') + ->and($array['description'])->toBe('Says hello') + ->and($array['inputSchema'])->toBe($inputSchema); +}); + +it('handles request by proxying to client', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => [], + 'serverInfo' => ['name' => 'test', 'version' => '1.0.0'], + ], + ]), + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 2, + 'result' => [ + 'content' => [['type' => 'text', 'text' => 'Hello, World!']], + 'isError' => false, + ], + ]), + ]); + + $client = new Client($transport, 'test'); + $client->connect(); + + $tool = ClientTool::fromArray([ + 'name' => 'say-hi', + 'description' => 'Says hello', + ], $client); + + $request = new \Laravel\Mcp\Request(['name' => 'World']); + $response = $tool->handle($request); + + expect($response->content())->toBeInstanceOf(\Laravel\Mcp\Server\Content\Text::class); +}); + +it('exposes remote input schema', function (): void { + $transport = new FakeClientTransport; + $client = new Client($transport, 'test'); + + $schema = ['type' => 'object', 'properties' => ['x' => ['type' => 'string']]]; + + $tool = ClientTool::fromArray([ + 'name' => 'my-tool', + 'inputSchema' => $schema, + ], $client); + + expect($tool->remoteInputSchema())->toBe($schema); +}); + +it('defaults empty fields gracefully', function (): void { + $transport = new FakeClientTransport; + $client = new Client($transport, 'test'); + + $tool = ClientTool::fromArray([], $client); + + expect($tool->name())->toBe('client-tool') + ->and($tool->description())->toBe('Client Tool') + ->and($tool->remoteInputSchema())->toBe([]); +}); diff --git a/tests/Unit/Client/ExceptionsTest.php b/tests/Unit/Client/ExceptionsTest.php new file mode 100644 index 000000000..ca0934b18 --- /dev/null +++ b/tests/Unit/Client/ExceptionsTest.php @@ -0,0 +1,20 @@ +toBeInstanceOf(RuntimeException::class) + ->and($exception->getMessage())->toBe('test error') + ->and($exception->getCode())->toBe(42); +}); + +it('connection exception extends client exception', function (): void { + $exception = new ConnectionException('connection failed'); + + expect($exception)->toBeInstanceOf(ClientException::class) + ->and($exception)->toBeInstanceOf(RuntimeException::class) + ->and($exception->getMessage())->toBe('connection failed'); +}); diff --git a/tests/Unit/Client/Methods/CallToolTest.php b/tests/Unit/Client/Methods/CallToolTest.php new file mode 100644 index 000000000..782b85bef --- /dev/null +++ b/tests/Unit/Client/Methods/CallToolTest.php @@ -0,0 +1,56 @@ + '2.0', + 'id' => 1, + 'result' => [ + 'content' => [['type' => 'text', 'text' => 'Hello, World!']], + 'isError' => false, + ], + ]), + ]); + + $transport->connect(); + + $context = new ClientContext($transport, 'test-client'); + $handler = new CallTool; + + $handler->handle($context, ['name' => 'say-hi', 'arguments' => ['name' => 'World']]); + + $sent = $transport->sentMessages(); + $request = json_decode($sent[0], true); + + expect($request['method'])->toBe('tools/call') + ->and($request['params']['name'])->toBe('say-hi') + ->and($request['params']['arguments'])->toBe(['name' => 'World']); +}); + +it('returns result content', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'content' => [['type' => 'text', 'text' => 'Result text']], + 'isError' => false, + ], + ]), + ]); + + $transport->connect(); + + $context = new ClientContext($transport, 'test-client'); + $handler = new CallTool; + + $result = $handler->handle($context, ['name' => 'test-tool', 'arguments' => []]); + + expect($result)->toHaveKey('content') + ->and($result['content'][0]['text'])->toBe('Result text') + ->and($result['isError'])->toBeFalse(); +}); diff --git a/tests/Unit/Client/Methods/InitializeTest.php b/tests/Unit/Client/Methods/InitializeTest.php new file mode 100644 index 000000000..ac6403d0c --- /dev/null +++ b/tests/Unit/Client/Methods/InitializeTest.php @@ -0,0 +1,87 @@ + '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => ['tools' => []], + 'serverInfo' => ['name' => 'test-server', 'version' => '1.0.0'], + ], + ]), + ]); + + $transport->connect(); + + $context = new ClientContext($transport, 'test-client'); + $handler = new Initialize; + + $handler->handle($context); + + $sent = $transport->sentMessages(); + expect($sent)->toHaveCount(1); + + $request = json_decode($sent[0], true); + expect($request['method'])->toBe('initialize') + ->and($request['params']['protocolVersion'])->toBe('2025-11-25') + ->and($request['params']['clientInfo']['name'])->toBe('test-client') + ->and($request['params']['clientInfo']['version'])->toBe('1.0.0'); +}); + +it('sends notifications/initialized notification', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => [], + 'serverInfo' => ['name' => 'test-server', 'version' => '1.0.0'], + ], + ]), + ]); + + $transport->connect(); + + $context = new ClientContext($transport, 'test-client'); + $handler = new Initialize; + + $handler->handle($context); + + $notifications = $transport->notifications(); + expect($notifications)->toHaveCount(1); + + $notification = json_decode($notifications[0], true); + expect($notification['method'])->toBe('notifications/initialized'); +}); + +it('returns result with serverInfo and capabilities', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => ['tools' => [], 'prompts' => []], + 'serverInfo' => ['name' => 'my-server', 'version' => '2.0.0'], + ], + ]), + ]); + + $transport->connect(); + + $context = new ClientContext($transport, 'test-client'); + $handler = new Initialize; + + $result = $handler->handle($context); + + expect($result)->toHaveKey('serverInfo') + ->and($result['serverInfo'])->toBe(['name' => 'my-server', 'version' => '2.0.0']) + ->and($result['capabilities'])->toBe(['tools' => [], 'prompts' => []]); +}); diff --git a/tests/Unit/Client/Methods/ListToolsTest.php b/tests/Unit/Client/Methods/ListToolsTest.php new file mode 100644 index 000000000..28e1db4c7 --- /dev/null +++ b/tests/Unit/Client/Methods/ListToolsTest.php @@ -0,0 +1,53 @@ + '2.0', + 'id' => 1, + 'result' => [ + 'tools' => [ + ['name' => 'say-hi', 'description' => 'Says hello', 'inputSchema' => ['type' => 'object']], + ['name' => 'ping', 'description' => 'Pings', 'inputSchema' => ['type' => 'object']], + ], + ], + ]), + ]); + + $transport->connect(); + + $context = new ClientContext($transport, 'test-client'); + $handler = new ListTools; + + $result = $handler->handle($context); + + expect($result)->toHaveKey('tools') + ->and($result['tools'])->toHaveCount(2) + ->and($result['tools'][0]['name'])->toBe('say-hi') + ->and($result['tools'][1]['name'])->toBe('ping'); +}); + +it('handles empty tools list', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'tools' => [], + ], + ]), + ]); + + $transport->connect(); + + $context = new ClientContext($transport, 'test-client'); + $handler = new ListTools; + + $result = $handler->handle($context); + + expect($result['tools'])->toBe([]); +}); diff --git a/tests/Unit/Client/Transport/FakeClientTransportTest.php b/tests/Unit/Client/Transport/FakeClientTransportTest.php new file mode 100644 index 000000000..89ef6ddf9 --- /dev/null +++ b/tests/Unit/Client/Transport/FakeClientTransportTest.php @@ -0,0 +1,65 @@ +connect(); + + $response = $transport->send('message-1'); + + expect($response)->toBe('response-1') + ->and($transport->sentMessages())->toBe(['message-1']); +}); + +it('records notifications', function (): void { + $transport = new FakeClientTransport; + $transport->connect(); + + $transport->notify('notification-1'); + + expect($transport->notifications())->toBe(['notification-1']); +}); + +it('returns queued responses in order', function (): void { + $transport = new FakeClientTransport(['first', 'second']); + $transport->connect(); + + expect($transport->send('a'))->toBe('first') + ->and($transport->send('b'))->toBe('second') + ->and($transport->send('c'))->toBe('{}'); +}); + +it('throws when sending while disconnected', function (): void { + $transport = new FakeClientTransport(['response']); + + $transport->send('message'); +})->throws(ConnectionException::class, 'Not connected.'); + +it('throws when notifying while disconnected', function (): void { + $transport = new FakeClientTransport; + + $transport->notify('notification'); +})->throws(ConnectionException::class, 'Not connected.'); + +it('tracks connection state', function (): void { + $transport = new FakeClientTransport; + + expect($transport->isConnected())->toBeFalse(); + + $transport->connect(); + expect($transport->isConnected())->toBeTrue(); + + $transport->disconnect(); + expect($transport->isConnected())->toBeFalse(); +}); + +it('queues responses dynamically', function (): void { + $transport = new FakeClientTransport; + $transport->connect(); + + $transport->queueResponse('dynamic-response'); + + expect($transport->send('message'))->toBe('dynamic-response'); +}); diff --git a/tests/Unit/Client/Transport/HttpClientTransportTest.php b/tests/Unit/Client/Transport/HttpClientTransportTest.php new file mode 100644 index 000000000..61266393d --- /dev/null +++ b/tests/Unit/Client/Transport/HttpClientTransportTest.php @@ -0,0 +1,97 @@ +fake([ + 'example.com/mcp' => $http->response('{"jsonrpc":"2.0","id":1,"result":{}}', 200), + ]); + + $transport = new HttpClientTransport('https://example.com/mcp', [], 30, $http); + $transport->connect(); + + $response = $transport->send('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'); + + expect($response)->toBe('{"jsonrpc":"2.0","id":1,"result":{}}'); +}); + +it('tracks session id from response headers', function (): void { + $http = new Factory; + $http->fake([ + 'example.com/mcp' => $http->response('{"jsonrpc":"2.0","id":1,"result":{}}', 200, [ + 'MCP-Session-Id' => 'session-abc', + ]), + ]); + + $transport = new HttpClientTransport('https://example.com/mcp', [], 30, $http); + $transport->connect(); + + $transport->send('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'); + + expect($transport->isConnected())->toBeTrue(); +}); + +it('throws on failed http response', function (): void { + $http = new Factory; + $http->fake([ + 'example.com/mcp' => $http->response('Internal Server Error', 500), + ]); + + $transport = new HttpClientTransport('https://example.com/mcp', [], 30, $http); + $transport->connect(); + + $transport->send('{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}'); +})->throws(ClientException::class, 'HTTP request failed with status 500.'); + +it('throws when sending while disconnected', function (): void { + $http = new Factory; + $transport = new HttpClientTransport('https://example.com/mcp', [], 30, $http); + + $transport->send('message'); +})->throws(ConnectionException::class, 'Not connected.'); + +it('sends notifications', function (): void { + $http = new Factory; + $http->fake([ + 'example.com/mcp' => $http->response('', 202), + ]); + + $transport = new HttpClientTransport('https://example.com/mcp', [], 30, $http); + $transport->connect(); + + $transport->notify('{"jsonrpc":"2.0","method":"notifications/initialized"}'); + + expect($transport->isConnected())->toBeTrue(); +}); + +it('manages connection state', function (): void { + $http = new Factory; + $transport = new HttpClientTransport('https://example.com/mcp', [], 30, $http); + + expect($transport->isConnected())->toBeFalse(); + + $transport->connect(); + expect($transport->isConnected())->toBeTrue(); + + $transport->disconnect(); + expect($transport->isConnected())->toBeFalse(); +}); + +it('includes custom headers', function (): void { + $http = new Factory; + $http->fake([ + 'example.com/mcp' => $http->response('{"jsonrpc":"2.0","id":1,"result":{}}', 200), + ]); + + $transport = new HttpClientTransport('https://example.com/mcp', ['Authorization' => 'Bearer test-token'], 30, $http); + $transport->connect(); + + $transport->send('{"jsonrpc":"2.0","id":1,"method":"ping"}'); + + $http->assertSent(fn ($request): bool => $request->hasHeader('Authorization', 'Bearer test-token') + && $request->hasHeader('Accept', 'application/json, text/event-stream')); +}); diff --git a/tests/Unit/Client/Transport/StdioClientTransportTest.php b/tests/Unit/Client/Transport/StdioClientTransportTest.php new file mode 100644 index 000000000..598dcfe1b --- /dev/null +++ b/tests/Unit/Client/Transport/StdioClientTransportTest.php @@ -0,0 +1,69 @@ +isConnected())->toBeFalse(); +}); + +it('throws when sending while not connected', function (): void { + $transport = new StdioClientTransport('php'); + + $transport->send('test'); +})->throws(ConnectionException::class, 'Not connected to process.'); + +it('throws when notifying while not connected', function (): void { + $transport = new StdioClientTransport('php'); + + $transport->notify('test'); +})->throws(ConnectionException::class, 'Not connected to process.'); + +it('detects disconnected process', function (): void { + $transport = new StdioClientTransport('php', ['-r', 'exit(0);']); + + $transport->connect(); + + usleep(100000); + + expect($transport->isConnected())->toBeFalse(); + + $transport->disconnect(); +}); + +it('can connect to a simple process and communicate', function (): void { + $transport = new StdioClientTransport('php', ['-r', 'while ($line = fgets(STDIN)) { echo trim($line) . "\n"; }']); + + $transport->connect(); + + expect($transport->isConnected())->toBeTrue(); + + $response = $transport->send('hello'); + expect($response)->toBe('hello'); + + $transport->disconnect(); + expect($transport->isConnected())->toBeFalse(); +}); + +it('can send notifications without reading response', function (): void { + $transport = new StdioClientTransport('php', ['-r', 'while ($line = fgets(STDIN)) { echo trim($line) . "\n"; }']); + + $transport->connect(); + + $transport->notify('{"jsonrpc":"2.0","method":"notifications/initialized"}'); + + expect($transport->isConnected())->toBeTrue(); + + $transport->disconnect(); +}); + +it('disconnect is idempotent', function (): void { + $transport = new StdioClientTransport('php', ['-r', 'echo "ok";']); + + $transport->disconnect(); + $transport->disconnect(); + + expect($transport->isConnected())->toBeFalse(); +}); diff --git a/tests/Unit/Transport/BaseJsonRpcNotificationTest.php b/tests/Unit/Transport/BaseJsonRpcNotificationTest.php new file mode 100644 index 000000000..c7f1cfd7d --- /dev/null +++ b/tests/Unit/Transport/BaseJsonRpcNotificationTest.php @@ -0,0 +1,41 @@ +method)->toBe('notifications/initialized') + ->and($notification->params)->toBe([]); +}); + +it('serializes to array', function (): void { + $notification = new JsonRpcNotification('notifications/progress', ['progress' => 50]); + + expect($notification->toArray())->toBe([ + 'jsonrpc' => '2.0', + 'method' => 'notifications/progress', + 'params' => ['progress' => 50], + ]); +}); + +it('omits params when empty', function (): void { + $notification = new JsonRpcNotification('notifications/initialized', []); + + expect($notification->toArray())->toBe([ + 'jsonrpc' => '2.0', + 'method' => 'notifications/initialized', + ]); +}); + +it('serializes to json', function (): void { + $notification = new JsonRpcNotification('notifications/progress', ['progress' => 75]); + + $json = $notification->toJson(); + + expect(json_decode($json, true))->toBe([ + 'jsonrpc' => '2.0', + 'method' => 'notifications/progress', + 'params' => ['progress' => 75], + ]); +}); diff --git a/tests/Unit/Transport/BaseJsonRpcRequestTest.php b/tests/Unit/Transport/BaseJsonRpcRequestTest.php new file mode 100644 index 000000000..92eb948f3 --- /dev/null +++ b/tests/Unit/Transport/BaseJsonRpcRequestTest.php @@ -0,0 +1,69 @@ + 'echo']); + + expect($request->id)->toBe(1) + ->and($request->method)->toBe('tools/call') + ->and($request->params)->toBe(['name' => 'echo']) + ->and($request->sessionId)->toBeNull(); +}); + +it('serializes to array', function (): void { + $request = new JsonRpcRequest(1, 'tools/call', ['name' => 'echo']); + + expect($request->toArray())->toBe([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => 'echo'], + ]); +}); + +it('omits params when empty', function (): void { + $request = new JsonRpcRequest(1, 'initialize', []); + + expect($request->toArray())->toBe([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + ]); +}); + +it('serializes to json', function (): void { + $request = new JsonRpcRequest(1, 'ping', []); + + $json = $request->toJson(); + + expect(json_decode($json, true))->toBe([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'ping', + ]); +}); + +it('delegates get cursor and meta to base class', function (): void { + $request = new JsonRpcRequest(1, 'tools/list', [ + 'cursor' => 'abc', + '_meta' => ['token' => '123'], + ]); + + expect($request->cursor())->toBe('abc') + ->and($request->get('cursor'))->toBe('abc') + ->and($request->meta())->toBe(['token' => '123']); +}); + +it('converts to mcp request', function (): void { + $request = new JsonRpcRequest(1, 'tools/call', [ + 'arguments' => ['name' => 'John'], + '_meta' => ['requestId' => '456'], + ], 'session-1'); + + $mcpRequest = $request->toRequest(); + + expect($mcpRequest->get('name'))->toBe('John') + ->and($mcpRequest->sessionId())->toBe('session-1') + ->and($mcpRequest->meta())->toBe(['requestId' => '456']); +}); diff --git a/tests/Unit/Transport/BaseJsonRpcResponseTest.php b/tests/Unit/Transport/BaseJsonRpcResponseTest.php new file mode 100644 index 000000000..50f634754 --- /dev/null +++ b/tests/Unit/Transport/BaseJsonRpcResponseTest.php @@ -0,0 +1,58 @@ + '2.0', + 'id' => 1, + 'result' => ['tools' => []], + ]); + + $result = JsonRpcResponse::fromJson($json); + + expect($result)->toBe([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => ['tools' => []], + ]); +}); + +it('returns empty array for invalid json', function (): void { + $result = JsonRpcResponse::fromJson('not-json'); + + expect($result)->toBe([]); +}); + +it('creates result response', function (): void { + $response = JsonRpcResponse::result(1, ['foo' => 'bar']); + + expect($response->toArray())->toBe([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => ['foo' => 'bar'], + ]); +}); + +it('creates error response', function (): void { + $response = JsonRpcResponse::error(1, -32600, 'Invalid Request'); + + expect($response->toArray())->toBe([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'error' => [ + 'code' => -32600, + 'message' => 'Invalid Request', + ], + ]); +}); + +it('creates notification response', function (): void { + $response = JsonRpcResponse::notification('notifications/progress', ['progress' => 50]); + + expect($response->toArray())->toBe([ + 'jsonrpc' => '2.0', + 'method' => 'notifications/progress', + 'params' => ['progress' => 50], + ]); +}); From d5acc693229994a9386b1cc800ba3238cf3f5629 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 19 Feb 2026 00:27:06 +0530 Subject: [PATCH 2/3] Update StdioClientTransportTest.php --- tests/Unit/Client/Transport/StdioClientTransportTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Client/Transport/StdioClientTransportTest.php b/tests/Unit/Client/Transport/StdioClientTransportTest.php index 598dcfe1b..1a4f2f324 100644 --- a/tests/Unit/Client/Transport/StdioClientTransportTest.php +++ b/tests/Unit/Client/Transport/StdioClientTransportTest.php @@ -34,7 +34,7 @@ }); it('can connect to a simple process and communicate', function (): void { - $transport = new StdioClientTransport('php', ['-r', 'while ($line = fgets(STDIN)) { echo trim($line) . "\n"; }']); + $transport = new StdioClientTransport('php', ['-r', 'while ($line = fgets(STDIN)) { echo trim($line) . PHP_EOL; }']); $transport->connect(); @@ -48,7 +48,7 @@ }); it('can send notifications without reading response', function (): void { - $transport = new StdioClientTransport('php', ['-r', 'while ($line = fgets(STDIN)) { echo trim($line) . "\n"; }']); + $transport = new StdioClientTransport('php', ['-r', 'while ($line = fgets(STDIN)) { echo trim($line) . PHP_EOL; }']); $transport->connect(); From 90c363aacd13ba09507745872f5026c7d261b0f7 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Thu, 19 Feb 2026 00:51:24 +0530 Subject: [PATCH 3/3] Fix MCP client tool issues and add ping support --- src/Client/Client.php | 26 +++- src/Client/ClientContext.php | 4 + src/Client/ClientManager.php | 1 + src/Client/Methods/Initialize.php | 2 +- src/Client/Methods/ListTools.php | 2 +- src/Client/Methods/Ping.php | 22 +++ src/Client/Transport/HttpClientTransport.php | 54 ++++---- src/Client/Transport/StdioClientTransport.php | 20 ++- tests/Unit/Client/ClientTest.php | 125 ++++++++++++++++++ tests/Unit/Client/Methods/ListToolsTest.php | 27 ++++ tests/Unit/Client/Methods/PingTest.php | 46 +++++++ .../Transport/HttpClientTransportTest.php | 31 +++++ .../Transport/StdioClientTransportTest.php | 8 ++ 13 files changed, 331 insertions(+), 37 deletions(-) create mode 100644 src/Client/Methods/Ping.php create mode 100644 tests/Unit/Client/Methods/PingTest.php diff --git a/src/Client/Client.php b/src/Client/Client.php index 990e440a3..25836ae41 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -10,6 +10,7 @@ use Laravel\Mcp\Client\Methods\CallTool; use Laravel\Mcp\Client\Methods\Initialize; use Laravel\Mcp\Client\Methods\ListTools; +use Laravel\Mcp\Client\Methods\Ping; class Client { @@ -28,15 +29,20 @@ class Client 'initialize' => Initialize::class, 'tools/list' => ListTools::class, 'tools/call' => CallTool::class, + 'ping' => Ping::class, ]; + /** + * @param array $capabilities + */ public function __construct( protected ClientTransport $transport, protected string $name = 'laravel-mcp-client', protected ?int $cacheTtl = null, protected string $protocolVersion = '2025-11-25', + protected array $capabilities = [], ) { - $this->context = new ClientContext($transport, $this->name, $this->protocolVersion); + $this->context = new ClientContext($transport, $this->name, $this->protocolVersion, $this->capabilities); } public function connect(): static @@ -72,13 +78,18 @@ public function tools(): Collection return Cache::get($cacheKey); } - $result = $this->callMethod('tools/list'); + $allTools = []; + $cursor = null; - /** @var array> $toolDefinitions */ - $toolDefinitions = $result['tools'] ?? []; + do { + $params = $cursor !== null ? ['cursor' => $cursor] : []; + $result = $this->callMethod('tools/list', $params); + array_push($allTools, ...($result['tools'] ?? [])); + $cursor = $result['nextCursor'] ?? null; + } while ($cursor !== null); /** @var Collection $tools */ - $tools = collect($toolDefinitions)->map( + $tools = collect($allTools)->map( fn (array $definition): ClientTool => ClientTool::fromArray($definition, $this) ); @@ -122,6 +133,11 @@ public function isConnected(): bool return $this->initialized && $this->transport->isConnected(); } + public function ping(): void + { + $this->callMethod('ping'); + } + public function clearCache(): void { Cache::forget("mcp-client:{$this->name}:tools"); diff --git a/src/Client/ClientContext.php b/src/Client/ClientContext.php index 247d2b393..3a8409ae0 100644 --- a/src/Client/ClientContext.php +++ b/src/Client/ClientContext.php @@ -14,10 +14,14 @@ class ClientContext { protected int $requestId = 0; + /** + * @param array $capabilities + */ public function __construct( protected ClientTransport $transport, public string $clientName, public string $protocolVersion = '2025-11-25', + public array $capabilities = [], ) {} /** diff --git a/src/Client/ClientManager.php b/src/Client/ClientManager.php index 44b0fb594..500e45b15 100644 --- a/src/Client/ClientManager.php +++ b/src/Client/ClientManager.php @@ -34,6 +34,7 @@ public function client(string $name): Client name: $name, cacheTtl: isset($config['cache_ttl']) ? (int) $config['cache_ttl'] : null, protocolVersion: (string) config('mcp.protocol_version', '2025-11-25'), + capabilities: (array) ($config['capabilities'] ?? []), ); $client->connect(); diff --git a/src/Client/Methods/Initialize.php b/src/Client/Methods/Initialize.php index 4ec85d059..bccc312a7 100644 --- a/src/Client/Methods/Initialize.php +++ b/src/Client/Methods/Initialize.php @@ -17,7 +17,7 @@ public function handle(ClientContext $context, array $params = []): array { $response = $context->sendRequest('initialize', [ 'protocolVersion' => $context->protocolVersion, - 'capabilities' => (object) [], + 'capabilities' => $context->capabilities !== [] ? $context->capabilities : (object) [], 'clientInfo' => [ 'name' => $context->clientName, 'version' => '1.0.0', diff --git a/src/Client/Methods/ListTools.php b/src/Client/Methods/ListTools.php index 2ddf0aa73..dc32d36b4 100644 --- a/src/Client/Methods/ListTools.php +++ b/src/Client/Methods/ListTools.php @@ -15,7 +15,7 @@ class ListTools implements ClientMethod */ public function handle(ClientContext $context, array $params = []): array { - $response = $context->sendRequest('tools/list'); + $response = $context->sendRequest('tools/list', $params); return $response['result'] ?? []; } diff --git a/src/Client/Methods/Ping.php b/src/Client/Methods/Ping.php new file mode 100644 index 000000000..42776d705 --- /dev/null +++ b/src/Client/Methods/Ping.php @@ -0,0 +1,22 @@ + $params + * @return array + */ + public function handle(ClientContext $context, array $params = []): array + { + $response = $context->sendRequest('ping'); + + return $response['result'] ?? []; + } +} diff --git a/src/Client/Transport/HttpClientTransport.php b/src/Client/Transport/HttpClientTransport.php index 8d3222ee8..0bb3ce224 100644 --- a/src/Client/Transport/HttpClientTransport.php +++ b/src/Client/Transport/HttpClientTransport.php @@ -5,6 +5,7 @@ namespace Laravel\Mcp\Client\Transport; use Illuminate\Http\Client\Factory; +use Illuminate\Http\Client\Response; use Laravel\Mcp\Client\Contracts\ClientTransport; use Laravel\Mcp\Client\Exceptions\ClientException; use Laravel\Mcp\Client\Exceptions\ConnectionException; @@ -36,17 +37,7 @@ public function connect(): void public function send(string $message): string { - $this->ensureConnected(); - - $response = $this->http - ->timeout((int) $this->timeout) - ->withHeaders($this->buildHeaders()) - ->withBody($message, 'application/json') - ->post($this->url); - - if ($response->header('MCP-Session-Id')) { - $this->sessionId = $response->header('MCP-Session-Id'); - } + $response = $this->post($message); if (! $response->successful()) { throw new ClientException("HTTP request failed with status {$response->status()}."); @@ -57,21 +48,18 @@ public function send(string $message): string public function notify(string $message): void { - $this->ensureConnected(); - - $response = $this->http - ->timeout((int) $this->timeout) - ->withHeaders($this->buildHeaders()) - ->withBody($message, 'application/json') - ->post($this->url); - - if ($response->header('MCP-Session-Id')) { - $this->sessionId = $response->header('MCP-Session-Id'); - } + $this->post($message); } public function disconnect(): void { + if ($this->connected && $this->sessionId !== null) { + $this->http + ->timeout((int) $this->timeout) + ->withHeaders($this->buildHeaders()) + ->delete($this->url); + } + $this->connected = false; $this->sessionId = null; } @@ -86,9 +74,10 @@ public function isConnected(): bool */ protected function buildHeaders(): array { - $headers = array_merge($this->headers, [ + $headers = [ + ...$this->headers, 'Accept' => 'application/json, text/event-stream', - ]); + ]; if ($this->sessionId !== null) { $headers['MCP-Session-Id'] = $this->sessionId; @@ -97,6 +86,23 @@ protected function buildHeaders(): array return $headers; } + protected function post(string $message): Response + { + $this->ensureConnected(); + + $response = $this->http + ->timeout((int) $this->timeout) + ->withHeaders($this->buildHeaders()) + ->withBody($message, 'application/json') + ->post($this->url); + + if ($response->header('MCP-Session-Id')) { + $this->sessionId = $response->header('MCP-Session-Id'); + } + + return $response; + } + protected function ensureConnected(): void { if (! $this->connected) { diff --git a/src/Client/Transport/StdioClientTransport.php b/src/Client/Transport/StdioClientTransport.php index 1738b8772..86e5a3ead 100644 --- a/src/Client/Transport/StdioClientTransport.php +++ b/src/Client/Transport/StdioClientTransport.php @@ -49,7 +49,7 @@ public function connect(): void $this->process = $process; - stream_set_blocking($this->pipes[1], true); + stream_set_blocking($this->pipes[1], false); } public function send(string $message): string @@ -59,13 +59,21 @@ public function send(string $message): string fwrite($this->pipes[0], $message."\n"); fflush($this->pipes[0]); - $response = fgets($this->pipes[1]); + $startTime = microtime(true); - if ($response === false) { - throw new ConnectionException('Failed to read response from process.'); - } + while (true) { + $response = fgets($this->pipes[1]); + + if ($response !== false) { + return trim($response); + } - return trim($response); + if ((microtime(true) - $startTime) >= $this->timeout) { + throw new ConnectionException("Read timeout after {$this->timeout} seconds."); + } + + usleep(10000); + } } public function notify(string $message): void diff --git a/tests/Unit/Client/ClientTest.php b/tests/Unit/Client/ClientTest.php index 2317e3217..99d566c80 100644 --- a/tests/Unit/Client/ClientTest.php +++ b/tests/Unit/Client/ClientTest.php @@ -197,6 +197,131 @@ $client->clearCache(); }); +it('paginates tools list using cursor', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => [], + 'serverInfo' => ['name' => 'test', 'version' => '1.0.0'], + ], + ]), + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 2, + 'result' => [ + 'tools' => [ + ['name' => 'tool-1', 'description' => 'First tool'], + ], + 'nextCursor' => 'cursor-abc', + ], + ]), + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 3, + 'result' => [ + 'tools' => [ + ['name' => 'tool-2', 'description' => 'Second tool'], + ], + ], + ]), + ]); + + $client = new Client($transport, 'test-client'); + $client->connect(); + + $tools = $client->tools(); + + expect($tools)->toHaveCount(2) + ->and($tools->first()->name())->toBe('tool-1') + ->and($tools->last()->name())->toBe('tool-2'); + + $sent = $transport->sentMessages(); + expect($sent)->toHaveCount(3); + + $secondRequest = json_decode($sent[1], true); + expect($secondRequest['method'])->toBe('tools/list') + ->and($secondRequest)->not->toHaveKey('params'); + + $thirdRequest = json_decode($sent[2], true); + expect($thirdRequest['method'])->toBe('tools/list') + ->and($thirdRequest['params']['cursor'])->toBe('cursor-abc'); +}); + +it('sends ping request', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => [], + 'serverInfo' => ['name' => 'test', 'version' => '1.0.0'], + ], + ]), + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 2, + 'result' => [], + ]), + ]); + + $client = new Client($transport, 'test-client'); + $client->connect(); + + $client->ping(); + + $sent = $transport->sentMessages(); + expect($sent)->toHaveCount(2); + + $pingRequest = json_decode($sent[1], true); + expect($pingRequest['method'])->toBe('ping'); +}); + +it('sends capabilities during initialization', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => [], + 'serverInfo' => ['name' => 'test', 'version' => '1.0.0'], + ], + ]), + ]); + + $client = new Client($transport, 'test-client', capabilities: ['sampling' => []]); + $client->connect(); + + $sent = $transport->sentMessages(); + $initRequest = json_decode($sent[0], true); + expect($initRequest['params']['capabilities'])->toBe(['sampling' => []]); +}); + +it('sends empty capabilities object when none configured', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => [], + 'serverInfo' => ['name' => 'test', 'version' => '1.0.0'], + ], + ]), + ]); + + $client = new Client($transport, 'test-client'); + $client->connect(); + + $sent = $transport->sentMessages(); + $initRequest = json_decode($sent[0], true); + expect($initRequest['params']['capabilities'])->toBeEmpty(); +}); + it('clears cache', function (): void { $transport = new FakeClientTransport([ json_encode([ diff --git a/tests/Unit/Client/Methods/ListToolsTest.php b/tests/Unit/Client/Methods/ListToolsTest.php index 28e1db4c7..8a967ccd5 100644 --- a/tests/Unit/Client/Methods/ListToolsTest.php +++ b/tests/Unit/Client/Methods/ListToolsTest.php @@ -31,6 +31,33 @@ ->and($result['tools'][1]['name'])->toBe('ping'); }); +it('passes cursor params to request', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [ + 'tools' => [ + ['name' => 'tool-2', 'description' => 'Second page'], + ], + ], + ]), + ]); + + $transport->connect(); + + $context = new ClientContext($transport, 'test-client'); + $handler = new ListTools; + + $handler->handle($context, ['cursor' => 'cursor-abc']); + + $sent = $transport->sentMessages(); + $request = json_decode($sent[0], true); + + expect($request['method'])->toBe('tools/list') + ->and($request['params']['cursor'])->toBe('cursor-abc'); +}); + it('handles empty tools list', function (): void { $transport = new FakeClientTransport([ json_encode([ diff --git a/tests/Unit/Client/Methods/PingTest.php b/tests/Unit/Client/Methods/PingTest.php new file mode 100644 index 000000000..b6b02704f --- /dev/null +++ b/tests/Unit/Client/Methods/PingTest.php @@ -0,0 +1,46 @@ + '2.0', + 'id' => 1, + 'result' => [], + ]), + ]); + + $transport->connect(); + + $context = new ClientContext($transport, 'test-client'); + $handler = new Ping; + + $handler->handle($context); + + $sent = $transport->sentMessages(); + $request = json_decode($sent[0], true); + + expect($request['method'])->toBe('ping'); +}); + +it('returns empty result', function (): void { + $transport = new FakeClientTransport([ + json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'result' => [], + ]), + ]); + + $transport->connect(); + + $context = new ClientContext($transport, 'test-client'); + $handler = new Ping; + + $result = $handler->handle($context); + + expect($result)->toBe([]); +}); diff --git a/tests/Unit/Client/Transport/HttpClientTransportTest.php b/tests/Unit/Client/Transport/HttpClientTransportTest.php index 61266393d..d9b787190 100644 --- a/tests/Unit/Client/Transport/HttpClientTransportTest.php +++ b/tests/Unit/Client/Transport/HttpClientTransportTest.php @@ -81,6 +81,37 @@ expect($transport->isConnected())->toBeFalse(); }); +it('sends delete on disconnect when session id exists', function (): void { + $http = new Factory; + $http->fake([ + 'example.com/mcp' => $http->sequence() + ->push('{"jsonrpc":"2.0","id":1,"result":{}}', 200, ['MCP-Session-Id' => 'session-123']) + ->push('', 200), + ]); + + $transport = new HttpClientTransport('https://example.com/mcp', [], 30, $http); + $transport->connect(); + + $transport->send('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'); + + $transport->disconnect(); + + $http->assertSentCount(2); + $http->assertSent(fn ($request): bool => $request->method() === 'DELETE' + && $request->hasHeader('MCP-Session-Id', 'session-123')); +}); + +it('does not send delete on disconnect without session id', function (): void { + $http = new Factory; + $http->fake(); + + $transport = new HttpClientTransport('https://example.com/mcp', [], 30, $http); + $transport->connect(); + $transport->disconnect(); + + $http->assertNothingSent(); +}); + it('includes custom headers', function (): void { $http = new Factory; $http->fake([ diff --git a/tests/Unit/Client/Transport/StdioClientTransportTest.php b/tests/Unit/Client/Transport/StdioClientTransportTest.php index 1a4f2f324..20239d4ad 100644 --- a/tests/Unit/Client/Transport/StdioClientTransportTest.php +++ b/tests/Unit/Client/Transport/StdioClientTransportTest.php @@ -59,6 +59,14 @@ $transport->disconnect(); }); +it('throws on read timeout', function (): void { + $transport = new StdioClientTransport('php', ['-r', 'sleep(60);'], timeout: 0.1); + + $transport->connect(); + + $transport->send('hello'); +})->throws(ConnectionException::class, 'Read timeout after 0.1 seconds.'); + it('disconnect is idempotent', function (): void { $transport = new StdioClientTransport('php', ['-r', 'echo "ok";']);