diff --git a/src/Core/TransporterPool.php b/src/Core/TransporterPool.php new file mode 100644 index 0000000..83e179a --- /dev/null +++ b/src/Core/TransporterPool.php @@ -0,0 +1,69 @@ + + */ + 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 + */ + public function getActiveServers(): array + { + return array_keys($this->transporters); + } +} diff --git a/src/MCPClient.php b/src/MCPClient.php index 573ae06..93364e1 100755 --- a/src/MCPClient.php +++ b/src/MCPClient.php @@ -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 @@ -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; } @@ -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; } @@ -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 diff --git a/src/MCPClientServiceProvider.php b/src/MCPClientServiceProvider.php index 80c2b3d..3d0a81f 100644 --- a/src/MCPClientServiceProvider.php +++ b/src/MCPClientServiceProvider.php @@ -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; @@ -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)); }); } } diff --git a/tests/Core/TransporterPoolTest.php b/tests/Core/TransporterPoolTest.php new file mode 100644 index 0000000..e2f65c8 --- /dev/null +++ b/tests/Core/TransporterPoolTest.php @@ -0,0 +1,113 @@ + '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); + }); +}); diff --git a/tests/MCPClient/MCPClientTest.php b/tests/MCPClient/MCPClientTest.php index a4b6ca4..c3e48ab 100644 --- a/tests/MCPClient/MCPClientTest.php +++ b/tests/MCPClient/MCPClientTest.php @@ -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; @@ -37,15 +37,15 @@ 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); @@ -53,15 +53,15 @@ 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); @@ -69,16 +69,16 @@ 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(); @@ -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(); @@ -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); + }); });