Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions config/mcp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
// ],
],

];
166 changes: 166 additions & 0 deletions src/Client/Client.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Client;

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Laravel\Mcp\Client\Contracts\ClientTransport;
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
{
/** @var array<string, mixed>|null */
protected ?array $serverInfo = null;

/** @var array<string, mixed>|null */
protected ?array $serverCapabilities = null;

protected bool $initialized = false;

protected ClientContext $context;

/** @var array<string, class-string<\Laravel\Mcp\Client\Contracts\ClientMethod>> */
protected array $methods = [
'initialize' => Initialize::class,
'tools/list' => ListTools::class,
'tools/call' => CallTool::class,
'ping' => Ping::class,
];

/**
* @param array<string, mixed> $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<int, ClientTool>
*/
public function tools(): Collection
{
$cacheKey = "mcp-client:{$this->name}:tools";

if ($this->cacheTtl !== null && Cache::has($cacheKey)) {
/** @var Collection<int, ClientTool> */
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<int, ClientTool> $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<string, mixed> $arguments
* @return array<string, mixed>
*/
public function callTool(string $name, array $arguments = []): array
{
return $this->callMethod('tools/call', [
'name' => $name,
'arguments' => $arguments,
]);
}

/**
* @return array<string, mixed>|null
*/
public function serverInfo(): ?array
{
return $this->serverInfo;
}

/**
* @return array<string, mixed>|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<string, mixed> $params
* @return array<string, mixed>
*/
protected function callMethod(string $method, array $params = []): array
{
$handler = new $this->methods[$method];

return $handler->handle($this->context, $params);
}
}
66 changes: 66 additions & 0 deletions src/Client/ClientContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Client;

use Laravel\Mcp\Client\Contracts\ClientTransport;
use Laravel\Mcp\Client\Exceptions\ClientException;
use Laravel\Mcp\Transport\JsonRpcNotification;
use Laravel\Mcp\Transport\JsonRpcRequest;
use Laravel\Mcp\Transport\JsonRpcResponse;

class ClientContext
{
protected int $requestId = 0;

/**
* @param array<string, mixed> $capabilities
*/
public function __construct(
protected ClientTransport $transport,
public string $clientName,
public string $protocolVersion = '2025-11-25',
public array $capabilities = [],
) {}

/**
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
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<string, mixed> $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;
}
}
86 changes: 86 additions & 0 deletions src/Client/ClientManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Client;

use InvalidArgumentException;
use Laravel\Mcp\Client\Contracts\ClientTransport;
use Laravel\Mcp\Client\Transport\HttpClientTransport;
use Laravel\Mcp\Client\Transport\StdioClientTransport;

class ClientManager
{
/** @var array<string, Client> */
protected array $clients = [];

public function client(string $name): Client
{
if (isset($this->clients[$name])) {
return $this->clients[$name];
}

/** @var array<string, mixed> $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<string, mixed> $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}]."),
};
}
}
Loading