diff --git a/composer.json b/composer.json index 53858e9f..28afa0c5 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 f299bf72..5413600f 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", diff --git a/src/Cli/Command/Mcp/Serve.php b/src/Cli/Command/Mcp/Serve.php index c037afb3..b16f33d3 100644 --- a/src/Cli/Command/Mcp/Serve.php +++ b/src/Cli/Command/Mcp/Serve.php @@ -3,11 +3,14 @@ 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; +use Psr\Log\LoggerInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -41,16 +44,26 @@ public function log($level, string|\Stringable $message, array $context = []): v } }; + self::createServer($version, $logger)->run(new StdioTransport()); + + return self::SUCCESS; + } + + /** + * Creates the MCP server with the tools from ToolRegistry. + */ + public static function createServer(string $version, LoggerInterface $logger, Client $client = new Client()): Server + { + $container = new Container(); + $container->set(Client::class, $client); + $toolRegistryDir = dirname((string) (new \ReflectionClass(ToolRegistry::class))->getFileName()); - $server = Server::builder() + 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 00000000..ce2a4fd5 --- /dev/null +++ b/tests/src/Command/Mcp/ServeTest.php @@ -0,0 +1,105 @@ +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(), new Client(false, 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; + } +}