feat(mcp): add instance-level MCP server toggle via API and UI (#9862)
This commit is contained in:
commit
7e5e046563
34 changed files with 2004 additions and 174 deletions
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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
app/Http/Middleware/EnsureMcpEnabled.php
Normal file
25
app/Http/Middleware/EnsureMcpEnabled.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
225
app/Mcp/Concerns/BuildsResponse.php
Normal file
225
app/Mcp/Concerns/BuildsResponse.php
Normal 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
app/Mcp/Concerns/ResolvesTeam.php
Normal file
35
app/Mcp/Concerns/ResolvesTeam.php
Normal 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
app/Mcp/Servers/CoolifyServer.php
Normal file
50
app/Mcp/Servers/CoolifyServer.php
Normal 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
app/Mcp/Tools/GetApplication.php
Normal file
60
app/Mcp/Tools/GetApplication.php
Normal 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
app/Mcp/Tools/GetDatabase.php
Normal file
58
app/Mcp/Tools/GetDatabase.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
93
app/Mcp/Tools/GetInfrastructureOverview.php
Normal file
93
app/Mcp/Tools/GetInfrastructureOverview.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Mcp\Concerns\BuildsResponse;
|
||||
use App\Mcp\Concerns\ResolvesTeam;
|
||||
use App\Models\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
app/Mcp/Tools/GetServer.php
Normal file
57
app/Mcp/Tools/GetServer.php
Normal 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
app/Mcp/Tools/GetService.php
Normal file
61
app/Mcp/Tools/GetService.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
77
app/Mcp/Tools/ListApplications.php
Normal file
77
app/Mcp/Tools/ListApplications.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Mcp\Concerns\BuildsResponse;
|
||||
use App\Mcp\Concerns\ResolvesTeam;
|
||||
use App\Models\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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
69
app/Mcp/Tools/ListDatabases.php
Normal file
69
app/Mcp/Tools/ListDatabases.php
Normal 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
app/Mcp/Tools/ListProjects.php
Normal file
66
app/Mcp/Tools/ListProjects.php
Normal 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
app/Mcp/Tools/ListServers.php
Normal file
67
app/Mcp/Tools/ListServers.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Mcp\Concerns\BuildsResponse;
|
||||
use App\Mcp\Concerns\ResolvesTeam;
|
||||
use App\Models\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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
66
app/Mcp/Tools/ListServices.php
Normal file
66
app/Mcp/Tools/ListServices.php
Normal 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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 * * * *',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
150
composer.lock
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
104
openapi.json
104
openapi.json
|
|
@ -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",
|
||||
|
|
|
|||
58
openapi.yaml
58
openapi.yaml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <token></code> using a token created in Security » 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
7
routes/ai.php
Normal 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']);
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
194
tests/Feature/Mcp/McpEndpointTest.php
Normal file
194
tests/Feature/Mcp/McpEndpointTest.php
Normal 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');
|
||||
});
|
||||
107
tests/Feature/Mcp/McpToggleApiTest.php
Normal file
107
tests/Feature/Mcp/McpToggleApiTest.php
Normal 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);
|
||||
});
|
||||
70
tests/Feature/QueryDatabaseByUuidWithinTeamTest.php
Normal file
70
tests/Feature/QueryDatabaseByUuidWithinTeamTest.php
Normal 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");
|
||||
}
|
||||
});
|
||||
71
tests/Feature/StandaloneDockerDatabasesTest.php
Normal file
71
tests/Feature/StandaloneDockerDatabasesTest.php
Normal 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();
|
||||
});
|
||||
45
tests/Unit/StandaloneDatabaseRegistryTest.php
Normal file
45
tests/Unit/StandaloneDatabaseRegistryTest.php
Normal 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()");
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue