Merge remote-tracking branch 'origin/next' into 9916-investigate-undefined-relationship

This commit is contained in:
Andras Bacsai 2026-05-06 14:32:16 +02:00
commit 29d41ba041
34 changed files with 2004 additions and 174 deletions

View file

@ -153,6 +153,118 @@ public function disable_api(Request $request)
return response()->json(['message' => 'API disabled.'], 200);
}
#[OA\Post(
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\Post(
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([

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

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

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 @@ public function rules()
'is_sponsorship_popup_enabled' => 'boolean',
'disable_two_step_confirmation' => 'boolean',
'is_wire_navigate_enabled' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
];
}
@ -67,6 +71,7 @@ public function mount()
$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 @@ public function instantSave()
$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) {

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

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

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 = [];
}

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

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

View file

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

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

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

View file

@ -0,0 +1,77 @@
<?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');
if ($tagName !== null && (! is_string($tagName) || trim($tagName) === '')) {
return Response::error('tag argument must be a non-empty string.');
}
$args = $this->paginationArgs($request);
$query = Application::ownedByCurrentTeamAPI($teamId)
->when($tagName !== null, function ($query) use ($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).'),
];
}
}

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

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

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

View file

@ -0,0 +1,66 @@
<?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('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);
$query = Service::whereHas('environment.project', fn ($q) => $q->where('team_id', $teamId));
$total = (clone $query)->count();
$summaries = $query
->orderBy('name')
->skip($args['offset'])
->take($args['per_page'])
->get()
->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).'),
];
}
}

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

View file

@ -134,8 +134,11 @@ public function databases()
$mongodbs = $this->mongodbs;
$mysqls = $this->mysqls;
$mariadbs = $this->mariadbs;
$keydbs = $this->keydbs;
$dragonflies = $this->dragonflies;
$clickhouses = $this->clickhouses;
return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs);
return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs)->concat($keydbs)->concat($dragonflies)->concat($clickhouses);
}
public function attachedTo()

View file

