feat(mcp): add resource diagnostics and deployment controls (#11000)

This commit is contained in:
Andras Bacsai 2026-08-03 23:08:11 +02:00 committed by GitHub
parent c27a5ec41e
commit 7b18777f06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 8471 additions and 67 deletions

View file

@ -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);
}

View file

@ -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(
'/(?<![\w])["\']?(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token)["\']?\s*[=:]\s*["\']?[^\s"\']{3,}["\']?/i',
'$1='.REDACTED,
$text
) ?? $text;
// export FOO=bar / "API_KEY":"..." style for sensitive-looking names
$text = preg_replace(
'/(?<![\w])(export\s+)?["\']?([A-Z][A-Z0-9_]*(?:SECRET|PASSWORD|TOKEN|PASSWD|API_KEY|PRIVATE_KEY)[A-Z0-9_]*)["\']?\s*[=:]\s*["\']?[^\s"\']{3,}["\']?/i',
'$1$2='.REDACTED,
$text
) ?? $text;
return $text;
}
/**
* @param array<string, mixed>|array<int, mixed> $data
* @param array<int, array<string, mixed>> $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<int, array<string, mixed>>
*/
@ -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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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;
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Mcp\Concerns;
use Illuminate\Database\Eloquent\Builder;
trait McpStatusFilters
{
/**
* SQL predicate for resources whose stored status is not a healthy running state.
*
* Matches empty/null status, non-running prefixes, and running-but-unhealthy/degraded.
*/
protected function scopeNotHealthyRunning(Builder $query, string $column = 'status'): Builder
{
return $query->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');
}
}

View file

@ -0,0 +1,103 @@
<?php
namespace App\Mcp\Concerns;
use App\Models\Application;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
trait ResolvesResource
{
/**
* Resource types that own env vars, storages, tags, and container logs.
*
* @var array<int, string>
*/
protected array $primaryResourceTypes = [
'application',
'database',
'service',
];
/**
* @var array<int, string>
*/
protected array $logResourceTypes = [
'application',
'database',
'service',
'service_application',
'service_database',
];
/**
* @var array<int, string>
*/
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);
}
}

View file

@ -7,6 +7,19 @@
trait ResolvesTeam
{
/**
* Abilities that team members must not exercise (parity with ApiAbility).
*
* @var array<int, string>
*/
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;
}

View file

@ -0,0 +1,68 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Prompts\Argument;
class ExplainFailedDeploy extends Prompt
{
protected string $name = 'explain_failed_deploy';
protected string $description = 'Guided workflow to explain a failed Coolify deployment using list/get deployment tools and optional log summary.';
public function handle(Request $request): Response
{
$deploymentUuid = $request->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,
),
];
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Prompts\Argument;
class TroubleshootApplication extends Prompt
{
protected string $name = 'troubleshoot_application';
protected string $description = 'DB-first guided workflow to diagnose an application. Live logs are optional and only when the container is running.';
public function handle(Request $request): Response
{
$uuid = $request->get('uuid');
$uuid = is_string($uuid) && $uuid !== '' ? $uuid : '{application_uuid}';
$text = <<<MD
You are troubleshooting a Coolify application (UUID: `{$uuid}`) for the authenticated team.
Use Coolify MCP tools over HTTP only (no shell/DB). Do not invent UUIDs. Team scope is enforced by the API token.
## Phase A — always available (Coolify DB / API metadata)
1. `get_application` uuid=`{$uuid}` name, status, fqdn, git, build pack.
2. If status is not running (or unknown): `list_unhealthy_resources` with sample_only=true, then confirm this app is listed.
3. `list_deployments` application_uuid=`{$uuid}` recent deploy statuses.
4. For any failed/cancelled/in_progress deploy: `get_deployment` with that deployment_uuid and **include_log_summary=true** (build failure context without full logs).
5. `list_env_keys` resource=application uuid=`{$uuid}` key **names only** (never values).
6. `list_application_previews` uuid=`{$uuid}` if the issue may be PR-related.
7. `list_storages` / `list_scheduled_tasks` if storage or cron is relevant.
## Phase B — optional live host tools (only if status starts with running)
8. `get_logs` resource=application uuid=`{$uuid}` lines=100200.
- If response has `ok: false`, use `reason` and `next_tools` do **not** retry logs blindly.
- Common reasons: not_running, server_unreachable, no_server.
## Phase C — lifecycle (only if user asked to fix and token has deploy ability)
9. `control` or `deploy` as appropriate; then poll `get_deployment`.
## Summary format
- Current status and whether server is reachable
- Last successful vs last failed deploy (or none)
- Evidence from deploy log_summary if any
- Missing env key names if any
- Recommended next operator actions (or control/deploy if already authorized)
Never request secrets, private keys, or unbounded build logs.
MD;
return Response::text($text);
}
public function arguments(): array
{
return [
new Argument(
name: 'uuid',
description: 'Application UUID to troubleshoot.',
required: true,
),
];
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Mcp\Resources;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Application;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Contracts\HasUriTemplate;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Support\UriTemplate;
class ApplicationResource extends Resource implements HasUriTemplate
{
use BuildsResponse;
use ResolvesTeam;
protected string $name = 'coolify-application';
protected string $description = 'Application summary JSON for a team-owned application UUID (coolify://application/{uuid}).';
protected string $mimeType = 'application/json';
public function uriTemplate(): UriTemplate
{
return new UriTemplate('coolify://application/{uuid}');
}
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 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));
}
}

