feat(mcp): add resource diagnostics and deployment controls (#11000)
This commit is contained in:
parent
c27a5ec41e
commit
7b18777f06
54 changed files with 8471 additions and 67 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
app/Mcp/Concerns/McpStatusFilters.php
Normal file
40
app/Mcp/Concerns/McpStatusFilters.php
Normal 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');
|
||||
}
|
||||
}
|
||||
103
app/Mcp/Concerns/ResolvesResource.php
Normal file
103
app/Mcp/Concerns/ResolvesResource.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
68
app/Mcp/Prompts/ExplainFailedDeploy.php
Normal file
68
app/Mcp/Prompts/ExplainFailedDeploy.php
Normal 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,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
66
app/Mcp/Prompts/TroubleshootApplication.php
Normal file
66
app/Mcp/Prompts/TroubleshootApplication.php
Normal 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=100–200.
|
||||
- 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,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
71
app/Mcp/Resources/ApplicationResource.php
Normal file
71
app/Mcp/Resources/ApplicationResource.php
Normal 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));
|
||||
}
|
||||
}
|
||||
91
app/Mcp/Resources/InfrastructureOverviewResource.php
Normal file
91
app/Mcp/Resources/InfrastructureOverviewResource.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
121
app/Mcp/Tools/CancelDeployment.php
Normal file
121
app/Mcp/Tools/CancelDeployment.php
Normal 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
189
app/Mcp/Tools/Control.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
180
app/Mcp/Tools/CoolifyHelp.php
Normal file
180
app/Mcp/Tools/CoolifyHelp.php
Normal 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
93
app/Mcp/Tools/Deploy.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
53
app/Mcp/Tools/GetCurrentTeam.php
Normal file
53
app/Mcp/Tools/GetCurrentTeam.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
183
app/Mcp/Tools/GetDeployment.php
Normal file
183
app/Mcp/Tools/GetDeployment.php
Normal 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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
71
app/Mcp/Tools/GetDestination.php
Normal file
71
app/Mcp/Tools/GetDestination.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
167
app/Mcp/Tools/GetEnvironment.php
Normal file
167
app/Mcp/Tools/GetEnvironment.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
338
app/Mcp/Tools/GetLogs.php
Normal 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);
|
||||
}
|
||||
}
|
||||
77
app/Mcp/Tools/GetProject.php
Normal file
77
app/Mcp/Tools/GetProject.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
93
app/Mcp/Tools/GetServerDomains.php
Normal file
93
app/Mcp/Tools/GetServerDomains.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/Mcp/Tools/GetServerResources.php
Normal file
64
app/Mcp/Tools/GetServerResources.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
87
app/Mcp/Tools/GetServiceApplication.php
Normal file
87
app/Mcp/Tools/GetServiceApplication.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
89
app/Mcp/Tools/GetServiceDatabase.php
Normal file
89
app/Mcp/Tools/GetServiceDatabase.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
87
app/Mcp/Tools/ListApplicationPreviews.php
Normal file
87
app/Mcp/Tools/ListApplicationPreviews.php
Normal 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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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).'),
|
||||
];
|
||||
|
|
|
|||
114
app/Mcp/Tools/ListBackupExecutions.php
Normal file
114
app/Mcp/Tools/ListBackupExecutions.php
Normal 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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
72
app/Mcp/Tools/ListDatabaseBackups.php
Normal file
72
app/Mcp/Tools/ListDatabaseBackups.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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).'),
|
||||
];
|
||||
|
|
|
|||
137
app/Mcp/Tools/ListDeployments.php
Normal file
137
app/Mcp/Tools/ListDeployments.php
Normal 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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
98
app/Mcp/Tools/ListDestinations.php
Normal file
98
app/Mcp/Tools/ListDestinations.php
Normal 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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
87
app/Mcp/Tools/ListEnvKeys.php
Normal file
87
app/Mcp/Tools/ListEnvKeys.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
74
app/Mcp/Tools/ListGithubApps.php
Normal file
74
app/Mcp/Tools/ListGithubApps.php
Normal 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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
153
app/Mcp/Tools/ListGithubBranches.php
Normal file
153
app/Mcp/Tools/ListGithubBranches.php
Normal 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);
|
||||
}
|
||||
}
|
||||
123
app/Mcp/Tools/ListGithubRepositories.php
Normal file
123
app/Mcp/Tools/ListGithubRepositories.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
74
app/Mcp/Tools/ListResourceTags.php
Normal file
74
app/Mcp/Tools/ListResourceTags.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
289
app/Mcp/Tools/ListResources.php
Normal file
289
app/Mcp/Tools/ListResources.php
Normal 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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
122
app/Mcp/Tools/ListScheduledTaskExecutions.php
Normal file
122
app/Mcp/Tools/ListScheduledTaskExecutions.php
Normal 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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
94
app/Mcp/Tools/ListScheduledTasks.php
Normal file
94
app/Mcp/Tools/ListScheduledTasks.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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).'),
|
||||
];
|
||||
|
|
|
|||
71
app/Mcp/Tools/ListServiceApplications.php
Normal file
71
app/Mcp/Tools/ListServiceApplications.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
70
app/Mcp/Tools/ListServiceDatabases.php
Normal file
70
app/Mcp/Tools/ListServiceDatabases.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
||||
return;
|
||||
}
|
||||
$query->whereHas('environment', fn ($q) => $q->where('project_id', $project->id));
|
||||
})
|
||||
->when(is_string($environmentUuid), function ($query) use ($environmentUuid, $teamId) {
|
||||
$env = Environment::ownedByCurrentTeamAPI($teamId)->where('uuid', $environmentUuid)->first();
|
||||
if (! $env) {
|
||||
$query->whereRaw('1 = 0');
|
||||
|
||||
return;
|
||||
}
|
||||
$query->where('environment_id', $env->id);
|
||||
})
|
||||
->when(is_string($serverUuid), function ($query) use ($serverUuid, $teamId) {
|
||||
$server = Server::whereTeamId($teamId)->where('uuid', $serverUuid)->first();
|
||||
if (! $server) {
|
||||
$query->whereRaw('1 = 0');
|
||||
|
||||
return;
|
||||
}
|
||||
$standaloneDockerIds = $server->standaloneDockers()->pluck('id');
|
||||
$swarmDockerIds = $server->swarmDockers()->pluck('id');
|
||||
$query->where(function ($q) use ($server, $standaloneDockerIds, $swarmDockerIds) {
|
||||
$q->where('server_id', $server->id)
|
||||
->orWhere(function ($inner) use ($standaloneDockerIds) {
|
||||
$inner->where('destination_type', StandaloneDocker::class)
|
||||
->whereIn('destination_id', $standaloneDockerIds);
|
||||
})
|
||||
->orWhere(function ($inner) use ($swarmDockerIds) {
|
||||
$inner->where('destination_type', SwarmDocker::class)
|
||||
->whereIn('destination_id', $swarmDockerIds);
|
||||
});
|
||||
});
|
||||
})
|
||||
->when(is_string($name), fn ($query) => $query->whereRaw('LOWER(name) LIKE ?', ['%'.strtolower($name).'%']));
|
||||
|
||||
if (is_string($status)) {
|
||||
// Status is a computed accessor over applications/databases, so filter
|
||||
// in PHP — but scan in chunks so large teams never hydrate every service.
|
||||
[$total, $services] = $this->paginateServicesByStatus(
|
||||
$query,
|
||||
trim($status),
|
||||
$args['offset'],
|
||||
$args['per_page'],
|
||||
);
|
||||
} else {
|
||||
$total = (clone $query)->count();
|
||||
|
||||
$summaries = $query
|
||||
$services = $query
|
||||
->orderBy('name')
|
||||
->orderBy('id')
|
||||
->skip($args['offset'])
|
||||
->take($args['per_page'])
|
||||
->get()
|
||||
->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()];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
117
app/Mcp/Tools/ListSharedEnvKeys.php
Normal file
117
app/Mcp/Tools/ListSharedEnvKeys.php
Normal 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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
116
app/Mcp/Tools/ListStorages.php
Normal file
116
app/Mcp/Tools/ListStorages.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
62
app/Mcp/Tools/ListTags.php
Normal file
62
app/Mcp/Tools/ListTags.php
Normal 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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
67
app/Mcp/Tools/ListTeamMembers.php
Normal file
67
app/Mcp/Tools/ListTeamMembers.php
Normal 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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
435
app/Mcp/Tools/ListUnhealthyResources.php
Normal file
435
app/Mcp/Tools/ListUnhealthyResources.php
Normal 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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
205
app/Mcp/Tools/SearchResources.php
Normal file
205
app/Mcp/Tools/SearchResources.php
Normal 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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
139
tests/Feature/Mcp/McpImproveTest.php
Normal file
139
tests/Feature/Mcp/McpImproveTest.php
Normal 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');
|
||||
});
|
||||
2414
tests/Feature/Mcp/McpReadToolsTest.php
Normal file
2414
tests/Feature/Mcp/McpReadToolsTest.php
Normal file
File diff suppressed because it is too large
Load diff
116
tests/Unit/Mcp/BuildsResponseActionsTest.php
Normal file
116
tests/Unit/Mcp/BuildsResponseActionsTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue