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