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
69 changes: 69 additions & 0 deletions src/Core/TransporterPool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace Redberry\MCPClient\Core;

use Redberry\MCPClient\Core\Transporters\Transporter;

/**
* Pool to manage and reuse transporter instances.
*
* This class maintains a registry of active transporters to avoid creating
* new instances for every connection to the same server, significantly
* reducing latency for subsequent requests.
*/
class TransporterPool
{
/**
* @var array<string, Transporter>
*/
private array $transporters = [];

/**
* Get or create a transporter for the given server.
*
* @param string $serverName The name of the server
* @param array $config The server configuration
* @return Transporter
*/
public function get(string $serverName, array $config): Transporter
{
if (!isset($this->transporters[$serverName])) {
$this->transporters[$serverName] = TransporterFactory::make($config);
}

return $this->transporters[$serverName];
}

/**
* Remove a specific transporter from the pool.
*
* @param string $serverName The name of the server
* @return void
*/
public function forget(string $serverName): void
{
unset($this->transporters[$serverName]);
}

/**
* Clear all transporters from the pool.
*
* @return void
*/
public function clear(): void
{
$this->transporters = [];
}

/**
* Get list of active server names.
*
* @return array<int, string>
*/
public function getActiveServers(): array
{
return array_keys($this->transporters);
}
}
10 changes: 5 additions & 5 deletions src/MCPClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace Redberry\MCPClient;

use Redberry\MCPClient\Contracts\MCPClient as IMCPClient;
use Redberry\MCPClient\Core\TransporterFactory;
use Redberry\MCPClient\Core\TransporterPool;
use Redberry\MCPClient\Core\Transporters\Transporter;

class MCPClient implements IMCPClient
Expand All @@ -19,7 +19,7 @@ class MCPClient implements IMCPClient
*/
public function __construct(
array $config,
private readonly TransporterFactory $factory = new TransporterFactory
private readonly TransporterPool $pool = new TransporterPool
) {
$this->config = $config;
}
Expand All @@ -30,7 +30,7 @@ public function connect(string $serverName): IMCPClient

$this->ensureConfigurationValidity();

$this->transporter = $this->getTransporter($this->serverConfig);
$this->transporter = $this->getTransporter($serverName, $this->serverConfig);

return $this;
}
Expand Down Expand Up @@ -84,9 +84,9 @@ public function resources(): Collection
return new Collection($resources);
}

private function getTransporter(array $config): Transporter
private function getTransporter(string $serverName, array $config): Transporter
{
return $this->factory->make($config);
return $this->pool->get($serverName, $config);
}

private function ensureConfigurationValidity(): void
Expand Down
9 changes: 7 additions & 2 deletions src/MCPClientServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace Redberry\MCPClient;

use Redberry\MCPClient\Commands\MCPClientCommand;
use Redberry\MCPClient\Core\TransporterFactory;
use Redberry\MCPClient\Core\TransporterPool;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;

Expand All @@ -25,10 +25,15 @@ public function configurePackage(Package $package): void

public function packageBooted(): void
{
// Register TransporterPool as singleton
$this->app->singleton(TransporterPool::class, function ($app) {
return new TransporterPool();
});

$this->app->bind(MCPClient::class, function ($app) {
$servers = $app['config']->get('mcp-client.servers', []);

return new MCPClient($servers, $app->make(TransporterFactory::class));
return new MCPClient($servers, $app->make(TransporterPool::class));
});
}
}
113 changes: 113 additions & 0 deletions tests/Core/TransporterPoolTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

use Redberry\MCPClient\Core\TransporterPool;
use Redberry\MCPClient\Core\Transporters\HttpTransporter;
use Redberry\MCPClient\Core\Transporters\StdioTransporter;

