app/Mcp/Tools/CoolifyHelp.php
Rama main · réplica de lectura del repositorio original.
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Mcp\Servers\CoolifyServer;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Str;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
class CoolifyHelp extends Tool
{
protected string $name = 'coolify_help';
protected string $description = 'Start here. Lists the Coolify MCP tools this token can use and step-by-step recipes for multi-step tasks. Optional: topic (recipe name) or search (filter tools).';
use BuildsResponse;
use ResolvesTeam;
/**
* Multi-step workflows. Each step names a registered tool (and action for grouped tools).
*
* @var array<string, array{description: string, steps: array<int, array{tool: string, action?: string, note: string}>}>
*/
public const RECIPES = [
'deploy_app_from_git' => [
'description' => 'Create and deploy an application from a Git repository',
'steps' => [
['tool' => 'list_projects', 'note' => 'Find the project, or create one with manage_project action=create.'],
['tool' => 'get_project', 'note' => 'Pick the environment (name or UUID).'],
['tool' => 'list_servers', 'note' => 'Pick a reachable server_uuid.'],
['tool' => 'list_destinations', 'note' => 'Needed only when the server has more than one destination.'],
['tool' => 'list_github_apps', 'note' => 'Private repo: use a GitHub app, or a deploy key (manage_private_key).'],
['tool' => 'create_application', 'action' => 'public', 'note' => 'Or action=github_app / deploy_key. Set git_repository, git_branch, build_pack, ports_exposes.'],
['tool' => 'manage_env', 'action' => 'bulk_update', 'note' => 'Set environment variables (resource=application).'],
['tool' => 'deploy', 'note' => 'Queue the first deployment.'],
['tool' => 'get_deployment', 'note' => 'Follow the status; include_log_summary=true on failure.'],
],
],
'add_database_to_app' => [
'description' => 'Create a database and connect an application to it',
'steps' => [
['tool' => 'get_application', 'note' => 'Note the project, environment, and server of the app.'],
['tool' => 'create_database', 'action' => 'postgresql', 'note' => 'Use the same environment and server_uuid as the app.'],
['tool' => 'control', 'note' => 'Start the database (resource=database, action=start).'],
['tool' => 'get_env_values', 'note' => 'Read the internal connection URL (needs read:sensitive).'],
['tool' => 'manage_env', 'action' => 'create', 'note' => 'Add DATABASE_URL to the application.'],
['tool' => 'deploy', 'note' => 'Redeploy the app so it gets the new variable.'],
],
],
'deploy_service_from_template' => [
'description' => 'Deploy a one-click service',
'steps' => [
['tool' => 'list_service_templates', 'note' => 'Find the template type (search=...).'],
['tool' => 'create_service', 'action' => 'template', 'note' => 'Set type, project, environment, server_uuid.'],
['tool' => 'manage_service', 'action' => 'update_application', 'note' => 'Set domains on the service application.'],
['tool' => 'control', 'note' => 'Start it (resource=service, action=start).'],
],
],
'add_server' => [
'description' => 'Connect a new server over SSH',
'steps' => [
['tool' => 'manage_private_key', 'action' => 'create', 'note' => 'Add the SSH private key (never returned again).'],
['tool' => 'manage_server', 'action' => 'create', 'note' => 'Set name, ip, port, user, private_key_uuid.'],
['tool' => 'manage_server', 'action' => 'validate', 'note' => 'Check the connection and install Docker if needed.'],
['tool' => 'get_server', 'note' => 'Check that the server is reachable and usable.'],
['tool' => 'manage_proxy', 'action' => 'get', 'note' => 'Check the proxy.'],
],
],
'debug_failed_deploy' => [
'description' => 'Find why a deployment failed',
'steps' => [
['tool' => 'list_deployments', 'note' => 'application_uuid=... for the history.'],
['tool' => 'get_deployment', 'note' => 'include_log_summary=true (needs read:sensitive).'],
['tool' => 'list_env_keys', 'note' => 'Check for missing variables (names only).'],
['tool' => 'get_logs', 'note' => 'Only if the container runs (needs read:sensitive).'],
['tool' => 'deploy', 'note' => 'Redeploy after the fix; force=true to rebuild.'],
],
],
'configure_dns' => [
'description' => 'Point the domains of an application or service to its server with Cloudflare DNS',
'steps' => [
['tool' => 'manage_integration_tokens', 'action' => 'create', 'note' => 'Add a Cloudflare token once (provider=cloudflare, capabilities=[dns]); the secret is never returned.'],
['tool' => 'manage_dns', 'action' => 'status', 'note' => 'Check the providers and records of each domain.'],
['tool' => 'manage_dns', 'action' => 'create', 'note' => 'Create the record for one hostname. On a conflict, ask the user before replace.'],
],
],
'backup_database' => [
'description' => 'Back up a database to an S3, SFTP, FTPS, or WebDAV storage',
'steps' => [
['tool' => 'manage_s3_storage', 'action' => 'create', 'note' => 'Add the storage with its type (s3, sftp, ftps, webdav; bucket and region only for s3). The secret is never returned.'],
['tool' => 'manage_s3_storage', 'action' => 'validate', 'note' => 'Check the credentials.'],
['tool' => 'manage_backup', 'action' => 'create', 'note' => 'Set frequency, s3_storage_uuid, save_s3=true; backup_now=true runs it at once.'],
['tool' => 'list_backup_executions', 'note' => 'Check the result.'],
],
],
];
/**
* @var array<int, string>
*/
private const START_HERE = ['get_infrastructure_overview', 'search_resources', 'list_unhealthy_resources'];
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read', $this->name)) {
return $error;
}
if (is_null($this->resolveTeamId($request))) {
return $this->mcpError($request, 'Invalid token.');
}
$tools = $this->visibleTools();
$topic = $request->get('topic');
if ($topic !== null) {
if (! is_string($topic) || ! isset(self::RECIPES[$topic])) {
return $this->mcpError($request, 'Unknown topic. Use: '.implode(', ', array_keys(self::RECIPES)).'.');
}
return $this->mcpSuccess($request, $this->respond([
'topic' => $topic,
'description' => self::RECIPES[$topic]['description'],
'steps' => collect(self::RECIPES[$topic]['steps'])
->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.'],
]));
}
$search = $request->get('search');
if ($search !== null && (! is_string($search) || trim($search) === '')) {
return $this->mcpError($request, 'search must be a non-empty string when provided.');
}
if (is_string($search)) {
$needle = Str::lower(trim($search));
$tools = array_filter($tools, fn (array $tool, string $name): bool => str_contains($name, $needle) || str_contains(Str::lower($tool['summary']), $needle), ARRAY_FILTER_USE_BOTH);
}
return $this->mcpSuccess($request, $this->respond([
'start_here' => array_values(array_filter(self::START_HERE, fn (string $name): bool => isset($tools[$name]))),
'read_tools' => $this->summaries(array_filter($tools, fn (array $tool): bool => $tool['read_only'])),
'change_tools' => $this->summaries(array_filter($tools, fn (array $tool): bool => ! $tool['read_only'])),
'recipes' => collect(self::RECIPES)->map(fn (array $recipe): string => $recipe['description'])->all(),
'workflow' => [
'Resolve a UUID with search_resources or list_*, then get_* for details.',
'Debug with list_deployments / get_deployment before get_logs.',
'Change tools use the same validation and permissions as the REST API.',
'Ask the user before every action that needs confirm=true.',
'Call coolify_help with topic=<recipe> for step-by-step guides.',
],
]));
}
/**
* Registered tools that this token can see, keyed by name.
*
* @return array<string, array{summary: string, read_only: bool, actions: array<int, string>|null}>
*/
private function visibleTools(): array
{
return collect(CoolifyServer::toolsForCurrentActor())
->map(fn (string $class): Tool => app($class))
->filter(fn (Tool $tool): bool => $tool->eligibleForRegistration())
->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();
}
/**
* 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
{
return array_map(fn (array $tool): string => Str::limit(rtrim($tool['summary'], '.').'.', 140), $tools);
}
public function schema(JsonSchema $schema): array
{
return [
'topic' => $schema->string()->enum(array_keys(self::RECIPES))->description('Optional recipe: step-by-step guide for a multi-step task.'),
'search' => $schema->string()->description('Optional: only list tools whose name or summary contains this text.'),
];
}
}