@ -1,7 +1,26 @@
<?php
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
const REDACTED = '<REDACTED>';
const DATABASE_TYPES = ['postgresql', 'redis', 'mongodb', 'mysql', 'mariadb', 'keydb', 'dragonfly', 'clickhouse'];
const STANDALONE_DATABASE_MODELS = [
'postgresql' => StandalonePostgresql::class,
'redis' => StandaloneRedis::class,
'mongodb' => StandaloneMongodb::class,
'mysql' => StandaloneMysql::class,
'mariadb' => StandaloneMariadb::class,
'keydb' => StandaloneKeydb::class,
'dragonfly' => StandaloneDragonfly::class,
'clickhouse' => StandaloneClickhouse::class,
];
const VALID_CRON_STRINGS = [
'every_minute' => '* * * * *',
'hourly' => '0 * * * *',

View file

@ -1058,44 +1058,17 @@ function getResourceByUuid(string $uuid, ?int $teamId = null)
}
function queryDatabaseByUuidWithinTeam(string $uuid, string $teamId)
{
$postgresql = StandalonePostgresql::whereUuid($uuid)->first();
if ($postgresql && $postgresql->team()->id == $teamId) {
return $postgresql->unsetRelation('environment');
}
$redis = StandaloneRedis::whereUuid($uuid)->first();
if ($redis && $redis->team()->id == $teamId) {
return $redis->unsetRelation('environment');
}
$mongodb = StandaloneMongodb::whereUuid($uuid)->first();
if ($mongodb && $mongodb->team()->id == $teamId) {
return $mongodb->unsetRelation('environment');
}
$mysql = StandaloneMysql::whereUuid($uuid)->first();
if ($mysql && $mysql->team()->id == $teamId) {
return $mysql->unsetRelation('environment');
}
$mariadb = StandaloneMariadb::whereUuid($uuid)->first();
if ($mariadb && $mariadb->team()->id == $teamId) {
return $mariadb->unsetRelation('environment');
}
$keydb = StandaloneKeydb::whereUuid($uuid)->first();
if ($keydb && $keydb->team()->id == $teamId) {
return $keydb->unsetRelation('environment');
}
$dragonfly = StandaloneDragonfly::whereUuid($uuid)->first();
if ($dragonfly && $dragonfly->team()->id == $teamId) {
return $dragonfly->unsetRelation('environment');
}
$clickhouse = StandaloneClickhouse::whereUuid($uuid)->first();
if ($clickhouse && $clickhouse->team()->id == $teamId) {
return $clickhouse->unsetRelation('environment');
foreach (STANDALONE_DATABASE_MODELS as $modelClass) {
$database = $modelClass::whereUuid($uuid)->first();
if ($database && $database->team()->id == $teamId) {
return $database->unsetRelation('environment');
}
}
return null;
}
function queryResourcesByUuid(string $uuid)
{
$resource = null;
$application = Application::whereUuid($uuid)->first();
if ($application) {
return $application;
@ -1104,37 +1077,11 @@ function queryResourcesByUuid(string $uuid)
if ($service) {
return $service;
}
$postgresql = StandalonePostgresql::whereUuid($uuid)->first();
if ($postgresql) {
return $postgresql;
}
$redis = StandaloneRedis::whereUuid($uuid)->first();
if ($redis) {
return $redis;
}
$mongodb = StandaloneMongodb::whereUuid($uuid)->first();
if ($mongodb) {
return $mongodb;
}
$mysql = StandaloneMysql::whereUuid($uuid)->first();
if ($mysql) {
return $mysql;
}
$mariadb = StandaloneMariadb::whereUuid($uuid)->first();
if ($mariadb) {
return $mariadb;
}
$keydb = StandaloneKeydb::whereUuid($uuid)->first();
if ($keydb) {
return $keydb;
}
$dragonfly = StandaloneDragonfly::whereUuid($uuid)->first();
if ($dragonfly) {
return $dragonfly;
}
$clickhouse = StandaloneClickhouse::whereUuid($uuid)->first();
if ($clickhouse) {
return $clickhouse;
foreach (STANDALONE_DATABASE_MODELS as $modelClass) {
$database = $modelClass::whereUuid($uuid)->first();
if ($database) {
return $database;
}
}
// Check for ServiceDatabase by its own UUID
@ -1143,7 +1090,7 @@ function queryResourcesByUuid(string $uuid)
return $serviceDatabase;
}
return $resource;
return null;
}
function generateTagDeployWebhook($tag_name)
{

View file

@ -18,6 +18,7 @@
"laravel/fortify": "^1.34.0",
"laravel/framework": "^12.49.0",
"laravel/horizon": "^5.43.0",
"laravel/mcp": "^0.6.7",
"laravel/nightwatch": "^1.24",
"laravel/pail": "^1.2.4",
"laravel/prompts": "^0.3.11|^0.3.11|^0.3.11",

150
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "40bddea995c1744e4aec517263109a2f",
"content-hash": "64b77285a7140ce68e83db2659e9a21d",
"packages": [
{
"name": "aws/aws-crt-php",
@ -2066,6 +2066,79 @@
},
"time": "2026-03-18T14:14:59+00:00"
},
{
"name": "laravel/mcp",
"version": "v0.6.7",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
"reference": "c3775e57b95d7eadb580d543689d9971ec8721f2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/mcp/zipball/c3775e57b95d7eadb580d543689d9971ec8721f2",
"reference": "c3775e57b95d7eadb580d543689d9971ec8721f2",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
"illuminate/container": "^11.45.3|^12.41.1|^13.0",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
"illuminate/http": "^11.45.3|^12.41.1|^13.0",
"illuminate/json-schema": "^12.41.1|^13.0",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
"php": "^8.2"
},
"require-dev": {
"laravel/pint": "^1.20",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"pestphp/pest": "^3.8.5|^4.3.2",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.2.4"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
},
"providers": [
"Laravel\\Mcp\\Server\\McpServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Mcp\\": "src/",
"Laravel\\Mcp\\Server\\": "src/Server/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Rapidly build MCP servers for your Laravel applications.",
"homepage": "https://github.com/laravel/mcp",
"keywords": [
"laravel",
"mcp"
],
"support": {
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
"time": "2026-04-15T08:30:42+00:00"
},
{
"name": "laravel/nightwatch",
"version": "v1.24.4",
@ -13738,79 +13811,6 @@
},
"time": "2026-03-21T11:50:49+00:00"
},
{
"name": "laravel/mcp",
"version": "v0.6.4",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
"reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/mcp/zipball/f822c5eb5beed19adb2e5bfe2f46f8c977ecea42",
"reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
"illuminate/container": "^11.45.3|^12.41.1|^13.0",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
"illuminate/http": "^11.45.3|^12.41.1|^13.0",
"illuminate/json-schema": "^12.41.1|^13.0",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
"php": "^8.2"
},
"require-dev": {
"laravel/pint": "^1.20",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"pestphp/pest": "^3.8.5|^4.3.2",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.2.4"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
},
"providers": [
"Laravel\\Mcp\\Server\\McpServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Mcp\\": "src/",
"Laravel\\Mcp\\Server\\": "src/Server/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Rapidly build MCP servers for your Laravel applications.",
"homepage": "https://github.com/laravel/mcp",
"keywords": [
"laravel",
"mcp"
],
"support": {
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
"time": "2026-03-19T12:37:13+00:00"
},
{
"name": "laravel/pint",
"version": "v1.29.0",
@ -17311,5 +17311,5 @@
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->boolean('is_mcp_server_enabled')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('is_mcp_server_enabled');
});
}
};

View file

@ -8650,6 +8650,110 @@
]
}
},
"\/mcp\/enable": {
"post": {
"summary": "Enable MCP Server",
"description": "Enable the MCP server endpoint at \/mcp (only with root permissions).",
"operationId": "enable-mcp",
"responses": {
"200": {
"description": "MCP server enabled.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "MCP server enabled."
}
},
"type": "object"
}
}
}
},
"403": {
"description": "You are not allowed to enable the MCP server.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "You are not allowed to enable the MCP server."
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/mcp\/disable": {
"post": {
"summary": "Disable MCP Server",
"description": "Disable the MCP server endpoint at \/mcp (only with root permissions).",
"operationId": "disable-mcp",
"responses": {
"200": {
"description": "MCP server disabled.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "MCP server disabled."
}
},
"type": "object"
}
}
}
},
"403": {
"description": "You are not allowed to disable the MCP server.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "You are not allowed to disable the MCP server."
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/health": {
"get": {
"summary": "Healthcheck",

View file

@ -5484,6 +5484,64 @@ paths:
security:
-
bearerAuth: []
/mcp/enable:
post:
summary: 'Enable MCP Server'
description: 'Enable the MCP server endpoint at /mcp (only with root permissions).'
operationId: enable-mcp
responses:
'200':
description: 'MCP server enabled.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'MCP server enabled.' }
type: object
'403':
description: 'You are not allowed to enable the MCP server.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'You are not allowed to enable the MCP server.' }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
security:
-
bearerAuth: []
/mcp/disable:
post:
summary: 'Disable MCP Server'
description: 'Disable the MCP server endpoint at /mcp (only with root permissions).'
operationId: disable-mcp
responses:
'200':
description: 'MCP server disabled.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'MCP server disabled.' }
type: object
'403':
description: 'You are not allowed to disable the MCP server.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'You are not allowed to disable the MCP server.' }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
security:
-
bearerAuth: []
/health:
get:
summary: Healthcheck

View file

@ -70,6 +70,17 @@ class="flex flex-col h-full gap-8 sm:flex-row">
environments!
</x-callout>
@endif
<h4 class="pt-4">MCP Server</h4>
<div class="md:w-96">
<x-forms.checkbox instantSave id="is_mcp_server_enabled" label="Enable MCP Server"
helper="Exposes a Streamable HTTP Model Context Protocol endpoint at /mcp for AI clients (Claude Desktop, Cursor, etc.). Authenticates via Sanctum API tokens (Security > API Tokens). Requires API Access to be enabled." />
</div>
@if ($is_mcp_server_enabled)
<x-callout type="info" title="MCP Endpoint" class="mt-2">
Endpoint: <code>{{ url('/mcp') }}</code><br>
Authenticate with <code>Authorization: Bearer &lt;token&gt;</code> using a token created in Security &raquo; API Tokens.
</x-callout>
@endif
<h4 class="pt-4">UI Settings</h4>
<div class="md:w-96">
<x-forms.checkbox instantSave id="is_wire_navigate_enabled" label="SPA Navigation"

7
routes/ai.php Normal file
View file

@ -0,0 +1,7 @@
<?php
use App\Mcp\Servers\CoolifyServer;
use Laravel\Mcp\Facades\Mcp;
Mcp::web('/mcp', CoolifyServer::class)
->middleware(['mcp.enabled', 'auth:sanctum']);

View file

@ -35,6 +35,8 @@
], function () {
Route::get('/enable', [OtherController::class, 'enable_api']);
Route::get('/disable', [OtherController::class, 'disable_api']);
Route::post('/mcp/enable', [OtherController::class, 'enable_mcp']);
Route::post('/mcp/disable', [OtherController::class, 'disable_mcp']);
});
Route::group([
'middleware' => ['auth:sanctum', ApiAllowed::class, 'api.sensitive'],

View file

@ -0,0 +1,194 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
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]);
});
function mcpPost(array $payload, ?string $token = null)
{
$headers = [
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
];
if ($token) {
$headers['Authorization'] = 'Bearer '.$token;
}
return test()->withHeaders($headers)->postJson('/mcp', $payload);
}
function mcpListTools(string $token)
{
return mcpPost([
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/list',
'params' => (object) [],
], $token);
}
function mcpCallTool(string $token, string $name, array $arguments = [])
{
return mcpPost([
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => $name,
'arguments' => (object) $arguments,
],
], $token);
}
function mcpToolJson($response): array
{
return json_decode($response->json('result.content.0.text'), true);
}
test('MCP endpoint returns 404 when the instance setting is disabled', function () {
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => false]);
Once::flush();
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
$response->assertStatus(404);
});
test('MCP endpoint rejects unauthenticated requests', function () {
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
$response->assertStatus(401);
});
test('MCP endpoint lists tools for an authenticated token', function () {
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpListTools($token);
$response->assertOk();
$toolNames = collect($response->json('result.tools'))->pluck('name')->all();
expect($toolNames)->toContain(
'get_infrastructure_overview',
'list_servers',
'get_server',
'list_projects',
'list_applications',
'get_application',
'list_databases',
'get_database',
'list_services',
'get_service',
);
expect($toolNames)->not->toContain('get_resource_status');
});
test('list_projects returns summary + pagination scoped to the token team', function () {
$project = Project::create(['name' => 'Mine', 'team_id' => $this->team->id]);
$otherTeam = Team::factory()->create();
Project::create(['name' => 'Theirs', 'team_id' => $otherTeam->id]);
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpCallTool($token, 'list_projects');
$response->assertOk();
$body = mcpToolJson($response);
expect($body)->toHaveKey('data');
expect($body)->toHaveKey('_pagination');
expect($body['_pagination']['total'])->toBe(1);
expect($body['_pagination']['per_page'])->toBe(50);
expect($body['_pagination'])->not->toHaveKey('next');
$uuids = collect($body['data'])->pluck('uuid')->all();
$names = collect($body['data'])->pluck('name')->all();
expect($uuids)->toContain($project->uuid);
expect($names)->not->toContain('Theirs');
expect($body['data'][0])->toHaveKeys(['uuid', 'name', 'description']);
});
test('list_projects paginates with per_page cap at 100', function () {
for ($i = 0; $i < 3; $i++) {
Project::create(['name' => "P{$i}", 'team_id' => $this->team->id]);
}
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpCallTool($token, 'list_projects', ['per_page' => 2, 'page' => 1]);
$body = mcpToolJson($response);
expect($body['_pagination']['total'])->toBe(3);
expect($body['_pagination']['total_pages'])->toBe(2);
expect($body['_pagination']['next']['args'])->toMatchArray(['page' => 2, 'per_page' => 2]);
expect($body['data'])->toHaveCount(2);
// Verify max cap
$capped = mcpCallTool($token, 'list_projects', ['per_page' => 500]);
$cappedBody = mcpToolJson($capped);
expect($cappedBody['_pagination']['per_page'])->toBe(100);
});
test('get_infrastructure_overview returns counts', function () {
Project::create(['name' => 'One', 'team_id' => $this->team->id]);
Project::create(['name' => 'Two', 'team_id' => $this->team->id]);
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpCallTool($token, 'get_infrastructure_overview');
$response->assertOk();
$body = mcpToolJson($response);
expect($body)->toHaveKey('data');
expect($body['data'])->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts']);
expect($body['data']['counts']['projects'])->toBe(2);
expect($body['data']['projects'])->toHaveCount(2);
expect($body['data']['projects'][0])->toHaveKey('counts');
});
test('get_server scrubs sensitive nested data and exposes connection_timeout', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
// creating hook auto-generates a sentinel_token; bump connection_timeout
// via saveQuietly to avoid triggering restartSentinel.
$server->settings->forceFill(['connection_timeout' => 42])->saveQuietly();
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpCallTool($token, 'get_server', ['uuid' => $server->uuid]);
$response->assertOk();
$body = mcpToolJson($response);
$raw = json_encode($body);
expect($raw)->not->toContain('sentinel_token');
expect($raw)->not->toContain('"team_id"');
expect($raw)->not->toContain('"private_key_id"');
expect($body['data']['connection_timeout'])->toBe(42);
expect($body['data']['uuid'])->toBe($server->uuid);
});
test('tool calls fail when the token lacks the read ability', function () {
$token = $this->user->createToken('mcp-no-abilities', [])->plainTextToken;
$response = mcpCallTool($token, 'list_projects');
$response->assertOk();
expect($response->json('result.isError'))->toBeTrue();
expect($response->json('result.content.0.text'))->toContain('Missing required permissions');
});

View file

@ -0,0 +1,107 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->delete();
$settings = new InstanceSettings([
'is_mcp_server_enabled' => false,
'is_api_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]);
});
function makeRootMcpToken(User $user): string
{
$token = $user->createToken('mcp-root', ['root']);
DB::table('personal_access_tokens')
->where('id', $token->accessToken->id)
->update(['team_id' => '0']);
return $token->plainTextToken;
}
function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write']): string
{
$token = $user->createToken('mcp-write', $abilities);
DB::table('personal_access_tokens')
->where('id', $token->accessToken->id)
->update(['team_id' => (string) $team->id]);
return $token->plainTextToken;
}
test('POST /api/v1/mcp/enable enables MCP server with root token', function () {
$token = makeRootMcpToken($this->user);
$response = test()->withHeaders([
'Authorization' => 'Bearer '.$token,
])->postJson('/api/v1/mcp/enable');
$response->assertOk();
$response->assertJson(['message' => 'MCP server enabled.']);
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue();
});
test('POST /api/v1/mcp/disable disables MCP server with root token', function () {
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]);
$token = makeRootMcpToken($this->user);
$response = test()->withHeaders([
'Authorization' => 'Bearer '.$token,
])->postJson('/api/v1/mcp/disable');
$response->assertOk();
$response->assertJson(['message' => 'MCP server disabled.']);
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse();
});
test('non-root token cannot enable MCP server', function () {
$token = makeNonRootMcpToken($this->user, $this->team);
$response = test()->withHeaders([
'Authorization' => 'Bearer '.$token,
])->postJson('/api/v1/mcp/enable');
$response->assertStatus(403);
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse();
});
test('non-root token cannot disable MCP server', function () {
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]);
$token = makeNonRootMcpToken($this->user, $this->team);
$response = test()->withHeaders([
'Authorization' => 'Bearer '.$token,
])->postJson('/api/v1/mcp/disable');
$response->assertStatus(403);
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue();
});
test('unauthenticated request to /api/v1/mcp/enable returns 401', function () {
$response = test()->postJson('/api/v1/mcp/enable');
$response->assertStatus(401);
});
test('read-only token cannot toggle MCP server (lacks write ability)', function () {
$token = makeNonRootMcpToken($this->user, $this->team, ['read']);
$response = test()->withHeaders([
'Authorization' => 'Bearer '.$token,
])->postJson('/api/v1/mcp/enable');
$response->assertStatus(403);
});

