mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
fix(mcp): show only tools and actions the token can use in help and hints
Closes #12295 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3d4df5e06f
commit
35f6092127
@@ -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<int, array<string, mixed>> $suggestions
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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}
|
||||
*/
|
||||
|
||||
@@ -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<string, array{summary: string, read_only: bool}>
|
||||
* @return array<string, array{summary: string, read_only: bool, actions: array<int, string>|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<string, array{summary: string, read_only: bool}> $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<string, array{summary: string, read_only: bool, actions: array<int, string>|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<string, array{summary: string, read_only: bool, actions: array<int, string>|null}> $tools
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function summaries(array $tools): array
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<int, string>
|
||||
*/
|
||||
public function availableActionNames(): array
|
||||
{
|
||||
return array_keys($this->availableActions());
|
||||
}
|
||||
|
||||
public function shouldRegister(): bool
|
||||
{
|
||||
return $this->availableActions() !== [];
|
||||
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user