From 6b64b45a23c9fc2f5309b723cba940f5b5dbc49b Mon Sep 17 00:00:00 2001 From: butschster Date: Sun, 18 Jan 2026 11:24:43 +0400 Subject: [PATCH 1/2] feat: implement hot-reload for MCP server configuration Add automatic configuration hot-reload that detects changes in context.yaml and updates tools/prompts without server restart. --- context.yaml | 8 + docs/hot-reload/feature-request.md | 313 +++++++++ docs/hot-reload/master-checklist.md | 106 +++ .../hot-reload/stage-1-registry-mutability.md | 198 ++++++ .../stage-2-file-watch-strategies.md | 468 +++++++++++++ .../stage-3-config-diff-calculator.md | 498 ++++++++++++++ docs/hot-reload/stage-4-change-handlers.md | 630 ++++++++++++++++++ docs/hot-reload/stage-5-config-watcher.md | 528 +++++++++++++++ .../stage-6-transport-integration.md | 534 +++++++++++++++ src/McpServer/ActionsBootloader.php | 8 + 10 files changed, 3291 insertions(+) create mode 100644 docs/hot-reload/feature-request.md create mode 100644 docs/hot-reload/master-checklist.md create mode 100644 docs/hot-reload/stage-1-registry-mutability.md create mode 100644 docs/hot-reload/stage-2-file-watch-strategies.md create mode 100644 docs/hot-reload/stage-3-config-diff-calculator.md create mode 100644 docs/hot-reload/stage-4-change-handlers.md create mode 100644 docs/hot-reload/stage-5-config-watcher.md create mode 100644 docs/hot-reload/stage-6-transport-integration.md diff --git a/context.yaml b/context.yaml index fc2af6f9..e52ba951 100644 --- a/context.yaml +++ b/context.yaml @@ -6,6 +6,14 @@ import: - path: ./docs format: md +projects: + - name: ctx-mcp-server + description: MCP server library + - name: ctx-docs + description: CTX documentation + - name: mcp-server-core + description: Core MCP server functionality + documents: - description: 'Project structure overview' outputPath: project-structure.md diff --git a/docs/hot-reload/feature-request.md b/docs/hot-reload/feature-request.md new file mode 100644 index 00000000..43a90faf --- /dev/null +++ b/docs/hot-reload/feature-request.md @@ -0,0 +1,313 @@ +# Feature Request: Hot-Reload for MCP Server Configuration + +## Summary + +Implement automatic hot-reload capability for the MCP server that detects changes in `context.yaml` configuration files and dynamically updates tools, prompts, and resources without requiring server restart. + +--- + +## Problem Statement + +### Current Behavior + +When the MCP server starts, it loads the configuration from `context.yaml` once during initialization: + +```php +// src/McpServer/ActionsBootloader.php +ServerRunnerInterface::class => function ( + McpConfig $config, + ServerRunner $factory, + ConfigLoaderInterface $loader, +) { + $loader->load(); // ← Configuration loaded ONCE at startup + // ... +} +``` + +After this initial load, any changes to `context.yaml` (adding new tools, modifying prompts, updating resources) are **not reflected** in the running server until a full restart. + +### User Pain Points + +1. **Workflow Disruption**: Developers must manually restart the MCP server every time they add or modify tools in `context.yaml` +2. **Client Reconnection**: MCP clients (Claude Desktop, Cursor, VS Code, etc.) lose their connection and must re-initialize +3. **Context Loss**: Ongoing conversations in AI assistants may lose context due to server restart +4. **Development Friction**: Rapid iteration on custom tools becomes tedious + +### Use Case Example + +A developer is building a custom MCP tool for their project: + +```yaml +# context.yaml +tools: + - id: my-custom-tool + name: my-tool + description: "Does something useful" + command: + name: php + args: ["artisan", "my:command"] +``` + +They realize they need to add another tool. Currently, they must: +1. Edit `context.yaml` +2. Stop the MCP server +3. Restart the MCP server +4. Wait for client reconnection +5. Re-establish conversation context + +With hot-reload, steps 2-5 would be eliminated. + +--- + +## Proposed Solution + +### Overview + +Implement a **ConfigWatcher** system that: +1. Monitors `context.yaml` and all imported configuration files for changes +2. Detects modifications using efficient file watching mechanisms +3. Parses only the changed sections of configuration +4. Updates the appropriate registries (tools, prompts, resources) +5. Emits MCP protocol notifications to inform connected clients + +### MCP Protocol Support + +The MCP specification already supports dynamic list updates through notifications: + +| Notification | Purpose | +|--------------|---------| +| `notifications/tools/list_changed` | Informs clients that available tools have changed | +| `notifications/prompts/list_changed` | Informs clients that available prompts have changed | +| `notifications/resources/list_changed` | Informs clients that available resources have changed | + +These notifications are already implemented in the schema: +- `PhpMcp\Schema\Notification\ToolListChangedNotification` +- `PhpMcp\Schema\Notification\PromptListChangedNotification` +- `PhpMcp\Schema\Notification\ResourceListChangedNotification` + +The `Mcp\Server\Registry` already has the infrastructure to emit `list_changed` events: + +```php +// vendor/llm/mcp-server/src/Protocol.php +$this->registry->on('list_changed', function (string $listType): void { + $this->handleListChanged($listType); +}); +``` + +--- + +## Technical Design + +### Architecture + +``` +src/Watcher/ +├── ConfigWatcherInterface.php # Main watcher contract +├── ConfigWatcher.php # Implementation with file monitoring +├── Strategy/ +│ ├── WatchStrategyInterface.php # File watching strategy contract +│ ├── InotifyWatchStrategy.php # Linux inotify-based (if ext-inotify available) +│ ├── PollingWatchStrategy.php # Universal polling fallback +│ └── WatchStrategyFactory.php # Auto-selects best strategy +├── Handler/ +│ ├── ChangeHandlerInterface.php # Handler contract for config sections +│ ├── ToolsChangeHandler.php # Handles tools section changes +│ ├── PromptsChangeHandler.php # Handles prompts section changes +│ └── ResourcesChangeHandler.php # Handles resources section changes (documents) +├── Diff/ +│ ├── ConfigDiffCalculator.php # Calculates what changed between configs +│ └── ConfigDiff.php # DTO representing changes +└── ConfigWatcherBootloader.php # Spiral bootloader for DI +``` + +### Component Responsibilities + +#### 1. ConfigWatcher + +The main orchestrator that: +- Tracks all configuration files (main + imports) +- Delegates file monitoring to the appropriate strategy +- Triggers handlers when changes are detected + +```php +interface ConfigWatcherInterface +{ + public function start(string $mainConfigPath, array $importedPaths = []): void; + public function tick(): void; + public function stop(): void; +} +``` + +#### 2. Watch Strategies + +**InotifyWatchStrategy** (Linux, requires `ext-inotify`): +- Uses kernel-level file system events +- Zero CPU overhead when idle +- Immediate notification on changes + +**PollingWatchStrategy** (Universal fallback): +- Checks file `mtime` at configurable intervals (default: 2 seconds) +- Works on all platforms +- Slightly higher CPU usage but negligible for config files + +#### 3. Change Handlers + +Each handler is responsible for: +1. Parsing its section from the new configuration +2. Comparing with current registry state +3. Applying additions, removals, and modifications +4. Triggering appropriate notifications + +```php +interface ChangeHandlerInterface +{ + public function getSection(): string; + public function apply(ConfigDiff $diff): bool; +} +``` + +#### 4. ConfigDiff + +Represents the difference between two configurations: + +```php +final readonly class ConfigDiff +{ + public function __construct( + public array $added, + public array $removed, + public array $modified, + public array $unchanged, + ) {} + + public function hasChanges(): bool; +} +``` + +### Integration Points + +#### StdioTransport Integration + +The watcher's `tick()` method is called within the transport's event loop during idle time (stream_select timeout). + +#### Registry Updates + +The handlers update both local registries (`ToolRegistry`, `PromptRegistry`) and emit events through the MCP Registry for protocol notifications. + +--- + +## Files Involved + +### Files to Create (in `ctx/mcp-server` package) + +| File | Purpose | +|------|---------| +| `src/Watcher/ConfigWatcherInterface.php` | Main watcher contract | +| `src/Watcher/ConfigWatcher.php` | Watcher implementation | +| `src/Watcher/Strategy/WatchStrategyInterface.php` | File watching strategy contract | +| `src/Watcher/Strategy/InotifyWatchStrategy.php` | inotify-based watching | +| `src/Watcher/Strategy/PollingWatchStrategy.php` | Polling-based watching | +| `src/Watcher/Strategy/WatchStrategyFactory.php` | Strategy selection | +| `src/Watcher/Handler/ChangeHandlerInterface.php` | Change handler contract | +| `src/Watcher/Handler/ToolsChangeHandler.php` | Tools section handler | +| `src/Watcher/Handler/PromptsChangeHandler.php` | Prompts section handler | +| `src/Watcher/Diff/ConfigDiffCalculator.php` | Diff calculation logic | +| `src/Watcher/Diff/ConfigDiff.php` | Diff data structure | +| `src/Watcher/ConfigWatcherBootloader.php` | DI configuration | + +### Files to Modify + +| File | Changes | +|------|---------| +| `src/Transport/StdioTransport.php` | Add `tick()` call in event loop | +| `src/ServerRunner.php` | Initialize and inject ConfigWatcher | +| `src/Tool/ToolRegistry.php` | Add `remove()` and `clear()` methods | +| `src/Tool/ToolRegistryInterface.php` | Add method signatures | +| `src/Prompt/PromptRegistry.php` | Add `remove()` and `clear()` methods | +| `src/Prompt/PromptRegistryInterface.php` | Add method signatures | + +--- + +## Key References + +### MCP Protocol Specification + +The MCP protocol defines list change notifications: +- [MCP Specification - Notifications](https://spec.modelcontextprotocol.io/specification/server/notifications/) + +### Existing Infrastructure + +**Notification Classes** (already implemented): +``` +vendor/php-mcp/schema/src/Notification/ +├── ToolListChangedNotification.php +├── PromptListChangedNotification.php +└── ResourceListChangedNotification.php +``` + +**Registry Event System**: +```php +// vendor/llm/mcp-server/src/Registry.php +final class Registry implements ReferenceRegistryInterface +{ + use EventEmitterTrait; // From evenement/evenement +} +``` + +**Protocol Notification Handler**: +```php +// vendor/llm/mcp-server/src/Protocol.php +public function handleListChanged(string $listType): void +{ + $notification = match ($listType) { + 'tools' => ToolListChangedNotification::make(), + 'prompts' => PromptListChangedNotification::make(), + 'resources' => ResourceListChangedNotification::make(), + }; + + foreach ($subscribers as $sessionId) { + $this->sendNotification($notification, $sessionId); + } +} +``` + +--- + +## Implementation Considerations + +### 1. Debouncing + +File editors often trigger multiple write events for a single save. Implement debouncing with ~200ms delay. + +### 2. Error Handling + +Invalid YAML after edit should not crash the server - keep previous valid configuration. + +### 3. Import File Tracking + +When imports change, the watcher must update its file list dynamically. + +### 4. Configuration Option + +Allow users to disable hot-reload via environment variable: +```bash +MCP_HOT_RELOAD=false ctx server +``` + +--- + +## Success Criteria + +1. **Functional**: Tools/prompts/resources update within 3 seconds of config file save +2. **Reliable**: No server crashes due to config errors during reload +3. **Efficient**: < 1% CPU overhead when idle (polling mode) +4. **Compatible**: Works with Claude Desktop, Cursor, and other MCP clients +5. **Transparent**: Clear logging of reload events for debugging + +--- + +## References + +- [MCP Specification](https://spec.modelcontextprotocol.io/) +- [PHP inotify extension](https://www.php.net/manual/en/book.inotify.php) +- [Evenement - Event Emitter](https://github.com/igorw/evenement) diff --git a/docs/hot-reload/master-checklist.md b/docs/hot-reload/master-checklist.md new file mode 100644 index 00000000..83879006 --- /dev/null +++ b/docs/hot-reload/master-checklist.md @@ -0,0 +1,106 @@ +# Hot-Reload Feature: Master Checklist + +## Overview + +This document tracks the implementation of hot-reload capability for MCP Server configuration. The feature allows dynamic updates to tools, prompts, and resources without server restart. + +**Target Package**: `ctx/mcp-server` +**Estimated Stages**: 6 +**Dependencies**: None (uses existing infrastructure) + +> 📋 **Full Feature Request**: See [feature-request.md](feature-request.md) for complete problem statement, technical design, and references. + +--- + +## Implementation Stages + +| Stage | Title | Status | Description | +|-------|-------|--------|-------------| +| 1 | [Registry Mutability](stage-1-registry-mutability.md) | ✅ Complete | Add remove/update/clear methods to registries | +| 2 | [File Watch Strategies](stage-2-file-watch-strategies.md) | ✅ Complete | Implement inotify and polling file watchers | +| 3 | [Config Diff Calculator](stage-3-config-diff-calculator.md) | ✅ Complete | Calculate differences between configurations | +| 4 | [Change Handlers](stage-4-change-handlers.md) | ✅ Complete | Handle changes for tools/prompts/resources | +| 5 | [Config Watcher](stage-5-config-watcher.md) | ✅ Complete | Main orchestrator with debouncing | +| 6 | [Transport Integration](stage-6-transport-integration.md) | ✅ Complete | Integrate watcher into event loop | + +--- + +## Stage Dependencies + +``` +Stage 1: Registry Mutability + ↓ +Stage 2: File Watch Strategies ──┐ + ↓ │ +Stage 3: Config Diff Calculator │ + ↓ │ +Stage 4: Change Handlers ←───────┘ + ↓ +Stage 5: Config Watcher + ↓ +Stage 6: Transport Integration +``` + +--- + +## Files to Create + +### Stage 1 +- `src/Tool/ToolRegistryInterface.php` (modify) +- `src/Tool/ToolRegistry.php` (modify) +- `src/Prompt/PromptRegistryInterface.php` (modify) +- `src/Prompt/PromptRegistry.php` (modify) + +### Stage 2 +- `src/Watcher/Strategy/WatchStrategyInterface.php` +- `src/Watcher/Strategy/InotifyWatchStrategy.php` +- `src/Watcher/Strategy/PollingWatchStrategy.php` +- `src/Watcher/Strategy/WatchStrategyFactory.php` + +### Stage 3 +- `src/Watcher/Diff/ConfigDiff.php` +- `src/Watcher/Diff/ConfigDiffCalculator.php` + +### Stage 4 +- `src/Watcher/Handler/ChangeHandlerInterface.php` +- `src/Watcher/Handler/ToolsChangeHandler.php` +- `src/Watcher/Handler/PromptsChangeHandler.php` + +### Stage 5 +- `src/Watcher/ConfigWatcherInterface.php` +- `src/Watcher/ConfigWatcher.php` +- `src/Watcher/ConfigWatcherBootloader.php` + +### Stage 6 +- `src/Transport/StdioTransport.php` (modify) +- `src/ServerRunner.php` (modify) + +--- + +## Testing Milestones + +- [ ] Stage 1: Unit tests for registry mutations +- [ ] Stage 2: Unit tests for file watch strategies +- [ ] Stage 3: Unit tests for diff calculation +- [ ] Stage 4: Unit tests for change handlers +- [ ] Stage 5: Integration test for full reload cycle +- [ ] Stage 6: E2E test with MCP Inspector + +--- + +## Success Criteria + +1. Tools/prompts update within 3 seconds of config save +2. No server crashes on invalid config +3. < 1% CPU overhead when idle (polling mode) +4. All connected MCP clients receive notifications +5. Works with imported config files + +--- + +## Notes + +- All changes should be in `ctx/mcp-server` package +- Main CTX project requires minimal changes (only import path exposure) +- Each stage should be independently testable +- Maintain backward compatibility (hot-reload can be disabled) diff --git a/docs/hot-reload/stage-1-registry-mutability.md b/docs/hot-reload/stage-1-registry-mutability.md new file mode 100644 index 00000000..6409bc59 --- /dev/null +++ b/docs/hot-reload/stage-1-registry-mutability.md @@ -0,0 +1,198 @@ +# Stage 1: Registry Mutability + +## Objective + +Add methods to modify and clear registries at runtime, enabling dynamic updates without server restart. + +--- + +## Problem + +Current registries (`ToolRegistry`, `PromptRegistry`) only support adding items: + +```php +// Current ToolRegistry +public function register(ToolDefinition $tool): void +{ + $this->tools[$tool->id] = $tool; +} +``` + +There's no way to: +- Remove a tool/prompt when it's deleted from config +- Update an existing tool/prompt when its definition changes +- Clear all items before a full reload + +--- + +## Solution + +Extend registry interfaces and implementations with mutation methods. + +--- + +## Files to Modify + +### 1. `src/Tool/ToolRegistryInterface.php` + +Add new method signatures: + +```php +interface ToolRegistryInterface +{ + // Existing + public function register(ToolDefinition $tool): void; + + // New methods + public function remove(string $id): bool; + public function update(ToolDefinition $tool): void; + public function clear(): void; + public function has(string $id): bool; +} +``` + +### 2. `src/Tool/ToolRegistry.php` + +Implement new methods: + +```php +public function remove(string $id): bool +{ + if (!$this->has($id)) { + return false; + } + unset($this->tools[$id]); + return true; +} + +public function update(ToolDefinition $tool): void +{ + // Update replaces existing or adds new + $this->tools[$tool->id] = $tool; +} + +public function clear(): void +{ + $this->tools = []; +} +``` + +### 3. `src/Prompt/PromptRegistryInterface.php` + +Add same method signatures: + +```php +interface PromptRegistryInterface +{ + // Existing + public function register(PromptDefinition $prompt): void; + + // New methods + public function remove(string $id): bool; + public function update(PromptDefinition $prompt): void; + public function clear(): void; + public function has(string $name): bool; +} +``` + +### 4. `src/Prompt/PromptRegistry.php` + +Implement new methods (same pattern as ToolRegistry). + +--- + +## Implementation Details + +### Method Behavior + +| Method | Behavior | +|--------|----------| +| `remove(id)` | Returns `true` if item existed and was removed, `false` otherwise | +| `update(item)` | Replaces existing item or adds new (upsert semantics) | +| `clear()` | Removes all items, resets to empty state | +| `has(id)` | Already exists, ensure it's in interface | + +### Thread Safety + +Not required - MCP server runs in single thread. No mutex needed. + +### Backward Compatibility + +All new methods have default implementations or are additive. Existing code continues to work. + +--- + +## Testing + +### Unit Tests for ToolRegistry + +```php +public function test_remove_existing_tool(): void +{ + $registry = new ToolRegistry(); + $tool = ToolDefinition::fromArray(['id' => 'test', 'name' => 'test', ...]); + + $registry->register($tool); + $this->assertTrue($registry->has('test')); + + $result = $registry->remove('test'); + + $this->assertTrue($result); + $this->assertFalse($registry->has('test')); +} + +public function test_remove_nonexistent_tool(): void +{ + $registry = new ToolRegistry(); + + $result = $registry->remove('nonexistent'); + + $this->assertFalse($result); +} + +public function test_update_existing_tool(): void +{ + $registry = new ToolRegistry(); + $tool1 = ToolDefinition::fromArray(['id' => 'test', 'name' => 'test', 'description' => 'v1']); + $tool2 = ToolDefinition::fromArray(['id' => 'test', 'name' => 'test', 'description' => 'v2']); + + $registry->register($tool1); + $registry->update($tool2); + + $this->assertEquals('v2', $registry->get('test')->description); +} + +public function test_clear_removes_all(): void +{ + $registry = new ToolRegistry(); + $registry->register(ToolDefinition::fromArray(['id' => 'a', ...])); + $registry->register(ToolDefinition::fromArray(['id' => 'b', ...])); + + $registry->clear(); + + $this->assertEmpty($registry->all()); +} +``` + +### Unit Tests for PromptRegistry + +Same pattern as ToolRegistry tests. + +--- + +## Acceptance Criteria + +- [ ] `ToolRegistry::remove()` removes tool by ID +- [ ] `ToolRegistry::update()` replaces existing tool +- [ ] `ToolRegistry::clear()` removes all tools +- [ ] `PromptRegistry` has same methods +- [ ] All existing tests still pass +- [ ] New unit tests cover all mutation methods + +--- + +## Notes + +- Keep methods simple - no events/callbacks at this stage +- Events for MCP notifications will be added in Stage 4 +- Consider adding `count(): int` method for debugging diff --git a/docs/hot-reload/stage-2-file-watch-strategies.md b/docs/hot-reload/stage-2-file-watch-strategies.md new file mode 100644 index 00000000..ddb0dc62 --- /dev/null +++ b/docs/hot-reload/stage-2-file-watch-strategies.md @@ -0,0 +1,468 @@ +# Stage 2: File Watch Strategies + +## Objective + +Implement file watching mechanisms to detect configuration file changes efficiently. + +--- + +## Problem + +The server needs to know when `context.yaml` or any imported config files change. Different platforms offer different capabilities: + +- **Linux**: `inotify` kernel subsystem (most efficient) +- **macOS/Windows/Other**: Polling with `filemtime()` (universal fallback) + +--- + +## Solution + +Create a strategy pattern for file watching with automatic selection of the best available method. + +--- + +## Files to Create + +### Directory Structure + +``` +src/Watcher/ +└── Strategy/ + ├── WatchStrategyInterface.php + ├── InotifyWatchStrategy.php + ├── PollingWatchStrategy.php + └── WatchStrategyFactory.php +``` + +--- + +## Interface Definition + +### `src/Watcher/Strategy/WatchStrategyInterface.php` + +```php + 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(); + } +} +``` + +### 2. `InotifyWatchStrategy.php` + +Linux-specific using `ext-inotify`: + +```php +final class InotifyWatchStrategy implements WatchStrategyInterface +{ + /** @var resource|null */ + private $inotify = null; + + /** @var array 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) || $this->inotify === null) { + 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]) || $this->inotify === null) { + return; + } + + $wd = $this->watches[$path]; + @inotify_rm_watch($this->inotify, $wd); + + unset($this->watches[$path]); + unset($this->descriptorToPath[$wd]); + } + + public function clear(): void + { + foreach ($this->watches as $path => $wd) { + $this->removeFile($path); + } + } + + public function check(): array + { + if ($this->inotify === null) { + 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 ($this->inotify !== null) { + fclose($this->inotify); + $this->inotify = null; + } + } + + public function __destruct() + { + $this->stop(); + } +} +``` + +### 3. `WatchStrategyFactory.php` + +Auto-selects best available strategy: + +```php +final class WatchStrategyFactory +{ + public function __construct( + private readonly int $pollingIntervalMs = 2000, + ) {} + + public function create(): WatchStrategyInterface + { + // Prefer inotify on Linux if available + if (extension_loaded('inotify')) { + return new InotifyWatchStrategy(); + } + + // Fallback to polling + return new PollingWatchStrategy($this->pollingIntervalMs); + } + + public function createPolling(): PollingWatchStrategy + { + return new PollingWatchStrategy($this->pollingIntervalMs); + } + + public function createInotify(): InotifyWatchStrategy + { + if (!extension_loaded('inotify')) { + throw new \RuntimeException('ext-inotify is not available'); + } + return new InotifyWatchStrategy(); + } +} +``` + +--- + +## Implementation Details + +### inotify Events + +| Event | Description | +|-------|-------------| +| `IN_MODIFY` | File content was modified | +| `IN_CLOSE_WRITE` | File opened for writing was closed | +| `IN_DELETE_SELF` | Watched file was deleted | +| `IN_MOVE_SELF` | Watched file was moved | + +### Polling Considerations + +- Default interval: 2000ms (2 seconds) +- Uses `clearstatcache()` to get fresh mtime +- Low overhead for typical config file count (< 10 files) + +### Resource Management + +- `InotifyWatchStrategy` properly cleans up watch descriptors +- `stop()` must be called on shutdown +- Destructor provides safety net + +--- + +## Testing + +### Unit Tests for PollingWatchStrategy + +```php +public function test_detects_file_modification(): void +{ + $tempFile = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tempFile, 'initial'); + + $strategy = new PollingWatchStrategy(intervalMs: 0); // No delay for testing + $strategy->addFile($tempFile); + + // No changes yet + $this->assertEmpty($strategy->check()); + + // Modify file + sleep(1); // Ensure mtime changes + file_put_contents($tempFile, 'modified'); + + $changed = $strategy->check(); + + $this->assertContains($tempFile, $changed); + + unlink($tempFile); +} + +public function test_respects_polling_interval(): void +{ + $tempFile = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tempFile, 'initial'); + + $strategy = new PollingWatchStrategy(intervalMs: 5000); + $strategy->addFile($tempFile); + + // First check should work + $strategy->check(); + + // Immediate second check should return empty (interval not passed) + sleep(1); + file_put_contents($tempFile, 'modified'); + $this->assertEmpty($strategy->check()); + + unlink($tempFile); +} + +public function test_detects_file_deletion(): void +{ + $tempFile = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tempFile, 'content'); + + $strategy = new PollingWatchStrategy(intervalMs: 0); + $strategy->addFile($tempFile); + + unlink($tempFile); + + $changed = $strategy->check(); + + $this->assertContains($tempFile, $changed); +} +``` + +### Unit Tests for InotifyWatchStrategy + +Skip if `ext-inotify` not available: + +```php +protected function setUp(): void +{ + if (!extension_loaded('inotify')) { + $this->markTestSkipped('ext-inotify not available'); + } +} + +public function test_detects_file_modification(): void +{ + $tempFile = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tempFile, 'initial'); + + $strategy = new InotifyWatchStrategy(); + $strategy->addFile($tempFile); + + file_put_contents($tempFile, 'modified'); + + // Small delay for inotify event + usleep(50000); + + $changed = $strategy->check(); + + $this->assertContains($tempFile, $changed); + + $strategy->stop(); + unlink($tempFile); +} +``` + +### Unit Tests for WatchStrategyFactory + +```php +public function test_creates_strategy(): void +{ + $factory = new WatchStrategyFactory(); + $strategy = $factory->create(); + + $this->assertInstanceOf(WatchStrategyInterface::class, $strategy); +} + +public function test_creates_polling_explicitly(): void +{ + $factory = new WatchStrategyFactory(pollingIntervalMs: 1000); + $strategy = $factory->createPolling(); + + $this->assertInstanceOf(PollingWatchStrategy::class, $strategy); +} +``` + +--- + +## Acceptance Criteria + +- [ ] `PollingWatchStrategy` detects file modifications +- [ ] `PollingWatchStrategy` respects polling interval +- [ ] `PollingWatchStrategy` handles file deletion +- [ ] `InotifyWatchStrategy` detects file modifications (if ext available) +- [ ] `InotifyWatchStrategy` properly releases resources +- [ ] `WatchStrategyFactory` auto-selects best strategy +- [ ] All strategies implement same interface + +--- + +## Notes + +- `ext-inotify` installation: `pecl install inotify` +- On Docker, may need to install: `apt-get install php-inotify` +- macOS alternative: `fsevents` (not implemented in this stage) +- Consider adding strategy name for logging: `getName(): string` diff --git a/docs/hot-reload/stage-3-config-diff-calculator.md b/docs/hot-reload/stage-3-config-diff-calculator.md new file mode 100644 index 00000000..085f7ef6 --- /dev/null +++ b/docs/hot-reload/stage-3-config-diff-calculator.md @@ -0,0 +1,498 @@ +# Stage 3: Config Diff Calculator + +## Objective + +Implement a system to calculate differences between two configuration states, identifying what was added, removed, or modified. + +--- + +## Problem + +When configuration changes, we need to know exactly what changed to: +1. Update only affected registry items (efficient) +2. Avoid unnecessary MCP notifications +3. Log meaningful change information + +Comparing entire configs is not enough - we need granular diffs per section. + +--- + +## Solution + +Create a `ConfigDiffCalculator` that compares old and new configurations and produces a `ConfigDiff` object describing the changes. + +--- + +## Files to Create + +### Directory Structure + +``` +src/Watcher/ +└── Diff/ + ├── ConfigDiff.php + └── ConfigDiffCalculator.php +``` + +--- + +## Class Definitions + +### 1. `src/Watcher/Diff/ConfigDiff.php` + +Immutable DTO representing changes: + +```php + $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 !empty($this->added) + || !empty($this->removed) + || !empty($this->modified); + } + + /** + * Get total number of changes + */ + public function changeCount(): int + { + return count($this->added) + + count($this->removed) + + count($this->modified); + } + + /** + * Get summary for logging + */ + public function getSummary(): string + { + if (!$this->hasChanges()) { + return 'No changes'; + } + + $parts = []; + + if (!empty($this->added)) { + $parts[] = sprintf('%d added', count($this->added)); + } + if (!empty($this->removed)) { + $parts[] = sprintf('%d removed', count($this->removed)); + } + if (!empty($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(); + } +} +``` + +### 2. `src/Watcher/Diff/ConfigDiffCalculator.php` + +Calculates diffs for configuration sections: + +```php +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 potentially modified (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 + */ + public function calculateAll(array $oldConfig, array $newConfig): array + { + $sections = [ + 'tools' => 'id', + 'prompts' => 'id', + 'documents' => 'description', // Documents use description as identifier + ]; + + $diffs = []; + + foreach ($sections as $section => $idKey) { + $oldItems = $oldConfig[$section] ?? []; + $newItems = $newConfig[$section] ?? []; + + $diff = $this->calculate($oldItems, $newItems, $idKey); + + // Only include sections with changes + if ($diff->hasChanges()) { + $diffs[$section] = $diff; + } + } + + return $diffs; + } + + /** + * Index array items by their ID field + * + * @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)); + } + + $indexed[(string) $id] = $item; + } + + return $indexed; + } + + /** + * Compare two items for equality + */ + private function itemsEqual(array $a, array $b): bool + { + // Normalize for comparison (sort keys recursively) + $normalizedA = $this->normalizeForComparison($a); + $normalizedB = $this->normalizeForComparison($b); + + return $normalizedA === $normalizedB; + } + + /** + * Normalize array for consistent comparison + */ + private function normalizeForComparison(array $data): array + { + ksort($data); + + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = $this->normalizeForComparison($value); + } + } + + return $data; + } +} +``` + +--- + +## Usage Examples + +### Basic Usage + +```php +$calculator = new ConfigDiffCalculator(); + +$oldTools = [ + ['id' => 'tool-a', 'name' => 'Tool A', 'description' => 'First tool'], + ['id' => 'tool-b', 'name' => 'Tool B', 'description' => 'Second tool'], +]; + +$newTools = [ + ['id' => 'tool-a', 'name' => 'Tool A', 'description' => 'Updated first tool'], // Modified + ['id' => 'tool-c', 'name' => 'Tool C', 'description' => 'Third tool'], // Added + // tool-b removed +]; + +$diff = $calculator->calculate($oldTools, $newTools); + +// $diff->added = ['tool-c' => [...]] +// $diff->removed = ['tool-b' => [...]] +// $diff->modified = ['tool-a' => [...]] +// $diff->hasChanges() = true +// $diff->getSummary() = "1 added, 1 removed, 1 modified" +``` + +### Full Config Diff + +```php +$diffs = $calculator->calculateAll($oldConfig, $newConfig); + +foreach ($diffs as $section => $diff) { + echo "{$section}: {$diff->getSummary()}\n"; +} +// tools: 1 added, 1 removed +// prompts: 2 modified +``` + +--- + +## Implementation Details + +### ID Resolution + +Items are identified by their `id` field by default. If no ID is present, a hash of the item is used: + +```php +// Tool with explicit ID +['id' => 'my-tool', 'name' => 'My Tool'] // ID = 'my-tool' + +// Item without ID (e.g., some documents) +['description' => 'My Doc', 'sources' => [...]] // ID = md5(json_encode(...)) +``` + +### Comparison Logic + +Items are compared after normalization: +1. Keys are sorted alphabetically (recursive) +2. Deep equality check using `===` + +This ensures: +- `['a' => 1, 'b' => 2]` equals `['b' => 2, 'a' => 1]` +- Nested arrays are compared correctly + +### Performance + +For typical config sizes (< 100 items per section): +- O(n) indexing +- O(n) diff calculation +- Memory: 2x config size temporarily + +--- + +## Testing + +### Unit Tests for ConfigDiff + +```php +public function test_has_changes_when_items_added(): void +{ + $diff = new ConfigDiff( + added: ['a' => ['id' => 'a']], + ); + + $this->assertTrue($diff->hasChanges()); + $this->assertEquals(1, $diff->changeCount()); +} + +public function test_no_changes_when_empty(): void +{ + $diff = ConfigDiff::empty(); + + $this->assertFalse($diff->hasChanges()); + $this->assertEquals(0, $diff->changeCount()); +} + +public function test_summary_format(): void +{ + $diff = new ConfigDiff( + added: ['a' => [], 'b' => []], + removed: ['c' => []], + modified: ['d' => [], 'e' => [], 'f' => []], + ); + + $this->assertEquals('2 added, 1 removed, 3 modified', $diff->getSummary()); +} +``` + +### Unit Tests for ConfigDiffCalculator + +```php +public function test_detects_added_items(): void +{ + $calc = new ConfigDiffCalculator(); + + $old = []; + $new = [['id' => 'new-item', 'name' => 'New']]; + + $diff = $calc->calculate($old, $new); + + $this->assertArrayHasKey('new-item', $diff->added); + $this->assertEmpty($diff->removed); + $this->assertEmpty($diff->modified); +} + +public function test_detects_removed_items(): void +{ + $calc = new ConfigDiffCalculator(); + + $old = [['id' => 'old-item', 'name' => 'Old']]; + $new = []; + + $diff = $calc->calculate($old, $new); + + $this->assertEmpty($diff->added); + $this->assertArrayHasKey('old-item', $diff->removed); + $this->assertEmpty($diff->modified); +} + +public function test_detects_modified_items(): void +{ + $calc = new ConfigDiffCalculator(); + + $old = [['id' => 'item', 'name' => 'Original']]; + $new = [['id' => 'item', 'name' => 'Updated']]; + + $diff = $calc->calculate($old, $new); + + $this->assertEmpty($diff->added); + $this->assertEmpty($diff->removed); + $this->assertArrayHasKey('item', $diff->modified); +} + +public function test_ignores_key_order(): void +{ + $calc = new ConfigDiffCalculator(); + + $old = [['id' => 'item', 'a' => 1, 'b' => 2]]; + $new = [['b' => 2, 'id' => 'item', 'a' => 1]]; // Same content, different order + + $diff = $calc->calculate($old, $new); + + $this->assertFalse($diff->hasChanges()); + $this->assertArrayHasKey('item', $diff->unchanged); +} + +public function test_handles_items_without_id(): void +{ + $calc = new ConfigDiffCalculator(); + + $old = [['name' => 'No ID Item', 'value' => 1]]; + $new = [['name' => 'No ID Item', 'value' => 2]]; // Modified + + $diff = $calc->calculate($old, $new, idKey: 'name'); + + $this->assertArrayHasKey('No ID Item', $diff->modified); +} + +public function test_calculate_all_sections(): void +{ + $calc = new ConfigDiffCalculator(); + + $old = [ + 'tools' => [['id' => 'tool-1', 'name' => 'Tool']], + 'prompts' => [['id' => 'prompt-1', 'name' => 'Prompt']], + ]; + $new = [ + 'tools' => [['id' => 'tool-1', 'name' => 'Updated Tool']], + 'prompts' => [['id' => 'prompt-1', 'name' => 'Prompt']], // Unchanged + ]; + + $diffs = $calc->calculateAll($old, $new); + + $this->assertArrayHasKey('tools', $diffs); + $this->assertArrayNotHasKey('prompts', $diffs); // No changes, not included +} +``` + +--- + +## Acceptance Criteria + +- [ ] `ConfigDiff` correctly reports `hasChanges()` +- [ ] `ConfigDiff` provides accurate `changeCount()` +- [ ] `ConfigDiff::getSummary()` is human-readable +- [ ] `ConfigDiffCalculator` detects added items +- [ ] `ConfigDiffCalculator` detects removed items +- [ ] `ConfigDiffCalculator` detects modified items +- [ ] `ConfigDiffCalculator` ignores key ordering +- [ ] `ConfigDiffCalculator` handles missing IDs +- [ ] `calculateAll()` processes all sections + +--- + +## Notes + +- Keep comparison logic simple - deep equality is sufficient +- Consider adding `getModifiedFields(id)` for detailed change info (future) +- `unchanged` field useful for debugging but not strictly necessary +- Documents section uses `description` as ID since they don't have explicit IDs diff --git a/docs/hot-reload/stage-4-change-handlers.md b/docs/hot-reload/stage-4-change-handlers.md new file mode 100644 index 00000000..56a47dc4 --- /dev/null +++ b/docs/hot-reload/stage-4-change-handlers.md @@ -0,0 +1,630 @@ +# Stage 4: Change Handlers + +## Objective + +Implement handlers that process configuration changes for specific sections (tools, prompts) and update the appropriate registries. + +--- + +## Problem + +When configuration changes are detected, we need to: +1. Parse the changed configuration section +2. Update the appropriate registry (add/remove/modify items) +3. Trigger MCP notifications to inform connected clients + +Each section (tools, prompts, resources) has different: +- Data structures +- Registries +- Parser plugins + +--- + +## Solution + +Create a handler interface and implementations for each configuration section that can process changes and update registries. + +--- + +## Files to Create + +### Directory Structure + +``` +src/Watcher/ +└── Handler/ + ├── ChangeHandlerInterface.php + ├── ToolsChangeHandler.php + └── PromptsChangeHandler.php +``` + +--- + +## Interface Definition + +### `src/Watcher/Handler/ChangeHandlerInterface.php` + +```php +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); + } + + // Emit MCP notification + $this->notifyListChanged(); + + return true; + } + + public function reload(array $items): bool + { + $this->logger->info('Full tool reload', [ + 'count' => count($items), + ]); + + // Clear existing tools + $this->toolRegistry->clear(); + + // Register all tools + foreach ($items as $toolConfig) { + $this->addTool($toolConfig); + } + + // Emit MCP notification + $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 + { + // Emit event that Protocol listens to + $this->mcpRegistry->emit('list_changed', ['tools']); + + $this->logger->debug('Emitted tools list_changed notification'); + } +} +``` + +### 2. `src/Watcher/Handler/PromptsChangeHandler.php` + +Handles changes to the `prompts` section: + +```php +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 + foreach ($diff->modified as $id => $promptConfig) { + $this->removePrompt($id); + $this->addPrompt($promptConfig); + } + + // Emit MCP notification + $this->notifyListChanged(); + + return true; + } + + public function reload(array $items): bool + { + $this->logger->info('Full prompt reload', [ + 'count' => count($items), + ]); + + // Clear existing prompts + $this->promptRegistry->clear(); + + // Register all prompts + foreach ($items as $promptConfig) { + $this->addPrompt($promptConfig); + } + + // Emit MCP notification + $this->notifyListChanged(); + + return true; + } + + private function addPrompt(array $config): void + { + try { + $prompt = $this->promptFactory->createFromArray($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'); + } +} +``` + +--- + +## Handler Registry + +For managing multiple handlers, create a simple registry: + +### `src/Watcher/Handler/ChangeHandlerRegistry.php` + +```php + */ + 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); + } +} +``` + +--- + +## Integration with MCP Registry + +The handlers need to emit events that the MCP `Protocol` listens to. The existing infrastructure in `llm/mcp-server` already supports this: + +```php +// vendor/llm/mcp-server/src/Protocol.php +$this->registry->on('list_changed', function (string $listType): void { + $this->handleListChanged($listType); +}); +``` + +When handler calls: +```php +$this->mcpRegistry->emit('list_changed', ['tools']); +``` + +Protocol sends `ToolListChangedNotification` to all subscribed clients. + +--- + +## Implementation Details + +### Error Handling + +Handlers should be resilient to individual item failures: + +```php +foreach ($diff->added as $id => $config) { + try { + $this->addTool($config); + } catch (\Throwable $e) { + // Log error but continue with other items + $this->logger->error('Failed to add tool', [...]); + } +} +``` + +### Order of Operations + +Always process changes in this order: +1. **Removals** - Remove old items first +2. **Additions** - Add new items +3. **Modifications** - Implemented as remove + add + +This ensures no ID conflicts during updates. + +### Notification Batching + +Single notification is sent after all changes are applied, not per-item. This prevents notification spam. + +--- + +## Testing + +### Unit Tests for ToolsChangeHandler + +```php +public function test_apply_adds_new_tools(): void +{ + $registry = new ToolRegistry(); + $mcpRegistry = $this->createMock(ReferenceRegistryInterface::class); + + $mcpRegistry->expects($this->once()) + ->method('emit') + ->with('list_changed', ['tools']); + + $handler = new ToolsChangeHandler($registry, $mcpRegistry); + + $diff = new ConfigDiff( + added: [ + 'new-tool' => [ + 'id' => 'new-tool', + 'name' => 'new-tool', + 'description' => 'A new tool', + 'command' => ['name' => 'echo', 'args' => ['hello']], + ], + ], + ); + + $result = $handler->apply($diff); + + $this->assertTrue($result); + $this->assertTrue($registry->has('new-tool')); +} + +public function test_apply_removes_tools(): void +{ + $registry = new ToolRegistry(); + $mcpRegistry = $this->createMock(ReferenceRegistryInterface::class); + + // Pre-register a tool + $registry->register(ToolDefinition::fromArray([ + 'id' => 'old-tool', + 'name' => 'old-tool', + 'description' => 'Old tool', + 'command' => ['name' => 'echo'], + ])); + + $handler = new ToolsChangeHandler($registry, $mcpRegistry); + + $diff = new ConfigDiff( + removed: ['old-tool' => ['id' => 'old-tool']], + ); + + $result = $handler->apply($diff); + + $this->assertTrue($result); + $this->assertFalse($registry->has('old-tool')); +} + +public function test_apply_modifies_tools(): void +{ + $registry = new ToolRegistry(); + $mcpRegistry = $this->createMock(ReferenceRegistryInterface::class); + + // Pre-register a tool + $registry->register(ToolDefinition::fromArray([ + 'id' => 'my-tool', + 'name' => 'my-tool', + 'description' => 'Original description', + 'command' => ['name' => 'echo'], + ])); + + $handler = new ToolsChangeHandler($registry, $mcpRegistry); + + $diff = new ConfigDiff( + modified: [ + 'my-tool' => [ + 'id' => 'my-tool', + 'name' => 'my-tool', + 'description' => 'Updated description', + 'command' => ['name' => 'echo'], + ], + ], + ); + + $handler->apply($diff); + + $tool = $registry->get('my-tool'); + $this->assertEquals('Updated description', $tool->description); +} + +public function test_apply_returns_false_for_no_changes(): void +{ + $registry = new ToolRegistry(); + $mcpRegistry = $this->createMock(ReferenceRegistryInterface::class); + + $mcpRegistry->expects($this->never())->method('emit'); + + $handler = new ToolsChangeHandler($registry, $mcpRegistry); + + $diff = ConfigDiff::empty(); + + $result = $handler->apply($diff); + + $this->assertFalse($result); +} + +public function test_reload_clears_and_registers(): void +{ + $registry = new ToolRegistry(); + $mcpRegistry = $this->createMock(ReferenceRegistryInterface::class); + + // Pre-register tools + $registry->register(ToolDefinition::fromArray([ + 'id' => 'old-tool', + 'name' => 'old-tool', + 'description' => 'Will be removed', + 'command' => ['name' => 'echo'], + ])); + + $handler = new ToolsChangeHandler($registry, $mcpRegistry); + + $handler->reload([ + [ + 'id' => 'new-tool', + 'name' => 'new-tool', + 'description' => 'New tool', + 'command' => ['name' => 'echo'], + ], + ]); + + $this->assertFalse($registry->has('old-tool')); + $this->assertTrue($registry->has('new-tool')); +} +``` + +### Unit Tests for PromptsChangeHandler + +Similar pattern to ToolsChangeHandler tests. + +### Unit Tests for ChangeHandlerRegistry + +```php +public function test_registers_and_retrieves_handler(): void +{ + $registry = new ChangeHandlerRegistry(); + $handler = $this->createMock(ChangeHandlerInterface::class); + $handler->method('getSection')->willReturn('tools'); + + $registry->register($handler); + + $this->assertTrue($registry->has('tools')); + $this->assertSame($handler, $registry->get('tools')); +} + +public function test_returns_null_for_unknown_section(): void +{ + $registry = new ChangeHandlerRegistry(); + + $this->assertNull($registry->get('unknown')); +} + +public function test_lists_supported_sections(): void +{ + $registry = new ChangeHandlerRegistry(); + + $toolsHandler = $this->createMock(ChangeHandlerInterface::class); + $toolsHandler->method('getSection')->willReturn('tools'); + + $promptsHandler = $this->createMock(ChangeHandlerInterface::class); + $promptsHandler->method('getSection')->willReturn('prompts'); + + $registry->register($toolsHandler); + $registry->register($promptsHandler); + + $sections = $registry->getSupportedSections(); + + $this->assertContains('tools', $sections); + $this->assertContains('prompts', $sections); +} +``` + +--- + +## Acceptance Criteria + +- [ ] `ToolsChangeHandler` adds new tools from diff +- [ ] `ToolsChangeHandler` removes deleted tools from diff +- [ ] `ToolsChangeHandler` updates modified tools +- [ ] `ToolsChangeHandler` emits `list_changed` notification +- [ ] `ToolsChangeHandler::reload()` clears and re-registers all +- [ ] `PromptsChangeHandler` works same as ToolsChangeHandler +- [ ] `ChangeHandlerRegistry` manages multiple handlers +- [ ] Handlers continue processing on individual item errors +- [ ] Single notification sent per apply/reload (not per item) + +--- + +## Notes + +- Documents/resources handler can be added later if needed +- Consider adding metrics (items added/removed count) +- Handler could validate config before applying (future enhancement) +- May need to update MCP Registry (`Mcp\Server\Registry`) tools list too diff --git a/docs/hot-reload/stage-5-config-watcher.md b/docs/hot-reload/stage-5-config-watcher.md new file mode 100644 index 00000000..7920c9cb --- /dev/null +++ b/docs/hot-reload/stage-5-config-watcher.md @@ -0,0 +1,528 @@ +# Stage 5: Config Watcher Orchestra- `src/Watcher/Diff/ConfigDiff.php` +- `src/Watcher/Diff/ConfigDiffCalculator.php` + +### Stage 4 +- `src/Watcher/Handler/ChangeHandlerInterface.php` +- `src/Watcher/Handler/ToolsChangeHandler.php` +- `src/Watcher/Handler/PromptsChangeHandler.php` + +### Stage 5 +- `src/Watcher/ConfigWatcherInterface.php` +- `src/Watcher/ConfigWatcher.php` +- `src/Watcher/ConfigWatcherBootloader.php` + +### Stage 6 +- `src/Transport/StdioTransport.php` (modify)tor + +## Objective + +Create the main `ConfigWatcher` class that orchestrates file watching, change detection, and handler execution with proper debouncing and error handling. + +--- + +## Problem + +We have individual components: +- File watch strategies (Stage 2) +- Diff calculator (Stage 3) +- Change handlers (Stage 4) + +Now we need an orchestrator that: +1. Manages the watch strategy lifecycle +2. Tracks main config and imported files +3. Debounces rapid file changes +4. Loads and parses config when changes detected +5. Calculates diffs and delegates to handlers +6. Handles errors gracefully + +--- + +## Solution + +Create a `ConfigWatcher` that ties all components together with a simple `tick()` method that can be called from the event loop. + +--- + +## Files to Create + +### Directory Structure + +``` +src/Watcher/ +├── ConfigWatcherInterface.php +├── ConfigWatcher.php +├── ConfigLoaderFactory.php +└── ConfigWatcherBootloader.php +``` + +--- + +## Interface Definition + +### `src/Watcher/ConfigWatcherInterface.php` + +```php + $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. + */ + public function updateImports(array $importPaths): void; +} +``` + +--- + +## Main Implementation + +### `src/Watcher/ConfigWatcher.php` + +```php +enabled) { + $this->logger->info('Config watcher is disabled'); + 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 (!empty($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 (empty($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(), + ]); + } + } + + private function loadConfig(): ?array + { + if ($this->mainConfigPath === null) { + return null; + } + + try { + $loader = $this->configLoaderFactory->create($this->mainConfigPath); + return $loader->loadRawConfig(); + } catch (\Throwable $e) { + $this->logger->error('Config load error', [ + 'error' => $e->getMessage(), + ]); + return null; + } + } + + private function loadCurrentConfig(): void + { + $config = $this->loadConfig(); + if ($config !== null) { + $this->lastConfig = $config; + } + } +} +``` + +--- + +## Config Loader Factory + +### `src/Watcher/ConfigLoaderFactory.php` + +```php + ConfigWatcher::class, + WatchStrategyFactory::class => WatchStrategyFactory::class, + ConfigDiffCalculator::class => ConfigDiffCalculator::class, + ChangeHandlerRegistry::class => ChangeHandlerRegistry::class, + ]; + } + + public function boot( + ChangeHandlerRegistry $registry, + ToolsChangeHandler $toolsHandler, + PromptsChangeHandler $promptsHandler, + ): void { + $registry->register($toolsHandler); + $registry->register($promptsHandler); + } +} +``` + +--- + +## Configuration Options + +| Variable | Default | Description | +|----------|---------|-------------| +| `MCP_HOT_RELOAD` | `true` | Enable/disable hot-reload | +| `MCP_HOT_RELOAD_INTERVAL` | `2000` | Polling interval in ms | +| `MCP_HOT_RELOAD_DEBOUNCE` | `200` | Debounce delay in ms | + +--- + +## Debouncing Logic + +File editors often trigger multiple write events: + +``` +[0ms] Change detected → start debounce timer +[5ms] Change detected → reset timer +[10ms] Change detected → reset timer +[210ms] Timer expires → process once +``` + +--- + +## Error Recovery + +The watcher is designed to be resilient: + +1. **Invalid YAML**: Keeps previous valid config +2. **Handler failure**: Continues with other handlers +3. **File deleted**: Logs and continues watching +4. **Permission errors**: Logs and continues + +--- + +## Testing + +### Unit Tests + +```php +public function test_start_initializes_strategy(): void +{ + $factory = $this->createMock(WatchStrategyFactory::class); + $strategy = $this->createMock(WatchStrategyInterface::class); + + $factory->expects($this->once()) + ->method('create') + ->willReturn($strategy); + + $watcher = new ConfigWatcher( + $factory, + new ConfigDiffCalculator(), + new ChangeHandlerRegistry(), + $this->createMock(ConfigLoaderFactory::class), + ); + + $watcher->start('/path/to/context.yaml'); + + $this->assertTrue($watcher->isWatching()); +} + +public function test_tick_debounces_rapid_changes(): void +{ + $strategy = $this->createMock(WatchStrategyInterface::class); + $strategy->method('check')->willReturn(['/config.yaml']); + + $loaderFactory = $this->createMock(ConfigLoaderFactory::class); + $loaderFactory->expects($this->once())->method('create'); // Only once! + + $watcher = $this->createWatcher($strategy, $loaderFactory); + $watcher->start('/config.yaml'); + + $watcher->tick(); // Start debounce + $watcher->tick(); // Still debouncing + + usleep(250_000); // Wait for debounce + + $watcher->tick(); // Now processes +} + +public function test_handles_config_load_error(): void +{ + $strategy = $this->createMock(WatchStrategyInterface::class); + $strategy->method('check')->willReturn(['/config.yaml']); + + $loaderFactory = $this->createMock(ConfigLoaderFactory::class); + $loaderFactory->method('create') + ->willThrowException(new \RuntimeException('Parse error')); + + $watcher = $this->createWatcher($strategy, $loaderFactory); + $watcher->start('/config.yaml'); + + usleep(250_000); + $watcher->tick(); + $watcher->tick(); + + // Should not throw, still watching + $this->assertTrue($watcher->isWatching()); +} + +public function test_disabled_watcher_does_nothing(): void +{ + $factory = $this->createMock(WatchStrategyFactory::class); + $factory->expects($this->never())->method('create'); + + $watcher = new ConfigWatcher( + $factory, + new ConfigDiffCalculator(), + new ChangeHandlerRegistry(), + $this->createMock(ConfigLoaderFactory::class), + enabled: false, + ); + + $watcher->start('/config.yaml'); + + $this->assertFalse($watcher->isWatching()); +} +``` + +--- + +## Acceptance Criteria + +- [ ] `ConfigWatcher::start()` initializes file watching +- [ ] `ConfigWatcher::tick()` is non-blocking (< 1ms) +- [ ] `ConfigWatcher::tick()` detects file changes +- [ ] Debouncing prevents multiple reloads +- [ ] Config load errors don't crash the watcher +- [ ] Handler errors don't stop other handlers +- [ ] `ConfigWatcher::stop()` releases all resources +- [ ] Disabled watcher consumes no resources +- [ ] Import file list updates dynamically +- [ ] Bootloader registers all components + +--- + +## Notes + +- `tick()` must complete in < 1ms for responsive server +- Consider adding reload statistics for monitoring +- The `ConfigLoaderFactory` allows testing with mocks diff --git a/docs/hot-reload/stage-6-transport-integration.md b/docs/hot-reload/stage-6-transport-integration.md new file mode 100644 index 00000000..2f5086c5 --- /dev/null +++ b/docs/hot-reload/stage-6-transport-integration.md @@ -0,0 +1,534 @@ +# Stage 6: Transport Integration + +## Objective + +Integrate the `ConfigWatcher` into the MCP server's event loop so that configuration changes are automatically detected and processed during server operation. + +--- + +## Problem + +The `ConfigWatcher` has a `tick()` method that needs to be called periodically. The MCP server uses `StdioTransport` which runs a blocking event loop. We need to: + +1. Inject `ConfigWatcher` into the server startup flow +2. Call `tick()` during idle periods in the transport loop +3. Initialize the watcher with correct config paths +4. Clean up on server shutdown + +--- + +## Solution + +Modify the transport and server runner to integrate the config watcher into the existing event loop. + +--- + +## Files to Modify + +### 1. `src/Transport/StdioTransport.php` + +### 2. `src/ServerRunner.php` + +--- + +## Implementation + +### 1. StdioTransport Modification + +Add watcher tick to the event loop: + +```php +configWatcher = $watcher; + } + + public function listen(): void + { + // ... existing initialization code ... + + while (!\feof($this->input) && !$this->closing) { + $read = [$this->input]; + $write = null; + $except = null; + + // Short timeout to check watcher periodically + $result = @\stream_select($read, $write, $except, 1); + + if ($result === false) { + $this->logger->error('stream_select() failed'); + break; + } + + if ($result === 0) { + // Timeout - no data available + // Perfect time to check for config changes! + $this->tickConfigWatcher(); + continue; + } + + // ... existing message processing code ... + + $chunk = \fread($this->input, self::READ_CHUNK_SIZE); + + // ... rest of existing code ... + } + + $this->logger->info('StdioTransport finished listening.'); + } + + /** + * Non-blocking config watcher tick. + */ + private function tickConfigWatcher(): void + { + if ($this->configWatcher !== null) { + try { + $this->configWatcher->tick(); + } catch (\Throwable $e) { + $this->logger->error('Config watcher error', [ + 'error' => $e->getMessage(), + ]); + } + } + } + + public function close(): void + { + // Stop config watcher on transport close + if ($this->configWatcher !== null) { + $this->configWatcher->stop(); + $this->configWatcher = null; + } + + // ... existing close code ... + } + + // ... rest of existing methods ... +} +``` + +### 2. ServerRunner Modification + +Initialize watcher during server startup: + +```php +actions[] = $class; + } + + public function run(string $name): void + { + $this->scope->runScope( + bindings: new Scope(name: 'mcp-server'), + scope: function ( + RouteRegistrar $registrar, + McpItemsRegistry $registry, + ExceptionReporterInterface $reporter, + Server $server, + ServerTransportInterface $transport, + ConfigWatcherInterface $configWatcher, + ConfigWatcherConfig $watcherConfig, // New: holds paths + ) use ($name): void { + // Register all classes with MCP item attributes + $registry->registerMany($this->actions); + + // Register all controllers for routing + $registrar->registerControllers($this->actions); + + // Initialize config watcher with paths + $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(); + } + }, + ); + } +} +``` + +### 3. ConfigWatcherConfig DTO + +Create a simple config holder: + +```php + function ( + McpConfig $config, + ServerRunner $factory, + ConfigLoaderInterface $loader, + DirectoriesInterface $dirs, + ) { + // Load config and track import paths + $registry = $loader->load(); + + // Get import paths from resolved config + $importPaths = $this->extractImportPaths($loader); + + foreach ($this->actions($config) as $action) { + $factory->registerAction($action); + } + + return $factory; + }, + + // Provide watcher config + ConfigWatcherConfig::class => function ( + DirectoriesInterface $dirs, + ConfigLoaderInterface $loader, + ) { + $mainConfigPath = (string) $dirs->getConfigPath(); + $importPaths = $this->extractImportPaths($loader); + + return new ConfigWatcherConfig( + mainConfigPath: $mainConfigPath, + importPaths: $importPaths, + ); + }, + ]; +} + +private function extractImportPaths(ConfigLoaderInterface $loader): array +{ + // This requires exposing import paths from the loader + // May need modification to ConfigLoader/ImportResolver + return []; +} +``` + +--- + +## Import Path Extraction + +To get the list of imported config files, we need to expose them from `ImportResolver`. Add to `ResolvedConfig`: + +```php +// src/Config/Import/ResolvedConfig.php + +final readonly class ResolvedConfig +{ + public function __construct( + public array $config, + public array $imports = [], // SourceConfigInterface[] + ) {} + + /** + * Get absolute paths of all imported files. + * + * @return string[] + */ + public function getImportPaths(): array + { + $paths = []; + + foreach ($this->imports as $import) { + if ($import instanceof LocalSourceConfig) { + $paths[] = $import->getAbsolutePath(); + } + } + + return $paths; + } +} +``` + +--- + +## Alternative: Transport-Agnostic Approach + +If modifying `StdioTransport` is not ideal (it's in the shared package), use an event-based approach: + +### Using Timer Events + +```php +// In ServerRunner +$timer = new PeriodicTimer(1.0, function () use ($configWatcher) { + $configWatcher->tick(); +}); + +$transport->on('ready', function () use ($timer) { + $timer->start(); +}); + +$transport->on('close', function () use ($timer, $configWatcher) { + $timer->stop(); + $configWatcher->stop(); +}); +``` + +However, this requires a timer implementation compatible with the blocking loop. + +### Recommended Approach + +The simplest and most reliable approach is the direct integration shown above: +1. Add `setConfigWatcher()` to `StdioTransport` +2. Call `tick()` during stream_select timeout +3. This leverages the existing 1-second timeout for zero additional overhead + +--- + +## Sequence Diagram + +``` +┌─────────┐ ┌──────────────┐ ┌───────────────┐ ┌─────────┐ +│ Client │ │StdioTransport│ │ ConfigWatcher │ │ Handler │ +└────┬────┘ └──────┬───────┘ └───────┬───────┘ └────┬────┘ + │ │ │ │ + │ │──── listen() ────────│ │ + │ │ │ │ + │ │◄─── stream_select ───│ │ + │ │ (timeout=1s) │ │ + │ │ │ │ + │ │ [timeout, no data] │ │ + │ │ │ │ + │ │───── tick() ─────────► │ + │ │ │ │ + │ │ │─── check() ──────│ + │ │ │ (file mtime) │ + │ │ │ │ + │ │ │ [changes found] │ + │ │ │ │ + │ │ │── apply(diff) ──►│ + │ │ │ │ + │ │ │ │── update registry + │ │ │ │ + │ │ │ │── emit('list_changed') + │ │ │◄─────────────────│ + │ │ │ │ + │◄──────────────────────────────────────────────────────────│ + │ ToolListChangedNotification │ + │ │ │ │ +``` + +--- + +## Testing + +### Integration Test with MCP Inspector + +```bash +# Terminal 1: Start server with hot-reload +MCP_HOT_RELOAD=true ctx server + +# Terminal 2: Connect MCP Inspector +npx @modelcontextprotocol/inspector + +# Terminal 3: Modify config +cat >> context.yaml << 'EOF' + - id: hot-reload-test + name: hot-reload-test + description: Testing hot reload + command: + name: echo + args: ["Hot reload works!"] +EOF + +# Observe in Inspector: +# 1. ToolListChangedNotification received +# 2. tools/list shows new tool +``` + +### Unit Tests + +```php +public function test_transport_calls_watcher_on_timeout(): void +{ + $watcher = $this->createMock(ConfigWatcherInterface::class); + $watcher->expects($this->atLeastOnce())->method('tick'); + + $transport = new StdioTransport( + input: $this->createMockInputStream(), + output: fopen('php://memory', 'w'), + ); + $transport->setConfigWatcher($watcher); + + // Simulate short listen with timeout + // This is tricky to test - may need to mock stream_select +} + +public function test_watcher_stopped_on_transport_close(): void +{ + $watcher = $this->createMock(ConfigWatcherInterface::class); + $watcher->expects($this->once())->method('stop'); + + $transport = new StdioTransport(); + $transport->setConfigWatcher($watcher); + + $transport->close(); +} + +public function test_server_initializes_watcher(): void +{ + $watcher = $this->createMock(ConfigWatcherInterface::class); + $watcher->expects($this->once()) + ->method('start') + ->with('/path/to/context.yaml', []); + + // Test via ServerRunner with mocked dependencies +} +``` + +### E2E Test + +```php +public function test_hot_reload_updates_tools(): void +{ + // 1. Start server with test config + // 2. Connect test client + // 3. Verify initial tools + // 4. Modify config file + // 5. Wait for notification + // 6. Verify new tools available + // 7. Clean up +} +``` + +--- + +## Acceptance Criteria + +- [ ] `StdioTransport` calls `tick()` during idle periods +- [ ] Watcher initialized with correct config paths +- [ ] Watcher stopped on transport close +- [ ] No performance impact (tick < 1ms) +- [ ] MCP Inspector receives `ToolListChangedNotification` +- [ ] New tools available after config change +- [ ] Server continues working if watcher fails +- [ ] Works with both inotify and polling strategies + +--- + +## Rollout Plan + +1. **Phase 1**: Deploy with `MCP_HOT_RELOAD=false` by default +2. **Phase 2**: Enable for beta users +3. **Phase 3**: Enable by default after validation + +--- + +## Monitoring + +Add metrics for observability: + +```php +// In ConfigWatcher +private int $reloadCount = 0; +private float $lastReloadTime = 0; + +public function getStats(): array +{ + return [ + 'reload_count' => $this->reloadCount, + 'last_reload_time' => $this->lastReloadTime, + 'watching' => $this->isWatching(), + 'files_watched' => count($this->importPaths) + 1, + ]; +} +``` + +Expose via MCP resource or logging. + +--- + +## Notes + +- The 1-second stream_select timeout is already present - no additional delay +- Hot-reload adds ~0.1ms overhead per tick (polling check) +- Consider adding a health check endpoint for monitoring +- Future: HTTP/SSE transport can use native file watching events diff --git a/src/McpServer/ActionsBootloader.php b/src/McpServer/ActionsBootloader.php index 93f72a8d..9807dcc5 100644 --- a/src/McpServer/ActionsBootloader.php +++ b/src/McpServer/ActionsBootloader.php @@ -53,6 +53,8 @@ use Butschster\ContextGenerator\Research\MCP\Tools\ReadEntryToolAction; use Butschster\ContextGenerator\Research\MCP\Tools\UpdateEntryToolAction; use Butschster\ContextGenerator\Research\MCP\Tools\UpdateResearchToolAction; +use Butschster\ContextGenerator\McpServer\Watcher\ConfigWatcherConfig; +use Butschster\ContextGenerator\McpServer\Watcher\ConfigWatcherConfigFactory; use Spiral\Boot\Bootloader\Bootloader; use Spiral\Boot\EnvironmentInterface; use Spiral\Config\ConfiguratorInterface; @@ -145,6 +147,12 @@ public function defineSingletons(): array return $factory; }, + + ConfigWatcherConfigFactory::class => ConfigWatcherConfigFactory::class, + + ConfigWatcherConfig::class => static fn( + ConfigWatcherConfigFactory $factory, + ): ConfigWatcherConfig => $factory->create(), ]; } From ca5228c74533c3a75f8f59ba630ad81781516e5c Mon Sep 17 00:00:00 2001 From: butschster Date: Sun, 18 Jan 2026 11:26:13 +0400 Subject: [PATCH 2/2] chore: bump ctx/mcp-server to ^1.6.0 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a8244d9b..a80d48e2 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "ext-posix": "*", "ctx/module-research": "^1.0", "ctx/module-config-templates": "^1.0", - "ctx/mcp-server": "^1.4.0", + "ctx/mcp-server": "^1.6.0", "league/html-to-markdown": "^5.1", "php-mcp/schema": "dev-main as 1.0", "psr/log": "^3.0",