View file

@ -0,0 +1,91 @@
<?php
namespace App\Mcp\Resources;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Project;
use App\Models\Server;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Resource;
class InfrastructureOverviewResource extends Resource
{
use ResolvesTeam;
protected string $name = 'coolify-overview';
protected string $description = 'Team infrastructure overview JSON for the authenticated Coolify token.';
protected string $uri = 'coolify://overview';
protected string $mimeType = 'application/json';
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 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));
}
}

View file

@ -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,
];
}

View file

@ -0,0 +1,121 @@
<?php
namespace App\Mcp\Tools;
use App\Enums\ApplicationDeploymentStatus;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Server;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class CancelDeployment extends Tool
{
protected string $name = 'cancel_deployment';
protected string $description = 'Cancel a queued or in-progress deployment by deployment UUID for the authenticated team. Requires deploy ability.';
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->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(),
];
}
}

189
app/Mcp/Tools/Control.php Normal file
View file

@ -0,0 +1,189 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Application\StopApplication;
use App\Actions\Database\RestartDatabase;
use App\Actions\Database\StartDatabase;
use App\Actions\Database\StopDatabase;
use App\Actions\Service\RestartService;
use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesResource;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Application;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class Control extends Tool
{
protected string $name = 'control';
protected string $description = 'Start, stop, or restart an application, database, or service owned by the authenticated team. Requires deploy ability. Stop requires confirm=true.';
use BuildsResponse;
use ResolvesResource;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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.'),
];
}
}

View file

@ -0,0 +1,180 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class CoolifyHelp extends Tool
{
protected string $name = 'coolify_help';
protected string $description = 'Tool catalog by intent. Call first when unsure which Coolify MCP tool to use. Optional intent: overview | search | inventory | debug | deploy | github | team | essentials.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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<int, string>
*/
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.'),
];
}
}

93
app/Mcp/Tools/Deploy.php Normal file
View file

@ -0,0 +1,93 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Application;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class Deploy extends Tool
{
protected string $name = 'deploy';
protected string $description = 'Queue a deployment for a team-owned application by UUID. Requires deploy ability. Optional force rebuild and pull_request_id for previews.';
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->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.'),
];
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Team;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class GetCurrentTeam extends Tool
{
protected string $name = 'get_current_team';
protected string $description = 'Get the team associated with the authenticated API token.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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 [];
}
}

View file

@ -0,0 +1,183 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\ApplicationDeploymentQueue;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class GetDeployment extends Tool
{
protected string $name = 'get_deployment';
protected string $description = 'Get deployment details by deployment UUID for the authenticated team. Optional include_log_summary requires read:sensitive and returns a capped, best-effort-redacted tail of build output (default off). Full logs and configuration snapshots are never returned; residual secrets in free-form log text may remain.';
use BuildsResponse;
use ResolvesTeam;
private const DEFAULT_LOG_LINES = 40;
private const MAX_LOG_LINES = 100;
private const MAX_LOG_CHARS = 8000;
public function handle(Request $request): Response
{
if ($error = $this->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).'),
];
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class GetDestination extends Tool
{
protected string $name = 'get_destination';
protected string $description = 'Get a Docker destination by UUID for the authenticated team.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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(),
];
}
}

View file

@ -0,0 +1,167 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Environment;
use App\Models\Project;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class GetEnvironment extends Tool
{
protected string $name = 'get_environment';
protected string $description = 'Get an environment by name or UUID within a team-owned project, with capped resource summaries and counts. Use list_applications / list_services / list_databases with environment_uuid for full inventory.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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.'),
];
}
}

View file

@ -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<int, int|string> $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 [];

338
app/Mcp/Tools/GetLogs.php Normal file
View file

