feat(mcp): add MCP server with read-only tools for Coolify resources

Add Model Context Protocol server exposing Coolify infrastructure data
to AI assistants. Includes tools for listing/fetching servers, projects,
applications, databases, and services, scoped to authenticated team tokens.

- Add CoolifyServer with 10 read-only tools (list/get for all resource types)
- Add BuildsResponse and ResolvesTeam traits for shared tool logic
- Add EnsureMcpEnabled middleware guarding /mcp routes
- Add enable/disable MCP API endpoints (root-only)
- Add is_mcp_server_enabled toggle in instance settings and advanced UI
- Add migration for is_mcp_server_enabled column
- Add feature tests for MCP endpoints and toggle API
- Scrub sensitive keys (passwords, tokens, raw IDs) from all responses
This commit is contained in:
Andras Bacsai committed 2026-04-29 10:30:43 +02:00
1 parent f8755261ba
commit 7ab16ad7b5
28 files changed
+1783 -109

No files matched your search

@@ -153,6 +153,118 @@ class OtherController extends Controller
return response()->json(['message' => 'API disabled.'], 200);
}
#[OA\Get(
summary: 'Enable MCP Server',
description: 'Enable the MCP server endpoint at /mcp (only with root permissions).',
path: '/mcp/enable',
operationId: 'enable-mcp',
security: [
['bearerAuth' => []],
],
responses: [
new OA\Response(
response: 200,
description: 'MCP server enabled.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'MCP server enabled.'),
]
)),
new OA\Response(
response: 403,
description: 'You are not allowed to enable the MCP server.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the MCP server.'),
]
)),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
]
)]
public function enable_mcp(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if ($teamId !== '0') {
auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning');
return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_mcp_server_enabled' => true]);
auditLog('api.mcp.enabled', ['team_id' => $teamId]);
return response()->json(['message' => 'MCP server enabled.'], 200);
}
#[OA\Get(
summary: 'Disable MCP Server',
description: 'Disable the MCP server endpoint at /mcp (only with root permissions).',
path: '/mcp/disable',
operationId: 'disable-mcp',
security: [
['bearerAuth' => []],
],
responses: [
new OA\Response(
response: 200,
description: 'MCP server disabled.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'MCP server disabled.'),
]
)),
new OA\Response(
response: 403,
description: 'You are not allowed to disable the MCP server.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to disable the MCP server.'),
]
)),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
]
)]
public function disable_mcp(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if ($teamId !== '0') {
auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning');
return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_mcp_server_enabled' => false]);
auditLog('api.mcp.disabled', ['team_id' => $teamId]);
return response()->json(['message' => 'MCP server disabled.'], 200);
}
public function feedback(Request $request)
{
$data = $request->validate([
+68 -34
View File
@@ -2,7 +2,40 @@
namespace App\Http;
use App\Http\Middleware\ApiAbility;
use App\Http\Middleware\ApiSensitiveData;
use App\Http\Middleware\Authenticate;
use App\Http\Middleware\CanAccessTerminal;
use App\Http\Middleware\CanCreateResources;
use App\Http\Middleware\CanUpdateResource;
use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\EnsureMcpEnabled;
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts;
use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\ValidateSignature;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Http\Middleware\HandleCors;
use Illuminate\Http\Middleware\SetCacheHeaders;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
class Kernel extends HttpKernel
{
@@ -14,13 +47,13 @@ class Kernel extends HttpKernel
* @var array<int, class-string|string>
*/
protected $middleware = [
\App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
TrustHosts::class,
TrustProxies::class,
HandleCors::class,
PreventRequestsDuringMaintenance::class,
ValidatePostSize::class,
TrimStrings::class,
ConvertEmptyStringsToNull::class,
];
@@ -31,21 +64,21 @@ class Kernel extends HttpKernel
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\CheckForcePasswordReset::class,
\App\Http\Middleware\DecideWhatToDoWithUser::class,
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
CheckForcePasswordReset::class,
DecideWhatToDoWithUser::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
ThrottleRequests::class.':api',
SubstituteBindings::class,
],
];
@@ -57,22 +90,23 @@ class Kernel extends HttpKernel
* @var array<string, class-string|string>
*/
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \App\Http\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
'api.ability' => \App\Http\Middleware\ApiAbility::class,
'api.sensitive' => \App\Http\Middleware\ApiSensitiveData::class,
'can.create.resources' => \App\Http\Middleware\CanCreateResources::class,
'can.update.resource' => \App\Http\Middleware\CanUpdateResource::class,
'can.access.terminal' => \App\Http\Middleware\CanAccessTerminal::class,
'auth' => Authenticate::class,
'auth.basic' => AuthenticateWithBasicAuth::class,
'auth.session' => AuthenticateSession::class,
'cache.headers' => SetCacheHeaders::class,
'can' => Authorize::class,
'guest' => RedirectIfAuthenticated::class,
'password.confirm' => RequirePassword::class,
'signed' => ValidateSignature::class,
'throttle' => ThrottleRequests::class,
'verified' => EnsureEmailIsVerified::class,
'abilities' => CheckAbilities::class,
'ability' => CheckForAnyAbility::class,
'api.ability' => ApiAbility::class,
'api.sensitive' => ApiSensitiveData::class,
'can.create.resources' => CanCreateResources::class,
'can.update.resource' => CanUpdateResource::class,
'can.access.terminal' => CanAccessTerminal::class,
'mcp.enabled' => EnsureMcpEnabled::class,
];
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use App\Models\InstanceSettings;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureMcpEnabled
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (! InstanceSettings::get()->is_mcp_server_enabled) {
abort(404);
}
return $next($request);
}
}
+6
View File
@@ -37,6 +37,9 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $is_wire_navigate_enabled;
#[Validate('boolean')]
public bool $is_mcp_server_enabled;
public function rules()
{
return [
@@ -49,6 +52,7 @@ class Advanced extends Component
'is_sponsorship_popup_enabled' => 'boolean',
'disable_two_step_confirmation' => 'boolean',
'is_wire_navigate_enabled' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
];
}
@@ -67,6 +71,7 @@ class Advanced extends Component
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
$this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
}
public function submit()
@@ -150,6 +155,7 @@ class Advanced extends Component
$this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled;
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
$this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled;
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
} catch (\Exception $e) {
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace App\Mcp\Concerns;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
trait BuildsResponse
{
protected int $defaultPerPage = 50;
protected int $maxPerPage = 100;
/**
* Keys removed at any depth from get_* responses.
*
* Covers: raw integer surrogate keys (id and *_id columns; uuid stays),
* Eloquent morph types, encrypted secrets, DB passwords, and bulky
* payloads that should never traverse the MCP boundary.
*
* @var array<int, string>
*/
protected array $sensitiveKeys = [
// raw IDs / morph types (uuid is the public identifier)
'id', 'team_id', 'tokenable_id', 'tokenable_type',
'server_id', 'private_key_id', 'cloud_provider_token_id',
'hetzner_server_id', 'environment_id', 'destination_id',
'source_id', 'repository_project_id', 'application_id',
'service_id', 'project_id', 'parent_id',
'resourceable', 'resourceable_id', 'resourceable_type',
'destination_type', 'source_type', 'tokenable',
// sentinel / observability secrets
'sentinel_token', 'sentinel_custom_url',
'logdrain_newrelic_license_key', 'logdrain_axiom_api_key',
'logdrain_custom_config', 'logdrain_custom_config_parser',
// database passwords
'postgres_password', 'dragonfly_password', 'keydb_password',
'redis_password', 'mongo_initdb_root_password',
'mariadb_password', 'mariadb_root_password',
'mysql_password', 'mysql_root_password',
'clickhouse_admin_password',
// app/env secrets
'value', 'real_value', 'http_basic_auth_password',
// database connection strings embed credentials
'internal_db_url', 'external_db_url', 'init_scripts',
// webhook secrets
'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea',
'manual_webhook_secret_github', 'manual_webhook_secret_gitlab',
// bulky / unsafe blobs
'dockerfile', 'docker_compose', 'docker_compose_raw',
'custom_labels', 'environment_variables',
'environment_variables_preview', 'validation_logs',
'server_metadata',
];
/**
* Recursively remove sensitive keys from any nested array structure.
*
* @param array<array-key, mixed> $data
* @return array<array-key, mixed>
*/
protected function scrubSensitive(array $data): array
{
$deny = array_flip($this->sensitiveKeys);
$walk = function ($value) use (&$walk, $deny) {
if (! is_array($value)) {
return $value;
}
$out = [];
foreach ($value as $key => $inner) {
if (is_string($key) && isset($deny[$key])) {
continue;
}
$out[$key] = $walk($inner);
}
return $out;
};
return $walk($data);
}
/**
* @param array<string, mixed>|array<int, mixed> $data
* @param array<int, array<string, mixed>> $actions
* @param array<string, mixed>|null $pagination
*/
protected function respond(array $data, array $actions = [], ?array $pagination = null): Response
{
$payload = ['data' => $data];
if ($actions !== []) {
$payload['_actions'] = $actions;
}
if ($pagination !== null) {
$payload['_pagination'] = $pagination;
}
return Response::json($payload);
}
/**
* @return array{page:int, per_page:int, offset:int}
*/
protected function paginationArgs(Request $request): array
{
$page = max(1, (int) ($request->get('page') ?? 1));
$perPage = (int) ($request->get('per_page') ?? $this->defaultPerPage);
$perPage = max(1, min($this->maxPerPage, $perPage));
return [
'page' => $page,
'per_page' => $perPage,
'offset' => ($page - 1) * $perPage,
];
}
/**
* @param array{page:int, per_page:int, offset:int} $args
* @return array<string, mixed>|null
*/
protected function paginationMeta(string $tool, array $args, int $total, array $extraArgs = []): ?array
{
$page = $args['page'];
$perPage = $args['per_page'];
$totalPages = (int) ceil($total / $perPage);
$meta = [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => $totalPages,
];
if ($page < $totalPages) {
$meta['next'] = [
'tool' => $tool,
'args' => array_merge($extraArgs, ['page' => $page + 1, 'per_page' => $perPage]),
];
}
return $meta;
}
/**
* HATEOAS-style action suggestions for an application.
*
* @return array<int, array<string, mixed>>
*/
protected function actionsForApplication(string $uuid, ?string $status = null): array
{
$actions = [
['tool' => 'get_application', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
];
$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'];
} else {
$actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start'];
}
return $actions;
}
/**
* @return array<int, array<string, mixed>>
*/
protected function actionsForDatabase(string $uuid, ?string $status = null): array
{
$actions = [
['tool' => 'get_database', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
];
$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'];
} else {
$actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start'];
}
return $actions;
}
/**
* @return array<int, array<string, mixed>>
*/
protected function actionsForService(string $uuid, ?string $status = null): array
{
$actions = [
['tool' => 'get_service', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
];
$s = strtolower((string) $status);
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'];
} else {
$actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start'];
}
return $actions;
}
/**
* @return array<int, array<string, mixed>>
*/
protected function actionsForServer(string $uuid): array
{
return [
['tool' => 'get_server', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
];
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Mcp\Concerns;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
trait ResolvesTeam
{
protected function ensureAbility(Request $request, string $ability = 'read'): ?Response
{
$user = $request->user();
if (! $user) {
return Response::error('Unauthenticated.');
}
$token = $user->currentAccessToken();
if (! $token) {
return Response::error('Invalid token.');
}
if ($token->can('root') || $token->can($ability)) {
return null;
}
return Response::error("Missing required permissions: {$ability}");
}
protected function resolveTeamId(Request $request): ?int
{
$token = $request->user()?->currentAccessToken();
return $token?->team_id;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Mcp\Servers;
use App\Mcp\Tools\GetApplication;
use App\Mcp\Tools\GetDatabase;
use App\Mcp\Tools\GetInfrastructureOverview;
use App\Mcp\Tools\GetServer;
use App\Mcp\Tools\GetService;
use App\Mcp\Tools\ListApplications;
use App\Mcp\Tools\ListDatabases;
use App\Mcp\Tools\ListProjects;
use App\Mcp\Tools\ListServers;
use App\Mcp\Tools\ListServices;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
#[Name('Coolify')]
#[Version('0.1.0')]
#[Instructions(<<<'MD'
Read-only MCP server for Coolify, scoped to the authenticated team token.
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.
Every response is `{ data, _actions?, _pagination? }`. `_actions` suggests the next tool + args; `_pagination.next` is the args to call again for the next page.
MD)]
class CoolifyServer extends Server
{
protected array $tools = [
GetInfrastructureOverview::class,
ListServers::class,
GetServer::class,
ListProjects::class,
ListApplications::class,
GetApplication::class,
ListDatabases::class,
GetDatabase::class,
ListServices::class,
GetService::class,
];
protected array $resources = [];
protected array $prompts = [];
}
+60
View File
@@ -0,0 +1,60 @@
<?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\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('get_application')]
#[Description('Get full details for a single application by UUID.')]
class GetApplication extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
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 argument is required.');
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
if (! $application) {
return Response::error("Application [{$uuid}] not found.");
}
// Drop relations that the server_status accessor lazy-loads — they
// pull in sensitive nested data (server.settings.sentinel_token, etc.)
$application->setRelations([]);
$application->makeHidden(['destination', 'source', 'additional_servers', 'environment', 'tags', 'environmentVariables']);
return $this->respond(
$this->scrubSensitive($application->toArray()),
$this->actionsForApplication($uuid, $application->status),
);
}
public function schema(JsonSchema $schema): array
{
return [
'uuid' => $schema->string()->description('Application UUID.')->required(),
];
}
}
+58
View File
@@ -0,0 +1,58 @@
<?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\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('get_database')]
#[Description('Get full details for a standalone database by UUID. Detects type across postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse.')]
class GetDatabase extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
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 argument is required.');
}
$database = queryDatabaseByUuidWithinTeam($uuid, (string) $teamId);
if (! $database) {
return Response::error("Database [{$uuid}] not found.");
}
// Drop relations so deep server/destination data doesn't leak.
$database->setRelations([]);
$database->makeHidden(['destination', 'source', 'environment', 'environment_variables', 'environment_variables_preview']);
return $this->respond(
$this->scrubSensitive($database->toArray()),
$this->actionsForDatabase($uuid, $database->status ?? null),
);
}
public function schema(JsonSchema $schema): array
{
return [
'uuid' => $schema->string()->description('Database UUID.')->required(),
];
}
}
@@ -0,0 +1,93 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Project;
use App\Models\Server;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('get_infrastructure_overview')]
#[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.')]
class GetInfrastructureOverview extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$servers = Server::whereTeamId($teamId)
->select('id', 'name', 'uuid', 'ip', 'description')
->with('settings:id,server_id,is_reachable,is_usable')
->get()
->map(fn ($s) => [
'uuid' => $s->uuid,
'name' => $s->name,
'ip' => $s->ip,
'is_reachable' => $s->settings?->is_reachable,
'is_usable' => $s->settings?->is_usable,
])
->values()
->all();
$projects = Project::where('team_id', $teamId)->get();
$appCount = 0;
$serviceCount = 0;
$databaseCount = 0;
$projectSummaries = [];
foreach ($projects as $project) {
$apps = $project->applications()->count();
$services = $project->services()->count();
$databases = $project->databases()->count();
$appCount += $apps;
$serviceCount += $services;
$databaseCount += $databases;
$projectSummaries[] = [
'uuid' => $project->uuid,
'name' => $project->name,
'counts' => [
'applications' => $apps,
'services' => $services,
'databases' => $databases,
],
];
}
return $this->respond([
'coolify_version' => config('constants.coolify.version'),
'servers' => $servers,
'projects' => $projectSummaries,
'counts' => [
'servers' => count($servers),
'projects' => count($projectSummaries),
'applications' => $appCount,
'services' => $serviceCount,
'databases' => $databaseCount,
],
]);
}
public function schema(JsonSchema $schema): array
{
return [];
}
}
+57
View File
@@ -0,0 +1,57 @@
<?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\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('get_server')]
#[Description('Get full details for a single server by UUID.')]
class GetServer extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
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 argument is required.');
}
$server = Server::whereTeamId($teamId)->where('uuid', $uuid)->with('settings')->first();
if (! $server) {
return Response::error("Server [{$uuid}] not found.");
}
$data = $this->scrubSensitive($server->toArray());
$data['is_reachable'] = $server->settings?->is_reachable;
$data['is_usable'] = $server->settings?->is_usable;
$data['connection_timeout'] = $server->settings?->connection_timeout;
return $this->respond($data, $this->actionsForServer($uuid));
}
public function schema(JsonSchema $schema): array
{
return [
'uuid' => $schema->string()->description('Server UUID.')->required(),
];
}
}
+61
View File
@@ -0,0 +1,61 @@
<?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\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('get_service')]
#[Description('Get full details for a single service (multi-container stack) by UUID.')]
class GetService extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
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 argument is required.');
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)
->where('uuid', $uuid)
->first();
if (! $service) {
return Response::error("Service [{$uuid}] not found.");
}
$service->setRelations([]);
$service->makeHidden(['destination', 'source', 'environment', 'applications', 'databases', 'serviceApplications', 'serviceDatabases']);
return $this->respond(
$this->scrubSensitive($service->toArray()),
$this->actionsForService($uuid, $service->status ?? null),
);
}
public function schema(JsonSchema $schema): array
{
return [
'uuid' => $schema->string()->description('Service UUID.')->required(),
];
}
}
+74
View File
@@ -0,0 +1,74 @@
<?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\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('list_applications')]
#[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.')]
class ListApplications extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$tagName = $request->get('tag');
$args = $this->paginationArgs($request);
$query = Application::ownedByCurrentTeamAPI($teamId)
->when($tagName, function ($query, $tagName) {
$query->whereHas('tags', fn ($q) => $q->where('name', $tagName));
});
$total = (clone $query)->count();
$summaries = $query
->skip($args['offset'])
->take($args['per_page'])
->get()
->map(fn ($app) => [
'uuid' => $app->uuid,
'name' => $app->name,
'status' => $app->status,
'fqdn' => $app->fqdn,
'git_repository' => $app->git_repository,
])
->values()
->all();
$extra = $tagName ? ['tag' => $tagName] : [];
return $this->respond(
$summaries,
[],
$this->paginationMeta('list_applications', $args, $total, $extra),
);
}
public function schema(JsonSchema $schema): array
{
return [
'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).'),
];
}
}
+69
View File
@@ -0,0 +1,69 @@
<?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\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('list_databases')]
#[Description('List standalone databases owned by the authenticated team. Returns summary (uuid, name, status, type). Use get_database for full details.')]
class ListDatabases extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$args = $this->paginationArgs($request);
$projects = Project::where('team_id', $teamId)->get();
$databases = collect();
foreach ($projects as $project) {
$databases = $databases->merge($project->databases());
}
$total = $databases->count();
$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),
])
->values()
->all();
return $this->respond(
$summaries,
[],
$this->paginationMeta('list_databases', $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).'),
];
}
}
+66
View File
@@ -0,0 +1,66 @@
<?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\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('list_projects')]
#[Description('List projects owned by the authenticated team. Returns summary (uuid, name, description).')]
class ListProjects extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$args = $this->paginationArgs($request);
$query = Project::whereTeamId($teamId);
$total = (clone $query)->count();
$summaries = $query
->select('name', 'description', 'uuid')
->orderBy('name')
->skip($args['offset'])
->take($args['per_page'])
->get()
->map(fn ($p) => [
'uuid' => $p->uuid,
'name' => $p->name,
'description' => $p->description,
])
->values()
->all();
return $this->respond(
$summaries,
[],
$this->paginationMeta('list_projects', $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
View File
@@ -0,0 +1,67 @@
<?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\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('list_servers')]
#[Description('List servers visible to the authenticated team token. Returns summary (uuid, name, ip, reachability). Use get_server for full details.')]
class ListServers extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$args = $this->paginationArgs($request);
$query = Server::whereTeamId($teamId)->with('settings:id,server_id,is_reachable,is_usable');
$total = (clone $query)->count();
$summaries = $query
->orderBy('name')
->skip($args['offset'])
->take($args['per_page'])
->get()
->map(fn ($s) => [
'uuid' => $s->uuid,
'name' => $s->name,
'ip' => $s->ip,
'is_reachable' => $s->settings?->is_reachable,
'is_usable' => $s->settings?->is_usable,
])
->values()
->all();
return $this->respond(
$summaries,
[],
$this->paginationMeta('list_servers', $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).'),
];
}
}
+68
View File
@@ -0,0 +1,68 @@
<?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\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('list_services')]
#[Description('List services (multi-container stacks) owned by the authenticated team. Returns summary (uuid, name, status). Use get_service for full details.')]
class ListServices extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$args = $this->paginationArgs($request);
$projects = Project::where('team_id', $teamId)->get();
$services = collect();
foreach ($projects as $project) {
$services = $services->merge($project->services()->get());
}
$total = $services->count();
$summaries = $services
->sortBy('name')
->slice($args['offset'], $args['per_page'])
->map(fn ($svc) => [
'uuid' => $svc->uuid,
'name' => $svc->name,
'status' => $svc->status ?? null,
])
->values()
->all();
return $this->respond(
$summaries,
[],
$this->paginationMeta('list_services', $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).'),
];
}
}
+2
View File
@@ -45,6 +45,7 @@ class InstanceSettings extends Model
'is_sponsorship_popup_enabled',
'dev_helper_version',
'is_wire_navigate_enabled',
'is_mcp_server_enabled',
];
protected $casts = [
@@ -67,6 +68,7 @@ class InstanceSettings extends Model
'update_check_frequency' => 'string',
'sentinel_token' => 'encrypted',
'is_wire_navigate_enabled' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
];
protected static function booted(): void