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).'), ]; } }