@ -0,0 +1,338 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesResource;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Application;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class GetLogs extends Tool
{
protected string $name = 'get_logs';
protected string $description = 'Fetch recent container logs for an application, database, service, or service child. Requires read:sensitive ability. Output is best-effort redacted (not a guarantee). Multi-container services require resource=service_application|service_database. Requires a running container on a reachable server. On failure returns structured reason + next_tools (DB-only follow-ups). Default 100 lines, max 500.';
use BuildsResponse;
use ResolvesResource;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->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<string, mixed>|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<string, mixed>
*/
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<array{resource: string, uuid: string, name: mixed}> $choices
*/
public function __construct(
string $message,
public readonly array $choices,
) {
parent::__construct($message);
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Project;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class GetProject extends Tool
{
protected string $name = 'get_project';
protected string $description = 'Get a project by UUID for the authenticated team, including environments and resource counts.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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(),
];
}
}

View file

@ -0,0 +1,93 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Application;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Stringable;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class GetServerDomains extends Tool
{
protected string $name = 'get_server_domains';
protected string $description = 'List domains hosted on a server owned by the authenticated team.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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(),
];
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Server;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class GetServerResources extends Tool
{
protected string $name = 'get_server_resources';
protected string $description = 'List resources defined on a server owned by the authenticated team.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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(),
];
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\ServiceApplication;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class GetServiceApplication extends Tool
{
protected string $name = 'get_service_application';
protected string $description = 'Get a service application component by UUID, scoped to the authenticated team.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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(),
];
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\ServiceDatabase;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class GetServiceDatabase extends Tool
{
protected string $name = 'get_service_database';
protected string $description = 'Get a service database component by UUID, scoped to the authenticated team.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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(),
];
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Application;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListApplicationPreviews extends Tool
{
protected string $name = 'list_application_previews';
protected string $description = 'List pull-request preview deployments for an application owned by the authenticated team.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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).'),
];
}
}

View file

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

View file

@ -0,0 +1,114 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\ScheduledDatabaseBackup;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListBackupExecutions extends Tool
{
protected string $name = 'list_backup_executions';
protected string $description = 'List backup executions for a scheduled database backup owned by the authenticated team. Execution messages require read:sensitive and are best-effort redacted.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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).'),
];
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\ScheduledDatabaseBackup;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListDatabaseBackups extends Tool
{
protected string $name = 'list_database_backups';
protected string $description = 'List backup schedules for a standalone database owned by the authenticated team.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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(),
];
}
}

View file

@ -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<int|string> $envIds
* @param array<class-string, list<int|string>>|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<int|string> $envIds
* @param array<class-string, list<int|string>>|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).'),
];

View file

@ -0,0 +1,137 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListDeployments extends Tool
{
protected string $name = 'list_deployments';
protected string $description = 'List deployments for the authenticated team. Without application_uuid, returns in_progress/queued deployments. With application_uuid, returns that app\'s deployment history (paginated). Build logs are never included.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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<string, mixed>
*/
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).'),
];
}
}

View file

@ -0,0 +1,98 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
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;
use Laravel\Mcp\Server\Tool;
class ListDestinations extends Tool
{
protected string $name = 'list_destinations';
protected string $description = 'List Docker destinations for the authenticated team. Optional server_uuid filter.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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<string, mixed>
*/
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).'),
];
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesResource;
use App\Mcp\Concerns\ResolvesTeam;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListEnvKeys extends Tool
{
protected string $name = 'list_env_keys';
protected string $description = 'List environment variable key names (never values) for an application, database, or service owned by the authenticated team.';
use BuildsResponse;
use ResolvesResource;
use ResolvesTeam;
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.');
}
$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(),
];
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\GithubApp;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListGithubApps extends Tool
{
protected string $name = 'list_github_apps';
protected string $description = 'List GitHub apps available to the authenticated team (team-owned or system-wide). Secrets are never returned.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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).'),
];
}
}

View file

@ -0,0 +1,153 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\GithubApp;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Http;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListGithubBranches extends Tool
{
protected string $name = 'list_github_branches';
protected string $description = 'List branches for a repository via a team GitHub app (calls GitHub API). Soft-registered only when a GitHub app exists.';
use BuildsResponse;
use ResolvesTeam;
public function shouldRegister(): bool
{
return GithubApp::query()->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);
}
}

View file

@ -0,0 +1,123 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\GithubApp;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Http;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListGithubRepositories extends Tool
{
protected string $name = 'list_github_repositories';
protected string $description = 'List repositories via a team GitHub app (calls GitHub API). Prefer list_github_apps first. Soft-registered only when a GitHub app exists.';
use BuildsResponse;
use ResolvesTeam;
public function shouldRegister(): bool
{
return GithubApp::query()->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(),
];
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesResource;
use App\Mcp\Concerns\ResolvesTeam;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListResourceTags extends Tool
{
protected string $name = 'list_resource_tags';
protected string $description = 'List tags attached to an application, database, or service owned by the authenticated team.';
use BuildsResponse;
use ResolvesResource;
use ResolvesTeam;
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.');
}
$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(),
];
}
}

View file

