diff --git a/demo/mcp.json b/demo/mcp.json index d413646308..3525d4681c 100644 --- a/demo/mcp.json +++ b/demo/mcp.json @@ -3,7 +3,8 @@ "symfony-ai-mate": { "command": "./vendor/bin/mate", "args": [ - "serve" + "serve", + "--force-keep-alive" ] } } diff --git a/src/mate/CHANGELOG.md b/src/mate/CHANGELOG.md index 6ff097047e..e7261d5ac5 100644 --- a/src/mate/CHANGELOG.md +++ b/src/mate/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +0.2 +--- + + * Add `StopCommand` to stop a running server + * Add `--force-keep-alive` option to `ServeCommand` to restart server if it was stopped + 0.1 --- diff --git a/src/mate/bin/mate b/src/mate/bin/mate index f8664a1a2b..afdf08d4d2 100755 --- a/src/mate/bin/mate +++ b/src/mate/bin/mate @@ -1,4 +1,63 @@ #!/usr/bin/env php ['file', 'php://stdin', 'r'], + 1 => ['file', 'php://stdout', 'w'], + 2 => ['file', 'php://stderr', 'w'], + ]; + + $process = proc_open($command, $descriptorSpec, $pipes, null, null, [ + 'bypass_shell' => true, + ]); + + if (!\is_resource($process)) { + fwrite(STDERR, "[mate][keep-alive] Failed to start process.\n"); + exit(70); // EX_SOFTWARE + } + + // Wait for the process to terminate and get its exit code + $exitCode = proc_close($process); + + if ($exitCode === 0) { + fwrite(STDERR, "[mate][keep-alive] Process exited with code 0, restarting...\n"); + sleep(1); + continue; + } + + if ($exitCode >= 129 && $exitCode <= 192) { + $signal = $exitCode - 128; + fwrite(STDERR, sprintf("[mate][keep-alive] Process terminated by signal %d (exit %d), not restarting.\n", $signal, $exitCode)); + } else { + fwrite(STDERR, sprintf("[mate][keep-alive] Process exited with code %d, not restarting.\n", $exitCode)); + } + + exit($exitCode); +} diff --git a/src/mate/resources/mcp.json b/src/mate/resources/mcp.json index d413646308..3525d4681c 100644 --- a/src/mate/resources/mcp.json +++ b/src/mate/resources/mcp.json @@ -3,7 +3,8 @@ "symfony-ai-mate": { "command": "./vendor/bin/mate", "args": [ - "serve" + "serve", + "--force-keep-alive" ] } } diff --git a/src/mate/src/App.php b/src/mate/src/App.php index dd19605e3b..8f638afee8 100644 --- a/src/mate/src/App.php +++ b/src/mate/src/App.php @@ -11,11 +11,14 @@ namespace Symfony\AI\Mate; +use Mcp\Server\Transport\Stdio\RunnerControl; +use Mcp\Server\Transport\Stdio\RunnerState; use Psr\Log\LoggerInterface; use Symfony\AI\Mate\Command\ClearCacheCommand; use Symfony\AI\Mate\Command\DiscoverCommand; use Symfony\AI\Mate\Command\InitCommand; use Symfony\AI\Mate\Command\ServeCommand; +use Symfony\AI\Mate\Command\StopCommand; use Symfony\AI\Mate\Exception\UnsupportedVersionException; use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\Command; @@ -46,8 +49,15 @@ public static function build(ContainerBuilder $container): Application self::addCommand($application, new InitCommand($rootDir)); self::addCommand($application, new ServeCommand($logger, $container)); self::addCommand($application, new DiscoverCommand($rootDir, $logger)); + self::addCommand($application, new StopCommand((string) $container->getParameter('mate.cache_dir'))); self::addCommand($application, new ClearCacheCommand($cacheDir)); + if (\defined('SIGUSR1') && class_exists(RunnerControl::class)) { + $application->getSignalRegistry()->register(\SIGUSR1, function () { + RunnerControl::$state = RunnerState::STOP; + }); + } + return $application; } diff --git a/src/mate/src/Command/ServeCommand.php b/src/mate/src/Command/ServeCommand.php index f9a5f1cd1c..056cbb2fa8 100644 --- a/src/mate/src/Command/ServeCommand.php +++ b/src/mate/src/Command/ServeCommand.php @@ -25,6 +25,7 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -59,8 +60,19 @@ public static function getDefaultDescription(): string return 'Starts the MCP server with stdio transport'; } + protected function configure(): void + { + $this->addOption('force-keep-alive', null, InputOption::VALUE_NONE, 'Force a restart of the server if it stops.'); + } + protected function execute(InputInterface $input, OutputInterface $output): int { + if ($input->getOption('force-keep-alive')) { + $output->writeln('The option --force-keep-alive requires using the "bin/mate" file. Try running "./vendor/bin/mate serve --force-keep-alive"'); + + return Command::INVALID; + } + $rootDir = $this->container->getParameter('mate.root_dir'); \assert(\is_string($rootDir)); @@ -100,7 +112,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int ->setLogger($this->logger) ->build(); - $server->run(new StdioTransport()); + $pidFileName = \sprintf('%s/server_%d.pid', $cacheDir, getmypid()); + file_put_contents($pidFileName, getmypid()); + + try { + $server->run(new StdioTransport()); + } finally { + unlink($pidFileName); + } return Command::SUCCESS; } diff --git a/src/mate/src/Command/StopCommand.php b/src/mate/src/Command/StopCommand.php new file mode 100644 index 0000000000..955c226b77 --- /dev/null +++ b/src/mate/src/Command/StopCommand.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Mate\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Finder\Finder; + +/** + * Stop all running servers. This will force the AI to restart the server. Can be combined with + * the "--force-keep-alive" option on the "serve" command to make sure the server is restarted + * and not killed. + * + * @author Tobias Nyholm + */ +#[AsCommand('stop', 'Stop running servers to allow them to be restarted with new configuration')] +class StopCommand extends Command +{ + public function __construct(private string $cacheDir) + { + parent::__construct(self::getDefaultName()); + } + + public static function getDefaultName(): string + { + return 'stop'; + } + + public static function getDefaultDescription(): string + { + return 'Stop running servers to allow them to be restarted with new configuration'; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if (!\function_exists('posix_kill')) { + $io->error('The "stop" command require the posix php extension.'); + + return Command::FAILURE; + } + + if (!\defined('SIGUSR1')) { + $io->error('The "stop" command require the pcntl php extension.'); + + return Command::FAILURE; + } + + $finder = new Finder(); + $finder->files() + ->in($this->cacheDir) + ->name('server_*.pid'); + + foreach ($finder as $file) { + $pid = (int) file_get_contents($file->getRealPath()); + posix_kill($pid, \SIGUSR1); + } + + return Command::SUCCESS; + } +}