diff --git a/README.md b/README.md index 739cfeb..24025e1 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ The integrated transport serves MCP through your Laravel application's routes: ```php // Routes are automatically registered at: // GET /mcp - Streamable connection endpoint -// POST /mcp - Message sending endpoint +// POST /mcp - Message sending endpoint // DELETE /mcp - Session termination endpoint // Legacy mode (if enabled): @@ -434,7 +434,7 @@ php artisan mcp:serve --transport=http \ **Transport Modes:** - **Streamable Mode** (`legacy: false`): Enhanced transport with resumability and event sourcing -- **Legacy Mode** (`legacy: true`): Deprecated HTTP+SSE transport. +- **Legacy Mode** (`legacy: true`): Deprecated HTTP+SSE transport. **JSON Response Mode:** @@ -451,7 +451,7 @@ php artisan mcp:serve --transport=http \ This creates a long-running process that should be managed with: - **Supervisor** (recommended) -- **systemd** +- **systemd** - **Docker** containers - **Process managers** @@ -566,13 +566,39 @@ php artisan mcp:serve --transport=http \ --host=0.0.0.0 \ --port=8091 \ --path-prefix=api/mcp + +# HTTP transport with file watching for development +php artisan mcp:serve --transport=http --watch +``` + +### File Watching for Development + +The `--watch` flag enables automatic server reloading when project files change, making development more efficient: + +```bash +# Enable file watching with HTTP transport +php artisan mcp:serve --transport=http --watch ``` +**What gets watched:** +- MCP discovery directories (configured in `mcp.discovery.directories`) +- MCP configuration files (`config/mcp.php`) +- MCP route definitions (`routes/mcp.php`) + +**Features:** +- Automatic server restart when PHP files in watched directories change +- Preserves server configuration and transport options +- Real-time feedback on file changes +- Development-friendly error handling + +> ⚠️ **Note**: File watching is only supported with HTTP transport. STDIO transport cannot be reloaded automatically as it requires process restart. If you need to use the STDIO transport, consider using `npx mcp-remote`. + **Command Options:** - `--transport`: Choose transport type (`stdio` or `http`) - `--host`: Host address for HTTP transport -- `--port`: Port number for HTTP transport +- `--port`: Port number for HTTP transport - `--path-prefix`: URL path prefix for HTTP transport +- `--watch`: Watch for file changes and automatically reload the server (HTTP transport only) ## Dynamic Updates & Events @@ -591,7 +617,7 @@ ToolsListChanged::dispatch(); // Notify about resource list changes ResourcesListChanged::dispatch(); -// Notify about prompt list changes +// Notify about prompt list changes PromptsListChanged::dispatch(); ``` @@ -625,13 +651,13 @@ class PostService public function createPost( #[Schema(minLength: 5, maxLength: 200)] string $title, - + #[Schema(minLength: 10)] string $content, - + #[Schema(enum: ['draft', 'published', 'archived'])] string $status = 'draft', - + #[Schema(type: 'array', items: ['type' => 'string'])] array $tags = [] ): array { @@ -709,14 +735,14 @@ class OrderService public function processOrder(array $orderData): array { $this->logger->info('Processing order', $orderData); - + $payment = $this->gateway->charge($orderData['amount']); - + if ($payment->successful()) { $this->notifications->sendOrderConfirmation($orderData['email']); return ['status' => 'success', 'order_id' => $payment->id]; } - + throw new \Exception('Payment failed: ' . $payment->error); } } @@ -732,15 +758,15 @@ Tool handlers can throw exceptions that are automatically converted to proper JS public function getUser(int $userId): array { $user = User::find($userId); - + if (!$user) { throw new \InvalidArgumentException("User with ID {$userId} not found"); } - + if (!$user->isActive()) { throw new \RuntimeException("User account is deactivated"); } - + return $user->toArray(); } ``` @@ -846,7 +872,7 @@ All existing v3.0 code continues to work without modification. The new features 'resources' => ['enabled' => true, 'subscribe' => true], ], -// New structure +// New structure 'capabilities' => [ 'tools' => true, 'toolsListChanged' => true, @@ -942,7 +968,7 @@ class ContentService public function generateContentSummary(string $postSlug, int $maxWords = 50): array { $post = Post::where('slug', $postSlug)->firstOrFail(); - + return [ [ 'role' => 'user', @@ -962,7 +988,7 @@ class ApiService public function sendNotification( #[Schema(format: 'email')] string $email, - + string $subject, string $message ): array { diff --git a/routes/web.php b/routes/web.php index ebf3b4b..886be54 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,8 +1,8 @@ getTransportOption(); + if ($this->option('watch')) { + return $this->handleWithFileWatcher($server, $transportOption); + } + return match ($transportOption) { 'stdio' => $this->handleStdioTransport($server), 'http' => $this->handleHttpTransport($server), @@ -84,10 +89,10 @@ private function handleStdioTransport(Server $server): int $output = $this->output->getOutput(); if ($output instanceof ConsoleOutputInterface) { - $output->getErrorOutput()->writeln("Starting MCP server"); - $output->getErrorOutput()->writeln(" - Transport: STDIO"); - $output->getErrorOutput()->writeln(" - Communication: STDIN/STDOUT"); - $output->getErrorOutput()->writeln(" - Mode: JSON-RPC over Standard I/O"); + $output->getErrorOutput()->writeln('Starting MCP server'); + $output->getErrorOutput()->writeln(' - Transport: STDIO'); + $output->getErrorOutput()->writeln(' - Communication: STDIN/STDOUT'); + $output->getErrorOutput()->writeln(' - Mode: JSON-RPC over Standard I/O'); } try { @@ -123,11 +128,11 @@ private function handleHttpTransport(Server $server): int private function handleSseHttpTransport(Server $server, string $host, int $port, string $pathPrefix, ?array $sslContextOptions): int { - $this->info("Starting MCP server on http://{$host}:{$port}"); - $this->line(" - Transport: Legacy HTTP"); + $this->line("🟢 Starting MCP server on http://{$host}:{$port}"); + $this->line(' - Transport: Legacy HTTP'); $this->line(" - SSE endpoint: http://{$host}:{$port}/{$pathPrefix}/sse"); $this->line(" - Message endpoint: http://{$host}:{$port}/{$pathPrefix}/message"); - $this->line(" - Mode: Server-Sent Events"); + $this->line(' - Mode: Server-Sent Events'); $transport = new HttpServerTransport( host: $host, @@ -153,10 +158,10 @@ private function handleStreamableHttpTransport(Server $server, string $host, int $eventStore = $this->createEventStore(); $stateless = config('mcp.transports.http_dedicated.stateless', false); - $this->info("Starting MCP server on http://{$host}:{$port}"); - $this->line(" - Transport: Streamable HTTP"); + $this->line("🟢 Starting MCP server on http://{$host}:{$port}"); + $this->line(' - Transport: Streamable HTTP'); $this->line(" - MCP endpoint: http://{$host}:{$port}/{$pathPrefix}"); - $this->line(" - Mode: " . ($enableJsonResponse ? 'JSON' : 'SSE Streaming')); + $this->line(' - Mode: '.($enableJsonResponse ? 'JSON' : 'SSE Streaming')); $transport = new StreamableHttpServerTransport( host: $host, @@ -186,7 +191,7 @@ private function createEventStore(): ?EventStoreInterface { $eventStoreFqcn = config('mcp.transports.http_dedicated.event_store'); - if (!$eventStoreFqcn) { + if (! $eventStoreFqcn) { return null; } @@ -197,7 +202,7 @@ private function createEventStore(): ?EventStoreInterface if (is_string($eventStoreFqcn) && class_exists($eventStoreFqcn)) { $instance = app($eventStoreFqcn); - if (!$instance instanceof EventStoreInterface) { + if (! $instance instanceof EventStoreInterface) { throw new \InvalidArgumentException( "Event store class {$eventStoreFqcn} must implement EventStoreInterface" ); @@ -211,6 +216,184 @@ private function createEventStore(): ?EventStoreInterface ); } + /** + * Handle the command with file watching enabled + */ + private function handleWithFileWatcher(Server $server, string $transportOption): int + { + if ($transportOption === 'stdio') { + $this->error('🛑 File watching is not supported with STDIO transport as it requires process restart.'); + + return Command::FAILURE; + } + + $this->line('🟢 Starting MCP server with file watching enabled...'); + $this->line(' - File changes will trigger server reload'); + + $watchedPaths = $this->getWatchedPaths(); + $this->line(' - Watching: '.implode(', ', $watchedPaths)); + + while (true) { + $lastModified = $this->getLastModificationTime($watchedPaths); + + $process = $this->startServerProcess($transportOption); + + $this->line('👀 Server started. Watching for file changes...'); + + while ($process && $this->isProcessRunning($process)) { + usleep(2000000); // 2 seconds + + $currentModified = $this->getLastModificationTime($watchedPaths); + if ($currentModified > $lastModified) { + $this->line('⏳ File changes detected. Restarting server...'); + $this->stopProcess($process); + continue 2; + } + } + + if (! $this->isProcessRunning($process)) { + $restartDelay = 5; + $this->error("🛑 Server process died unexpectedly. Restarting in {$restartDelay}..."); + usleep($restartDelay * 1000000); // 5 seconds + } + } + + return Command::SUCCESS; + } + + /** + * Get paths to watch for changes + */ + private function getWatchedPaths(): array + { + $basePath = config('mcp.discovery.base_path', base_path()); + $discoveryDirs = config('mcp.discovery.directories', ['app/Mcp']); + $mcpConfigPath = config('mcp.discovery.definitions_file', base_path('routes/mcp.php')); + + $paths = []; + + foreach ($discoveryDirs as $dir) { + $fullPath = rtrim($basePath, '/').'/'.ltrim($dir, '/'); + if (is_dir($fullPath)) { + $paths[] = $fullPath; + } + } + + if (file_exists($mcpConfigPath)) { + $paths[] = dirname($mcpConfigPath); + } + + $configPath = base_path('config'); + if (is_dir($configPath)) { + $paths[] = $configPath; + } + + return array_unique($paths); + } + + /** + * Get the latest modification time from watched paths + */ + private function getLastModificationTime(array $paths): int + { + $latestTime = 0; + + foreach ($paths as $path) { + if (is_file($path)) { + $latestTime = max($latestTime, filemtime($path)); + } elseif (is_dir($path)) { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $latestTime = max($latestTime, $file->getMTime()); + } + } + } + } + + return $latestTime; + } + + /** + * Start the server process + */ + private function startServerProcess(string $transportOption): array + { + $command = [ + PHP_BINARY, + base_path('artisan'), + 'mcp:serve', + '--transport='.$transportOption, + ]; + + if ($transportOption === 'http') { + if ($host = $this->option('host')) { + $command[] = '--host='.$host; + } + if ($port = $this->option('port')) { + $command[] = '--port='.$port; + } + if ($pathPrefix = $this->option('path-prefix')) { + $command[] = '--path-prefix='.$pathPrefix; + } + } + + $descriptorSpec = [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ]; + + $process = proc_open(implode(' ', array_map('escapeshellarg', $command)), $descriptorSpec, $pipes); + + if (! is_resource($process)) { + throw new \RuntimeException('Failed to start server process'); + } + + return [ + 'process' => $process, + 'pipes' => $pipes, + ]; + } + + /** + * Check if process is still running + */ + private function isProcessRunning(array $processInfo): bool + { + if (! isset($processInfo['process']) || ! is_resource($processInfo['process'])) { + return false; + } + + $status = proc_get_status($processInfo['process']); + + return $status['running']; + } + + /** + * Stop the process + */ + private function stopProcess(array $processInfo): void + { + if (! isset($processInfo['process']) || ! is_resource($processInfo['process'])) { + return; + } + + if (isset($processInfo['pipes'])) { + foreach ($processInfo['pipes'] as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + } + + proc_terminate($processInfo['process']); + proc_close($processInfo['process']); + } + private function handleInvalidTransport(string $transportOption): int { $this->error("Invalid transport specified: {$transportOption}. Use 'stdio' or 'http'."); diff --git a/tests/Feature/Commands/FileWatchingTest.php b/tests/Feature/Commands/FileWatchingTest.php new file mode 100644 index 0000000..acc45a6 --- /dev/null +++ b/tests/Feature/Commands/FileWatchingTest.php @@ -0,0 +1,251 @@ +tempDir = sys_get_temp_dir().'/mcp_file_watch_test_'.uniqid(); + mkdir($this->tempDir, 0755, true); + } + + protected function tearDown(): void + { + // Clean up temporary directory + $this->recursiveRemoveDirectory($this->tempDir); + parent::tearDown(); + } + + public function test_watch_flag_is_registered_in_command_signature() + { + $command = new ServeCommand; + $reflection = new \ReflectionClass($command); + $signatureProperty = $reflection->getProperty('signature'); + $signatureProperty->setAccessible(true); + + $signature = $signatureProperty->getValue($command); + $this->assertStringContainsString('--watch', $signature); + $this->assertStringContainsString('Watch for file changes and automatically reload the server', $signature); + } + + public function test_watch_flag_prevents_stdio_transport() + { + $this->artisan('mcp:serve --transport=stdio --watch') + ->expectsOutputToContain('File watching is not supported with STDIO transport') + ->assertExitCode(1); + } + + public function test_get_watched_paths_includes_discovery_directories() + { + // Set up test configuration + Config::set('mcp.discovery.base_path', $this->tempDir); + Config::set('mcp.discovery.directories', ['app/Mcp', 'custom/handlers']); + Config::set('mcp.discovery.definitions_file', $this->tempDir.'/routes/mcp.php'); + + // Create test directories + mkdir($this->tempDir.'/app/Mcp', 0755, true); + mkdir($this->tempDir.'/custom/handlers', 0755, true); + mkdir($this->tempDir.'/routes', 0755, true); + + // Create MCP definitions file + file_put_contents($this->tempDir.'/routes/mcp.php', 'invokePrivateMethod($command, 'getWatchedPaths'); + + $this->assertContains($this->tempDir.'/app/Mcp', $watchedPaths); + $this->assertContains($this->tempDir.'/custom/handlers', $watchedPaths); + $this->assertContains($this->tempDir.'/routes', $watchedPaths); + + // The config directory path will be from base_path, not our temp directory + // Just check that the method returns an array with some paths + $this->assertIsArray($watchedPaths); + $this->assertNotEmpty($watchedPaths); + } + + public function test_get_watched_paths_handles_non_existent_directories() + { + Config::set('mcp.discovery.base_path', $this->tempDir); + Config::set('mcp.discovery.directories', ['non/existent', 'also/missing']); + Config::set('mcp.discovery.definitions_file', $this->tempDir.'/missing/mcp.php'); + + $command = new ServeCommand; + $watchedPaths = $this->invokePrivateMethod($command, 'getWatchedPaths'); + + // Should not contain non-existent directories + $this->assertNotContains($this->tempDir.'/non/existent', $watchedPaths); + $this->assertNotContains($this->tempDir.'/also/missing', $watchedPaths); + + // Should return an array with at least some paths + $this->assertIsArray($watchedPaths); + $this->assertNotEmpty($watchedPaths); + } + + public function test_get_last_modification_time_detects_php_file_changes() + { + // Create test PHP files + $subDir = $this->tempDir.'/test_dir'; + mkdir($subDir, 0755, true); + + $phpFile = $subDir.'/test.php'; + $nonPhpFile = $subDir.'/test.txt'; + + file_put_contents($phpFile, 'invokePrivateMethod($command, 'getLastModificationTime', [[$this->tempDir]]); + + $this->assertEquals($initialPhpTime, $initialModTime); + + // Modify the non-PHP file (should not affect modification time) + sleep(1); + file_put_contents($nonPhpFile, 'modified non-php content'); + + $afterNonPhpModTime = $this->invokePrivateMethod($command, 'getLastModificationTime', [[$this->tempDir]]); + $this->assertEquals($initialModTime, $afterNonPhpModTime); + + // Modify the PHP file (should affect modification time) + sleep(1); + file_put_contents($phpFile, 'invokePrivateMethod($command, 'getLastModificationTime', [[$this->tempDir]]); + $this->assertGreaterThan($initialModTime, $afterPhpModTime); + } + + public function test_get_last_modification_time_handles_single_file_path() + { + $testFile = $this->tempDir.'/single_file.php'; + file_put_contents($testFile, 'invokePrivateMethod($command, 'getLastModificationTime', [[$testFile]]); + + $this->assertEquals($expectedTime, $modTime); + } + + public function test_get_last_modification_time_handles_empty_paths() + { + $command = new ServeCommand; + $modTime = $this->invokePrivateMethod($command, 'getLastModificationTime', [[]]); + + $this->assertEquals(0, $modTime); + } + + public function test_get_last_modification_time_handles_nested_directories() + { + // Create nested directory structure with PHP files + $nestedPath = $this->tempDir.'/level1/level2/level3'; + mkdir($nestedPath, 0755, true); + + $files = [ + $this->tempDir.'/level1/file1.php', + $this->tempDir.'/level1/level2/file2.php', + $this->tempDir.'/level1/level2/level3/file3.php', + ]; + + $latestTime = 0; + foreach ($files as $i => $file) { + // Create files with different timestamps + sleep(1); + file_put_contents($file, "invokePrivateMethod($command, 'getLastModificationTime', [[$this->tempDir]]); + + $this->assertEquals($latestTime, $modTime); + } + + public function test_process_management_methods_handle_edge_cases() + { + $command = new ServeCommand; + + // Test isProcessRunning with various invalid inputs + $this->assertFalse($this->invokePrivateMethod($command, 'isProcessRunning', [[]])); + $this->assertFalse($this->invokePrivateMethod($command, 'isProcessRunning', [['process' => null]])); + $this->assertFalse($this->invokePrivateMethod($command, 'isProcessRunning', [['process' => 'invalid']])); + + // Test stopProcess with various invalid inputs (should not throw) + $this->invokePrivateMethod($command, 'stopProcess', [[]]); + $this->invokePrivateMethod($command, 'stopProcess', [['process' => null]]); + $this->invokePrivateMethod($command, 'stopProcess', [['process' => 'invalid']]); + + // If we reach here without exceptions, the test passes + $this->assertTrue(true); + } + + public function test_start_server_process_method_exists() + { + $command = new ServeCommand; + $reflection = new \ReflectionClass($command); + + // Verify the method exists and is accessible + $this->assertTrue($reflection->hasMethod('startServerProcess')); + + $method = $reflection->getMethod('startServerProcess'); + $this->assertTrue($method->isPrivate()); + $this->assertEquals('startServerProcess', $method->getName()); + } + + public function test_command_description_mentions_watch_functionality() + { + $command = new ServeCommand; + $reflection = new \ReflectionClass($command); + $descriptionProperty = $reflection->getProperty('description'); + $descriptionProperty->setAccessible(true); + + $description = $descriptionProperty->getValue($command); + $this->assertStringContainsString('--watch', $description); + $this->assertStringContainsString('automatic reloading', $description); + } + + /** + * Helper method to invoke private methods for testing + */ + private function invokePrivateMethod(object $object, string $methodName, array $args = []) + { + $reflection = new \ReflectionClass($object); + $method = $reflection->getMethod($methodName); + $method->setAccessible(true); + + return $method->invokeArgs($object, $args); + } + + /** + * Helper method to recursively remove directories + */ + private function recursiveRemoveDirectory(string $dir): void + { + if (! is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir.'/'.$file; + if (is_dir($path)) { + $this->recursiveRemoveDirectory($path); + } else { + unlink($path); + } + } + rmdir($dir); + } +} diff --git a/tests/Feature/Commands/ServeCommandTest.php b/tests/Feature/Commands/ServeCommandTest.php index 270054f..66532d3 100644 --- a/tests/Feature/Commands/ServeCommandTest.php +++ b/tests/Feature/Commands/ServeCommandTest.php @@ -4,11 +4,11 @@ use PhpMcp\Laravel\Tests\TestCase; use PhpMcp\Server\Server; -use PhpMcp\Server\Transports\HttpServerTransport; use PhpMcp\Server\Transports\StreamableHttpServerTransport; use PhpMcp\Server\Transports\StdioServerTransport; use Mockery; use Orchestra\Testbench\Attributes\DefineEnvironment; +use PhpMcp\Laravel\Commands\ServeCommand; class ServeCommandTest extends TestCase { @@ -76,11 +76,8 @@ public function test_serve_command_uses_http_transport_config_fallbacks() Mockery::on(function ($transport) { $reflection = new \ReflectionClass($transport); $hostProp = $reflection->getProperty('host'); - $hostProp->setAccessible(true); $portProp = $reflection->getProperty('port'); - $portProp->setAccessible(true); $prefixProp = $reflection->getProperty('mcpPath'); - $prefixProp->setAccessible(true); return $transport instanceof StreamableHttpServerTransport && $hostProp->getValue($transport) === '0.0.0.0' && @@ -132,4 +129,212 @@ public function test_serve_command_handles_server_listen_exception() ->expectsOutputToContain('Simulated listen failure!') ->assertFailed(); } + + public function test_watch_flag_fails_with_stdio_transport() + { + $this->artisan('mcp:serve --transport=stdio --watch') + ->expectsOutputToContain('File watching is not supported with STDIO transport as it requires process restart.') + ->assertFailed(); + } + + public function test_help_shows_all_available_flags() + { + // Test that the essential flags we added are documented in help + $this->artisan('mcp:serve --help') + ->expectsOutputToContain('--transport') + ->expectsOutputToContain('--host') + ->expectsOutputToContain('--port') + ->expectsOutputToContain('--path-prefix') + ->expectsOutputToContain('--watch') + ->assertSuccessful(); + } + + public function test_command_signature_contains_all_expected_flags() + { + // Test the command signature directly to ensure all flags are properly defined + $command = new ServeCommand(); + $reflection = new \ReflectionClass($command); + $signatureProperty = $reflection->getProperty('signature'); + + $signature = $signatureProperty->getValue($command); + + // Verify all expected flags are in the signature + $expectedFlags = ['--transport=', '--H|host=', '--P|port=', '--path-prefix=', '--watch']; + + foreach ($expectedFlags as $flag) { + $this->assertStringContainsString($flag, $signature, "Flag {$flag} should be in command signature"); + } + + // Verify descriptions are in the signature + $this->assertStringContainsString('Watch for file changes', $signature); + $this->assertStringContainsString('transport to use', $signature); + } + + public function test_get_watched_paths_returns_configured_directories() + { + // Set up test configuration + config(['mcp.discovery.base_path' => '/test/base']); + config(['mcp.discovery.directories' => ['app/Mcp', 'custom/mcp']]); + config(['mcp.discovery.definitions_file' => '/test/base/routes/mcp.php']); + + // Create a temporary directory structure for testing + $tempDir = sys_get_temp_dir() . '/mcp_test_' . uniqid(); + mkdir($tempDir, 0755, true); + mkdir($tempDir . '/app/Mcp', 0755, true); + mkdir($tempDir . '/custom/mcp', 0755, true); + mkdir($tempDir . '/routes', 0755, true); + mkdir($tempDir . '/config', 0755, true); + + // Create the mcp.php file + file_put_contents($tempDir . '/routes/mcp.php', ' $tempDir]); + config(['mcp.discovery.definitions_file' => $tempDir . '/routes/mcp.php']); + + $command = new ServeCommand(); + $reflection = new \ReflectionClass($command); + $method = $reflection->getMethod('getWatchedPaths'); + + $watchedPaths = $method->invoke($command); + + $this->assertContains($tempDir . '/app/Mcp', $watchedPaths); + $this->assertContains($tempDir . '/custom/mcp', $watchedPaths); + $this->assertContains($tempDir . '/routes', $watchedPaths); + + // The config directory path comes from base_path, not our temp directory + // Just verify that some config directory is in the watched paths + $hasConfigDir = false; + foreach ($watchedPaths as $path) { + if (str_ends_with($path, '/config')) { + $hasConfigDir = true; + break; + } + } + $this->assertTrue($hasConfigDir, 'Expected a config directory to be in watched paths'); + + // Clean up + $this->recursiveRemoveDirectory($tempDir); + } + + public function test_get_last_modification_time_detects_file_changes() + { + // Create a temporary directory with test files + $tempDir = sys_get_temp_dir() . '/mcp_mod_test_' . uniqid(); + mkdir($tempDir, 0755, true); + + $testFile = $tempDir . '/test.php'; + file_put_contents($testFile, 'getMethod('getLastModificationTime'); + + $firstCheck = $method->invoke($command, [$tempDir]); + $this->assertEquals($initialTime, $firstCheck); + + // Wait a bit and modify the file + sleep(1); + file_put_contents($testFile, 'invoke($command, [$tempDir]); + $this->assertGreaterThan($firstCheck, $secondCheck); + + // Clean up + $this->recursiveRemoveDirectory($tempDir); + } + + public function test_start_server_process_creates_proper_command() + { + $command = new ServeCommand(); + $reflection = new \ReflectionClass($command); + + // Mock the option method to return test values + $optionMethod = $reflection->getMethod('option'); + $commandMock = $this->partialMock(\PhpMcp\Laravel\Commands\ServeCommand::class, function ($mock) { + $mock->shouldReceive('option') + ->with('host')->andReturn('test-host') + ->shouldReceive('option') + ->with('port')->andReturn('9999') + ->shouldReceive('option') + ->with('path-prefix')->andReturn('test-prefix'); + }); + + $method = $reflection->getMethod('startServerProcess'); + + // We can't easily test the actual process creation without starting a real process, + // but we can test that the method exists and is accessible + $this->assertTrue($method->isPrivate()); + $this->assertEquals('startServerProcess', $method->getName()); + } + + public function test_process_management_methods_exist() + { + $command = new ServeCommand(); + $reflection = new \ReflectionClass($command); + + // Test that process management methods exist + $this->assertTrue($reflection->hasMethod('isProcessRunning')); + $this->assertTrue($reflection->hasMethod('stopProcess')); + + $isRunningMethod = $reflection->getMethod('isProcessRunning'); + $stopMethod = $reflection->getMethod('stopProcess'); + + $this->assertTrue($isRunningMethod->isPrivate()); + $this->assertTrue($stopMethod->isPrivate()); + } + + public function test_is_process_running_handles_invalid_process() + { + $command = new ServeCommand(); + $reflection = new \ReflectionClass($command); + $method = $reflection->getMethod('isProcessRunning'); + + // Test with invalid process info + $result = $method->invoke($command, []); + $this->assertFalse($result); + + $result = $method->invoke($command, ['process' => null]); + $this->assertFalse($result); + + $result = $method->invoke($command, ['process' => 'not-a-resource']); + $this->assertFalse($result); + } + + public function test_stop_process_handles_invalid_process_gracefully() + { + $command = new ServeCommand(); + $reflection = new \ReflectionClass($command); + $method = $reflection->getMethod('stopProcess'); + + // Test that method doesn't throw with invalid input + $method->invoke($command, []); + $method->invoke($command, ['process' => null]); + $method->invoke($command, ['process' => 'not-a-resource']); + + // If we get here without exceptions, the test passes + $this->assertTrue(true); + } + + /** + * Helper method to recursively remove directories + */ + private function recursiveRemoveDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + if (is_dir($path)) { + $this->recursiveRemoveDirectory($path); + } else { + unlink($path); + } + } + rmdir($dir); + } }