@ -0,0 +1,289 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Application;
use App\Models\Service;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListResources extends Tool
{
protected string $name = 'list_resources';
protected string $description = 'List all resources (applications, services, databases) owned by the authenticated team. Optional filters: type, project_uuid, tag.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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<int, object> $rows
* @return list<array<string, mixed>>
*/
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).'),
];
}
}

View file

@ -0,0 +1,122 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesResource;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\ScheduledTask;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListScheduledTaskExecutions extends Tool
{
protected string $name = 'list_scheduled_task_executions';
protected string $description = 'List recent executions for a scheduled task on an application or service owned by the authenticated team. Execution messages require read:sensitive and are best-effort redacted.';
use BuildsResponse;
use ResolvesResource;
use ResolvesTeam;
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.');
}
$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).'),
];
}
}

View file

@ -0,0 +1,94 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesResource;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\ScheduledTask;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListScheduledTasks extends Tool
{
protected string $name = 'list_scheduled_tasks';
protected string $description = 'List scheduled tasks for an application or service owned by the authenticated team. Task command bodies require read:sensitive; without it only metadata is returned.';
use BuildsResponse;
use ResolvesResource;
use ResolvesTeam;
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.');
}
$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(),
];
}
}

View file

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

View file

@ -0,0 +1,71 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Service;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListServiceApplications extends Tool
{
protected string $name = 'list_service_applications';
protected string $description = 'List application components of a service stack owned by the authenticated team.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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(),
];
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Service;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListServiceDatabases extends Tool
{
protected string $name = 'list_service_databases';
protected string $description = 'List database components of a service stack owned by the authenticated team.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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(),
];
}
}

View file

@ -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<Service> $query
* @return array{0: int, 1: Collection<int, Service>}
*/
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()];
}
}

View file

@ -0,0 +1,117 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Environment;
use App\Models\Project;
use App\Models\SharedEnvironmentVariable;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListSharedEnvKeys extends Tool
{
protected string $name = 'list_shared_env_keys';
protected string $description = 'List shared environment variable key names (never values) for a project or environment owned by the authenticated team. Scope: project | environment.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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.'),
];
}
}

View file

@ -0,0 +1,116 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesResource;
use App\Mcp\Concerns\ResolvesTeam;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListStorages extends Tool
{
protected string $name = 'list_storages';
protected string $description = 'List persistent and file storage mounts for an application, database, or service owned by the authenticated team. File contents are never returned.';
use BuildsResponse;
use ResolvesResource;
use ResolvesTeam;
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.');
}
$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(),
];
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Tag;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListTags extends Tool
{
protected string $name = 'list_tags';
protected string $description = 'List tags owned by the authenticated team.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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).'),
];
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Team;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class ListTeamMembers extends Tool
{
protected string $name = 'list_team_members';
protected string $description = 'List members of the team associated with the authenticated API token.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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).'),
];
}
}

View file

@ -0,0 +1,435 @@
<?php
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\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
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;
class ListUnhealthyResources extends Tool
{
protected string $name = 'list_unhealthy_resources';
protected string $description = 'List team resources that look unhealthy or down. Prefer sample_only=true first (cheap sample + counts). Apps/DBs use SQL filters; services need a lightweight status scan. Full mode paginates without hydrating the full unhealthy set. Default per_page 20.';
use BuildsResponse;
use McpStatusFilters;
use ResolvesTeam;
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.');
}
$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<int, array<string, mixed>> $unhealthyServers
* @return list<array<string, mixed>>
*/
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<string, mixed>
*/
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<int, array<string, mixed>>}
*/
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<int, array<string, mixed>>}
*/
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).'),
];
}
}

View file

@ -0,0 +1,205 @@
<?php
namespace App\Mcp\Tools;
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\Service;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class SearchResources extends Tool
{
protected string $name = 'search_resources';
protected string $description = 'Fuzzy search across team-owned applications, services, databases, servers, and projects by name, UUID, or domain. Use when the resource type is unknown.';
use BuildsResponse;
use ResolvesTeam;
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.');
}
$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).'),
];
}
}

View file

@ -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 () {

View file

@ -0,0 +1,139 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->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');
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,116 @@
<?php
use App\Mcp\Concerns\BuildsResponse;
/**
* Expose protected action helpers for unit testing without booting MCP tools.
*/
class BuildsResponseActionsHarness
{
use BuildsResponse;
/**
* @return array<int, array<string, mixed>>
*/
public function applicationActions(string $uuid, ?string $status = null): array
{
return $this->actionsForApplication($uuid, $status);
}
/**
* @return array<int, array<string, mixed>>
*/
public function databaseActions(string $uuid, ?string $status = null): array
{
return $this->actionsForDatabase($uuid, $status);
}
/**
* @return array<int, array<string, mixed>>
*/
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');
});