From 68af2743ec696d7581594e1221e87019fc7bab85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20S=C3=A1nchez?= <34519027+fsmonter@users.noreply.github.com> Date: Fri, 29 Aug 2025 18:07:47 +0100 Subject: [PATCH] Implement list changed notification and dynamic tool management --- README.md | 46 +++++- src/Commands/LoopMcpServerStartCommand.php | 17 ++ src/Loop.php | 20 +++ src/LoopTools.php | 75 ++++++++- src/McpHandler.php | 19 ++- tests/Unit/LoopDynamicToolsTest.php | 184 +++++++++++++++++++++ 6 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/LoopDynamicToolsTest.php diff --git a/README.md b/README.md index d07679f..ce02832 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ Be aware that if you are exposing your endpoint publicly, you are exposing your 'streamable_http' => [ 'middleware' => ['auth:sanctum'], ], - + 'sse' => [ 'middleware' => ['auth:sanctum'], ], @@ -265,6 +265,50 @@ Please note that not all clients support direct SSE connections. For those situa } ``` +## Dynamic Tools + +Laravel Loop supports registering and removing tools during STDIO sessions. When tools are added or removed, MCP clients automatically receive `tools/list_changed` notifications. + +**Important:** This feature is only available for the STDIO transport. + +### Example Usage in STDIO + +```php +// Tools can be dynamically managed during STDIO session +// Clients will receive notifications automatically +Loop::tool(new CustomTool()); +Loop::removeTool('tool-name'); +``` + +#### Adding Tools Dynamically + +```php +// Add a single tool at runtime +Loop::tool(new MyCustomTool()); + +// Method chaining is supported +Loop::tool(new ToolOne()) + ->tool(new ToolTwo()); +``` + +#### Removing Tools Dynamically + +```php +// Remove a tool by name +Loop::removeTool('my-custom-tool'); + +// Method chaining is supported +Loop::removeTool('tool-one') + ->removeTool('tool-two'); +``` + +#### Clearing All Tools + +```php +// Remove all registered tools and toolkits +Loop::clear(); +``` + *** ## Troubleshooting diff --git a/src/Commands/LoopMcpServerStartCommand.php b/src/Commands/LoopMcpServerStartCommand.php index bf340fc..2217f71 100644 --- a/src/Commands/LoopMcpServerStartCommand.php +++ b/src/Commands/LoopMcpServerStartCommand.php @@ -5,6 +5,7 @@ use Illuminate\Console\Command; use Kirschbaum\Loop\Commands\Concerns\AuthenticateUsers; use Kirschbaum\Loop\Enums\ErrorCode; +use Kirschbaum\Loop\LoopTools; use Kirschbaum\Loop\McpHandler; use React\EventLoop\Loop; use React\Stream\ReadableResourceStream; @@ -52,6 +53,8 @@ public function handle(McpHandler $mcpHandler): int $this->processData($data); }); + $this->registerToolChangeCallback(); + if ($this->option('debug')) { $this->debug('Laravel Loop MCP server running. Press Ctrl+C or send SIGTERM to stop.'); } @@ -134,6 +137,20 @@ protected function processData(string $data): void } } + protected function registerToolChangeCallback(): void + { + $loopTools = app(LoopTools::class); + + $loopTools->onToolsChanged(function () { + if ($this->option('debug')) { + $this->debug('Tools list changed, sending notification'); + } + + $notification = $this->mcpHandler->createToolsChangedNotification(); + $this->stdout->write(json_encode($notification).PHP_EOL); + }); + } + protected function debug(string $message): void { $this->getOutput()->getOutput()->getErrorOutput()->writeln($message); // @phpstan-ignore method.notFound diff --git a/src/Loop.php b/src/Loop.php index e907b39..c57c466 100644 --- a/src/Loop.php +++ b/src/Loop.php @@ -51,4 +51,24 @@ public function getPrismTool(string $name): PrismTool ->getTool($name) ->build(); } + + /** + * Remove a tool dynamically (persists only within STDIO session) + */ + public function removeTool(string $name): static + { + $this->loopTools->removeTool($name); + + return $this; + } + + /** + * Clear all tools and toolkits + */ + public function clear(): static + { + $this->loopTools->clear(); + + return $this; + } } diff --git a/src/LoopTools.php b/src/LoopTools.php index 7b5e55b..43c7de0 100644 --- a/src/LoopTools.php +++ b/src/LoopTools.php @@ -13,14 +13,54 @@ class LoopTools /** @var array */ protected array $toolkits = []; + /** @var callable|null */ + protected $changeCallback = null; + + protected ?string $toolsHash = null; + public function __construct() { $this->tools = new ToolCollection; } + /** + * Register a tool if not already present (prevents duplicates) + */ public function registerTool(Tool $tool): void { - $this->tools->push($tool); + $toolName = $tool->getName(); + + if (! $this->tools->contains(function ($existingTool) use ($toolName) { + return $existingTool->getName() === $toolName; + })) { + $this->tools->push($tool); + } + + $this->notifyIfChanged(); + } + + /** + * Remove a tool by name + */ + public function removeTool(string $name): void + { + $originalCount = $this->tools->count(); + + $this->tools = $this->tools->reject(function ($tool) use ($name) { + return $tool->getName() === $name; + }); + + if ($this->tools->count() !== $originalCount) { + $this->notifyIfChanged(); + } + } + + /** + * Register a callback to be called when tools change + */ + public function onToolsChanged(callable $callback): void + { + $this->changeCallback = $callback; } public function registerToolkit(Toolkit $toolkit): void @@ -57,5 +97,38 @@ public function clear(): void { $this->tools = new ToolCollection; $this->toolkits = []; + $this->notifyIfChanged(); + } + + /** + * Check if tools have changed and notify callback if so + */ + protected function notifyIfChanged(): void + { + $currentHash = $this->computeToolsHash(); + + if ($currentHash !== $this->toolsHash) { + $this->toolsHash = $currentHash; + + if ($this->changeCallback) { + ($this->changeCallback)(); + } + } + } + + /** + * Compute a hash of the current tools for change detection + */ + protected function computeToolsHash(): string + { + $toolNames = $this->tools + ->map(fn ($tool) => $tool->getName()) + ->sort() + ->values() + ->toArray(); + + $json = json_encode($toolNames); + + return md5($json !== false ? $json : '[]'); } } diff --git a/src/McpHandler.php b/src/McpHandler.php index 46e6190..c939115 100644 --- a/src/McpHandler.php +++ b/src/McpHandler.php @@ -55,7 +55,9 @@ public function __construct( ]; $this->serverCapabilities = $this->config['capabilities'] ?? [ - 'tools' => $this->listTools(), + 'tools' => [ + 'listChanged' => true, + ], ]; } @@ -108,7 +110,7 @@ public function listTools(): array 'additionalProperties' => false, ], ]; - })->toArray(), + })->values()->toArray(), ]; } @@ -171,6 +173,19 @@ public function ping(): array return []; } + /** + * Create a tools/list_changed notification + * + * @return array + */ + public function createToolsChangedNotification(): array + { + return [ + 'jsonrpc' => '2.0', + 'method' => 'notifications/tools/list_changed', + ]; + } + public function handle(array $message): array { if (! isset($message['jsonrpc']) || $message['jsonrpc'] !== '2.0') { diff --git a/tests/Unit/LoopDynamicToolsTest.php b/tests/Unit/LoopDynamicToolsTest.php new file mode 100644 index 0000000..a99fe5e --- /dev/null +++ b/tests/Unit/LoopDynamicToolsTest.php @@ -0,0 +1,184 @@ +forgetInstance(Loop::class); +}); + +it('can add tools dynamically', function () { + $loop = app(Loop::class); + $tool = CustomTool::make('test-tool', 'Test tool'); + + $result = $loop->tool($tool); + + expect($result) + ->toBe($loop) + ->and($loop->getPrismTools()) + ->toHaveCount(1); +}); + +it('can remove tools dynamically', function () { + $loop = app(Loop::class); + $tool = CustomTool::make('test-tool', 'Test tool'); + + $loop->tool($tool); + expect($loop->getPrismTools())->toHaveCount(1); + + $result = $loop->removeTool('test-tool'); + + expect($result) + ->toBe($loop) + ->and($loop->getPrismTools()) + ->toHaveCount(0); +}); + +it('can clear all tools', function () { + $loop = app(Loop::class); + $tool1 = CustomTool::make('tool-1', 'Tool 1'); + $tool2 = CustomTool::make('tool-2', 'Tool 2'); + + $loop->tool($tool1)->tool($tool2); + expect($loop->getPrismTools())->toHaveCount(2); + + $result = $loop->clear(); + + expect($result) + ->toBe($loop) + ->and($loop->getPrismTools()) + ->toHaveCount(0); +}); + +it('supports method chaining', function () { + $loop = app(Loop::class); + $tool1 = CustomTool::make('tool-1', 'Tool 1'); + $tool2 = CustomTool::make('tool-2', 'Tool 2'); + + $result = $loop + ->tool($tool1) + ->tool($tool2) + ->removeTool('tool-1'); + + expect($result) + ->toBe($loop) + ->and($loop->getPrismTools()) + ->toHaveCount(1); +}); + +it('can register a callback for tool changes', function () { + $loopTools = new LoopTools; + $callbackCalled = false; + + $loopTools->onToolsChanged(function () use (&$callbackCalled) { + $callbackCalled = true; + }); + + $tool = CustomTool::make('test-tool', 'Test tool'); + + $loopTools->registerTool($tool); + + expect($callbackCalled)->toBeTrue(); +}); + +it('calls callback only when tools actually change', function () { + $loopTools = new LoopTools; + $callbackCount = 0; + + $loopTools->onToolsChanged(function () use (&$callbackCount) { + $callbackCount++; + }); + + $tool = CustomTool::make('test-tool', 'Test tool'); + + $loopTools->registerTool($tool); + expect($callbackCount)->toBe(1); + + $loopTools->registerTool($tool); + expect($callbackCount)->toBe(1); +}); + +it('calls callback when tool is removed', function () { + $loopTools = new LoopTools; + $callbackCalled = false; + + $tool = CustomTool::make('test-tool', 'Test tool'); + $loopTools->registerTool($tool); + + $loopTools->onToolsChanged(function () use (&$callbackCalled) { + $callbackCalled = true; + }); + + $loopTools->removeTool('test-tool'); + + expect($callbackCalled)->toBeTrue(); +}); + +it('does not call callback when removing non-existent tool', function () { + $loopTools = new LoopTools; + $callbackCalled = false; + + $loopTools->onToolsChanged(function () use (&$callbackCalled) { + $callbackCalled = true; + }); + + $loopTools->removeTool('non-existent-tool'); + + expect($callbackCalled)->toBeFalse(); +}); + +it('calls callback when clearing tools', function () { + $loopTools = new LoopTools; + $callbackCalled = false; + + $tool = CustomTool::make('test-tool', 'Test tool'); + $loopTools->registerTool($tool); + + $loopTools->onToolsChanged(function () use (&$callbackCalled) { + $callbackCalled = true; + }); + + $loopTools->clear(); + + expect($callbackCalled)->toBeTrue(); +}); + +it('calls callback when registering new tool', function () { + $loopTools = new LoopTools; + $callbackCalled = false; + + $loopTools->onToolsChanged(function () use (&$callbackCalled) { + $callbackCalled = true; + }); + + $tool = CustomTool::make('test-tool', 'Test tool'); + + $loopTools->registerTool($tool); + + expect($callbackCalled)->toBeTrue(); +}); + +it('uses hash-based change detection', function () { + $loopTools = new LoopTools; + $callbackCount = 0; + + $loopTools->onToolsChanged(function () use (&$callbackCount) { + $callbackCount++; + }); + + $tool1 = CustomTool::make('tool-1', 'Tool 1'); + $tool2 = CustomTool::make('tool-2', 'Tool 2'); + + $loopTools->registerTool($tool1); + expect($callbackCount)->toBe(1); + + $loopTools->registerTool($tool2); + expect($callbackCount)->toBe(2); + + $loopTools->removeTool('tool-1'); + expect($callbackCount)->toBe(3); + + $loopTools->removeTool('tool-2'); + expect($callbackCount)->toBe(4); +});