describe('TransporterPool', function () {

it('creates transporter on first call', function () {
$config = ['type' => 'http', 'base_url' => 'https://example.com', 'timeout' => 30];

$pool = new TransporterPool();
$transporter = $pool->get('github', $config);

expect($transporter)->toBeInstanceOf(HttpTransporter::class);
});

it('returns same transporter on subsequent calls', function () {
$config = ['type' => 'http', 'base_url' => 'https://example.com', 'timeout' => 30];

$pool = new TransporterPool();

// First call
$transporter1 = $pool->get('github', $config);

// Second call - should return same instance
$transporter2 = $pool->get('github', $config);

expect($transporter1)->toBeInstanceOf(HttpTransporter::class)
->and($transporter2)->toBeInstanceOf(HttpTransporter::class)
->and($transporter1)->toBe($transporter2);
});

it('forget removes transporter from pool', function () {
$config = ['type' => 'http', 'base_url' => 'https://example.com', 'timeout' => 30];

$pool = new TransporterPool();

// First call
$transporter1 = $pool->get('github', $config);
expect($transporter1)->toBeInstanceOf(HttpTransporter::class);

// Forget the transporter
$pool->forget('github');

// Next call should create a new transporter
$transporter2 = $pool->get('github', $config);
expect($transporter2)->toBeInstanceOf(HttpTransporter::class)
->and($transporter1)->not->toBe($transporter2);
});

it('clear removes all transporters', function () {
$config1 = ['type' => 'http', 'base_url' => 'https://github.com', 'timeout' => 30];
$config2 = ['type' => 'http', 'base_url' => 'https://gitlab.com', 'timeout' => 30];

$pool = new TransporterPool();

// Add two transporters
$pool->get('github', $config1);
$pool->get('gitlab', $config2);

expect($pool->getActiveServers())->toHaveCount(2)
->toContain('github')
->toContain('gitlab');

// Clear all
$pool->clear();

expect($pool->getActiveServers())->toBeEmpty();
});

it('getActiveServers returns list of server names', function () {
$config1 = ['type' => 'http', 'base_url' => 'https://github.com', 'timeout' => 30];
$config2 = ['type' => 'http', 'base_url' => 'https://gitlab.com', 'timeout' => 30];

$pool = new TransporterPool();

expect($pool->getActiveServers())->toBeEmpty();

$pool->get('github', $config1);
expect($pool->getActiveServers())->toBe(['github']);

$pool->get('gitlab', $config2);
expect($pool->getActiveServers())->toHaveCount(2)
->toContain('github')
->toContain('gitlab');
});

it('handles multiple different servers correctly', function () {
$config1 = ['type' => 'http', 'base_url' => 'https://github.com', 'timeout' => 30];
$config2 = ['type' => 'stdio', 'command' => ['echo', 'test'], 'timeout' => 30];

$pool = new TransporterPool();

// Get different servers
$t1 = $pool->get('github', $config1);
$t2 = $pool->get('npx_server', $config2);

// Should be different instances and different types
expect($t1)->toBeInstanceOf(HttpTransporter::class)
->and($t2)->toBeInstanceOf(StdioTransporter::class)
->and($t1)->not->toBe($t2);

// Getting same servers again should return same instances
$t1Again = $pool->get('github', $config1);
$t2Again = $pool->get('npx_server', $config2);

expect($t1Again)->toBe($t1)
->and($t2Again)->toBe($t2);
});
});
63 changes: 48 additions & 15 deletions tests/MCPClient/MCPClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use Illuminate\Support\Facades\Config;
use Redberry\MCPClient\Collection;
use Redberry\MCPClient\Core\TransporterFactory;
use Redberry\MCPClient\Core\TransporterPool;
use Redberry\MCPClient\Core\Transporters\Transporter;
use Redberry\MCPClient\Enums\Transporters;
use Redberry\MCPClient\MCPClient;
Expand Down Expand Up @@ -37,48 +37,48 @@

