mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 10:14:47 +00:00
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:
1 parent
f8755261ba
commit
7ab16ad7b5
28 files changed
+1783
-109
No files matched your search
@@ -153,6 +153,118 @@ class OtherController extends Controller
|
||||
return response()->json(['message' => 'API disabled.'], 200);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Enable MCP Server',
|
||||
description: 'Enable the MCP server endpoint at /mcp (only with root permissions).',
|
||||
path: '/mcp/enable',
|
||||
operationId: 'enable-mcp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'MCP server enabled.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'MCP server enabled.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'You are not allowed to enable the MCP server.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the MCP server.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function enable_mcp(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_mcp_server_enabled' => true]);
|
||||
|
||||
auditLog('api.mcp.enabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'MCP server enabled.'], 200);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Disable MCP Server',
|
||||
description: 'Disable the MCP server endpoint at /mcp (only with root permissions).',
|
||||
path: '/mcp/disable',
|
||||
operationId: 'disable-mcp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'MCP server disabled.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'MCP server disabled.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'You are not allowed to disable the MCP server.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to disable the MCP server.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function disable_mcp(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_mcp_server_enabled' => false]);
|
||||
|
||||
auditLog('api.mcp.disabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'MCP server disabled.'], 200);
|
||||
}
|
||||
|
||||
public function feedback(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
|
||||
+68
-34
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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 @@ class Advanced extends Component
|
||||
'is_sponsorship_popup_enabled' => 'boolean',
|
||||
'disable_two_step_confirmation' => 'boolean',
|
||||
'is_wire_navigate_enabled' => 'boolean',
|
||||
'is_mcp_server_enabled' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -67,6 +71,7 @@ class Advanced extends Component
|
||||
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
|
||||
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
|
||||
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
|
||||
$this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
|
||||
}
|
||||
|
||||
public function submit()
|
||||
@@ -150,6 +155,7 @@ class Advanced extends Component
|
||||
$this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled;
|
||||
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
|
||||
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
|
||||
$this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled;
|
||||
$this->settings->save();
|
||||
$this->dispatch('success', 'Settings updated!');
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Mcp\Concerns\BuildsResponse;
|
||||
use App\Mcp\Concerns\ResolvesTeam;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Name('get_database')]
|
||||
#[Description('Get full details for a standalone database by UUID. Detects type across postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse.')]
|
||||
class GetDatabase extends Tool
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ResolvesTeam;
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
if ($error = $this->ensureAbility($request, 'read')) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$teamId = $this->resolveTeamId($request);
|
||||
if (is_null($teamId)) {
|
||||
return Response::error('Invalid token.');
|
||||
}
|
||||
|
||||
$uuid = $request->get('uuid');
|
||||
if (! is_string($uuid) || $uuid === '') {
|
||||
return Response::error('uuid argument is required.');
|
||||
}
|
||||
|
||||
$database = queryDatabaseByUuidWithinTeam($uuid, (string) $teamId);
|
||||
if (! $database) {
|
||||
return Response::error("Database [{$uuid}] not found.");
|
||||
}
|
||||
|
||||
// Drop relations so deep server/destination data doesn't leak.
|
||||
$database->setRelations([]);
|
||||
$database->makeHidden(['destination', 'source', 'environment', 'environment_variables', 'environment_variables_preview']);
|
||||
|
||||
return $this->respond(
|
||||
$this->scrubSensitive($database->toArray()),
|
||||
$this->actionsForDatabase($uuid, $database->status ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $schema->string()->description('Database UUID.')->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Mcp\Concerns\BuildsResponse;
|
||||
use App\Mcp\Concerns\ResolvesTeam;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Name('get_infrastructure_overview')]
|
||||
#[Description('High-level overview of the authenticated team: Coolify version, all servers, projects with resource counts, and aggregate counts. Start here to understand the setup.')]
|
||||
class GetInfrastructureOverview extends Tool
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ResolvesTeam;
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
if ($error = $this->ensureAbility($request, 'read')) {
|
||||
return $error;
|
||||
}
|
||||
|
||||
$teamId = $this->resolveTeamId($request);
|
||||
if (is_null($teamId)) {
|
||||
return Response::error('Invalid token.');
|
||||
}
|
||||
|
||||
$servers = Server::whereTeamId($teamId)
|
||||
->select('id', 'name', 'uuid', 'ip', 'description')
|
||||
->with('settings:id,server_id,is_reachable,is_usable')
|
||||
->get()
|
||||
->map(fn ($s) => [
|
||||
'uuid' => $s->uuid,
|
||||
'name' => $s->name,
|
||||
'ip' => $s->ip,
|
||||
'is_reachable' => $s->settings?->is_reachable,
|
||||
'is_usable' => $s->settings?->is_usable,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$projects = Project::where('team_id', $teamId)->get();
|
||||
|
||||
$appCount = 0;
|
||||
$serviceCount = 0;
|
||||
$databaseCount = 0;
|
||||
$projectSummaries = [];
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$apps = $project->applications()->count();
|
||||
$services = $project->services()->count();
|
||||
$databases = $project->databases()->count();
|
||||
|
||||
$appCount += $apps;
|
||||
$serviceCount += $services;
|
||||
$databaseCount += $databases;
|
||||
|
||||
$projectSummaries[] = [
|
||||
'uuid' => $project->uuid,
|
||||
'name' => $project->name,
|
||||
'counts' => [
|
||||
'applications' => $apps,
|
||||
'services' => $services,
|
||||
'databases' => $databases,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->respond([
|
||||
'coolify_version' => config('constants.coolify.version'),
|
||||
'servers' => $servers,
|
||||
'projects' => $projectSummaries,
|
||||
'counts' => [
|
||||
'servers' => count($servers),
|
||||
'projects' => count($projectSummaries),
|
||||
'applications' => $appCount,
|
||||
'services' => $serviceCount,
|
||||
'databases' => $databaseCount,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user