diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php index ae3899d60f..59c19889f5 100644 --- a/app/Mcp/Concerns/BuildsResponse.php +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -2,9 +2,11 @@ namespace App\Mcp\Concerns; +use App\Mcp\Servers\CoolifyServer; use App\Services\Security\SensitiveDataRedactor; use Laravel\Mcp\Request; use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Tool; trait BuildsResponse { @@ -128,6 +130,7 @@ trait BuildsResponse { $payload = ['data' => $data]; + $actions = $this->onlyVisibleTools($actions); if ($actions !== []) { $payload['_actions'] = $actions; } @@ -139,6 +142,28 @@ trait BuildsResponse return Response::json($payload); } + /** + * Removes suggestions for tools that the current token cannot see, so agents do not call hidden tools. + * + * @param array> $suggestions + * @return array> + */ + protected function onlyVisibleTools(array $suggestions): array + { + $names = array_column($suggestions, 'tool'); + if ($names === []) { + return $suggestions; + } + + $visible = collect(CoolifyServer::TOOLS) + ->map(fn (string $class): Tool => app($class)) + ->filter(fn (Tool $tool): bool => in_array($tool->name(), $names, true) && $tool->eligibleForRegistration()) + ->map(fn (Tool $tool): string => $tool->name()) + ->all(); + + return array_values(array_filter($suggestions, fn (array $suggestion): bool => in_array($suggestion['tool'] ?? null, $visible, true))); + } + /** * @return array{page:int, per_page:int, offset:int} */ diff --git a/app/Mcp/Tools/CoolifyHelp.php b/app/Mcp/Tools/CoolifyHelp.php index a3e4e09c36..effe2a4e50 100644 --- a/app/Mcp/Tools/CoolifyHelp.php +++ b/app/Mcp/Tools/CoolifyHelp.php @@ -120,7 +120,7 @@ class CoolifyHelp extends Tool 'topic' => $topic, 'description' => self::RECIPES[$topic]['description'], 'steps' => collect(self::RECIPES[$topic]['steps']) - ->map(fn (array $step): array => $step + ['available' => isset($tools[$step['tool']])]) + ->map(fn (array $step): array => $step + ['available' => $this->stepAvailable($step, $tools)]) ->all(), 'notes' => ['Steps with available=false need more token abilities.', 'Ask the user before every action that needs confirm=true.'], ])); @@ -153,7 +153,7 @@ class CoolifyHelp extends Tool /** * Registered tools that this token can see, keyed by name. * - * @return array + * @return array|null}> */ private function visibleTools(): array { @@ -163,12 +163,28 @@ class CoolifyHelp extends Tool ->mapWithKeys(fn (Tool $tool): array => [$tool->name() => [ 'summary' => Str::before($tool->description(), '. ').'.', 'read_only' => ($tool->annotations()['readOnlyHint'] ?? false) === true, + 'actions' => $tool instanceof RestActionTool ? $tool->availableActionNames() : null, ]]) ->all(); } /** - * @param array $tools + * A step is available when the token sees its tool and, for grouped tools, its action. + * + * @param array{tool: string, action?: string, note: string} $step + * @param array|null}> $tools + */ + private function stepAvailable(array $step, array $tools): bool + { + if (! isset($tools[$step['tool']])) { + return false; + } + + return ! isset($step['action']) || in_array($step['action'], $tools[$step['tool']]['actions'] ?? [], true); + } + + /** + * @param array|null}> $tools * @return array */ private function summaries(array $tools): array diff --git a/app/Mcp/Tools/GetLogs.php b/app/Mcp/Tools/GetLogs.php index 8bc84fb22a..3d4a431d69 100644 --- a/app/Mcp/Tools/GetLogs.php +++ b/app/Mcp/Tools/GetLogs.php @@ -206,6 +206,8 @@ class GetLogs extends Tool $next[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start if token has deploy ability']; } + $next = $this->onlyVisibleTools($next); + return [ 'ok' => false, 'reason' => $reason, diff --git a/app/Mcp/Tools/RestActionTool.php b/app/Mcp/Tools/RestActionTool.php index feccc6a864..6da98d05d8 100644 --- a/app/Mcp/Tools/RestActionTool.php +++ b/app/Mcp/Tools/RestActionTool.php @@ -57,6 +57,16 @@ abstract class RestActionTool extends Tool return array_filter($this->actions(), fn (array $action): bool => $this->currentTokenCan($action['ability'])); } + /** + * Names of the actions that the current token has the ability for. + * + * @return array + */ + public function availableActionNames(): array + { + return array_keys($this->availableActions()); + } + public function shouldRegister(): bool { return $this->availableActions() !== []; diff --git a/tests/Feature/Mcp/McpReadToolsTest.php b/tests/Feature/Mcp/McpReadToolsTest.php index 155941ffd7..eec03a226d 100644 --- a/tests/Feature/Mcp/McpReadToolsTest.php +++ b/tests/Feature/Mcp/McpReadToolsTest.php @@ -1650,7 +1650,20 @@ test('get_logs returns structured next_tools when application is not running', f expect($body['data']['ok'])->toBeFalse(); expect($body['data']['reason'])->toBe('not_running'); expect($body['data']['next_tools'])->not->toBeEmpty(); - expect(collect($body['data']['next_tools'])->pluck('tool'))->toContain('list_deployments', 'list_unhealthy_resources'); + expect(collect($body['data']['next_tools'])->pluck('tool'))->toContain('list_deployments', 'list_unhealthy_resources') + ->not->toContain('control'); +}); + +test('get_application suggests only tools that the token can see', function () { + $this->application->update(['status' => 'running:healthy']); + + $readTools = collect(mcpReadJson(mcpReadCall('get_application', ['uuid' => $this->application->uuid]))['_actions'])->pluck('tool'); + $sensitiveTools = collect(mcpReadJson(mcpSensitiveReadCall('get_application', ['uuid' => $this->application->uuid]))['_actions'])->pluck('tool'); + + expect($readTools)->toContain('get_application', 'list_deployments') + ->not->toContain('get_logs', 'control') + ->and($sensitiveTools)->toContain('get_logs') + ->not->toContain('control'); }); test('get_logs returns structured choices when service has multiple containers', function () { @@ -1713,6 +1726,18 @@ test('coolify_help topic returns recipe steps and marks tools the token cannot u ->and($steps['create_application']['action'])->toBe('public'); }); +test('coolify_help marks recipe steps unavailable when the token lacks the action ability', function () { + $body = mcpReadJson(mcpReadCall('coolify_help', ['topic' => 'backup_database'])); + $steps = collect($body['data']['steps'])->mapWithKeys(fn (array $step) => [$step['tool'].':'.($step['action'] ?? '') => $step['available']]); + + expect($steps->all())->toBe([ + 'manage_s3_storage:create' => false, + 'manage_s3_storage:validate' => false, + 'manage_backup:create' => false, + 'list_backup_executions:' => true, + ]); +}); + test('coolify_help search filters tools and rejects unknown topics', function () { $body = mcpReadJson(mcpReadCall('coolify_help', ['search' => 'deployment'])); expect($body['data']['read_tools'])->toHaveKeys(['list_deployments', 'get_deployment'])