From 7ab16ad7b5e34e0da37f91f4be4c7396bc97264a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:30:43 +0200 Subject: [PATCH 1/5] 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 --- app/Http/Controllers/Api/OtherController.php | 112 +++++++++ app/Http/Kernel.php | 102 +++++--- app/Http/Middleware/EnsureMcpEnabled.php | 25 ++ app/Livewire/Settings/Advanced.php | 6 + app/Mcp/Concerns/BuildsResponse.php | 225 ++++++++++++++++++ app/Mcp/Concerns/ResolvesTeam.php | 35 +++ app/Mcp/Servers/CoolifyServer.php | 50 ++++ app/Mcp/Tools/GetApplication.php | 60 +++++ app/Mcp/Tools/GetDatabase.php | 58 +++++ app/Mcp/Tools/GetInfrastructureOverview.php | 93 ++++++++ app/Mcp/Tools/GetServer.php | 57 +++++ app/Mcp/Tools/GetService.php | 61 +++++ app/Mcp/Tools/ListApplications.php | 74 ++++++ app/Mcp/Tools/ListDatabases.php | 69 ++++++ app/Mcp/Tools/ListProjects.php | 66 +++++ app/Mcp/Tools/ListServers.php | 67 ++++++ app/Mcp/Tools/ListServices.php | 68 ++++++ app/Models/InstanceSettings.php | 2 + composer.json | 1 + composer.lock | 150 ++++++------ ...ver_enabled_to_instance_settings_table.php | 28 +++ openapi.json | 104 ++++++++ openapi.yaml | 58 +++++ .../livewire/settings/advanced.blade.php | 11 + routes/ai.php | 7 + routes/api.php | 2 + tests/Feature/Mcp/McpEndpointTest.php | 194 +++++++++++++++ tests/Feature/Mcp/McpToggleApiTest.php | 107 +++++++++ 28 files changed, 1783 insertions(+), 109 deletions(-) create mode 100644 app/Http/Middleware/EnsureMcpEnabled.php create mode 100644 app/Mcp/Concerns/BuildsResponse.php create mode 100644 app/Mcp/Concerns/ResolvesTeam.php create mode 100644 app/Mcp/Servers/CoolifyServer.php create mode 100644 app/Mcp/Tools/GetApplication.php create mode 100644 app/Mcp/Tools/GetDatabase.php create mode 100644 app/Mcp/Tools/GetInfrastructureOverview.php create mode 100644 app/Mcp/Tools/GetServer.php create mode 100644 app/Mcp/Tools/GetService.php create mode 100644 app/Mcp/Tools/ListApplications.php create mode 100644 app/Mcp/Tools/ListDatabases.php create mode 100644 app/Mcp/Tools/ListProjects.php create mode 100644 app/Mcp/Tools/ListServers.php create mode 100644 app/Mcp/Tools/ListServices.php create mode 100644 database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php create mode 100644 routes/ai.php create mode 100644 tests/Feature/Mcp/McpEndpointTest.php create mode 100644 tests/Feature/Mcp/McpToggleApiTest.php diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 5ac274f93..17c5a884a 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -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([ diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 515d40c62..a584bc111 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -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 */ 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 */ 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, ]; } diff --git a/app/Http/Middleware/EnsureMcpEnabled.php b/app/Http/Middleware/EnsureMcpEnabled.php new file mode 100644 index 000000000..9c4f1339c --- /dev/null +++ b/app/Http/Middleware/EnsureMcpEnabled.php @@ -0,0 +1,25 @@ +is_mcp_server_enabled) { + abort(404); + } + + return $next($request); + } +} diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index d31f68859..3a6237183 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -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) { diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php new file mode 100644 index 000000000..10d87ae92 --- /dev/null +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -0,0 +1,225 @@ + + */ + 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 $data + * @return array + */ + 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|array $data + * @param array> $actions + * @param array|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|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> + */ + 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> + */ + 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> + */ + 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> + */ + protected function actionsForServer(string $uuid): array + { + return [ + ['tool' => 'get_server', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'], + ]; + } +} diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php new file mode 100644 index 000000000..f75219fcf --- /dev/null +++ b/app/Mcp/Concerns/ResolvesTeam.php @@ -0,0 +1,35 @@ +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; + } +} diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php new file mode 100644 index 000000000..aff7e3f76 --- /dev/null +++ b/app/Mcp/Servers/CoolifyServer.php @@ -0,0 +1,50 @@ +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(), + ]; + } +} diff --git a/app/Mcp/Tools/GetDatabase.php b/app/Mcp/Tools/GetDatabase.php new file mode 100644 index 000000000..4eee9c961 --- /dev/null +++ b/app/Mcp/Tools/GetDatabase.php @@ -0,0 +1,58 @@ +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(), + ]; + } +} diff --git a/app/Mcp/Tools/GetInfrastructureOverview.php b/app/Mcp/Tools/GetInfrastructureOverview.php new file mode 100644 index 000000000..06e91ff57 --- /dev/null +++ b/app/Mcp/Tools/GetInfrastructureOverview.php @@ -0,0 +1,93 @@ +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 []; + } +} diff --git a/app/Mcp/Tools/GetServer.php b/app/Mcp/Tools/GetServer.php new file mode 100644 index 000000000..fc3e72f14 --- /dev/null +++ b/app/Mcp/Tools/GetServer.php @@ -0,0 +1,57 @@ +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(), + ]; + } +} diff --git a/app/Mcp/Tools/GetService.php b/app/Mcp/Tools/GetService.php new file mode 100644 index 000000000..475958272 --- /dev/null +++ b/app/Mcp/Tools/GetService.php @@ -0,0 +1,61 @@ +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(), + ]; + } +} diff --git a/app/Mcp/Tools/ListApplications.php b/app/Mcp/Tools/ListApplications.php new file mode 100644 index 000000000..947d49ed1 --- /dev/null +++ b/app/Mcp/Tools/ListApplications.php @@ -0,0 +1,74 @@ +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).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListDatabases.php b/app/Mcp/Tools/ListDatabases.php new file mode 100644 index 000000000..7eb1fde00 --- /dev/null +++ b/app/Mcp/Tools/ListDatabases.php @@ -0,0 +1,69 @@ +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).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListProjects.php b/app/Mcp/Tools/ListProjects.php new file mode 100644 index 000000000..9ce1576b9 --- /dev/null +++ b/app/Mcp/Tools/ListProjects.php @@ -0,0 +1,66 @@ +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).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListServers.php b/app/Mcp/Tools/ListServers.php new file mode 100644 index 000000000..20250c454 --- /dev/null +++ b/app/Mcp/Tools/ListServers.php @@ -0,0 +1,67 @@ +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).'), + ]; + } +} diff --git a/app/Mcp/Tools/ListServices.php b/app/Mcp/Tools/ListServices.php new file mode 100644 index 000000000..4b33231ba --- /dev/null +++ b/app/Mcp/Tools/ListServices.php @@ -0,0 +1,68 @@ +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).'), + ]; + } +} diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 6061bc863..d5c3bfa28 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -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 diff --git a/composer.json b/composer.json index e2b16b31b..9415aa624 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 2f27235f5..36715d2c5 100644 --- a/composer.lock +++ b/composer.lock @@ -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" } diff --git a/database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php b/database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php new file mode 100644 index 000000000..f24548142 --- /dev/null +++ b/database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/openapi.json b/openapi.json index 059b3d911..c35c0cdd6 100644 --- a/openapi.json +++ b/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", diff --git a/openapi.yaml b/openapi.yaml index 83aa30744..eab531674 100644 --- a/openapi.yaml +++ b/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 diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 6c26b453d..544ed7d4c 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -70,6 +70,17 @@ class="flex flex-col h-full gap-8 sm:flex-row"> environments! @endif +

