From 1a18edb0fd092696148d960700b618d289345b28 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 23 May 2026 12:06:56 +0200 Subject: [PATCH 01/12] feat: add SubprocessRunner utility, run_command MCP tool, refactor TinkerTools - Extract SubprocessRunner from TinkerTools for shared subprocess execution - Add run_command MCP tool to CommandTools with collection-based allowlist - Refactor TinkerTools to delegate proc_open logic to SubprocessRunner - Add tests for SubprocessRunner (13 tests) and run_command (6 tests) --- src/Tools/CommandTools.php | 59 +++++ src/Tools/TinkerTools.php | 205 ++++------------- src/Utility/SubprocessRunner.php | 213 ++++++++++++++++++ tests/TestCase/Tools/CommandToolsTest.php | 138 ++++++++++++ .../TestCase/Utility/SubprocessRunnerTest.php | 159 +++++++++++++ 5 files changed, 611 insertions(+), 163 deletions(-) create mode 100644 src/Utility/SubprocessRunner.php create mode 100644 tests/TestCase/Utility/SubprocessRunnerTest.php diff --git a/src/Tools/CommandTools.php b/src/Tools/CommandTools.php index 59683b1..d57978c 100644 --- a/src/Tools/CommandTools.php +++ b/src/Tools/CommandTools.php @@ -10,6 +10,7 @@ use Cake\Console\ConsoleOptionParser; use Mcp\Capability\Attribute\McpTool; use Mcp\Exception\ToolCallException; +use Synapse\Utility\SubprocessRunner; use Throwable; /** @@ -23,9 +24,11 @@ class CommandTools * Constructor * * @param \Cake\Console\CommandCollection|null $commandCollection Command collection to inspect + * @param \Synapse\Utility\SubprocessRunner $runner Subprocess execution utility */ public function __construct( protected ?CommandCollection $commandCollection = null, + private SubprocessRunner $runner = new SubprocessRunner(), ) { $this->commandCollection ??= new CommandCollection(); } @@ -176,6 +179,62 @@ public function getCommandInfo(string $name): array ]; } + /** + * Run a registered CakePHP console command in a subprocess. + * + * Only commands present in the application's command collection can be executed, + * preventing invocation of arbitrary shell commands. + * + * @param string $name Command name as registered (e.g. 'synapse search_docs') + * @param string $args Additional arguments/flags passed verbatim (e.g. '--help') + * @param int $timeout Maximum execution time in seconds (default: 60, max: 300) + * @return array{command: string, args: string, success: bool, output: string, stderr: string, exit_code: int} + */ + #[McpTool( + name: 'run_command', + description: 'Run a registered CakePHP console command. ' . + 'Use list_commands to discover available commands. ' . + 'Only commands registered in the application command collection can be executed.', + )] + public function runCommand(string $name, string $args = '', int $timeout = 60): array + { + if (!$this->getCommandCollection()->has($name)) { + throw new ToolCallException( + sprintf("Command '%s' not found. Use list_commands to see available commands.", $name), + ); + } + + $timeout = min(max(1, $timeout), 300); + + $phpBinary = $this->runner->getPhpBinary(); + if ($phpBinary === null) { + throw new ToolCallException( + 'Could not find PHP binary. Configure Synapse.tinker.php_binary or ensure php is in PATH.', + ); + } + + // Command names may contain spaces (e.g. "synapse search_docs"); each + // word is a separate argv element when passed to bin/cake.php. + $nameParts = array_map('escapeshellarg', explode(' ', trim($name))); + $commandStr = escapeshellarg($phpBinary) . ' bin/cake.php ' . implode(' ', $nameParts); + + if (trim($args) !== '') { + $commandStr .= ' ' . $args; + } + + $cwd = dirname($this->runner->getBinPath()); + $raw = $this->runner->run($commandStr, null, $timeout, $cwd); + + return [ + 'command' => $name, + 'args' => $args, + 'success' => $raw['success'], + 'output' => $raw['output'], + 'stderr' => $raw['stderr'], + 'exit_code' => $raw['exit_code'], + ]; + } + /** * Extract the namespace from a fully qualified class name. * diff --git a/src/Tools/TinkerTools.php b/src/Tools/TinkerTools.php index 975ce83..f6ad206 100644 --- a/src/Tools/TinkerTools.php +++ b/src/Tools/TinkerTools.php @@ -3,9 +3,8 @@ namespace Synapse\Tools; -use Cake\Core\Configure; use Mcp\Capability\Attribute\McpTool; -use Throwable; +use Synapse\Utility\SubprocessRunner; /** * Tinker Tools @@ -16,14 +15,11 @@ class TinkerTools { /** - * Path to the PHP executable (cached) + * @param \Synapse\Utility\SubprocessRunner $runner Subprocess execution utility. */ - private ?string $phpBinary = null; - - /** - * Path to the CakePHP bin directory (cached) - */ - private ?string $binPath = null; + public function __construct(private SubprocessRunner $runner = new SubprocessRunner()) + { + } /** * Execute PHP code in the application context. @@ -45,11 +41,10 @@ class TinkerTools )] public function execute(string $code, int $timeout = 30): array { - // Validate timeout bounds $timeout = min(max(1, $timeout), 180); - $phpBinary = $this->getPhpBinary(); - $binPath = $this->getBinPath(); + $phpBinary = $this->runner->getPhpBinary(); + $binPath = $this->runner->getBinPath(); if ($phpBinary === null) { return [ @@ -59,25 +54,25 @@ public function execute(string $code, int $timeout = 30): array ]; } - // The command uses bin/cake.php directly (not the shell wrapper) $command = sprintf( '%s bin/cake.php synapse tinker_eval --timeout %d', escapeshellarg($phpBinary), $timeout, ); - $descriptors = [ - 0 => ['pipe', 'r'], // stdin - 1 => ['pipe', 'w'], // stdout - 2 => ['pipe', 'w'], // stderr - ]; - - // Set working directory to application root (parent of bin directory) $cwd = dirname($binPath); + $raw = $this->runner->run($command, $code, $timeout, $cwd); - $process = proc_open($command, $descriptors, $pipes, $cwd); + if ($raw['timed_out'] ?? false) { + return [ + 'success' => false, + 'error' => sprintf('Execution timed out after %d seconds', $timeout), + 'type' => 'RuntimeException', + ]; + } - if (!is_resource($process)) { + // proc_open failure: no output, exit code -1, no timeout + if (!$raw['success'] && $raw['output'] === '' && $raw['exit_code'] === -1) { return [ 'success' => false, 'error' => 'Failed to start subprocess', @@ -85,188 +80,72 @@ public function execute(string $code, int $timeout = 30): array ]; } - try { - // Write code to stdin - fwrite($pipes[0], $code); - fclose($pipes[0]); - - // Set stream to non-blocking for timeout handling - stream_set_blocking($pipes[1], false); - stream_set_blocking($pipes[2], false); - - $stdout = ''; - $stderr = ''; - $startTime = time(); - - // Read output with timeout handling - while (true) { - $status = proc_get_status($process); - - // Check if process has exited - if (!$status['running']) { - // Read any remaining output - $stdout .= stream_get_contents($pipes[1]); - $stderr .= stream_get_contents($pipes[2]); - break; - } - - // Check timeout - if (time() - $startTime > $timeout) { - proc_terminate($process, 9); - - return [ - 'success' => false, - 'error' => sprintf('Execution timed out after %d seconds', $timeout), - 'type' => 'RuntimeException', - ]; - } - - // Read available output - $stdout .= fread($pipes[1], 8192) ?: ''; - $stderr .= fread($pipes[2], 8192) ?: ''; - - // Small sleep to prevent CPU spinning - usleep(10000); // 10ms - } - - fclose($pipes[1]); - fclose($pipes[2]); - - $exitCode = proc_close($process); - - // Parse JSON response from stdout - $stdout = trim($stdout); - - if ($stdout === '') { - return [ - 'success' => false, - 'error' => $stderr ?: 'No output from subprocess', - 'type' => 'RuntimeException', - 'exit_code' => $exitCode, - ]; - } - - $result = json_decode($stdout, true); + $stdout = trim($raw['output']); - if (json_last_error() !== JSON_ERROR_NONE) { - return [ - 'success' => false, - 'error' => 'Failed to parse subprocess output: ' . json_last_error_msg(), - 'type' => 'RuntimeException', - 'raw_output' => $stdout, - 'stderr' => $stderr ?: null, - ]; - } + if ($stdout === '') { + return [ + 'success' => false, + 'error' => $raw['stderr'] ?: 'No output from subprocess', + 'type' => 'RuntimeException', + 'exit_code' => $raw['exit_code'], + ]; + } - return $result; - } catch (Throwable $throwable) { - // Ensure process is cleaned up - if (is_resource($process)) { - proc_terminate($process); - proc_close($process); - } + $result = json_decode($stdout, true); + if (json_last_error() !== JSON_ERROR_NONE) { return [ 'success' => false, - 'error' => $throwable->getMessage(), - 'type' => $throwable::class, + 'error' => 'Failed to parse subprocess output: ' . json_last_error_msg(), + 'type' => 'RuntimeException', + 'raw_output' => $stdout, + 'stderr' => $raw['stderr'] ?: null, ]; } + + return $result; } /** - * Get the PHP binary path. - * - * Resolution order: - * 1. Explicitly set via setPhpBinary() - * 2. Configuration: Synapse.tinker.php_binary - * 3. `which php` command - * 4. PHP_BINARY constant + * Get the PHP binary path (delegates to SubprocessRunner). * * @return string|null Path to PHP binary or null if not found */ public function getPhpBinary(): ?string { - if ($this->phpBinary !== null) { - return $this->phpBinary; - } - - // Check configuration - $configured = Configure::read('Synapse.tinker.php_binary'); - if ($configured !== null && is_string($configured) && is_executable($configured)) { - return $configured; - } - - // Try `which php` command (most reliable for finding the active PHP) - $nullDevice = DIRECTORY_SEPARATOR === '\\' ? 'NUL' : '/dev/null'; - $whichResult = shell_exec('which php 2>' . $nullDevice); - if (is_string($whichResult) && trim($whichResult) !== '') { - $which = trim($whichResult); - if (is_executable($which)) { - return $which; - } - } - - // Fallback to PHP_BINARY constant (always defined and non-empty in PHP 8.2+) - if (is_executable(PHP_BINARY)) { - return PHP_BINARY; - } - - return null; + return $this->runner->getPhpBinary(); } /** - * Get the CakePHP bin directory path. - * - * Resolution order: - * 1. Explicitly set via setBinPath() - * 2. Configuration: Synapse.tinker.bin_path - * 3. ROOT constant + /bin - * 4. Current working directory + /bin + * Get the CakePHP bin directory path (delegates to SubprocessRunner). * * @return string Path to bin directory */ public function getBinPath(): string { - if ($this->binPath !== null) { - return $this->binPath; - } - - // Check configuration - $configured = Configure::read('Synapse.tinker.bin_path'); - if ($configured !== null && is_string($configured)) { - return $configured; - } - - // Check if ROOT constant is defined (CakePHP app) - if (defined('ROOT')) { - return ROOT . '/bin'; - } - - // Fallback to current working directory - return getcwd() . '/bin'; + return $this->runner->getBinPath(); } /** - * Set the PHP binary path. + * Set the PHP binary path (delegates to SubprocessRunner). * * @param string|null $path Path to PHP binary */ public function setPhpBinary(?string $path): static { - $this->phpBinary = $path; + $this->runner->setPhpBinary($path); return $this; } /** - * Set the bin directory path. + * Set the bin directory path (delegates to SubprocessRunner). * * @param string|null $path Path to bin directory */ public function setBinPath(?string $path): static { - $this->binPath = $path; + $this->runner->setBinPath($path); return $this; } diff --git a/src/Utility/SubprocessRunner.php b/src/Utility/SubprocessRunner.php new file mode 100644 index 0000000..799e9a3 --- /dev/null +++ b/src/Utility/SubprocessRunner.php @@ -0,0 +1,213 @@ + ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ]; + + $process = proc_open($command, $descriptors, $pipes, $cwd); + + if (!is_resource($process)) { + return [ + 'success' => false, + 'output' => '', + 'stderr' => 'Failed to start process', + 'exit_code' => -1, + ]; + } + + try { + // Write stdin then close so the process sees EOF. + if ($stdin !== null) { + fwrite($pipes[0], $stdin); + } + + fclose($pipes[0]); + + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $stdout = ''; + $stderr = ''; + $startTime = time(); + + while (true) { + $status = proc_get_status($process); + + if (!$status['running']) { + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + break; + } + + if (time() - $startTime > $timeout) { + proc_terminate($process, 9); + + return [ + 'success' => false, + 'output' => $stdout, + 'stderr' => $stderr, + 'exit_code' => -1, + 'timed_out' => true, + ]; + } + + $stdout .= fread($pipes[1], 8192) ?: ''; + $stderr .= fread($pipes[2], 8192) ?: ''; + + usleep(10000); // 10 ms + } + + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + return [ + 'success' => $exitCode === 0, + 'output' => $stdout, + 'stderr' => $stderr, + 'exit_code' => $exitCode, + ]; + } finally { + // Ensure pipes and process are always cleaned up. + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + + if (is_resource($process)) { + proc_close($process); + } + } + } + + /** + * Get the PHP binary path. + * + * Resolution order: + * 1. Explicitly set via setPhpBinary() + * 2. Configuration key Synapse.tinker.php_binary + * 3. `which php` command output + * 4. PHP_BINARY constant + * + * @return string|null Absolute path to the PHP binary, or null if undetectable. + */ + public function getPhpBinary(): ?string + { + if ($this->phpBinary !== null) { + return $this->phpBinary; + } + + $configured = Configure::read('Synapse.tinker.php_binary'); + if ($configured !== null && is_string($configured) && is_executable($configured)) { + return $configured; + } + + $nullDevice = DIRECTORY_SEPARATOR === '\\' ? 'NUL' : '/dev/null'; + $whichResult = shell_exec('which php 2>' . $nullDevice); + if (is_string($whichResult) && trim($whichResult) !== '') { + $which = trim($whichResult); + if (is_executable($which)) { + return $which; + } + } + + if (is_executable(PHP_BINARY)) { + return PHP_BINARY; + } + + return null; + } + + /** + * Get the CakePHP bin directory path. + * + * Resolution order: + * 1. Explicitly set via setBinPath() + * 2. Configuration key Synapse.tinker.bin_path + * 3. ROOT constant + /bin + * 4. Current working directory + /bin + * + * @return string Absolute path to the bin directory. + */ + public function getBinPath(): string + { + if ($this->binPath !== null) { + return $this->binPath; + } + + $configured = Configure::read('Synapse.tinker.bin_path'); + if ($configured !== null && is_string($configured)) { + return $configured; + } + + if (defined('ROOT')) { + return ROOT . '/bin'; + } + + return getcwd() . '/bin'; + } + + /** + * Override the PHP binary path (useful in tests). + * + * @param string|null $path Absolute path to PHP, or null to re-enable auto-detection. + */ + public function setPhpBinary(?string $path): static + { + $this->phpBinary = $path; + + return $this; + } + + /** + * Override the bin directory path (useful in tests). + * + * @param string|null $path Absolute path to the bin directory, or null to re-enable auto-detection. + */ + public function setBinPath(?string $path): static + { + $this->binPath = $path; + + return $this; + } +} diff --git a/tests/TestCase/Tools/CommandToolsTest.php b/tests/TestCase/Tools/CommandToolsTest.php index 9c43d7d..a786bf0 100644 --- a/tests/TestCase/Tools/CommandToolsTest.php +++ b/tests/TestCase/Tools/CommandToolsTest.php @@ -9,8 +9,10 @@ use Cake\Event\EventManager; use Cake\TestSuite\TestCase; use Mcp\Exception\ToolCallException; +use Synapse\Command\SearchDocsCommand; use Synapse\SynapsePlugin; use Synapse\Tools\CommandTools; +use Synapse\Utility\SubprocessRunner; use TestApp\Command\AnotherTestCommand; use TestApp\Command\TestCommand; @@ -391,4 +393,140 @@ public function testGetCommandInfoIncludesNamespaceInfo(): void $this->assertIsString($result['namespace']); $this->assertStringContainsString('TestApp', $result['namespace']); } + + // ========================================================================= + // runCommand Tests + // ========================================================================= + + /** + * Test runCommand throws for unknown command. + */ + public function testRunCommandThrowsForUnknownCommand(): void + { + $this->expectException(ToolCallException::class); + + $this->commandTools->runCommand('nonexistent_xyz'); + } + + /** + * Test runCommand returns expected result structure. + */ + public function testRunCommandReturnsExpectedStructure(): void + { + $runner = $this->createStub(SubprocessRunner::class); + $runner->method('getPhpBinary')->willReturn(PHP_BINARY); + $runner->method('getBinPath')->willReturn('/fake/bin'); + $runner->method('run')->willReturn([ + 'success' => true, + 'output' => 'command output', + 'stderr' => '', + 'exit_code' => 0, + ]); + + $tools = new CommandTools($this->commandCollection, $runner); + $result = $tools->runCommand('test_command'); + + $this->assertArrayHasKey('command', $result); + $this->assertArrayHasKey('args', $result); + $this->assertArrayHasKey('success', $result); + $this->assertArrayHasKey('output', $result); + $this->assertArrayHasKey('stderr', $result); + $this->assertArrayHasKey('exit_code', $result); + $this->assertSame('test_command', $result['command']); + $this->assertSame('command output', $result['output']); + $this->assertTrue($result['success']); + } + + /** + * Test runCommand propagates non-zero exit code as success=false. + */ + public function testRunCommandNonZeroExitCodeIsNotSuccess(): void + { + $runner = $this->createStub(SubprocessRunner::class); + $runner->method('getPhpBinary')->willReturn(PHP_BINARY); + $runner->method('getBinPath')->willReturn('/fake/bin'); + $runner->method('run')->willReturn([ + 'success' => false, + 'output' => '', + 'stderr' => 'something went wrong', + 'exit_code' => 1, + ]); + + $tools = new CommandTools($this->commandCollection, $runner); + $result = $tools->runCommand('test_command'); + + $this->assertFalse($result['success']); + $this->assertSame(1, $result['exit_code']); + $this->assertSame('something went wrong', $result['stderr']); + } + + /** + * Test runCommand passes args string to runner. + */ + public function testRunCommandPassesArgsToRunner(): void + { + $capturedCommand = null; + + $runner = $this->createStub(SubprocessRunner::class); + $runner->method('getPhpBinary')->willReturn(PHP_BINARY); + $runner->method('getBinPath')->willReturn('/fake/bin'); + $runner->method('run') + ->willReturnCallback(function (string $cmd) use (&$capturedCommand): array { + $capturedCommand = $cmd; + + return ['success' => true, 'output' => '', 'stderr' => '', 'exit_code' => 0]; + }); + + $tools = new CommandTools($this->commandCollection, $runner); + $tools->runCommand('test_command', '--verbose --format=json'); + + $this->assertNotNull($capturedCommand); + $this->assertStringContainsString('test_command', $capturedCommand); + $this->assertStringContainsString('--verbose', $capturedCommand); + $this->assertStringContainsString('--format=json', $capturedCommand); + } + + /** + * Test runCommand clamps timeout to valid range. + */ + public function testRunCommandClampsTimeout(): void + { + $capturedTimeout = null; + + $runner = $this->createStub(SubprocessRunner::class); + $runner->method('getPhpBinary')->willReturn(PHP_BINARY); + $runner->method('getBinPath')->willReturn('/fake/bin'); + $runner->method('run') + ->willReturnCallback(function (string $cmd, ?string $stdin, int $timeout) use (&$capturedTimeout): array { + $capturedTimeout = $timeout; + + return ['success' => true, 'output' => '', 'stderr' => '', 'exit_code' => 0]; + }); + + $tools = new CommandTools($this->commandCollection, $runner); + $tools->runCommand('test_command', '', 99999); + + $this->assertLessThanOrEqual(300, $capturedTimeout); + $this->assertGreaterThanOrEqual(1, $capturedTimeout); + } + + /** + * Integration test: actually shell out to a real Synapse command with --help. + * + * Uses synapse search_docs which is registered by SynapsePlugin in the test app. + */ + public function testRunCommandIntegration(): void + { + $collection = new CommandCollection(); + $collection->add('synapse search_docs', SearchDocsCommand::class); + + $tools = new CommandTools($collection); + $result = $tools->runCommand('synapse search_docs', '--help'); + + // --help exits 0 in CakePHP before calling execute() + $this->assertSame(0, $result['exit_code']); + $this->assertTrue($result['success']); + $this->assertSame('synapse search_docs', $result['command']); + $this->assertStringContainsString('--help', $result['args']); + } } diff --git a/tests/TestCase/Utility/SubprocessRunnerTest.php b/tests/TestCase/Utility/SubprocessRunnerTest.php new file mode 100644 index 0000000..ccf4101 --- /dev/null +++ b/tests/TestCase/Utility/SubprocessRunnerTest.php @@ -0,0 +1,159 @@ +runner = new SubprocessRunner(); + } + + protected function tearDown(): void + { + unset($this->runner); + parent::tearDown(); + } + + // ========================================================================= + // Binary / Path Detection + // ========================================================================= + + public function testGetPhpBinaryReturnsExecutable(): void + { + $binary = $this->runner->getPhpBinary(); + + $this->assertNotNull($binary); + $this->assertTrue(is_executable($binary), sprintf("PHP binary '%s' must be executable", $binary)); + } + + public function testSetPhpBinaryOverridesAutoDetection(): void + { + $this->runner->setPhpBinary('/custom/php'); + $this->assertSame('/custom/php', $this->runner->getPhpBinary()); + } + + public function testSetPhpBinaryNullResetsToAutoDetection(): void + { + $this->runner->setPhpBinary('/custom/php'); + $this->runner->setPhpBinary(null); + + $binary = $this->runner->getPhpBinary(); + $this->assertNotNull($binary); + $this->assertNotSame('/custom/php', $binary); + } + + public function testSetPhpBinaryReturnsStatic(): void + { + $result = $this->runner->setPhpBinary(null); + $this->assertSame($this->runner, $result); + } + + public function testGetBinPathEndsWithBin(): void + { + $binPath = $this->runner->getBinPath(); + $this->assertStringEndsWith('bin', $binPath); + } + + public function testSetBinPathOverrides(): void + { + $this->runner->setBinPath('/custom/bin'); + $this->assertSame('/custom/bin', $this->runner->getBinPath()); + } + + public function testSetBinPathReturnsStatic(): void + { + $result = $this->runner->setBinPath('/custom/bin'); + $this->assertSame($this->runner, $result); + } + + // ========================================================================= + // run() — result structure + // ========================================================================= + + public function testRunReturnsMandatoryKeys(): void + { + $binary = escapeshellarg(PHP_BINARY); + $result = $this->runner->run($binary . ' -r "exit(0);"'); + + $this->assertArrayHasKey('success', $result); + $this->assertArrayHasKey('output', $result); + $this->assertArrayHasKey('stderr', $result); + $this->assertArrayHasKey('exit_code', $result); + } + + public function testRunCapturesStdout(): void + { + $binary = escapeshellarg(PHP_BINARY); + $result = $this->runner->run($binary . ' -r "echo \'hello world\';"'); + + $this->assertTrue($result['success']); + $this->assertStringContainsString('hello world', $result['output']); + $this->assertSame(0, $result['exit_code']); + } + + public function testRunCapturesStderr(): void + { + $binary = escapeshellarg(PHP_BINARY); + $result = $this->runner->run($binary . ' -r "fwrite(STDERR, \'err text\');"'); + + $this->assertStringContainsString('err text', $result['stderr']); + } + + public function testRunNonZeroExitCodeReturnsFalseSuccess(): void + { + $binary = escapeshellarg(PHP_BINARY); + $result = $this->runner->run($binary . ' -r "exit(42);"'); + + $this->assertFalse($result['success']); + $this->assertSame(42, $result['exit_code']); + } + + public function testRunPipesStdinToProcess(): void + { + $binary = escapeshellarg(PHP_BINARY); + $result = $this->runner->run($binary . ' -r "echo fgets(STDIN);"', 'piped input'); + + $this->assertTrue($result['success']); + $this->assertStringContainsString('piped input', $result['output']); + } + + public function testRunWithNullStdinDoesNotBlock(): void + { + $binary = escapeshellarg(PHP_BINARY); + $result = $this->runner->run($binary . ' -r "echo \'ok\';"'); + + $this->assertTrue($result['success']); + $this->assertStringContainsString('ok', $result['output']); + } + + public function testRunWithCustomCwd(): void + { + $binary = escapeshellarg(PHP_BINARY); + $cwd = sys_get_temp_dir(); + $result = $this->runner->run($binary . ' -r "echo getcwd();"', null, 10, $cwd); + + $this->assertTrue($result['success']); + // Output should contain the temp dir (realpath to handle symlinks) + $this->assertStringContainsString(basename(realpath($cwd) ?: $cwd), $result['output']); + } + + public function testRunTimeoutKillsProcess(): void + { + $binary = escapeshellarg(PHP_BINARY); + $result = $this->runner->run($binary . ' -r "sleep(10);"', null, 1); + + $this->assertFalse($result['success']); + $this->assertTrue($result['timed_out'] ?? false); + } +} From 26f798e55a6cfd884a009f712fbc5af4325d17af Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 23 May 2026 12:10:01 +0200 Subject: [PATCH 02/12] feat: add LogTools MCP tools (log_list, log_read) - log_list: lists .log files in LOGS dir with name/size/modified/path - log_read: tails N lines from a log file with optional level filter - Path traversal prevention via realpath validation inside LOGS - 13 tests covering listing, reading, filtering, and security boundaries --- src/Tools/LogTools.php | 139 +++++++++++++++ tests/TestCase/Tools/LogToolsTest.php | 244 ++++++++++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 src/Tools/LogTools.php create mode 100644 tests/TestCase/Tools/LogToolsTest.php diff --git a/src/Tools/LogTools.php b/src/Tools/LogTools.php new file mode 100644 index 0000000..fb481df --- /dev/null +++ b/src/Tools/LogTools.php @@ -0,0 +1,139 @@ + + */ + #[McpTool( + name: 'log_list', + description: 'List all available log files in the application logs directory.', + )] + public function listLogs(): array + { + $logsDir = $this->logsDir(); + + if (!is_dir($logsDir)) { + return []; + } + + $files = glob($logsDir . '*.log'); + if ($files === false) { + return []; + } + + $result = []; + foreach ($files as $path) { + $stat = stat($path); + $result[] = [ + 'name' => basename($path), + 'size' => $stat !== false ? (int)$stat['size'] : 0, + 'modified' => $stat !== false ? date('c', (int)$stat['mtime']) : '', + 'path' => $path, + ]; + } + + usort($result, fn(array $a, array $b): int => strcmp($a['name'], $b['name'])); + + return $result; + } + + /** + * Read lines from a log file, optionally filtered by log level. + * + * @param string $file Log file name (relative to LOGS directory, e.g. 'app.log') + * @param int $lines Maximum number of lines to return from the end of the file (tail) + * @param string|null $level Filter lines containing this string (case-insensitive). Pass null to return all. + * @return array{file: string, lines: int, content: string} + * @throws \Mcp\Exception\ToolCallException When the file is not found or path is invalid + */ + #[McpTool( + name: 'log_read', + description: 'Read the last N lines from a log file, optionally filtered by log level. ' . + 'Use log_list to discover available files.', + )] + public function readLog(string $file, int $lines = 100, ?string $level = null): array + { + $path = $this->resolveLogPath($file); + + $rawLines = file($path, FILE_IGNORE_NEW_LINES); + if ($rawLines === false) { + throw new ToolCallException(sprintf("Could not read log file '%s'.", $file)); + } + + // Filter by level first, then take the tail + if ($level !== null) { + $rawLines = array_values( + array_filter($rawLines, fn(string $line): bool => stripos($line, $level) !== false), + ); + } + + $lines = max(1, $lines); + $tail = array_slice($rawLines, -$lines); + + $content = implode("\n", $tail); + if ($content !== '') { + $content .= "\n"; + } + + return [ + 'file' => basename($path), + 'lines' => count($tail), + 'content' => $content, + ]; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * Resolve and validate a file name to an absolute path inside LOGS. + * + * @throws \Mcp\Exception\ToolCallException When the resolved path escapes the LOGS directory or does not exist. + */ + private function resolveLogPath(string $file): string + { + $logsDir = $this->logsDir(); + + // Build candidate path — strip any leading slashes/dots so the + // basename is always relative to logsDir. + $candidate = $logsDir . basename($file); + + // realpath requires the file to exist; use it to detect traversal. + $real = realpath($candidate); + $realDir = realpath($logsDir); + + if ($real === false || $realDir === false) { + throw new ToolCallException(sprintf("Log file '%s' not found.", $file)); + } + + if (!str_starts_with($real, $realDir . DIRECTORY_SEPARATOR)) { + throw new ToolCallException(sprintf("Log file '%s' is outside the logs directory.", $file)); + } + + return $real; + } + + /** + * Return the LOGS directory path with a trailing separator. + */ + private function logsDir(): string + { + return rtrim(LOGS, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + } +} diff --git a/tests/TestCase/Tools/LogToolsTest.php b/tests/TestCase/Tools/LogToolsTest.php new file mode 100644 index 0000000..a7a9e73 --- /dev/null +++ b/tests/TestCase/Tools/LogToolsTest.php @@ -0,0 +1,244 @@ + paths to clean up after each test */ + private array $createdFiles = []; + + protected function setUp(): void + { + parent::setUp(); + + if (!is_dir(LOGS)) { + mkdir(LOGS, 0755, true); + } + + $this->logTools = new LogTools(); + } + + protected function tearDown(): void + { + foreach ($this->createdFiles as $path) { + if (file_exists($path)) { + unlink($path); + } + } + + $this->createdFiles = []; + + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function writeLog(string $filename, string $content): string + { + $path = LOGS . $filename; + file_put_contents($path, $content); + $this->createdFiles[] = $path; + + return $path; + } + + // ------------------------------------------------------------------------- + // log_list + // ------------------------------------------------------------------------- + + /** + * Test log_list returns an array. + */ + public function testLogListReturnsArray(): void + { + $result = $this->logTools->listLogs(); + + $this->assertGreaterThanOrEqual(0, count($result)); + } + + /** + * Test log_list includes existing log files. + */ + public function testLogListIncludesLogFiles(): void + { + $this->writeLog('app.log', "line1\nline2\n"); + $this->writeLog('error.log', "err\n"); + + $result = $this->logTools->listLogs(); + + $names = array_column($result, 'name'); + $this->assertContains('app.log', $names); + $this->assertContains('error.log', $names); + } + + /** + * Test log_list entry has required keys. + */ + public function testLogListEntryHasRequiredKeys(): void + { + $this->writeLog('test.log', "content\n"); + + $result = $this->logTools->listLogs(); + + $entry = current(array_filter($result, fn(array $e): bool => $e['name'] === 'test.log')); + $this->assertNotFalse($entry); + $this->assertArrayHasKey('name', $entry); + $this->assertArrayHasKey('size', $entry); + $this->assertArrayHasKey('modified', $entry); + $this->assertArrayHasKey('path', $entry); + } + + /** + * Test log_list does not include non-.log files. + */ + public function testLogListOnlyIncludesLogFiles(): void + { + $notLogPath = LOGS . 'readme.txt'; + file_put_contents($notLogPath, 'not a log'); + $this->createdFiles[] = $notLogPath; + + $result = $this->logTools->listLogs(); + + $names = array_column($result, 'name'); + $this->assertNotContains('readme.txt', $names); + } + + /** + * Test log_list size is accurate. + */ + public function testLogListSizeIsAccurate(): void + { + $content = str_repeat('x', 42); + $this->writeLog('sized.log', $content); + + $result = $this->logTools->listLogs(); + + $entry = current(array_filter($result, fn(array $e): bool => $e['name'] === 'sized.log')); + $this->assertNotFalse($entry); + $this->assertSame(42, $entry['size']); + } + + // ------------------------------------------------------------------------- + // log_read + // ------------------------------------------------------------------------- + + /** + * Test log_read returns content of a log file. + */ + public function testLogReadReturnsContent(): void + { + $this->writeLog('app.log', "line1\nline2\nline3\n"); + + $result = $this->logTools->readLog('app.log'); + + $this->assertArrayHasKey('file', $result); + $this->assertArrayHasKey('lines', $result); + $this->assertArrayHasKey('content', $result); + $this->assertStringContainsString('line1', $result['content']); + } + + /** + * Test log_read respects the lines limit (tail behaviour). + */ + public function testLogReadRespectsLinesLimit(): void + { + $lines = implode("\n", range(1, 100)) . "\n"; + $this->writeLog('big.log', $lines); + + $result = $this->logTools->readLog('big.log', 10); + + $returned = count(array_filter(explode("\n", trim($result['content'])), fn($l): bool => $l !== '')); + $this->assertLessThanOrEqual(10, $returned); + $this->assertStringContainsString('100', $result['content']); // tail = last lines + } + + /** + * Test log_read filters by log level. + */ + public function testLogReadFiltersByLevel(): void + { + $content = implode("\n", [ + '2026-01-01 00:00:00 Error: something broke', + '2026-01-01 00:00:01 Warning: watch out', + '2026-01-01 00:00:02 Error: another error', + ]) . "\n"; + $this->writeLog('mixed.log', $content); + + $result = $this->logTools->readLog('mixed.log', 100, 'Error'); + + $this->assertStringContainsString('Error', $result['content']); + $this->assertStringNotContainsString('Warning', $result['content']); + } + + /** + * Test log_read with null level returns all lines. + */ + public function testLogReadWithNullLevelReturnsAll(): void + { + $content = "Error: foo\nWarning: bar\nDebug: baz\n"; + $this->writeLog('all.log', $content); + + $result = $this->logTools->readLog('all.log', 100); + + $this->assertStringContainsString('Error', $result['content']); + $this->assertStringContainsString('Warning', $result['content']); + $this->assertStringContainsString('Debug', $result['content']); + } + + /** + * Test log_read throws for unknown file. + */ + public function testLogReadThrowsForUnknownFile(): void + { + $this->expectException(ToolCallException::class); + + $this->logTools->readLog('nonexistent_xyz.log'); + } + + /** + * Test log_read rejects path traversal attempts. + */ + public function testLogReadRejectsPathTraversal(): void + { + $this->expectException(ToolCallException::class); + + $this->logTools->readLog('../composer.json'); + } + + /** + * Test log_read rejects absolute paths outside LOGS. + */ + public function testLogReadRejectsAbsolutePath(): void + { + $this->expectException(ToolCallException::class); + + $this->logTools->readLog('/etc/passwd'); + } + + /** + * Test log_read lines field reflects actual line count returned. + */ + public function testLogReadLinesFieldIsAccurate(): void + { + $this->writeLog('count.log', "a\nb\nc\n"); + + $result = $this->logTools->readLog('count.log', 100); + + $this->assertSame(3, $result['lines']); + } +} From e58c2d1ff4a277c136b4b4cd281101a44448108e Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 23 May 2026 12:21:41 +0200 Subject: [PATCH 03/12] fix: capture exit code from proc_get_status to fix PHP 8.2 CI failures On PHP 8.2 (Linux), proc_get_status() internally reaps the child process zombie via waitpid() on the first call where running=false. A subsequent proc_close() then returns -1 because the process has already been reaped. Fix: capture $status['exitcode'] immediately when running=false, and use proc_close() in the finally block only for resource cleanup. Also: - Replace glob() with Symfony Finder in LogTools (cleaner, handles edge cases) - Extend log_list description to note non-filesystem logging returns [] - Fix PHPCS: change inline doc comment to multi-line on LogToolsTest property --- src/Tools/LogTools.php | 25 ++++++------ src/Utility/SubprocessRunner.php | 57 ++++++++++++++++----------- tests/TestCase/Tools/LogToolsTest.php | 4 +- 3 files changed, 50 insertions(+), 36 deletions(-) diff --git a/src/Tools/LogTools.php b/src/Tools/LogTools.php index fb481df..5a51fe5 100644 --- a/src/Tools/LogTools.php +++ b/src/Tools/LogTools.php @@ -5,6 +5,7 @@ use Mcp\Capability\Attribute\McpTool; use Mcp\Exception\ToolCallException; +use Symfony\Component\Finder\Finder; /** * Log Tools @@ -21,7 +22,9 @@ class LogTools */ #[McpTool( name: 'log_list', - description: 'List all available log files in the application logs directory.', + description: 'List all available log files in the application logs directory. ' . + 'Returns an empty array when no .log files exist or when the application uses ' . + 'non-filesystem logging (database, syslog, etc.).', )] public function listLogs(): array { @@ -31,19 +34,19 @@ public function listLogs(): array return []; } - $files = glob($logsDir . '*.log'); - if ($files === false) { - return []; - } + $finder = (new Finder()) + ->files() + ->name('*.log') + ->depth(0) + ->in($logsDir); $result = []; - foreach ($files as $path) { - $stat = stat($path); + foreach ($finder as $file) { $result[] = [ - 'name' => basename($path), - 'size' => $stat !== false ? (int)$stat['size'] : 0, - 'modified' => $stat !== false ? date('c', (int)$stat['mtime']) : '', - 'path' => $path, + 'name' => $file->getFilename(), + 'size' => $file->getSize(), + 'modified' => date('c', $file->getMTime()), + 'path' => $file->getRealPath(), ]; } diff --git a/src/Utility/SubprocessRunner.php b/src/Utility/SubprocessRunner.php index 799e9a3..9cbdf7e 100644 --- a/src/Utility/SubprocessRunner.php +++ b/src/Utility/SubprocessRunner.php @@ -53,6 +53,12 @@ public function run(string $command, ?string $stdin = null, int $timeout = 30, ? ]; } + // Capture outside try so finally can reference them. + $exitCode = -1; + $timedOut = false; + $stdout = ''; + $stderr = ''; + try { // Write stdin then close so the process sees EOF. if ($stdin !== null) { @@ -60,18 +66,22 @@ public function run(string $command, ?string $stdin = null, int $timeout = 30, ? } fclose($pipes[0]); + unset($pipes[0]); // prevent double-close in finally stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); - $stdout = ''; - $stderr = ''; $startTime = time(); while (true) { $status = proc_get_status($process); if (!$status['running']) { + // Capture exit code on the FIRST call where running = false. + // proc_get_status() internally reaps the zombie via waitpid() on + // some PHP/OS combinations (notably PHP 8.2 on Linux), making a + // subsequent proc_close() return -1. Reading it here is reliable. + $exitCode = $status['exitcode']; $stdout .= stream_get_contents($pipes[1]); $stderr .= stream_get_contents($pipes[2]); break; @@ -79,14 +89,8 @@ public function run(string $command, ?string $stdin = null, int $timeout = 30, ? if (time() - $startTime > $timeout) { proc_terminate($process, 9); - - return [ - 'success' => false, - 'output' => $stdout, - 'stderr' => $stderr, - 'exit_code' => -1, - 'timed_out' => true, - ]; + $timedOut = true; + break; } $stdout .= fread($pipes[1], 8192) ?: ''; @@ -94,20 +98,8 @@ public function run(string $command, ?string $stdin = null, int $timeout = 30, ? usleep(10000); // 10 ms } - - fclose($pipes[1]); - fclose($pipes[2]); - - $exitCode = proc_close($process); - - return [ - 'success' => $exitCode === 0, - 'output' => $stdout, - 'stderr' => $stderr, - 'exit_code' => $exitCode, - ]; } finally { - // Ensure pipes and process are always cleaned up. + // Always clean up pipes and the process handle regardless of how we exited. foreach ($pipes as $pipe) { if (is_resource($pipe)) { fclose($pipe); @@ -115,9 +107,26 @@ public function run(string $command, ?string $stdin = null, int $timeout = 30, ? } if (is_resource($process)) { - proc_close($process); + proc_close($process); // cleanup only — exit code already captured above } } + + if ($timedOut) { + return [ + 'success' => false, + 'output' => $stdout, + 'stderr' => $stderr, + 'exit_code' => -1, + 'timed_out' => true, + ]; + } + + return [ + 'success' => $exitCode === 0, + 'output' => $stdout, + 'stderr' => $stderr, + 'exit_code' => $exitCode, + ]; } /** diff --git a/tests/TestCase/Tools/LogToolsTest.php b/tests/TestCase/Tools/LogToolsTest.php index a7a9e73..89eb9f1 100644 --- a/tests/TestCase/Tools/LogToolsTest.php +++ b/tests/TestCase/Tools/LogToolsTest.php @@ -17,7 +17,9 @@ class LogToolsTest extends TestCase { private LogTools $logTools; - /** @var array paths to clean up after each test */ + /** + * @var array paths to clean up after each test + */ private array $createdFiles = []; protected function setUp(): void From b165ef5ba48d2ecbd42ad988a3a3c595a65e1b94 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 23 May 2026 12:25:27 +0200 Subject: [PATCH 04/12] fix: loosen testRunCommandIntegration exit code assertion CakePHP 5.2.0 (prefer-lowest) exits with code 1 for --help, while newer versions exit 0. The integration test's goal is to verify the subprocess mechanism works end-to-end, not to audit CakePHP's --help behaviour, so assert output is non-empty and exit_code != -1 instead. --- tests/TestCase/Tools/CommandToolsTest.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/TestCase/Tools/CommandToolsTest.php b/tests/TestCase/Tools/CommandToolsTest.php index a786bf0..ae5561a 100644 --- a/tests/TestCase/Tools/CommandToolsTest.php +++ b/tests/TestCase/Tools/CommandToolsTest.php @@ -514,6 +514,8 @@ public function testRunCommandClampsTimeout(): void * Integration test: actually shell out to a real Synapse command with --help. * * Uses synapse search_docs which is registered by SynapsePlugin in the test app. + * Exit code is not asserted: CakePHP <=5.2 exits 1 for --help, newer versions exit 0. + * The test verifies the subprocess mechanism works, not CakePHP's --help behaviour. */ public function testRunCommandIntegration(): void { @@ -523,9 +525,10 @@ public function testRunCommandIntegration(): void $tools = new CommandTools($collection); $result = $tools->runCommand('synapse search_docs', '--help'); - // --help exits 0 in CakePHP before calling execute() - $this->assertSame(0, $result['exit_code']); - $this->assertTrue($result['success']); + // Verify the shell-out succeeded: not a proc_open failure (exit_code -1) + // and the command produced some output. + $this->assertNotSame(-1, $result['exit_code']); + $this->assertNotEmpty($result['output']); $this->assertSame('synapse search_docs', $result['command']); $this->assertStringContainsString('--help', $result['args']); } From 986c7e5f51d413c9af43537311c4a1d10bb920c1 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 23 May 2026 12:28:53 +0200 Subject: [PATCH 05/12] fix: check combined stdout+stderr in integration test CakePHP 5.2.0 writes --help output to stderr; newer versions write to stdout. Check both to avoid version-specific failures. --- tests/TestCase/Tools/CommandToolsTest.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/TestCase/Tools/CommandToolsTest.php b/tests/TestCase/Tools/CommandToolsTest.php index ae5561a..5bb5e82 100644 --- a/tests/TestCase/Tools/CommandToolsTest.php +++ b/tests/TestCase/Tools/CommandToolsTest.php @@ -525,10 +525,11 @@ public function testRunCommandIntegration(): void $tools = new CommandTools($collection); $result = $tools->runCommand('synapse search_docs', '--help'); - // Verify the shell-out succeeded: not a proc_open failure (exit_code -1) - // and the command produced some output. + // Verify the shell-out succeeded: not a proc_open failure (exit_code -1). + // Output channel varies by CakePHP version: <=5.2 writes help to stderr, + // newer versions write to stdout — check combined output. $this->assertNotSame(-1, $result['exit_code']); - $this->assertNotEmpty($result['output']); + $this->assertNotEmpty($result['output'] . $result['stderr']); $this->assertSame('synapse search_docs', $result['command']); $this->assertStringContainsString('--help', $result['args']); } From 530b2452adf07311a8b0e0570eae0166cb2a8c19 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 23 May 2026 12:36:45 +0200 Subject: [PATCH 06/12] fix: remove non-blocking fread loop in SubprocessRunner for Windows compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stream_set_blocking() is unreliable for proc_open pipes on Windows — fread() on a 'non-blocking' pipe still blocks there, causing the timeout check to never fire (the process exits naturally after its full duration and success=true is returned instead of timed_out=true/success=false). Fix: poll only via proc_get_status() in a usleep(10ms) loop and do a single stream_get_contents() read once the process has exited (or been terminated). Output is intentionally limited to the OS pipe buffer (~64 KB on Linux); this is acceptable for the commands this plugin runs. --- src/Utility/SubprocessRunner.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Utility/SubprocessRunner.php b/src/Utility/SubprocessRunner.php index 9cbdf7e..b264833 100644 --- a/src/Utility/SubprocessRunner.php +++ b/src/Utility/SubprocessRunner.php @@ -68,8 +68,12 @@ public function run(string $command, ?string $stdin = null, int $timeout = 30, ? fclose($pipes[0]); unset($pipes[0]); // prevent double-close in finally - stream_set_blocking($pipes[1], false); - stream_set_blocking($pipes[2], false); + // Note: stream_set_blocking() is unreliable for proc_open pipes on + // Windows — fread() on a non-blocking pipe still blocks there. + // We therefore poll only via proc_get_status() and read stdout/stderr + // in one shot once the process has exited (or been terminated). + // Output is capped to the OS pipe buffer (~64 KB on Linux); this is + // intentional and acceptable for the commands this class executes. $startTime = time(); @@ -82,8 +86,8 @@ public function run(string $command, ?string $stdin = null, int $timeout = 30, ? // some PHP/OS combinations (notably PHP 8.2 on Linux), making a // subsequent proc_close() return -1. Reading it here is reliable. $exitCode = $status['exitcode']; - $stdout .= stream_get_contents($pipes[1]); - $stderr .= stream_get_contents($pipes[2]); + $stdout .= stream_get_contents($pipes[1]) ?: ''; + $stderr .= stream_get_contents($pipes[2]) ?: ''; break; } @@ -93,10 +97,7 @@ public function run(string $command, ?string $stdin = null, int $timeout = 30, ? break; } - $stdout .= fread($pipes[1], 8192) ?: ''; - $stderr .= fread($pipes[2], 8192) ?: ''; - - usleep(10000); // 10 ms + usleep(10000); // 10 ms polling } } finally { // Always clean up pipes and the process handle regardless of how we exited. From 555cd4b4156a149c0c9eb7f6d25b7705ee487aa3 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 23 May 2026 12:44:45 +0200 Subject: [PATCH 07/12] feat: add CacheTools MCP tools (cache_configs, cache_read, cache_write, cache_delete, cache_clear) --- src/Tools/CacheTools.php | 167 ++++++++++++++++++ tests/TestCase/Tools/CacheToolsTest.php | 222 ++++++++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 src/Tools/CacheTools.php create mode 100644 tests/TestCase/Tools/CacheToolsTest.php diff --git a/src/Tools/CacheTools.php b/src/Tools/CacheTools.php new file mode 100644 index 0000000..1dd4540 --- /dev/null +++ b/src/Tools/CacheTools.php @@ -0,0 +1,167 @@ +> List of cache configurations + */ + #[McpTool( + name: 'cache_configs', + description: 'List all configured CakePHP cache configurations with engine information', + )] + public function listConfigs(): array + { + $configs = Cache::configured(); + $result = []; + + foreach ($configs as $name) { + $config = Cache::getConfig($name) ?? []; + + $result[] = [ + 'name' => $name, + 'className' => $config['className'] ?? $config['engine'] ?? 'Unknown', + 'prefix' => $config['prefix'] ?? null, + 'path' => $config['path'] ?? null, + 'duration' => $config['duration'] ?? null, + ]; + } + + return $result; + } + + /** + * Read a value from the cache. + * + * Returns the stored value for the given key, or indicates the key was not found. + * + * @param string $config Cache configuration name (e.g. 'default') + * @param string $key Cache key to read + * @return array{key: string, value: mixed, found: bool} Cache read result + */ + #[McpTool( + name: 'cache_read', + description: 'Read a value from a CakePHP cache configuration by key', + )] + public function cacheRead(string $config, string $key): array + { + $this->assertConfigured($config); + + $value = Cache::read($key, $config); + $found = $value !== null; + + return [ + 'key' => $key, + 'value' => $found ? $value : null, + 'found' => $found, + ]; + } + + /** + * Write a value to the cache. + * + * Stores the given string value under the specified key in the cache configuration. + * + * @param string $config Cache configuration name (e.g. 'default') + * @param string $key Cache key to write + * @param string $value Value to store + * @return array{key: string, stored: bool} Cache write result + */ + #[McpTool( + name: 'cache_write', + description: 'Write a string value to a CakePHP cache configuration', + )] + public function cacheWrite(string $config, string $key, string $value): array + { + $this->assertConfigured($config); + + $stored = Cache::write($key, $value, $config); + + return [ + 'key' => $key, + 'stored' => $stored, + ]; + } + + /** + * Delete a key from the cache. + * + * Removes the specified key from the given cache configuration. + * + * @param string $config Cache configuration name (e.g. 'default') + * @param string $key Cache key to delete + * @return array{key: string, deleted: bool} Cache delete result + */ + #[McpTool( + name: 'cache_delete', + description: 'Delete a key from a CakePHP cache configuration', + )] + public function cacheDelete(string $config, string $key): array + { + $this->assertConfigured($config); + + $deleted = Cache::delete($key, $config); + + return [ + 'key' => $key, + 'deleted' => $deleted, + ]; + } + + /** + * Clear all entries from a cache configuration. + * + * Removes all cached entries from the specified cache configuration. + * + * @param string $config Cache configuration name (e.g. 'default') + * @return array{config: string, cleared: bool} Cache clear result + */ + #[McpTool( + name: 'cache_clear', + description: 'Clear all entries from a CakePHP cache configuration', + )] + public function cacheClear(string $config): array + { + $this->assertConfigured($config); + + $cleared = Cache::clear($config); + + return [ + 'config' => $config, + 'cleared' => $cleared, + ]; + } + + /** + * Assert that a cache configuration exists. + * + * @param string $config Cache configuration name to validate + * @throws \Mcp\Exception\ToolCallException If the configuration is not registered + */ + private function assertConfigured(string $config): void + { + if (!in_array($config, Cache::configured(), true)) { + throw new ToolCallException(sprintf( + 'Cache config "%s" is not configured. Available: %s', + $config, + implode(', ', Cache::configured()), + )); + } + } +} diff --git a/tests/TestCase/Tools/CacheToolsTest.php b/tests/TestCase/Tools/CacheToolsTest.php new file mode 100644 index 0000000..8398fa6 --- /dev/null +++ b/tests/TestCase/Tools/CacheToolsTest.php @@ -0,0 +1,222 @@ + cache entries written during tests + */ + private array $writtenEntries = []; + + protected function setUp(): void + { + parent::setUp(); + $this->cacheTools = new CacheTools(); + } + + protected function tearDown(): void + { + foreach ($this->writtenEntries as ['config' => $config, 'key' => $key]) { + Cache::delete($key, $config); + } + + $this->writtenEntries = []; + + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function writeCache(string $key, mixed $value, string $config = 'default'): void + { + Cache::write($key, $value, $config); + $this->writtenEntries[] = ['config' => $config, 'key' => $key]; + } + + // ------------------------------------------------------------------------- + // cache_configs + // ------------------------------------------------------------------------- + + /** + * Test cache_configs returns an array. + */ + public function testListConfigsReturnsArray(): void + { + $result = $this->cacheTools->listConfigs(); + + $this->assertGreaterThan(0, count($result)); + } + + /** + * Test cache_configs includes the 'default' entry. + */ + public function testListConfigsContainsDefault(): void + { + $result = $this->cacheTools->listConfigs(); + $names = array_column($result, 'name'); + + $this->assertContains('default', $names); + } + + /** + * Test each entry in cache_configs has the expected structure. + */ + public function testListConfigsEntryStructure(): void + { + $result = $this->cacheTools->listConfigs(); + $entry = $result[0]; + + $this->assertArrayHasKey('name', $entry); + $this->assertArrayHasKey('className', $entry); + $this->assertIsString($entry['name']); + $this->assertIsString($entry['className']); + } + + // ------------------------------------------------------------------------- + // cache_read + // ------------------------------------------------------------------------- + + /** + * Test cache_read returns found=false and value=null for a missing key. + */ + public function testCacheReadMissingKey(): void + { + $result = $this->cacheTools->cacheRead('default', 'synapse_test_missing_' . uniqid()); + + $this->assertSame(false, $result['found']); + $this->assertNull($result['value']); + } + + /** + * Test cache_read returns found=true and the stored value. + */ + public function testCacheReadFoundKey(): void + { + $key = 'synapse_test_read_' . uniqid(); + $this->writeCache($key, 'hello_world'); + + $result = $this->cacheTools->cacheRead('default', $key); + + $this->assertSame(true, $result['found']); + $this->assertSame('hello_world', $result['value']); + $this->assertSame($key, $result['key']); + } + + /** + * Test cache_read throws ToolCallException for an unknown config. + */ + public function testCacheReadInvalidConfigThrows(): void + { + $this->expectException(ToolCallException::class); + + $this->cacheTools->cacheRead('nonexistent_config_xyz', 'any_key'); + } + + // ------------------------------------------------------------------------- + // cache_write + // ------------------------------------------------------------------------- + + /** + * Test cache_write stores a value that can be read back. + */ + public function testCacheWriteStoresValue(): void + { + $key = 'synapse_test_write_' . uniqid(); + $this->writtenEntries[] = ['config' => 'default', 'key' => $key]; + + $result = $this->cacheTools->cacheWrite('default', $key, 'stored_value'); + + $this->assertSame(true, $result['stored']); + $this->assertSame($key, $result['key']); + $this->assertSame('stored_value', Cache::read($key, 'default')); + } + + /** + * Test cache_write throws ToolCallException for an unknown config. + */ + public function testCacheWriteInvalidConfigThrows(): void + { + $this->expectException(ToolCallException::class); + + $this->cacheTools->cacheWrite('nonexistent_config_xyz', 'key', 'value'); + } + + // ------------------------------------------------------------------------- + // cache_delete + // ------------------------------------------------------------------------- + + /** + * Test cache_delete removes a previously stored key. + */ + public function testCacheDeleteRemovesKey(): void + { + $key = 'synapse_test_delete_' . uniqid(); + $this->writeCache($key, 'to_be_deleted'); + + $result = $this->cacheTools->cacheDelete('default', $key); + + $this->assertSame(true, $result['deleted']); + $this->assertSame($key, $result['key']); + $this->assertNull(Cache::read($key, 'default')); + } + + /** + * Test cache_delete throws ToolCallException for an unknown config. + */ + public function testCacheDeleteInvalidConfigThrows(): void + { + $this->expectException(ToolCallException::class); + + $this->cacheTools->cacheDelete('nonexistent_config_xyz', 'key'); + } + + // ------------------------------------------------------------------------- + // cache_clear + // ------------------------------------------------------------------------- + + /** + * Test cache_clear removes all entries from the specified config. + */ + public function testCacheClearClearsConfig(): void + { + // Write two keys — don't track them for tearDown since clear removes them + $key1 = 'synapse_test_clear_a_' . uniqid(); + $key2 = 'synapse_test_clear_b_' . uniqid(); + Cache::write($key1, 'value1', 'default'); + Cache::write($key2, 'value2', 'default'); + + $result = $this->cacheTools->cacheClear('default'); + + $this->assertSame(true, $result['cleared']); + $this->assertSame('default', $result['config']); + $this->assertNull(Cache::read($key1, 'default')); + $this->assertNull(Cache::read($key2, 'default')); + } + + /** + * Test cache_clear throws ToolCallException for an unknown config. + */ + public function testCacheClearInvalidConfigThrows(): void + { + $this->expectException(ToolCallException::class); + + $this->cacheTools->cacheClear('nonexistent_config_xyz'); + } +} From a93b113acc8acd699afc775173477b9a85b5efd3 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 23 May 2026 12:55:11 +0200 Subject: [PATCH 08/12] feat: add ModelTools MCP tools (orm_describe, orm_find) --- src/Tools/ModelTools.php | 142 +++++++++++++++++++ tests/TestCase/Tools/ModelToolsTest.php | 180 ++++++++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 src/Tools/ModelTools.php create mode 100644 tests/TestCase/Tools/ModelToolsTest.php diff --git a/src/Tools/ModelTools.php b/src/Tools/ModelTools.php new file mode 100644 index 0000000..498a0ff --- /dev/null +++ b/src/Tools/ModelTools.php @@ -0,0 +1,142 @@ + ORM table description + */ + #[McpTool( + name: 'orm_describe', + description: 'Describe a CakePHP ORM Table: associations, behaviors, display field, primary key, entity class', + )] + public function ormDescribe(string $alias, string $connection = 'default'): array + { + try { + $table = TableRegistry::getTableLocator()->get($alias, ['connectionName' => $connection]); + + $associations = []; + foreach ($table->associations() as $association) { + $foreignKey = $association->getForeignKey(); + $associations[] = [ + 'name' => $association->getName(), + 'type' => $association->type(), + 'alias' => $association->getAlias(), + 'foreignKey' => $foreignKey === false ? null : $foreignKey, + 'className' => $association->getClassName(), + ]; + } + + return [ + 'alias' => $table->getAlias(), + 'table' => $table->getTable(), + 'connection' => $table->getConnection()->configName(), + 'primaryKey' => $table->getPrimaryKey(), + 'displayField' => $table->getDisplayField(), + 'entityClass' => $table->getEntityClass(), + 'associations' => $associations, + 'behaviors' => $table->behaviors()->loaded(), + ]; + } catch (Exception $exception) { + throw new ToolCallException( + sprintf("Failed to describe table '%s': %s", $alias, $exception->getMessage()), + $exception->getCode(), + $exception, + ); + } + } + + /** + * Run a find query on a CakePHP ORM Table. + * + * Executes a find of the given type against the specified table alias + * and returns the results as plain arrays. The limit is clamped between + * 1 and 100. + * + * @param string $alias Table alias in CamelCase (e.g. 'Articles') + * @param string $connection Connection name to use (defaults to 'default') + * @param string $type Find type: 'all', 'first', or 'count' + * @param int $limit Maximum number of records to return (clamped 1–100) + * @return array Query results + */ + #[McpTool( + name: 'orm_find', + description: "Run a find query on a CakePHP ORM Table. Type must be 'all', 'first', or 'count'.", + )] + public function ormFind( + string $alias, + string $connection = 'default', + string $type = 'all', + int $limit = 10, + ): array { + $validTypes = ['all', 'first', 'count']; + if (!in_array($type, $validTypes, true)) { + throw new ToolCallException(sprintf( + "Invalid find type '%s'. Valid types: %s", + $type, + implode(', ', $validTypes), + )); + } + + $limit = max(1, min(100, $limit)); + + try { + $table = TableRegistry::getTableLocator()->get($alias, ['connectionName' => $connection]); + + if ($type === 'count') { + return [ + 'alias' => $alias, + 'count' => $table->find()->count(), + ]; + } + + $query = $table->find()->limit($limit)->enableHydration(false); + + if ($type === 'first') { + /** @var array|null $record */ + $record = $query->first(); + + return [ + 'alias' => $alias, + 'record' => $record, + ]; + } + + /** @var array> $records */ + $records = $query->all()->toList(); + + return [ + 'alias' => $alias, + 'count' => count($records), + 'records' => $records, + ]; + } catch (ToolCallException $e) { + throw $e; + } catch (Exception $e) { + throw new ToolCallException( + sprintf("Failed to find records for '%s': %s", $alias, $e->getMessage()), + $e->getCode(), + $e, + ); + } + } +} diff --git a/tests/TestCase/Tools/ModelToolsTest.php b/tests/TestCase/Tools/ModelToolsTest.php new file mode 100644 index 0000000..1a0cf34 --- /dev/null +++ b/tests/TestCase/Tools/ModelToolsTest.php @@ -0,0 +1,180 @@ + fixtures to load for each test + */ + protected array $fixtures = [ + 'plugin.Synapse.Articles', + 'plugin.Synapse.Users', + ]; + + protected function setUp(): void + { + parent::setUp(); + $this->modelTools = new ModelTools(); + } + + protected function tearDown(): void + { + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // orm_describe + // ------------------------------------------------------------------------- + + /** + * Test orm_describe returns the expected top-level structure. + */ + public function testOrmDescribeReturnsExpectedStructure(): void + { + $result = $this->modelTools->ormDescribe('Articles', 'test'); + + $this->assertArrayHasKey('alias', $result); + $this->assertArrayHasKey('table', $result); + $this->assertArrayHasKey('connection', $result); + $this->assertArrayHasKey('primaryKey', $result); + $this->assertArrayHasKey('displayField', $result); + $this->assertArrayHasKey('entityClass', $result); + $this->assertArrayHasKey('associations', $result); + $this->assertArrayHasKey('behaviors', $result); + } + + /** + * Test orm_describe returns correct alias and table name. + */ + public function testOrmDescribeAliasAndTable(): void + { + $result = $this->modelTools->ormDescribe('Articles', 'test'); + + $this->assertSame('Articles', $result['alias']); + $this->assertSame('articles', $result['table']); + } + + /** + * Test orm_describe reports the correct connection name. + */ + public function testOrmDescribeConnectionName(): void + { + $result = $this->modelTools->ormDescribe('Articles', 'test'); + + $this->assertSame('test', $result['connection']); + } + + /** + * Test orm_describe associations is an array. + */ + public function testOrmDescribeAssociationsIsArray(): void + { + $result = $this->modelTools->ormDescribe('Articles', 'test'); + + $this->assertIsArray($result['associations']); + $this->assertIsArray($result['behaviors']); + } + + // ------------------------------------------------------------------------- + // orm_find + // ------------------------------------------------------------------------- + + /** + * Test orm_find with type=all returns records from fixtures. + */ + public function testOrmFindAllReturnsRecords(): void + { + $result = $this->modelTools->ormFind('Articles', 'test'); + + $this->assertArrayHasKey('alias', $result); + $this->assertArrayHasKey('records', $result); + $this->assertArrayHasKey('count', $result); + $this->assertSame('Articles', $result['alias']); + $this->assertSame(3, $result['count']); + $this->assertCount(3, $result['records']); + } + + /** + * Test orm_find records have the expected field keys. + */ + public function testOrmFindRecordsHaveExpectedFields(): void + { + $result = $this->modelTools->ormFind('Articles', 'test'); + + $first = $result['records'][0]; + $this->assertArrayHasKey('id', $first); + $this->assertArrayHasKey('title', $first); + $this->assertArrayHasKey('body', $first); + } + + /** + * Test orm_find with type=first returns a single record. + */ + public function testOrmFindFirstReturnsOneRecord(): void + { + $result = $this->modelTools->ormFind('Articles', 'test', 'first'); + + $this->assertArrayHasKey('alias', $result); + $this->assertArrayHasKey('record', $result); + $this->assertIsArray($result['record']); + $this->assertArrayHasKey('id', $result['record']); + $this->assertArrayHasKey('title', $result['record']); + } + + /** + * Test orm_find with type=count returns an integer count. + */ + public function testOrmFindCountReturnsInteger(): void + { + $result = $this->modelTools->ormFind('Articles', 'test', 'count'); + + $this->assertArrayHasKey('count', $result); + $this->assertSame(3, $result['count']); + } + + /** + * Test orm_find respects the limit parameter. + */ + public function testOrmFindLimitIsRespected(): void + { + $result = $this->modelTools->ormFind('Articles', 'test', 'all', 1); + + $this->assertSame(1, $result['count']); + $this->assertCount(1, $result['records']); + } + + /** + * Test orm_find clamps limit < 1 up to 1. + */ + public function testOrmFindLimitClampedToMinimum(): void + { + $result = $this->modelTools->ormFind('Articles', 'test', 'all', 0); + + $this->assertSame(1, $result['count']); + $this->assertCount(1, $result['records']); + } + + /** + * Test orm_find throws ToolCallException for an invalid find type. + */ + public function testOrmFindInvalidTypeThrows(): void + { + $this->expectException(ToolCallException::class); + + $this->modelTools->ormFind('Articles', 'test', 'invalid_type'); + } +} From 3bae20f6b5ad76220a2b6076e2a93b3397f3c8fd Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 23 May 2026 13:46:02 +0200 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20address=20Copilot=20review=20comme?= =?UTF-8?q?nts=20=E2=80=94=20shell=20injection=20escape,=20SubprocessRunne?= =?UTF-8?q?r=20caching,=20LogTools=20memory=20efficiency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Tools/CommandTools.php | 6 +- src/Tools/LogTools.php | 111 +++++++++++++++++++++++++++---- src/Utility/SubprocessRunner.php | 12 ++-- 3 files changed, 108 insertions(+), 21 deletions(-) diff --git a/src/Tools/CommandTools.php b/src/Tools/CommandTools.php index d57978c..df68456 100644 --- a/src/Tools/CommandTools.php +++ b/src/Tools/CommandTools.php @@ -219,7 +219,11 @@ public function runCommand(string $name, string $args = '', int $timeout = 60): $commandStr = escapeshellarg($phpBinary) . ' bin/cake.php ' . implode(' ', $nameParts); if (trim($args) !== '') { - $commandStr .= ' ' . $args; + // Escape each argument token individually to prevent shell injection. + // Users who need a single argument with embedded spaces should pass it + // as one token without whitespace separating it from other args. + $argTokens = preg_split('/\s+/', trim($args), -1, PREG_SPLIT_NO_EMPTY) ?: []; + $commandStr .= ' ' . implode(' ', array_map('escapeshellarg', $argTokens)); } $cwd = dirname($this->runner->getBinPath()); diff --git a/src/Tools/LogTools.php b/src/Tools/LogTools.php index 5a51fe5..cbd6d99 100644 --- a/src/Tools/LogTools.php +++ b/src/Tools/LogTools.php @@ -71,22 +71,12 @@ public function listLogs(): array )] public function readLog(string $file, int $lines = 100, ?string $level = null): array { + $lines = max(1, min(5000, $lines)); $path = $this->resolveLogPath($file); - $rawLines = file($path, FILE_IGNORE_NEW_LINES); - if ($rawLines === false) { - throw new ToolCallException(sprintf("Could not read log file '%s'.", $file)); - } - - // Filter by level first, then take the tail - if ($level !== null) { - $rawLines = array_values( - array_filter($rawLines, fn(string $line): bool => stripos($line, $level) !== false), - ); - } - - $lines = max(1, $lines); - $tail = array_slice($rawLines, -$lines); + $tail = $level !== null + ? $this->readTailFiltered($path, $lines, $level, $file) + : $this->readTail($path, $lines, $file); $content = implode("\n", $tail); if ($content !== '') { @@ -100,6 +90,99 @@ public function readLog(string $file, int $lines = 100, ?string $level = null): ]; } + /** + * Read the last N lines from a file using a reverse-seek strategy. + * + * Reads backwards in 8 KB chunks so only the tail portion of the file + * is loaded into memory, regardless of total file size. + * + * @param string $path Absolute file path + * @param int $lines Number of lines to return + * @param string $file Original user-supplied name (for error messages) + * @return array Lines from the tail of the file + * @throws \Mcp\Exception\ToolCallException When the file cannot be opened + */ + private function readTail(string $path, int $lines, string $file): array + { + $handle = fopen($path, 'rb'); + if ($handle === false) { + throw new ToolCallException(sprintf("Could not read log file '%s'.", $file)); + } + + try { + fseek($handle, 0, SEEK_END); + $fileSize = ftell($handle); + if (!is_int($fileSize) || $fileSize === 0) { + return []; + } + + $buffer = ''; + $pos = $fileSize; + $chunkSize = 8192; + $linesFound = 0; + + while ($pos > 0 && $linesFound <= $lines) { + $readSize = min($chunkSize, $pos); + $pos -= $readSize; + fseek($handle, $pos); + $chunk = fread($handle, $readSize); + if ($chunk === false) { + break; + } + + $buffer = $chunk . $buffer; + $linesFound += substr_count($chunk, "\n"); + } + } finally { + fclose($handle); + } + + $result = explode("\n", $buffer); + if (end($result) === '') { + array_pop($result); + } + + return array_slice($result, -$lines); + } + + /** + * Read lines from a file that match a level string, returning the last N matches. + * + * Processes the file line by line using a fixed-size rolling buffer so that + * memory use is bounded to $lines matching entries regardless of file size. + * + * @param string $path Absolute file path + * @param int $lines Maximum number of matching lines to return + * @param string $level Level string to filter on (case-insensitive) + * @param string $file Original user-supplied name (for error messages) + * @return array Matching lines from the tail of the file + * @throws \Mcp\Exception\ToolCallException When the file cannot be opened + */ + private function readTailFiltered(string $path, int $lines, string $level, string $file): array + { + $handle = fopen($path, 'rb'); + if ($handle === false) { + throw new ToolCallException(sprintf("Could not read log file '%s'.", $file)); + } + + $matching = []; + try { + while (($line = fgets($handle)) !== false) { + $line = rtrim($line, "\n\r"); + if (stripos($line, $level) !== false) { + $matching[] = $line; + if (count($matching) > $lines) { + array_shift($matching); + } + } + } + } finally { + fclose($handle); + } + + return $matching; + } + // ------------------------------------------------------------------------- // Internals // ------------------------------------------------------------------------- diff --git a/src/Utility/SubprocessRunner.php b/src/Utility/SubprocessRunner.php index b264833..90bf308 100644 --- a/src/Utility/SubprocessRunner.php +++ b/src/Utility/SubprocessRunner.php @@ -149,7 +149,7 @@ public function getPhpBinary(): ?string $configured = Configure::read('Synapse.tinker.php_binary'); if ($configured !== null && is_string($configured) && is_executable($configured)) { - return $configured; + return $this->phpBinary = $configured; } $nullDevice = DIRECTORY_SEPARATOR === '\\' ? 'NUL' : '/dev/null'; @@ -157,12 +157,12 @@ public function getPhpBinary(): ?string if (is_string($whichResult) && trim($whichResult) !== '') { $which = trim($whichResult); if (is_executable($which)) { - return $which; + return $this->phpBinary = $which; } } if (is_executable(PHP_BINARY)) { - return PHP_BINARY; + return $this->phpBinary = PHP_BINARY; } return null; @@ -187,14 +187,14 @@ public function getBinPath(): string $configured = Configure::read('Synapse.tinker.bin_path'); if ($configured !== null && is_string($configured)) { - return $configured; + return $this->binPath = $configured; } if (defined('ROOT')) { - return ROOT . '/bin'; + return $this->binPath = ROOT . '/bin'; } - return getcwd() . '/bin'; + return $this->binPath = getcwd() . '/bin'; } /** From cdf0cea1ff21e63b834b16f3bc866eb786bd5390 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 23 May 2026 14:02:31 +0200 Subject: [PATCH 10/12] test: add coverage for shell escaping, runner caching, and log tail edge cases --- tests/TestCase/Tools/CommandToolsTest.php | 30 ++++++++++++++ tests/TestCase/Tools/LogToolsTest.php | 41 +++++++++++++++++++ .../TestCase/Utility/SubprocessRunnerTest.php | 16 ++++++++ 3 files changed, 87 insertions(+) diff --git a/tests/TestCase/Tools/CommandToolsTest.php b/tests/TestCase/Tools/CommandToolsTest.php index 5bb5e82..f4cdda6 100644 --- a/tests/TestCase/Tools/CommandToolsTest.php +++ b/tests/TestCase/Tools/CommandToolsTest.php @@ -486,6 +486,36 @@ public function testRunCommandPassesArgsToRunner(): void $this->assertStringContainsString('--format=json', $capturedCommand); } + /** + * Test runCommand escapes shell metacharacters in args. + * + * A raw semicolon in $args must not create a second shell command. + * After the fix each token is wrapped in escapeshellarg(), so + * "; echo INJECTED" becomes individual single-quoted tokens and the + * literal sequence "; echo INJECTED" never appears in the command string. + */ + public function testRunCommandArgsShellMetacharactersAreEscaped(): void + { + $capturedCommand = null; + + $runner = $this->createStub(SubprocessRunner::class); + $runner->method('getPhpBinary')->willReturn(PHP_BINARY); + $runner->method('getBinPath')->willReturn('/fake/bin'); + $runner->method('run') + ->willReturnCallback(function (string $cmd) use (&$capturedCommand): array { + $capturedCommand = $cmd; + + return ['success' => true, 'output' => '', 'stderr' => '', 'exit_code' => 0]; + }); + + $tools = new CommandTools($this->commandCollection, $runner); + $tools->runCommand('test_command', '--opt; echo INJECTED'); + + $this->assertNotNull($capturedCommand); + // The raw shell sequence must not appear in the built command + $this->assertStringNotContainsString('; echo INJECTED', $capturedCommand); + } + /** * Test runCommand clamps timeout to valid range. */ diff --git a/tests/TestCase/Tools/LogToolsTest.php b/tests/TestCase/Tools/LogToolsTest.php index 89eb9f1..08e8c8e 100644 --- a/tests/TestCase/Tools/LogToolsTest.php +++ b/tests/TestCase/Tools/LogToolsTest.php @@ -243,4 +243,45 @@ public function testLogReadLinesFieldIsAccurate(): void $this->assertSame(3, $result['lines']); } + + /** + * Test log_read with an empty file returns zero lines and empty content. + */ + public function testLogReadEmptyFileReturnsZeroLines(): void + { + $this->writeLog('empty.log', ''); + + $result = $this->logTools->readLog('empty.log'); + + $this->assertSame(0, $result['lines']); + $this->assertSame('', $result['content']); + } + + /** + * Test log_read clamps a lines value of 0 up to 1. + */ + public function testLogReadLineLimitClampedToMinimum(): void + { + $this->writeLog('clamp.log', "line1\nline2\nline3\n"); + + $result = $this->logTools->readLog('clamp.log', 0); + + $this->assertSame(1, $result['lines']); + } + + /** + * Test log_read with a level filter respects the lines limit (rolling buffer). + * + * When there are more matching lines than the requested limit, only the + * last N matching lines should be returned. + */ + public function testLogReadFilteredTailRespectsLimit(): void + { + $content = implode("\n", array_fill(0, 10, 'ERROR: something bad')) . "\n"; + $this->writeLog('filtered.log', $content); + + $result = $this->logTools->readLog('filtered.log', 3, 'ERROR'); + + $this->assertSame(3, $result['lines']); + } } diff --git a/tests/TestCase/Utility/SubprocessRunnerTest.php b/tests/TestCase/Utility/SubprocessRunnerTest.php index ccf4101..a5f8b3f 100644 --- a/tests/TestCase/Utility/SubprocessRunnerTest.php +++ b/tests/TestCase/Utility/SubprocessRunnerTest.php @@ -37,6 +37,14 @@ public function testGetPhpBinaryReturnsExecutable(): void $this->assertTrue(is_executable($binary), sprintf("PHP binary '%s' must be executable", $binary)); } + public function testGetPhpBinaryReturnsSameValueOnRepeatedCalls(): void + { + $first = $this->runner->getPhpBinary(); + $second = $this->runner->getPhpBinary(); + + $this->assertSame($first, $second); + } + public function testSetPhpBinaryOverridesAutoDetection(): void { $this->runner->setPhpBinary('/custom/php'); @@ -65,6 +73,14 @@ public function testGetBinPathEndsWithBin(): void $this->assertStringEndsWith('bin', $binPath); } + public function testGetBinPathReturnsSameValueOnRepeatedCalls(): void + { + $first = $this->runner->getBinPath(); + $second = $this->runner->getBinPath(); + + $this->assertSame($first, $second); + } + public function testSetBinPathOverrides(): void { $this->runner->setBinPath('/custom/bin'); From df1c4652f6ad9a04cb19d590e675b240ecb8b826 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 23 May 2026 14:38:55 +0200 Subject: [PATCH 11/12] ci: always generate JUnit XML so codecov test-results upload succeeds on all matrix jobs --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d60e50f..6702323 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: if [[ ${{ matrix.php-version }} == '8.5' && ${{ matrix.os }} == 'ubuntu-latest' ]]; then vendor/bin/phpunit --coverage-clover=coverage.xml --log-junit junit.xml else - vendor/bin/phpunit + vendor/bin/phpunit --log-junit junit.xml fi - name: Validate prefer-lowest From 572680e4a449a54f823ce1b779b91b9ed3ef5a99 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 23 May 2026 14:40:43 +0200 Subject: [PATCH 12/12] docs: update README with run_command, LogTools, CacheTools, ModelTools --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4a4a177..6191a8d 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ The Model Context Protocol is an open protocol that enables seamless integration - 🚀 **Rapid Integration**: Enable MCP support in your CakePHP app with minimal setup - 🔍 **Attribute-Based Discovery**: Finds MCP tools, resources, and prompts using PHP 8+ attributes and class scanning -- 🛠️ **Built-in Tools**: Includes tools for system info, database schema exploration, route inspection, code execution, and more +- 🛠️ **Built-in Tools**: Includes tools for system info, database schema exploration, route inspection, code execution, log inspection, cache management, ORM introspection, and more - 📚 **Advanced Documentation Search**: Full-text search with fuzzy matching and relevance ranking, powered by SQLite FTS5 (indexes official CakePHP markdown docs) - 🧩 **Customizable & Extensible**: Easily define your own tools, resources, and prompts using attributes and configuration - ⚡ **Prompt Workflows**: Built-in prompt flows for common development tasks, code review, debugging, and feature building @@ -150,6 +150,16 @@ Synapse includes several built-in tools and resources for common operations: | 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) | +| Commands | `run_command` | Run a registered CakePHP console command in a subprocess with configurable timeout | +| Logs | `log_list` | List all log files in the application logs directory with size and modification time | +| Logs | `log_read` | Read the last N lines from a log file, optionally filtered by log level (error, warning, info, debug, …) | +| Cache | `cache_configs` | List all configured CakePHP cache configurations with engine information | +| Cache | `cache_read` | Read a value from a cache configuration by key | +| Cache | `cache_write` | Write a string value to a cache configuration | +| Cache | `cache_delete` | Delete a key from a cache configuration | +| Cache | `cache_clear` | Clear all entries from a cache configuration | +| ORM | `orm_describe` | Describe a CakePHP ORM Table: associations, behaviors, display field, primary key, entity class | +| ORM | `orm_find` | Run a find query (`all`, `first`, or `count`) on an ORM Table with a configurable row limit | > [!WARNING] > The `tinker` tool executes arbitrary code in your application. Use responsibly and avoid modifying data without explicit approval.