From eb6f8040bb90ace8aa3dbc42177220517603e066 Mon Sep 17 00:00:00 2001 From: Dave Long Date: Thu, 15 Oct 2026 13:59:51 +0100 Subject: [PATCH 1/4] Add symfony/finder as MCP requires it. --- composer.json | 2 ++ composer.lock | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 53858e9..28afa0c 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "mcp/sdk": "^0.8", "psr/log": "^3.0", "symfony/console": "^6.0 || ^7.0", + "symfony/finder": "^6.4", "symfony/process": "^6.0 || ^7.0" }, "license": "GPL-2.0-or-later", @@ -43,6 +44,7 @@ "php": "8.1.99" }, "allow-plugins": { + "php-http/discovery": true, "phpstan/extension-installer": true } }, diff --git a/composer.lock b/composer.lock index f299bf7..5413600 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "539b915193267a69214fc71c614f6173", + "content-hash": "2604c1378e9d9c03c7e1fc5d0a9d36a3", "packages": [ { "name": "caseyamcl/guzzle_retry_middleware", @@ -2413,6 +2413,74 @@ ], "time": "2026-02-24T17:51:06+00:00" }, + { + "name": "symfony/finder", + "version": "v6.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "c8d1b2a09bb820175d1f2f9a3cf30303200b10a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/c8d1b2a09bb820175d1f2f9a3cf30303200b10a7", + "reference": "c8d1b2a09bb820175d1f2f9a3cf30303200b10a7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "symfony/filesystem": "^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v6.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T09:03:59+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.37.0", From 9fa6780049d3393d4071bfc0f98c7d9702cf220a Mon Sep 17 00:00:00 2001 From: Dave Long Date: Thu, 15 Oct 2026 14:00:25 +0100 Subject: [PATCH 2/4] Fix 'LogicException: No handler has been specified' when using mcp:serve. --- src/Cli/Command/Mcp/Serve.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Cli/Command/Mcp/Serve.php b/src/Cli/Command/Mcp/Serve.php index c037afb..7cf7f44 100644 --- a/src/Cli/Command/Mcp/Serve.php +++ b/src/Cli/Command/Mcp/Serve.php @@ -3,8 +3,10 @@ namespace mglaman\DrupalOrgCli\Command\Mcp; use Composer\InstalledVersions; +use Mcp\Capability\Registry\Container; use Mcp\Server; use Mcp\Server\Transport\StdioTransport; +use mglaman\DrupalOrg\Client; use mglaman\DrupalOrg\Mcp\ToolRegistry; use mglaman\DrupalOrgCli\Command\Command; use Psr\Log\AbstractLogger; @@ -43,7 +45,11 @@ public function log($level, string|\Stringable $message, array $context = []): v $toolRegistryDir = dirname((string) (new \ReflectionClass(ToolRegistry::class))->getFileName()); + $container = new Container(); + $container->set(Client::class, new Client()); + $server = Server::builder() + ->setContainer($container) ->setDiscovery($toolRegistryDir, ['.']) ->setServerInfo('Drupal.org CLI', $version) ->setLogger($logger) From 8d73fe9d573860362fb8c87d54411e28754ed74c Mon Sep 17 00:00:00 2001 From: Dave Long Date: Thu, 15 Oct 2026 14:05:10 +0100 Subject: [PATCH 3/4] Add tests. --- src/Cli/Command/Mcp/Serve.php | 26 +++++-- tests/src/Command/Mcp/ServeTest.php | 104 ++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 tests/src/Command/Mcp/ServeTest.php diff --git a/src/Cli/Command/Mcp/Serve.php b/src/Cli/Command/Mcp/Serve.php index 7cf7f44..0583389 100644 --- a/src/Cli/Command/Mcp/Serve.php +++ b/src/Cli/Command/Mcp/Serve.php @@ -3,6 +3,7 @@ namespace mglaman\DrupalOrgCli\Command\Mcp; use Composer\InstalledVersions; +use GuzzleHttp\HandlerStack; use Mcp\Capability\Registry\Container; use Mcp\Server; use Mcp\Server\Transport\StdioTransport; @@ -10,6 +11,7 @@ use mglaman\DrupalOrg\Mcp\ToolRegistry; use mglaman\DrupalOrgCli\Command\Command; use Psr\Log\AbstractLogger; +use Psr\Log\LoggerInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -43,20 +45,30 @@ public function log($level, string|\Stringable $message, array $context = []): v } }; - $toolRegistryDir = dirname((string) (new \ReflectionClass(ToolRegistry::class))->getFileName()); + self::createServer($version, $logger)->run(new StdioTransport()); + return self::SUCCESS; + } + + /** + * Creates the MCP server with the tools from ToolRegistry. + * + * @param \GuzzleHttp\HandlerStack|null $handler + * Tests pass a stack built around a MockHandler. Production callers pass + * nothing. + */ + public static function createServer(string $version, LoggerInterface $logger, ?HandlerStack $handler = null): Server + { $container = new Container(); - $container->set(Client::class, new Client()); + $container->set(Client::class, new Client(false, $handler)); - $server = Server::builder() + $toolRegistryDir = dirname((string) (new \ReflectionClass(ToolRegistry::class))->getFileName()); + + return Server::builder() ->setContainer($container) ->setDiscovery($toolRegistryDir, ['.']) ->setServerInfo('Drupal.org CLI', $version) ->setLogger($logger) ->build(); - - $server->run(new StdioTransport()); - - return self::SUCCESS; } } diff --git a/tests/src/Command/Mcp/ServeTest.php b/tests/src/Command/Mcp/ServeTest.php new file mode 100644 index 0000000..62ff96c --- /dev/null +++ b/tests/src/Command/Mcp/ServeTest.php @@ -0,0 +1,104 @@ +runServer(new MockHandler(), [ + ['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list'], + ]); + + $names = array_column($responses[2]['result']['tools'], 'name'); + self::assertContains('issue_show', $names); + self::assertContains('mr_list', $names); + } + + public function testCallsToolOverHttp(): void + { + $http = new MockHandler([new Response(200, [], json_encode([ + 'nid' => '3383637', + 'type' => 'project_issue', + 'title' => 'Schedule transition button size', + ], JSON_THROW_ON_ERROR))]); + + $responses = $this->runServer($http, [ + [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => ['name' => 'issue_show', 'arguments' => ['nid' => '3383637']], + ], + ]); + + self::assertArrayNotHasKey('error', $responses[2]); + self::assertSame('Schedule transition button size', $responses[2]['result']['structuredContent']['title']); + self::assertSame(0, $http->count()); + } + + /** + * Initializes a session, sends the given requests and returns the + * responses keyed by request ID. + * + * @param list> $requests + * + * @return array> + */ + private function runServer(MockHandler $http, array $requests): array + { + $messages = [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-06-18', + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'test', 'version' => '1'], + ], + ], + ['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], + ...$requests, + ]; + $input = fopen('php://memory', 'r+'); + self::assertIsResource($input); + foreach ($messages as $message) { + fwrite($input, json_encode($message, JSON_THROW_ON_ERROR) . "\n"); + } + rewind($input); + // The transport closes its output stream when the session ends, so + // write to a file that can be read back afterwards. + $outputFile = (string) tempnam(sys_get_temp_dir(), 'drupalorg-cli-mcp'); + $output = fopen($outputFile, 'w'); + self::assertIsResource($output); + + Serve::createServer('test', new NullLogger(), HandlerStack::create($http)) + ->run(new StdioTransport($input, $output)); + + $lines = (string) file_get_contents($outputFile); + unlink($outputFile); + $responses = []; + foreach (array_filter(explode("\n", $lines)) as $line) { + $response = json_decode($line, true, flags: JSON_THROW_ON_ERROR); + $responses[$response['id']] = $response; + } + return $responses; + } +} From b3ec5a2b729859588bc793ad9d63d7f01cc9e1ce Mon Sep 17 00:00:00 2001 From: Dave Long Date: Thu, 15 Oct 2026 14:07:51 +0100 Subject: [PATCH 4/4] Simplify. --- src/Cli/Command/Mcp/Serve.php | 9 ++------- tests/src/Command/Mcp/ServeTest.php | 3 ++- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Cli/Command/Mcp/Serve.php b/src/Cli/Command/Mcp/Serve.php index 0583389..b16f33d 100644 --- a/src/Cli/Command/Mcp/Serve.php +++ b/src/Cli/Command/Mcp/Serve.php @@ -3,7 +3,6 @@ namespace mglaman\DrupalOrgCli\Command\Mcp; use Composer\InstalledVersions; -use GuzzleHttp\HandlerStack; use Mcp\Capability\Registry\Container; use Mcp\Server; use Mcp\Server\Transport\StdioTransport; @@ -52,15 +51,11 @@ public function log($level, string|\Stringable $message, array $context = []): v /** * Creates the MCP server with the tools from ToolRegistry. - * - * @param \GuzzleHttp\HandlerStack|null $handler - * Tests pass a stack built around a MockHandler. Production callers pass - * nothing. */ - public static function createServer(string $version, LoggerInterface $logger, ?HandlerStack $handler = null): Server + public static function createServer(string $version, LoggerInterface $logger, Client $client = new Client()): Server { $container = new Container(); - $container->set(Client::class, new Client(false, $handler)); + $container->set(Client::class, $client); $toolRegistryDir = dirname((string) (new \ReflectionClass(ToolRegistry::class))->getFileName()); diff --git a/tests/src/Command/Mcp/ServeTest.php b/tests/src/Command/Mcp/ServeTest.php index 62ff96c..ce2a4fd 100644 --- a/tests/src/Command/Mcp/ServeTest.php +++ b/tests/src/Command/Mcp/ServeTest.php @@ -8,6 +8,7 @@ use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; use Mcp\Server\Transport\StdioTransport; +use mglaman\DrupalOrg\Client; use mglaman\DrupalOrgCli\Command\Mcp\Serve; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -89,7 +90,7 @@ private function runServer(MockHandler $http, array $requests): array $output = fopen($outputFile, 'w'); self::assertIsResource($output); - Serve::createServer('test', new NullLogger(), HandlerStack::create($http)) + Serve::createServer('test', new NullLogger(), new Client(false, HandlerStack::create($http))) ->run(new StdioTransport($input, $output)); $lines = (string) file_get_contents($outputFile);