View file

@ -0,0 +1,70 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->teamA = Team::factory()->create();
$this->teamB = Team::factory()->create();
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->first();
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->envA = Environment::factory()->create(['project_id' => $this->projectA->id]);
});
test('queryDatabaseByUuidWithinTeam returns database when team owns it', function () {
$database = StandalonePostgresql::create([
'name' => 'pg-team-a',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->envA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => $this->destinationA->getMorphClass(),
]);
$found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamA->id);
expect($found)->not->toBeNull();
expect($found->uuid)->toBe($database->uuid);
expect($found)->toBeInstanceOf(StandalonePostgresql::class);
});
test('queryDatabaseByUuidWithinTeam returns null when team does not own the database', function () {
$database = StandalonePostgresql::create([
'name' => 'pg-team-a',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->envA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => $this->destinationA->getMorphClass(),
]);
$found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamB->id);
expect($found)->toBeNull();
});
test('queryDatabaseByUuidWithinTeam returns null for unknown uuid', function () {
$found = queryDatabaseByUuidWithinTeam('does-not-exist', (string) $this->teamA->id);
expect($found)->toBeNull();
});
test('queryDatabaseByUuidWithinTeam can query every registered standalone database type without error', function () {
foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) {
$count = $modelClass::query()->whereUuid('non-existent-uuid')->count();
expect($count)->toBe(0, "{$modelClass} ({$slug}) failed whereUuid() smoke query");
}
});

