diff --git a/README.md b/README.md index 5f9932d..41a7937 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ Expose your CakePHP application functionality via the Model Context Protocol (MC - [Installing the Plugin](#installing-the-plugin) - [Quick Start](#quick-start) - [Configuration](#configuration) -- [Creating MCP Tools](#creating-mcp-tools) - [Built-in Tools](#built-in-tools) - [Built-in Prompts](#built-in-prompts) + - [Creating Custom Tools, Resources, and Prompts](#creating-custom-tools-resources-and-prompts) - [CLI usage](#cli-usage) - [Running the Server](#running-the-server) - [Command Options](#command-options) @@ -123,66 +123,7 @@ Or run when using `DDEV` instance Various configuration options are available for Synapse. Refer to `config/synapse.php` in this plugin for details on available settings and customization. -## Creating MCP Tools -Create custom tools by adding the `#[McpTool]` attribute to public methods: - -```php -fetchTable('Users'); - $user = $usersTable->get($id); - - return [ - 'id' => $user->id, - 'email' => $user->email, - 'name' => $user->name, - ]; - } - - #[McpTool(name: 'list_users')] - public function listUsers(int $limit = 10): array - { - $usersTable = $this->fetchTable('Users'); - $users = $usersTable->find() - ->limit($limit) - ->toArray(); - - return [ - 'total' => count($users), - 'users' => $users, - ]; - } -} -``` - -The plugin will automatically discover these tools and make them available to MCP clients. - -### Tool Parameters - -Tools support typed parameters with automatic validation: - -```php -#[McpTool(name: 'search_articles')] -public function searchArticles( - string $query, - int $limit = 20, - bool $publishedOnly = true -): array { - // Implementation -} -``` ## Built-in Tools @@ -206,6 +147,8 @@ Synapse includes several built-in tools and resources for common operations: | Documentation | `docs_stats` | View index statistics and available sources | | Documentation | `docs://search/{query}` | Search CakePHP documentation and return formatted results | | Documentation | `docs://content/{documentId}` | Retrieve full document content by document ID (format: `source::path`) | +| Commands | `list_commands` | List all available CakePHP console commands with optional filtering and sorting | +| Commands | `get_command_info` | Get detailed information about a specific console command (options, arguments, help) | > [!WARNING] > The `tinker` tool executes arbitrary code in your application. Use responsibly and avoid modifying data without explicit approval. @@ -248,10 +191,11 @@ Synapse includes pre-defined prompt workflows that guide LLMs through common Cak - **Consistency** - Standardized approaches to common problems - **Discovery** - See available workflows without remembering tool combinations -### Configuring prompts +### Configuring Prompts Prompts can reference a specific CakePHP version and use various quality tools. Configure both in `config/synapse.php`: +```php return [ 'Synapse' => [ 'prompts' => [ @@ -272,6 +216,43 @@ return [ ], ], ]; +``` + +### Creating Custom Tools, Resources, and Prompts + +You can extend Synapse with your own tools, resources, and prompts using PHP attributes. Synapse automatically discovers classes in your `src/` directory using MCP attributes. + +**Tools** expose functions that AI assistants can call. Create them with `#[McpTool]`: + +```php +fetchTable('Users'); + $user = $usersTable->get($id); + + return [ + 'id' => $user->id, + 'email' => $user->email, + 'name' => $user->name, + ]; + } +} +``` + +**Resources** expose data sources. Create them with `#[McpResourceTemplate]`. + +**Prompts** guide LLMs through workflows. Create them with `#[McpPrompt]`. + +For detailed documentation on all MCP capabilities, attributes, and implementation patterns, see the [MCP PHP SDK documentation](https://github.com/modelcontextprotocol/php-sdk). + ## CLI usage diff --git a/src/SynapsePlugin.php b/src/SynapsePlugin.php index b6e3755..4ea44bb 100644 --- a/src/SynapsePlugin.php +++ b/src/SynapsePlugin.php @@ -3,12 +3,16 @@ namespace Synapse; +use Cake\Console\CommandCollection; use Cake\Core\BasePlugin; use Cake\Core\Configure; use Cake\Core\ContainerInterface; use Cake\Core\PluginApplicationInterface; +use Cake\Event\EventInterface; +use Cake\Event\EventManagerInterface; use Cake\Log\Log; use Synapse\Command\ServerCommand; +use Synapse\Tools\CommandTools; /** * Synapse Plugin @@ -23,6 +27,19 @@ class SynapsePlugin extends BasePlugin */ protected ?string $name = 'Synapse'; + /** + * Stores the CommandCollection captured from Console.buildCommands event + */ + protected static ?CommandCollection $commandCollection = null; + + /** + * Reset the static CommandCollection (for testing purposes) + */ + public static function resetCommandCollection(): void + { + static::$commandCollection = null; + } + /** * Load all the plugin configuration and bootstrap logic. * @@ -48,6 +65,26 @@ public function bootstrap(PluginApplicationInterface $app): void } } + /** + * Register application event listeners. + * + * @param \Cake\Event\EventManagerInterface $eventManager The Event Manager to update. + */ + public function events(EventManagerInterface $eventManager): EventManagerInterface + { + // Listen for Console.buildCommands event to capture CommandCollection + $eventManager->on('Console.buildCommands', function (EventInterface $event): void { + $commands = $event->getData('commands'); + + if ($commands instanceof CommandCollection) { + // Store in static property so services() can access it + static::$commandCollection = $commands; + } + }); + + return $eventManager; + } + /** * Register application container services. * @@ -58,5 +95,14 @@ public function services(ContainerInterface $container): void // Register ServerCommand with container for proper DI $container->add(ServerCommand::class) ->addArgument($container); + + // Register CommandCollection factory that returns the captured collection + $container->addShared(CommandCollection::class, function (): CommandCollection { + return static::$commandCollection ?? new CommandCollection(); + }); + + // Register CommandTools with CommandCollection dependency + $container->add(CommandTools::class) + ->addArgument(CommandCollection::class); } } diff --git a/src/Tools/CommandTools.php b/src/Tools/CommandTools.php new file mode 100644 index 0000000..59683b1 --- /dev/null +++ b/src/Tools/CommandTools.php @@ -0,0 +1,333 @@ +commandCollection ??= new CommandCollection(); + } + + /** + * Get the CommandCollection for inspection. + * + * @return \Cake\Console\CommandCollection The command collection + */ + protected function getCommandCollection(): CommandCollection + { + return $this->commandCollection ?? new CommandCollection(); + } + + /** + * List all available CakePHP console commands. + * + * Returns a list of all registered commands with optional filtering and sorting. + * Useful for discovering available console commands and understanding their plugins. + * + * @param string|null $plugin Filter by plugin name + * @param string|null $namespace Filter by command namespace + * @param bool $sort Sort commands alphabetically by name + * @return array List of commands with metadata + */ + #[McpTool( + name: 'list_commands', + description: 'List all available CakePHP console commands with optional filtering and sorting', + )] + public function listCommands( + ?string $plugin = null, + ?string $namespace = null, + bool $sort = false, + ): array { + $commandsList = []; + + $commandCollection = $this->getCommandCollection(); + + foreach ($commandCollection as $name => $command) { + $commandClass = is_string($command) ? $command : $command::class; + $commandNamespace = $this->extractNamespace($commandClass); + $commandPlugin = $this->extractPlugin($commandNamespace); + + // Apply plugin filter + if ($plugin !== null && $commandPlugin !== $plugin) { + continue; + } + + // Apply namespace filter + if ($namespace !== null && !str_starts_with($commandNamespace, $namespace)) { + continue; + } + + // Try to get description, but handle commands that can't be instantiated + $description = null; + try { + if (is_string($command)) { + // Some commands require constructor arguments, wrap in try-catch + $commandToDescribe = new $command(); + } else { + $commandToDescribe = $command; + } + + if ($commandToDescribe instanceof Command) { + $description = $this->getCommandDescription($commandToDescribe); + } + } catch (Throwable) { + // Command couldn't be instantiated, description will be null + } + + $commandsList[] = [ + 'name' => $name, + 'class' => $commandClass, + 'namespace' => $commandNamespace, + 'plugin' => $commandPlugin, + 'description' => $description, + ]; + } + + if ($sort) { + usort($commandsList, function (array $a, array $b): int { + return strcasecmp($a['name'], $b['name']); + }); + } + + return [ + 'total' => count($commandsList), + 'commands' => $commandsList, + ]; + } + + /** + * Get detailed information about a specific console command. + * + * Returns comprehensive information about a command including its description, + * usage, options, arguments, and their details. Options include short names, + * help text, defaults, and choices. Arguments include help text, required status. + * + * @param string $name The command name to look up (e.g., 'migrations list') + * @return array Command details including options and arguments + */ + #[McpTool( + name: 'get_command_info', + description: 'Get detailed information about a specific CakePHP console command', + )] + public function getCommandInfo(string $name): array + { + $commandCollection = $this->getCommandCollection(); + if (!$commandCollection->has($name)) { + throw new ToolCallException(sprintf("Command '%s' not found", $name)); + } + + $command = $commandCollection->get($name); + $commandClass = is_string($command) ? $command : $command::class; + $commandNamespace = $this->extractNamespace($commandClass); + $commandPlugin = $this->extractPlugin($commandNamespace); + + // Try to get the command instance to access option parser + $commandInstance = $this->instantiateCommand($command); + + // If command couldn't be instantiated, return basic info + if (!$commandInstance instanceof Command) { + return [ + 'name' => $name, + 'class' => $commandClass, + 'namespace' => $commandNamespace, + 'plugin' => $commandPlugin, + 'description' => null, + 'help' => null, + 'usage' => null, + 'options' => [], + 'arguments' => [], + ]; + } + + $optionParser = $commandInstance->getOptionParser(); + + return [ + 'name' => $name, + 'class' => $commandClass, + 'namespace' => $commandNamespace, + 'plugin' => $commandPlugin, + 'description' => $this->getCommandDescription($commandInstance), + 'help' => $optionParser->help(), + 'usage' => null, + 'options' => $this->parseOptions($optionParser), + 'arguments' => $this->parseArguments($optionParser), + ]; + } + + /** + * Extract the namespace from a fully qualified class name. + * + * @param string $className Fully qualified class name + * @return string Namespace without class name + */ + private function extractNamespace(string $className): string + { + $parts = explode('\\', $className); + array_pop($parts); // Remove class name + + return implode('\\', $parts); + } + + /** + * Extract plugin name from namespace. + * + * Assumes plugin commands are in a vendor namespace. Skips common app namespaces + * like App, Synapse, and TestApp. + * + * @param string $namespace Full namespace + * @return string|null Plugin name or null if not a plugin command + */ + private function extractPlugin(string $namespace): ?string + { + $parts = explode('\\', $namespace); + + if (count($parts) < 2) { + return null; + } + + $firstPart = $parts[0]; + + // Skip common app namespaces + if (in_array($firstPart, ['App', 'Synapse', 'TestApp'], true)) { + return null; + } + + return $parts[1]; + } + + /** + * Get description from a command. + * + * Attempts to get the description from the command's option parser. + * + * @param \Cake\Command\Command $command Command instance + * @return string|null Command description + */ + private function getCommandDescription(Command $command): ?string + { + try { + $optionParser = $command->getOptionParser(); + + return $optionParser->getDescription(); + } catch (Throwable) { + // Return null if description cannot be retrieved + return null; + } + } + + /** + * Instantiate a command if given a class name string or CommandInterface. + * + * Returns null if the command cannot be instantiated (e.g., requires constructor arguments). + * + * @param \Cake\Command\Command|\Cake\Console\CommandInterface|string $command Command instance or class name + * @return \Cake\Command\Command|null Command instance or null if instantiation fails + */ + private function instantiateCommand(mixed $command): ?Command + { + try { + if ($command instanceof Command) { + return $command; + } + + if (is_string($command)) { + $instance = new $command(); + + return $instance instanceof Command ? $instance : null; + } + + // CommandInterface that's not a Command - instantiate by class name + $className = $command::class; + $instance = new $className(); + + return $instance instanceof Command ? $instance : null; + } catch (Throwable) { + // Command requires constructor arguments or can't be instantiated + return null; + } + } + + /** + * Parse options from console option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser Option parser + * @return array> List of options with details + */ + private function parseOptions(ConsoleOptionParser $parser): array + { + $options = []; + + try { + $parserOptions = $parser->options(); + + foreach ($parserOptions as $option) { + if ($option instanceof ConsoleInputOption) { + $options[] = [ + 'name' => $option->name(), + 'short' => $option->short(), + 'help' => $option->help(), + 'default' => $option->defaultValue(), + 'boolean' => $option->isBoolean(), + 'choices' => $option->choices(), + ]; + } + } + } catch (Throwable) { + // If parsing fails, return empty array + } + + return $options; + } + + /** + * Parse arguments from console option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser Option parser + * @return array> List of arguments with details + */ + private function parseArguments(ConsoleOptionParser $parser): array + { + $arguments = []; + + try { + $parserArguments = $parser->arguments(); + + // arguments() returns an indexed array of ConsoleInputArgument objects + foreach ($parserArguments as $argument) { + if ($argument instanceof ConsoleInputArgument) { + $arguments[] = [ + 'name' => $argument->name(), + 'help' => $argument->help(), + 'required' => $argument->isRequired(), + ]; + } + } + } catch (Throwable) { + // If parsing fails, return empty array + } + + return $arguments; + } +} diff --git a/tests/TestCase/Tools/CommandToolsTest.php b/tests/TestCase/Tools/CommandToolsTest.php new file mode 100644 index 0000000..03a5b1c --- /dev/null +++ b/tests/TestCase/Tools/CommandToolsTest.php @@ -0,0 +1,371 @@ +commandCollection = new CommandCollection(); + + // Add test app commands + $this->commandCollection->add('test_command', TestCommand::class); + $this->commandCollection->add('another_test', AnotherTestCommand::class); + + $this->commandTools = new CommandTools($this->commandCollection); + + // Simulate the Console.buildCommands event to populate the static cache + $event = new Event('Console.buildCommands', $this->commandCollection); + EventManager::instance()->dispatch($event); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + unset($this->commandTools); + unset($this->commandCollection); + parent::tearDown(); + } + + /** + * Test listCommands without filters + */ + public function testListCommandsWithoutFilters(): void + { + $result = $this->commandTools->listCommands(); + + $this->assertArrayHasKey('total', $result); + $this->assertArrayHasKey('commands', $result); + $this->assertGreaterThanOrEqual(2, $result['total']); + $this->assertIsArray($result['commands']); + + // Check first command structure + $firstCommand = $result['commands'][0]; + $this->assertArrayHasKey('name', $firstCommand); + $this->assertArrayHasKey('class', $firstCommand); + $this->assertArrayHasKey('namespace', $firstCommand); + $this->assertArrayHasKey('plugin', $firstCommand); + $this->assertArrayHasKey('description', $firstCommand); + } + + /** + * Test listCommands with sort + */ + public function testListCommandsWithSort(): void + { + $result = $this->commandTools->listCommands(sort: true); + + $this->assertArrayHasKey('commands', $result); + $commands = $result['commands']; + + // Verify sorting + $commandsCount = count($commands); + for ($i = 0; $i < $commandsCount - 1; $i++) { + $current = strtolower($commands[$i]['name']); + $next = strtolower($commands[$i + 1]['name']); + $this->assertLessThanOrEqual(0, strcmp($current, $next)); + } + } + + /** + * Test listCommands with namespace filter + */ + public function testListCommandsWithNamespaceFilter(): void + { + $result = $this->commandTools->listCommands(namespace: 'TestApp\\Command'); + + $this->assertArrayHasKey('commands', $result); + $this->assertGreaterThanOrEqual(2, $result['total']); + + // Verify all returned commands have the filtered namespace + foreach ($result['commands'] as $command) { + $this->assertStringContainsString('TestApp\\Command', $command['namespace']); + } + } + + /** + * Test listCommands with non-matching filter + */ + public function testListCommandsWithNonMatchingFilter(): void + { + $result = $this->commandTools->listCommands(namespace: 'NonExistent\\Namespace'); + + $this->assertArrayHasKey('commands', $result); + $this->assertEquals(0, $result['total']); + $this->assertEmpty($result['commands']); + } + + /** + * Test getCommandInfo for existing command + */ + public function testGetCommandInfoForExistingCommand(): void + { + $result = $this->commandTools->getCommandInfo('test_command'); + + $this->assertArrayHasKey('name', $result); + $this->assertArrayHasKey('class', $result); + $this->assertArrayHasKey('namespace', $result); + $this->assertArrayHasKey('plugin', $result); + $this->assertArrayHasKey('description', $result); + $this->assertArrayHasKey('help', $result); + $this->assertArrayHasKey('usage', $result); + $this->assertArrayHasKey('options', $result); + $this->assertArrayHasKey('arguments', $result); + + $this->assertEquals('test_command', $result['name']); + $this->assertIsArray($result['options']); + $this->assertIsArray($result['arguments']); + } + + /** + * Test getCommandInfo for non-existent command + */ + public function testGetCommandInfoForNonExistentCommand(): void + { + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage("Command 'non_existent' not found"); + + $this->commandTools->getCommandInfo('non_existent'); + } + + /** + * Test getCommandInfo returns correct command class + */ + public function testGetCommandInfoReturnsCorrectClass(): void + { + $result = $this->commandTools->getCommandInfo('test_command'); + + $this->assertEquals(TestCommand::class, $result['class']); + } + + /** + * Test that CommandTools can be instantiated + */ + public function testCommandToolsCanBeInstantiated(): void + { + $this->assertInstanceOf(CommandTools::class, $this->commandTools); + } + + /** + * Test listCommands returns command names + */ + public function testListCommandsReturnsCommandNames(): void + { + $result = $this->commandTools->listCommands(); + + $commandNames = array_column($result['commands'], 'name'); + + $this->assertContains('test_command', $commandNames); + $this->assertContains('another_test', $commandNames); + } + + /** + * Test getCommandInfo includes options + */ + public function testGetCommandInfoIncludesOptions(): void + { + $result = $this->commandTools->getCommandInfo('test_command'); + + $this->assertIsArray($result['options']); + // The TestCommand has an 'output-format' option (plus default options) + $optionNames = array_column($result['options'], 'name'); + $this->assertContains('output-format', $optionNames); + } + + /** + * Test getCommandInfo options have required structure + */ + public function testGetCommandInfoOptionsHaveRequiredStructure(): void + { + $result = $this->commandTools->getCommandInfo('test_command'); + + $this->assertNotEmpty($result['options']); + $firstOption = $result['options'][0]; + $this->assertArrayHasKey('name', $firstOption); + $this->assertArrayHasKey('short', $firstOption); + $this->assertArrayHasKey('help', $firstOption); + $this->assertArrayHasKey('default', $firstOption); + $this->assertArrayHasKey('boolean', $firstOption); + } + + /** + * Test getCommandInfo arguments have required structure + */ + public function testGetCommandInfoArgumentsHaveRequiredStructure(): void + { + $result = $this->commandTools->getCommandInfo('test_command'); + + // TestCommand has 'input' argument + $this->assertNotEmpty($result['arguments']); + $firstArg = $result['arguments'][0]; + $this->assertArrayHasKey('name', $firstArg); + $this->assertArrayHasKey('help', $firstArg); + $this->assertArrayHasKey('required', $firstArg); + } + + /** + * Test getCommandInfo with command that has arguments + */ + public function testGetCommandInfoWithTestCommandArguments(): void + { + $result = $this->commandTools->getCommandInfo('test_command'); + + $this->assertNotEmpty($result['arguments']); + $argumentNames = array_column($result['arguments'], 'name'); + $this->assertContains('name', $argumentNames); + } + + /** + * Test getCommandInfo with arguments + */ + public function testGetCommandInfoWithArguments(): void + { + $result = $this->commandTools->getCommandInfo('test_command'); + + $this->assertNotEmpty($result['arguments']); + $argumentNames = array_column($result['arguments'], 'name'); + $this->assertContains('name', $argumentNames); + } + + /** + * Test listCommands total count matches commands array + */ + public function testListCommandsTotalCountMatches(): void + { + $result = $this->commandTools->listCommands(); + + $this->assertEquals(count($result['commands']), $result['total']); + } + + /** + * Test listCommands with plugin filter + */ + public function testListCommandsWithPluginFilter(): void + { + $result = $this->commandTools->listCommands(plugin: 'NonExistentPlugin'); + + $this->assertArrayHasKey('commands', $result); + $this->assertEquals(0, $result['total']); + } + + /** + * Test getCommandInfo returns correct description + */ + public function testGetCommandInfoReturnsCorrectDescription(): void + { + $result = $this->commandTools->getCommandInfo('test_command'); + + $this->assertEquals('A test command for unit testing', $result['description']); + } + + /** + * Test listCommands with sort and namespace filter + */ + public function testListCommandsWithSortAndNamespaceFilter(): void + { + $result = $this->commandTools->listCommands( + namespace: 'TestApp\\Command', + sort: true, + ); + + $this->assertGreaterThanOrEqual(1, $result['total']); + + $commands = $result['commands']; + $commandsCount = count($commands); + for ($i = 0; $i < $commandsCount - 1; $i++) { + $current = strtolower($commands[$i]['name']); + $next = strtolower($commands[$i + 1]['name']); + $this->assertLessThanOrEqual(0, strcmp($current, $next)); + } + } + + /** + * Test CommandTools receives CommandCollection via DI container + * + * Simulates the real-world flow where: + * 1. Plugin registers services and events + * 2. Console.buildCommands event fires with CommandCollection + * 3. Container resolves CommandTools with injected collection + */ + public function testCommandToolsReceivesCollectionViaDI(): void + { + SynapsePlugin::resetCommandCollection(); + + $container = new Container(); + $eventManager = new EventManager(); + + $plugin = new SynapsePlugin(); + $plugin->services($container); + $plugin->events($eventManager); + + // Dispatch event with commands + $commands = new CommandCollection(); + $commands->add('test_command', TestCommand::class); + $commands->add('another_test', AnotherTestCommand::class); + + $event = new Event('Console.buildCommands', null, ['commands' => $commands]); + $eventManager->dispatch($event); + + // Resolve CommandTools from container (as MCP SDK would) + $this->assertTrue($container->has(CommandTools::class)); + $commandTools = $container->get(CommandTools::class); + + $result = $commandTools->listCommands(); + + $this->assertGreaterThanOrEqual(2, $result['total']); + $commandNames = array_column($result['commands'], 'name'); + $this->assertContains('test_command', $commandNames); + $this->assertContains('another_test', $commandNames); + } + + /** + * Test CommandTools falls back to empty collection when event not fired + */ + public function testCommandToolsFallbackWithoutEvent(): void + { + SynapsePlugin::resetCommandCollection(); + + $container = new Container(); + $eventManager = new EventManager(); + + $plugin = new SynapsePlugin(); + $plugin->services($container); + $plugin->events($eventManager); + + // Don't dispatch event - simulate case where collection not captured + $commandTools = $container->get(CommandTools::class); + $result = $commandTools->listCommands(); + + $this->assertEquals(0, $result['total']); + $this->assertEmpty($result['commands']); + } +} diff --git a/tests/test_app/src/Command/AnotherTestCommand.php b/tests/test_app/src/Command/AnotherTestCommand.php new file mode 100644 index 0000000..44b63e7 --- /dev/null +++ b/tests/test_app/src/Command/AnotherTestCommand.php @@ -0,0 +1,44 @@ +setDescription('Another test command for unit testing'); + + $parser->addArgument('name', [ + 'help' => 'Name argument', + 'required' => true, + ]); + + $parser->addArgument('tags', [ + 'help' => 'Optional tags', + 'required' => false, + ]); + + return $parser; + } + + public function execute(Arguments $args, ConsoleIo $io): ?int + { + return static::CODE_SUCCESS; + } +} diff --git a/tests/test_app/src/Command/TestCommand.php b/tests/test_app/src/Command/TestCommand.php new file mode 100644 index 0000000..728d480 --- /dev/null +++ b/tests/test_app/src/Command/TestCommand.php @@ -0,0 +1,46 @@ +setDescription('A test command for unit testing'); + + $parser->addOption('output-format', [ + 'short' => 'o', + 'help' => 'Output format', + 'default' => 'json', + 'choices' => ['json', 'xml', 'yaml'], + ]); + + $parser->addArgument('name', [ + 'help' => 'Name to process', + 'required' => true, + ]); + + return $parser; + } + + public function execute(Arguments $args, ConsoleIo $io): ?int + { + return static::CODE_SUCCESS; + } +}