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..25836ae41 --- /dev/null +++ b/src/Client/Client.php @@ -0,0 +1,166 @@ +|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, + '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->capabilities); + } + + 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); + } + + $allTools = []; + $cursor = null; + + 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($allTools)->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 ping(): void + { + $this->callMethod('ping'); + } + + 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..3a8409ae0 --- /dev/null +++ b/src/Client/ClientContext.php @@ -0,0 +1,66 @@ + $capabilities + */ + public function __construct( + protected ClientTransport $transport, + public string $clientName, + public string $protocolVersion = '2025-11-25', + public array $capabilities = [], + ) {} + + /** + * @param array $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..500e45b15 --- /dev/null +++ b/src/Client/ClientManager.php @@ -0,0 +1,86 @@ + */ + 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'), + capabilities: (array) ($config['capabilities'] ?? []), + ); + + $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..bccc312a7 --- /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' => $context->capabilities !== [] ? $context->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..dc32d36b4 --- /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', $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 new file mode 100644 index 000000000..0bb3ce224 --- /dev/null +++ b/src/Client/Transport/HttpClientTransport.php @@ -0,0 +1,112 @@ + $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 + { + $response = $this->post($message); + + if (! $response->successful()) { + throw new ClientException("HTTP request failed with status {$response->status()}."); + } + + return $response->body(); + } + + public function notify(string $message): void + { + $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; + } + + public function isConnected(): bool + { + return $this->connected; + } + + /** + * @return array + */ + protected function buildHeaders(): array + { + $headers = [ + ...$this->headers, + 'Accept' => 'application/json, text/event-stream', + ]; + + if ($this->sessionId !== null) { + $headers['MCP-Session-Id'] = $this->sessionId; + } + + 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) { + 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..86e5a3ead --- /dev/null +++ b/src/Client/Transport/StdioClientTransport.php @@ -0,0 +1,121 @@ + */ + 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], false); + } + + public function send(string $message): string + { + $this->ensureConnected(); + + fwrite($this->pipes[0], $message."\n"); + fflush($this->pipes[0]); + + $startTime = microtime(true); + + while (true) { + $response = fgets($this->pipes[1]); + + if ($response !== false) { + 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 + { + $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..99d566c80 --- /dev/null +++ b/tests/Unit/Client/ClientTest.php @@ -0,0 +1,344 @@ + '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('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([ + '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..8a967ccd5 --- /dev/null +++ b/tests/Unit/Client/Methods/ListToolsTest.php @@ -0,0 +1,80 @@ + '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('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([ + '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/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/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..d9b787190 --- /dev/null +++ b/tests/Unit/Client/Transport/HttpClientTransportTest.php @@ -0,0 +1,128 @@ +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('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([ + '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..20239d4ad --- /dev/null +++ b/tests/Unit/Client/Transport/StdioClientTransportTest.php @@ -0,0 +1,77 @@ +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) . PHP_EOL; }']); + + $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) . PHP_EOL; }']); + + $transport->connect(); + + $transport->notify('{"jsonrpc":"2.0","method":"notifications/initialized"}'); + + expect($transport->isConnected())->toBeTrue(); + + $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";']); + + $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], + ]); +});