diff --git a/src/McpServerBootloader.php b/src/McpServerBootloader.php index 5c9e4e8..f86a893 100644 --- a/src/McpServerBootloader.php +++ b/src/McpServerBootloader.php @@ -4,7 +4,6 @@ namespace Butschster\ContextGenerator\McpServer; -use Butschster\ContextGenerator\Application\Bootloader\ConsoleBootloader; use Butschster\ContextGenerator\McpServer\Interceptor\McpServerInterceptorBootloader; use Butschster\ContextGenerator\McpServer\McpConfig\McpConfigBootloader; use Butschster\ContextGenerator\McpServer\Projects\McpProjectsBootloader; @@ -13,6 +12,7 @@ use Butschster\ContextGenerator\McpServer\Routing\McpResponseStrategy; use Butschster\ContextGenerator\McpServer\Routing\RouteRegistrar; use Butschster\ContextGenerator\McpServer\Tool\McpToolBootloader; +use Butschster\ContextGenerator\McpServer\Watcher\ConfigWatcherBootloader; use League\Route\Router; use League\Route\Strategy\StrategyInterface; use Psr\Container\ContainerInterface; @@ -31,6 +31,7 @@ public function defineDependencies(): array McpProjectsBootloader::class, McpConfigBootloader::class, McpServerCoreBootloader::class, + ConfigWatcherBootloader::class, ]; } diff --git a/src/Prompt/PromptRegistry.php b/src/Prompt/PromptRegistry.php index 3f55d40..11e58a6 100644 --- a/src/Prompt/PromptRegistry.php +++ b/src/Prompt/PromptRegistry.php @@ -51,6 +51,21 @@ public function has(string $name): bool return isset($this->prompts[$name]); } + public function remove(string $id): bool + { + if (!$this->has($id)) { + return false; + } + + unset($this->prompts[$id]); + return true; + } + + public function clear(): void + { + $this->prompts = []; + } + public function all(): array { return $this->prompts; diff --git a/src/Prompt/PromptRegistryInterface.php b/src/Prompt/PromptRegistryInterface.php index 0ae3aa1..b796c0d 100644 --- a/src/Prompt/PromptRegistryInterface.php +++ b/src/Prompt/PromptRegistryInterface.php @@ -9,7 +9,24 @@ interface PromptRegistryInterface { /** - * Registers a prompt in the registry. + * Registers a prompt in the registry (upsert semantics - replaces if exists). */ public function register(PromptDefinition $prompt): void; + + /** + * Removes a prompt from the registry by ID. + * + * @return bool True if the prompt existed and was removed, false otherwise + */ + public function remove(string $id): bool; + + /** + * Checks if a prompt with the given ID exists. + */ + public function has(string $id): bool; + + /** + * Removes all prompts from the registry. + */ + public function clear(): void; } diff --git a/src/ServerRunner.php b/src/ServerRunner.php index bd22594..44214a8 100644 --- a/src/ServerRunner.php +++ b/src/ServerRunner.php @@ -4,9 +4,11 @@ namespace Butschster\ContextGenerator\McpServer; -use Butschster\ContextGenerator\Application\Logger\HasPrefixLoggerInterface; use Butschster\ContextGenerator\McpServer\Registry\McpItemsRegistry; use Butschster\ContextGenerator\McpServer\Routing\RouteRegistrar; +use Butschster\ContextGenerator\McpServer\Transport\StdioTransport; +use Butschster\ContextGenerator\McpServer\Watcher\ConfigWatcherConfig; +use Butschster\ContextGenerator\McpServer\Watcher\ConfigWatcherInterface; use Mcp\Server\Contracts\ServerTransportInterface; use Mcp\Server\Server; use Spiral\Core\Attribute\Proxy; @@ -49,6 +51,8 @@ public function run(string $name): void ExceptionReporterInterface $reporter, Server $server, ServerTransportInterface $transport, + ConfigWatcherInterface $configWatcher, + ?ConfigWatcherConfig $watcherConfig, ) use ($name): void { // Register all classes with MCP item attributes. Should be before registering controllers! $registry->registerMany($this->actions); @@ -56,10 +60,26 @@ public function run(string $name): void // Register all controllers for routing $registrar->registerControllers($this->actions); + // Initialize config watcher with paths (if config provided) + if ($watcherConfig !== null) { + $configWatcher->start( + mainConfigPath: $watcherConfig->mainConfigPath, + importPaths: $watcherConfig->importPaths, + ); + + // Inject watcher into transport (if StdioTransport) + if ($transport instanceof StdioTransport) { + $transport->setConfigWatcher($configWatcher); + } + } + try { $server->listen($transport); } catch (\Throwable $e) { $reporter->report($e); + } finally { + // Ensure watcher is stopped on exit + $configWatcher->stop(); } }, ); diff --git a/src/Tool/ToolRegistry.php b/src/Tool/ToolRegistry.php index 1cf5ada..40b016c 100644 --- a/src/Tool/ToolRegistry.php +++ b/src/Tool/ToolRegistry.php @@ -43,6 +43,21 @@ public function has(string $id): bool return isset($this->tools[$id]); } + public function remove(string $id): bool + { + if (!$this->has($id)) { + return false; + } + + unset($this->tools[$id]); + return true; + } + + public function clear(): void + { + $this->tools = []; + } + public function all(): array { return $this->getItems(); diff --git a/src/Tool/ToolRegistryInterface.php b/src/Tool/ToolRegistryInterface.php index 83eea58..4efebba 100644 --- a/src/Tool/ToolRegistryInterface.php +++ b/src/Tool/ToolRegistryInterface.php @@ -9,9 +9,24 @@ interface ToolRegistryInterface { /** - * Registers a tool in the registry. - * - * @throws \InvalidArgumentException If a tool with the same ID already exists + * Registers a tool in the registry (upsert semantics - replaces if exists). */ public function register(ToolDefinition $tool): void; + + /** + * Removes a tool from the registry by ID. + * + * @return bool True if the tool existed and was removed, false otherwise + */ + public function remove(string $id): bool; + + /** + * Checks if a tool with the given ID exists. + */ + public function has(string $id): bool; + + /** + * Removes all tools from the registry. + */ + public function clear(): void; } diff --git a/src/Transport/StdioTransport.php b/src/Transport/StdioTransport.php index c8b45c3..866602a 100644 --- a/src/Transport/StdioTransport.php +++ b/src/Transport/StdioTransport.php @@ -4,6 +4,7 @@ namespace Butschster\ContextGenerator\McpServer\Transport; +use Butschster\ContextGenerator\McpServer\Watcher\ConfigWatcherInterface; use Evenement\EventEmitter; use Mcp\Server\Contracts\ServerTransportInterface; use Mcp\Server\Exception\TransportException; @@ -35,6 +36,8 @@ final class StdioTransport extends EventEmitter implements ServerTransportInterf protected bool $closing = false; protected bool $listening = false; + private ?ConfigWatcherInterface $configWatcher = null; + /** * @param resource $input * @param resource $output @@ -45,6 +48,14 @@ public function __construct( private readonly LoggerInterface $logger = new NullLogger(), ) {} + /** + * Set the config watcher for hot-reload support. + */ + public function setConfigWatcher(ConfigWatcherInterface $watcher): void + { + $this->configWatcher = $watcher; + } + public function listen(): void { if ($this->listening) { @@ -78,7 +89,9 @@ public function listen(): void } if ($result === 0) { - // Timeout - no data available, check closing flag and continue + // Timeout - no data available + // Perfect time to check for config changes! + $this->tickConfigWatcher(); continue; } @@ -176,6 +189,12 @@ public function sendMessage(Message $message, string $sessionId, array $context public function close(): void { + // Stop config watcher on transport close + if ($this->configWatcher !== null) { + $this->configWatcher->stop(); + $this->configWatcher = null; + } + if (\is_resource($this->input)) { \fclose($this->input); } @@ -187,6 +206,24 @@ public function close(): void $this->removeAllListeners(); } + /** + * Non-blocking config watcher tick. + */ + private function tickConfigWatcher(): void + { + if ($this->configWatcher === null) { + return; + } + + try { + $this->configWatcher->tick(); + } catch (\Throwable $e) { + $this->logger->error('Config watcher error', [ + 'error' => $e->getMessage(), + ]); + } + } + /** * Processes the internal buffer to find complete lines/frames. */ diff --git a/src/Watcher/ConfigWatcher.php b/src/Watcher/ConfigWatcher.php new file mode 100644 index 0000000..92dbbd8 --- /dev/null +++ b/src/Watcher/ConfigWatcher.php @@ -0,0 +1,240 @@ + */ + private array $importPaths = []; + + /** @var array */ + private array $lastConfig = []; + + private float $lastChangeTime = 0; + private bool $pendingReload = false; + private bool $enabled; + + private const int DEBOUNCE_MS = 1000; + + public function __construct( + private readonly WatchStrategyFactory $strategyFactory, + private readonly ConfigDiffCalculator $diffCalculator, + private readonly ChangeHandlerRegistry $handlerRegistry, + #[Proxy] private readonly ConfigLoaderFactoryInterface $configLoaderFactory, + #[Proxy] private readonly LoggerInterface $logger = new NullLogger(), + EnvironmentInterface $env, + ) { + $this->enabled = (bool) $env->get('MCP_HOT_RELOAD', true); + } + + public function start(string $mainConfigPath, array $importPaths = []): void + { + if (!$this->enabled) { + $this->logger->info('Config watcher is disabled'); + return; + } + + if (!\file_exists($mainConfigPath)) { + $this->logger->error('Config file not found, watcher disabled', [ + 'path' => $mainConfigPath, + ]); + return; + } + + $this->mainConfigPath = $mainConfigPath; + $this->importPaths = $importPaths; + + $this->strategy = $this->strategyFactory->create(); + + $this->logger->info('Starting config watcher', [ + 'strategy' => $this->strategy::class, + 'mainConfig' => $mainConfigPath, + 'imports' => \count($importPaths), + ]); + + $this->strategy->addFile($mainConfigPath); + + foreach ($importPaths as $path) { + $this->strategy->addFile($path); + } + + $this->loadCurrentConfig(); + } + + public function tick(): void + { + if ($this->strategy === null || !$this->enabled) { + return; + } + + // Check for pending debounced reload + if ($this->pendingReload) { + $now = \microtime(true) * 1000; + + if ($now - $this->lastChangeTime >= self::DEBOUNCE_MS) { + $this->processChanges(); + $this->pendingReload = false; + } + + return; + } + + // Check for file changes + $changedFiles = $this->strategy->check(); + + if ($changedFiles !== []) { + $this->logger->debug('Config file changes detected', [ + 'files' => $changedFiles, + ]); + + $this->lastChangeTime = \microtime(true) * 1000; + $this->pendingReload = true; + } + } + + public function stop(): void + { + if ($this->strategy !== null) { + $this->strategy->stop(); + $this->strategy = null; + } + + $this->mainConfigPath = null; + $this->importPaths = []; + $this->lastConfig = []; + $this->pendingReload = false; + + $this->logger->info('Config watcher stopped'); + } + + public function isWatching(): bool + { + return $this->strategy !== null && $this->enabled; + } + + public function updateImports(array $importPaths): void + { + if ($this->strategy === null) { + return; + } + + $newPaths = \array_diff($importPaths, $this->importPaths); + $removedPaths = \array_diff($this->importPaths, $importPaths); + + foreach ($removedPaths as $path) { + $this->strategy->removeFile($path); + } + + foreach ($newPaths as $path) { + $this->strategy->addFile($path); + } + + $this->importPaths = $importPaths; + + $this->logger->debug('Import watch list updated', [ + 'added' => \count($newPaths), + 'removed' => \count($removedPaths), + ]); + } + + private function processChanges(): void + { + $this->logger->info('Processing config changes'); + + try { + $newConfig = $this->loadConfig(); + + if ($newConfig === null) { + $this->logger->warning('Failed to load config, keeping current state'); + return; + } + + $diffs = $this->diffCalculator->calculateAll($this->lastConfig, $newConfig); + + if ($diffs === []) { + $this->logger->debug('No effective changes detected'); + $this->lastConfig = $newConfig; + return; + } + + foreach ($diffs as $section => $diff) { + $handler = $this->handlerRegistry->get($section); + + if ($handler === null) { + $this->logger->debug('No handler for section', ['section' => $section]); + continue; + } + + try { + $handler->apply($diff); + + $this->logger->info('Applied changes', [ + 'section' => $section, + 'summary' => $diff->getSummary(), + ]); + } catch (\Throwable $e) { + $this->logger->error('Handler failed', [ + 'section' => $section, + 'error' => $e->getMessage(), + ]); + } + } + + $this->lastConfig = $newConfig; + } catch (\Throwable $e) { + $this->logger->error('Config reload failed', [ + 'error' => $e->getMessage(), + ]); + } + } + + /** + * @return array|null + */ + private function loadConfig(): ?array + { + if ($this->mainConfigPath === null) { + return null; + } + + try { + $loader = $this->configLoaderFactory->createForFile($this->mainConfigPath); + + return $loader->loadRawConfig(); + } catch (\Throwable $e) { + $this->logger->error('Config load error', [ + 'path' => $this->mainConfigPath, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + private function loadCurrentConfig(): void + { + $config = $this->loadConfig(); + + if ($config !== null) { + $this->lastConfig = $config; + } + } +} diff --git a/src/Watcher/ConfigWatcherBootloader.php b/src/Watcher/ConfigWatcherBootloader.php new file mode 100644 index 0000000..d094f0c --- /dev/null +++ b/src/Watcher/ConfigWatcherBootloader.php @@ -0,0 +1,41 @@ + static fn( + EnvironmentInterface $env, + ): WatchStrategyFactory => new WatchStrategyFactory( + pollingIntervalMs: (int) $env->get('MCP_HOT_RELOAD_INTERVAL', 2000), + ), + + ConfigDiffCalculator::class => ConfigDiffCalculator::class, + ChangeHandlerRegistryFactory::class => ChangeHandlerRegistryFactory::class, + + ChangeHandlerRegistry::class => static fn( + ChangeHandlerRegistryFactory $factory, + ): ChangeHandlerRegistry => $factory->create(), + + ConfigWatcherInterface::class => ConfigWatcher::class, + ]; + } +} diff --git a/src/Watcher/ConfigWatcherConfig.php b/src/Watcher/ConfigWatcherConfig.php new file mode 100644 index 0000000..0b46a43 --- /dev/null +++ b/src/Watcher/ConfigWatcherConfig.php @@ -0,0 +1,21 @@ + $importPaths Paths to imported config files + */ + public function __construct( + public string $mainConfigPath, + public array $importPaths = [], + ) {} +} diff --git a/src/Watcher/ConfigWatcherConfigFactory.php b/src/Watcher/ConfigWatcherConfigFactory.php new file mode 100644 index 0000000..7710ba6 --- /dev/null +++ b/src/Watcher/ConfigWatcherConfigFactory.php @@ -0,0 +1,39 @@ +dirs->getConfigPath(); + + // If it's a directory, look for context.yaml or context.json + if (\is_dir($configPath)) { + foreach (['context.yaml', 'context.yml', 'context.json'] as $filename) { + $filePath = $configPath . '/' . $filename; + if (\file_exists($filePath)) { + $configPath = $filePath; + break; + } + } + } + + return new ConfigWatcherConfig( + mainConfigPath: $configPath, + importPaths: [], + ); + } +} diff --git a/src/Watcher/ConfigWatcherInterface.php b/src/Watcher/ConfigWatcherInterface.php new file mode 100644 index 0000000..7375118 --- /dev/null +++ b/src/Watcher/ConfigWatcherInterface.php @@ -0,0 +1,42 @@ + $importPaths Paths to imported config files + */ + public function start(string $mainConfigPath, array $importPaths = []): void; + + /** + * Check for changes and process them (non-blocking). + * Should be called periodically from event loop. + */ + public function tick(): void; + + /** + * Stop watching and release resources. + */ + public function stop(): void; + + /** + * Check if watcher is currently active. + */ + public function isWatching(): bool; + + /** + * Update the list of imported files to watch. + * + * @param array $importPaths New import paths + */ + public function updateImports(array $importPaths): void; +} diff --git a/src/Watcher/Diff/ConfigDiff.php b/src/Watcher/Diff/ConfigDiff.php new file mode 100644 index 0000000..ee8e3f3 --- /dev/null +++ b/src/Watcher/Diff/ConfigDiff.php @@ -0,0 +1,80 @@ + $added Items present in new but not in old (keyed by ID) + * @param array $removed Items present in old but not in new (keyed by ID) + * @param array $modified Items present in both but with different values (keyed by ID) + * @param array $unchanged Items identical in both (keyed by ID) + */ + public function __construct( + public array $added = [], + public array $removed = [], + public array $modified = [], + public array $unchanged = [], + ) {} + + /** + * Check if there are any changes. + */ + public function hasChanges(): bool + { + return $this->added !== [] + || $this->removed !== [] + || $this->modified !== []; + } + + /** + * Get total number of changes. + */ + public function changeCount(): int + { + return \count($this->added) + + \count($this->removed) + + \count($this->modified); + } + + /** + * Get human-readable summary for logging. + */ + public function getSummary(): string + { + if (!$this->hasChanges()) { + return 'No changes'; + } + + $parts = []; + + if ($this->added !== []) { + $parts[] = \sprintf('%d added', \count($this->added)); + } + + if ($this->removed !== []) { + $parts[] = \sprintf('%d removed', \count($this->removed)); + } + + if ($this->modified !== []) { + $parts[] = \sprintf('%d modified', \count($this->modified)); + } + + return \implode(', ', $parts); + } + + /** + * Create empty diff (no changes). + */ + public static function empty(): self + { + return new self(); + } +} diff --git a/src/Watcher/Diff/ConfigDiffCalculator.php b/src/Watcher/Diff/ConfigDiffCalculator.php new file mode 100644 index 0000000..d47fc56 --- /dev/null +++ b/src/Watcher/Diff/ConfigDiffCalculator.php @@ -0,0 +1,140 @@ +> $oldItems Old configuration items + * @param array> $newItems New configuration items + * @param string $idKey Key used to identify items (default: 'id') + */ + public function calculate(array $oldItems, array $newItems, string $idKey = 'id'): ConfigDiff + { + $oldById = $this->indexById($oldItems, $idKey); + $newById = $this->indexById($newItems, $idKey); + + $oldIds = \array_keys($oldById); + $newIds = \array_keys($newById); + + // Find added (in new, not in old) + $addedIds = \array_diff($newIds, $oldIds); + $added = \array_intersect_key($newById, \array_flip($addedIds)); + + // Find removed (in old, not in new) + $removedIds = \array_diff($oldIds, $newIds); + $removed = \array_intersect_key($oldById, \array_flip($removedIds)); + + // Find modified and unchanged (in both) + $commonIds = \array_intersect($oldIds, $newIds); + + $modified = []; + $unchanged = []; + + foreach ($commonIds as $id) { + if ($this->itemsEqual($oldById[$id], $newById[$id])) { + $unchanged[$id] = $newById[$id]; + } else { + $modified[$id] = $newById[$id]; + } + } + + return new ConfigDiff( + added: $added, + removed: $removed, + modified: $modified, + unchanged: $unchanged, + ); + } + + /** + * Calculate diff for entire config (all sections). + * + * @return array Section name => diff (only sections with changes) + */ + public function calculateAll(array $oldConfig, array $newConfig): array + { + $sections = [ + 'tools' => 'id', + 'prompts' => 'id', + 'documents' => 'description', + ]; + + $diffs = []; + + foreach ($sections as $section => $idKey) { + $oldItems = $oldConfig[$section] ?? []; + $newItems = $newConfig[$section] ?? []; + + $diff = $this->calculate($oldItems, $newItems, $idKey); + + if ($diff->hasChanges()) { + $diffs[$section] = $diff; + } + } + + return $diffs; + } + + /** + * Index array items by their ID field. + * + * @param array> $items + * @return array> + */ + private function indexById(array $items, string $idKey): array + { + $indexed = []; + + foreach ($items as $item) { + if (!\is_array($item)) { + continue; + } + + $id = $item[$idKey] ?? null; + + if ($id === null || $id === '') { + // Generate ID from hash if not present + $id = \md5(\json_encode($item, \JSON_THROW_ON_ERROR)); + } + + $indexed[(string) $id] = $item; + } + + return $indexed; + } + + /** + * Compare two items for equality (order-independent). + */ + private function itemsEqual(array $a, array $b): bool + { + $normalizedA = $this->normalizeForComparison($a); + $normalizedB = $this->normalizeForComparison($b); + + return $normalizedA === $normalizedB; + } + + /** + * Normalize array for consistent comparison (recursive key sorting). + */ + private function normalizeForComparison(array $data): array + { + \ksort($data); + + foreach ($data as $key => $value) { + if (\is_array($value)) { + $data[$key] = $this->normalizeForComparison($value); + } + } + + return $data; + } +} diff --git a/src/Watcher/Handler/ChangeHandlerInterface.php b/src/Watcher/Handler/ChangeHandlerInterface.php new file mode 100644 index 0000000..0235ca3 --- /dev/null +++ b/src/Watcher/Handler/ChangeHandlerInterface.php @@ -0,0 +1,36 @@ +> $items New configuration items + * @return bool True if any changes were made + */ + public function reload(array $items): bool; +} diff --git a/src/Watcher/Handler/ChangeHandlerRegistry.php b/src/Watcher/Handler/ChangeHandlerRegistry.php new file mode 100644 index 0000000..aede724 --- /dev/null +++ b/src/Watcher/Handler/ChangeHandlerRegistry.php @@ -0,0 +1,45 @@ + */ + private array $handlers = []; + + public function register(ChangeHandlerInterface $handler): void + { + $this->handlers[$handler->getSection()] = $handler; + } + + public function get(string $section): ?ChangeHandlerInterface + { + return $this->handlers[$section] ?? null; + } + + public function has(string $section): bool + { + return isset($this->handlers[$section]); + } + + /** + * @return array + */ + public function all(): array + { + return $this->handlers; + } + + /** + * @return string[] + */ + public function getSupportedSections(): array + { + return \array_keys($this->handlers); + } +} diff --git a/src/Watcher/Handler/ChangeHandlerRegistryFactory.php b/src/Watcher/Handler/ChangeHandlerRegistryFactory.php new file mode 100644 index 0000000..a62dc52 --- /dev/null +++ b/src/Watcher/Handler/ChangeHandlerRegistryFactory.php @@ -0,0 +1,46 @@ +register(new ToolsChangeHandler( + toolRegistry: $this->toolRegistry, + mcpRegistry: $this->mcpRegistry, + logger: $this->logger, + )); + + $registry->register(new PromptsChangeHandler( + promptRegistry: $this->promptRegistry, + promptFactory: $this->promptFactory, + mcpRegistry: $this->mcpRegistry, + logger: $this->logger, + )); + + return $registry; + } +} diff --git a/src/Watcher/Handler/PromptsChangeHandler.php b/src/Watcher/Handler/PromptsChangeHandler.php new file mode 100644 index 0000000..bc635ac --- /dev/null +++ b/src/Watcher/Handler/PromptsChangeHandler.php @@ -0,0 +1,107 @@ +hasChanges()) { + return false; + } + + $this->logger->info('Applying prompt changes', [ + 'summary' => $diff->getSummary(), + ]); + + // Process removals first + foreach ($diff->removed as $id => $promptConfig) { + $this->removePrompt($id); + } + + // Process additions + foreach ($diff->added as $id => $promptConfig) { + $this->addPrompt($promptConfig); + } + + // Process modifications (remove old, add new) + foreach ($diff->modified as $id => $promptConfig) { + $this->removePrompt($id); + $this->addPrompt($promptConfig); + } + + $this->notifyListChanged(); + + return true; + } + + public function reload(array $items): bool + { + $this->logger->info('Full prompt reload', [ + 'count' => \count($items), + ]); + + $this->promptRegistry->clear(); + + foreach ($items as $promptConfig) { + $this->addPrompt($promptConfig); + } + + $this->notifyListChanged(); + + return true; + } + + private function addPrompt(array $config): void + { + try { + $prompt = $this->promptFactory->createFromConfig($config); + $this->promptRegistry->register($prompt); + + $this->logger->debug('Prompt registered', ['id' => $prompt->id]); + } catch (\Throwable $e) { + $this->logger->error('Failed to register prompt', [ + 'config' => $config, + 'error' => $e->getMessage(), + ]); + } + } + + private function removePrompt(string $id): void + { + if ($this->promptRegistry->remove($id)) { + $this->logger->debug('Prompt removed', ['id' => $id]); + } + } + + private function notifyListChanged(): void + { + $this->mcpRegistry->emit('list_changed', ['prompts']); + + $this->logger->debug('Emitted prompts list_changed notification'); + } +} diff --git a/src/Watcher/Handler/ToolsChangeHandler.php b/src/Watcher/Handler/ToolsChangeHandler.php new file mode 100644 index 0000000..d6f67fc --- /dev/null +++ b/src/Watcher/Handler/ToolsChangeHandler.php @@ -0,0 +1,106 @@ +hasChanges()) { + return false; + } + + $this->logger->info('Applying tool changes', [ + 'summary' => $diff->getSummary(), + ]); + + // Process removals first + foreach ($diff->removed as $id => $toolConfig) { + $this->removeTool($id); + } + + // Process additions + foreach ($diff->added as $id => $toolConfig) { + $this->addTool($toolConfig); + } + + // Process modifications (remove old, add new) + foreach ($diff->modified as $id => $toolConfig) { + $this->removeTool($id); + $this->addTool($toolConfig); + } + + $this->notifyListChanged(); + + return true; + } + + public function reload(array $items): bool + { + $this->logger->info('Full tool reload', [ + 'count' => \count($items), + ]); + + $this->toolRegistry->clear(); + + foreach ($items as $toolConfig) { + $this->addTool($toolConfig); + } + + $this->notifyListChanged(); + + return true; + } + + private function addTool(array $config): void + { + try { + $tool = ToolDefinition::fromArray($config); + $this->toolRegistry->register($tool); + + $this->logger->debug('Tool registered', ['id' => $tool->id]); + } catch (\Throwable $e) { + $this->logger->error('Failed to register tool', [ + 'config' => $config, + 'error' => $e->getMessage(), + ]); + } + } + + private function removeTool(string $id): void + { + if ($this->toolRegistry->remove($id)) { + $this->logger->debug('Tool removed', ['id' => $id]); + } + } + + private function notifyListChanged(): void + { + $this->mcpRegistry->emit('list_changed', ['tools']); + + $this->logger->debug('Emitted tools list_changed notification'); + } +} diff --git a/src/Watcher/Strategy/InotifyWatchStrategy.php b/src/Watcher/Strategy/InotifyWatchStrategy.php new file mode 100644 index 0000000..f759e2b --- /dev/null +++ b/src/Watcher/Strategy/InotifyWatchStrategy.php @@ -0,0 +1,115 @@ + path => watch descriptor */ + private array $watches = []; + + /** @var array watch descriptor => path */ + private array $descriptorToPath = []; + + public function __construct() + { + if (!\extension_loaded('inotify')) { + throw new \RuntimeException('ext-inotify is not available'); + } + + $this->inotify = \inotify_init(); + + // Make non-blocking + \stream_set_blocking($this->inotify, false); + } + + public function addFile(string $path): void + { + if (!\file_exists($path) || !\is_resource($this->inotify)) { + return; + } + + if (isset($this->watches[$path])) { + return; // Already watching + } + + $wd = \inotify_add_watch( + $this->inotify, + $path, + \IN_MODIFY | \IN_CLOSE_WRITE | \IN_DELETE_SELF | \IN_MOVE_SELF, + ); + + if ($wd !== false) { + $this->watches[$path] = $wd; + $this->descriptorToPath[$wd] = $path; + } + } + + public function removeFile(string $path): void + { + if (!isset($this->watches[$path]) || !\is_resource($this->inotify)) { + return; + } + + $wd = $this->watches[$path]; + @\inotify_rm_watch($this->inotify, $wd); + + unset($this->watches[$path], $this->descriptorToPath[$wd]); + } + + public function clear(): void + { + foreach (\array_keys($this->watches) as $path) { + $this->removeFile($path); + } + } + + public function check(): array + { + if (!\is_resource($this->inotify)) { + return []; + } + + $events = @\inotify_read($this->inotify); + + if ($events === false) { + return []; // No events (non-blocking) + } + + $changed = []; + + foreach ($events as $event) { + $wd = $event['wd']; + if (isset($this->descriptorToPath[$wd])) { + $changed[] = $this->descriptorToPath[$wd]; + } + } + + return \array_unique($changed); + } + + public function stop(): void + { + $this->clear(); + + if (\is_resource($this->inotify)) { + \fclose($this->inotify); + $this->inotify = null; + } + } + + public function __destruct() + { + $this->stop(); + } +} diff --git a/src/Watcher/Strategy/PollingWatchStrategy.php b/src/Watcher/Strategy/PollingWatchStrategy.php new file mode 100644 index 0000000..85b1a1c --- /dev/null +++ b/src/Watcher/Strategy/PollingWatchStrategy.php @@ -0,0 +1,77 @@ + path => last known mtime */ + private array $files = []; + + private float $lastCheckTime = 0; + + public function __construct( + private readonly int $intervalMs = 2000, + ) {} + + public function addFile(string $path): void + { + if (!\file_exists($path)) { + return; + } + + $this->files[$path] = \filemtime($path) ?: 0; + } + + public function removeFile(string $path): void + { + unset($this->files[$path]); + } + + public function clear(): void + { + $this->files = []; + } + + public function check(): array + { + $now = \microtime(true) * 1000; + + // Respect polling interval + if ($now - $this->lastCheckTime < $this->intervalMs) { + return []; + } + + $this->lastCheckTime = $now; + + $changed = []; + + foreach ($this->files as $path => $lastMtime) { + if (!\file_exists($path)) { + // File deleted - consider it changed + $changed[] = $path; + continue; + } + + \clearstatcache(true, $path); + $currentMtime = \filemtime($path) ?: 0; + + if ($currentMtime > $lastMtime) { + $changed[] = $path; + $this->files[$path] = $currentMtime; + } + } + + return $changed; + } + + public function stop(): void + { + $this->clear(); + } +} diff --git a/src/Watcher/Strategy/WatchStrategyFactory.php b/src/Watcher/Strategy/WatchStrategyFactory.php new file mode 100644 index 0000000..2b62575 --- /dev/null +++ b/src/Watcher/Strategy/WatchStrategyFactory.php @@ -0,0 +1,50 @@ +pollingIntervalMs); + } + + /** + * Create a polling strategy explicitly. + */ + public function createPolling(): PollingWatchStrategy + { + return new PollingWatchStrategy($this->pollingIntervalMs); + } + + /** + * Create an inotify strategy explicitly. + * + * @throws \RuntimeException If ext-inotify is not available + */ + public function createInotify(): InotifyWatchStrategy + { + if (!\extension_loaded('inotify')) { + throw new \RuntimeException('ext-inotify is not available'); + } + + return new InotifyWatchStrategy(); + } +} diff --git a/src/Watcher/Strategy/WatchStrategyInterface.php b/src/Watcher/Strategy/WatchStrategyInterface.php new file mode 100644 index 0000000..a6f7dc5 --- /dev/null +++ b/src/Watcher/Strategy/WatchStrategyInterface.php @@ -0,0 +1,38 @@ +