View file

@ -0,0 +1,71 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function attachDb(string $modelClass, array $extra, $destination, $environment)
{
return $modelClass::create(array_merge([
'name' => 'test-'.strtolower(class_basename($modelClass)),
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
], $extra));
}
test('StandaloneDocker::databases() includes attached keydb', function () {
attachDb(StandaloneKeydb::class, ['keydb_password' => 'pw'], $this->destination, $this->environment);
expect($this->destination->databases()->count())->toBe(1);
expect($this->destination->attachedTo())->toBeTrue();
});
test('StandaloneDocker::databases() includes attached dragonfly', function () {
attachDb(StandaloneDragonfly::class, ['dragonfly_password' => 'pw'], $this->destination, $this->environment);
expect($this->destination->databases()->count())->toBe(1);
expect($this->destination->attachedTo())->toBeTrue();
});
test('StandaloneDocker::databases() includes attached clickhouse', function () {
attachDb(StandaloneClickhouse::class, ['clickhouse_admin_password' => 'pw'], $this->destination, $this->environment);
expect($this->destination->databases()->count())->toBe(1);
expect($this->destination->attachedTo())->toBeTrue();
});
test('StandaloneDocker::databases() includes all 8 standalone database types', function () {
attachDb(StandalonePostgresql::class, ['postgres_password' => 'pw'], $this->destination, $this->environment);
attachDb(StandaloneRedis::class, ['redis_password' => 'pw'], $this->destination, $this->environment);
attachDb(StandaloneMongodb::class, ['mongo_initdb_root_password' => 'pw'], $this->destination, $this->environment);
attachDb(StandaloneMysql::class, ['mysql_root_password' => 'pw', 'mysql_password' => 'pw'], $this->destination, $this->environment);
attachDb(StandaloneMariadb::class, ['mariadb_root_password' => 'pw', 'mariadb_password' => 'pw'], $this->destination, $this->environment);
attachDb(StandaloneKeydb::class, ['keydb_password' => 'pw'], $this->destination, $this->environment);
attachDb(StandaloneDragonfly::class, ['dragonfly_password' => 'pw'], $this->destination, $this->environment);
attachDb(StandaloneClickhouse::class, ['clickhouse_admin_password' => 'pw'], $this->destination, $this->environment);
expect($this->destination->databases()->count())->toBe(8);
expect($this->destination->attachedTo())->toBeTrue();
});

