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:
Andras Bacsai
2026-10-23 23:54:31 +02:00
co-authored by Claude Opus 5.5
parent 3d4df5e06f
commit 35f6092127
5 changed files with 82 additions and 4 deletions
+25
View File
@@ -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}
*/
+19 -3
View File
@@ -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
+2
View File
@@ -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,
+10
View File
@@ -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() !== [];
+26 -1
View File
@@ -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'])