MCP Server

+
+ +
+ @if ($is_mcp_server_enabled) + + Endpoint: {{ url('/mcp') }}
+ Authenticate with Authorization: Bearer <token> using a token created in Security » API Tokens. +
+ @endif

UI Settings

middleware(['mcp.enabled', 'auth:sanctum']); diff --git a/routes/api.php b/routes/api.php index c944a6ebc..f38576d4d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'], diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php new file mode 100644 index 000000000..34ae493cc --- /dev/null +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -0,0 +1,194 @@ +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'); +}); diff --git a/tests/Feature/Mcp/McpToggleApiTest.php b/tests/Feature/Mcp/McpToggleApiTest.php new file mode 100644 index 000000000..2700f9b7b --- /dev/null +++ b/tests/Feature/Mcp/McpToggleApiTest.php @@ -0,0 +1,107 @@ +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); +}); From 79174b749d8e59bf8cc5d3828ecc7ae051837705 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:48:48 +0200 Subject: [PATCH 2/5] refactor(helpers): extract STANDALONE_DATABASE_MODELS registry, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 8× repeated per-type if-blocks in `queryDatabaseByUuidWithinTeam` and `queryResourcesByUuid` with a single loop over the new `STANDALONE_DATABASE_MODELS` constant. Add unit tests to guard the registry against drift (keys mirror `DATABASE_TYPES`, every entry is a valid Eloquent model with `team()`), and feature tests covering team-ownership, wrong-team, and unknown-UUID cases for `queryDatabaseByUuidWithinTeam`. --- bootstrap/helpers/constants.php | 19 +++++ bootstrap/helpers/shared.php | 75 +++---------------- .../QueryDatabaseByUuidWithinTeamTest.php | 70 +++++++++++++++++ tests/Unit/StandaloneDatabaseRegistryTest.php | 45 +++++++++++ 4 files changed, 145 insertions(+), 64 deletions(-) create mode 100644 tests/Feature/QueryDatabaseByUuidWithinTeamTest.php create mode 100644 tests/Unit/StandaloneDatabaseRegistryTest.php diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index bae2573de..043a3346d 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -1,7 +1,26 @@ '; const DATABASE_TYPES = ['postgresql', 'redis', 'mongodb', 'mysql', 'mariadb', 'keydb', 'dragonfly', 'clickhouse']; +const STANDALONE_DATABASE_MODELS = [ + 'postgresql' => StandalonePostgresql::class, + 'redis' => StandaloneRedis::class, + 'mongodb' => StandaloneMongodb::class, + 'mysql' => StandaloneMysql::class, + 'mariadb' => StandaloneMariadb::class, + 'keydb' => StandaloneKeydb::class, + 'dragonfly' => StandaloneDragonfly::class, + 'clickhouse' => StandaloneClickhouse::class, +]; const VALID_CRON_STRINGS = [ 'every_minute' => '* * * * *', 'hourly' => '0 * * * *', diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 9f0f2cd73..f76995c6f 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1058,44 +1058,17 @@ function getResourceByUuid(string $uuid, ?int $teamId = null) } function queryDatabaseByUuidWithinTeam(string $uuid, string $teamId) { - $postgresql = StandalonePostgresql::whereUuid($uuid)->first(); - if ($postgresql && $postgresql->team()->id == $teamId) { - return $postgresql->unsetRelation('environment'); - } - $redis = StandaloneRedis::whereUuid($uuid)->first(); - if ($redis && $redis->team()->id == $teamId) { - return $redis->unsetRelation('environment'); - } - $mongodb = StandaloneMongodb::whereUuid($uuid)->first(); - if ($mongodb && $mongodb->team()->id == $teamId) { - return $mongodb->unsetRelation('environment'); - } - $mysql = StandaloneMysql::whereUuid($uuid)->first(); - if ($mysql && $mysql->team()->id == $teamId) { - return $mysql->unsetRelation('environment'); - } - $mariadb = StandaloneMariadb::whereUuid($uuid)->first(); - if ($mariadb && $mariadb->team()->id == $teamId) { - return $mariadb->unsetRelation('environment'); - } - $keydb = StandaloneKeydb::whereUuid($uuid)->first(); - if ($keydb && $keydb->team()->id == $teamId) { - return $keydb->unsetRelation('environment'); - } - $dragonfly = StandaloneDragonfly::whereUuid($uuid)->first(); - if ($dragonfly && $dragonfly->team()->id == $teamId) { - return $dragonfly->unsetRelation('environment'); - } - $clickhouse = StandaloneClickhouse::whereUuid($uuid)->first(); - if ($clickhouse && $clickhouse->team()->id == $teamId) { - return $clickhouse->unsetRelation('environment'); + foreach (STANDALONE_DATABASE_MODELS as $modelClass) { + $database = $modelClass::whereUuid($uuid)->first(); + if ($database && $database->team()->id == $teamId) { + return $database->unsetRelation('environment'); + } } return null; } function queryResourcesByUuid(string $uuid) { - $resource = null; $application = Application::whereUuid($uuid)->first(); if ($application) { return $application; @@ -1104,37 +1077,11 @@ function queryResourcesByUuid(string $uuid) if ($service) { return $service; } - $postgresql = StandalonePostgresql::whereUuid($uuid)->first(); - if ($postgresql) { - return $postgresql; - } - $redis = StandaloneRedis::whereUuid($uuid)->first(); - if ($redis) { - return $redis; - } - $mongodb = StandaloneMongodb::whereUuid($uuid)->first(); - if ($mongodb) { - return $mongodb; - } - $mysql = StandaloneMysql::whereUuid($uuid)->first(); - if ($mysql) { - return $mysql; - } - $mariadb = StandaloneMariadb::whereUuid($uuid)->first(); - if ($mariadb) { - return $mariadb; - } - $keydb = StandaloneKeydb::whereUuid($uuid)->first(); - if ($keydb) { - return $keydb; - } - $dragonfly = StandaloneDragonfly::whereUuid($uuid)->first(); - if ($dragonfly) { - return $dragonfly; - } - $clickhouse = StandaloneClickhouse::whereUuid($uuid)->first(); - if ($clickhouse) { - return $clickhouse; + foreach (STANDALONE_DATABASE_MODELS as $modelClass) { + $database = $modelClass::whereUuid($uuid)->first(); + if ($database) { + return $database; + } } // Check for ServiceDatabase by its own UUID @@ -1143,7 +1090,7 @@ function queryResourcesByUuid(string $uuid) return $serviceDatabase; } - return $resource; + return null; } function generateTagDeployWebhook($tag_name) { diff --git a/tests/Feature/QueryDatabaseByUuidWithinTeamTest.php b/tests/Feature/QueryDatabaseByUuidWithinTeamTest.php new file mode 100644 index 000000000..db7eb16b2 --- /dev/null +++ b/tests/Feature/QueryDatabaseByUuidWithinTeamTest.php @@ -0,0 +1,70 @@ +teamA = Team::factory()->create(); + $this->teamB = Team::factory()->create(); + + $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]); + $this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->first(); + $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]); + $this->envA = Environment::factory()->create(['project_id' => $this->projectA->id]); +}); + +test('queryDatabaseByUuidWithinTeam returns database when team owns it', function () { + $database = StandalonePostgresql::create([ + 'name' => 'pg-team-a', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->envA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + ]); + + $found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamA->id); + + expect($found)->not->toBeNull(); + expect($found->uuid)->toBe($database->uuid); + expect($found)->toBeInstanceOf(StandalonePostgresql::class); +}); + +test('queryDatabaseByUuidWithinTeam returns null when team does not own the database', function () { + $database = StandalonePostgresql::create([ + 'name' => 'pg-team-a', + 'image' => 'postgres:15-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->envA->id, + 'destination_id' => $this->destinationA->id, + 'destination_type' => $this->destinationA->getMorphClass(), + ]); + + $found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamB->id); + + expect($found)->toBeNull(); +}); + +test('queryDatabaseByUuidWithinTeam returns null for unknown uuid', function () { + $found = queryDatabaseByUuidWithinTeam('does-not-exist', (string) $this->teamA->id); + + expect($found)->toBeNull(); +}); + +test('queryDatabaseByUuidWithinTeam can query every registered standalone database type without error', function () { + foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) { + $count = $modelClass::query()->whereUuid('non-existent-uuid')->count(); + expect($count)->toBe(0, "{$modelClass} ({$slug}) failed whereUuid() smoke query"); + } +}); diff --git a/tests/Unit/StandaloneDatabaseRegistryTest.php b/tests/Unit/StandaloneDatabaseRegistryTest.php new file mode 100644 index 000000000..7c56d5f8d --- /dev/null +++ b/tests/Unit/StandaloneDatabaseRegistryTest.php @@ -0,0 +1,45 @@ +not->toBeEmpty(); + + $onDisk = collect($files) + ->map(fn (string $path) => 'App\\Models\\'.basename($path, '.php')) + ->reject(fn (string $class) => $class === StandaloneDocker::class) + ->sort() + ->values() + ->all(); + + $registered = collect(STANDALONE_DATABASE_MODELS)->values()->sort()->values()->all(); + + expect($registered)->toBe( + $onDisk, + 'STANDALONE_DATABASE_MODELS in bootstrap/helpers/constants.php is out of sync with the App\\Models\\Standalone* classes on disk. ' + .'Add the missing model(s) to the registry (and to DATABASE_TYPES) so MCP/API helpers can resolve them.' + ); +}); + +test('STANDALONE_DATABASE_MODELS keys mirror DATABASE_TYPES', function () { + expect(array_keys(STANDALONE_DATABASE_MODELS))->toEqualCanonicalizing(DATABASE_TYPES); +}); + +test('every STANDALONE_DATABASE_MODELS entry is an Eloquent model with whereUuid scope', function () { + foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) { + expect(class_exists($modelClass))->toBeTrue("{$slug} maps to non-existent class {$modelClass}"); + expect(is_subclass_of($modelClass, Model::class)) + ->toBeTrue("{$modelClass} is not an Eloquent model"); + expect(method_exists($modelClass, 'team')) + ->toBeTrue("{$modelClass} is missing team() accessor required by queryDatabaseByUuidWithinTeam()"); + } +}); From a51f114a70a1dc0493e797a6ecdc1a441101bc11 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:01:48 +0200 Subject: [PATCH 3/5] fix(standalone-docker): include keydb, dragonfly, clickhouse in databases() Add missing DB types to StandaloneDocker::databases() concat chain and cover all 8 standalone database types with feature tests. --- app/Models/StandaloneDocker.php | 5 +- .../Feature/StandaloneDockerDatabasesTest.php | 71 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/StandaloneDockerDatabasesTest.php diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index d6b4d1a1c..d12a15a7c 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -134,8 +134,11 @@ public function databases() $mongodbs = $this->mongodbs; $mysqls = $this->mysqls; $mariadbs = $this->mariadbs; + $keydbs = $this->keydbs; + $dragonflies = $this->dragonflies; + $clickhouses = $this->clickhouses; - return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs); + return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs)->concat($keydbs)->concat($dragonflies)->concat($clickhouses); } public function attachedTo() diff --git a/tests/Feature/StandaloneDockerDatabasesTest.php b/tests/Feature/StandaloneDockerDatabasesTest.php new file mode 100644 index 000000000..8d7889149 --- /dev/null +++ b/tests/Feature/StandaloneDockerDatabasesTest.php @@ -0,0 +1,71 @@ +team = Team::factory()->create(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function attachDb(string $modelClass, array $extra, $destination, $environment) +{ + return $modelClass::create(array_merge([ + 'name' => 'test-'.strtolower(class_basename($modelClass)), + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ], $extra)); +} + +test('StandaloneDocker::databases() includes attached keydb', function () { + attachDb(StandaloneKeydb::class, ['keydb_password' => 'pw'], $this->destination, $this->environment); + + expect($this->destination->databases()->count())->toBe(1); + expect($this->destination->attachedTo())->toBeTrue(); +}); + +test('StandaloneDocker::databases() includes attached dragonfly', function () { + attachDb(StandaloneDragonfly::class, ['dragonfly_password' => 'pw'], $this->destination, $this->environment); + + expect($this->destination->databases()->count())->toBe(1); + expect($this->destination->attachedTo())->toBeTrue(); +}); + +test('StandaloneDocker::databases() includes attached clickhouse', function () { + attachDb(StandaloneClickhouse::class, ['clickhouse_admin_password' => 'pw'], $this->destination, $this->environment); + + expect($this->destination->databases()->count())->toBe(1); + expect($this->destination->attachedTo())->toBeTrue(); +}); + +test('StandaloneDocker::databases() includes all 8 standalone database types', function () { + attachDb(StandalonePostgresql::class, ['postgres_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneRedis::class, ['redis_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneMongodb::class, ['mongo_initdb_root_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneMysql::class, ['mysql_root_password' => 'pw', 'mysql_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneMariadb::class, ['mariadb_root_password' => 'pw', 'mariadb_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneKeydb::class, ['keydb_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneDragonfly::class, ['dragonfly_password' => 'pw'], $this->destination, $this->environment); + attachDb(StandaloneClickhouse::class, ['clickhouse_admin_password' => 'pw'], $this->destination, $this->environment); + + expect($this->destination->databases()->count())->toBe(8); + expect($this->destination->attachedTo())->toBeTrue(); +}); From 1849b5903ec6e92b3f2b097a52be128aaa349d0e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 4 May 2026 12:26:15 +0200 Subject: [PATCH 4/5] refactor(scheduled-task): simplify server() with nullsafe operators and add return type Replace nested null checks with nullsafe operator chains, add ?Server return type, drop unreachable database branch, and cover all paths with feature tests. --- app/Models/ScheduledTask.php | 18 +++---- tests/Feature/ScheduledTaskServerTest.php | 66 +++++++++++++++++++++++ 2 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 tests/Feature/ScheduledTaskServerTest.php diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index 40f8e1860..0a53395d3 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -76,20 +76,14 @@ public function executions(): HasMany return $this->hasMany(ScheduledTaskExecution::class)->orderBy('created_at', 'desc'); } - public function server() + public function server(): ?Server { if ($this->application) { - if ($this->application->destination && $this->application->destination->server) { - return $this->application->destination->server; - } - } elseif ($this->service) { - if ($this->service->destination && $this->service->destination->server) { - return $this->service->destination->server; - } - } elseif ($this->database) { - if ($this->database->destination && $this->database->destination->server) { - return $this->database->destination->server; - } + return $this->application->destination?->server; + } + + if ($this->service) { + return $this->service->destination?->server; } return null; diff --git a/tests/Feature/ScheduledTaskServerTest.php b/tests/Feature/ScheduledTaskServerTest.php new file mode 100644 index 000000000..68a9020d0 --- /dev/null +++ b/tests/Feature/ScheduledTaskServerTest.php @@ -0,0 +1,66 @@ +team = Team::factory()->create(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first(); +}); + +it('returns null when neither application nor service is set', function () { + $task = ScheduledTask::factory()->create([ + 'team_id' => $this->team->id, + ]); + + expect($task->server())->toBeNull(); +}); + +it('does not throw when accessing dynamic properties on a parentless task', function () { + $task = ScheduledTask::factory()->create([ + 'team_id' => $this->team->id, + ]); + + expect(fn () => $task->server())->not->toThrow(Exception::class); +}); + +it('resolves server via application destination', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $task = ScheduledTask::factory()->create([ + 'application_id' => $application->id, + 'team_id' => $this->team->id, + ]); + + expect($task->server()?->id)->toBe($this->server->id); +}); + +it('resolves server via service destination', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $task = ScheduledTask::factory()->create([ + 'service_id' => $service->id, + 'team_id' => $this->team->id, + ]); + + expect($task->server()?->id)->toBe($this->server->id); +}); From 45f65481e637713a5c55f4e72461ad5fe879f513 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 5 May 2026 22:07:58 +0200 Subject: [PATCH 5/5] fix(mcp): change enable/disable endpoints from GET to POST and fix service/app listing - `/mcp/enable` and `/mcp/disable` now use POST (state-mutating ops) - `ListServices` queries DB directly instead of loading all projects into memory - `ListApplications` validates tag arg rejects empty string (not just falsy) --- app/Http/Controllers/Api/OtherController.php | 4 ++-- app/Mcp/Tools/ListApplications.php | 5 ++++- app/Mcp/Tools/ListServices.php | 18 ++++++++---------- openapi.json | 4 ++-- openapi.yaml | 4 ++-- routes/api.php | 4 ++-- tests/Feature/Mcp/McpToggleApiTest.php | 16 ++++++++-------- 7 files changed, 28 insertions(+), 27 deletions(-) diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 17c5a884a..f17a4e46b 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -153,7 +153,7 @@ public function disable_api(Request $request) return response()->json(['message' => 'API disabled.'], 200); } - #[OA\Get( + #[OA\Post( summary: 'Enable MCP Server', description: 'Enable the MCP server endpoint at /mcp (only with root permissions).', path: '/mcp/enable', @@ -209,7 +209,7 @@ public function enable_mcp(Request $request) return response()->json(['message' => 'MCP server enabled.'], 200); } - #[OA\Get( + #[OA\Post( summary: 'Disable MCP Server', description: 'Disable the MCP server endpoint at /mcp (only with root permissions).', path: '/mcp/disable', diff --git a/app/Mcp/Tools/ListApplications.php b/app/Mcp/Tools/ListApplications.php index 947d49ed1..815edd61a 100644 --- a/app/Mcp/Tools/ListApplications.php +++ b/app/Mcp/Tools/ListApplications.php @@ -31,10 +31,13 @@ public function handle(Request $request): Response } $tagName = $request->get('tag'); + if ($tagName !== null && (! is_string($tagName) || trim($tagName) === '')) { + return Response::error('tag argument must be a non-empty string.'); + } $args = $this->paginationArgs($request); $query = Application::ownedByCurrentTeamAPI($teamId) - ->when($tagName, function ($query, $tagName) { + ->when($tagName !== null, function ($query) use ($tagName) { $query->whereHas('tags', fn ($q) => $q->where('name', $tagName)); }); diff --git a/app/Mcp/Tools/ListServices.php b/app/Mcp/Tools/ListServices.php index 4b33231ba..b0bff4fad 100644 --- a/app/Mcp/Tools/ListServices.php +++ b/app/Mcp/Tools/ListServices.php @@ -4,7 +4,7 @@ use App\Mcp\Concerns\BuildsResponse; use App\Mcp\Concerns\ResolvesTeam; -use App\Models\Project; +use App\Models\Service; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -32,17 +32,15 @@ public function handle(Request $request): Response $args = $this->paginationArgs($request); - $projects = Project::where('team_id', $teamId)->get(); - $services = collect(); - foreach ($projects as $project) { - $services = $services->merge($project->services()->get()); - } + $query = Service::whereHas('environment.project', fn ($q) => $q->where('team_id', $teamId)); - $total = $services->count(); + $total = (clone $query)->count(); - $summaries = $services - ->sortBy('name') - ->slice($args['offset'], $args['per_page']) + $summaries = $query + ->orderBy('name') + ->skip($args['offset']) + ->take($args['per_page']) + ->get() ->map(fn ($svc) => [ 'uuid' => $svc->uuid, 'name' => $svc->name, diff --git a/openapi.json b/openapi.json index c35c0cdd6..711c7d8f3 100644 --- a/openapi.json +++ b/openapi.json @@ -8651,7 +8651,7 @@ } }, "\/mcp\/enable": { - "get": { + "post": { "summary": "Enable MCP Server", "description": "Enable the MCP server endpoint at \/mcp (only with root permissions).", "operationId": "enable-mcp", @@ -8703,7 +8703,7 @@ } }, "\/mcp\/disable": { - "get": { + "post": { "summary": "Disable MCP Server", "description": "Disable the MCP server endpoint at \/mcp (only with root permissions).", "operationId": "disable-mcp", diff --git a/openapi.yaml b/openapi.yaml index eab531674..fef77f5a7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5485,7 +5485,7 @@ paths: - bearerAuth: [] /mcp/enable: - get: + post: summary: 'Enable MCP Server' description: 'Enable the MCP server endpoint at /mcp (only with root permissions).' operationId: enable-mcp @@ -5514,7 +5514,7 @@ paths: - bearerAuth: [] /mcp/disable: - get: + post: summary: 'Disable MCP Server' description: 'Disable the MCP server endpoint at /mcp (only with root permissions).' operationId: disable-mcp diff --git a/routes/api.php b/routes/api.php index f38576d4d..38ded350a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -35,8 +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::post('/mcp/enable', [OtherController::class, 'enable_mcp']); + Route::post('/mcp/disable', [OtherController::class, 'disable_mcp']); }); Route::group([ 'middleware' => ['auth:sanctum', ApiAllowed::class, 'api.sensitive'], diff --git a/tests/Feature/Mcp/McpToggleApiTest.php b/tests/Feature/Mcp/McpToggleApiTest.php index 2700f9b7b..68d5d335a 100644 --- a/tests/Feature/Mcp/McpToggleApiTest.php +++ b/tests/Feature/Mcp/McpToggleApiTest.php @@ -43,25 +43,25 @@ function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write' return $token->plainTextToken; } -test('GET /api/v1/mcp/enable enables MCP server with root token', function () { +test('POST /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'); + ])->postJson('/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 () { +test('POST /api/v1/mcp/disable disables MCP server with root token', function () { InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]); $token = makeRootMcpToken($this->user); $response = test()->withHeaders([ 'Authorization' => 'Bearer '.$token, - ])->getJson('/api/v1/mcp/disable'); + ])->postJson('/api/v1/mcp/disable'); $response->assertOk(); $response->assertJson(['message' => 'MCP server disabled.']); @@ -73,7 +73,7 @@ function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write' $response = test()->withHeaders([ 'Authorization' => 'Bearer '.$token, - ])->getJson('/api/v1/mcp/enable'); + ])->postJson('/api/v1/mcp/enable'); $response->assertStatus(403); expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse(); @@ -85,14 +85,14 @@ function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write' $response = test()->withHeaders([ 'Authorization' => 'Bearer '.$token, - ])->getJson('/api/v1/mcp/disable'); + ])->postJson('/api/v1/mcp/disable'); $response->assertStatus(403); expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue(); }); test('unauthenticated request to /api/v1/mcp/enable returns 401', function () { - $response = test()->getJson('/api/v1/mcp/enable'); + $response = test()->postJson('/api/v1/mcp/enable'); $response->assertStatus(401); }); @@ -101,7 +101,7 @@ function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write' $response = test()->withHeaders([ 'Authorization' => 'Bearer '.$token, - ])->getJson('/api/v1/mcp/enable'); + ])->postJson('/api/v1/mcp/enable'); $response->assertStatus(403); });