diff --git a/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php index 7c858b38b..83ae05400 100644 --- a/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php +++ b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php @@ -27,8 +27,27 @@ public function handle(Request $request, Closure $next): Response } $role = $team->pivot?->role; - if (($token->can('root') || $token->can('write') || $token->can('write:sensitive')) - && ! in_array($role, ['admin', 'owner'], true)) { + // Match ApiAbility::MEMBER_DISALLOWED_ABILITIES — members are read-only. + $elevated = $token->can('root') + || $token->can('write') + || $token->can('write:sensitive') + || $token->can('deploy') + || $token->can('read:sensitive'); + + if ($elevated && ! in_array($role, ['admin', 'owner'], true)) { + // MCP clients expect JSON-RPC envelopes (often only parsed on HTTP 200). + // Keep REST API clients on plain 403 JSON. + if ($request->is('mcp') || $request->is('mcp/*')) { + return response()->json([ + 'jsonrpc' => '2.0', + 'id' => $request->input('id'), + 'error' => [ + 'code' => -32003, + 'message' => 'Missing required team role.', + ], + ]); + } + return response()->json(['message' => 'Missing required team role.'], 403); } diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php index 1473d8994..d429edb5f 100644 --- a/app/Mcp/Concerns/BuildsResponse.php +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -29,6 +29,8 @@ trait BuildsResponse 'service_id', 'project_id', 'parent_id', 'resourceable', 'resourceable_id', 'resourceable_type', 'destination_type', 'source_type', 'tokenable', + 'build_server_id', 'horizon_job_id', 'horizon_job_worker', + 'current_process_id', 'app_id', 'installation_id', // sentinel / observability secrets 'sentinel_token', 'sentinel_custom_url', @@ -48,15 +50,18 @@ trait BuildsResponse // database connection strings embed credentials 'internal_db_url', 'external_db_url', 'init_scripts', - // webhook secrets + // webhook / oauth / key secrets 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', + 'client_secret', 'webhook_secret', 'client_id', + 'private_key', 'public_key', // bulky / unsafe blobs 'dockerfile', 'docker_compose', 'docker_compose_raw', 'custom_labels', 'environment_variables', 'environment_variables_preview', 'validation_logs', - 'server_metadata', + 'server_metadata', 'logs', 'configuration_snapshot', + 'configuration_diff', 'fs_path', 'content', 'file_storage_content', ]; /** @@ -88,6 +93,31 @@ protected function scrubSensitive(array $data): array return $walk($data); } + /** + * Best-effort redaction for free-form log text shown to MCP clients. + * Uses remove_iip plus common KEY=value / JSON secret patterns; not a guarantee. + */ + protected function redactLogText(string $text): string + { + $text = remove_iip($text); + + // password= / secret= / token= / "token":"..." style (shell or JSON; quoted or bare) + $text = preg_replace( + '/(?|array $data * @param array> $actions @@ -132,7 +162,7 @@ protected function paginationMeta(string $tool, array $args, int $total, array $ { $page = $args['page']; $perPage = $args['per_page']; - $totalPages = (int) ceil($total / $perPage); + $totalPages = (int) ceil($total / max(1, $perPage)); $meta = [ 'page' => $page, @@ -152,7 +182,7 @@ protected function paginationMeta(string $tool, array $args, int $total, array $ } /** - * HATEOAS-style action suggestions for an application. + * HATEOAS-style action suggestions for an application (read tools only). * * @return array> */ @@ -160,14 +190,22 @@ protected function actionsForApplication(string $uuid, ?string $status = null): { $actions = [ ['tool' => 'get_application', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'], + ['tool' => 'list_env_keys', 'args' => ['resource' => 'application', 'uuid' => $uuid], 'hint' => 'Env key names (no values)'], + ['tool' => 'list_deployments', 'args' => ['application_uuid' => $uuid], 'hint' => 'Deployment history'], + ['tool' => 'list_application_previews', 'args' => ['uuid' => $uuid], 'hint' => 'PR preview deployments'], + ['tool' => 'list_storages', 'args' => ['resource' => 'application', 'uuid' => $uuid], 'hint' => 'Volumes / file mounts'], + ['tool' => 'list_scheduled_tasks', 'args' => ['resource' => 'application', 'uuid' => $uuid], 'hint' => 'Scheduled tasks'], ]; $s = strtolower((string) $status); - if (str_contains($s, 'running')) { - $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart']; - $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop']; + // Match GetLogs/control: any running* status (including unhealthy) can fetch logs / restart / stop. + if (str_starts_with($s, 'running')) { + $actions[] = ['tool' => 'get_logs', 'args' => ['resource' => 'application', 'uuid' => $uuid], 'hint' => 'Live container logs (requires running)']; + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart (needs deploy ability)']; + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'stop', 'uuid' => $uuid, 'confirm' => true], 'hint' => 'Stop (needs deploy + confirm)']; } else { - $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start']; + $actions[] = ['tool' => 'deploy', 'args' => ['uuid' => $uuid], 'hint' => 'Deploy/start (needs deploy ability)']; + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start (needs deploy ability)']; } return $actions; @@ -180,14 +218,19 @@ protected function actionsForDatabase(string $uuid, ?string $status = null): arr { $actions = [ ['tool' => 'get_database', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'], + ['tool' => 'list_env_keys', 'args' => ['resource' => 'database', 'uuid' => $uuid], 'hint' => 'Env key names (no values)'], + ['tool' => 'list_database_backups', 'args' => ['uuid' => $uuid], 'hint' => 'Backup schedules'], + ['tool' => 'list_storages', 'args' => ['resource' => 'database', 'uuid' => $uuid], 'hint' => 'Volumes / file mounts'], ]; $s = strtolower((string) $status); - if (str_contains($s, 'running')) { - $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart']; - $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop']; + // Control treats any status containing "running" as already started (including unhealthy). + // Suggest logs/restart for those; only non-running statuses get start. + if (str_starts_with($s, 'running')) { + $actions[] = ['tool' => 'get_logs', 'args' => ['resource' => 'database', 'uuid' => $uuid], 'hint' => 'Live logs if running']; + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart (needs deploy ability)']; } else { - $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start']; + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start (needs deploy ability)']; } return $actions; @@ -200,14 +243,21 @@ protected function actionsForService(string $uuid, ?string $status = null): arra { $actions = [ ['tool' => 'get_service', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'], + ['tool' => 'list_service_applications', 'args' => ['uuid' => $uuid], 'hint' => 'Service applications'], + ['tool' => 'list_service_databases', 'args' => ['uuid' => $uuid], 'hint' => 'Service databases'], + ['tool' => 'list_env_keys', 'args' => ['resource' => 'service', 'uuid' => $uuid], 'hint' => 'Env key names (no values)'], + ['tool' => 'list_storages', 'args' => ['resource' => 'service', 'uuid' => $uuid], 'hint' => 'Volumes / file mounts'], + ['tool' => 'list_scheduled_tasks', 'args' => ['resource' => 'service', 'uuid' => $uuid], 'hint' => 'Scheduled tasks'], ]; $s = strtolower((string) $status); + // Control treats any status containing "running" as already started (including unhealthy). + // Suggest logs/restart for those; only non-running statuses get start. if (str_contains($s, 'running')) { - $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart']; - $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop']; + $actions[] = ['tool' => 'get_logs', 'args' => ['resource' => 'service', 'uuid' => $uuid], 'hint' => 'Live logs if running']; + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart (needs deploy ability)']; } else { - $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start']; + $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start (needs deploy ability)']; } return $actions; @@ -220,6 +270,39 @@ protected function actionsForServer(string $uuid): array { return [ ['tool' => 'get_server', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'], + ['tool' => 'get_server_domains', 'args' => ['uuid' => $uuid], 'hint' => 'Domains on this server'], + ['tool' => 'get_server_resources', 'args' => ['uuid' => $uuid], 'hint' => 'Resources on this server'], + ['tool' => 'list_destinations', 'args' => ['server_uuid' => $uuid], 'hint' => 'Docker destinations'], ]; } + + /** + * @return array> + */ + protected function actionsForProject(string $uuid): array + { + return [ + ['tool' => 'get_project', 'args' => ['uuid' => $uuid], 'hint' => 'Project details'], + ['tool' => 'list_applications', 'args' => ['project_uuid' => $uuid], 'hint' => 'Applications in project'], + ['tool' => 'list_services', 'args' => ['project_uuid' => $uuid], 'hint' => 'Services in project'], + ['tool' => 'list_databases', 'args' => ['project_uuid' => $uuid], 'hint' => 'Databases in project'], + ]; + } + + /** + * @return array> + */ + protected function actionsForDeployment(string $deploymentUuid, ?string $applicationUuid = null): array + { + $actions = [ + ['tool' => 'get_deployment', 'args' => ['uuid' => $deploymentUuid], 'hint' => 'Deployment details'], + ]; + + if (is_string($applicationUuid) && $applicationUuid !== '') { + $actions[] = ['tool' => 'get_application', 'args' => ['uuid' => $applicationUuid], 'hint' => 'Parent application']; + $actions[] = ['tool' => 'get_logs', 'args' => ['resource' => 'application', 'uuid' => $applicationUuid], 'hint' => 'Application logs']; + } + + return $actions; + } } diff --git a/app/Mcp/Concerns/McpStatusFilters.php b/app/Mcp/Concerns/McpStatusFilters.php new file mode 100644 index 000000000..73aea140e --- /dev/null +++ b/app/Mcp/Concerns/McpStatusFilters.php @@ -0,0 +1,40 @@ +where(function (Builder $q) use ($column) { + $q->whereNull($column) + ->orWhere($column, '') + ->orWhereRaw("LOWER({$column}) NOT LIKE ?", ['running%']) + ->orWhereRaw("LOWER({$column}) LIKE ?", ['%unhealthy%']) + ->orWhereRaw("LOWER({$column}) LIKE ?", ['%degraded%']) + ->orWhereRaw("LOWER({$column}) LIKE ?", ['%restarting%']); + }); + } + + protected function looksHealthy(?string $status): bool + { + if ($status === null || trim($status) === '') { + return false; + } + + $s = strtolower($status); + + if (str_contains($s, 'unhealthy') || str_contains($s, 'degraded') || str_contains($s, 'exited') || str_contains($s, 'restarting')) { + return false; + } + + return str_starts_with($s, 'running'); + } +} diff --git a/app/Mcp/Concerns/ResolvesResource.php b/app/Mcp/Concerns/ResolvesResource.php new file mode 100644 index 000000000..7695fb9e2 --- /dev/null +++ b/app/Mcp/Concerns/ResolvesResource.php @@ -0,0 +1,103 @@ + + */ + protected array $primaryResourceTypes = [ + 'application', + 'database', + 'service', + ]; + + /** + * @var array + */ + protected array $logResourceTypes = [ + 'application', + 'database', + 'service', + 'service_application', + 'service_database', + ]; + + /** + * @var array + */ + protected array $scheduledTaskResourceTypes = [ + 'application', + 'service', + ]; + + /** + * Resolve a team-scoped primary resource (application, database, service). + * + * Always enforces team ownership — never returns a resource from another team. + */ + protected function resolveTeamResource(int $teamId, string $resourceType, string $uuid): ?Model + { + return match ($resourceType) { + 'application' => Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(), + 'database' => queryDatabaseByUuidWithinTeam($uuid, (string) $teamId), + 'service' => Service::whereRelation('environment.project.team', 'id', $teamId) + ->where('uuid', $uuid) + ->first(), + default => null, + }; + } + + /** + * Resolve a team-scoped resource for log tools (includes service children). + */ + protected function resolveTeamLogResource(int $teamId, string $resourceType, string $uuid, ?string $parentUuid = null): ?Model + { + if (in_array($resourceType, $this->primaryResourceTypes, true)) { + return $this->resolveTeamResource($teamId, $resourceType, $uuid); + } + + if ($resourceType === 'service_application') { + $query = ServiceApplication::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid); + if (is_string($parentUuid) && $parentUuid !== '') { + $query->whereHas('service', fn (Builder $q) => $q->where('uuid', $parentUuid)); + } + + return $query->first(); + } + + if ($resourceType === 'service_database') { + $query = ServiceDatabase::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid); + if (is_string($parentUuid) && $parentUuid !== '') { + $query->whereHas('service', fn (Builder $q) => $q->where('uuid', $parentUuid)); + } + + return $query->first(); + } + + return null; + } + + protected function isValidResourceType(string $type, array $allowed): bool + { + return in_array($type, $allowed, true); + } + + /** + * MCP log line cap (stricter than the REST API max of 10000). + */ + protected function normalizeMcpLogLines(mixed $lines): int + { + return normalizeLogLines($lines, default: 100, max: 500); + } +} diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php index 8e0ae0467..fc15f7886 100644 --- a/app/Mcp/Concerns/ResolvesTeam.php +++ b/app/Mcp/Concerns/ResolvesTeam.php @@ -7,6 +7,19 @@ trait ResolvesTeam { + /** + * Abilities that team members must not exercise (parity with ApiAbility). + * + * @var array + */ + private const MEMBER_DISALLOWED_ABILITIES = [ + 'root', + 'write', + 'write:sensitive', + 'deploy', + 'read:sensitive', + ]; + protected function ensureAbility(Request $request, string $ability = 'read', ?string $tool = null): ?Response { $user = $request->user(); @@ -23,6 +36,26 @@ protected function ensureAbility(Request $request, string $ability = 'read', ?st return Response::error('Invalid token.'); } + $teamId = $token->team_id; + if ($teamId !== null) { + // Fresh pivot lookup (avoid stale $user->teams cache after role changes). + $role = $user->teams()->where('teams.id', $teamId)->first()?->pivot?->role; + $isAdminOrOwner = in_array($role, ['admin', 'owner'], true); + + if (! $isAdminOrOwner) { + $tokenAbilities = $token->abilities ?? []; + $disallowed = array_intersect($tokenAbilities, self::MEMBER_DISALLOWED_ABILITIES); + if ($disallowed !== [] || in_array($ability, self::MEMBER_DISALLOWED_ABILITIES, true)) { + $this->auditMcpTool($request, $tool, 'denied', [ + 'reason' => 'member_role', + 'required_ability' => $ability, + ]); + + return Response::error('Missing required team role.'); + } + } + } + if ($token->can('root') || $token->can($ability)) { return null; } diff --git a/app/Mcp/Prompts/ExplainFailedDeploy.php b/app/Mcp/Prompts/ExplainFailedDeploy.php new file mode 100644 index 000000000..6d5cf8768 --- /dev/null +++ b/app/Mcp/Prompts/ExplainFailedDeploy.php @@ -0,0 +1,68 @@ +get('deployment_uuid'); + $applicationUuid = $request->get('application_uuid'); + + $deploymentUuid = is_string($deploymentUuid) && $deploymentUuid !== '' ? $deploymentUuid : null; + $applicationUuid = is_string($applicationUuid) && $applicationUuid !== '' ? $applicationUuid : null; + + $steps = []; + if ($deploymentUuid) { + $steps[] = "Call `get_deployment` with uuid=`{$deploymentUuid}` and include_log_summary=true, log_lines=60."; + $steps[] = 'Note status, commit, finished_at, and log_summary text (already redacted/truncated).'; + $steps[] = 'If application_uuid is present on the result, call `get_application` for context (branch, build pack, fqdn).'; + $steps[] = 'Optionally `list_deployments` with that application_uuid to compare with the previous successful deploy.'; + } elseif ($applicationUuid) { + $steps[] = "Call `list_deployments` with application_uuid=`{$applicationUuid}` and review the most recent failed/cancelled items."; + $steps[] = 'Pick the failed deployment_uuid and call `get_deployment` with include_log_summary=true.'; + $steps[] = "Call `get_application` with uuid=`{$applicationUuid}` for build/git context."; + $steps[] = 'If useful, `list_env_keys` (names only) and `get_logs` for runtime errors after a partial deploy.'; + } else { + $steps[] = 'Call `list_deployments` (no application filter) to list currently in_progress/queued deployments, or ask the user for application_uuid / deployment_uuid.'; + $steps[] = 'Once you have a deployment_uuid, call `get_deployment` with include_log_summary=true.'; + } + + $steps[] = 'Explain the failure in plain language: root cause hypothesis, evidence from log_summary, and safe next steps for the human operator.'; + $steps[] = 'Never request or display secret env values, private keys, or full unbounded build logs.'; + + $numbered = collect($steps) + ->values() + ->map(fn (string $step, int $i) => ($i + 1).'. '.$step) + ->implode("\n"); + + $body = "You are explaining a failed Coolify deployment for the authenticated team.\n\n{$numbered}"; + + return Response::text($body); + } + + public function arguments(): array + { + return [ + new Argument( + name: 'deployment_uuid', + description: 'Optional deployment UUID. Prefer this when known.', + required: false, + ), + new Argument( + name: 'application_uuid', + description: 'Optional application UUID when deployment UUID is unknown.', + required: false, + ), + ]; + } +} diff --git a/app/Mcp/Prompts/TroubleshootApplication.php b/app/Mcp/Prompts/TroubleshootApplication.php new file mode 100644 index 000000000..24aa36b6e --- /dev/null +++ b/app/Mcp/Prompts/TroubleshootApplication.php @@ -0,0 +1,66 @@ +get('uuid'); + $uuid = is_string($uuid) && $uuid !== '' ? $uuid : '{application_uuid}'; + + $text = <<ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return Response::error('uuid is required in resource URI.'); + } + + $application = Application::ownedByCurrentTeamAPI($teamId) + ->with(['environment.project:id,uuid,name,team_id']) + ->where('uuid', $uuid) + ->first(); + + if (! $application) { + return Response::error("Application [{$uuid}] not found."); + } + + $payload = $this->scrubSensitive([ + 'uuid' => $application->uuid, + 'name' => $application->name, + 'status' => $application->status, + 'fqdn' => $application->fqdn, + 'git_repository' => $application->git_repository, + 'git_branch' => $application->git_branch, + 'build_pack' => $application->build_pack, + 'project_uuid' => $application->environment?->project?->uuid, + 'project_name' => $application->environment?->project?->name, + 'environment_uuid' => $application->environment?->uuid, + 'environment_name' => $application->environment?->name, + ]); + + return Response::text(json_encode($payload, JSON_PRETTY_PRINT)); + } +} diff --git a/app/Mcp/Resources/InfrastructureOverviewResource.php b/app/Mcp/Resources/InfrastructureOverviewResource.php new file mode 100644 index 000000000..449878914 --- /dev/null +++ b/app/Mcp/Resources/InfrastructureOverviewResource.php @@ -0,0 +1,91 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return Response::error('Invalid token.'); + } + + $servers = Server::whereTeamId($teamId) + ->with('settings:id,server_id,is_reachable,is_usable') + ->get() + ->map(fn (Server $s) => [ + 'uuid' => $s->uuid, + 'name' => $s->name, + 'ip' => $s->ip, + 'is_reachable' => $s->settings?->is_reachable, + 'is_usable' => $s->settings?->is_usable, + ]) + ->values() + ->all(); + + // databases() is a Collection helper (not an Eloquent relation), so withCount + // must target the individual standalone DB relations and sum them. + $projects = Project::where('team_id', $teamId) + ->withCount([ + 'applications', + 'services', + 'postgresqls', + 'redis', + 'mongodbs', + 'mysqls', + 'mariadbs', + 'keydbs', + 'dragonflies', + 'clickhouses', + ]) + ->get() + ->map(fn (Project $project) => [ + 'uuid' => $project->uuid, + 'name' => $project->name, + 'counts' => [ + 'applications' => $project->applications_count, + 'services' => $project->services_count, + 'databases' => $project->postgresqls_count + + $project->redis_count + + $project->mongodbs_count + + $project->mysqls_count + + $project->mariadbs_count + + $project->keydbs_count + + $project->dragonflies_count + + $project->clickhouses_count, + ], + ])->values()->all(); + + return Response::text(json_encode([ + 'coolify_version' => config('constants.coolify.version'), + 'servers' => $servers, + 'projects' => $projects, + 'counts' => [ + 'servers' => count($servers), + 'projects' => count($projects), + ], + ], JSON_PRETTY_PRINT)); + } +} diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php index 2b2d33d60..957109590 100644 --- a/app/Mcp/Servers/CoolifyServer.php +++ b/app/Mcp/Servers/CoolifyServer.php @@ -2,49 +2,149 @@ namespace App\Mcp\Servers; +use App\Mcp\Prompts\ExplainFailedDeploy; +use App\Mcp\Prompts\TroubleshootApplication; +use App\Mcp\Resources\ApplicationResource; +use App\Mcp\Resources\InfrastructureOverviewResource; +use App\Mcp\Tools\CancelDeployment; +use App\Mcp\Tools\Control; +use App\Mcp\Tools\CoolifyHelp; +use App\Mcp\Tools\Deploy; use App\Mcp\Tools\GetApplication; +use App\Mcp\Tools\GetCurrentTeam; use App\Mcp\Tools\GetDatabase; +use App\Mcp\Tools\GetDeployment; +use App\Mcp\Tools\GetDestination; +use App\Mcp\Tools\GetEnvironment; use App\Mcp\Tools\GetInfrastructureOverview; +use App\Mcp\Tools\GetLogs; +use App\Mcp\Tools\GetProject; use App\Mcp\Tools\GetServer; +use App\Mcp\Tools\GetServerDomains; +use App\Mcp\Tools\GetServerResources; use App\Mcp\Tools\GetService; +use App\Mcp\Tools\GetServiceApplication; +use App\Mcp\Tools\GetServiceDatabase; +use App\Mcp\Tools\ListApplicationPreviews; use App\Mcp\Tools\ListApplications; +use App\Mcp\Tools\ListBackupExecutions; +use App\Mcp\Tools\ListDatabaseBackups; use App\Mcp\Tools\ListDatabases; +use App\Mcp\Tools\ListDeployments; +use App\Mcp\Tools\ListDestinations; +use App\Mcp\Tools\ListEnvKeys; +use App\Mcp\Tools\ListGithubApps; +use App\Mcp\Tools\ListGithubBranches; +use App\Mcp\Tools\ListGithubRepositories; use App\Mcp\Tools\ListProjects; +use App\Mcp\Tools\ListResources; +use App\Mcp\Tools\ListResourceTags; +use App\Mcp\Tools\ListScheduledTaskExecutions; +use App\Mcp\Tools\ListScheduledTasks; use App\Mcp\Tools\ListServers; +use App\Mcp\Tools\ListServiceApplications; +use App\Mcp\Tools\ListServiceDatabases; use App\Mcp\Tools\ListServices; +use App\Mcp\Tools\ListSharedEnvKeys; +use App\Mcp\Tools\ListStorages; +use App\Mcp\Tools\ListTags; +use App\Mcp\Tools\ListTeamMembers; +use App\Mcp\Tools\ListUnhealthyResources; +use App\Mcp\Tools\SearchResources; use Laravel\Mcp\Server; class CoolifyServer extends Server { protected string $name = 'Coolify'; - protected string $version = '0.1.0'; + protected string $version = '0.2.0'; + + /** + * Return all registered tools in a single tools/list page (default package limit is 15). + */ + public int $maxPaginationLength = 100; + + public int $defaultPaginationLength = 100; protected string $instructions = <<<'MD' -Read-only MCP server for Coolify, scoped to the authenticated team token. +Coolify MCP for the authenticated team token. Every tool enforces team ownership. -Recommended workflow: -1. get_infrastructure_overview — start here; single call returns all servers, projects with resource counts, and aggregates. -2. list_servers / list_projects / list_applications / list_databases / list_services — paginated summary listings (default 50 per page, cap 100). -3. get_server / get_application / get_database / get_service — full details for a single UUID. +Start here (prefer these before deep get_*): +1. coolify_help — tool catalog by intent (overview|search|debug|deploy|essentials). +2. get_infrastructure_overview — counts + health_hints. +3. search_resources — fuzzy name/UUID/domain when type is unknown. +4. list_unhealthy_resources sample_only=true — cheap "what's broken?" sample + counts. -Every response is `{ data, _actions?, _pagination? }`. `_actions` suggests the next tool + args; `_pagination.next` is the args to call again for the next page. +Then: list_*/get_* for details. Debug is DB-first: +- list_deployments → get_deployment(include_log_summary=true) +- list_env_keys / list_shared_env_keys (names only, never values) +- get_logs only if status is running; on failure use reason + next_tools (do not loop) + +Lifecycle (requires token ability **deploy**): +- control (start|stop|restart; stop needs confirm=true) +- deploy, cancel_deployment + +Prompts: troubleshoot_application, explain_failed_deploy. +Resources: coolify://overview, coolify://application/{uuid}. + +Responses: `{ data, _actions?, _pagination? }`. Env values, configuration snapshots, and full deploy logs are never returned. Optional deploy log summaries are best-effort redacted only. MD; protected array $tools = [ + CoolifyHelp::class, GetInfrastructureOverview::class, + SearchResources::class, + ListUnhealthyResources::class, + GetCurrentTeam::class, + ListTeamMembers::class, ListServers::class, GetServer::class, + GetServerDomains::class, + GetServerResources::class, + ListDestinations::class, + GetDestination::class, ListProjects::class, + GetProject::class, + GetEnvironment::class, + ListResources::class, ListApplications::class, GetApplication::class, + ListApplicationPreviews::class, ListDatabases::class, GetDatabase::class, + ListDatabaseBackups::class, + ListBackupExecutions::class, ListServices::class, GetService::class, + ListServiceApplications::class, + GetServiceApplication::class, + ListServiceDatabases::class, + GetServiceDatabase::class, + ListDeployments::class, + GetDeployment::class, + GetLogs::class, + ListEnvKeys::class, + ListSharedEnvKeys::class, + ListStorages::class, + ListResourceTags::class, + ListScheduledTasks::class, + ListScheduledTaskExecutions::class, + ListTags::class, + ListGithubApps::class, + ListGithubRepositories::class, + ListGithubBranches::class, + Control::class, + Deploy::class, + CancelDeployment::class, ]; - protected array $resources = []; + protected array $resources = [ + InfrastructureOverviewResource::class, + ApplicationResource::class, + ]; - protected array $prompts = []; + protected array $prompts = [ + TroubleshootApplication::class, + ExplainFailedDeploy::class, + ]; } diff --git a/app/Mcp/Tools/CancelDeployment.php b/app/Mcp/Tools/CancelDeployment.php new file mode 100644 index 000000000..bbec09126 --- /dev/null +++ b/app/Mcp/Tools/CancelDeployment.php @@ -0,0 +1,121 @@ +ensureAbility($request, 'deploy', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $deployment = ApplicationDeploymentQueue::where('deployment_uuid', $uuid)->first(); + if (! $deployment) { + return $this->mcpError($request, "Deployment [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + // Match deployment_by_uuid: authorize by application ownership, not server ownership. + // Server-scoped checks allow a shared-server host team to cancel another team's deployment. + // Include soft-deleted apps so in-flight deploys remain cancellable after app delete. + $application = $deployment->application()->withTrashed()->first(); + if (! $application || data_get($application->team(), 'id') !== (int) $teamId) { + return $this->mcpError($request, "Deployment [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $cancellable = [ + ApplicationDeploymentStatus::QUEUED->value, + ApplicationDeploymentStatus::IN_PROGRESS->value, + ]; + $deploymentUuid = $deployment->deployment_uuid; + + $updated = ApplicationDeploymentQueue::whereKey($deployment->getKey()) + ->whereIn('status', $cancellable) + ->update(['status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value]); + + if ($updated !== 1) { + $deployment->refresh(); + + return $this->mcpError($request, "Deployment cannot be cancelled. Current status: {$deployment->status}", ['resource_uuid' => $uuid]); + } + + $deployment->status = ApplicationDeploymentStatus::CANCELLED_BY_USER->value; + + try { + $buildServerId = $deployment->build_server_id ?? $deployment->server_id; + $server = Server::whereTeamId($teamId)->find($buildServerId); + if ($server) { + $deployment->addLogEntry('Deployment cancelled by user via MCP.', 'stderr'); + + $checkCommand = "docker ps -a --filter name={$deploymentUuid} --format '{{.Names}}'"; + $containerExists = instant_remote_process([$checkCommand], $server); + + if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { + instant_remote_process(["docker rm -f {$deploymentUuid}"], $server); + $deployment->addLogEntry('Deployment container stopped.'); + } else { + $deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.'); + } + + // Parity with REST cancel_deployment: stop the remote build process if known. + if ($deployment->current_process_id) { + try { + instant_remote_process(["kill -9 {$deployment->current_process_id}"], $server); + } catch (\Throwable) { + // Process might already be gone. + } + } + } + } catch (\Throwable) { + // Cancellation is still recorded even if remote kill fails. + } + + auditLog('mcp.deployment.cancelled', [ + 'team_id' => $teamId, + 'deployment_uuid' => $uuid, + 'application_id' => $deployment->application_id, + 'server_id' => $deployment->server_id, + ]); + + return $this->mcpSuccess($request, $this->respond([ + 'ok' => true, + 'message' => 'Deployment cancelled successfully.', + 'deployment_uuid' => $uuid, + 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, + ]), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Deployment UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/Control.php b/app/Mcp/Tools/Control.php new file mode 100644 index 000000000..4b52d3d93 --- /dev/null +++ b/app/Mcp/Tools/Control.php @@ -0,0 +1,189 @@ +ensureAbility($request, 'deploy', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $resourceType = $request->get('resource'); + $action = $request->get('action'); + $uuid = $request->get('uuid'); + $confirm = filter_var($request->get('confirm'), FILTER_VALIDATE_BOOLEAN); + + if (! is_string($resourceType) || ! in_array($resourceType, ['application', 'database', 'service'], true)) { + return $this->mcpError($request, 'resource must be application, database, or service.'); + } + if (! is_string($action) || ! in_array($action, ['start', 'stop', 'restart'], true)) { + return $this->mcpError($request, 'action must be start, stop, or restart.'); + } + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + if ($action === 'stop' && ! $confirm) { + return $this->mcpError($request, 'stop requires confirm=true to prevent accidental downtime.'); + } + + $resource = $this->resolveTeamResource($teamId, $resourceType, $uuid); + if (! $resource) { + return $this->mcpError($request, ucfirst($resourceType)." [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + try { + $result = match ($resourceType) { + 'application' => $this->controlApplication($resource, $action), + 'database' => $this->controlDatabase($resource, $action), + 'service' => $this->controlService($resource, $action), + }; + } catch (\Throwable $e) { + return $this->mcpError($request, $e->getMessage(), ['resource_uuid' => $uuid]); + } + + auditLog('mcp.control', [ + 'team_id' => $teamId, + 'resource' => $resourceType, + 'action' => $action, + 'resource_uuid' => $uuid, + ]); + + return $this->mcpSuccess($request, $this->respond([ + 'ok' => true, + 'resource' => $resourceType, + 'uuid' => $uuid, + 'action' => $action, + ...$result, + ]), ['resource_uuid' => $uuid, 'action' => $action]); + } + + /** + * @return array + */ + private function controlApplication(Application $application, string $action): array + { + if ($action === 'stop') { + StopApplication::dispatch($application, false, true); + + return ['message' => 'Application stopping request queued.']; + } + + $deploymentUuid = new_public_id(); + $result = queue_application_deployment( + application: $application, + deployment_uuid: $deploymentUuid, + force_rebuild: false, + restart_only: $action === 'restart', + is_api: true, + no_questions_asked: $action === 'start', + ); + + if (($result['status'] ?? null) === 'skipped' || ($result['status'] ?? null) === 'queue_full') { + return [ + 'message' => $result['message'] ?? 'Deployment skipped.', + 'deployment_uuid' => null, + ]; + } + + return [ + 'message' => $action === 'restart' ? 'Restart request queued.' : 'Deployment request queued.', + 'deployment_uuid' => $deploymentUuid, + 'next_tools' => [ + ['tool' => 'get_deployment', 'args' => ['uuid' => $deploymentUuid], 'hint' => 'Poll deployment status'], + ], + ]; + } + + /** + * @return array + */ + private function controlDatabase(mixed $database, string $action): array + { + if ($action === 'stop') { + StopDatabase::dispatch($database); + + return ['message' => 'Database stopping request queued.']; + } + + if ($action === 'start' && str($database->status ?? '')->contains('running')) { + return ['message' => 'Database is already running.']; + } + + if ($action === 'restart') { + RestartDatabase::dispatch($database); + + return ['message' => 'Database restart request queued.']; + } + + StartDatabase::dispatch($database); + + return ['message' => 'Database starting request queued.']; + } + + /** + * @return array + */ + private function controlService(mixed $service, string $action): array + { + if ($action === 'stop') { + StopService::dispatch($service); + + return ['message' => 'Service stopping request queued.']; + } + + if ($action === 'start' && str($service->status ?? '')->contains('running')) { + return ['message' => 'Service is already running.']; + } + + if ($action === 'restart') { + RestartService::dispatch($service, false); + + return ['message' => 'Service restart request queued.']; + } + + StartService::dispatch($service); + + return ['message' => 'Service starting request queued.']; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'resource' => $schema->string()->description('application | database | service')->required(), + 'action' => $schema->string()->description('start | stop | restart')->required(), + 'uuid' => $schema->string()->description('Resource UUID.')->required(), + 'confirm' => $schema->boolean()->description('Required true when action=stop.'), + ]; + } +} diff --git a/app/Mcp/Tools/CoolifyHelp.php b/app/Mcp/Tools/CoolifyHelp.php new file mode 100644 index 000000000..784b106d4 --- /dev/null +++ b/app/Mcp/Tools/CoolifyHelp.php @@ -0,0 +1,180 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $intent = $request->get('intent'); + if ($intent !== null && (! is_string($intent) || trim($intent) === '')) { + return $this->mcpError($request, 'intent must be a non-empty string when provided.'); + } + $intent = is_string($intent) ? strtolower(trim($intent)) : null; + + $catalog = [ + 'overview' => [ + 'description' => 'Bird\'s-eye view and "what is broken?"', + 'tools' => [ + 'get_infrastructure_overview', + 'list_unhealthy_resources', + 'search_resources', + 'get_current_team', + ], + ], + 'search' => [ + 'description' => 'Find a resource by name/UUID/domain without knowing the type', + 'tools' => [ + 'search_resources', + 'list_resources', + 'list_tags', + ], + ], + 'inventory' => [ + 'description' => 'Browse servers, projects, apps, DBs, services', + 'tools' => [ + 'list_servers', + 'get_server', + 'list_projects', + 'get_project', + 'get_environment', + 'list_applications', + 'get_application', + 'list_databases', + 'get_database', + 'list_services', + 'get_service', + 'list_destinations', + 'get_destination', + ], + ], + 'debug' => [ + 'description' => 'Diagnose failures (prefer DB-backed tools before live logs)', + 'tools' => [ + 'get_application', + 'list_deployments', + 'get_deployment', + 'list_env_keys', + 'list_shared_env_keys', + 'list_application_previews', + 'list_storages', + 'list_scheduled_tasks', + 'get_logs', + ], + 'notes' => [ + 'get_logs needs a running container on a reachable server; on failure it returns reason + next_tools.', + 'Use get_deployment with include_log_summary=true for build failures.', + 'Prompts: troubleshoot_application, explain_failed_deploy.', + ], + ], + 'deploy' => [ + 'description' => 'Lifecycle actions (requires token ability: deploy)', + 'tools' => [ + 'control', + 'deploy', + 'cancel_deployment', + 'list_deployments', + 'get_deployment', + ], + 'notes' => [ + 'stop requires confirm=true.', + 'Read-only tokens receive a clear missing_ability error.', + ], + ], + 'github' => [ + 'description' => 'GitHub apps / repos / branches (list_github_apps is DB-only; repos/branches call GitHub)', + 'tools' => [ + 'list_github_apps', + 'list_github_repositories', + 'list_github_branches', + ], + ], + 'team' => [ + 'description' => 'Team identity', + 'tools' => [ + 'get_current_team', + 'list_team_members', + ], + ], + 'essentials' => [ + 'description' => 'Minimal set for most questions', + 'tools' => [ + 'coolify_help', + 'get_infrastructure_overview', + 'search_resources', + 'list_unhealthy_resources', + 'list_applications', + 'get_application', + 'list_deployments', + 'get_deployment', + 'list_env_keys', + 'get_logs', + 'control', + 'deploy', + ], + ], + ]; + + if ($intent !== null) { + if (! isset($catalog[$intent])) { + return $this->mcpError($request, 'Unknown intent. Use: '.implode(', ', array_keys($catalog))); + } + + return $this->mcpSuccess($request, $this->respond([ + 'intent' => $intent, + 'catalog' => [$intent => $catalog[$intent]], + 'workflow' => $this->workflowHints(), + ])); + } + + return $this->mcpSuccess($request, $this->respond([ + 'intents' => array_keys($catalog), + 'catalog' => $catalog, + 'workflow' => $this->workflowHints(), + ])); + } + + /** + * @return array + */ + private function workflowHints(): array + { + return [ + 'Start with coolify_help, get_infrastructure_overview, search_resources, or list_unhealthy_resources (sample_only=true).', + 'Resolve a UUID, then get_* for details.', + 'Debug with list_deployments / get_deployment(include_log_summary) before get_logs.', + 'get_logs is optional and fails structured when not running or server unreachable.', + 'Never request env values or secrets over MCP.', + ]; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'intent' => $schema->string()->description('Optional: overview | search | inventory | debug | deploy | github | team | essentials.'), + ]; + } +} diff --git a/app/Mcp/Tools/Deploy.php b/app/Mcp/Tools/Deploy.php new file mode 100644 index 000000000..769b7cacd --- /dev/null +++ b/app/Mcp/Tools/Deploy.php @@ -0,0 +1,93 @@ +ensureAbility($request, 'deploy', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(); + if (! $application) { + return $this->mcpError($request, "Application [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $force = filter_var($request->get('force'), FILTER_VALIDATE_BOOLEAN); + $pullRequestId = (int) ($request->get('pull_request_id') ?? 0); + $deploymentUuid = new_public_id(); + + $result = queue_application_deployment( + application: $application, + deployment_uuid: $deploymentUuid, + pull_request_id: $pullRequestId, + force_rebuild: $force, + is_api: true, + no_questions_asked: true, + ); + + if (($result['status'] ?? null) === 'skipped' || ($result['status'] ?? null) === 'queue_full') { + return $this->mcpSuccess($request, $this->respond([ + 'ok' => false, + 'message' => $result['message'] ?? 'Deployment not queued.', + 'deployment_uuid' => null, + ]), ['resource_uuid' => $uuid]); + } + + auditLog('mcp.deploy', [ + 'team_id' => $teamId, + 'application_uuid' => $uuid, + 'deployment_uuid' => $deploymentUuid, + 'force_rebuild' => $force, + 'pull_request_id' => $pullRequestId, + ]); + + return $this->mcpSuccess($request, $this->respond([ + 'ok' => true, + 'message' => 'Deployment request queued.', + 'application_uuid' => $uuid, + 'deployment_uuid' => $deploymentUuid, + 'force' => $force, + 'pull_request_id' => $pullRequestId, + 'next_tools' => [ + ['tool' => 'get_deployment', 'args' => ['uuid' => $deploymentUuid, 'include_log_summary' => true], 'hint' => 'Poll status / failure summary'], + ], + ]), ['resource_uuid' => $uuid, 'deployment_uuid' => $deploymentUuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Application UUID.')->required(), + 'force' => $schema->boolean()->description('Force rebuild (default false).'), + 'pull_request_id' => $schema->integer()->description('Optional PR id for preview deploy.'), + ]; + } +} diff --git a/app/Mcp/Tools/GetCurrentTeam.php b/app/Mcp/Tools/GetCurrentTeam.php new file mode 100644 index 000000000..43f622646 --- /dev/null +++ b/app/Mcp/Tools/GetCurrentTeam.php @@ -0,0 +1,53 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $team = Team::query()->find($teamId); + if (! $team) { + return $this->mcpError($request, 'Team not found.'); + } + + // Teams have no public UUID column; expose stable name + flags only. + // Numeric team ids are intentionally omitted (scrubSensitive policy). + return $this->mcpSuccess($request, $this->respond($this->scrubSensitive([ + 'name' => $team->name, + 'description' => $team->description ?? null, + 'personal_team' => (bool) ($team->personal_team ?? false), + 'member_count' => $team->members()->count(), + 'is_mcp_server_enabled' => (bool) $team->is_mcp_server_enabled, + ]))); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/app/Mcp/Tools/GetDeployment.php b/app/Mcp/Tools/GetDeployment.php new file mode 100644 index 000000000..b07894462 --- /dev/null +++ b/app/Mcp/Tools/GetDeployment.php @@ -0,0 +1,183 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $includeSummary = filter_var($request->get('include_log_summary'), FILTER_VALIDATE_BOOLEAN); + if ($includeSummary && $error = $this->ensureAbility($request, 'read:sensitive', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $deployment = ApplicationDeploymentQueue::where('deployment_uuid', $uuid)->first(); + if (! $deployment) { + return $this->mcpError($request, "Deployment [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + // Include soft-deleted apps so mid-deploy deletes remain inspectable/cancellable. + $application = $deployment->application()->withTrashed()->first(); + $appTeamId = $application?->team()?->id; + if (! $application || (int) $appTeamId !== $teamId) { + return $this->mcpError($request, "Deployment [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $data = $this->scrubSensitive([ + 'deployment_uuid' => $deployment->deployment_uuid, + 'application_uuid' => $application->uuid, + 'application_name' => $deployment->application_name, + 'server_name' => $deployment->server_name, + 'status' => $deployment->status, + 'commit' => $deployment->commit, + 'commit_message' => $deployment->commit_message, + 'pull_request_id' => $deployment->pull_request_id, + 'force_rebuild' => $deployment->force_rebuild, + 'is_webhook' => $deployment->is_webhook, + 'is_api' => $deployment->is_api, + 'restart_only' => $deployment->restart_only, + 'rollback' => $deployment->rollback, + 'git_type' => $deployment->git_type, + 'deployment_url' => $deployment->deployment_url, + 'docker_registry_image_tag' => $deployment->docker_registry_image_tag, + 'created_at' => $deployment->created_at, + 'updated_at' => $deployment->updated_at, + 'finished_at' => $deployment->finished_at, + ]); + + if ($includeSummary) { + $lines = max(1, min(self::MAX_LOG_LINES, (int) ($request->get('log_lines') ?? self::DEFAULT_LOG_LINES))); + $data['log_summary'] = $this->buildLogSummary($deployment, $lines); + } + + return $this->mcpSuccess($request, $this->respond( + $data, + $this->actionsForDeployment($uuid, $application->uuid), + ), ['resource_uuid' => $uuid]); + } + + /** + * @return array{available: bool, lines: int, truncated: bool, text: string|null} + */ + private function buildLogSummary(ApplicationDeploymentQueue $deployment, int $lines): array + { + $raw = $deployment->getRawOriginal('logs') ?? $deployment->getAttributes()['logs'] ?? null; + if (! is_string($raw) || $raw === '') { + // logs may be hidden — force read from DB attribute bag + $raw = $deployment->getAttributes()['logs'] ?? null; + } + + if (! is_string($raw) || trim($raw) === '') { + return [ + 'available' => false, + 'lines' => 0, + 'truncated' => false, + 'text' => null, + ]; + } + + try { + $entries = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); + } catch (\Throwable) { + $text = $this->redactLogText((string) $raw); + $allLines = preg_split('/\r\n|\r|\n/', $text) ?: [$text]; + $tail = array_slice($allLines, -$lines); + $text = implode("\n", $tail); + $truncated = count($allLines) > $lines || strlen($text) > self::MAX_LOG_CHARS; + $text = $this->truncateText($text, self::MAX_LOG_CHARS); + + return [ + 'available' => true, + 'lines' => substr_count($text, "\n") + 1, + 'truncated' => $truncated, + 'text' => $text, + ]; + } + + if (! is_array($entries)) { + return [ + 'available' => false, + 'lines' => 0, + 'truncated' => false, + 'text' => null, + ]; + } + + $outputs = collect($entries) + ->filter(fn ($e) => is_array($e) && ! ($e['hidden'] ?? false)) + ->map(function ($e) { + $type = $e['type'] ?? 'stdout'; + $output = (string) ($e['output'] ?? ''); + $prefix = $type === 'stderr' || $type === 'error' ? '[err] ' : ''; + + return $prefix.$this->redactLogText($output); + }) + ->filter(fn ($line) => trim($line) !== '') + ->values(); + + $tail = $outputs->slice(max(0, $outputs->count() - $lines))->values(); + $text = $tail->implode("\n"); + $truncated = $outputs->count() > $lines || strlen($text) > self::MAX_LOG_CHARS; + $text = $this->truncateText($text, self::MAX_LOG_CHARS); + + return [ + 'available' => true, + 'lines' => $tail->count(), + 'truncated' => $truncated, + 'text' => $text, + ]; + } + + private function truncateText(string $text, int $max): string + { + if (strlen($text) <= $max) { + return $text; + } + + return substr($text, -$max); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Deployment UUID.')->required(), + 'include_log_summary' => $schema->boolean()->description('If true, include a capped redacted tail of deploy output; requires read:sensitive (default false).'), + 'log_lines' => $schema->integer()->description('Log summary lines when include_log_summary is true (default 40, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/GetDestination.php b/app/Mcp/Tools/GetDestination.php new file mode 100644 index 000000000..5065f799a --- /dev/null +++ b/app/Mcp/Tools/GetDestination.php @@ -0,0 +1,71 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $destination = StandaloneDocker::with('server:id,uuid') + ->whereHas('server', fn ($q) => $q->whereTeamId($teamId)) + ->whereUuid($uuid) + ->first() + ?? SwarmDocker::with('server:id,uuid') + ->whereHas('server', fn ($q) => $q->whereTeamId($teamId)) + ->whereUuid($uuid) + ->first(); + + if (! $destination) { + return $this->mcpError($request, "Destination [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $type = $destination instanceof SwarmDocker ? 'swarm' : 'standalone'; + + return $this->mcpSuccess($request, $this->respond([ + 'uuid' => $destination->uuid, + 'name' => $destination->name, + 'network' => $destination->network, + 'type' => $type, + 'server_uuid' => $destination->server?->uuid, + 'created_at' => $destination->created_at, + 'updated_at' => $destination->updated_at, + ]), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Destination UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/GetEnvironment.php b/app/Mcp/Tools/GetEnvironment.php new file mode 100644 index 000000000..4ae9073e8 --- /dev/null +++ b/app/Mcp/Tools/GetEnvironment.php @@ -0,0 +1,167 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $projectUuid = $request->get('project_uuid'); + $envKey = $request->get('environment_name_or_uuid'); + + if (! is_string($projectUuid) || $projectUuid === '') { + return $this->mcpError($request, 'project_uuid argument is required.'); + } + if (! is_string($envKey) || $envKey === '') { + return $this->mcpError($request, 'environment_name_or_uuid argument is required.'); + } + + $project = Project::where('team_id', $teamId)->where('uuid', $projectUuid)->first(); + if (! $project) { + return $this->mcpError($request, "Project [{$projectUuid}] not found.", ['resource_uuid' => $projectUuid]); + } + + $environment = Environment::where('project_id', $project->id) + ->where(function ($q) use ($envKey) { + $q->where('uuid', $envKey)->orWhere('name', $envKey); + }) + ->first(); + + if (! $environment) { + return $this->mcpError($request, "Environment [{$envKey}] not found.", ['resource_uuid' => $envKey]); + } + + $samplePerType = max(1, min($this->maxPerPage, (int) ($request->get('sample_per_type') ?? $this->defaultPerPage))); + + $appQuery = $environment->applications()->orderBy('name')->orderBy('id'); + $appCount = (clone $appQuery)->count(); + $applications = $appQuery + ->limit($samplePerType) + ->get() + ->map(fn ($app) => [ + 'uuid' => $app->uuid, + 'name' => $app->name, + 'status' => $app->status, + 'fqdn' => $app->fqdn, + ]) + ->values() + ->all(); + + $serviceQuery = $environment->services()->orderBy('name')->orderBy('id'); + $serviceCount = (clone $serviceQuery)->count(); + $services = $serviceQuery + ->limit($samplePerType) + ->get() + ->map(fn ($svc) => [ + 'uuid' => $svc->uuid, + 'name' => $svc->name, + 'status' => $svc->status ?? null, + ]) + ->values() + ->all(); + + // databases() returns a merged Collection (polymorphic standalone types), not a builder. + $allDatabases = $environment->databases() + ->sortBy(fn ($db) => strtolower((string) ($db->name ?? '')), SORT_NATURAL) + ->values(); + $databaseCount = $allDatabases->count(); + $databases = $allDatabases + ->take($samplePerType) + ->map(fn ($db) => [ + 'uuid' => $db->uuid, + 'name' => $db->name, + 'status' => $db->status ?? null, + 'type' => method_exists($db, 'type') ? $db->type() : class_basename($db), + ]) + ->values() + ->all(); + + $truncated = [ + 'applications' => $appCount > $samplePerType, + 'services' => $serviceCount > $samplePerType, + 'databases' => $databaseCount > $samplePerType, + ]; + + $nextTools = []; + $envUuid = $environment->uuid; + if ($truncated['applications']) { + $nextTools[] = [ + 'tool' => 'list_applications', + 'args' => ['environment_uuid' => $envUuid], + 'hint' => 'Full application list for this environment', + ]; + } + if ($truncated['services']) { + $nextTools[] = [ + 'tool' => 'list_services', + 'args' => ['environment_uuid' => $envUuid], + 'hint' => 'Full service list for this environment', + ]; + } + if ($truncated['databases']) { + $nextTools[] = [ + 'tool' => 'list_databases', + 'args' => ['environment_uuid' => $envUuid], + 'hint' => 'Full database list for this environment', + ]; + } + + $data = [ + 'uuid' => $environment->uuid, + 'name' => $environment->name, + 'project' => [ + 'uuid' => $project->uuid, + 'name' => $project->name, + ], + 'sample_per_type' => $samplePerType, + 'counts' => [ + 'applications' => $appCount, + 'services' => $serviceCount, + 'databases' => $databaseCount, + ], + 'truncated' => $truncated, + 'applications' => $applications, + 'services' => $services, + 'databases' => $databases, + 'next_tools' => $nextTools, + ]; + + return $this->mcpSuccess($request, $this->respond($this->scrubSensitive($data)), [ + 'resource_uuid' => $environment->uuid, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'project_uuid' => $schema->string()->description('Project UUID.')->required(), + 'environment_name_or_uuid' => $schema->string()->description('Environment name or UUID.')->required(), + 'sample_per_type' => $schema->integer()->description('Max resources per type in the sample (default 50, max 100). Use list_* tools for full inventory.'), + ]; + } +} diff --git a/app/Mcp/Tools/GetInfrastructureOverview.php b/app/Mcp/Tools/GetInfrastructureOverview.php index 6fcafa316..8496b8b33 100644 --- a/app/Mcp/Tools/GetInfrastructureOverview.php +++ b/app/Mcp/Tools/GetInfrastructureOverview.php @@ -3,10 +3,16 @@ namespace App\Mcp\Tools; use App\Mcp\Concerns\BuildsResponse; +use App\Mcp\Concerns\McpStatusFilters; use App\Mcp\Concerns\ResolvesTeam; +use App\Models\Application; +use App\Models\ApplicationDeploymentQueue; +use App\Models\Environment; use App\Models\Project; use App\Models\Server; +use App\Models\Service; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Collection; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; @@ -15,9 +21,10 @@ class GetInfrastructureOverview extends Tool { protected string $name = 'get_infrastructure_overview'; - protected string $description = 'High-level overview of the authenticated team: Coolify version, all servers, projects with resource counts, and aggregate counts. Start here to understand the setup.'; + protected string $description = 'High-level overview of the authenticated team: Coolify version, servers, projects with resource counts, open deployments, and SQL-based health_hints counts. Start here or with coolify_help / search_resources.'; use BuildsResponse; + use McpStatusFilters; use ResolvesTeam; public function handle(Request $request): Response @@ -34,7 +41,9 @@ public function handle(Request $request): Response $servers = Server::whereTeamId($teamId) ->select('id', 'name', 'uuid', 'ip', 'description') ->with('settings:id,server_id,is_reachable,is_usable') - ->get() + ->get(); + + $serverSummaries = $servers ->map(fn ($s) => [ 'uuid' => $s->uuid, 'name' => $s->name, @@ -45,7 +54,26 @@ public function handle(Request $request): Response ->values() ->all(); - $projects = Project::where('team_id', $teamId)->get(); + $unreachableServers = Server::whereTeamId($teamId) + ->whereHas('settings', fn ($q) => $q->where('is_reachable', false)) + ->count(); + + // One query with relation counts (avoids per-project applications/services/databases fan-out). + $projects = Project::where('team_id', $teamId) + ->select('id', 'uuid', 'name') + ->withCount([ + 'applications', + 'services', + 'postgresqls', + 'redis', + 'mongodbs', + 'mysqls', + 'mariadbs', + 'keydbs', + 'dragonflies', + 'clickhouses', + ]) + ->get(); $appCount = 0; $serviceCount = 0; @@ -53,9 +81,18 @@ public function handle(Request $request): Response $projectSummaries = []; foreach ($projects as $project) { - $apps = $project->applications()->count(); - $services = $project->services()->count(); - $databases = $project->databases()->count(); + $apps = (int) $project->applications_count; + $services = (int) $project->services_count; + $databases = (int) ( + $project->postgresqls_count + + $project->redis_count + + $project->mongodbs_count + + $project->mysqls_count + + $project->mariadbs_count + + $project->keydbs_count + + $project->dragonflies_count + + $project->clickhouses_count + ); $appCount += $apps; $serviceCount += $services; @@ -72,20 +109,106 @@ public function handle(Request $request): Response ]; } + // application_deployment_queues.application_id is varchar; whereHas joins to + // applications.id (bigint) and breaks on PostgreSQL. Scope via string IDs instead. + $teamApplicationIds = Application::ownedByCurrentTeamAPI($teamId) + ->pluck('id') + ->map(fn ($id) => (string) $id); + + $openDeployments = ApplicationDeploymentQueue::query() + ->whereIn('application_id', $teamApplicationIds) + ->whereIn('status', ['in_progress', 'queued']) + ->count(); + + // Count-only health hints (SQL for apps/DBs; chunked scan for service aggregated status). + $appNotRunningQuery = Application::ownedByCurrentTeamAPI($teamId); + $this->scopeNotHealthyRunning($appNotRunningQuery); + $nonRunningApps = $appNotRunningQuery->count(); + + $nonRunningServices = $this->countUnhealthyServices($teamId); + $nonRunningDatabases = $this->countUnhealthyDatabases($projects->pluck('id')); + return $this->mcpSuccess($request, $this->respond([ 'coolify_version' => config('constants.coolify.version'), - 'servers' => $servers, + 'servers' => $serverSummaries, 'projects' => $projectSummaries, 'counts' => [ - 'servers' => count($servers), + 'servers' => count($serverSummaries), 'projects' => count($projectSummaries), 'applications' => $appCount, 'services' => $serviceCount, 'databases' => $databaseCount, + 'open_deployments' => $openDeployments, + ], + 'health_hints' => [ + 'unreachable_servers' => $unreachableServers, + 'applications_not_running' => $nonRunningApps, + 'services_not_running' => $nonRunningServices, + 'databases_not_running' => $nonRunningDatabases, + 'next' => [ + 'tool' => 'list_unhealthy_resources', + 'args' => ['sample_only' => true], + 'hint' => 'Sample unhealthy resources + full counts', + ], ], ])); } + /** + * Service status is a computed accessor over child apps/DBs — scan in chunks + * so large teams never hydrate every service at once for a count. + */ + private function countUnhealthyServices(int $teamId): int + { + $count = 0; + + Service::whereHas('environment.project', fn ($q) => $q->where('team_id', $teamId)) + ->with([ + 'applications:id,service_id,status,exclude_from_status', + 'databases:id,service_id,status,exclude_from_status', + ]) + ->select(['id', 'uuid']) + ->orderBy('id') + ->chunkById(100, function ($chunk) use (&$count) { + foreach ($chunk as $service) { + if (! $this->looksHealthy($service->status ?? null)) { + $count++; + } + } + }); + + return $count; + } + + /** + * One env-id fetch + one scoped count per standalone DB model (constant query count). + * + * @param Collection $projectIds + */ + private function countUnhealthyDatabases(Collection $projectIds): int + { + if ($projectIds->isEmpty()) { + return 0; + } + + $environmentIds = Environment::query() + ->whereIn('project_id', $projectIds) + ->pluck('id'); + + if ($environmentIds->isEmpty()) { + return 0; + } + + $count = 0; + foreach (STANDALONE_DATABASE_MODELS as $modelClass) { + $dbQuery = $modelClass::query()->whereIn('environment_id', $environmentIds); + $this->scopeNotHealthyRunning($dbQuery); + $count += $dbQuery->count(); + } + + return $count; + } + public function schema(JsonSchema $schema): array { return []; diff --git a/app/Mcp/Tools/GetLogs.php b/app/Mcp/Tools/GetLogs.php new file mode 100644 index 000000000..6635fcc58 --- /dev/null +++ b/app/Mcp/Tools/GetLogs.php @@ -0,0 +1,338 @@ +ensureAbility($request, 'read:sensitive', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $resourceType = $request->get('resource'); + $uuid = $request->get('uuid'); + $parentUuid = $request->get('parent_uuid'); + + if (! is_string($resourceType) || ! $this->isValidResourceType($resourceType, $this->logResourceTypes)) { + return $this->mcpError($request, 'resource must be one of: application, database, service, service_application, service_database.'); + } + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $resource = $this->resolveTeamLogResource( + $teamId, + $resourceType, + $uuid, + is_string($parentUuid) ? $parentUuid : null, + ); + + if (! $resource) { + return $this->mcpError($request, ucfirst(str_replace('_', ' ', $resourceType))." [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $lines = $this->normalizeMcpLogLines($request->get('lines')); + $showTimestamps = parseLogTimestampFlag($request->get('show_timestamps')); + + $failure = $this->preflightFailure($resource, $resourceType, $uuid); + if ($failure !== null) { + return $this->mcpSuccess($request, $this->respond($failure), ['resource_uuid' => $uuid, 'outcome' => 'unavailable']); + } + + try { + $logs = $this->redactLogText( + $this->fetchLogs($resource, $resourceType, $lines, $showTimestamps) + ); + } catch (MultipleContainersException $e) { + $payload = $this->failurePayload( + reason: 'multiple_containers', + message: $e->getMessage(), + resourceType: $resourceType, + uuid: $uuid, + status: $resource->status ?? null, + server: $this->resolveServerMeta($resource, $resourceType), + ); + $payload['choices'] = $e->choices; + + return $this->mcpSuccess($request, $this->respond($payload), ['resource_uuid' => $uuid, 'outcome' => 'multiple_containers']); + } catch (\Throwable $e) { + return $this->mcpSuccess($request, $this->respond( + $this->failurePayload( + reason: 'log_fetch_failed', + message: $e->getMessage(), + resourceType: $resourceType, + uuid: $uuid, + status: $resource->status ?? null, + server: $this->resolveServerMeta($resource, $resourceType), + ) + ), ['resource_uuid' => $uuid, 'outcome' => 'error']); + } + + return $this->mcpSuccess($request, $this->respond([ + 'ok' => true, + 'resource' => $resourceType, + 'uuid' => $uuid, + 'lines' => $lines, + 'logs' => $logs, + 'redacted' => true, + ]), ['resource_uuid' => $uuid]); + } + + /** + * @return array|null + */ + private function preflightFailure(mixed $resource, string $resourceType, string $uuid): ?array + { + $status = $resource->status ?? null; + $serverMeta = $this->resolveServerMeta($resource, $resourceType); + + // Prefer status-based failure (DB-only) before host reachability so agents + // can continue with deploy history without needing a live SSH path. + if (is_string($status) && $status !== '' && ! str_starts_with(strtolower($status), 'running')) { + return $this->failurePayload( + reason: 'not_running', + message: 'Resource is not running; live container logs are unavailable.', + resourceType: $resourceType, + uuid: $uuid, + status: $status, + server: $serverMeta, + ); + } + + if ($serverMeta === null) { + return $this->failurePayload( + reason: 'no_server', + message: 'Resource has no server destination; cannot fetch logs.', + resourceType: $resourceType, + uuid: $uuid, + status: $status, + server: null, + ); + } + + if ($serverMeta['is_reachable'] === false) { + return $this->failurePayload( + reason: 'server_unreachable', + message: 'Destination server is not reachable from Coolify; cannot fetch live logs.', + resourceType: $resourceType, + uuid: $uuid, + status: $status, + server: $serverMeta, + ); + } + + return null; + } + + /** + * @param array{uuid: ?string, name: ?string, is_reachable: ?bool}|null $server + * @return array + */ + private function failurePayload( + string $reason, + string $message, + string $resourceType, + string $uuid, + mixed $status, + ?array $server, + ): array { + $next = [ + ['tool' => 'list_unhealthy_resources', 'args' => new \stdClass, 'hint' => 'See all unhealthy resources'], + ['tool' => 'list_deployments', 'args' => $resourceType === 'application' ? ['application_uuid' => $uuid] : new \stdClass, 'hint' => 'Check deploy history'], + ]; + + if ($resourceType === 'application') { + $next[] = ['tool' => 'get_application', 'args' => ['uuid' => $uuid], 'hint' => 'Refresh application status']; + $next[] = ['tool' => 'get_deployment', 'args' => ['uuid' => '{deployment_uuid}', 'include_log_summary' => true], 'hint' => 'If a failed deploy exists, use its UUID']; + $next[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start/deploy if token has deploy ability']; + } elseif ($resourceType === 'database') { + $next[] = ['tool' => 'get_database', 'args' => ['uuid' => $uuid], 'hint' => 'Refresh database status']; + $next[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start if token has deploy ability']; + } elseif ($resourceType === 'service') { + $next[] = ['tool' => 'get_service', 'args' => ['uuid' => $uuid], 'hint' => 'Refresh service status']; + $next[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start if token has deploy ability']; + } + + return [ + 'ok' => false, + 'reason' => $reason, + 'message' => $message, + 'resource' => $resourceType, + 'uuid' => $uuid, + 'status' => $status, + 'server' => $server, + 'next_tools' => $next, + ]; + } + + /** + * @return array{uuid: ?string, name: ?string, is_reachable: ?bool}|null + */ + private function resolveServerMeta(mixed $resource, string $resourceType): ?array + { + $server = null; + if ($resource instanceof Application || $resourceType === 'database') { + $server = $resource->destination?->server; + } elseif ($resource instanceof Service) { + $server = $resource->server; + } elseif ($resource instanceof ServiceApplication || $resource instanceof ServiceDatabase) { + $server = $resource->service?->server; + } + + if (! $server) { + return null; + } + + return [ + 'uuid' => $server->uuid, + 'name' => $server->name, + 'is_reachable' => $server->settings?->is_reachable, + ]; + } + + private function fetchLogs(mixed $resource, string $resourceType, int $lines, bool $showTimestamps): string + { + if ($resource instanceof Application) { + $server = $resource->destination?->server; + if (! $server) { + throw new \RuntimeException('Application has no server destination.'); + } + $containers = getCurrentApplicationContainerStatus($server, $resource->id); + if ($containers->count() === 0) { + throw new \RuntimeException('Application has no running containers.'); + } + $container = $containers->first(); + $status = getContainerStatus($server, $container['Names']); + if ($status !== 'running') { + throw new \RuntimeException('Application container is not running.'); + } + + return (string) getContainerLogs($server, $container['ID'], $lines, $showTimestamps); + } + + if ($resourceType === 'database') { + $server = $resource->destination?->server; + if (! $server) { + throw new \RuntimeException('Database has no server destination.'); + } + $status = getContainerStatus($server, $resource->uuid); + if ($status !== 'running') { + throw new \RuntimeException('Database is not running.'); + } + + return (string) getContainerLogs($server, $resource->uuid, $lines, $showTimestamps); + } + + if ($resource instanceof Service) { + $server = $resource->server; + if (! $server) { + throw new \RuntimeException('Service has no server.'); + } + + $apps = $resource->applications()->get(['id', 'uuid', 'name', 'service_id', 'status']); + $dbs = $resource->databases()->get(['id', 'uuid', 'name', 'service_id', 'status']); + $total = $apps->count() + $dbs->count(); + + if ($total === 0) { + throw new \RuntimeException('Service has no containers.'); + } + + // Multi-container services need an explicit child resource type. + if ($total > 1) { + $choices = $apps->map(fn ($app) => [ + 'resource' => 'service_application', + 'uuid' => $app->uuid, + 'name' => $app->name, + ])->concat($dbs->map(fn ($db) => [ + 'resource' => 'service_database', + 'uuid' => $db->uuid, + 'name' => $db->name, + ]))->values()->all(); + + throw new MultipleContainersException( + 'Service has multiple containers. Call get_logs with resource=service_application or service_database and the child uuid.', + $choices, + ); + } + + $child = $apps->first() ?? $dbs->first(); + $containerName = $child->name.'-'.$resource->uuid; + $status = getContainerStatus($server, $containerName); + if ($status !== 'running') { + throw new \RuntimeException('Service container is not running.'); + } + + return (string) getContainerLogs($server, $containerName, $lines, $showTimestamps); + } + + if ($resource instanceof ServiceApplication || $resource instanceof ServiceDatabase) { + $service = $resource->service; + $server = $service?->server; + if (! $server) { + throw new \RuntimeException('Service child has no server.'); + } + $containerName = $resource->name.'-'.$service->uuid; + $status = getContainerStatus($server, $containerName); + if ($status !== 'running') { + throw new \RuntimeException('Container is not running.'); + } + + return (string) getContainerLogs($server, $containerName, $lines, $showTimestamps); + } + + throw new \RuntimeException('Unsupported resource type for logs.'); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'resource' => $schema->string()->description('application | database | service | service_application | service_database')->required(), + 'uuid' => $schema->string()->description('Resource UUID.')->required(), + 'parent_uuid' => $schema->string()->description('Optional parent service UUID for service_application / service_database.'), + 'lines' => $schema->integer()->description('Number of log lines (default 100, max 500).'), + 'show_timestamps' => $schema->boolean()->description('Include timestamps in log output.'), + ]; + } +} + +/** + * Service has more than one child container; caller must pick service_application or service_database. + */ +class MultipleContainersException extends \RuntimeException +{ + /** + * @param list $choices + */ + public function __construct( + string $message, + public readonly array $choices, + ) { + parent::__construct($message); + } +} diff --git a/app/Mcp/Tools/GetProject.php b/app/Mcp/Tools/GetProject.php new file mode 100644 index 000000000..8c967f814 --- /dev/null +++ b/app/Mcp/Tools/GetProject.php @@ -0,0 +1,77 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $project = Project::where('team_id', $teamId)->where('uuid', $uuid)->first(); + if (! $project) { + return $this->mcpError($request, "Project [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $environments = $project->environments() + ->orderBy('name') + ->get() + ->map(fn ($env) => [ + 'uuid' => $env->uuid, + 'name' => $env->name, + ]) + ->values() + ->all(); + + $data = [ + 'uuid' => $project->uuid, + 'name' => $project->name, + 'description' => $project->description, + 'environments' => $environments, + 'counts' => [ + 'applications' => $project->applications()->count(), + 'services' => $project->services()->count(), + 'databases' => $project->databases()->count(), + ], + ]; + + return $this->mcpSuccess($request, $this->respond( + $this->scrubSensitive($data), + $this->actionsForProject($uuid), + ), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Project UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/GetServerDomains.php b/app/Mcp/Tools/GetServerDomains.php new file mode 100644 index 000000000..7780f5971 --- /dev/null +++ b/app/Mcp/Tools/GetServerDomains.php @@ -0,0 +1,93 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $server = Server::whereTeamId($teamId)->whereUuid($uuid)->first(); + if (! $server) { + return $this->mcpError($request, "Server [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $standaloneDockerIds = $server->standaloneDockers()->pluck('id'); + $swarmDockerIds = $server->swarmDockers()->pluck('id'); + + $applications = Application::ownedByCurrentTeamAPI($teamId) + ->where(function ($query) use ($standaloneDockerIds, $swarmDockerIds) { + $query->where(function ($q) use ($standaloneDockerIds) { + $q->where('destination_type', StandaloneDocker::class) + ->whereIn('destination_id', $standaloneDockerIds); + })->orWhere(function ($q) use ($swarmDockerIds) { + $q->where('destination_type', SwarmDocker::class) + ->whereIn('destination_id', $swarmDockerIds); + }); + }) + ->get(['uuid', 'name', 'fqdn']); + + $domains = collect(); + + foreach ($applications as $application) { + $fqdn = str($application->fqdn)->explode(',')->map(function ($fqdn) { + $f = str($fqdn)->replace('http://', '')->replace('https://', '')->explode('/'); + + return str(str($f[0])->explode(':')[0]); + })->filter(fn (Stringable $f) => $f->isNotEmpty()); + + if ($fqdn->isNotEmpty()) { + $domains->push([ + 'resource_type' => 'application', + 'resource_uuid' => $application->uuid, + 'resource_name' => $application->name, + 'domains' => $fqdn->map(fn ($d) => (string) $d)->values()->all(), + ]); + } + } + + return $this->mcpSuccess($request, $this->respond([ + 'server_uuid' => $server->uuid, + 'domains' => $domains->values()->all(), + ]), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Server UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/GetServerResources.php b/app/Mcp/Tools/GetServerResources.php new file mode 100644 index 000000000..d796e9315 --- /dev/null +++ b/app/Mcp/Tools/GetServerResources.php @@ -0,0 +1,64 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $server = Server::whereTeamId($teamId)->whereUuid($uuid)->first(); + if (! $server) { + return $this->mcpError($request, "Server [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $resources = $server->definedResources()->map(fn ($resource) => $this->scrubSensitive([ + 'uuid' => $resource->uuid, + 'name' => $resource->name, + 'type' => method_exists($resource, 'type') ? $resource->type() : class_basename($resource), + 'status' => $resource->status ?? null, + 'created_at' => $resource->created_at, + 'updated_at' => $resource->updated_at, + ]))->values()->all(); + + return $this->mcpSuccess($request, $this->respond([ + 'server_uuid' => $server->uuid, + 'resources' => $resources, + ]), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Server UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/GetServiceApplication.php b/app/Mcp/Tools/GetServiceApplication.php new file mode 100644 index 000000000..0d86d0315 --- /dev/null +++ b/app/Mcp/Tools/GetServiceApplication.php @@ -0,0 +1,87 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $serviceUuid = $request->get('service_uuid'); + $uuid = $request->get('uuid'); + + if (! is_string($serviceUuid) || $serviceUuid === '') { + return $this->mcpError($request, 'service_uuid argument is required.'); + } + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $app = ServiceApplication::ownedByCurrentTeamAPI($teamId) + ->where('uuid', $uuid) + ->whereHas('service', fn ($q) => $q->where('uuid', $serviceUuid)) + ->first(); + + if (! $app) { + return $this->mcpError($request, "Service application [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + // Explicit whitelist so new model columns stay private by default (fail-closed). + $data = $this->scrubSensitive([ + 'uuid' => $app->uuid, + 'service_uuid' => $serviceUuid, + 'name' => $app->name, + 'human_name' => $app->human_name, + 'description' => $app->description, + 'status' => $app->status, + 'fqdn' => $app->fqdn, + 'ports' => $app->ports, + 'exposes' => $app->exposes, + 'image' => $app->image, + 'exclude_from_status' => $app->exclude_from_status, + 'required_fqdn' => $app->required_fqdn, + 'is_log_drain_enabled' => $app->is_log_drain_enabled, + 'is_include_timestamps' => $app->is_include_timestamps, + 'is_gzip_enabled' => $app->is_gzip_enabled, + 'is_stripprefix_enabled' => $app->is_stripprefix_enabled, + 'last_online_at' => $app->last_online_at, + 'created_at' => $app->created_at, + 'updated_at' => $app->updated_at, + ]); + + return $this->mcpSuccess($request, $this->respond($data, [ + ['tool' => 'get_logs', 'args' => ['resource' => 'service_application', 'uuid' => $uuid, 'parent_uuid' => $serviceUuid], 'hint' => 'Container logs'], + ]), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'service_uuid' => $schema->string()->description('Parent service UUID.')->required(), + 'uuid' => $schema->string()->description('Service application UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/GetServiceDatabase.php b/app/Mcp/Tools/GetServiceDatabase.php new file mode 100644 index 000000000..fa3da3863 --- /dev/null +++ b/app/Mcp/Tools/GetServiceDatabase.php @@ -0,0 +1,89 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $serviceUuid = $request->get('service_uuid'); + $uuid = $request->get('uuid'); + + if (! is_string($serviceUuid) || $serviceUuid === '') { + return $this->mcpError($request, 'service_uuid argument is required.'); + } + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $db = ServiceDatabase::ownedByCurrentTeamAPI($teamId) + ->where('uuid', $uuid) + ->whereHas('service', fn ($q) => $q->where('uuid', $serviceUuid)) + ->first(); + + if (! $db) { + return $this->mcpError($request, "Service database [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + // Explicit whitelist so new model columns stay private by default (fail-closed). + $data = $this->scrubSensitive([ + 'uuid' => $db->uuid, + 'service_uuid' => $serviceUuid, + 'name' => $db->name, + 'human_name' => $db->human_name, + 'description' => $db->description, + 'status' => $db->status, + 'fqdn' => $db->fqdn, + 'ports' => $db->ports, + 'exposes' => $db->exposes, + 'image' => $db->image, + 'exclude_from_status' => $db->exclude_from_status, + 'public_port' => $db->public_port, + 'is_public' => $db->is_public, + 'is_log_drain_enabled' => $db->is_log_drain_enabled, + 'is_include_timestamps' => $db->is_include_timestamps, + 'is_gzip_enabled' => $db->is_gzip_enabled, + 'is_stripprefix_enabled' => $db->is_stripprefix_enabled, + 'custom_type' => $db->custom_type, + 'last_online_at' => $db->last_online_at, + 'created_at' => $db->created_at, + 'updated_at' => $db->updated_at, + ]); + + return $this->mcpSuccess($request, $this->respond($data, [ + ['tool' => 'get_logs', 'args' => ['resource' => 'service_database', 'uuid' => $uuid, 'parent_uuid' => $serviceUuid], 'hint' => 'Container logs'], + ]), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'service_uuid' => $schema->string()->description('Parent service UUID.')->required(), + 'uuid' => $schema->string()->description('Service database UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListApplicationPreviews.php b/app/Mcp/Tools/ListApplicationPreviews.php new file mode 100644 index 000000000..8a4e7fc21 --- /dev/null +++ b/app/Mcp/Tools/ListApplicationPreviews.php @@ -0,0 +1,87 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(); + if (! $application) { + return $this->mcpError($request, "Application [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $args = $this->paginationArgs($request); + $query = $application->previews()->orderByDesc('pull_request_id'); + $total = (clone $query)->count(); + + $previews = $query + ->skip($args['offset']) + ->take($args['per_page']) + ->get() + ->map(fn ($preview) => $this->scrubSensitive([ + 'uuid' => $preview->uuid, + 'pull_request_id' => $preview->pull_request_id, + 'pull_request_html_url' => $preview->pull_request_html_url, + 'fqdn' => $preview->fqdn, + 'status' => $preview->status, + 'git_type' => $preview->git_type, + 'docker_registry_image_tag' => $preview->docker_registry_image_tag, + 'last_online_at' => $preview->last_online_at, + 'created_at' => $preview->created_at, + 'updated_at' => $preview->updated_at, + ])) + ->values() + ->all(); + + return $this->mcpSuccess($request, $this->respond( + [ + 'application_uuid' => $uuid, + 'previews' => $previews, + ], + [ + ['tool' => 'get_application', 'args' => ['uuid' => $uuid], 'hint' => 'Parent application'], + ['tool' => 'list_deployments', 'args' => ['application_uuid' => $uuid], 'hint' => 'Deployments (includes PR deploys)'], + ], + $this->paginationMeta('list_application_previews', $args, $total, ['uuid' => $uuid]), + ), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Application UUID.')->required(), + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListApplications.php b/app/Mcp/Tools/ListApplications.php index bf31131b2..3bab877eb 100644 --- a/app/Mcp/Tools/ListApplications.php +++ b/app/Mcp/Tools/ListApplications.php @@ -5,6 +5,11 @@ use App\Mcp\Concerns\BuildsResponse; use App\Mcp\Concerns\ResolvesTeam; use App\Models\Application; +use App\Models\Environment; +use App\Models\Project; +use App\Models\Server; +use App\Models\StandaloneDocker; +use App\Models\SwarmDocker; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -14,7 +19,7 @@ class ListApplications extends Tool { protected string $name = 'list_applications'; - protected string $description = 'List applications owned by the authenticated team. Returns summary (uuid, name, status, fqdn, git_repository). Optional "tag" argument filters by tag name. Use get_application for full details.'; + protected string $description = 'List applications owned by the authenticated team. Filters: tag, project_uuid, environment_uuid, server_uuid, status, name.'; use BuildsResponse; use ResolvesTeam; @@ -34,16 +39,84 @@ public function handle(Request $request): Response if ($tagName !== null && (! is_string($tagName) || trim($tagName) === '')) { return $this->mcpError($request, 'tag argument must be a non-empty string.'); } + + $projectUuid = $request->get('project_uuid'); + if ($projectUuid !== null && (! is_string($projectUuid) || $projectUuid === '')) { + return $this->mcpError($request, 'project_uuid must be a non-empty string.'); + } + + $environmentUuid = $request->get('environment_uuid'); + if ($environmentUuid !== null && (! is_string($environmentUuid) || $environmentUuid === '')) { + return $this->mcpError($request, 'environment_uuid must be a non-empty string.'); + } + + $serverUuid = $request->get('server_uuid'); + if ($serverUuid !== null && (! is_string($serverUuid) || $serverUuid === '')) { + return $this->mcpError($request, 'server_uuid must be a non-empty string.'); + } + + $status = $request->get('status'); + if ($status !== null && (! is_string($status) || trim($status) === '')) { + return $this->mcpError($request, 'status must be a non-empty string.'); + } + + $name = $request->get('name'); + if ($name !== null && (! is_string($name) || trim($name) === '')) { + return $this->mcpError($request, 'name argument must be a non-empty string.'); + } + $args = $this->paginationArgs($request); $query = Application::ownedByCurrentTeamAPI($teamId) + ->with(['environment.project:id,uuid,name,team_id']) ->when($tagName !== null, function ($query) use ($tagName) { $query->whereHas('tags', fn ($q) => $q->where('name', $tagName)); - }); + }) + ->when(is_string($projectUuid), function ($query) use ($projectUuid, $teamId) { + $project = Project::where('team_id', $teamId)->where('uuid', $projectUuid)->first(); + if (! $project) { + $query->whereRaw('1 = 0'); + + return; + } + $query->whereHas('environment', fn ($q) => $q->where('project_id', $project->id)); + }) + ->when(is_string($environmentUuid), function ($query) use ($environmentUuid, $teamId) { + $env = Environment::ownedByCurrentTeamAPI($teamId)->where('uuid', $environmentUuid)->first(); + if (! $env) { + $query->whereRaw('1 = 0'); + + return; + } + $query->where('environment_id', $env->id); + }) + ->when(is_string($serverUuid), function ($query) use ($serverUuid, $teamId) { + $server = Server::whereTeamId($teamId)->where('uuid', $serverUuid)->first(); + if (! $server) { + $query->whereRaw('1 = 0'); + + return; + } + $standaloneDockerIds = $server->standaloneDockers()->pluck('id'); + $swarmDockerIds = $server->swarmDockers()->pluck('id'); + $query->where(function ($q) use ($standaloneDockerIds, $swarmDockerIds) { + $q->where(function ($inner) use ($standaloneDockerIds) { + $inner->where('destination_type', StandaloneDocker::class) + ->whereIn('destination_id', $standaloneDockerIds); + })->orWhere(function ($inner) use ($swarmDockerIds) { + $inner->where('destination_type', SwarmDocker::class) + ->whereIn('destination_id', $swarmDockerIds); + }); + }); + }) + ->when(is_string($status), fn ($query) => $query->whereRaw('LOWER(status) LIKE ?', ['%'.strtolower($status).'%'])) + ->when(is_string($name), fn ($query) => $query->whereRaw('LOWER(name) LIKE ?', ['%'.strtolower($name).'%'])); $total = (clone $query)->count(); $summaries = $query + ->orderBy('name') + ->orderBy('id') ->skip($args['offset']) ->take($args['per_page']) ->get() @@ -53,11 +126,22 @@ public function handle(Request $request): Response 'status' => $app->status, 'fqdn' => $app->fqdn, 'git_repository' => $app->git_repository, + 'project_uuid' => $app->environment?->project?->uuid, + 'project_name' => $app->environment?->project?->name, + 'environment_name' => $app->environment?->name, + 'environment_uuid' => $app->environment?->uuid, ]) ->values() ->all(); - $extra = $tagName ? ['tag' => $tagName] : []; + $extra = array_filter([ + 'tag' => $tagName, + 'project_uuid' => $projectUuid, + 'environment_uuid' => $environmentUuid, + 'server_uuid' => $serverUuid, + 'status' => $status, + 'name' => $name, + ], fn ($v) => $v !== null); return $this->mcpSuccess($request, $this->respond( $summaries, @@ -70,6 +154,11 @@ public function schema(JsonSchema $schema): array { return [ 'tag' => $schema->string()->description('Optional tag name filter.'), + 'project_uuid' => $schema->string()->description('Optional project UUID filter.'), + 'environment_uuid' => $schema->string()->description('Optional environment UUID filter.'), + 'server_uuid' => $schema->string()->description('Optional server UUID filter (via destination).'), + 'status' => $schema->string()->description('Optional status substring filter (e.g. running, exited).'), + 'name' => $schema->string()->description('Optional name substring filter.'), 'page' => $schema->integer()->description('Page number (default 1).'), 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), ]; diff --git a/app/Mcp/Tools/ListBackupExecutions.php b/app/Mcp/Tools/ListBackupExecutions.php new file mode 100644 index 000000000..b1880baba --- /dev/null +++ b/app/Mcp/Tools/ListBackupExecutions.php @@ -0,0 +1,114 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $databaseUuid = $request->get('database_uuid'); + $backupUuid = $request->get('scheduled_backup_uuid'); + + if (! is_string($databaseUuid) || $databaseUuid === '') { + return $this->mcpError($request, 'database_uuid argument is required.'); + } + if (! is_string($backupUuid) || $backupUuid === '') { + return $this->mcpError($request, 'scheduled_backup_uuid argument is required.'); + } + + $database = queryDatabaseByUuidWithinTeam($databaseUuid, (string) $teamId); + if (! $database) { + return $this->mcpError($request, "Database [{$databaseUuid}] not found.", ['resource_uuid' => $databaseUuid]); + } + + $backup = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId) + ->where('uuid', $backupUuid) + ->where('database_id', $database->id) + ->where('database_type', $database->getMorphClass()) + ->first(); + + if (! $backup) { + return $this->mcpError($request, "Backup schedule [{$backupUuid}] not found.", ['resource_uuid' => $backupUuid]); + } + + // Free-form execution output can embed secrets; gate like get_logs / task executions. + $token = $request->user()?->currentAccessToken(); + $includeMessage = $token !== null && ($token->can('root') || $token->can('read:sensitive')); + + $args = $this->paginationArgs($request); + $query = $backup->executions()->orderByDesc('created_at')->orderByDesc('id'); + $total = (clone $query)->count(); + + $executions = $query + ->skip($args['offset']) + ->take($args['per_page']) + ->get() + ->map(function ($ex) use ($includeMessage) { + $row = [ + 'uuid' => $ex->uuid ?? null, + 'status' => $ex->status ?? null, + 'message_included' => $includeMessage, + 'size' => $ex->size ?? null, + 'filename' => $ex->filename ?? null, + 'created_at' => $ex->created_at, + 'updated_at' => $ex->updated_at, + ]; + if ($includeMessage) { + $message = $ex->message; + $row['message'] = is_string($message) ? $this->redactLogText($message) : $message; + } + + return $this->scrubSensitive($row); + }) + ->values() + ->all(); + + return $this->mcpSuccess($request, $this->respond( + [ + 'database_uuid' => $databaseUuid, + 'scheduled_backup_uuid' => $backupUuid, + 'message_included' => $includeMessage, + 'executions' => $executions, + ], + [], + $this->paginationMeta('list_backup_executions', $args, $total, [ + 'database_uuid' => $databaseUuid, + 'scheduled_backup_uuid' => $backupUuid, + ]), + ), ['resource_uuid' => $backupUuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'database_uuid' => $schema->string()->description('Database UUID.')->required(), + 'scheduled_backup_uuid' => $schema->string()->description('Scheduled backup UUID.')->required(), + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListDatabaseBackups.php b/app/Mcp/Tools/ListDatabaseBackups.php new file mode 100644 index 000000000..7c6513093 --- /dev/null +++ b/app/Mcp/Tools/ListDatabaseBackups.php @@ -0,0 +1,72 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $database = queryDatabaseByUuidWithinTeam($uuid, (string) $teamId); + if (! $database) { + return $this->mcpError($request, "Database [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $backups = ScheduledDatabaseBackup::ownedByCurrentTeamAPI($teamId) + ->where('database_id', $database->id) + ->where('database_type', $database->getMorphClass()) + ->get() + ->map(fn ($backup) => $this->scrubSensitive([ + 'uuid' => $backup->uuid, + 'enabled' => $backup->enabled ?? null, + 'frequency' => $backup->frequency ?? null, + 'database_backup_retention_amount_locally' => $backup->database_backup_retention_amount_locally ?? null, + 'save_s3' => $backup->save_s3 ?? null, + 's3_storage_uuid' => $backup->s3?->uuid ?? null, + 'created_at' => $backup->created_at, + 'updated_at' => $backup->updated_at, + ])) + ->values() + ->all(); + + return $this->mcpSuccess($request, $this->respond([ + 'database_uuid' => $uuid, + 'backups' => $backups, + ]), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Database UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListDatabases.php b/app/Mcp/Tools/ListDatabases.php index 98de6ecee..c05efd81e 100644 --- a/app/Mcp/Tools/ListDatabases.php +++ b/app/Mcp/Tools/ListDatabases.php @@ -4,8 +4,15 @@ use App\Mcp\Concerns\BuildsResponse; use App\Mcp\Concerns\ResolvesTeam; +use App\Models\Environment; use App\Models\Project; +use App\Models\Server; +use App\Models\StandaloneDocker; +use App\Models\SwarmDocker; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; @@ -14,7 +21,7 @@ class ListDatabases extends Tool { protected string $name = 'list_databases'; - protected string $description = 'List standalone databases owned by the authenticated team. Returns summary (uuid, name, status, type). Use get_database for full details.'; + protected string $description = 'List standalone databases owned by the authenticated team. Filters: project_uuid, environment_uuid, server_uuid, status, name.'; use BuildsResponse; use ResolvesTeam; @@ -30,24 +37,125 @@ public function handle(Request $request): Response return $this->mcpError($request, 'Invalid token.'); } - $args = $this->paginationArgs($request); - - $projects = Project::where('team_id', $teamId)->get(); - $databases = collect(); - foreach ($projects as $project) { - $databases = $databases->merge($project->databases()); + $projectUuid = $request->get('project_uuid'); + if ($projectUuid !== null && (! is_string($projectUuid) || $projectUuid === '')) { + return $this->mcpError($request, 'project_uuid must be a non-empty string.'); } - $total = $databases->count(); + $environmentUuid = $request->get('environment_uuid'); + if ($environmentUuid !== null && (! is_string($environmentUuid) || $environmentUuid === '')) { + return $this->mcpError($request, 'environment_uuid must be a non-empty string.'); + } - $summaries = $databases - ->sortBy('name') - ->slice($args['offset'], $args['per_page']) - ->map(fn ($db) => [ - 'uuid' => $db->uuid, - 'name' => $db->name, - 'status' => $db->status ?? null, - 'type' => method_exists($db, 'type') ? $db->type() : class_basename($db), + $serverUuid = $request->get('server_uuid'); + if ($serverUuid !== null && (! is_string($serverUuid) || $serverUuid === '')) { + return $this->mcpError($request, 'server_uuid must be a non-empty string.'); + } + + $status = $request->get('status'); + if ($status !== null && (! is_string($status) || trim($status) === '')) { + return $this->mcpError($request, 'status must be a non-empty string.'); + } + + $name = $request->get('name'); + if ($name !== null && (! is_string($name) || trim($name) === '')) { + return $this->mcpError($request, 'name argument must be a non-empty string.'); + } + + $args = $this->paginationArgs($request); + $extra = array_filter([ + 'project_uuid' => $projectUuid, + 'environment_uuid' => $environmentUuid, + 'server_uuid' => $serverUuid, + 'status' => $status, + 'name' => $name, + ], fn ($v) => $v !== null); + + $projectsQuery = Project::where('team_id', $teamId)->select('id', 'uuid', 'name'); + if (is_string($projectUuid)) { + $projectsQuery->where('uuid', $projectUuid); + } + $projects = $projectsQuery->get()->keyBy('id'); + + if ($projects->isEmpty()) { + return $this->mcpSuccess($request, $this->respond( + [], + [], + $this->paginationMeta('list_databases', $args, 0, $extra), + )); + } + + $envQuery = Environment::query()->whereIn('project_id', $projects->keys()); + if (is_string($environmentUuid)) { + $env = Environment::ownedByCurrentTeamAPI($teamId)->where('uuid', $environmentUuid)->first(); + if (! $env || ! $projects->has($env->project_id)) { + return $this->mcpSuccess($request, $this->respond( + [], + [], + $this->paginationMeta('list_databases', $args, 0, $extra), + )); + } + $envQuery->where('id', $env->id); + } + + $envIds = $envQuery->pluck('id'); + if ($envIds->isEmpty()) { + return $this->mcpSuccess($request, $this->respond( + [], + [], + $this->paginationMeta('list_databases', $args, 0, $extra), + )); + } + + $destinationFilter = null; + if (is_string($serverUuid)) { + $server = Server::whereTeamId($teamId)->where('uuid', $serverUuid)->first(); + if (! $server) { + return $this->mcpSuccess($request, $this->respond( + [], + [], + $this->paginationMeta('list_databases', $args, 0, $extra), + )); + } + // Keep morph type and ID sets separate so overlapping auto-increment IDs + // across standalone_dockers / swarm_dockers cannot cross-match. + $destinationFilter = [ + StandaloneDocker::class => $server->standaloneDockers()->pluck('id')->all(), + SwarmDocker::class => $server->swarmDockers()->pluck('id')->all(), + ]; + } + + $union = $this->buildDatabaseUnion( + $envIds->all(), + $destinationFilter, + is_string($name) ? $name : null, + is_string($status) ? $status : null, + ); + + if ($union === null) { + return $this->mcpSuccess($request, $this->respond( + [], + [], + $this->paginationMeta('list_databases', $args, 0, $extra), + )); + } + + $total = (int) DB::query()->fromSub($union, 'databases')->count(); + + $summaries = DB::query() + ->fromSub($union, 'databases') + ->orderBy('name') + ->orderBy('uuid') + ->offset($args['offset']) + ->limit($args['per_page']) + ->get() + ->map(fn ($row) => [ + 'uuid' => $row->uuid, + 'name' => $row->name, + 'status' => $row->status, + 'type' => $row->type, + 'project_uuid' => $row->project_uuid, + 'project_name' => $row->project_name, ]) ->values() ->all(); @@ -55,13 +163,107 @@ public function handle(Request $request): Response return $this->mcpSuccess($request, $this->respond( $summaries, [], - $this->paginationMeta('list_databases', $args, $total), + $this->paginationMeta('list_databases', $args, $total, $extra), )); } + /** + * @param list $envIds + * @param array>|null $destinationFilter morph class => destination IDs + */ + private function buildDatabaseUnion( + array $envIds, + ?array $destinationFilter, + ?string $name, + ?string $status, + ): ?\Illuminate\Database\Query\Builder { + $parts = []; + + foreach (STANDALONE_DATABASE_MODELS as $typeKey => $modelClass) { + $parts[] = $this->databaseSelectQuery( + $modelClass, + (string) $typeKey, + $envIds, + $destinationFilter, + $name, + $status, + ); + } + + if ($parts === []) { + return null; + } + + /** @var Builder $union */ + $union = array_shift($parts); + foreach ($parts as $part) { + $union->unionAll($part); + } + + return $union->toBase(); + } + + /** + * @param class-string $modelClass + * @param list $envIds + * @param array>|null $destinationFilter morph class => destination IDs + */ + private function databaseSelectQuery( + string $modelClass, + string $typeKey, + array $envIds, + ?array $destinationFilter, + ?string $name, + ?string $status, + ): Builder { + /** @var Model $model */ + $model = new $modelClass; + $table = $model->getTable(); + $resourceType = method_exists($model, 'type') ? $model->type() : 'standalone-'.$typeKey; + $typeLiteral = "'".str_replace("'", "''", $resourceType)."'"; + + return $modelClass::query() + ->select([ + "{$table}.uuid", + "{$table}.name", + "{$table}.status", + DB::raw("{$typeLiteral} as type"), + 'projects.uuid as project_uuid', + 'projects.name as project_name', + ]) + ->join('environments', "{$table}.environment_id", '=', 'environments.id') + ->join('projects', 'environments.project_id', '=', 'projects.id') + ->whereIn("{$table}.environment_id", $envIds) + ->when(is_array($destinationFilter), function ($q) use ($table, $destinationFilter) { + $q->where(function ($outer) use ($table, $destinationFilter) { + $hasPredicate = false; + foreach ($destinationFilter as $destinationType => $ids) { + if ($ids === []) { + continue; + } + $hasPredicate = true; + $outer->orWhere(function ($inner) use ($table, $destinationType, $ids) { + $inner->where("{$table}.destination_type", $destinationType) + ->whereIn("{$table}.destination_id", $ids); + }); + } + if (! $hasPredicate) { + $outer->whereRaw('1 = 0'); + } + }); + }) + ->when(is_string($name), fn ($q) => $q->whereRaw("LOWER({$table}.name) LIKE ?", ['%'.strtolower($name).'%'])) + ->when(is_string($status), fn ($q) => $q->whereRaw("LOWER({$table}.status) LIKE ?", ['%'.strtolower($status).'%'])); + } + public function schema(JsonSchema $schema): array { return [ + 'project_uuid' => $schema->string()->description('Optional project UUID filter.'), + 'environment_uuid' => $schema->string()->description('Optional environment UUID filter.'), + 'server_uuid' => $schema->string()->description('Optional server UUID filter.'), + 'status' => $schema->string()->description('Optional status substring filter.'), + 'name' => $schema->string()->description('Optional name substring filter.'), 'page' => $schema->integer()->description('Page number (default 1).'), 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), ]; diff --git a/app/Mcp/Tools/ListDeployments.php b/app/Mcp/Tools/ListDeployments.php new file mode 100644 index 000000000..8af839a58 --- /dev/null +++ b/app/Mcp/Tools/ListDeployments.php @@ -0,0 +1,137 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $applicationUuid = $request->get('application_uuid'); + if ($applicationUuid !== null && (! is_string($applicationUuid) || $applicationUuid === '')) { + return $this->mcpError($request, 'application_uuid must be a non-empty string.'); + } + + $args = $this->paginationArgs($request); + + if (is_string($applicationUuid)) { + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $applicationUuid)->first(); + if (! $application) { + return $this->mcpError($request, "Application [{$applicationUuid}] not found.", ['resource_uuid' => $applicationUuid]); + } + + $query = ApplicationDeploymentQueue::query() + ->where('application_id', $application->id) + ->orderByDesc('created_at'); + + $total = (clone $query)->count(); + $rows = $query->skip($args['offset'])->take($args['per_page'])->get(); + + $summaries = $rows->map(fn ($d) => $this->summarizeDeployment($d, $application->uuid))->values()->all(); + + return $this->mcpSuccess($request, $this->respond( + $summaries, + [], + $this->paginationMeta('list_deployments', $args, $total, ['application_uuid' => $applicationUuid]), + )); + } + + $status = $request->get('status'); + $statuses = ['in_progress', 'queued']; + if (is_string($status) && $status !== '' && $status !== 'all') { + $statuses = [$status]; + } elseif ($status === 'all') { + $statuses = null; + } + + // application_deployment_queues.application_id is varchar; whereHas joins to + // applications.id (bigint) and breaks on PostgreSQL. Scope via string IDs instead. + $teamApplicationIds = Application::ownedByCurrentTeamAPI($teamId) + ->pluck('id') + ->map(fn ($id) => (string) $id); + + $query = ApplicationDeploymentQueue::query() + ->with('application:id,uuid') + ->whereIn('application_id', $teamApplicationIds) + ->orderByDesc('id'); + + if (is_array($statuses)) { + $query->whereIn('status', $statuses); + } + + $total = (clone $query)->count(); + $rows = $query->skip($args['offset'])->take($args['per_page'])->get(); + + $summaries = $rows->map(function ($d) { + $appUuid = $d->application?->uuid; + + return $this->summarizeDeployment($d, $appUuid); + })->values()->all(); + + $extra = array_filter(['status' => is_string($status) ? $status : null]); + + return $this->mcpSuccess($request, $this->respond( + $summaries, + [], + $this->paginationMeta('list_deployments', $args, $total, $extra), + )); + } + + /** + * @return array + */ + private function summarizeDeployment(ApplicationDeploymentQueue $deployment, ?string $applicationUuid): array + { + return $this->scrubSensitive([ + 'deployment_uuid' => $deployment->deployment_uuid, + 'application_uuid' => $applicationUuid, + 'application_name' => $deployment->application_name, + 'server_name' => $deployment->server_name, + 'status' => $deployment->status, + 'commit' => $deployment->commit, + 'commit_message' => $deployment->commit_message, + 'pull_request_id' => $deployment->pull_request_id, + 'force_rebuild' => $deployment->force_rebuild, + 'is_webhook' => $deployment->is_webhook, + 'is_api' => $deployment->is_api, + 'deployment_url' => $deployment->deployment_url, + 'created_at' => $deployment->created_at, + 'updated_at' => $deployment->updated_at, + 'finished_at' => $deployment->finished_at, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'application_uuid' => $schema->string()->description('Optional application UUID for deployment history.'), + 'status' => $schema->string()->description('Optional status filter when not using application_uuid: in_progress, queued, finished, failed, or all.'), + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListDestinations.php b/app/Mcp/Tools/ListDestinations.php new file mode 100644 index 000000000..dc98020de --- /dev/null +++ b/app/Mcp/Tools/ListDestinations.php @@ -0,0 +1,98 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $serverUuid = $request->get('server_uuid'); + $serverId = null; + if ($serverUuid !== null) { + if (! is_string($serverUuid) || $serverUuid === '') { + return $this->mcpError($request, 'server_uuid must be a non-empty string.'); + } + $server = Server::whereTeamId($teamId)->whereUuid($serverUuid)->first(); + if (! $server) { + return $this->mcpError($request, "Server [{$serverUuid}] not found.", ['resource_uuid' => $serverUuid]); + } + $serverId = $server->id; + } + + $standaloneQuery = StandaloneDocker::with('server:id,uuid') + ->whereHas('server', fn ($q) => $q->whereTeamId($teamId)); + $swarmQuery = SwarmDocker::with('server:id,uuid') + ->whereHas('server', fn ($q) => $q->whereTeamId($teamId)); + + if ($serverId !== null) { + $standaloneQuery->where('server_id', $serverId); + $swarmQuery->where('server_id', $serverId); + } + + $destinations = $standaloneQuery->get() + ->map(fn ($d) => $this->transform($d, 'standalone')) + ->concat($swarmQuery->get()->map(fn ($d) => $this->transform($d, 'swarm'))) + ->values(); + + $args = $this->paginationArgs($request); + $total = $destinations->count(); + $page = $destinations->slice($args['offset'], $args['per_page'])->values()->all(); + + $extra = array_filter(['server_uuid' => is_string($serverUuid) ? $serverUuid : null]); + + return $this->mcpSuccess($request, $this->respond( + $page, + [], + $this->paginationMeta('list_destinations', $args, $total, $extra), + )); + } + + /** + * @return array + */ + private function transform(StandaloneDocker|SwarmDocker $destination, string $type): array + { + return [ + 'uuid' => $destination->uuid, + 'name' => $destination->name, + 'network' => $destination->network, + 'type' => $type, + 'server_uuid' => $destination->server?->uuid, + ]; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'server_uuid' => $schema->string()->description('Optional server UUID filter.'), + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListEnvKeys.php b/app/Mcp/Tools/ListEnvKeys.php new file mode 100644 index 000000000..98fe4281d --- /dev/null +++ b/app/Mcp/Tools/ListEnvKeys.php @@ -0,0 +1,87 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $resourceType = $request->get('resource'); + $uuid = $request->get('uuid'); + + if (! is_string($resourceType) || ! $this->isValidResourceType($resourceType, $this->primaryResourceTypes)) { + return $this->mcpError($request, 'resource must be one of: application, database, service.'); + } + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $resource = $this->resolveTeamResource($teamId, $resourceType, $uuid); + if (! $resource) { + return $this->mcpError($request, ucfirst($resourceType)." [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $envs = collect(); + if (method_exists($resource, 'environment_variables')) { + $envs = $envs->merge($resource->environment_variables); + } + if (method_exists($resource, 'environment_variables_preview')) { + $envs = $envs->merge($resource->environment_variables_preview); + } + + $keys = $envs + ->unique(fn ($env) => $env->uuid ?? ($env->key.'|'.((int) $env->is_preview))) + ->map(fn ($env) => [ + 'uuid' => $env->uuid, + 'key' => $env->key, + 'is_preview' => (bool) $env->is_preview, + 'is_literal' => (bool) ($env->is_literal ?? false), + 'is_multiline' => (bool) ($env->is_multiline ?? false), + 'is_runtime' => (bool) ($env->is_runtime ?? true), + 'is_buildtime' => (bool) ($env->is_buildtime ?? true), + 'is_shown_once' => (bool) ($env->is_shown_once ?? false), + 'comment' => $env->comment, + ]) + ->values() + ->all(); + + return $this->mcpSuccess($request, $this->respond([ + 'resource' => $resourceType, + 'uuid' => $uuid, + 'keys' => $keys, + ]), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'resource' => $schema->string()->description('application | database | service')->required(), + 'uuid' => $schema->string()->description('Resource UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListGithubApps.php b/app/Mcp/Tools/ListGithubApps.php new file mode 100644 index 000000000..0283bbc0d --- /dev/null +++ b/app/Mcp/Tools/ListGithubApps.php @@ -0,0 +1,74 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $args = $this->paginationArgs($request); + + $query = GithubApp::query() + ->where(function ($q) use ($teamId) { + $q->where('team_id', $teamId)->orWhere('is_system_wide', true); + }) + ->orderBy('name'); + + $total = (clone $query)->count(); + + $apps = $query + ->skip($args['offset']) + ->take($args['per_page']) + ->get() + ->map(fn ($app) => $this->scrubSensitive([ + 'uuid' => $app->uuid, + 'name' => $app->name, + 'organization' => $app->organization, + 'api_url' => $app->api_url, + 'html_url' => $app->html_url, + 'is_system_wide' => $app->is_system_wide, + 'is_public' => $app->is_public, + 'type' => $app->type ?? 'github_app', + ])) + ->values() + ->all(); + + return $this->mcpSuccess($request, $this->respond( + $apps, + [], + $this->paginationMeta('list_github_apps', $args, $total), + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListGithubBranches.php b/app/Mcp/Tools/ListGithubBranches.php new file mode 100644 index 000000000..7954cecfa --- /dev/null +++ b/app/Mcp/Tools/ListGithubBranches.php @@ -0,0 +1,153 @@ +exists(); + } + + public function handle(Request $request): Response + { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $appUuid = $request->get('github_app_uuid'); + $owner = $request->get('owner'); + $repo = $request->get('repo'); + + if (! is_string($appUuid) || $appUuid === '') { + return $this->mcpError($request, 'github_app_uuid argument is required.'); + } + if (! is_string($owner) || $owner === '') { + return $this->mcpError($request, 'owner argument is required.'); + } + if (! is_string($repo) || $repo === '') { + return $this->mcpError($request, 'repo argument is required.'); + } + // GitHub path segments only — reject /, .., spaces, etc. before interpolating into the API path. + if (! $this->isValidGithubPathSegment($owner)) { + return $this->mcpError($request, 'owner must be a valid GitHub login or organization name.'); + } + if (! $this->isValidGithubPathSegment($repo)) { + return $this->mcpError($request, 'repo must be a valid GitHub repository name.'); + } + + $githubApp = GithubApp::query() + ->where('uuid', $appUuid) + ->where(function ($q) use ($teamId) { + $q->where('team_id', $teamId)->orWhere('is_system_wide', true); + }) + ->first(); + + if (! $githubApp) { + return $this->mcpError($request, "GitHub app [{$appUuid}] not found.", ['resource_uuid' => $appUuid]); + } + + try { + // Anonymous access only for public sources. Missing credentials on a private + // app are a configuration error — do not fall back to anonymous GitHub API + // (that can silently return branches from a same-named public repo). + $hasInstallationCredentials = filled($githubApp->app_id) + && filled($githubApp->installation_id) + && filled($githubApp->private_key_id); + + if (! $githubApp->is_public && ! $hasInstallationCredentials) { + return $this->mcpError( + $request, + 'This GitHub app is not public and is missing installation credentials. Configure the app, or use a public GitHub source.', + ['resource_uuid' => $appUuid], + ); + } + + $token = $githubApp->is_public ? null : generateGithubInstallationToken($githubApp); + $branches = collect(); + $page = 1; + $maxPages = 20; + + while ($page <= $maxPages) { + $response = Http::GitHub($githubApp->api_url, $token) + ->timeout(20) + ->get("/repos/{$owner}/{$repo}/branches", ['per_page' => 100, 'page' => $page]); + + if ($response->failed()) { + $status = $response->status(); + $hint = match (true) { + $status === 401, $status === 403 => 'GitHub app credentials/installation invalid or missing permissions.', + $status === 404 => 'Repository not found or not accessible to this GitHub app.', + default => 'GitHub API request failed.', + }; + + return $this->mcpError($request, "{$hint} (HTTP {$status})", ['resource_uuid' => $appUuid]); + } + + $batch = collect($response->json() ?? []); + if ($batch->isEmpty()) { + break; + } + + $branches = $branches->merge($batch); + if ($batch->count() < 100) { + break; + } + $page++; + } + + $summaries = $branches->map(fn ($branch) => [ + 'name' => data_get($branch, 'name'), + 'protected' => data_get($branch, 'protected'), + 'commit_sha' => data_get($branch, 'commit.sha'), + ])->values()->all(); + + return $this->mcpSuccess($request, $this->respond([ + 'github_app_uuid' => $appUuid, + 'owner' => $owner, + 'repo' => $repo, + 'branches' => $summaries, + ]), ['resource_uuid' => $appUuid]); + } catch (\Throwable $e) { + return $this->mcpError($request, 'Failed to load branches: '.$e->getMessage(), ['resource_uuid' => $appUuid]); + } + } + + public function schema(JsonSchema $schema): array + { + return [ + 'github_app_uuid' => $schema->string()->description('GitHub app UUID.')->required(), + 'owner' => $schema->string()->description('Repository owner.')->required(), + 'repo' => $schema->string()->description('Repository name.')->required(), + ]; + } + + /** + * GitHub owner/repo path segments: letters, digits, underscore, period, hyphen only. + */ + private function isValidGithubPathSegment(string $value): bool + { + return (bool) preg_match('/^[A-Za-z0-9_.-]+$/', $value); + } +} diff --git a/app/Mcp/Tools/ListGithubRepositories.php b/app/Mcp/Tools/ListGithubRepositories.php new file mode 100644 index 000000000..7c639f509 --- /dev/null +++ b/app/Mcp/Tools/ListGithubRepositories.php @@ -0,0 +1,123 @@ +exists(); + } + + public function handle(Request $request): Response + { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $appUuid = $request->get('github_app_uuid'); + if (! is_string($appUuid) || $appUuid === '') { + return $this->mcpError($request, 'github_app_uuid argument is required.'); + } + + $githubApp = GithubApp::query() + ->where('uuid', $appUuid) + ->where(function ($q) use ($teamId) { + $q->where('team_id', $teamId)->orWhere('is_system_wide', true); + }) + ->first(); + + if (! $githubApp) { + return $this->mcpError($request, "GitHub app [{$appUuid}] not found.", ['resource_uuid' => $appUuid]); + } + + // Public (anonymous) GitHub sources have no app credentials / installation. + // Listing installation repositories requires a GitHub App installation token. + if ($githubApp->is_public || blank($githubApp->app_id) || blank($githubApp->installation_id) || blank($githubApp->private_key_id)) { + return $this->mcpError( + $request, + 'This GitHub source is public or missing app installation credentials. list_github_repositories requires a configured GitHub App with installation access. Use list_github_apps to pick a non-public app, or list_github_branches with owner/repo for public repositories.', + ['resource_uuid' => $appUuid], + ); + } + + try { + $token = generateGithubInstallationToken($githubApp); + $repositories = collect(); + $page = 1; + $maxPages = 20; + + while ($page <= $maxPages) { + $response = Http::GitHub($githubApp->api_url, $token) + ->timeout(20) + ->get('/installation/repositories', ['per_page' => 100, 'page' => $page]); + + if ($response->failed()) { + $status = $response->status(); + $hint = match (true) { + $status === 401, $status === 403 => 'GitHub app credentials/installation invalid or missing permissions.', + $status === 404 => 'GitHub installation or endpoint not found.', + default => 'GitHub API request failed.', + }; + + return $this->mcpError($request, "{$hint} (HTTP {$status})", ['resource_uuid' => $appUuid]); + } + + $batch = collect($response->json('repositories') ?? []); + if ($batch->isEmpty()) { + break; + } + + $repositories = $repositories->merge($batch); + if ($batch->count() < 100) { + break; + } + $page++; + } + + $summaries = $repositories->map(fn ($repo) => [ + 'id' => data_get($repo, 'id'), + 'name' => data_get($repo, 'name'), + 'full_name' => data_get($repo, 'full_name'), + 'private' => data_get($repo, 'private'), + 'html_url' => data_get($repo, 'html_url'), + 'default_branch' => data_get($repo, 'default_branch'), + ])->values()->all(); + + return $this->mcpSuccess($request, $this->respond([ + 'github_app_uuid' => $appUuid, + 'repositories' => $summaries, + ]), ['resource_uuid' => $appUuid]); + } catch (\Throwable $e) { + return $this->mcpError($request, 'Failed to load repositories: '.$e->getMessage(), ['resource_uuid' => $appUuid]); + } + } + + public function schema(JsonSchema $schema): array + { + return [ + 'github_app_uuid' => $schema->string()->description('GitHub app UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListResourceTags.php b/app/Mcp/Tools/ListResourceTags.php new file mode 100644 index 000000000..523a52417 --- /dev/null +++ b/app/Mcp/Tools/ListResourceTags.php @@ -0,0 +1,74 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $resourceType = $request->get('resource'); + $uuid = $request->get('uuid'); + + if (! is_string($resourceType) || ! $this->isValidResourceType($resourceType, $this->primaryResourceTypes)) { + return $this->mcpError($request, 'resource must be one of: application, database, service.'); + } + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $resource = $this->resolveTeamResource($teamId, $resourceType, $uuid); + if (! $resource) { + return $this->mcpError($request, ucfirst($resourceType)." [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $tags = collect(); + if (method_exists($resource, 'tags')) { + $tags = $resource->tags + ->filter(fn ($tag) => (int) $tag->team_id === $teamId || $tag->team_id === null) + ->map(fn ($tag) => [ + 'uuid' => $tag->uuid, + 'name' => $tag->name, + ]) + ->values(); + } + + return $this->mcpSuccess($request, $this->respond([ + 'resource' => $resourceType, + 'uuid' => $uuid, + 'tags' => $tags->all(), + ]), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'resource' => $schema->string()->description('application | database | service')->required(), + 'uuid' => $schema->string()->description('Resource UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListResources.php b/app/Mcp/Tools/ListResources.php new file mode 100644 index 000000000..f6cde3e12 --- /dev/null +++ b/app/Mcp/Tools/ListResources.php @@ -0,0 +1,289 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $typeFilter = $request->get('type'); + if ($typeFilter !== null && (! is_string($typeFilter) || ! in_array($typeFilter, ['application', 'service', 'database'], true))) { + return $this->mcpError($request, 'type must be one of: application, service, database.'); + } + + $projectUuid = $request->get('project_uuid'); + if ($projectUuid !== null && (! is_string($projectUuid) || $projectUuid === '')) { + return $this->mcpError($request, 'project_uuid must be a non-empty string.'); + } + + $tagName = $request->get('tag'); + if ($tagName !== null && (! is_string($tagName) || trim($tagName) === '')) { + return $this->mcpError($request, 'tag argument must be a non-empty string.'); + } + + $args = $this->paginationArgs($request); + + $union = $this->buildResourceUnion( + $teamId, + is_string($typeFilter) ? $typeFilter : null, + is_string($projectUuid) ? $projectUuid : null, + is_string($tagName) ? $tagName : null, + ); + + $extra = array_filter([ + 'type' => $typeFilter, + 'project_uuid' => $projectUuid, + 'tag' => $tagName, + ], fn ($v) => $v !== null); + + if ($union === null) { + return $this->mcpSuccess($request, $this->respond( + [], + [], + $this->paginationMeta('list_resources', $args, 0, $extra), + )); + } + + $total = (int) DB::query()->fromSub($union, 'resources')->count(); + + $rows = DB::query() + ->fromSub($union, 'resources') + ->orderBy('name') + ->offset($args['offset']) + ->limit($args['per_page']) + ->get(); + + $page = $this->mapPageRows($rows); + + return $this->mcpSuccess($request, $this->respond( + $page, + [], + $this->paginationMeta('list_resources', $args, $total, $extra), + )); + } + + /** + * Build a UNION ALL of team-scoped resource queries (apps, services, DBs). + * Sorting and pagination are applied by the caller on the outer query so only + * one page of rows is materialised. + */ + private function buildResourceUnion( + int $teamId, + ?string $typeFilter, + ?string $projectUuid, + ?string $tagName, + ): ?QueryBuilder { + $parts = []; + + if ($typeFilter === null || $typeFilter === 'application') { + $parts[] = $this->applicationQuery($teamId, $projectUuid, $tagName); + } + + if ($typeFilter === null || $typeFilter === 'service') { + $parts[] = $this->serviceQuery($teamId, $projectUuid, $tagName); + } + + if ($typeFilter === null || $typeFilter === 'database') { + foreach (STANDALONE_DATABASE_MODELS as $typeKey => $modelClass) { + $parts[] = $this->databaseQuery($modelClass, (string) $typeKey, $teamId, $projectUuid, $tagName); + } + } + + if ($parts === []) { + return null; + } + + /** @var Builder $union */ + $union = array_shift($parts); + foreach ($parts as $part) { + $union->unionAll($part); + } + + return $union->toBase(); + } + + private function applicationQuery(int $teamId, ?string $projectUuid, ?string $tagName): Builder + { + // Drop withCount global scope so UNION column counts match other resource selects. + $query = Application::query() + ->withoutGlobalScope('withRelations') + ->select([ + 'applications.uuid', + 'applications.name', + DB::raw("'application' as type"), + 'applications.status', + 'projects.uuid as project_uuid', + 'projects.name as project_name', + ]) + ->join('environments', 'applications.environment_id', '=', 'environments.id') + ->join('projects', 'environments.project_id', '=', 'projects.id') + ->where('projects.team_id', $teamId); + + $this->applyProjectAndTagFilters($query, 'applications', Application::class, $projectUuid, $tagName); + + return $query; + } + + private function serviceQuery(int $teamId, ?string $projectUuid, ?string $tagName): Builder + { + // Service status is a computed accessor, not a column — fill per page later. + $query = Service::query() + ->select([ + 'services.uuid', + 'services.name', + DB::raw("'service' as type"), + DB::raw('NULL as status'), + 'projects.uuid as project_uuid', + 'projects.name as project_name', + ]) + ->join('environments', 'services.environment_id', '=', 'environments.id') + ->join('projects', 'environments.project_id', '=', 'projects.id') + ->where('projects.team_id', $teamId); + + $this->applyProjectAndTagFilters($query, 'services', Service::class, $projectUuid, $tagName); + + return $query; + } + + /** + * @param class-string $modelClass + */ + private function databaseQuery( + string $modelClass, + string $typeKey, + int $teamId, + ?string $projectUuid, + ?string $tagName, + ): Builder { + /** @var Model $model */ + $model = new $modelClass; + $table = $model->getTable(); + $resourceType = method_exists($model, 'type') ? $model->type() : 'standalone-'.$typeKey; + + // Type string is model-controlled (e.g. standalone-postgresql), not user input. + $typeLiteral = "'".str_replace("'", "''", $resourceType)."'"; + + $query = $modelClass::query() + ->select([ + "{$table}.uuid", + "{$table}.name", + DB::raw("{$typeLiteral} as type"), + "{$table}.status", + 'projects.uuid as project_uuid', + 'projects.name as project_name', + ]) + ->join('environments', "{$table}.environment_id", '=', 'environments.id') + ->join('projects', 'environments.project_id', '=', 'projects.id') + ->where('projects.team_id', $teamId); + + $this->applyProjectAndTagFilters($query, $table, $modelClass, $projectUuid, $tagName); + + return $query; + } + + /** + * @param class-string $modelClass + */ + private function applyProjectAndTagFilters( + Builder $query, + string $table, + string $modelClass, + ?string $projectUuid, + ?string $tagName, + ): void { + if (is_string($projectUuid)) { + $query->where('projects.uuid', $projectUuid); + } + + if (is_string($tagName)) { + $morphClass = (new $modelClass)->getMorphClass(); + $query->whereExists(function (QueryBuilder $sub) use ($table, $morphClass, $tagName) { + $sub->select(DB::raw(1)) + ->from('taggables') + ->join('tags', 'tags.id', '=', 'taggables.tag_id') + ->whereColumn('taggables.taggable_id', "{$table}.id") + ->where('taggables.taggable_type', $morphClass) + ->where('tags.name', $tagName); + }); + } + } + + /** + * @param Collection $rows + * @return list> + */ + private function mapPageRows(Collection $rows): array + { + $items = $rows->map(fn ($row) => [ + 'uuid' => $row->uuid, + 'name' => $row->name, + 'type' => $row->type, + 'status' => $row->status, + 'project_uuid' => $row->project_uuid, + 'project_name' => $row->project_name, + ])->values(); + + $serviceUuids = $items->where('type', 'service')->pluck('uuid')->filter()->values(); + if ($serviceUuids->isNotEmpty()) { + $services = Service::query() + ->whereIn('uuid', $serviceUuids->all()) + ->with([ + 'applications:id,service_id,status,exclude_from_status', + 'databases:id,service_id,status,exclude_from_status', + ]) + ->get() + ->keyBy('uuid'); + + $items = $items->map(function (array $item) use ($services) { + if ($item['type'] === 'service') { + $item['status'] = $services->get($item['uuid'])?->status; + } + + return $item; + }); + } + + return $items->all(); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'type' => $schema->string()->description('Optional filter: application, service, or database.'), + 'project_uuid' => $schema->string()->description('Optional project UUID filter.'), + 'tag' => $schema->string()->description('Optional tag name filter.'), + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListScheduledTaskExecutions.php b/app/Mcp/Tools/ListScheduledTaskExecutions.php new file mode 100644 index 000000000..d88636f8f --- /dev/null +++ b/app/Mcp/Tools/ListScheduledTaskExecutions.php @@ -0,0 +1,122 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $resourceType = $request->get('resource'); + $uuid = $request->get('uuid'); + $taskUuid = $request->get('task_uuid'); + + if (! is_string($resourceType) || ! $this->isValidResourceType($resourceType, $this->scheduledTaskResourceTypes)) { + return $this->mcpError($request, 'resource must be one of: application, service.'); + } + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + if (! is_string($taskUuid) || $taskUuid === '') { + return $this->mcpError($request, 'task_uuid argument is required.'); + } + + $resource = $this->resolveTeamResource($teamId, $resourceType, $uuid); + if (! $resource) { + return $this->mcpError($request, ucfirst($resourceType)." [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $taskQuery = ScheduledTask::ownedByCurrentTeamAPI($teamId)->where('uuid', $taskUuid); + if ($resourceType === 'application') { + $taskQuery->where('application_id', $resource->id); + } else { + $taskQuery->where('service_id', $resource->id); + } + + $task = $taskQuery->first(); + if (! $task) { + return $this->mcpError($request, "Scheduled task [{$taskUuid}] not found.", ['resource_uuid' => $taskUuid]); + } + + // Free-form execution output can embed secrets; gate like get_logs / task commands. + $token = $request->user()?->currentAccessToken(); + $includeMessage = $token !== null && ($token->can('root') || $token->can('read:sensitive')); + + $args = $this->paginationArgs($request); + $execQuery = $task->executions()->orderByDesc('created_at')->orderByDesc('id'); + $total = (clone $execQuery)->count(); + $executions = $execQuery + ->skip($args['offset']) + ->take($args['per_page']) + ->get() + ->map(function ($ex) use ($includeMessage) { + $row = [ + 'uuid' => $ex->uuid ?? null, + 'status' => $ex->status ?? null, + 'message_included' => $includeMessage, + 'created_at' => $ex->created_at, + 'updated_at' => $ex->updated_at, + ]; + if ($includeMessage) { + $message = $ex->message; + $row['message'] = is_string($message) ? $this->redactLogText($message) : $message; + } + + return $this->scrubSensitive($row); + }) + ->values() + ->all(); + + return $this->mcpSuccess($request, $this->respond( + [ + 'resource' => $resourceType, + 'uuid' => $uuid, + 'task_uuid' => $taskUuid, + 'message_included' => $includeMessage, + 'executions' => $executions, + ], + [], + $this->paginationMeta('list_scheduled_task_executions', $args, $total, [ + 'resource' => $resourceType, + 'uuid' => $uuid, + 'task_uuid' => $taskUuid, + ]), + ), ['resource_uuid' => $taskUuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'resource' => $schema->string()->description('application | service')->required(), + 'uuid' => $schema->string()->description('Parent resource UUID.')->required(), + 'task_uuid' => $schema->string()->description('Scheduled task UUID.')->required(), + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListScheduledTasks.php b/app/Mcp/Tools/ListScheduledTasks.php new file mode 100644 index 000000000..cdefe7313 --- /dev/null +++ b/app/Mcp/Tools/ListScheduledTasks.php @@ -0,0 +1,94 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $resourceType = $request->get('resource'); + $uuid = $request->get('uuid'); + + if (! is_string($resourceType) || ! $this->isValidResourceType($resourceType, $this->scheduledTaskResourceTypes)) { + return $this->mcpError($request, 'resource must be one of: application, service.'); + } + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $resource = $this->resolveTeamResource($teamId, $resourceType, $uuid); + if (! $resource) { + return $this->mcpError($request, ucfirst($resourceType)." [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + // Optional: command bodies are sensitive; omit unless token has read:sensitive/root. + // Do not call ensureAbility() here — lack of sensitive ability must not fail the tool. + $token = $request->user()?->currentAccessToken(); + $includeCommand = $token !== null && ($token->can('root') || $token->can('read:sensitive')); + + $query = ScheduledTask::ownedByCurrentTeamAPI($teamId); + if ($resourceType === 'application') { + $query->where('application_id', $resource->id); + } else { + $query->where('service_id', $resource->id); + } + + $tasks = $query->get()->map(function ($task) use ($includeCommand) { + $row = [ + 'uuid' => $task->uuid, + 'name' => $task->name, + 'enabled' => $task->enabled, + 'frequency' => $task->frequency, + 'container' => $task->container, + 'timeout' => $task->timeout, + 'command_included' => $includeCommand, + ]; + if ($includeCommand) { + $row['command'] = $task->command; + } + + return $this->scrubSensitive($row); + })->values()->all(); + + return $this->mcpSuccess($request, $this->respond([ + 'resource' => $resourceType, + 'uuid' => $uuid, + 'tasks' => $tasks, + 'command_included' => $includeCommand, + ]), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'resource' => $schema->string()->description('application | service')->required(), + 'uuid' => $schema->string()->description('Resource UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListServers.php b/app/Mcp/Tools/ListServers.php index ed10afc93..98846b33f 100644 --- a/app/Mcp/Tools/ListServers.php +++ b/app/Mcp/Tools/ListServers.php @@ -14,7 +14,7 @@ class ListServers extends Tool { protected string $name = 'list_servers'; - protected string $description = 'List servers visible to the authenticated team token. Returns summary (uuid, name, ip, reachability). Use get_server for full details.'; + protected string $description = 'List servers visible to the authenticated team token. Optional reachable filter (true/false).'; use BuildsResponse; use ResolvesTeam; @@ -30,9 +30,26 @@ public function handle(Request $request): Response return $this->mcpError($request, 'Invalid token.'); } + $reachable = $request->get('reachable'); + $reachableFilter = null; + if ($reachable !== null) { + if (is_bool($reachable)) { + $reachableFilter = $reachable; + } elseif (is_string($reachable) && in_array(strtolower($reachable), ['true', 'false', '1', '0'], true)) { + $reachableFilter = in_array(strtolower($reachable), ['true', '1'], true); + } else { + return $this->mcpError($request, 'reachable must be true or false.'); + } + } + $args = $this->paginationArgs($request); - $query = Server::whereTeamId($teamId)->with('settings:id,server_id,is_reachable,is_usable'); + $query = Server::whereTeamId($teamId) + ->with('settings:id,server_id,is_reachable,is_usable') + ->when($reachableFilter !== null, function ($query) use ($reachableFilter) { + $query->whereHas('settings', fn ($q) => $q->where('is_reachable', $reachableFilter)); + }); + $total = (clone $query)->count(); $summaries = $query @@ -50,16 +67,21 @@ public function handle(Request $request): Response ->values() ->all(); + $extra = array_filter([ + 'reachable' => $reachableFilter === null ? null : ($reachableFilter ? 'true' : 'false'), + ], fn ($v) => $v !== null); + return $this->mcpSuccess($request, $this->respond( $summaries, [], - $this->paginationMeta('list_servers', $args, $total), + $this->paginationMeta('list_servers', $args, $total, $extra), )); } public function schema(JsonSchema $schema): array { return [ + 'reachable' => $schema->boolean()->description('Optional filter: only reachable (true) or unreachable (false) servers.'), 'page' => $schema->integer()->description('Page number (default 1).'), 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), ]; diff --git a/app/Mcp/Tools/ListServiceApplications.php b/app/Mcp/Tools/ListServiceApplications.php new file mode 100644 index 000000000..bfe16cdea --- /dev/null +++ b/app/Mcp/Tools/ListServiceApplications.php @@ -0,0 +1,71 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId) + ->where('uuid', $uuid) + ->first(); + + if (! $service) { + return $this->mcpError($request, "Service [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $apps = $service->applications() + ->get() + ->map(fn ($app) => $this->scrubSensitive([ + 'uuid' => $app->uuid, + 'name' => $app->name, + 'human_name' => $app->human_name ?? null, + 'status' => $app->status, + 'fqdn' => $app->fqdn, + 'image' => $app->image ?? null, + ])) + ->values() + ->all(); + + return $this->mcpSuccess($request, $this->respond([ + 'service_uuid' => $uuid, + 'applications' => $apps, + ]), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Service UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListServiceDatabases.php b/app/Mcp/Tools/ListServiceDatabases.php new file mode 100644 index 000000000..1a90ca866 --- /dev/null +++ b/app/Mcp/Tools/ListServiceDatabases.php @@ -0,0 +1,70 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $uuid = $request->get('uuid'); + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $service = Service::whereRelation('environment.project.team', 'id', $teamId) + ->where('uuid', $uuid) + ->first(); + + if (! $service) { + return $this->mcpError($request, "Service [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $databases = $service->databases() + ->get() + ->map(fn ($db) => $this->scrubSensitive([ + 'uuid' => $db->uuid, + 'name' => $db->name, + 'human_name' => $db->human_name ?? null, + 'status' => $db->status, + 'image' => $db->image ?? null, + ])) + ->values() + ->all(); + + return $this->mcpSuccess($request, $this->respond([ + 'service_uuid' => $uuid, + 'databases' => $databases, + ]), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'uuid' => $schema->string()->description('Service UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListServices.php b/app/Mcp/Tools/ListServices.php index 3a0ea158a..e3a268cd2 100644 --- a/app/Mcp/Tools/ListServices.php +++ b/app/Mcp/Tools/ListServices.php @@ -4,8 +4,15 @@ use App\Mcp\Concerns\BuildsResponse; use App\Mcp\Concerns\ResolvesTeam; +use App\Models\Environment; +use App\Models\Project; +use App\Models\Server; use App\Models\Service; +use App\Models\StandaloneDocker; +use App\Models\SwarmDocker; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; @@ -14,7 +21,7 @@ class ListServices extends Tool { protected string $name = 'list_services'; - protected string $description = 'List services (multi-container stacks) owned by the authenticated team. Returns summary (uuid, name, status). Use get_service for full details.'; + protected string $description = 'List services (multi-container stacks) owned by the authenticated team. Filters: project_uuid, environment_uuid, server_uuid, status, name.'; use BuildsResponse; use ResolvesTeam; @@ -30,37 +37,170 @@ public function handle(Request $request): Response return $this->mcpError($request, 'Invalid token.'); } + $projectUuid = $request->get('project_uuid'); + if ($projectUuid !== null && (! is_string($projectUuid) || $projectUuid === '')) { + return $this->mcpError($request, 'project_uuid must be a non-empty string.'); + } + + $environmentUuid = $request->get('environment_uuid'); + if ($environmentUuid !== null && (! is_string($environmentUuid) || $environmentUuid === '')) { + return $this->mcpError($request, 'environment_uuid must be a non-empty string.'); + } + + $serverUuid = $request->get('server_uuid'); + if ($serverUuid !== null && (! is_string($serverUuid) || $serverUuid === '')) { + return $this->mcpError($request, 'server_uuid must be a non-empty string.'); + } + + $status = $request->get('status'); + if ($status !== null && (! is_string($status) || trim($status) === '')) { + return $this->mcpError($request, 'status must be a non-empty string.'); + } + + $name = $request->get('name'); + if ($name !== null && (! is_string($name) || trim($name) === '')) { + return $this->mcpError($request, 'name argument must be a non-empty string.'); + } + $args = $this->paginationArgs($request); - $query = Service::whereHas('environment.project', fn ($q) => $q->where('team_id', $teamId)); + $query = Service::whereHas('environment.project', fn ($q) => $q->where('team_id', $teamId)) + ->with(['environment.project:id,uuid,name,team_id']) + ->when(is_string($projectUuid), function ($query) use ($projectUuid, $teamId) { + $project = Project::where('team_id', $teamId)->where('uuid', $projectUuid)->first(); + if (! $project) { + $query->whereRaw('1 = 0'); - $total = (clone $query)->count(); + return; + } + $query->whereHas('environment', fn ($q) => $q->where('project_id', $project->id)); + }) + ->when(is_string($environmentUuid), function ($query) use ($environmentUuid, $teamId) { + $env = Environment::ownedByCurrentTeamAPI($teamId)->where('uuid', $environmentUuid)->first(); + if (! $env) { + $query->whereRaw('1 = 0'); - $summaries = $query - ->orderBy('name') - ->skip($args['offset']) - ->take($args['per_page']) - ->get() + return; + } + $query->where('environment_id', $env->id); + }) + ->when(is_string($serverUuid), function ($query) use ($serverUuid, $teamId) { + $server = Server::whereTeamId($teamId)->where('uuid', $serverUuid)->first(); + if (! $server) { + $query->whereRaw('1 = 0'); + + return; + } + $standaloneDockerIds = $server->standaloneDockers()->pluck('id'); + $swarmDockerIds = $server->swarmDockers()->pluck('id'); + $query->where(function ($q) use ($server, $standaloneDockerIds, $swarmDockerIds) { + $q->where('server_id', $server->id) + ->orWhere(function ($inner) use ($standaloneDockerIds) { + $inner->where('destination_type', StandaloneDocker::class) + ->whereIn('destination_id', $standaloneDockerIds); + }) + ->orWhere(function ($inner) use ($swarmDockerIds) { + $inner->where('destination_type', SwarmDocker::class) + ->whereIn('destination_id', $swarmDockerIds); + }); + }); + }) + ->when(is_string($name), fn ($query) => $query->whereRaw('LOWER(name) LIKE ?', ['%'.strtolower($name).'%'])); + + if (is_string($status)) { + // Status is a computed accessor over applications/databases, so filter + // in PHP — but scan in chunks so large teams never hydrate every service. + [$total, $services] = $this->paginateServicesByStatus( + $query, + trim($status), + $args['offset'], + $args['per_page'], + ); + } else { + $total = (clone $query)->count(); + $services = $query + ->orderBy('name') + ->orderBy('id') + ->skip($args['offset']) + ->take($args['per_page']) + ->get(); + } + + $summaries = $services ->map(fn ($svc) => [ 'uuid' => $svc->uuid, 'name' => $svc->name, 'status' => $svc->status ?? null, + 'project_uuid' => $svc->environment?->project?->uuid, + 'project_name' => $svc->environment?->project?->name, + 'environment_name' => $svc->environment?->name, + 'environment_uuid' => $svc->environment?->uuid, ]) ->values() ->all(); + $extra = array_filter([ + 'project_uuid' => $projectUuid, + 'environment_uuid' => $environmentUuid, + 'server_uuid' => $serverUuid, + 'status' => $status, + 'name' => $name, + ], fn ($v) => $v !== null); + return $this->mcpSuccess($request, $this->respond( $summaries, [], - $this->paginationMeta('list_services', $args, $total), + $this->paginationMeta('list_services', $args, $total, $extra), )); } public function schema(JsonSchema $schema): array { return [ + 'project_uuid' => $schema->string()->description('Optional project UUID filter.'), + 'environment_uuid' => $schema->string()->description('Optional environment UUID filter.'), + 'server_uuid' => $schema->string()->description('Optional server UUID filter.'), + 'status' => $schema->string()->description('Optional status substring filter.'), + 'name' => $schema->string()->description('Optional name substring filter.'), 'page' => $schema->integer()->description('Page number (default 1).'), 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), ]; } + + /** + * Filter services by computed status in chunks, keeping only the requested page in memory. + * + * @param Builder $query + * @return array{0: int, 1: Collection} + */ + private function paginateServicesByStatus(Builder $query, string $status, int $offset, int $perPage): array + { + $statusNeedle = strtolower($status); + $matched = collect(); + $total = 0; + $pageEnd = $offset + $perPage; + + $query + ->with([ + 'applications:id,service_id,status,exclude_from_status', + 'databases:id,service_id,status,exclude_from_status', + ]) + ->orderBy('name') + ->orderBy('id') + ->chunk(100, function ($chunk) use ($statusNeedle, $offset, $pageEnd, &$matched, &$total) { + foreach ($chunk as $service) { + if (! str_contains(strtolower((string) $service->status), $statusNeedle)) { + continue; + } + + if ($total >= $offset && $total < $pageEnd) { + $matched->push($service); + } + + $total++; + } + }); + + return [$total, $matched->values()]; + } } diff --git a/app/Mcp/Tools/ListSharedEnvKeys.php b/app/Mcp/Tools/ListSharedEnvKeys.php new file mode 100644 index 000000000..cfd64435f --- /dev/null +++ b/app/Mcp/Tools/ListSharedEnvKeys.php @@ -0,0 +1,117 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $scope = $request->get('scope'); + $uuid = $request->get('uuid'); + + if (! is_string($scope) || ! in_array($scope, ['project', 'environment'], true)) { + return $this->mcpError($request, 'scope must be project or environment.'); + } + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + if ($scope === 'project') { + $project = Project::where('team_id', $teamId)->where('uuid', $uuid)->first(); + if (! $project) { + return $this->mcpError($request, "Project [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $vars = SharedEnvironmentVariable::query() + ->where('team_id', $teamId) + ->where('project_id', $project->id) + ->where('type', 'project') + ->orderBy('key') + ->get(); + } else { + $environment = Environment::ownedByCurrentTeamAPI($teamId) + ->with('project:id,uuid,team_id') + ->where('uuid', $uuid) + ->first(); + + if (! $environment) { + // Also allow lookup by name under project_uuid if provided. + $projectUuid = $request->get('project_uuid'); + if (is_string($projectUuid) && $projectUuid !== '') { + $project = Project::where('team_id', $teamId)->where('uuid', $projectUuid)->first(); + if ($project) { + $environment = Environment::where('project_id', $project->id) + ->with('project:id,uuid,team_id') + ->where(function ($q) use ($uuid) { + $q->where('uuid', $uuid)->orWhere('name', $uuid); + }) + ->first(); + } + } + } + + if (! $environment || (int) $environment->project?->team_id !== $teamId) { + return $this->mcpError($request, "Environment [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $vars = SharedEnvironmentVariable::query() + ->where('team_id', $teamId) + ->where('environment_id', $environment->id) + ->where('type', 'environment') + ->orderBy('key') + ->get(); + + $uuid = $environment->uuid; + } + + $keys = $vars->map(fn ($var) => [ + 'key' => $var->key, + 'is_literal' => (bool) ($var->is_literal ?? false), + 'is_multiline' => (bool) ($var->is_multiline ?? false), + 'is_shown_once' => (bool) ($var->is_shown_once ?? false), + 'comment' => $var->comment, + 'type' => $var->type, + ])->values()->all(); + + return $this->mcpSuccess($request, $this->respond([ + 'scope' => $scope, + 'uuid' => $uuid, + 'keys' => $keys, + ]), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'scope' => $schema->string()->description('project | environment')->required(), + 'uuid' => $schema->string()->description('Project UUID, or environment UUID (or name with project_uuid).')->required(), + 'project_uuid' => $schema->string()->description('Optional project UUID when resolving environment by name.'), + ]; + } +} diff --git a/app/Mcp/Tools/ListStorages.php b/app/Mcp/Tools/ListStorages.php new file mode 100644 index 000000000..e788f3c8c --- /dev/null +++ b/app/Mcp/Tools/ListStorages.php @@ -0,0 +1,116 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $resourceType = $request->get('resource'); + $uuid = $request->get('uuid'); + + if (! is_string($resourceType) || ! $this->isValidResourceType($resourceType, $this->primaryResourceTypes)) { + return $this->mcpError($request, 'resource must be one of: application, database, service.'); + } + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'uuid argument is required.'); + } + + $resource = $this->resolveTeamResource($teamId, $resourceType, $uuid); + if (! $resource) { + return $this->mcpError($request, ucfirst($resourceType)." [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + $persistent = collect(); + $files = collect(); + + if (method_exists($resource, 'persistentStorages')) { + $persistent = $resource->persistentStorages->map(fn ($s) => $this->scrubSensitive([ + 'uuid' => $s->uuid, + 'name' => $s->name, + 'mount_path' => $s->mount_path, + 'host_path' => $s->host_path, + 'is_directory' => $s->is_directory ?? null, + ])); + } + + if (method_exists($resource, 'fileStorages')) { + $files = $resource->fileStorages->map(fn ($s) => $this->scrubSensitive([ + 'uuid' => $s->uuid, + 'name' => $s->name ?? null, + 'mount_path' => $s->mount_path, + 'is_directory' => $s->is_directory ?? null, + ])); + } + + // Services aggregate child storages + if ($resourceType === 'service') { + if (method_exists($resource, 'applications')) { + foreach ($resource->applications as $app) { + if (method_exists($app, 'persistentStorages')) { + $persistent = $persistent->merge($app->persistentStorages->map(fn ($s) => $this->scrubSensitive([ + 'uuid' => $s->uuid, + 'name' => $s->name, + 'mount_path' => $s->mount_path, + 'host_path' => $s->host_path, + 'owner' => 'service_application:'.$app->uuid, + ]))); + } + } + } + if (method_exists($resource, 'databases')) { + foreach ($resource->databases as $db) { + if (method_exists($db, 'persistentStorages')) { + $persistent = $persistent->merge($db->persistentStorages->map(fn ($s) => $this->scrubSensitive([ + 'uuid' => $s->uuid, + 'name' => $s->name, + 'mount_path' => $s->mount_path, + 'host_path' => $s->host_path, + 'owner' => 'service_database:'.$db->uuid, + ]))); + } + } + } + } + + return $this->mcpSuccess($request, $this->respond([ + 'resource' => $resourceType, + 'uuid' => $uuid, + 'persistent_storages' => $persistent->values()->all(), + 'file_storages' => $files->values()->all(), + ]), ['resource_uuid' => $uuid]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'resource' => $schema->string()->description('application | database | service')->required(), + 'uuid' => $schema->string()->description('Resource UUID.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListTags.php b/app/Mcp/Tools/ListTags.php new file mode 100644 index 000000000..c3ba21211 --- /dev/null +++ b/app/Mcp/Tools/ListTags.php @@ -0,0 +1,62 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $args = $this->paginationArgs($request); + $query = Tag::where('team_id', $teamId)->orderBy('name'); + $total = (clone $query)->count(); + + $tags = $query + ->skip($args['offset']) + ->take($args['per_page']) + ->get() + ->map(fn ($tag) => [ + 'uuid' => $tag->uuid, + 'name' => $tag->name, + ]) + ->values() + ->all(); + + return $this->mcpSuccess($request, $this->respond( + $tags, + [], + $this->paginationMeta('list_tags', $args, $total), + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListTeamMembers.php b/app/Mcp/Tools/ListTeamMembers.php new file mode 100644 index 000000000..c4bc3f5ed --- /dev/null +++ b/app/Mcp/Tools/ListTeamMembers.php @@ -0,0 +1,67 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $team = Team::query()->find($teamId); + if (! $team) { + return $this->mcpError($request, 'Team not found.'); + } + + $args = $this->paginationArgs($request); + $members = $team->members()->orderBy('name')->get(); + $total = $members->count(); + + // Users have no public UUID column; email is the stable public identifier. + $page = $members + ->slice($args['offset'], $args['per_page']) + ->map(fn ($user) => $this->scrubSensitive([ + 'name' => $user->name, + 'email' => $user->email, + 'role' => $user->pivot->role ?? null, + ])) + ->values() + ->all(); + + return $this->mcpSuccess($request, $this->respond( + $page, + [], + $this->paginationMeta('list_team_members', $args, $total), + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListUnhealthyResources.php b/app/Mcp/Tools/ListUnhealthyResources.php new file mode 100644 index 000000000..a394d8ce0 --- /dev/null +++ b/app/Mcp/Tools/ListUnhealthyResources.php @@ -0,0 +1,435 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $sampleOnly = filter_var($request->get('sample_only'), FILTER_VALIDATE_BOOLEAN); + $samplePerType = max(1, min(20, (int) ($request->get('sample_per_type') ?? 5))); + + // Prefer smaller pages for this heavy tool (default 20 unless caller sets per_page). + if ($request->get('per_page') === null) { + $request->merge(['per_page' => 20]); + } + $args = $this->paginationArgs($request); + + // --- Servers (small set; filter via settings) --- + $unhealthyServers = Server::whereTeamId($teamId) + ->with('settings:id,server_id,is_reachable,is_usable') + ->whereHas('settings', function ($q) { + $q->where('is_reachable', false)->orWhere('is_usable', false); + }) + ->orderBy('name') + ->orderBy('id') + ->get() + ->map(function ($server) { + $reachable = (bool) $server->settings?->is_reachable; + $usable = (bool) $server->settings?->is_usable; + + return [ + 'type' => 'server', + 'uuid' => $server->uuid, + 'name' => $server->name, + 'status' => $reachable ? 'unreachable_or_unusable' : 'unreachable', + 'is_reachable' => $reachable, + 'is_usable' => $usable, + 'reason' => ! $reachable ? 'server_unreachable' : 'server_not_usable', + ]; + }); + + // --- Applications (SQL on status column) --- + $appQuery = Application::ownedByCurrentTeamAPI($teamId) + ->with(['environment.project:id,uuid,name,team_id']); + $this->scopeNotHealthyRunning($appQuery); + $appCount = (clone $appQuery)->count(); + + // --- Services / DBs: count always; hydrate samples only when sample_only --- + [$serviceCount, $serviceSamples] = $this->collectUnhealthyServices( + $teamId, + $sampleOnly, + $samplePerType, + skip: 0, + take: $sampleOnly ? $samplePerType : 0, + ); + + [$dbCount, $dbSamples] = $this->collectUnhealthyDatabases( + $teamId, + $sampleOnly, + $samplePerType, + skip: 0, + take: $sampleOnly ? $samplePerType : 0, + ); + + $summary = [ + 'total' => $unhealthyServers->count() + $appCount + $serviceCount + $dbCount, + 'servers' => $unhealthyServers->count(), + 'applications' => $appCount, + 'services' => $serviceCount, + 'databases' => $dbCount, + ]; + + if ($sampleOnly) { + $unhealthyApps = (clone $appQuery) + ->orderBy('name') + ->orderBy('id') + ->limit($samplePerType) + ->get() + ->map(fn ($app) => $this->mapApplication($app)); + + return $this->mcpSuccess($request, $this->respond([ + 'sample_only' => true, + 'sample_per_type' => $samplePerType, + 'summary' => $summary, + 'samples' => [ + 'servers' => $unhealthyServers->take($samplePerType)->values()->all(), + 'applications' => $unhealthyApps->values()->all(), + 'services' => $serviceSamples->values()->all(), + 'databases' => $dbSamples->values()->all(), + ], + 'next' => [ + 'tool' => 'list_unhealthy_resources', + 'args' => ['sample_only' => false, 'page' => 1, 'per_page' => 20], + 'hint' => 'Full paginated unhealthy list', + ], + ])); + } + + // Full mode: paginate by type group without loading the full unhealthy set. + // Global order matches sortBy(['type','name']): application, server, service, then standalone-*. + $page = $this->paginateFullList( + $teamId, + $appQuery, + $unhealthyServers, + $appCount, + $serviceCount, + $dbCount, + $args['offset'], + $args['per_page'], + ); + + return $this->mcpSuccess($request, $this->respond( + [ + 'unhealthy' => $page, + 'summary' => $summary, + ], + [], + $this->paginationMeta('list_unhealthy_resources', $args, $summary['total'], ['sample_only' => false]), + )); + } + + /** + * Walk type groups in sorted order and fetch only the current page window. + * + * @param Collection> $unhealthyServers + * @return list> + */ + private function paginateFullList( + int $teamId, + Builder $appQuery, + Collection $unhealthyServers, + int $appCount, + int $serviceCount, + int $dbCount, + int $offset, + int $perPage, + ): array { + $skip = $offset; + $need = $perPage; + $page = []; + + // 1) applications (type = application) + if ($need > 0) { + if ($skip >= $appCount) { + $skip -= $appCount; + } else { + $take = min($need, $appCount - $skip); + $rows = (clone $appQuery) + ->orderBy('name') + ->orderBy('id') + ->skip($skip) + ->take($take) + ->get() + ->map(fn ($app) => $this->mapApplication($app)) + ->all(); + $page = array_merge($page, $rows); + $need -= count($rows); + $skip = 0; + } + } + + // 2) servers (type = server) — small set + $serverCount = $unhealthyServers->count(); + if ($need > 0) { + if ($skip >= $serverCount) { + $skip -= $serverCount; + } else { + $rows = $unhealthyServers->slice($skip, $need)->values()->all(); + $page = array_merge($page, $rows); + $need -= count($rows); + $skip = 0; + } + } + + // 3) services (type = service) + if ($need > 0) { + if ($skip >= $serviceCount) { + $skip -= $serviceCount; + } else { + // Count already known from the earlier full scan; stop once the page window is full. + [, $serviceRows] = $this->collectUnhealthyServices( + $teamId, + sampleOnly: false, + samplePerType: 1, + skip: $skip, + take: $need, + needsCount: false, + ); + $rows = $serviceRows->values()->all(); + $page = array_merge($page, $rows); + $need -= count($rows); + $skip = 0; + } + } + + // 4) databases by type() alphabetically (standalone-*) + if ($need > 0) { + if ($skip >= $dbCount) { + // nothing left + } else { + [, $dbRows] = $this->collectUnhealthyDatabases( + $teamId, + sampleOnly: false, + samplePerType: 1, + skip: $skip, + take: $need, + ); + $page = array_merge($page, $dbRows->values()->all()); + } + } + + return $page; + } + + /** + * @return array + */ + private function mapApplication(Application $app): array + { + return [ + 'type' => 'application', + 'uuid' => $app->uuid, + 'name' => $app->name, + 'status' => $app->status, + 'project_uuid' => $app->environment?->project?->uuid, + 'project_name' => $app->environment?->project?->name, + 'reason' => 'status_not_running', + ]; + } + + /** + * Scan services for unhealthy status. When $needsCount is false, stop once the page window is full + * (callers that already know the total can skip the rest of the table). + * + * @return array{0: int, 1: Collection>} + */ + private function collectUnhealthyServices( + int $teamId, + bool $sampleOnly, + int $samplePerType, + int $skip = 0, + ?int $take = null, + bool $needsCount = true, + ): array { + $base = Service::whereHas('environment.project', fn ($q) => $q->where('team_id', $teamId)) + ->with([ + 'environment.project:id,uuid,name,team_id', + 'applications:id,service_id,status,exclude_from_status', + 'databases:id,service_id,status,exclude_from_status', + ]) + ->orderBy('name') + ->orderBy('id'); + + $unhealthy = collect(); + $serviceCount = 0; + $skipped = 0; + // take=0 means count-only (no row hydration beyond status check). + $limit = $take === null ? ($sampleOnly ? $samplePerType : PHP_INT_MAX) : max(0, $take); + + // Chunk so large teams do not hydrate every service at once. + $base->chunk(100, function ($chunk) use ($skip, $limit, $needsCount, &$unhealthy, &$serviceCount, &$skipped) { + foreach ($chunk as $svc) { + if ($this->looksHealthy($svc->status ?? null)) { + continue; + } + $serviceCount++; + + if ($limit === 0 || $unhealthy->count() >= $limit) { + if (! $needsCount) { + // Page window full and total already known — stop scanning. + return false; + } + + // Still count remaining unhealthy services. + continue; + } + + if ($skipped < $skip) { + $skipped++; + + continue; + } + + $unhealthy->push([ + 'type' => 'service', + 'uuid' => $svc->uuid, + 'name' => $svc->name, + 'status' => $svc->status ?? null, + 'project_uuid' => $svc->environment?->project?->uuid, + 'project_name' => $svc->environment?->project?->name, + 'reason' => 'status_not_running', + ]); + } + }); + + return [$serviceCount, $unhealthy->values()]; + } + + /** + * @return array{0: int, 1: Collection>} + */ + private function collectUnhealthyDatabases( + int $teamId, + bool $sampleOnly, + int $samplePerType, + int $skip = 0, + ?int $take = null, + ): array { + $projects = Project::where('team_id', $teamId)->select('id', 'uuid', 'name')->get()->keyBy('id'); + $envToProject = Environment::query() + ->whereIn('project_id', $projects->keys()) + ->pluck('project_id', 'id'); + + $envIds = $envToProject->keys(); + $dbItems = collect(); + $dbCount = 0; + + if ($envIds->isEmpty()) { + return [0, $dbItems]; + } + + // Walk model types in alphabetical type() order to match global sortBy(['type','name']). + $models = collect(STANDALONE_DATABASE_MODELS) + ->mapWithKeys(function ($modelClass, $typeKey) { + /** @var class-string $modelClass */ + $model = new $modelClass; + $type = method_exists($model, 'type') ? $model->type() : 'standalone-'.$typeKey; + + return [$type => $modelClass]; + }) + ->sortKeys(); + + $skipped = 0; + // take=0 means count-only (no row hydration). + $limit = $take === null ? ($sampleOnly ? $samplePerType : PHP_INT_MAX) : max(0, $take); + + foreach ($models as $type => $modelClass) { + $dq = $modelClass::query()->whereIn('environment_id', $envIds); + $this->scopeNotHealthyRunning($dq); + $count = (clone $dq)->count(); + $dbCount += $count; + + if ($limit === 0 || $dbItems->count() >= $limit) { + continue; + } + + if ($sampleOnly) { + $remaining = $limit - $dbItems->count(); + $rows = $dq->orderBy('name')->orderBy('id')->limit($remaining)->get(['uuid', 'name', 'status', 'environment_id']); + } else { + if ($skipped + $count <= $skip) { + $skipped += $count; + + continue; + } + $localSkip = max(0, $skip - $skipped); + $localTake = min($count - $localSkip, $limit - $dbItems->count()); + if ($localTake <= 0) { + $skipped += $count; + + continue; + } + $rows = $dq->orderBy('name')->orderBy('id')->skip($localSkip)->take($localTake)->get(['uuid', 'name', 'status', 'environment_id']); + $skipped += $count; + } + + foreach ($rows as $db) { + $projectId = $envToProject[$db->environment_id] ?? null; + $project = $projectId ? $projects->get($projectId) : null; + $dbItems->push([ + 'type' => $type, + 'resource_kind' => 'database', + 'uuid' => $db->uuid, + 'name' => $db->name, + 'status' => $db->status ?? null, + 'project_uuid' => $project?->uuid, + 'project_name' => $project?->name, + 'reason' => 'status_not_running', + ]); + } + + if ($sampleOnly && $dbItems->count() >= $limit) { + break; + } + } + + if ($sampleOnly) { + $dbItems = $dbItems->unique('uuid')->take($samplePerType)->values(); + } + + return [$dbCount, $dbItems->values()]; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'sample_only' => $schema->boolean()->description('If true, return only a small sample per type plus full summary counts (cheaper). Prefer this first.'), + 'sample_per_type' => $schema->integer()->description('Sample size per type when sample_only=true (default 5, max 20).'), + 'page' => $schema->integer()->description('Page number (default 1).'), + 'per_page' => $schema->integer()->description('Items per page (default 20, max 100).'), + ]; + } +} diff --git a/app/Mcp/Tools/SearchResources.php b/app/Mcp/Tools/SearchResources.php new file mode 100644 index 000000000..4db76bfd2 --- /dev/null +++ b/app/Mcp/Tools/SearchResources.php @@ -0,0 +1,205 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $query = $request->get('query'); + if (! is_string($query) || trim($query) === '') { + return $this->mcpError($request, 'query argument is required.'); + } + + $needle = trim($query); + $like = '%'.strtolower($needle).'%'; + $limit = max(1, min(50, (int) ($request->get('limit') ?? 25))); + + $types = $request->get('types'); + $allowed = ['application', 'service', 'database', 'server', 'project']; + if ($types !== null) { + if (! is_string($types) && ! is_array($types)) { + return $this->mcpError($request, 'types must be a comma-separated string or omitted.'); + } + $typesList = is_array($types) + ? $types + : array_filter(array_map('trim', explode(',', $types))); + $typesList = array_values(array_intersect($typesList, $allowed)); + if ($typesList === []) { + return $this->mcpError($request, 'types must include application, service, database, server, and/or project.'); + } + } else { + $typesList = $allowed; + } + + $results = collect(); + + if (in_array('project', $typesList, true)) { + Project::where('team_id', $teamId) + ->where(function ($q) use ($like, $needle) { + $q->whereRaw('LOWER(name) LIKE ?', [$like]) + ->orWhere('uuid', $needle) + ->orWhereRaw('LOWER(COALESCE(description, \'\')) LIKE ?', [$like]); + }) + ->limit($limit) + ->get() + ->each(fn ($p) => $results->push([ + 'type' => 'project', + 'uuid' => $p->uuid, + 'name' => $p->name, + 'match' => 'name_or_uuid', + ])); + } + + if (in_array('server', $typesList, true)) { + Server::whereTeamId($teamId) + ->where(function ($q) use ($like, $needle) { + $q->whereRaw('LOWER(name) LIKE ?', [$like]) + ->orWhere('uuid', $needle) + ->orWhereRaw('LOWER(ip) LIKE ?', [$like]); + }) + ->limit($limit) + ->get() + ->each(fn ($s) => $results->push([ + 'type' => 'server', + 'uuid' => $s->uuid, + 'name' => $s->name, + 'ip' => $s->ip, + 'match' => 'name_ip_or_uuid', + ])); + } + + if (in_array('application', $typesList, true)) { + Application::ownedByCurrentTeamAPI($teamId) + ->with(['environment.project:id,uuid,name,team_id']) + ->where(function ($q) use ($like, $needle) { + $q->whereRaw('LOWER(name) LIKE ?', [$like]) + ->orWhere('uuid', $needle) + ->orWhereRaw('LOWER(COALESCE(fqdn, \'\')) LIKE ?', [$like]) + ->orWhereRaw('LOWER(COALESCE(git_repository, \'\')) LIKE ?', [$like]); + }) + ->limit($limit) + ->get() + ->each(fn ($app) => $results->push([ + 'type' => 'application', + 'uuid' => $app->uuid, + 'name' => $app->name, + 'status' => $app->status, + 'fqdn' => $app->fqdn, + 'project_uuid' => $app->environment?->project?->uuid, + 'project_name' => $app->environment?->project?->name, + 'match' => 'name_domain_repo_or_uuid', + ])); + } + + if (in_array('service', $typesList, true)) { + Service::whereHas('environment.project', fn ($q) => $q->where('team_id', $teamId)) + ->with(['environment.project:id,uuid,name,team_id']) + ->where(function ($q) use ($like, $needle) { + $q->whereRaw('LOWER(name) LIKE ?', [$like]) + ->orWhere('uuid', $needle); + }) + ->limit($limit) + ->get() + ->each(fn ($svc) => $results->push([ + 'type' => 'service', + 'uuid' => $svc->uuid, + 'name' => $svc->name, + 'status' => $svc->status ?? null, + 'project_uuid' => $svc->environment?->project?->uuid, + 'project_name' => $svc->environment?->project?->name, + 'match' => 'name_or_uuid', + ])); + } + + if (in_array('database', $typesList, true)) { + $projects = Project::where('team_id', $teamId)->select('id', 'uuid', 'name')->get()->keyBy('id'); + $envToProject = Environment::query() + ->whereIn('project_id', $projects->keys()) + ->pluck('project_id', 'id'); + $envIds = $envToProject->keys(); + + if ($envIds->isNotEmpty()) { + foreach (STANDALONE_DATABASE_MODELS as $modelClass) { + $rows = $modelClass::query() + ->whereIn('environment_id', $envIds) + ->where(function ($q) use ($like, $needle) { + $q->whereRaw('LOWER(name) LIKE ?', [$like]) + ->orWhere('uuid', $needle); + }) + ->limit($limit) + ->get(['uuid', 'name', 'status', 'environment_id']); + + foreach ($rows as $db) { + $projectId = $envToProject[$db->environment_id] ?? null; + $project = $projectId ? $projects->get($projectId) : null; + $results->push([ + 'type' => method_exists($db, 'type') ? $db->type() : 'database', + 'resource_kind' => 'database', + 'uuid' => $db->uuid, + 'name' => $db->name, + 'status' => $db->status ?? null, + 'project_uuid' => $project?->uuid, + 'project_name' => $project?->name, + 'match' => 'name_or_uuid', + ]); + } + } + } + } + + $ranked = $results + ->sortBy(function ($item) use ($needle) { + $exact = strcasecmp((string) ($item['uuid'] ?? ''), $needle) === 0 + || strcasecmp((string) ($item['name'] ?? ''), $needle) === 0; + + return $exact ? 0 : 1; + }) + ->take($limit) + ->values() + ->all(); + + return $this->mcpSuccess($request, $this->respond([ + 'query' => $needle, + 'results' => $ranked, + 'count' => count($ranked), + ])); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'query' => $schema->string()->description('Search string (name, UUID, domain, IP, git repo).')->required(), + 'types' => $schema->string()->description('Optional comma-separated types: application,service,database,server,project.'), + 'limit' => $schema->integer()->description('Max results (default 25, max 50).'), + ]; + } +} diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php index ca966bdb2..240f5b2e1 100644 --- a/tests/Feature/Mcp/McpEndpointTest.php +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -134,8 +134,13 @@ function expectMcpAuditLog(array $expected): void 'get_database', 'list_services', 'get_service', + 'get_project', + 'list_deployments', + 'get_logs', + 'list_env_keys', ); expect($toolNames)->not->toContain('get_resource_status'); + expect($toolNames)->toContain('coolify_help', 'control', 'deploy'); }); test('list_projects returns summary + pagination scoped to the token team', function () { @@ -195,10 +200,18 @@ function expectMcpAuditLog(array $expected): void $body = mcpToolJson($response); expect($body)->toHaveKey('data'); - expect($body['data'])->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts']); + expect($body['data'])->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts', 'health_hints']); expect($body['data']['counts']['projects'])->toBe(2); expect($body['data']['projects'])->toHaveCount(2); expect($body['data']['projects'][0])->toHaveKey('counts'); + expect($body['data']['projects'][0]['counts'])->toHaveKeys(['applications', 'services', 'databases']); + expect($body['data']['health_hints'])->toHaveKeys([ + 'unreachable_servers', + 'applications_not_running', + 'services_not_running', + 'databases_not_running', + 'next', + ]); }); test('get_server scrubs sensitive nested data and exposes connection_timeout', function () { diff --git a/tests/Feature/Mcp/McpImproveTest.php b/tests/Feature/Mcp/McpImproveTest.php new file mode 100644 index 000000000..2847b37e2 --- /dev/null +++ b/tests/Feature/Mcp/McpImproveTest.php @@ -0,0 +1,139 @@ +where('id', 0)->delete(); + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true]); + $settings->id = 0; + $settings->save(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first() + ?? Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'exited:unhealthy', + ]); +}); + +function mcpImproveToken(array $abilities = ['read']): string +{ + return test()->user->createToken('mcp-improve', $abilities)->plainTextToken; +} + +function mcpImproveCall(string $name, array $arguments = [], ?string $token = null) +{ + $token ??= mcpImproveToken(); + + // Ensure each call resolves the Bearer token freshly (no guard bleed between tokens). + auth()->forgetGuards(); + + return test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => $name, + 'arguments' => (object) $arguments, + ], + ]); +} + +function mcpImproveJson($response): array +{ + return json_decode($response->json('result.content.0.text'), true); +} + +test('get_logs returns structured next_tools when not running', function () { + $response = mcpImproveCall('get_logs', [ + 'resource' => 'application', + 'uuid' => $this->application->uuid, + ], mcpImproveToken(['read', 'read:sensitive'])); + $response->assertOk(); + $body = mcpImproveJson($response); + expect($body['data']['ok'])->toBeFalse() + ->and($body['data']['reason'])->toBe('not_running') + ->and(collect($body['data']['next_tools'])->pluck('tool')->all()) + ->toContain('list_deployments', 'list_unhealthy_resources'); +}); + +test('coolify_help returns essentials catalog', function () { + $response = mcpImproveCall('coolify_help', ['intent' => 'essentials']); + $response->assertOk(); + $body = mcpImproveJson($response); + expect($body['data']['catalog']['essentials']['tools'])->toContain('coolify_help', 'control', 'search_resources'); +}); + +test('list_unhealthy_resources sample_only returns summary', function () { + $response = mcpImproveCall('list_unhealthy_resources', [ + 'sample_only' => true, + 'sample_per_type' => 3, + ]); + $response->assertOk(); + $body = mcpImproveJson($response); + expect($body['data']['sample_only'])->toBeTrue() + ->and($body['data']['summary'])->toHaveKeys(['total', 'applications', 'servers']) + ->and($body['data']['samples'])->toHaveKeys(['applications', 'servers', 'services', 'databases']); +}); + +test('control requires deploy ability and stop requires confirm', function () { + $denied = mcpImproveCall('control', [ + 'resource' => 'application', + 'action' => 'start', + 'uuid' => $this->application->uuid, + ]); + expect($denied->json('result.isError'))->toBeTrue(); + expect($denied->json('result.content.0.text'))->toContain('Missing required permissions'); + + $deployToken = $this->user->createToken('mcp-deploy-stop', ['read', 'deploy'])->plainTextToken; + $stop = mcpImproveCall('control', [ + 'resource' => 'application', + 'action' => 'stop', + 'uuid' => $this->application->uuid, + ], $deployToken); + + expect($stop->json('result.isError'))->toBeTrue(); + expect((string) $stop->json('result.content.0.text'))->toContain('confirm=true'); +}); + +test('tools list includes coolify_help and control', function () { + $token = mcpImproveToken(); + $response = test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => (object) [], + ]); + $response->assertOk(); + $names = collect($response->json('result.tools'))->pluck('name')->all(); + expect($names)->toContain('coolify_help', 'control', 'deploy', 'cancel_deployment'); +}); diff --git a/tests/Feature/Mcp/McpReadToolsTest.php b/tests/Feature/Mcp/McpReadToolsTest.php new file mode 100644 index 000000000..b755e75b9 --- /dev/null +++ b/tests/Feature/Mcp/McpReadToolsTest.php @@ -0,0 +1,2414 @@ +where('id', 0)->delete(); + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true]); + $settings->id = 0; + $settings->save(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + // Server::created auto-provisions a default StandaloneDocker (network=coolify). + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + // Project::created auto-creates a production environment. + $this->environment = $this->project->environments()->first() + ?? Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'fqdn' => 'https://app.example.com', + ]); +}); + +function mcpReadToken(): string +{ + return test()->user->createToken('mcp-read', ['read'])->plainTextToken; +} + +function mcpReadCall(string $name, array $arguments = []) +{ + // Ensure each call resolves the Bearer token freshly (no guard bleed between tokens). + auth()->forgetGuards(); + + return test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.mcpReadToken(), + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => $name, + 'arguments' => (object) $arguments, + ], + ]); +} + +function mcpSensitiveReadCall(string $name, array $arguments = []) +{ + // Ensure each call resolves the Bearer token freshly (no guard bleed between tokens). + auth()->forgetGuards(); + + $token = test()->user->createToken('mcp-sensitive-read', ['read', 'read:sensitive'])->plainTextToken; + + return test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => $name, + 'arguments' => (object) $arguments, + ], + ]); +} + +function mcpReadJson($response): array +{ + return json_decode($response->json('result.content.0.text'), true); +} + +test('tools/list includes new read tools and lifecycle tools', function () { + $token = mcpReadToken(); + $response = test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => (object) [], + ]); + + $response->assertOk(); + $names = collect($response->json('result.tools'))->pluck('name')->all(); + + expect($names)->toContain( + 'get_project', + 'get_environment', + 'list_resources', + 'list_deployments', + 'get_deployment', + 'get_logs', + 'list_env_keys', + 'list_storages', + 'list_destinations', + 'get_destination', + 'get_server_domains', + 'get_server_resources', + 'list_tags', + 'list_github_apps', + 'get_current_team', + 'list_team_members', + 'list_database_backups', + 'list_service_applications', + 'list_service_databases', + 'search_resources', + 'list_unhealthy_resources', + 'list_application_previews', + 'list_shared_env_keys', + 'coolify_help', + 'control', + 'deploy', + 'cancel_deployment', + ); +}); + +test('database backup tools scope schedules by database type and id', function () { + $postgres = StandalonePostgresql::create([ + 'name' => 'postgres', + 'postgres_password' => 'password', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $mysql = StandaloneMysql::create([ + 'name' => 'mysql', + 'mysql_root_password' => 'password', + 'mysql_password' => 'password', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + expect($mysql->id)->toBe($postgres->id); + + $postgresBackup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => '0 0 * * *', + 'database_id' => $postgres->id, + 'database_type' => $postgres->getMorphClass(), + ]); + $mysqlBackup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => '0 0 * * *', + 'database_id' => $mysql->id, + 'database_type' => $mysql->getMorphClass(), + ]); + + $response = mcpReadCall('list_database_backups', ['uuid' => $postgres->uuid]); + $response->assertOk(); + + $backupUuids = collect(mcpReadJson($response)['data']['backups'])->pluck('uuid'); + expect($backupUuids) + ->toContain($postgresBackup->uuid) + ->not->toContain($mysqlBackup->uuid); + + $response = mcpReadCall('list_backup_executions', [ + 'database_uuid' => $postgres->uuid, + 'scheduled_backup_uuid' => $mysqlBackup->uuid, + ]); + $response->assertOk(); + expect($response->json('result.isError'))->toBeTrue(); +}); + +test('list_backup_executions omits messages without sensitive read and redacts when included', function () { + $postgres = StandalonePostgresql::create([ + 'name' => 'backup-msg-db', + 'postgres_password' => 'password', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => '0 0 * * *', + 'database_id' => $postgres->id, + 'database_type' => $postgres->getMorphClass(), + ]); + + $older = ScheduledDatabaseBackupExecution::create([ + 'uuid' => (string) Str::uuid(), + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'success', + 'message' => 'older backup ok', + 'size' => 100, + 'filename' => 'old.sql.gz', + 'created_at' => now()->subHours(2), + 'updated_at' => now()->subHours(2), + ]); + $newer = ScheduledDatabaseBackupExecution::create([ + 'uuid' => (string) Str::uuid(), + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'failed', + 'message' => "backup failed password=redactme01\n", + 'size' => 0, + 'filename' => 'new.sql.gz', + 'created_at' => now()->subHour(), + 'updated_at' => now()->subHour(), + ]); + + $readOnly = mcpReadCall('list_backup_executions', [ + 'database_uuid' => $postgres->uuid, + 'scheduled_backup_uuid' => $backup->uuid, + 'page' => 1, + 'per_page' => 1, + ]); + $readOnly->assertOk(); + $readBody = mcpReadJson($readOnly); + expect($readBody['data']['message_included'])->toBeFalse() + ->and($readBody['data']['executions'])->toHaveCount(1) + ->and($readBody['data']['executions'][0]['status'])->toBe('failed') + ->and($readBody['data']['executions'][0]['filename'])->toBe('new.sql.gz') + ->and($readBody['data']['executions'][0])->not->toHaveKey('message') + ->and($readBody['_pagination']['total'])->toBe(2); + + $page2 = mcpReadCall('list_backup_executions', [ + 'database_uuid' => $postgres->uuid, + 'scheduled_backup_uuid' => $backup->uuid, + 'page' => 2, + 'per_page' => 1, + ]); + $page2->assertOk(); + expect(mcpReadJson($page2)['data']['executions'][0]['status'])->toBe('success'); + + $sensitive = mcpSensitiveReadCall('list_backup_executions', [ + 'database_uuid' => $postgres->uuid, + 'scheduled_backup_uuid' => $backup->uuid, + 'page' => 1, + 'per_page' => 1, + ]); + $sensitive->assertOk(); + $sensitiveBody = mcpReadJson($sensitive); + $message = $sensitiveBody['data']['executions'][0]['message'] ?? ''; + expect($sensitiveBody['data']['message_included'])->toBeTrue() + ->and($message)->not->toContain('redactme01') + ->and($message)->toContain('password=') + ->and($message)->toContain(REDACTED); + + expect($older->id)->not->toBe($newer->id); +}); + +test('get_project returns environments and counts for team project only', function () { + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + + $response = mcpReadCall('get_project', ['uuid' => $this->project->uuid]); + $response->assertOk(); + $body = mcpReadJson($response); + + expect($body['data']['uuid'])->toBe($this->project->uuid); + expect($body['data']['counts']['applications'])->toBeGreaterThanOrEqual(1); + expect($body['data']['environments'])->not->toBeEmpty(); + + $denied = mcpReadCall('get_project', ['uuid' => $otherProject->uuid]); + expect($denied->json('result.isError'))->toBeTrue(); +}); + +test('get_environment is team scoped via project', function () { + $response = mcpReadCall('get_environment', [ + 'project_uuid' => $this->project->uuid, + 'environment_name_or_uuid' => $this->environment->name, + ]); + $response->assertOk(); + $body = mcpReadJson($response); + + expect($body['data']['uuid'])->toBe($this->environment->uuid); + expect(collect($body['data']['applications'])->pluck('uuid'))->toContain($this->application->uuid); + expect($body['data']['counts']['applications'])->toBeGreaterThanOrEqual(1); + expect($body['data']['truncated']['applications'])->toBeFalse(); + + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnv = Environment::factory()->create(['project_id' => $otherProject->id]); + + $denied = mcpReadCall('get_environment', [ + 'project_uuid' => $otherProject->uuid, + 'environment_name_or_uuid' => $otherEnv->uuid, + ]); + expect($denied->json('result.isError'))->toBeTrue(); +}); + +test('get_environment caps resource samples and points to list tools when truncated', function () { + foreach (['env-app-a', 'env-app-b', 'env-app-c'] as $name) { + Application::factory()->create([ + 'name' => $name, + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + } + + $response = mcpReadCall('get_environment', [ + 'project_uuid' => $this->project->uuid, + 'environment_name_or_uuid' => $this->environment->uuid, + 'sample_per_type' => 2, + ]); + $response->assertOk(); + $body = mcpReadJson($response); + + // beforeEach already has one application in this environment. + expect($body['data']['counts']['applications'])->toBeGreaterThanOrEqual(4) + ->and($body['data']['applications'])->toHaveCount(2) + ->and($body['data']['truncated']['applications'])->toBeTrue() + ->and(collect($body['data']['next_tools'])->pluck('tool')->all())->toContain('list_applications'); +}); + +test('list_resources only returns team resources', function () { + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnv = Environment::factory()->create(['project_id' => $otherProject->id]); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); + Application::factory()->create([ + 'name' => 'OtherTeamApp', + 'environment_id' => $otherEnv->id, + 'destination_id' => $otherDest->id, + 'destination_type' => $otherDest->getMorphClass(), + ]); + + $response = mcpReadCall('list_resources'); + $response->assertOk(); + $body = mcpReadJson($response); + + $uuids = collect($body['data'])->pluck('uuid'); + $names = collect($body['data'])->pluck('name'); + expect($uuids)->toContain($this->application->uuid); + expect($names)->not->toContain('OtherTeamApp'); +}); + +test('list_resources paginates sorts and filters at the query layer', function () { + $this->application->update(['name' => 'Charlie App']); + + $alphaApp = Application::factory()->create([ + 'name' => 'Alpha App', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $bravoService = Service::factory()->create([ + 'name' => 'Bravo Service', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $deltaDb = StandalonePostgresql::create([ + 'name' => 'Delta DB', + 'postgres_password' => 'password', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $tag = Tag::create([ + 'name' => 'mcp-listed', + 'team_id' => $this->team->id, + ]); + $alphaApp->tags()->attach($tag->id); + $bravoService->tags()->attach($tag->id); + + $otherProject = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Other Project']); + $otherEnv = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + Application::factory()->create([ + 'name' => 'Zed Other Project App', + 'environment_id' => $otherEnv->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $page1 = mcpReadCall('list_resources', ['page' => 1, 'per_page' => 2]); + $page1->assertOk(); + $body1 = mcpReadJson($page1); + expect($body1['_pagination']['total'])->toBe(5) + ->and($body1['_pagination']['per_page'])->toBe(2) + ->and($body1['_pagination']['page'])->toBe(1) + ->and(collect($body1['data'])->pluck('name')->all())->toBe(['Alpha App', 'Bravo Service']); + + $page2 = mcpReadCall('list_resources', ['page' => 2, 'per_page' => 2]); + $page2->assertOk(); + $body2 = mcpReadJson($page2); + expect(collect($body2['data'])->pluck('name')->all())->toBe(['Charlie App', 'Delta DB']); + + $appsOnly = mcpReadCall('list_resources', ['type' => 'application']); + $appsOnly->assertOk(); + $appsBody = mcpReadJson($appsOnly); + expect(collect($appsBody['data'])->pluck('type')->unique()->values()->all())->toBe(['application']) + ->and($appsBody['_pagination']['total'])->toBe(3); + + $dbsOnly = mcpReadCall('list_resources', ['type' => 'database']); + $dbsOnly->assertOk(); + $dbsBody = mcpReadJson($dbsOnly); + expect($dbsBody['_pagination']['total'])->toBe(1) + ->and($dbsBody['data'][0]['uuid'])->toBe($deltaDb->uuid) + ->and($dbsBody['data'][0]['type'])->toBe('standalone-postgresql'); + + $tagged = mcpReadCall('list_resources', ['tag' => 'mcp-listed']); + $tagged->assertOk(); + $taggedBody = mcpReadJson($tagged); + expect(collect($taggedBody['data'])->pluck('uuid')->sort()->values()->all()) + ->toBe(collect([$alphaApp->uuid, $bravoService->uuid])->sort()->values()->all()); + + $byProject = mcpReadCall('list_resources', ['project_uuid' => $otherProject->uuid]); + $byProject->assertOk(); + $projectBody = mcpReadJson($byProject); + expect($projectBody['_pagination']['total'])->toBe(1) + ->and($projectBody['data'][0]['name'])->toBe('Zed Other Project App') + ->and($projectBody['data'][0]['project_uuid'])->toBe($otherProject->uuid); +}); + +test('list_deployments and get_deployment are team scoped and scrub logs', function () { + $deployment = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'dep-'.fake()->uuid(), + 'status' => 'in_progress', + 'server_id' => $this->server->id, + 'application_name' => $this->application->name, + 'server_name' => $this->server->name, + 'commit' => 'abc123', + 'logs' => json_encode([['name' => 'build', 'output' => 'SECRET_TOKEN=redactme01']]), + ]); + + $list = mcpReadCall('list_deployments'); + $list->assertOk(); + $listBody = mcpReadJson($list); + expect(collect($listBody['data'])->pluck('deployment_uuid'))->toContain($deployment->deployment_uuid); + expect(json_encode($listBody))->not->toContain('redactme01'); + expect(json_encode($listBody))->not->toContain('"logs"'); + + $get = mcpReadCall('get_deployment', ['uuid' => $deployment->deployment_uuid]); + $get->assertOk(); + $getBody = mcpReadJson($get); + expect($getBody['data']['deployment_uuid'])->toBe($deployment->deployment_uuid); + expect($getBody['data']['application_uuid'])->toBe($this->application->uuid); + expect(json_encode($getBody))->not->toContain('redactme01'); + + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnv = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); + $otherApp = Application::factory()->create([ + 'environment_id' => $otherEnv->id, + 'destination_id' => $otherDest->id, + 'destination_type' => $otherDest->getMorphClass(), + ]); + $otherDep = ApplicationDeploymentQueue::create([ + 'application_id' => $otherApp->id, + 'deployment_uuid' => 'dep-other-'.fake()->uuid(), + 'status' => 'in_progress', + 'server_id' => $otherServer->id, + 'application_name' => $otherApp->name, + 'server_name' => $otherServer->name, + ]); + + $denied = mcpReadCall('get_deployment', ['uuid' => $otherDep->deployment_uuid]); + expect($denied->json('result.isError'))->toBeTrue(); +}); + +test('deployment listings and overview scope shared server deployments by application team', function () { + $teamDeployment = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'dep-team-'.fake()->uuid(), + 'status' => 'in_progress', + 'server_id' => $this->server->id, + 'application_name' => $this->application->name, + 'server_name' => $this->server->name, + ]); + + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnvironment = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + $otherApplication = Application::factory()->create([ + 'environment_id' => $otherEnvironment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $otherDeployment = ApplicationDeploymentQueue::create([ + 'application_id' => $otherApplication->id, + 'deployment_uuid' => 'dep-other-shared-server-'.fake()->uuid(), + 'status' => 'in_progress', + 'server_id' => $this->server->id, + 'application_name' => $otherApplication->name, + 'server_name' => $this->server->name, + ]); + + $listBody = mcpReadJson(mcpReadCall('list_deployments')); + expect(collect($listBody['data'])->pluck('deployment_uuid')) + ->toContain($teamDeployment->deployment_uuid) + ->not->toContain($otherDeployment->deployment_uuid); + + $overviewBody = mcpReadJson(mcpReadCall('get_infrastructure_overview')); + expect($overviewBody['data']['counts']['open_deployments'])->toBe(1); +}); + +test('list_env_keys never returns values and is team scoped', function () { + EnvironmentVariable::create([ + 'key' => 'DATABASE_URL', + 'value' => 'postgres://secret@localhost/db', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + 'is_preview' => false, + ]); + + $response = mcpReadCall('list_env_keys', [ + 'resource' => 'application', + 'uuid' => $this->application->uuid, + ]); + $response->assertOk(); + $body = mcpReadJson($response); + $raw = json_encode($body); + + expect(collect($body['data']['keys'])->pluck('key'))->toContain('DATABASE_URL'); + expect($raw)->not->toContain('postgres://secret'); + expect($raw)->not->toContain('"value"'); + expect($raw)->not->toContain('real_value'); + + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnv = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); + $otherApp = Application::factory()->create([ + 'environment_id' => $otherEnv->id, + 'destination_id' => $otherDest->id, + 'destination_type' => $otherDest->getMorphClass(), + ]); + + $denied = mcpReadCall('list_env_keys', [ + 'resource' => 'application', + 'uuid' => $otherApp->uuid, + ]); + expect($denied->json('result.isError'))->toBeTrue(); +}); + +test('list_destinations and get_destination are team scoped', function () { + $response = mcpReadCall('list_destinations'); + $response->assertOk(); + $body = mcpReadJson($response); + expect(collect($body['data'])->pluck('uuid'))->toContain($this->destination->uuid); + + $get = mcpReadCall('get_destination', ['uuid' => $this->destination->uuid]); + $get->assertOk(); + expect(mcpReadJson($get)['data']['uuid'])->toBe($this->destination->uuid); + + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); + $denied = mcpReadCall('get_destination', ['uuid' => $otherDest->uuid]); + expect($denied->json('result.isError'))->toBeTrue(); +}); + +test('get_server_domains and get_server_resources are team scoped', function () { + $domains = mcpReadCall('get_server_domains', ['uuid' => $this->server->uuid]); + $domains->assertOk(); + $domainBody = mcpReadJson($domains); + expect($domainBody['data']['server_uuid'])->toBe($this->server->uuid); + expect($domainBody['data']['domains'])->toHaveCount(1); + expect($domainBody['data']['domains'][0]['resource_uuid'])->toBe($this->application->uuid); + expect($domainBody['data']['domains'][0]['domains'])->toContain('app.example.com'); + + $resources = mcpReadCall('get_server_resources', ['uuid' => $this->server->uuid]); + $resources->assertOk(); + + $otherServer = Server::factory()->create(['team_id' => Team::factory()->create()->id]); + expect(mcpReadCall('get_server_domains', ['uuid' => $otherServer->uuid])->json('result.isError'))->toBeTrue(); + expect(mcpReadCall('get_server_resources', ['uuid' => $otherServer->uuid])->json('result.isError'))->toBeTrue(); +}); + +test('get_server_domains filters polymorphic destinations by type and id', function () { + // Other server gets the next standalone_dockers id (typically 2). + $otherServer = Server::factory()->create(['team_id' => $this->team->id]); + $otherStandalone = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); + + // Swarm on this server with the same numeric id as the other server's + // standalone docker. Untyped whereIn(destination_id) would merge that id + // and wrongly attribute the other server's app to this server. + DB::table('swarm_dockers')->insert([ + 'id' => $otherStandalone->id, + 'uuid' => (string) Str::uuid(), + 'server_id' => $this->server->id, + 'name' => 'swarm-network', + 'network' => 'swarm-network', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $swarmOnThisServer = SwarmDocker::query()->findOrFail($otherStandalone->id); + + expect($swarmOnThisServer->id)->toBe($otherStandalone->id); + expect($this->destination->id)->not->toBe($otherStandalone->id); + + $swarmApp = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $swarmOnThisServer->id, + 'destination_type' => SwarmDocker::class, + 'fqdn' => 'https://swarm.example.com', + 'name' => 'swarm-app', + ]); + + $otherServerApp = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $otherStandalone->id, + 'destination_type' => StandaloneDocker::class, + 'fqdn' => 'https://other-server.example.com', + 'name' => 'other-server-app', + ]); + + $domains = mcpReadCall('get_server_domains', ['uuid' => $this->server->uuid]); + $domains->assertOk(); + $domainBody = mcpReadJson($domains); + $resourceUuids = collect($domainBody['data']['domains'])->pluck('resource_uuid'); + + expect($resourceUuids)->toContain($this->application->uuid); + expect($resourceUuids)->toContain($swarmApp->uuid); + expect($resourceUuids)->not->toContain($otherServerApp->uuid); +}); + +test('list_applications list_databases list_services server_uuid filters use destination type', function () { + // Other server gets the next standalone_dockers id (typically 2). + $otherServer = Server::factory()->create(['team_id' => $this->team->id]); + $otherStandalone = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); + + // Swarm on this server with the same numeric id as the other server's standalone docker. + DB::table('swarm_dockers')->insert([ + 'id' => $otherStandalone->id, + 'uuid' => (string) Str::uuid(), + 'server_id' => $this->server->id, + 'name' => 'swarm-network-list-filter', + 'network' => 'swarm-network-list-filter', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $swarmOnThisServer = SwarmDocker::query()->findOrFail($otherStandalone->id); + expect($swarmOnThisServer->id)->toBe($otherStandalone->id); + + $swarmApp = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $swarmOnThisServer->id, + 'destination_type' => SwarmDocker::class, + 'name' => 'swarm-list-app', + ]); + $otherServerApp = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $otherStandalone->id, + 'destination_type' => StandaloneDocker::class, + 'name' => 'other-server-list-app', + ]); + + $swarmDb = StandalonePostgresql::create([ + 'name' => 'swarm-list-db', + 'status' => 'running:healthy', + 'postgres_password' => 'password', + 'environment_id' => $this->environment->id, + 'destination_id' => $swarmOnThisServer->id, + 'destination_type' => SwarmDocker::class, + ]); + $otherServerDb = StandalonePostgresql::create([ + 'name' => 'other-server-list-db', + 'status' => 'running:healthy', + 'postgres_password' => 'password', + 'environment_id' => $this->environment->id, + 'destination_id' => $otherStandalone->id, + 'destination_type' => StandaloneDocker::class, + ]); + + $swarmService = Service::factory()->create([ + 'name' => 'swarm-list-svc', + 'environment_id' => $this->environment->id, + 'server_id' => null, + 'destination_id' => $swarmOnThisServer->id, + 'destination_type' => SwarmDocker::class, + ]); + $otherServerService = Service::factory()->create([ + 'name' => 'other-server-list-svc', + 'environment_id' => $this->environment->id, + 'server_id' => null, + 'destination_id' => $otherStandalone->id, + 'destination_type' => StandaloneDocker::class, + ]); + + $apps = mcpReadCall('list_applications', ['server_uuid' => $this->server->uuid]); + $apps->assertOk(); + $appUuids = collect(mcpReadJson($apps)['data'])->pluck('uuid'); + expect($appUuids)->toContain($this->application->uuid, $swarmApp->uuid) + ->not->toContain($otherServerApp->uuid); + + $dbs = mcpReadCall('list_databases', ['server_uuid' => $this->server->uuid]); + $dbs->assertOk(); + $dbUuids = collect(mcpReadJson($dbs)['data'])->pluck('uuid'); + expect($dbUuids)->toContain($swarmDb->uuid) + ->not->toContain($otherServerDb->uuid); + + $services = mcpReadCall('list_services', ['server_uuid' => $this->server->uuid]); + $services->assertOk(); + $serviceUuids = collect(mcpReadJson($services)['data'])->pluck('uuid'); + expect($serviceUuids)->toContain($swarmService->uuid) + ->not->toContain($otherServerService->uuid); +}); + +test('list_tags and get_current_team and list_team_members are team scoped', function () { + Tag::create(['name' => 'prod', 'team_id' => $this->team->id]); + Tag::create(['name' => 'theirs', 'team_id' => Team::factory()->create()->id]); + + $tags = mcpReadCall('list_tags'); + $tags->assertOk(); + $tagNames = collect(mcpReadJson($tags)['data'])->pluck('name'); + expect($tagNames)->toContain('prod'); + expect($tagNames)->not->toContain('theirs'); + + $team = mcpReadCall('get_current_team'); + $team->assertOk(); + expect(mcpReadJson($team)['data']['name'])->toBe($this->team->name); + + $members = mcpReadCall('list_team_members'); + $members->assertOk(); + expect(collect(mcpReadJson($members)['data'])->pluck('email'))->toContain($this->user->email); +}); + +test('list_github_apps is team scoped and scrubs secrets', function () { + $app = GithubApp::create([ + 'name' => 'Mine', + 'team_id' => $this->team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 1, + 'installation_id' => 1, + 'client_id' => 'client-id', + 'client_secret' => 'super-client-secret', + 'webhook_secret' => 'super-webhook-secret', + 'is_public' => false, + 'is_system_wide' => false, + ]); + + GithubApp::create([ + 'name' => 'Theirs', + 'team_id' => Team::factory()->create()->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 2, + 'installation_id' => 2, + 'client_id' => 'other-client', + 'client_secret' => 'other-secret', + 'webhook_secret' => 'other-webhook', + 'is_public' => false, + 'is_system_wide' => false, + ]); + + $response = mcpReadCall('list_github_apps'); + $response->assertOk(); + $body = mcpReadJson($response); + $names = collect($body['data'])->pluck('name'); + $raw = json_encode($body); + + expect($names)->toContain('Mine'); + expect($names)->not->toContain('Theirs'); + expect($raw)->not->toContain('super-client-secret'); + expect($raw)->not->toContain('super-webhook-secret'); + expect(collect($body['data'])->pluck('uuid'))->toContain($app->uuid); +}); + +test('list_github_repositories rejects public github sources cleanly', function () { + $publicApp = GithubApp::create([ + 'name' => 'Public Source', + 'uuid' => 'github-public-test', + 'team_id' => $this->team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'is_public' => true, + 'is_system_wide' => false, + ]); + + $response = mcpReadCall('list_github_repositories', [ + 'github_app_uuid' => $publicApp->uuid, + ]); + $response->assertOk(); + expect($response->json('result.isError'))->toBeTrue(); + expect($response->json('result.content.0.text')) + ->toContain('public or missing app installation credentials') + ->not->toContain('private_key'); +}); + +test('list_github_branches uses anonymous github api for public sources', function () { + $publicApp = GithubApp::create([ + 'name' => 'Public Source', + 'uuid' => 'github-public-branches', + 'team_id' => $this->team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'is_public' => true, + 'is_system_wide' => false, + ]); + + Http::fake([ + 'https://api.github.com/repos/coollabsio/coolify/branches*' => Http::response([ + ['name' => 'v4.x', 'protected' => true, 'commit' => ['sha' => 'abc123']], + ['name' => 'next', 'protected' => false, 'commit' => ['sha' => 'def456']], + ], 200), + ]); + + $response = mcpReadCall('list_github_branches', [ + 'github_app_uuid' => $publicApp->uuid, + 'owner' => 'coollabsio', + 'repo' => 'coolify', + ]); + $response->assertOk(); + expect($response->json('result.isError'))->toBeFalse(); + $body = mcpReadJson($response); + expect(collect($body['data']['branches'])->pluck('name')->all())->toContain('v4.x', 'next'); + expect($body['data']['branches'][0]['commit_sha'])->toBe('abc123'); +}); + +test('list_github_branches rejects private apps missing installation credentials', function () { + $privateApp = GithubApp::create([ + 'name' => 'Incomplete Private App', + 'uuid' => 'github-private-incomplete', + 'team_id' => $this->team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => null, + 'installation_id' => null, + 'private_key_id' => null, + 'is_public' => false, + 'is_system_wide' => false, + ]); + + Http::fake(); + + $response = mcpReadCall('list_github_branches', [ + 'github_app_uuid' => $privateApp->uuid, + 'owner' => 'coollabsio', + 'repo' => 'coolify', + ]); + $response->assertOk(); + expect($response->json('result.isError'))->toBeTrue(); + expect($response->json('result.content.0.text')) + ->toContain('missing installation credentials') + ->not->toContain('private_key'); + Http::assertNothingSent(); +}); + +test('list_github_branches rejects owner or repo path segment injection', function () { + $publicApp = GithubApp::create([ + 'name' => 'Public Source Path Check', + 'uuid' => 'github-public-path-check', + 'team_id' => $this->team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'is_public' => true, + 'is_system_wide' => false, + ]); + + Http::fake(); + + $badOwner = mcpReadCall('list_github_branches', [ + 'github_app_uuid' => $publicApp->uuid, + 'owner' => 'cool/../labs', + 'repo' => 'coolify', + ]); + $badOwner->assertOk(); + expect($badOwner->json('result.isError'))->toBeTrue(); + expect($badOwner->json('result.content.0.text'))->toContain('valid GitHub login'); + + $badRepo = mcpReadCall('list_github_branches', [ + 'github_app_uuid' => $publicApp->uuid, + 'owner' => 'coollabsio', + 'repo' => 'coolify/extra', + ]); + $badRepo->assertOk(); + expect($badRepo->json('result.isError'))->toBeTrue(); + expect($badRepo->json('result.content.0.text'))->toContain('valid GitHub repository'); + + Http::assertNothingSent(); +}); + +test('list_applications project_uuid filter is team scoped', function () { + $otherProject = Project::factory()->create(['team_id' => $this->team->id]); + $otherEnv = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + Application::factory()->create([ + 'name' => 'OtherProjectApp', + 'environment_id' => $otherEnv->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = mcpReadCall('list_applications', ['project_uuid' => $this->project->uuid]); + $response->assertOk(); + $body = mcpReadJson($response); + $names = collect($body['data'])->pluck('name'); + expect($names)->toContain($this->application->name); + expect($names)->not->toContain('OtherProjectApp'); +}); + +test('get_logs rejects other team application uuid', function () { + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnv = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); + $otherApp = Application::factory()->create([ + 'environment_id' => $otherEnv->id, + 'destination_id' => $otherDest->id, + 'destination_type' => $otherDest->getMorphClass(), + ]); + + $response = mcpSensitiveReadCall('get_logs', [ + 'resource' => 'application', + 'uuid' => $otherApp->uuid, + ]); + expect($response->json('result.isError'))->toBeTrue(); +}); + +test('search_resources finds team app by name and domain and excludes other team', function () { + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnv = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); + Application::factory()->create([ + 'name' => 'SecretOtherApp', + 'fqdn' => 'https://app.example.com', + 'environment_id' => $otherEnv->id, + 'destination_id' => $otherDest->id, + 'destination_type' => $otherDest->getMorphClass(), + ]); + + $byName = mcpReadCall('search_resources', ['query' => $this->application->name]); + $byName->assertOk(); + $names = collect(mcpReadJson($byName)['data']['results'])->pluck('name'); + expect($names)->toContain($this->application->name); + expect($names)->not->toContain('SecretOtherApp'); + + $byDomain = mcpReadCall('search_resources', ['query' => 'app.example.com', 'types' => 'application']); + $byDomain->assertOk(); + $uuids = collect(mcpReadJson($byDomain)['data']['results'])->pluck('uuid'); + expect($uuids)->toContain($this->application->uuid); +}); + +test('list_unhealthy_resources includes non-running apps and is team scoped', function () { + $this->application->update(['status' => 'exited:unhealthy']); + + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnv = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); + Application::factory()->create([ + 'name' => 'OtherDown', + 'status' => 'exited:unhealthy', + 'environment_id' => $otherEnv->id, + 'destination_id' => $otherDest->id, + 'destination_type' => $otherDest->getMorphClass(), + ]); + + $response = mcpReadCall('list_unhealthy_resources'); + $response->assertOk(); + $body = mcpReadJson($response); + $names = collect($body['data']['unhealthy'])->pluck('name'); + expect($names)->toContain($this->application->name); + expect($names)->not->toContain('OtherDown'); +}); + +test('get_infrastructure_overview health_hints and project counts stay accurate', function () { + $this->application->update(['status' => 'exited:unhealthy']); + Application::factory()->create([ + 'name' => 'HealthyApp', + 'status' => 'running:healthy', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + StandalonePostgresql::create([ + 'name' => 'DownDb', + 'postgres_password' => 'password', + 'status' => 'exited:unhealthy', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + StandalonePostgresql::create([ + 'name' => 'UpDb', + 'postgres_password' => 'password', + 'status' => 'running:healthy', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + Service::factory()->create([ + 'name' => 'EmptyService', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnv = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); + Application::factory()->create([ + 'name' => 'OtherTeamDown', + 'status' => 'exited:unhealthy', + 'environment_id' => $otherEnv->id, + 'destination_id' => $otherDest->id, + 'destination_type' => $otherDest->getMorphClass(), + ]); + StandalonePostgresql::create([ + 'name' => 'OtherTeamDb', + 'postgres_password' => 'password', + 'status' => 'exited:unhealthy', + 'environment_id' => $otherEnv->id, + 'destination_id' => $otherDest->id, + 'destination_type' => $otherDest->getMorphClass(), + ]); + + $response = mcpReadCall('get_infrastructure_overview'); + $response->assertOk(); + $body = mcpReadJson($response); + $data = $body['data']; + + expect($data['counts']['applications'])->toBe(2) + ->and($data['counts']['services'])->toBe(1) + ->and($data['counts']['databases'])->toBe(2) + ->and($data['projects'][0]['counts']['applications'])->toBe(2) + ->and($data['projects'][0]['counts']['services'])->toBe(1) + ->and($data['projects'][0]['counts']['databases'])->toBe(2) + ->and($data['health_hints']['applications_not_running'])->toBe(1) + ->and($data['health_hints']['databases_not_running'])->toBe(1) + // Empty service has no containers → aggregated status is not healthy. + ->and($data['health_hints']['services_not_running'])->toBe(1); +}); + +test('list_application_previews is team scoped', function () { + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://github.com/org/repo/pull/42', + 'fqdn' => 'https://pr-42.example.com', + 'status' => 'running:healthy', + ]); + + $response = mcpReadCall('list_application_previews', ['uuid' => $this->application->uuid]); + $response->assertOk(); + $body = mcpReadJson($response); + expect(collect($body['data']['previews'])->pluck('uuid'))->toContain($preview->uuid); + expect(collect($body['data']['previews'])->pluck('pull_request_id'))->toContain(42); + + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnv = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); + $otherApp = Application::factory()->create([ + 'environment_id' => $otherEnv->id, + 'destination_id' => $otherDest->id, + 'destination_type' => $otherDest->getMorphClass(), + ]); + + expect(mcpReadCall('list_application_previews', ['uuid' => $otherApp->uuid])->json('result.isError'))->toBeTrue(); +}); + +test('list_shared_env_keys returns names without values and is team scoped', function () { + SharedEnvironmentVariable::create([ + 'key' => 'SHARED_API_URL', + 'value' => 'https://secret.example.com', + 'type' => 'project', + 'team_id' => $this->team->id, + 'project_id' => $this->project->id, + ]); + + $response = mcpReadCall('list_shared_env_keys', [ + 'scope' => 'project', + 'uuid' => $this->project->uuid, + ]); + $response->assertOk(); + $body = mcpReadJson($response); + $raw = json_encode($body); + expect(collect($body['data']['keys'])->pluck('key'))->toContain('SHARED_API_URL'); + expect($raw)->not->toContain('secret.example.com'); + expect($raw)->not->toContain('"value"'); + + $otherProject = Project::factory()->create(['team_id' => Team::factory()->create()->id]); + expect(mcpReadCall('list_shared_env_keys', [ + 'scope' => 'project', + 'uuid' => $otherProject->uuid, + ])->json('result.isError'))->toBeTrue(); +}); + +test('get_deployment include_log_summary requires sensitive read ability', function () { + $deployment = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'dep-log-'.fake()->uuid(), + 'status' => 'failed', + 'server_id' => $this->server->id, + 'application_name' => $this->application->name, + 'server_name' => $this->server->name, + 'commit' => 'deadbeef', + 'logs' => json_encode([ + ['output' => 'unstructured-sensitive-build-output', 'type' => 'stdout', 'hidden' => false], + ]), + ]); + + $response = mcpReadCall('get_deployment', [ + 'uuid' => $deployment->deployment_uuid, + 'include_log_summary' => true, + ]); + + $response->assertOk(); + expect($response->json('result.isError'))->toBeTrue() + ->and($response->json('result.content.0.text'))->toContain('read:sensitive') + ->and($response->json('result.content.0.text'))->not->toContain('unstructured-sensitive-build-output'); +}); + +test('get_deployment include_log_summary returns capped redacted text with sensitive read ability', function () { + $logs = json_encode([ + ['output' => 'step 1 ok', 'type' => 'stdout', 'hidden' => false], + ['output' => 'password=redactme01', 'type' => 'stderr', 'hidden' => false], + ['output' => 'done', 'type' => 'stdout', 'hidden' => false], + ]); + + $deployment = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'dep-log-'.fake()->uuid(), + 'status' => 'failed', + 'server_id' => $this->server->id, + 'application_name' => $this->application->name, + 'server_name' => $this->server->name, + 'commit' => 'deadbeef', + 'logs' => $logs, + ]); + + $response = mcpSensitiveReadCall('get_deployment', [ + 'uuid' => $deployment->deployment_uuid, + 'include_log_summary' => true, + 'log_lines' => 10, + ]); + $response->assertOk(); + $body = mcpReadJson($response); + + expect($body['data']['log_summary']['available'])->toBeTrue(); + expect($body['data']['log_summary']['text'])->toContain('step 1 ok'); + expect($body['data']['log_summary']['text'])->not->toContain('redactme01'); + expect($body['data']['log_summary']['text'])->toContain('password='); + // Full logs field still scrubbed from root payload + expect(json_encode($body))->not->toContain('"logs":'); +}); + +test('get_deployment plain-text log summary respects the requested line limit', function () { + $deployment = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'dep-log-'.fake()->uuid(), + 'status' => 'failed', + 'server_id' => $this->server->id, + 'application_name' => $this->application->name, + 'server_name' => $this->server->name, + 'commit' => 'deadbeef', + 'logs' => "first line\nsecond line token=redactme01\nlast line", + ]); + + $response = mcpSensitiveReadCall('get_deployment', [ + 'uuid' => $deployment->deployment_uuid, + 'include_log_summary' => true, + 'log_lines' => 1, + ]); + $response->assertOk(); + $summary = mcpReadJson($response)['data']['log_summary']; + + expect($summary['lines'])->toBe(1) + ->and($summary['truncated'])->toBeTrue() + ->and($summary['text'])->toBe('last line') + ->and($summary['text'])->not->toContain('redactme01'); +}); + +test('list_servers reachable filter works', function () { + $this->server->settings->forceFill(['is_reachable' => true])->saveQuietly(); + + $response = mcpReadCall('list_servers', ['reachable' => true]); + $response->assertOk(); + $uuids = collect(mcpReadJson($response)['data'])->pluck('uuid'); + expect($uuids)->toContain($this->server->uuid); + + $none = mcpReadCall('list_servers', ['reachable' => false]); + $none->assertOk(); + expect(collect(mcpReadJson($none)['data'])->pluck('uuid'))->not->toContain($this->server->uuid); +}); + +test('list_applications status and server_uuid filters work', function () { + $this->application->update(['status' => 'running:healthy']); + + $byStatus = mcpReadCall('list_applications', ['status' => 'running']); + $byStatus->assertOk(); + expect(collect(mcpReadJson($byStatus)['data'])->pluck('uuid'))->toContain($this->application->uuid); + + $byServer = mcpReadCall('list_applications', ['server_uuid' => $this->server->uuid]); + $byServer->assertOk(); + expect(collect(mcpReadJson($byServer)['data'])->pluck('uuid'))->toContain($this->application->uuid); + + $missingServer = mcpReadCall('list_applications', ['server_uuid' => 'no-such-server']); + $missingServer->assertOk(); + expect(mcpReadJson($missingServer)['data'])->toBe([]); +}); + +test('list_applications paginates with stable name order and disjoint pages', function () { + $this->application->update(['name' => 'app-z-original']); + + foreach (['app-a', 'app-b', 'app-c'] as $name) { + Application::factory()->create([ + 'name' => $name, + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + } + + $page1 = mcpReadCall('list_applications', ['page' => 1, 'per_page' => 2]); + $page1->assertOk(); + $page1Body = mcpReadJson($page1); + $page1Names = collect($page1Body['data'])->pluck('name')->all(); + $page1Uuids = collect($page1Body['data'])->pluck('uuid')->all(); + + expect($page1Names)->toBe(collect($page1Names)->sort()->values()->all()) + ->and($page1Body['_pagination']['total'])->toBeGreaterThanOrEqual(4) + ->and($page1Body['_pagination']['next']['args']['page'] ?? null)->toBe(2); + + $page2 = mcpReadCall('list_applications', ['page' => 2, 'per_page' => 2]); + $page2->assertOk(); + $page2Uuids = collect(mcpReadJson($page2)['data'])->pluck('uuid')->all(); + + expect(array_intersect($page1Uuids, $page2Uuids))->toBe([]); +}); + +test('list_databases filters by project, name, status, and server and is team scoped', function () { + $matching = StandalonePostgresql::create([ + 'name' => 'prod-postgres', + 'status' => 'running:healthy', + 'postgres_password' => 'password', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $otherName = StandalonePostgresql::create([ + 'name' => 'dev-redis-like', + 'status' => 'exited:unhealthy', + 'postgres_password' => 'password', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $otherProject = Project::factory()->create(['team_id' => $this->team->id]); + $otherEnv = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + StandalonePostgresql::create([ + 'name' => 'other-project-db', + 'status' => 'running:healthy', + 'postgres_password' => 'password', + 'environment_id' => $otherEnv->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $otherTeam = Team::factory()->create(); + $otherTeamProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherTeamEnv = $otherTeamProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherTeamProject->id]); + $otherTeamServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherTeamDest = StandaloneDocker::query()->where('server_id', $otherTeamServer->id)->firstOrFail(); + StandalonePostgresql::create([ + 'name' => 'foreign-db', + 'status' => 'running:healthy', + 'postgres_password' => 'password', + 'environment_id' => $otherTeamEnv->id, + 'destination_id' => $otherTeamDest->id, + 'destination_type' => $otherTeamDest->getMorphClass(), + ]); + + $all = mcpReadCall('list_databases'); + $all->assertOk(); + $allUuids = collect(mcpReadJson($all)['data'])->pluck('uuid'); + expect($allUuids) + ->toContain($matching->uuid, $otherName->uuid) + ->not->toContain(StandalonePostgresql::where('name', 'foreign-db')->value('uuid')); + + $byProject = mcpReadCall('list_databases', ['project_uuid' => $this->project->uuid]); + $byProject->assertOk(); + $projectUuids = collect(mcpReadJson($byProject)['data'])->pluck('uuid'); + expect($projectUuids) + ->toContain($matching->uuid) + ->not->toContain(StandalonePostgresql::where('name', 'other-project-db')->value('uuid')); + + $byName = mcpReadCall('list_databases', ['name' => 'prod-']); + $byName->assertOk(); + expect(collect(mcpReadJson($byName)['data'])->pluck('uuid')) + ->toContain($matching->uuid) + ->not->toContain($otherName->uuid); + + $byStatus = mcpReadCall('list_databases', ['status' => 'exited']); + $byStatus->assertOk(); + expect(collect(mcpReadJson($byStatus)['data'])->pluck('uuid')) + ->toContain($otherName->uuid) + ->not->toContain($matching->uuid); + + $byServer = mcpReadCall('list_databases', ['server_uuid' => $this->server->uuid]); + $byServer->assertOk(); + expect(collect(mcpReadJson($byServer)['data'])->pluck('uuid'))->toContain($matching->uuid); + + $missingServer = mcpReadCall('list_databases', ['server_uuid' => 'no-such-server']); + $missingServer->assertOk(); + expect(mcpReadJson($missingServer)['data'])->toBe([]); + + $row = collect(mcpReadJson($byProject)['data'])->firstWhere('uuid', $matching->uuid); + expect($row) + ->toHaveKeys(['uuid', 'name', 'status', 'type', 'project_uuid', 'project_name']) + ->and($row['project_uuid'])->toBe($this->project->uuid) + ->and($row['type'])->toBe('standalone-postgresql'); +}); + +test('list_services filters by its computed status before pagination', function () { + Service::factory()->create([ + 'name' => 'Matching service', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + Service::factory()->create([ + 'name' => 'Another service', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = mcpReadCall('list_services', [ + 'status' => 'unknown', + 'per_page' => 1, + ]); + + $response->assertOk(); + $body = mcpReadJson($response); + + expect($body['_pagination']['total'])->toBe(2) + ->and($body['data'])->toHaveCount(1) + ->and($body['data'][0]['status'])->toContain('unknown'); +}); + +test('get_service_application returns a field whitelist and is team scoped', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + $app = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'web', + 'human_name' => 'Web', + 'description' => 'Frontend container', + 'service_id' => $service->id, + 'image' => 'nginx:alpine', + 'fqdn' => 'https://web.example.com', + 'status' => 'running:healthy', + ]); + + $response = mcpReadCall('get_service_application', [ + 'service_uuid' => $service->uuid, + 'uuid' => $app->uuid, + ]); + $response->assertOk(); + $body = mcpReadJson($response); + $data = $body['data']; + + expect($data['uuid'])->toBe($app->uuid) + ->and($data['service_uuid'])->toBe($service->uuid) + ->and($data['name'])->toBe('web') + ->and($data['human_name'])->toBe('Web') + ->and($data['status'])->toBe('running:healthy') + ->and($data['fqdn'])->toBe('https://web.example.com') + ->and($data['image'])->toBe('nginx:alpine') + ->and($data)->toHaveKeys([ + 'uuid', + 'service_uuid', + 'name', + 'human_name', + 'description', + 'status', + 'fqdn', + 'ports', + 'exposes', + 'image', + 'exclude_from_status', + 'required_fqdn', + 'is_log_drain_enabled', + 'is_include_timestamps', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + 'last_online_at', + 'created_at', + 'updated_at', + ]) + ->and($data)->not->toHaveKey('id') + ->and($data)->not->toHaveKey('service_id') + ->and($data)->not->toHaveKey('is_migrated'); + + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnv = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); + $otherService = Service::factory()->create([ + 'environment_id' => $otherEnv->id, + 'server_id' => $otherServer->id, + 'destination_id' => $otherDest->id, + 'destination_type' => $otherDest->getMorphClass(), + ]); + $otherApp = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'theirs', + 'service_id' => $otherService->id, + 'image' => 'nginx:alpine', + ]); + + $denied = mcpReadCall('get_service_application', [ + 'service_uuid' => $otherService->uuid, + 'uuid' => $otherApp->uuid, + ]); + expect($denied->json('result.isError'))->toBeTrue(); +}); + +test('MCP lists prompts for troubleshooting workflows', function () { + $token = mcpReadToken(); + $response = test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'prompts/list', + 'params' => (object) [], + ]); + + $response->assertOk(); + $names = collect($response->json('result.prompts'))->pluck('name')->all(); + expect($names)->toContain('troubleshoot_application', 'explain_failed_deploy'); +}); + +test('get_logs requires sensitive read ability', function () { + $this->application->update(['status' => 'running:healthy']); + + $response = mcpReadCall('get_logs', [ + 'resource' => 'application', + 'uuid' => $this->application->uuid, + ]); + + $response->assertOk(); + expect($response->json('result.isError'))->toBeTrue() + ->and($response->json('result.content.0.text'))->toContain('read:sensitive'); +}); + +test('team members cannot retrieve logs with sensitive read ability', function () { + $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); + + $response = mcpSensitiveReadCall('get_logs', [ + 'resource' => 'application', + 'uuid' => $this->application->uuid, + ]); + + // Elevated member tokens are rejected as JSON-RPC errors (HTTP 200) so MCP clients can parse them. + $response->assertOk(); + expect($response->json('error.message') ?? $response->json('result.content.0.text') ?? '') + ->toMatch('/team role|Missing required/i'); +}); + +test('get_logs returns structured next_tools when application is not running', function () { + $this->application->update(['status' => 'exited:unhealthy']); + + $response = mcpSensitiveReadCall('get_logs', [ + 'resource' => 'application', + 'uuid' => $this->application->uuid, + ]); + $response->assertOk(); + $body = mcpReadJson($response); + + 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'); +}); + +test('get_logs returns structured choices when service has multiple containers', function () { + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + + $service = Service::factory()->create([ + 'name' => 'multi-container-svc', + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $childApp = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'web', + 'service_id' => $service->id, + 'status' => 'running:healthy', + 'image' => 'nginx:latest', + ]); + $childDb = ServiceDatabase::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'db', + 'service_id' => $service->id, + 'status' => 'running:healthy', + 'image' => 'postgres:16', + ]); + + $response = mcpSensitiveReadCall('get_logs', [ + 'resource' => 'service', + 'uuid' => $service->uuid, + ]); + $response->assertOk(); + $body = mcpReadJson($response); + + expect($body['data']['ok'])->toBeFalse() + ->and($body['data']['reason'])->toBe('multiple_containers') + ->and($body['data']['choices'])->toBeArray() + ->and(collect($body['data']['choices'])->pluck('uuid')->all()) + ->toContain($childApp->uuid, $childDb->uuid) + ->and(json_encode($body['data']['message'] ?? ''))->not->toContain('"uuid"'); +}); + +test('coolify_help returns catalog intents', function () { + $response = mcpReadCall('coolify_help', ['intent' => 'essentials']); + $response->assertOk(); + $body = mcpReadJson($response); + expect($body['data']['catalog']['essentials']['tools'])->toContain('search_resources', 'control'); +}); + +test('list_unhealthy_resources sample_only returns summary and samples', function () { + $this->application->update(['status' => 'exited:unhealthy']); + + $response = mcpReadCall('list_unhealthy_resources', ['sample_only' => true, 'sample_per_type' => 3]); + $response->assertOk(); + $body = mcpReadJson($response); + expect($body['data']['sample_only'])->toBeTrue(); + expect($body['data']['summary'])->toHaveKeys(['total', 'applications', 'servers']); + expect($body['data']['samples'])->toHaveKeys(['applications', 'servers', 'services', 'databases']); +}); + +test('control and deploy require deploy ability', function () { + $denied = mcpReadCall('control', [ + 'resource' => 'application', + 'action' => 'start', + 'uuid' => $this->application->uuid, + ]); + $denied->assertOk(); + expect($denied->json('result.isError'))->toBeTrue(); + expect($denied->json('result.content.0.text'))->toContain('Missing required permissions'); + + $deployDenied = mcpReadCall('deploy', ['uuid' => $this->application->uuid]); + expect($deployDenied->json('result.isError'))->toBeTrue(); +}); + +test('control stop requires confirm', function () { + $token = test()->user->createToken('mcp-deploy', ['read', 'deploy'])->plainTextToken; + $response = test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'control', + 'arguments' => (object) [ + 'resource' => 'application', + 'action' => 'stop', + 'uuid' => $this->application->uuid, + ], + ], + ]); + expect($response->json('result.isError'))->toBeTrue(); + expect($response->json('result.content.0.text'))->toContain('confirm=true'); +}); + +test('team member with deploy ability cannot call lifecycle tools', function () { + $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); + + $token = $this->user->createToken('mcp-member-deploy', ['read', 'deploy'])->plainTextToken; + + $response = test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'control', + 'arguments' => (object) [ + 'resource' => 'application', + 'action' => 'start', + 'uuid' => $this->application->uuid, + ], + ], + ]); + + // Middleware returns a JSON-RPC error envelope (HTTP 200) for MCP clients. + $response->assertOk(); + expect($response->json('jsonrpc'))->toBe('2.0'); + expect($response->json('error.message') ?? $response->json('result.content.0.text') ?? '') + ->toMatch('/team role|Missing required/i'); +}); + +test('control start with deploy ability queues application deployment', function () { + Bus::fake(); + + $token = $this->user->createToken('mcp-deploy-start', ['read', 'deploy'])->plainTextToken; + $response = test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'control', + 'arguments' => (object) [ + 'resource' => 'application', + 'action' => 'start', + 'uuid' => $this->application->uuid, + ], + ], + ]); + + $response->assertOk(); + expect($response->json('result.isError'))->toBeFalse(); + $body = mcpReadJson($response); + expect($body['data']['ok'])->toBeTrue() + ->and($body['data']['action'])->toBe('start') + ->and($body['data']['deployment_uuid'])->not->toBeEmpty(); +}); + +test('deploy tool queues application deployment', function () { + Bus::fake(); + + $token = $this->user->createToken('mcp-deploy-tool', ['read', 'deploy'])->plainTextToken; + $response = test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'deploy', + 'arguments' => (object) [ + 'uuid' => $this->application->uuid, + 'force' => false, + ], + ], + ]); + + $response->assertOk(); + expect($response->json('result.isError'))->toBeFalse(); + $body = mcpReadJson($response); + expect($body['data']['ok'])->toBeTrue() + ->and($body['data']['deployment_uuid'])->not->toBeEmpty(); + expect(ApplicationDeploymentQueue::where('deployment_uuid', $body['data']['deployment_uuid'])->exists())->toBeTrue(); +}); + +test('cancel_deployment cancels team deployment and rejects other team', function () { + // Avoid real SSH via instant_remote_process during cancellation cleanup. + Process::fake([ + '*' => Process::result(output: ''), + ]); + + $deployment = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'dep-cancel-'.fake()->uuid(), + 'status' => 'in_progress', + 'server_id' => $this->server->id, + 'application_name' => $this->application->name, + 'server_name' => $this->server->name, + 'commit' => 'abc', + 'current_process_id' => '12345', + ]); + + $token = $this->user->createToken('mcp-cancel', ['read', 'deploy'])->plainTextToken; + $ok = test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'cancel_deployment', + 'arguments' => (object) ['uuid' => $deployment->deployment_uuid], + ], + ]); + + $ok->assertOk(); + expect($ok->json('result.isError'))->toBeFalse(); + $body = mcpReadJson($ok); + expect($body['data']['ok'])->toBeTrue() + ->and($body['data']['status'])->toBe('cancelled-by-user'); + expect($deployment->fresh()->status)->toBe('cancelled-by-user'); + + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnv = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); + $otherApp = Application::factory()->create([ + 'environment_id' => $otherEnv->id, + 'destination_id' => $otherDest->id, + 'destination_type' => $otherDest->getMorphClass(), + ]); + $otherDep = ApplicationDeploymentQueue::create([ + 'application_id' => $otherApp->id, + 'deployment_uuid' => 'dep-other-cancel-'.fake()->uuid(), + 'status' => 'in_progress', + 'server_id' => $otherServer->id, + 'application_name' => $otherApp->name, + 'server_name' => $otherServer->name, + ]); + + $denied = test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'cancel_deployment', + 'arguments' => (object) ['uuid' => $otherDep->deployment_uuid], + ], + ]); + expect($denied->json('result.isError'))->toBeTrue(); + expect($otherDep->fresh()->status)->toBe('in_progress'); +}); + +test('cancel_deployment rejects other team deployment even on owned server', function () { + // Shared-server case: caller's team owns the host server, but the application belongs to another team. + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnv = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + $otherApp = Application::factory()->create([ + 'environment_id' => $otherEnv->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $sharedServerDep = ApplicationDeploymentQueue::create([ + 'application_id' => $otherApp->id, + 'deployment_uuid' => 'dep-shared-server-cancel-'.fake()->uuid(), + 'status' => 'in_progress', + 'server_id' => $this->server->id, + 'application_name' => $otherApp->name, + 'server_name' => $this->server->name, + ]); + + $token = $this->user->createToken('mcp-shared-server-cancel', ['read', 'deploy'])->plainTextToken; + $denied = test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'cancel_deployment', + 'arguments' => (object) ['uuid' => $sharedServerDep->deployment_uuid], + ], + ]); + + expect($denied->json('result.isError'))->toBeTrue(); + expect($sharedServerDep->fresh()->status)->toBe('in_progress'); +}); + +test('cancel_deployment updates only a still cancellable deployment', function () { + // Avoid real SSH via instant_remote_process during cancellation cleanup. + Process::fake([ + '*' => Process::result(output: ''), + ]); + + $deployment = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'dep-atomic-cancel-'.fake()->uuid(), + 'status' => 'in_progress', + 'server_id' => $this->server->id, + 'application_name' => $this->application->name, + 'server_name' => $this->server->name, + ]); + + $updates = []; + DB::listen(function ($query) use (&$updates) { + if (str_starts_with(strtolower(ltrim($query->sql)), 'update')) { + $updates[] = strtolower($query->sql); + } + }); + + $token = $this->user->createToken('mcp-atomic-cancel', ['read', 'deploy'])->plainTextToken; + $response = test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'cancel_deployment', + 'arguments' => (object) ['uuid' => $deployment->deployment_uuid], + ], + ]); + + $response->assertOk(); + expect(collect($updates)->contains( + fn (string $sql) => str_contains($sql, 'application_deployment_queues') + && str_contains($sql, 'status') + && str_contains($sql, ' in '), + ))->toBeTrue(); +}); + +test('MCP resources list includes overview and application template', function () { + $token = mcpReadToken(); + $response = test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/list', + 'params' => (object) [], + ]); + + $response->assertOk(); + $uris = collect($response->json('result.resources'))->pluck('uri')->filter()->all(); + $templates = collect($response->json('result.resources'))->pluck('uriTemplate')->filter()->all(); + + // Static resource may appear under resources; templates under list or templates/list depending on server. + $all = collect($uris)->merge($templates)->implode(' '); + expect($all)->toContain('coolify://'); +}); + +test('MCP overview resource returns batched project resource counts', function () { + StandalonePostgresql::create([ + 'name' => 'overview-postgres', + 'postgres_password' => 'password', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + Service::create([ + 'name' => 'overview-service', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'docker_compose_raw' => 'services: {}', + ]); + + $token = mcpReadToken(); + $response = test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$token, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'resources/read', + 'params' => [ + 'uri' => 'coolify://overview', + ], + ]); + + $response->assertOk(); + + $text = collect($response->json('result.contents'))->pluck('text')->first(); + expect($text)->not->toBeNull(); + + $body = json_decode($text, true); + expect($body)->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts']); + expect($body['counts']['projects'])->toBe(1); + + $project = collect($body['projects'])->firstWhere('uuid', $this->project->uuid); + expect($project)->not->toBeNull(); + expect($project['counts'])->toMatchArray([ + 'applications' => 1, + 'services' => 1, + 'databases' => 1, + ]); +}); + +test('get_deployment and cancel_deployment work for soft-deleted applications', function () { + Process::fake([ + '*' => Process::result(output: ''), + ]); + + $deployment = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'dep-soft-delete-'.fake()->uuid(), + 'status' => 'in_progress', + 'server_id' => $this->server->id, + 'application_name' => $this->application->name, + 'server_name' => $this->server->name, + 'commit' => 'abc123', + ]); + + $deployToken = $this->user->createToken('mcp-soft-cancel', ['read', 'deploy'])->plainTextToken; + + $this->application->delete(); + expect(Application::withTrashed()->find($this->application->id))->not->toBeNull(); + expect(Application::find($this->application->id))->toBeNull(); + + // get_deployment still resolves soft-deleted applications for the team. + $get = test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$deployToken, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get_deployment', + 'arguments' => (object) ['uuid' => $deployment->deployment_uuid], + ], + ]); + $get->assertOk(); + expect($get->json('result.isError'))->toBeFalse(); + $getBody = mcpReadJson($get); + expect($getBody['data']['deployment_uuid'])->toBe($deployment->deployment_uuid) + ->and($getBody['data']['application_uuid'])->toBe($this->application->uuid); + + $cancel = test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.$deployToken, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'cancel_deployment', + 'arguments' => (object) ['uuid' => $deployment->deployment_uuid], + ], + ]); + $cancel->assertOk(); + expect($cancel->json('result.isError'))->toBeFalse(); + expect($deployment->fresh()->status)->toBe('cancelled-by-user'); +}); + +test('list_databases paginates at the query layer', function () { + foreach (['alpha-db', 'beta-db', 'gamma-db'] as $name) { + StandalonePostgresql::create([ + 'name' => $name, + 'status' => 'running:healthy', + 'postgres_password' => 'password', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + } + + $page1 = mcpReadCall('list_databases', ['per_page' => 2, 'page' => 1]); + $page1->assertOk(); + $body1 = mcpReadJson($page1); + expect($body1['_pagination']['total'])->toBe(3) + ->and($body1['data'])->toHaveCount(2) + ->and($body1['data'][0]['name'])->toBe('alpha-db') + ->and($body1['data'][1]['name'])->toBe('beta-db'); + + $page2 = mcpReadCall('list_databases', ['per_page' => 2, 'page' => 2]); + $page2->assertOk(); + $body2 = mcpReadJson($page2); + expect($body2['data'])->toHaveCount(1) + ->and($body2['data'][0]['name'])->toBe('gamma-db'); + + $page1Uuids = collect($body1['data'])->pluck('uuid')->all(); + $page2Uuids = collect($body2['data'])->pluck('uuid')->all(); + expect(array_intersect($page1Uuids, $page2Uuids))->toBe([]); +}); + +test('list_unhealthy_resources full mode paginates without dropping summary totals', function () { + $this->application->update(['name' => 'AppA', 'status' => 'exited:unhealthy']); + Application::factory()->create([ + 'name' => 'AppB', + 'status' => 'exited:unhealthy', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + Application::factory()->create([ + 'name' => 'AppC', + 'status' => 'exited:unhealthy', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + // Ensure the default server is not counted as unhealthy (factory settings vary). + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + + $page1 = mcpReadCall('list_unhealthy_resources', [ + 'sample_only' => false, + 'per_page' => 2, + 'page' => 1, + ]); + $page1->assertOk(); + $body1 = mcpReadJson($page1); + expect($body1['data']['summary']['applications'])->toBe(3) + ->and($body1['data']['summary']['servers'])->toBe(0) + ->and($body1['_pagination']['total'])->toBe(3) + ->and($body1['data']['unhealthy'])->toHaveCount(2); + + $page1Names = collect($body1['data']['unhealthy'])->pluck('name')->all(); + expect($page1Names)->toBe(['AppA', 'AppB']); + + $page2 = mcpReadCall('list_unhealthy_resources', [ + 'sample_only' => false, + 'per_page' => 2, + 'page' => 2, + ]); + $page2->assertOk(); + $body2 = mcpReadJson($page2); + expect($body2['data']['unhealthy'])->toHaveCount(1) + ->and($body2['data']['unhealthy'][0]['name'])->toBe('AppC'); +}); + +test('list_unhealthy_resources full mode paginates services without dropping summary totals', function () { + // Keep apps/servers healthy so the page window is pure services. + $this->application->update(['status' => 'running:healthy']); + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + + // Empty services have no running status → treated as unhealthy by the status scan. + foreach (['SvcA', 'SvcB', 'SvcC', 'SvcD', 'SvcE'] as $name) { + Service::factory()->create([ + 'name' => $name, + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + } + + $page1 = mcpReadCall('list_unhealthy_resources', [ + 'sample_only' => false, + 'per_page' => 2, + 'page' => 1, + ]); + $page1->assertOk(); + $body1 = mcpReadJson($page1); + expect($body1['data']['summary']['services'])->toBe(5) + ->and($body1['data']['summary']['total'])->toBe(5) + ->and($body1['_pagination']['total'])->toBe(5) + ->and($body1['data']['unhealthy'])->toHaveCount(2) + ->and(collect($body1['data']['unhealthy'])->pluck('name')->all())->toBe(['SvcA', 'SvcB']) + ->and(collect($body1['data']['unhealthy'])->pluck('type')->unique()->all())->toBe(['service']); + + $page3 = mcpReadCall('list_unhealthy_resources', [ + 'sample_only' => false, + 'per_page' => 2, + 'page' => 3, + ]); + $page3->assertOk(); + $body3 = mcpReadJson($page3); + expect($body3['data']['summary']['services'])->toBe(5) + ->and($body3['data']['unhealthy'])->toHaveCount(1) + ->and($body3['data']['unhealthy'][0]['name'])->toBe('SvcE'); +}); + +test('get_service_database returns a field whitelist and is team scoped', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n db:\n image: postgres:16\n", + ]); + $db = ServiceDatabase::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'db', + 'human_name' => 'Database', + 'description' => 'Primary DB', + 'service_id' => $service->id, + 'image' => 'postgres:16', + 'status' => 'running:healthy', + ]); + + $response = mcpReadCall('get_service_database', [ + 'service_uuid' => $service->uuid, + 'uuid' => $db->uuid, + ]); + $response->assertOk(); + $data = mcpReadJson($response)['data']; + + expect($data['uuid'])->toBe($db->uuid) + ->and($data['service_uuid'])->toBe($service->uuid) + ->and($data['name'])->toBe('db') + ->and($data)->toHaveKeys([ + 'uuid', + 'service_uuid', + 'name', + 'human_name', + 'description', + 'status', + 'image', + 'created_at', + 'updated_at', + ]) + ->and($data)->not->toHaveKey('id') + ->and($data)->not->toHaveKey('service_id') + ->and($data)->not->toHaveKey('is_migrated'); + + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnv = $otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $otherProject->id]); + $otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail(); + $otherService = Service::factory()->create([ + 'environment_id' => $otherEnv->id, + 'server_id' => $otherServer->id, + 'destination_id' => $otherDest->id, + 'destination_type' => $otherDest->getMorphClass(), + ]); + $otherDb = ServiceDatabase::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'theirs', + 'service_id' => $otherService->id, + 'image' => 'postgres:16', + ]); + + $denied = mcpReadCall('get_service_database', [ + 'service_uuid' => $otherService->uuid, + 'uuid' => $otherDb->uuid, + ]); + expect($denied->json('result.isError'))->toBeTrue(); +}); + +test('list_scheduled_tasks omits command without sensitive read ability', function () { + ScheduledTask::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'nightly-backup', + 'command' => 'pg_dump --flag=redactme01', + 'frequency' => '0 2 * * *', + 'enabled' => true, + 'timeout' => 3600, + 'team_id' => $this->team->id, + 'application_id' => $this->application->id, + ]); + + $readOnly = mcpReadCall('list_scheduled_tasks', [ + 'resource' => 'application', + 'uuid' => $this->application->uuid, + ]); + $readOnly->assertOk(); + $tasks = mcpReadJson($readOnly)['data']['tasks']; + expect($tasks)->toHaveCount(1) + ->and($tasks[0]['name'])->toBe('nightly-backup') + ->and($tasks[0]['command_included'])->toBeFalse() + ->and($tasks[0])->not->toHaveKey('command'); + expect(json_encode($tasks))->not->toContain('redactme01'); + + $sensitive = mcpSensitiveReadCall('list_scheduled_tasks', [ + 'resource' => 'application', + 'uuid' => $this->application->uuid, + ]); + $sensitive->assertOk(); + expect($sensitive->json('result.isError'))->toBeFalse(); + $sensitiveBody = mcpReadJson($sensitive); + expect($sensitiveBody['data']['command_included'])->toBeTrue(); + $sensitiveTasks = $sensitiveBody['data']['tasks']; + expect($sensitiveTasks[0]['command_included'])->toBeTrue() + ->and($sensitiveTasks[0]['command'])->toContain('pg_dump'); +}); + +test('list_scheduled_task_executions returns newest first across pages', function () { + $task = ScheduledTask::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'exec-history', + 'command' => 'echo ok', + 'frequency' => '0 1 * * *', + 'enabled' => true, + 'timeout' => 60, + 'team_id' => $this->team->id, + 'application_id' => $this->application->id, + ]); + + $older = ScheduledTaskExecution::create([ + 'scheduled_task_id' => $task->id, + 'status' => 'success', + 'message' => 'older-run', + 'started_at' => now()->subHours(2), + 'finished_at' => now()->subHours(2)->addMinute(), + 'created_at' => now()->subHours(2), + 'updated_at' => now()->subHours(2), + ]); + $newer = ScheduledTaskExecution::create([ + 'scheduled_task_id' => $task->id, + 'status' => 'failed', + 'message' => 'newer-run', + 'started_at' => now()->subHour(), + 'finished_at' => now()->subHour()->addMinute(), + 'created_at' => now()->subHour(), + 'updated_at' => now()->subHour(), + ]); + + $page1 = mcpReadCall('list_scheduled_task_executions', [ + 'resource' => 'application', + 'uuid' => $this->application->uuid, + 'task_uuid' => $task->uuid, + 'page' => 1, + 'per_page' => 1, + ]); + $page1->assertOk(); + $page1Body = mcpReadJson($page1); + expect($page1Body['data']['executions'])->toHaveCount(1) + ->and($page1Body['data']['message_included'])->toBeFalse() + ->and($page1Body['data']['executions'][0])->not->toHaveKey('message') + ->and($page1Body['data']['executions'][0]['status'])->toBe('failed') + ->and($page1Body['_pagination']['total'])->toBe(2); + + $page2 = mcpReadCall('list_scheduled_task_executions', [ + 'resource' => 'application', + 'uuid' => $this->application->uuid, + 'task_uuid' => $task->uuid, + 'page' => 2, + 'per_page' => 1, + ]); + $page2->assertOk(); + $page2Body = mcpReadJson($page2); + expect($page2Body['data']['executions'][0]['status'])->toBe('success') + ->and($page2Body['data']['executions'][0])->not->toHaveKey('message'); + + $sensitive = mcpSensitiveReadCall('list_scheduled_task_executions', [ + 'resource' => 'application', + 'uuid' => $this->application->uuid, + 'task_uuid' => $task->uuid, + 'page' => 1, + 'per_page' => 1, + ]); + $sensitive->assertOk(); + $sensitiveBody = mcpReadJson($sensitive); + expect($sensitiveBody['data']['message_included'])->toBeTrue() + ->and($sensitiveBody['data']['executions'][0]['message'])->toBe('newer-run'); + + // Silence unused variable analysis when timestamps are forced via create attributes. + expect($older->id)->not->toBe($newer->id); +}); + +test('list_scheduled_task_executions redacts secret-like values in messages with sensitive read', function () { + $task = ScheduledTask::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'exec-redact', + 'command' => 'echo ok', + 'frequency' => '0 1 * * *', + 'enabled' => true, + 'timeout' => 60, + 'team_id' => $this->team->id, + 'application_id' => $this->application->id, + ]); + ScheduledTaskExecution::create([ + 'scheduled_task_id' => $task->id, + 'status' => 'failed', + 'message' => "backup failed password=redactme01\n", + 'started_at' => now()->subMinute(), + 'finished_at' => now(), + ]); + + $response = mcpSensitiveReadCall('list_scheduled_task_executions', [ + 'resource' => 'application', + 'uuid' => $this->application->uuid, + 'task_uuid' => $task->uuid, + ]); + $response->assertOk(); + $body = mcpReadJson($response); + $message = $body['data']['executions'][0]['message'] ?? ''; + + expect($body['data']['message_included'])->toBeTrue() + ->and($message)->not->toContain('redactme01') + ->and($message)->toContain('password=') + ->and($message)->toContain(REDACTED); +}); + +test('get_logs redacts secret-like values in container output', function () { + // Preflight fails for non-running apps, so exercise redaction via the shared helper path + // through get_deployment (already covered) and unit-level BuildsResponse redaction. + // Use low-entropy test markers so secret scanners do not flag fixtures. + $trait = new class + { + use BuildsResponse; + + public function redact(string $text): string + { + return $this->redactLogText($text); + } + }; + + $redacted = $trait->redact("boot ok\npassword=redactme01\nAPI_TOKEN=redactme02\n"); + expect($redacted)->toContain('boot ok') + ->and($redacted)->not->toContain('redactme01') + ->and($redacted)->not->toContain('redactme02') + ->and($redacted)->toContain('password=') + ->and($redacted)->toContain(REDACTED); +}); + +test('redactLogText redacts JSON secret fields in log lines', function () { + $trait = new class + { + use BuildsResponse; + + public function redact(string $text): string + { + return $this->redactLogText($text); + } + }; + + $jsonLine = '{"token":"redactme01","API_KEY":"redactme02","status":"ok"}'; + $redacted = $trait->redact("request failed: {$jsonLine}"); + + expect($redacted)->toContain('status') + ->and($redacted)->toContain('ok') + ->and($redacted)->not->toContain('redactme01') + ->and($redacted)->not->toContain('redactme02') + ->and($redacted)->toContain('token=') + ->and($redacted)->toContain('API_KEY=') + ->and($redacted)->toContain(REDACTED); + + // Shell-style still works alongside JSON + $mixed = $trait->redact('password=redactme01 {"client_secret":"redactme02"} export DB_PASSWORD=redactme03'); + expect($mixed)->not->toContain('redactme01') + ->and($mixed)->not->toContain('redactme02') + ->and($mixed)->not->toContain('redactme03') + ->and($mixed)->toContain(REDACTED); +}); diff --git a/tests/Unit/Mcp/BuildsResponseActionsTest.php b/tests/Unit/Mcp/BuildsResponseActionsTest.php new file mode 100644 index 000000000..5c15e6e74 --- /dev/null +++ b/tests/Unit/Mcp/BuildsResponseActionsTest.php @@ -0,0 +1,116 @@ +> + */ + public function applicationActions(string $uuid, ?string $status = null): array + { + return $this->actionsForApplication($uuid, $status); + } + + /** + * @return array> + */ + public function databaseActions(string $uuid, ?string $status = null): array + { + return $this->actionsForDatabase($uuid, $status); + } + + /** + * @return array> + */ + public function serviceActions(string $uuid, ?string $status = null): array + { + return $this->actionsForService($uuid, $status); + } +} + +function controlActionFrom(array $actions): ?string +{ + $control = collect($actions)->firstWhere('tool', 'control'); + + return is_array($control) ? ($control['args']['action'] ?? null) : null; +} + +function toolsFrom(array $actions): array +{ + return collect($actions)->pluck('tool')->all(); +} + +test('healthy running application suggests logs restart and stop', function () { + $actions = (new BuildsResponseActionsHarness)->applicationActions('app-uuid', 'running:healthy'); + + expect(toolsFrom($actions))->toContain('get_logs') + ->and(collect($actions)->pluck('args.action')->filter()->all())->toContain('restart', 'stop') + ->and(toolsFrom($actions))->not->toContain('deploy'); +}); + +test('unhealthy running application suggests logs and restart not start', function () { + $actions = (new BuildsResponseActionsHarness)->applicationActions('app-uuid', 'running:unhealthy'); + + expect(toolsFrom($actions))->toContain('get_logs') + ->and(controlActionFrom($actions))->toBe('restart') + ->and(collect($actions)->pluck('args.action')->filter()->all())->not->toContain('start') + ->and(toolsFrom($actions))->not->toContain('deploy'); +}); + +test('stopped application suggests deploy and start not restart', function () { + $actions = (new BuildsResponseActionsHarness)->applicationActions('app-uuid', 'exited'); + + expect(toolsFrom($actions))->toContain('deploy') + ->and(controlActionFrom($actions))->toBe('start') + ->and(toolsFrom($actions))->not->toContain('get_logs'); +}); + +test('healthy running database suggests logs and restart', function () { + $actions = (new BuildsResponseActionsHarness)->databaseActions('db-uuid', 'running:healthy'); + + expect(toolsFrom($actions))->toContain('get_logs') + ->and(controlActionFrom($actions))->toBe('restart'); +}); + +test('unhealthy running database suggests logs and restart not start', function () { + $actions = (new BuildsResponseActionsHarness)->databaseActions('db-uuid', 'running:unhealthy'); + + expect(toolsFrom($actions))->toContain('get_logs') + ->and(controlActionFrom($actions))->toBe('restart') + ->and(collect($actions)->pluck('args.action')->filter()->all())->not->toContain('start'); +}); + +test('stopped database suggests start not restart', function () { + $actions = (new BuildsResponseActionsHarness)->databaseActions('db-uuid', 'exited'); + + expect(controlActionFrom($actions))->toBe('start') + ->and(toolsFrom($actions))->not->toContain('get_logs'); +}); + +test('healthy running service suggests logs and restart', function () { + $actions = (new BuildsResponseActionsHarness)->serviceActions('svc-uuid', 'running:healthy'); + + expect(toolsFrom($actions))->toContain('get_logs') + ->and(controlActionFrom($actions))->toBe('restart'); +}); + +test('unhealthy running service suggests logs and restart not start', function () { + $actions = (new BuildsResponseActionsHarness)->serviceActions('svc-uuid', 'running:unhealthy'); + + expect(toolsFrom($actions))->toContain('get_logs') + ->and(controlActionFrom($actions))->toBe('restart') + ->and(collect($actions)->pluck('args.action')->filter()->all())->not->toContain('start'); +}); + +test('stopped service suggests start not restart', function () { + $actions = (new BuildsResponseActionsHarness)->serviceActions('svc-uuid', 'exited:unhealthy'); + + expect(controlActionFrom($actions))->toBe('start') + ->and(toolsFrom($actions))->not->toContain('get_logs'); +});