test('connect sets server config and transporter', function () {

$mockFactory = Mockery::mock(TransporterFactory::class);
$mockPool = Mockery::mock(TransporterPool::class);
$mockTransporter = Mockery::mock(Transporter::class);

$mockFactory->shouldReceive('make')
$mockPool->shouldReceive('get')
->once()
->with(config('mcp-client.servers.using_enum'))
->with('using_enum', config('mcp-client.servers.using_enum'))
->andReturn($mockTransporter);

$client = new MCPClient(config('mcp-client.servers'), $mockFactory);
$client = new MCPClient(config('mcp-client.servers'), $mockPool);
$connected = $client->connect('using_enum');

expect($connected)->toBeInstanceOf(MCPClient::class);
});

test('connect sets server config and transporter when type is not enum', function () {

$mockFactory = Mockery::mock(TransporterFactory::class);
$mockPool = Mockery::mock(TransporterPool::class);
$mockTransporter = Mockery::mock(Transporter::class);

$mockFactory->shouldReceive('make')
$mockPool->shouldReceive('get')
->once()
->with(config('mcp-client.servers.without_enum'))
->with('without_enum', config('mcp-client.servers.without_enum'))
->andReturn($mockTransporter);

$client = new MCPClient(config('mcp-client.servers'), $mockFactory);
$client = new MCPClient(config('mcp-client.servers'), $mockPool);
$connected = $client->connect('without_enum');

expect($connected)->toBeInstanceOf(MCPClient::class);
});

test('tools returns collection of tools', function () {
$mockTransporter = Mockery::mock(Transporter::class);
$mockFactory = Mockery::mock(TransporterFactory::class);
$mockPool = Mockery::mock(TransporterPool::class);

$mockTransporter->shouldReceive('request')
->once()
->with('tools/list')
->andReturn(['tools' => [['name' => 'tool1'], ['name' => 'tool2']]]);

$mockFactory->shouldReceive('make')->andReturn($mockTransporter);
$mockPool->shouldReceive('get')->andReturn($mockTransporter);

$client = new MCPClient(config('mcp-client.servers'), $mockFactory);
$client = new MCPClient(config('mcp-client.servers'), $mockPool);
$client->connect('using_enum');
$tools = $client->tools();

Expand All @@ -88,15 +88,15 @@

test('resources returns collection of resources', function () {
$mockTransporter = Mockery::mock(Transporter::class);
$mockFactory = Mockery::mock(TransporterFactory::class);
$mockPool = Mockery::mock(TransporterPool::class);
$mockTransporter->shouldReceive('request')
->once()
->with('resources/list')
->andReturn(['resources' => [['id' => 1], ['id' => 2]]]);

$mockFactory->shouldReceive('make')->andReturn($mockTransporter);
$mockPool->shouldReceive('get')->andReturn($mockTransporter);

$client = new MCPClient(config('mcp-client.servers'), $mockFactory);
$client = new MCPClient(config('mcp-client.servers'), $mockPool);
$client->connect('using_enum');
$resources = $client->resources();

Expand All @@ -115,4 +115,37 @@

$client->resources(); // should throw
})->throws(RuntimeException::class, 'Server configuration is not set. Please connect to a server first.');

test('multiple connects to same server reuse transporter', function () {
$mockPool = Mockery::mock(TransporterPool::class);
$mockTransporter = Mockery::mock(Transporter::class);

// The pool's get method will be called twice (once per connect)
// but it should return the same transporter instance
$mockPool->shouldReceive('get')
->twice()
->with('using_enum', config('mcp-client.servers.using_enum'))
->andReturn($mockTransporter);

$mockTransporter->shouldReceive('request')
->with('tools/list')
->andReturn(['tools' => [['name' => 'tool1']]]);

$mockTransporter->shouldReceive('request')
->with('resources/list')
->andReturn(['resources' => [['id' => 1]]]);

$client = new MCPClient(config('mcp-client.servers'), $mockPool);

// First connect
$client->connect('using_enum');
$tools = $client->tools();

// Second connect to the same server - pool returns the same transporter
$client->connect('using_enum');
$resources = $client->resources();

expect($tools)->toBeInstanceOf(Collection::class)
->and($resources)->toBeInstanceOf(Collection::class);
});
});
Loading