View file

@ -0,0 +1,45 @@
<?php
use App\Models\StandaloneDocker;
use Illuminate\Database\Eloquent\Model;
/**
* Guards STANDALONE_DATABASE_MODELS against drift.
*
* MCP and API endpoints rely on this registry for team-scoped UUID lookups.
* If a new App\Models\Standalone* model lands without a registry entry, the
* helpers in bootstrap/helpers/shared.php silently fail to resolve it.
*/
test('STANDALONE_DATABASE_MODELS contains every Standalone* model on disk', function () {
$files = glob(dirname(__DIR__, 2).'/app/Models/Standalone*.php');
expect($files)->not->toBeEmpty();
$onDisk = collect($files)
->map(fn (string $path) => 'App\\Models\\'.basename($path, '.php'))
->reject(fn (string $class) => $class === StandaloneDocker::class)
->sort()
->values()
->all();
$registered = collect(STANDALONE_DATABASE_MODELS)->values()->sort()->values()->all();
expect($registered)->toBe(
$onDisk,
'STANDALONE_DATABASE_MODELS in bootstrap/helpers/constants.php is out of sync with the App\\Models\\Standalone* classes on disk. '
.'Add the missing model(s) to the registry (and to DATABASE_TYPES) so MCP/API helpers can resolve them.'
);
});
test('STANDALONE_DATABASE_MODELS keys mirror DATABASE_TYPES', function () {
expect(array_keys(STANDALONE_DATABASE_MODELS))->toEqualCanonicalizing(DATABASE_TYPES);
});
test('every STANDALONE_DATABASE_MODELS entry is an Eloquent model with whereUuid scope', function () {
foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) {
expect(class_exists($modelClass))->toBeTrue("{$slug} maps to non-existent class {$modelClass}");
expect(is_subclass_of($modelClass, Model::class))
->toBeTrue("{$modelClass} is not an Eloquent model");
expect(method_exists($modelClass, 'team'))
->toBeTrue("{$modelClass} is missing team() accessor required by queryDatabaseByUuidWithinTeam()");
}
});