feat(mcp): add MCP server with read-only tools for Coolify resources
Add Model Context Protocol server exposing Coolify infrastructure data to AI assistants. Includes tools for listing/fetching servers, projects, applications, databases, and services, scoped to authenticated team tokens. - Add CoolifyServer with 10 read-only tools (list/get for all resource types) - Add BuildsResponse and ResolvesTeam traits for shared tool logic - Add EnsureMcpEnabled middleware guarding /mcp routes - Add enable/disable MCP API endpoints (root-only) - Add is_mcp_server_enabled toggle in instance settings and advanced UI - Add migration for is_mcp_server_enabled column - Add feature tests for MCP endpoints and toggle API - Scrub sensitive keys (passwords, tokens, raw IDs) from all responses
This commit is contained in:
parent
f8755261ba
commit
7ab16ad7b5
28 changed files with 1783 additions and 109 deletions
|
|
@ -153,6 +153,118 @@ public function disable_api(Request $request)
|
|||
return response()->json(['message' => 'API disabled.'], 200);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Enable MCP Server',
|
||||
description: 'Enable the MCP server endpoint at /mcp (only with root permissions).',
|
||||
path: '/mcp/enable',
|
||||
operationId: 'enable-mcp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'MCP server enabled.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'MCP server enabled.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'You are not allowed to enable the MCP server.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the MCP server.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function enable_mcp(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_mcp_server_enabled' => true]);
|
||||
|
||||
auditLog('api.mcp.enabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'MCP server enabled.'], 200);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Disable MCP Server',
|
||||
description: 'Disable the MCP server endpoint at /mcp (only with root permissions).',
|
||||
path: '/mcp/disable',
|
||||
operationId: 'disable-mcp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'MCP server disabled.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'MCP server disabled.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'You are not allowed to disable the MCP server.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to disable the MCP server.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function disable_mcp(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_mcp_server_enabled' => false]);
|
||||
|
||||
auditLog('api.mcp.disabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'MCP server disabled.'], 200);
|
||||
}
|
||||
|
||||
public function feedback(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
74
app/Mcp/Tools/ListApplications.php
Normal file
74
app/Mcp/Tools/ListApplications.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Mcp\Concerns\BuildsResponse;
|
||||
use App\Mcp\Concerns\ResolvesTeam;
|
||||
use App\Models\Application;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Name('list_applications')]
|
||||
#[Description('List applications owned by the authenticated team. Returns summary (uuid, name, status, fqdn, git_repository). Optional "tag" argument filters by tag name. Use get_application for full details.')]
|
||||
class ListApplications extends Tool
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ResolvesTeam;
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
if ($error = $this->ensureAbility($request, 'read')) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$teamId = $this->resolveTeamId($request);
|
||||
if (is_null($teamId)) {
|
||||
return Response::error('Invalid token.');
|
||||
}
|
||||
|
||||
$tagName = $request->get('tag');
|
||||
$args = $this->paginationArgs($request);
|
||||
|
||||
$query = Application::ownedByCurrentTeamAPI($teamId)
|
||||
->when($tagName, function ($query, $tagName) {
|
||||
$query->whereHas('tags', fn ($q) => $q->where('name', $tagName));
|
||||
});
|
||||
|
||||
$total = (clone $query)->count();
|
||||
|
||||
$summaries = $query
|
||||
->skip($args['offset'])
|
||||
->take($args['per_page'])
|
||||
->get()
|
||||
->map(fn ($app) => [
|
||||
'uuid' => $app->uuid,
|
||||
'name' => $app->name,
|
||||
'status' => $app->status,
|
||||
'fqdn' => $app->fqdn,
|
||||
'git_repository' => $app->git_repository,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$extra = $tagName ? ['tag' => $tagName] : [];
|
||||
|
||||
return $this->respond(
|
||||
$summaries,
|
||||
[],
|
||||
$this->paginationMeta('list_applications', $args, $total, $extra),
|
||||
);
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'tag' => $schema->string()->description('Optional tag name filter.'),
|
||||
'page' => $schema->integer()->description('Page number (default 1).'),
|
||||
'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
69
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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
68
app/Mcp/Tools/ListServices.php
Normal file
68
app/Mcp/Tools/ListServices.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Mcp\Concerns\BuildsResponse;
|
||||
use App\Mcp\Concerns\ResolvesTeam;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Name('list_services')]
|
||||
#[Description('List services (multi-container stacks) owned by the authenticated team. Returns summary (uuid, name, status). Use get_service for full details.')]
|
||||
class ListServices extends Tool
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ResolvesTeam;
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
if ($error = $this->ensureAbility($request, 'read')) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$teamId = $this->resolveTeamId($request);
|
||||
if (is_null($teamId)) {
|
||||
return Response::error('Invalid token.');
|
||||
}
|
||||
|
||||
$args = $this->paginationArgs($request);
|
||||
|
||||
$projects = Project::where('team_id', $teamId)->get();
|
||||
$services = collect();
|
||||
foreach ($projects as $project) {
|
||||
$services = $services->merge($project->services()->get());
|
||||
}
|
||||
|
||||
$total = $services->count();
|
||||
|
||||
$summaries = $services
|
||||
->sortBy('name')
|
||||
->slice($args['offset'], $args['per_page'])
|
||||
->map(fn ($svc) => [
|
||||
'uuid' => $svc->uuid,
|
||||
'name' => $svc->name,
|
||||
'status' => $svc->status ?? null,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $this->respond(
|
||||
$summaries,
|
||||
[],
|
||||
$this->paginationMeta('list_services', $args, $total),
|
||||
);
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'page' => $schema->integer()->description('Page number (default 1).'),
|
||||
'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
"get": {
|
||||
"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": {
|
||||
"get": {
|
||||
"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:
|
||||
get:
|
||||
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:
|
||||
get:
|
||||
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::get('/mcp/enable', [OtherController::class, 'enable_mcp']);
|
||||
Route::get('/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('GET /api/v1/mcp/enable enables MCP server with root token', function () {
|
||||
$token = makeRootMcpToken($this->user);
|
||||
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->getJson('/api/v1/mcp/enable');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['message' => 'MCP server enabled.']);
|
||||
expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('GET /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,
|
||||
])->getJson('/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,
|
||||
])->getJson('/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,
|
||||
])->getJson('/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()->getJson('/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,
|
||||
])->getJson('/api/v1/mcp/enable');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
Loading…
Reference in a new issue