From 521b3cb90f8d0b01dec0c2148116394473786ca6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:26:12 +0200 Subject: [PATCH] feat(mcp): add REST-backed resource management tools (#12163) --- .ai/lessons.md | 1 + app/Http/Controllers/Api/OtherController.php | 56 -- .../Controllers/Api/SecurityController.php | 3 +- app/Http/Kernel.php | 2 + app/Http/Middleware/ApiAllowed.php | 24 +- app/Http/Middleware/ApiIpAllowed.php | 35 + app/Http/Middleware/CanAccessTerminal.php | 2 +- .../Middleware/CheckForcePasswordReset.php | 2 +- .../Middleware/RedirectIfAuthenticated.php | 2 +- app/Jobs/DatabaseBackupJob.php | 12 +- app/Livewire/Security/ApiTokens.php | 6 +- app/Mcp/Concerns/BuildsResponse.php | 5 +- app/Mcp/Concerns/CallsRestApi.php | 170 ++++ app/Mcp/Concerns/ChecksTokenAbilities.php | 17 + app/Mcp/Concerns/ResolvesTeam.php | 12 + app/Mcp/Servers/CoolifyServer.php | 84 +- app/Mcp/Tools/CancelDeployment.php | 13 + app/Mcp/Tools/CloudProvider.php | 72 ++ app/Mcp/Tools/Control.php | 77 +- app/Mcp/Tools/CoolifyHelp.php | 266 +++--- app/Mcp/Tools/CreateApplication.php | 39 + app/Mcp/Tools/CreateDatabase.php | 39 + app/Mcp/Tools/CreateService.php | 34 + app/Mcp/Tools/Deploy.php | 76 +- app/Mcp/Tools/DockerCleanup.php | 37 + app/Mcp/Tools/GetApplication.php | 2 + app/Mcp/Tools/GetApplicationPreview.php | 34 + app/Mcp/Tools/GetCurrentTeam.php | 2 + app/Mcp/Tools/GetDatabase.php | 2 + app/Mcp/Tools/GetDatabaseImport.php | 32 + app/Mcp/Tools/GetDeployment.php | 2 + app/Mcp/Tools/GetDestination.php | 2 + app/Mcp/Tools/GetEnvValues.php | 36 + app/Mcp/Tools/GetEnvironment.php | 2 + app/Mcp/Tools/GetInfrastructureOverview.php | 2 + app/Mcp/Tools/GetLogs.php | 9 + app/Mcp/Tools/GetProject.php | 2 + app/Mcp/Tools/GetServer.php | 2 + app/Mcp/Tools/GetServerDomains.php | 2 + app/Mcp/Tools/GetServerMetrics.php | 136 +++ app/Mcp/Tools/GetServerResources.php | 2 + app/Mcp/Tools/GetService.php | 2 + app/Mcp/Tools/GetServiceApplication.php | 2 + app/Mcp/Tools/GetServiceDatabase.php | 2 + app/Mcp/Tools/ListApplicationPreviews.php | 2 + app/Mcp/Tools/ListApplications.php | 2 + app/Mcp/Tools/ListAuditEvents.php | 30 + app/Mcp/Tools/ListBackupExecutions.php | 2 + app/Mcp/Tools/ListDatabaseBackups.php | 2 + app/Mcp/Tools/ListDatabases.php | 2 + app/Mcp/Tools/ListDeployments.php | 2 + app/Mcp/Tools/ListDestinations.php | 2 + app/Mcp/Tools/ListEnvKeys.php | 2 + app/Mcp/Tools/ListGithubApps.php | 2 + app/Mcp/Tools/ListGithubBranches.php | 4 + app/Mcp/Tools/ListGithubRepositories.php | 4 + app/Mcp/Tools/ListProjects.php | 2 + app/Mcp/Tools/ListResourceTags.php | 2 + app/Mcp/Tools/ListResources.php | 2 + app/Mcp/Tools/ListRollbackImages.php | 27 + app/Mcp/Tools/ListScheduledTaskExecutions.php | 2 + app/Mcp/Tools/ListScheduledTasks.php | 2 + app/Mcp/Tools/ListServers.php | 2 + app/Mcp/Tools/ListServiceApplications.php | 2 + app/Mcp/Tools/ListServiceDatabases.php | 2 + app/Mcp/Tools/ListServiceTemplates.php | 72 ++ app/Mcp/Tools/ListServices.php | 2 + app/Mcp/Tools/ListSharedEnvKeys.php | 2 + app/Mcp/Tools/ListStorages.php | 2 + app/Mcp/Tools/ListTags.php | 2 + app/Mcp/Tools/ListTeamMembers.php | 2 + app/Mcp/Tools/ListUnhealthyResources.php | 2 + app/Mcp/Tools/ManageApplication.php | 40 + app/Mcp/Tools/ManageBackup.php | 39 + app/Mcp/Tools/ManageDatabase.php | 38 + app/Mcp/Tools/ManageDestination.php | 37 + app/Mcp/Tools/ManageEnv.php | 54 ++ app/Mcp/Tools/ManageGitSource.php | 37 + app/Mcp/Tools/ManageNotifications.php | 44 + app/Mcp/Tools/ManagePreview.php | 37 + app/Mcp/Tools/ManagePrivateKey.php | 38 + app/Mcp/Tools/ManageProject.php | 40 + app/Mcp/Tools/ManageProxy.php | 37 + app/Mcp/Tools/ManageRegistry.php | 38 + app/Mcp/Tools/ManageS3Storage.php | 39 + app/Mcp/Tools/ManageScheduledTask.php | 50 + app/Mcp/Tools/ManageServer.php | 37 + app/Mcp/Tools/ManageService.php | 42 + app/Mcp/Tools/ManageSharedEnv.php | 53 ++ app/Mcp/Tools/ManageStorage.php | 52 ++ app/Mcp/Tools/ManageTags.php | 47 + app/Mcp/Tools/RestActionTool.php | 164 ++++ app/Mcp/Tools/RollbackApplication.php | 34 + app/Mcp/Tools/SearchResources.php | 2 + .../DatabaseImportCommandBuilder.php | 59 +- app/Support/PostgresUser.php | 54 ++ .../user/contents.d/horizon | 0 .../user/contents.d/init-setup | 0 .../user/contents.d/reverb | 0 .../user/contents.d/scheduler-worker | 0 .../user/contents.d/terminal-server | 0 openapi.json | 55 -- openapi.yaml | 31 - .../livewire/security/api-tokens.blade.php | 7 +- .../livewire/settings/advanced.blade.php | 4 +- routes/ai.php | 2 +- routes/api.php | 4 +- tests/Feature/Api/PrivateKeyUpdateApiTest.php | 64 ++ .../Api/StateChangingRoutesUsePostTest.php | 1 - tests/Feature/ApiDisabledCalloutTest.php | 17 + tests/Feature/BackupRunSafetyTest.php | 28 +- tests/Feature/Mcp/CallsRestApiTest.php | 41 + tests/Feature/Mcp/McpApplicationToolsTest.php | 520 +++++++++++ tests/Feature/Mcp/McpDataToolsTest.php | 787 ++++++++++++++++ tests/Feature/Mcp/McpEndpointTest.php | 94 +- tests/Feature/Mcp/McpImproveTest.php | 13 +- tests/Feature/Mcp/McpOperationsToolsTest.php | 618 +++++++++++++ tests/Feature/Mcp/McpReadToolsTest.php | 61 +- tests/Feature/Mcp/McpServerToolsTest.php | 863 ++++++++++++++++++ tests/Feature/Mcp/McpToggleApiTest.php | 22 +- tests/Feature/Mcp/McpWriteToolsTest.php | 150 +++ tests/Feature/Security/AuditLogTest.php | 21 +- .../DatabaseImportCommandBuilderTest.php | 4 +- .../DatabaseImportRestoreScriptTest.php | 95 +- .../Unit/Livewire/Database/S3RestoreTest.php | 9 +- 125 files changed, 5749 insertions(+), 418 deletions(-) create mode 100644 app/Http/Middleware/ApiIpAllowed.php create mode 100644 app/Mcp/Concerns/CallsRestApi.php create mode 100644 app/Mcp/Concerns/ChecksTokenAbilities.php create mode 100644 app/Mcp/Tools/CloudProvider.php create mode 100644 app/Mcp/Tools/CreateApplication.php create mode 100644 app/Mcp/Tools/CreateDatabase.php create mode 100644 app/Mcp/Tools/CreateService.php create mode 100644 app/Mcp/Tools/DockerCleanup.php create mode 100644 app/Mcp/Tools/GetApplicationPreview.php create mode 100644 app/Mcp/Tools/GetDatabaseImport.php create mode 100644 app/Mcp/Tools/GetEnvValues.php create mode 100644 app/Mcp/Tools/GetServerMetrics.php create mode 100644 app/Mcp/Tools/ListAuditEvents.php create mode 100644 app/Mcp/Tools/ListRollbackImages.php create mode 100644 app/Mcp/Tools/ListServiceTemplates.php create mode 100644 app/Mcp/Tools/ManageApplication.php create mode 100644 app/Mcp/Tools/ManageBackup.php create mode 100644 app/Mcp/Tools/ManageDatabase.php create mode 100644 app/Mcp/Tools/ManageDestination.php create mode 100644 app/Mcp/Tools/ManageEnv.php create mode 100644 app/Mcp/Tools/ManageGitSource.php create mode 100644 app/Mcp/Tools/ManageNotifications.php create mode 100644 app/Mcp/Tools/ManagePreview.php create mode 100644 app/Mcp/Tools/ManagePrivateKey.php create mode 100644 app/Mcp/Tools/ManageProject.php create mode 100644 app/Mcp/Tools/ManageProxy.php create mode 100644 app/Mcp/Tools/ManageRegistry.php create mode 100644 app/Mcp/Tools/ManageS3Storage.php create mode 100644 app/Mcp/Tools/ManageScheduledTask.php create mode 100644 app/Mcp/Tools/ManageServer.php create mode 100644 app/Mcp/Tools/ManageService.php create mode 100644 app/Mcp/Tools/ManageSharedEnv.php create mode 100644 app/Mcp/Tools/ManageStorage.php create mode 100644 app/Mcp/Tools/ManageTags.php create mode 100644 app/Mcp/Tools/RestActionTool.php create mode 100644 app/Mcp/Tools/RollbackApplication.php create mode 100644 app/Support/PostgresUser.php rename docker/development/etc/s6-overlay/{user-bundles.d => s6-rc.d}/user/contents.d/horizon (100%) rename docker/development/etc/s6-overlay/{user-bundles.d => s6-rc.d}/user/contents.d/init-setup (100%) rename docker/development/etc/s6-overlay/{user-bundles.d => s6-rc.d}/user/contents.d/reverb (100%) rename docker/development/etc/s6-overlay/{user-bundles.d => s6-rc.d}/user/contents.d/scheduler-worker (100%) rename docker/development/etc/s6-overlay/{user-bundles.d => s6-rc.d}/user/contents.d/terminal-server (100%) create mode 100644 tests/Feature/Api/PrivateKeyUpdateApiTest.php create mode 100644 tests/Feature/Mcp/CallsRestApiTest.php create mode 100644 tests/Feature/Mcp/McpApplicationToolsTest.php create mode 100644 tests/Feature/Mcp/McpDataToolsTest.php create mode 100644 tests/Feature/Mcp/McpOperationsToolsTest.php create mode 100644 tests/Feature/Mcp/McpServerToolsTest.php create mode 100644 tests/Feature/Mcp/McpWriteToolsTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index c5f83d759d..4740622e14 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -17,6 +17,7 @@ - Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. - Do not symlink `vendor` into another worktree. Composer resolves the base path from the real `vendor` path, so the tests load the app classes of the other checkout. Run `composer install --ignore-platform-req=ext-zip` in the worktree. - Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`. +- SQLite returns `0`/`1` for boolean columns without a `boolean` cast, while Postgres returns real booleans. A strict `=== false` check on such a column never matches in tests, so tests can pass on behavior that Postgres blocks. Add the cast or compare loosely. ## Verify the complete user flow - Do not use a passing unit test, a successful build, or a healthy process as proof for a reported UI failure. diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php index 8fa3125aec..8e36629921 100644 --- a/app/Http/Controllers/Api/OtherController.php +++ b/app/Http/Controllers/Api/OtherController.php @@ -50,62 +50,6 @@ class OtherController extends Controller return response(config('constants.coolify.version')); } - #[OA\Post( - summary: 'Enable API', - description: 'Enable API (only with root permissions).', - path: '/enable', - operationId: 'enable-api', - security: [ - ['bearerAuth' => []], - ], - responses: [ - new OA\Response( - response: 200, - description: 'Enable API.', - content: new OA\JsonContent( - type: 'object', - properties: [ - new OA\Property(property: 'message', type: 'string', example: 'API enabled.'), - ] - )), - new OA\Response( - response: 403, - description: 'You are not allowed to enable the API.', - content: new OA\JsonContent( - type: 'object', - properties: [ - new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the API.'), - ] - )), - new OA\Response( - response: 401, - ref: '#/components/responses/401', - ), - new OA\Response( - response: 400, - ref: '#/components/responses/400', - ), - ] - )] - public function enable_api(Request $request) - { - $teamId = getTeamIdFromToken(); - if (is_null($teamId)) { - return invalidTokenResponse(); - } - if ($teamId !== '0') { - auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning'); - - return response()->json(['message' => 'You are not allowed to enable the API.'], 403); - } - $settings = instanceSettings(); - $settings->update(['is_api_enabled' => true]); - - auditLog('api.instance.enabled', ['team_id' => $teamId]); - - return response()->json(['message' => 'API enabled.'], 200); - } - #[OA\Post( summary: 'Disable API', description: 'Disable API (only with root permissions).', diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index f5b0b0366b..9df897caa3 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -269,7 +269,6 @@ class SecurityController extends Controller mediaType: 'application/json', schema: new OA\Schema( type: 'object', - required: ['private_key'], properties: [ 'name' => ['type' => 'string'], 'description' => ['type' => 'string'], @@ -328,7 +327,7 @@ class SecurityController extends Controller $validator = customApiValidator($request->all(), [ 'name' => 'string|max:255', 'description' => 'string|max:255', - 'private_key' => 'required|string', + 'private_key' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index bfb98ede79..2e6c36b722 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -4,6 +4,7 @@ namespace App\Http; use App\Http\Middleware\AddServerTimingHeaders; use App\Http\Middleware\ApiAbility; +use App\Http\Middleware\ApiIpAllowed; use App\Http\Middleware\ApiSensitiveData; use App\Http\Middleware\Authenticate; use App\Http\Middleware\CanAccessTerminal; @@ -114,6 +115,7 @@ class Kernel extends HttpKernel 'can.update.resource' => CanUpdateResource::class, 'can.access.terminal' => CanAccessTerminal::class, 'mcp.enabled' => EnsureMcpEnabled::class, + 'api.ip.allowed' => ApiIpAllowed::class, 'mcp.team.enabled' => EnsureTeamMcpEnabled::class, ]; } diff --git a/app/Http/Middleware/ApiAllowed.php b/app/Http/Middleware/ApiAllowed.php index 21441a117b..541bf3919a 100644 --- a/app/Http/Middleware/ApiAllowed.php +++ b/app/Http/Middleware/ApiAllowed.php @@ -13,26 +13,14 @@ class ApiAllowed if (isCloud()) { return $next($request); } - $settings = instanceSettings(); - if ($settings->is_api_enabled === false) { + + // MCP write tools run REST routes internally; MCP has its own enable switch. + $isMcpRestBridge = $request->attributes->get('mcp_rest_bridge') === true; + + if (! $isMcpRestBridge && instanceSettings()->is_api_enabled === false) { return response()->json(['success' => true, 'message' => 'API is disabled.'], 403); } - if ($settings->allowed_ips) { - // Check for special case: 0.0.0.0 means allow all - if (trim($settings->allowed_ips) === '0.0.0.0') { - return $next($request); - } - - $allowedIps = explode(',', $settings->allowed_ips); - $allowedIps = array_map('trim', $allowedIps); - $allowedIps = array_filter($allowedIps); // Remove empty entries - - if (! empty($allowedIps) && ! checkIPAgainstAllowlist($request->ip(), $allowedIps)) { - return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403); - } - } - - return $next($request); + return app(ApiIpAllowed::class)->handle($request, $next); } } diff --git a/app/Http/Middleware/ApiIpAllowed.php b/app/Http/Middleware/ApiIpAllowed.php new file mode 100644 index 0000000000..da2d009576 --- /dev/null +++ b/app/Http/Middleware/ApiIpAllowed.php @@ -0,0 +1,35 @@ +allowed_ips; + if (! $allowedIps) { + return $next($request); + } + + // Check for special case: 0.0.0.0 means allow all + if (trim($allowedIps) === '0.0.0.0') { + return $next($request); + } + + $allowedIps = array_filter(array_map('trim', explode(',', $allowedIps))); + + if (! empty($allowedIps) && ! checkIPAgainstAllowlist($request->ip(), $allowedIps)) { + return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/CanAccessTerminal.php b/app/Http/Middleware/CanAccessTerminal.php index 348f389ea9..94dbae9874 100644 --- a/app/Http/Middleware/CanAccessTerminal.php +++ b/app/Http/Middleware/CanAccessTerminal.php @@ -11,7 +11,7 @@ class CanAccessTerminal /** * Handle an incoming request. * - * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + * @param Closure(Request): (Response) $next */ public function handle(Request $request, Closure $next): Response { diff --git a/app/Http/Middleware/CheckForcePasswordReset.php b/app/Http/Middleware/CheckForcePasswordReset.php index c857cb8363..ad7f6aa42c 100644 --- a/app/Http/Middleware/CheckForcePasswordReset.php +++ b/app/Http/Middleware/CheckForcePasswordReset.php @@ -11,7 +11,7 @@ class CheckForcePasswordReset /** * Handle an incoming request. * - * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + * @param Closure(Request): (Response) $next */ public function handle(Request $request, Closure $next): Response { diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php index afc78c4e53..b92e43f7ec 100644 --- a/app/Http/Middleware/RedirectIfAuthenticated.php +++ b/app/Http/Middleware/RedirectIfAuthenticated.php @@ -13,7 +13,7 @@ class RedirectIfAuthenticated /** * Handle an incoming request. * - * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + * @param Closure(Request): (Response) $next */ public function handle(Request $request, Closure $next, string ...$guards): Response { diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index ebdc947e97..55f43ab0a3 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -24,6 +24,7 @@ use App\Services\Security\SensitiveDataRedactor; use App\Support\BackupCompression; use App\Support\ClickhouseBackupCommand; use App\Support\DatabaseOperationReservation; +use App\Support\PostgresUser; use App\Support\ResourceStartActivity; use App\Support\ValidationPatterns; use Carbon\Carbon; @@ -167,18 +168,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue $this->directory_name = $serviceName.'-'.$this->container_name; $escapedContainerName = escapeshellarg($this->container_name); if (str($databaseType)->contains('postgres')) { - $commands[] = "docker exec {$escapedContainerName} env | grep POSTGRES_"; + $commands[] = "docker exec {$escapedContainerName} env | grep -E '^(POSTGRES|PGUSER=)'"; $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $envs = str($envs)->explode("\n"); - $user = $envs->filter(function ($env) { - return str($env)->startsWith('POSTGRES_USER='); - })->first(); - if ($user) { - $this->database->postgres_user = str($user)->after('POSTGRES_USER=')->value(); - } else { - $this->database->postgres_user = 'postgres'; - } + $this->database->postgres_user = PostgresUser::fromEnvLines($envs); $db = $envs->filter(function ($env) { return str($env)->startsWith('POSTGRES_DB='); diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index ca98698450..bd7189bd24 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -37,6 +37,8 @@ class ApiTokens extends Component public $isApiEnabled; + public $isMcpEnabled; + #[Locked] public bool $canUseRootPermissions = false; @@ -56,7 +58,9 @@ class ApiTokens extends Component public function mount() { - $this->isApiEnabled = InstanceSettings::get()->is_api_enabled; + $settings = InstanceSettings::get(); + $this->isApiEnabled = $settings->is_api_enabled; + $this->isMcpEnabled = $settings->is_mcp_server_enabled; $this->canUseRootPermissions = auth()->user()->can('useRootPermissions', PersonalAccessToken::class); $this->canUseWritePermissions = auth()->user()->can('useWritePermissions', PersonalAccessToken::class); $this->canUseDeployPermissions = auth()->user()->can('useDeployPermissions', PersonalAccessToken::class); diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php index e2d3e3dfea..ae3899d60f 100644 --- a/app/Mcp/Concerns/BuildsResponse.php +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -82,11 +82,12 @@ trait BuildsResponse * Recursively remove sensitive keys from any nested array structure. * * @param array $data + * @param array $publicKeys Identifiers required by management actions. * @return array */ - protected function scrubSensitive(array $data): array + protected function scrubSensitive(array $data, array $publicKeys = []): array { - $deny = array_flip($this->sensitiveKeys); + $deny = array_flip(array_diff($this->sensitiveKeys, $publicKeys)); $walk = function ($value) use (&$walk, $deny) { if (! is_array($value)) { diff --git a/app/Mcp/Concerns/CallsRestApi.php b/app/Mcp/Concerns/CallsRestApi.php new file mode 100644 index 0000000000..32a7286233 --- /dev/null +++ b/app/Mcp/Concerns/CallsRestApi.php @@ -0,0 +1,170 @@ + + */ + private array $blockedRestPaths = [ + 'api/v1/enable', + 'api/v1/disable', + 'api/v1/mcp/', + 'api/v1/feedback', + 'api/v1/sentinel/', + 'api/v1/settings/', + 'api/v1/teams', + 'api/v1/security/integration-tokens', + ]; + + /** + * Response keys that are never returned to MCP clients, whatever the token abilities are. + */ + private string $neverReturnKeyPattern = '/(private_key|secret|api_key|license_key|user_key|webhook_url|password|^(internal|external)_db_url$|(^|_)token$)/i'; + + private const MAX_BODY_BYTES = 1_048_576; + + /** + * @param array $pathParams Values for {placeholders} in $path. + * @param array $body + * @return array{status: int, body: mixed} + */ + protected function callRestApi(string $method, string $path, array $pathParams = [], array $body = []): array + { + $resolvedPath = preg_replace_callback('/\{(\w+)\}/', function (array $match) use ($pathParams): string { + $value = $pathParams[$match[1]] ?? null; + if (! $this->isSafePathSegment($value)) { + throw new \InvalidArgumentException("{$match[1]} is missing or has invalid characters."); + } + + return rawurlencode((string) $value); + }, $path); + + $uri = '/api/v1/'.ltrim($resolvedPath, '/'); + foreach ($this->blockedRestPaths as $blocked) { + if (str_starts_with(ltrim($uri, '/'), $blocked)) { + throw new \InvalidArgumentException('This API route is not available through MCP.'); + } + } + + $json = json_encode((object) $body, JSON_THROW_ON_ERROR); + if (strlen($json) > self::MAX_BODY_BYTES) { + throw new \InvalidArgumentException('Request data is too large.'); + } + + // REST controllers read GET filters and DELETE flags (?force=...) from the query string. + $usesQueryString = in_array($method, ['GET', 'DELETE'], true); + + /** @var HttpRequest $original */ + $original = app('request'); + if ($usesQueryString && $body !== []) { + $uri .= '?'.http_build_query($body); + } + + $subRequest = HttpRequest::create( + $uri, + $method, + $usesQueryString ? [] : $body, + [], + [], + $original->server->all(), + $usesQueryString ? null : $json, + ); + if (! $usesQueryString) { + // Same input as a real JSON request: $request->get()/all() read the JSON body. + $subRequest->setJson(new InputBag($body)); + $subRequest->request = $subRequest->json(); + } + $subRequest->headers->set('Accept', 'application/json'); + $subRequest->headers->set('Content-Type', 'application/json'); + // Server-side marker only: request attributes cannot be set by the client. + $subRequest->attributes->set('mcp_rest_bridge', true); + $subRequest->setUserResolver($original->getUserResolver()); + + /** @var Router $router */ + $router = app('router'); + + app()->instance('request', $subRequest); + Facade::clearResolvedInstance('request'); + + try { + $route = $router->getRoutes()->match($subRequest); + $subRequest->setRouteResolver(fn () => $route); + + $response = (new Pipeline(app())) + ->send($subRequest) + ->through($router->gatherRouteMiddleware($route)) + ->then(fn ($request) => $router->prepareResponse($request, $route->run())); + } catch (HttpExceptionInterface $e) { + return ['status' => $e->getStatusCode(), 'body' => ['message' => $e->getMessage() ?: 'Not found.']]; + } catch (Throwable $e) { + report($e); + + return ['status' => 500, 'body' => ['message' => 'Something went wrong while processing the request.']]; + } finally { + app()->instance('request', $original); + Facade::clearResolvedInstance('request'); + } + + $decoded = json_decode((string) $response->getContent(), true); + + if (is_array($decoded)) { + $decoded = $this->removeNeverReturnedKeys($decoded); + // Some REST write responses include credentials (for example database URLs) for write-only tokens. + if ($subRequest->attributes->get('can_read_sensitive') !== true) { + // REST management actions use the record id for follow-up updates and deletes. + // Keep foreign IDs and credentials filtered; read tools still omit record IDs. + $decoded = $this->scrubSensitive($decoded, ['id']); + } + } + + return [ + 'status' => $response->getStatusCode(), + 'body' => is_array($decoded) ? $decoded : ['message' => (string) $response->getContent()], + ]; + } + + private function isSafePathSegment(mixed $value): bool + { + if (is_int($value)) { + return $value >= 0; + } + + return is_string($value) && preg_match('/^[A-Za-z0-9][A-Za-z0-9_.:\-]{0,254}$/', $value) === 1 && ! str_contains($value, '..'); + } + + /** + * @param array $data + * @return array + */ + private function removeNeverReturnedKeys(array $data): array + { + $out = []; + foreach ($data as $key => $value) { + if (is_string($key) && preg_match($this->neverReturnKeyPattern, $key) === 1) { + continue; + } + $out[$key] = is_array($value) ? $this->removeNeverReturnedKeys($value) : $value; + } + + return $out; + } +} diff --git a/app/Mcp/Concerns/ChecksTokenAbilities.php b/app/Mcp/Concerns/ChecksTokenAbilities.php new file mode 100644 index 0000000000..a46d425024 --- /dev/null +++ b/app/Mcp/Concerns/ChecksTokenAbilities.php @@ -0,0 +1,17 @@ +user()?->currentAccessToken(); + + return $token !== null && ($token->can('root') || $token->can($ability)); + } +} diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php index fc15f78867..d77455b170 100644 --- a/app/Mcp/Concerns/ResolvesTeam.php +++ b/app/Mcp/Concerns/ResolvesTeam.php @@ -68,6 +68,18 @@ trait ResolvesTeam return Response::error("Missing required permissions: {$ability}"); } + /** + * Destructive tools must call this so agents cannot cause damage or downtime without an explicit confirm=true. + */ + protected function ensureConfirmed(Request $request, string $action): ?Response + { + if (filter_var($request->get('confirm'), FILTER_VALIDATE_BOOLEAN)) { + return null; + } + + return $this->mcpError($request, "{$action} requires confirm=true."); + } + protected function resolveTeamId(Request $request): ?int { $user = $request->user(); diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php index 957109590a..115b36c0fc 100644 --- a/app/Mcp/Servers/CoolifyServer.php +++ b/app/Mcp/Servers/CoolifyServer.php @@ -7,26 +7,36 @@ use App\Mcp\Prompts\TroubleshootApplication; use App\Mcp\Resources\ApplicationResource; use App\Mcp\Resources\InfrastructureOverviewResource; use App\Mcp\Tools\CancelDeployment; +use App\Mcp\Tools\CloudProvider; use App\Mcp\Tools\Control; use App\Mcp\Tools\CoolifyHelp; +use App\Mcp\Tools\CreateApplication; +use App\Mcp\Tools\CreateDatabase; +use App\Mcp\Tools\CreateService; use App\Mcp\Tools\Deploy; +use App\Mcp\Tools\DockerCleanup; use App\Mcp\Tools\GetApplication; +use App\Mcp\Tools\GetApplicationPreview; use App\Mcp\Tools\GetCurrentTeam; use App\Mcp\Tools\GetDatabase; +use App\Mcp\Tools\GetDatabaseImport; use App\Mcp\Tools\GetDeployment; use App\Mcp\Tools\GetDestination; use App\Mcp\Tools\GetEnvironment; +use App\Mcp\Tools\GetEnvValues; use App\Mcp\Tools\GetInfrastructureOverview; use App\Mcp\Tools\GetLogs; use App\Mcp\Tools\GetProject; use App\Mcp\Tools\GetServer; use App\Mcp\Tools\GetServerDomains; +use App\Mcp\Tools\GetServerMetrics; use App\Mcp\Tools\GetServerResources; use App\Mcp\Tools\GetService; use App\Mcp\Tools\GetServiceApplication; use App\Mcp\Tools\GetServiceDatabase; use App\Mcp\Tools\ListApplicationPreviews; use App\Mcp\Tools\ListApplications; +use App\Mcp\Tools\ListAuditEvents; use App\Mcp\Tools\ListBackupExecutions; use App\Mcp\Tools\ListDatabaseBackups; use App\Mcp\Tools\ListDatabases; @@ -39,25 +49,48 @@ use App\Mcp\Tools\ListGithubRepositories; use App\Mcp\Tools\ListProjects; use App\Mcp\Tools\ListResources; use App\Mcp\Tools\ListResourceTags; +use App\Mcp\Tools\ListRollbackImages; use App\Mcp\Tools\ListScheduledTaskExecutions; use App\Mcp\Tools\ListScheduledTasks; use App\Mcp\Tools\ListServers; use App\Mcp\Tools\ListServiceApplications; use App\Mcp\Tools\ListServiceDatabases; use App\Mcp\Tools\ListServices; +use App\Mcp\Tools\ListServiceTemplates; use App\Mcp\Tools\ListSharedEnvKeys; use App\Mcp\Tools\ListStorages; use App\Mcp\Tools\ListTags; use App\Mcp\Tools\ListTeamMembers; use App\Mcp\Tools\ListUnhealthyResources; +use App\Mcp\Tools\ManageApplication; +use App\Mcp\Tools\ManageBackup; +use App\Mcp\Tools\ManageDatabase; +use App\Mcp\Tools\ManageDestination; +use App\Mcp\Tools\ManageEnv; +use App\Mcp\Tools\ManageGitSource; +use App\Mcp\Tools\ManageNotifications; +use App\Mcp\Tools\ManagePreview; +use App\Mcp\Tools\ManagePrivateKey; +use App\Mcp\Tools\ManageProject; +use App\Mcp\Tools\ManageProxy; +use App\Mcp\Tools\ManageRegistry; +use App\Mcp\Tools\ManageS3Storage; +use App\Mcp\Tools\ManageScheduledTask; +use App\Mcp\Tools\ManageServer; +use App\Mcp\Tools\ManageService; +use App\Mcp\Tools\ManageSharedEnv; +use App\Mcp\Tools\ManageStorage; +use App\Mcp\Tools\ManageTags; +use App\Mcp\Tools\RollbackApplication; use App\Mcp\Tools\SearchResources; use Laravel\Mcp\Server; +use Laravel\Mcp\Server\Tool; class CoolifyServer extends Server { protected string $name = 'Coolify'; - protected string $version = '0.2.0'; + protected string $version = '0.3.0'; /** * Return all registered tools in a single tools/list page (default package limit is 15). @@ -70,7 +103,7 @@ class CoolifyServer extends Server Coolify MCP for the authenticated team token. Every tool enforces team ownership. Start here (prefer these before deep get_*): -1. coolify_help — tool catalog by intent (overview|search|debug|deploy|essentials). +1. coolify_help — tools this token can use, plus step-by-step recipes (topic=deploy_app_from_git, add_database_to_app, ...). 2. get_infrastructure_overview — counts + health_hints. 3. search_resources — fuzzy name/UUID/domain when type is unknown. 4. list_unhealthy_resources sample_only=true — cheap "what's broken?" sample + counts. @@ -82,15 +115,22 @@ Then: list_*/get_* for details. Debug is DB-first: Lifecycle (requires token ability **deploy**): - control (start|stop|restart; stop needs confirm=true) -- deploy, cancel_deployment +- deploy, cancel_deployment, rollback_application + +Changes (requires token ability **write**): manage_* and create_* tools. They run the same REST API routes, with the same validation and permissions. Destructive actions need confirm=true: ask the user first. Prompts: troubleshoot_application, explain_failed_deploy. Resources: coolify://overview, coolify://application/{uuid}. -Responses: `{ data, _actions?, _pagination? }`. Env values, configuration snapshots, and full deploy logs are never returned. Optional deploy log summaries are best-effort redacted only. +Responses: `{ data, _actions?, _pagination? }`. Private keys, S3 secrets, tokens, passwords, and webhook URLs are never returned. Env values only with get_env_values (read:sensitive). Full deploy logs are never returned; optional log summaries are best-effort redacted only. MD; - protected array $tools = [ + /** + * Every tool class. Tools hide themselves when the token cannot use them (shouldRegister). + * + * @var array> + */ + public const TOOLS = [ CoolifyHelp::class, GetInfrastructureOverview::class, SearchResources::class, @@ -136,8 +176,42 @@ MD; Control::class, Deploy::class, CancelDeployment::class, + ManageProject::class, + CreateDatabase::class, + ManageDatabase::class, + GetDatabaseImport::class, + ListServiceTemplates::class, + CreateService::class, + ManageService::class, + ManageEnv::class, + ManageSharedEnv::class, + GetEnvValues::class, + CreateApplication::class, + ManageApplication::class, + ManagePreview::class, + GetApplicationPreview::class, + RollbackApplication::class, + ListRollbackImages::class, + ManageScheduledTask::class, + ManageBackup::class, + ManageStorage::class, + ManageTags::class, + ManageServer::class, + ManageProxy::class, + DockerCleanup::class, + ManageDestination::class, + ManageRegistry::class, + ManagePrivateKey::class, + ManageS3Storage::class, + ManageGitSource::class, + CloudProvider::class, + ManageNotifications::class, + ListAuditEvents::class, + GetServerMetrics::class, ]; + protected array $tools = self::TOOLS; + protected array $resources = [ InfrastructureOverviewResource::class, ApplicationResource::class, diff --git a/app/Mcp/Tools/CancelDeployment.php b/app/Mcp/Tools/CancelDeployment.php index c9913c4e5f..a7cb96ef84 100644 --- a/app/Mcp/Tools/CancelDeployment.php +++ b/app/Mcp/Tools/CancelDeployment.php @@ -5,6 +5,7 @@ namespace App\Mcp\Tools; use App\Actions\Application\CleanupCancelledDeployment; use App\Enums\ApplicationDeploymentStatus; use App\Mcp\Concerns\BuildsResponse; +use App\Mcp\Concerns\ChecksTokenAbilities; use App\Mcp\Concerns\ResolvesTeam; use App\Models\ApplicationDeploymentQueue; use App\Models\Server; @@ -12,7 +13,13 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; +use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly(false)] +#[IsDestructive] +#[IsIdempotent] class CancelDeployment extends Tool { protected string $name = 'cancel_deployment'; @@ -20,8 +27,14 @@ class CancelDeployment extends Tool protected string $description = 'Cancel a queued or in-progress deployment by deployment UUID for the authenticated team. Requires deploy ability.'; use BuildsResponse; + use ChecksTokenAbilities; use ResolvesTeam; + public function shouldRegister(): bool + { + return $this->currentTokenCan('deploy'); + } + public function handle(Request $request): Response { if ($error = $this->ensureAbility($request, 'deploy', $this->name)) { diff --git a/app/Mcp/Tools/CloudProvider.php b/app/Mcp/Tools/CloudProvider.php new file mode 100644 index 0000000000..fdc545c258 --- /dev/null +++ b/app/Mcp/Tools/CloudProvider.php @@ -0,0 +1,72 @@ + 'hetzner/locations', + 'hetzner_server_types' => 'hetzner/server-types', + 'hetzner_images' => 'hetzner/images', + 'hetzner_ssh_keys' => 'hetzner/ssh-keys', + 'hetzner_firewalls' => 'hetzner/firewalls', + 'hetzner_networks' => 'hetzner/networks', + 'digitalocean_regions' => 'digitalocean/regions', + 'digitalocean_sizes' => 'digitalocean/sizes', + 'digitalocean_images' => 'digitalocean/images', + 'digitalocean_ssh_keys' => 'digitalocean/ssh-keys', + 'vultr_regions' => 'vultr/regions', + 'vultr_plans' => 'vultr/plans', + 'vultr_os' => 'vultr/os', + 'vultr_ssh_keys' => 'vultr/ssh-keys', + 'hostinger_data_centers' => 'hostinger/data-centers', + 'hostinger_catalog' => 'hostinger/catalog', + 'hostinger_templates' => 'hostinger/templates', + 'hostinger_ssh_keys' => 'hostinger/ssh-keys', + 'hostinger_post_install_scripts' => 'hostinger/post-install-scripts', + ]; + + private const PROVISION_PATHS = [ + 'hetzner' => 'servers/hetzner', + 'digitalocean' => 'servers/digitalocean', + 'vultr' => 'servers/vultr', + 'hostinger' => 'servers/hostinger', + ]; + + protected function actions(): array + { + return [ + 'list_tokens' => ['method' => 'GET', 'path' => 'cloud-tokens', 'ability' => 'read', 'description' => 'cloud provider tokens of the team (no token values)'], + 'get_token' => ['method' => 'GET', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'read', 'description' => 'one cloud provider token (no token value)'], + 'create_token' => ['method' => 'POST', 'path' => 'cloud-tokens', 'ability' => 'write', 'description' => 'add a provider token; it is checked with the provider first (data: provider hetzner|digitalocean|vultr|hostinger, token, name)'], + 'update_token' => ['method' => 'PATCH', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'write', 'description' => 'rename a provider token (data: name)'], + 'delete_token' => ['method' => 'DELETE', 'path' => 'cloud-tokens/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a provider token that no server uses'], + 'validate_token' => ['method' => 'POST', 'path' => 'cloud-tokens/{uuid}/validate', 'ability' => 'write', 'description' => 'check a stored provider token with the provider'], + 'catalog' => ['method' => 'GET', 'paths' => self::CATALOG_PATHS, 'ability' => 'read', 'description' => 'read a provider catalog; resource is _, for example hetzner_locations (data: cloud_provider_token_uuid)'], + 'provision_server' => ['method' => 'POST', 'paths' => self::PROVISION_PATHS, 'ability' => 'write', 'confirm' => true, 'description' => 'create a new paid server at the provider and add it to Coolify; resource is the provider (data: cloud_provider_token_uuid, private_key_uuid, name, and the provider fields, for example location, server_type, image for hetzner)'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Cloud provider token UUID (get_token, update_token, delete_token, validate_token).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body or catalog query, same fields as the REST API. Catalogs and provision_server need cloud_provider_token_uuid.'; + } +} diff --git a/app/Mcp/Tools/Control.php b/app/Mcp/Tools/Control.php index debbb4a080..3abd1fd744 100644 --- a/app/Mcp/Tools/Control.php +++ b/app/Mcp/Tools/Control.php @@ -10,6 +10,8 @@ use App\Actions\Service\RestartService; use App\Actions\Service\StartService; use App\Actions\Service\StopService; use App\Mcp\Concerns\BuildsResponse; +use App\Mcp\Concerns\CallsRestApi; +use App\Mcp\Concerns\ChecksTokenAbilities; use App\Mcp\Concerns\ResolvesResource; use App\Mcp\Concerns\ResolvesTeam; use App\Models\Application; @@ -19,17 +21,28 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly(false)] +#[IsDestructive] class Control extends Tool { protected string $name = 'control'; - protected string $description = 'Start, stop, or restart an application, database, or service owned by the authenticated team. Requires deploy ability. Stop requires confirm=true.'; + protected string $description = 'Start, stop, or restart an application, database, service, or a single service application or service database (service_uuid + uuid) owned by the authenticated team. Requires deploy ability. Stop requires confirm=true.'; use BuildsResponse; + use CallsRestApi; + use ChecksTokenAbilities; use ResolvesResource; use ResolvesTeam; + public function shouldRegister(): bool + { + return $this->currentTokenCan('deploy'); + } + public function handle(Request $request): Response { if ($error = $this->ensureAbility($request, 'deploy', $this->name)) { @@ -44,10 +57,9 @@ class Control extends Tool $resourceType = $request->get('resource'); $action = $request->get('action'); $uuid = $request->get('uuid'); - $confirm = filter_var($request->get('confirm'), FILTER_VALIDATE_BOOLEAN); - if (! is_string($resourceType) || ! in_array($resourceType, ['application', 'database', 'service'], true)) { - return $this->mcpError($request, 'resource must be application, database, or service.'); + if (! is_string($resourceType) || ! in_array($resourceType, ['application', 'database', 'service', 'service_application', 'service_database'], true)) { + return $this->mcpError($request, 'resource must be application, database, service, service_application, or service_database.'); } if (! is_string($action) || ! in_array($action, ['start', 'stop', 'restart'], true)) { return $this->mcpError($request, 'action must be start, stop, or restart.'); @@ -55,8 +67,12 @@ class Control extends Tool if (! is_string($uuid) || $uuid === '') { return $this->mcpError($request, 'uuid argument is required.'); } - if ($action === 'stop' && ! $confirm) { - return $this->mcpError($request, 'stop requires confirm=true to prevent accidental downtime.'); + if ($action === 'stop' && $error = $this->ensureConfirmed($request, 'stop')) { + return $error; + } + + if ($resourceType === 'service_application' || $resourceType === 'service_database') { + return $this->controlServiceChild($request, $teamId, $resourceType, $action, $uuid); } $resource = $this->resolveTeamResource($teamId, $resourceType, $uuid); @@ -191,12 +207,57 @@ class Control extends Tool return ['message' => 'Service starting request queued.']; } + /** + * Runs the REST route for a single service application or service database, with its own team scoping and policies. + */ + private function controlServiceChild(Request $request, int $teamId, string $resourceType, string $action, string $uuid): Response + { + $serviceUuid = $request->get('service_uuid'); + if (! is_string($serviceUuid) || $serviceUuid === '') { + return $this->mcpError($request, "service_uuid argument is required for {$resourceType}."); + } + + $path = $resourceType === 'service_application' + ? "services/{service_uuid}/applications/{uuid}/{$action}" + : "services/{service_uuid}/databases/{uuid}/{$action}"; + + try { + $result = $this->callRestApi('POST', $path, ['service_uuid' => $serviceUuid, 'uuid' => $uuid]); + } catch (\InvalidArgumentException $e) { + return $this->mcpError($request, $e->getMessage(), ['resource_uuid' => $uuid]); + } + + $body = is_array($result['body']) ? $result['body'] : []; + $context = ['resource_uuid' => $uuid, 'service_uuid' => $serviceUuid, 'action' => $action, 'status' => $result['status']]; + if ($result['status'] >= 400) { + return $this->mcpError($request, (string) ($body['message'] ?? 'Request failed.'), $context); + } + + auditLog('mcp.control', [ + 'team_id' => $teamId, + 'resource' => $resourceType, + 'action' => $action, + 'resource_uuid' => $uuid, + 'service_uuid' => $serviceUuid, + ]); + + return $this->mcpSuccess($request, $this->respond([ + 'ok' => true, + 'resource' => $resourceType, + 'uuid' => $uuid, + 'service_uuid' => $serviceUuid, + 'action' => $action, + 'message' => $body['message'] ?? null, + ]), $context); + } + public function schema(JsonSchema $schema): array { return [ - 'resource' => $schema->string()->description('application | database | service')->required(), + 'resource' => $schema->string()->description('application | database | service | service_application | service_database')->required(), 'action' => $schema->string()->description('start | stop | restart')->required(), - 'uuid' => $schema->string()->description('Resource UUID.')->required(), + 'uuid' => $schema->string()->description('Resource UUID. For service_application / service_database: the UUID of the service application or service database.')->required(), + 'service_uuid' => $schema->string()->description('Parent service UUID. Required when resource is service_application or service_database.'), 'confirm' => $schema->boolean()->description('Required true when action=stop.'), ]; } diff --git a/app/Mcp/Tools/CoolifyHelp.php b/app/Mcp/Tools/CoolifyHelp.php index 784b106d48..a3e4e09c36 100644 --- a/app/Mcp/Tools/CoolifyHelp.php +++ b/app/Mcp/Tools/CoolifyHelp.php @@ -4,177 +4,183 @@ namespace App\Mcp\Tools; use App\Mcp\Concerns\BuildsResponse; use App\Mcp\Concerns\ResolvesTeam; +use App\Mcp\Servers\CoolifyServer; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Str; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class CoolifyHelp extends Tool { protected string $name = 'coolify_help'; - protected string $description = 'Tool catalog by intent. Call first when unsure which Coolify MCP tool to use. Optional intent: overview | search | inventory | debug | deploy | github | team | essentials.'; + protected string $description = 'Start here. Lists the Coolify MCP tools this token can use and step-by-step recipes for multi-step tasks. Optional: topic (recipe name) or search (filter tools).'; use BuildsResponse; use ResolvesTeam; + /** + * Multi-step workflows. Each step names a registered tool (and action for grouped tools). + * + * @var array}> + */ + public const RECIPES = [ + 'deploy_app_from_git' => [ + 'description' => 'Create and deploy an application from a Git repository', + 'steps' => [ + ['tool' => 'list_projects', 'note' => 'Find the project, or create one with manage_project action=create.'], + ['tool' => 'get_project', 'note' => 'Pick the environment (name or UUID).'], + ['tool' => 'list_servers', 'note' => 'Pick a reachable server_uuid.'], + ['tool' => 'list_destinations', 'note' => 'Needed only when the server has more than one destination.'], + ['tool' => 'list_github_apps', 'note' => 'Private repo: use a GitHub app, or a deploy key (manage_private_key).'], + ['tool' => 'create_application', 'action' => 'public', 'note' => 'Or action=github_app / deploy_key. Set git_repository, git_branch, build_pack, ports_exposes.'], + ['tool' => 'manage_env', 'action' => 'bulk_update', 'note' => 'Set environment variables (resource=application).'], + ['tool' => 'deploy', 'note' => 'Queue the first deployment.'], + ['tool' => 'get_deployment', 'note' => 'Follow the status; include_log_summary=true on failure.'], + ], + ], + 'add_database_to_app' => [ + 'description' => 'Create a database and connect an application to it', + 'steps' => [ + ['tool' => 'get_application', 'note' => 'Note the project, environment, and server of the app.'], + ['tool' => 'create_database', 'action' => 'postgresql', 'note' => 'Use the same environment and server_uuid as the app.'], + ['tool' => 'control', 'note' => 'Start the database (resource=database, action=start).'], + ['tool' => 'get_env_values', 'note' => 'Read the internal connection URL (needs read:sensitive).'], + ['tool' => 'manage_env', 'action' => 'create', 'note' => 'Add DATABASE_URL to the application.'], + ['tool' => 'deploy', 'note' => 'Redeploy the app so it gets the new variable.'], + ], + ], + 'deploy_service_from_template' => [ + 'description' => 'Deploy a one-click service', + 'steps' => [ + ['tool' => 'list_service_templates', 'note' => 'Find the template type (search=...).'], + ['tool' => 'create_service', 'action' => 'template', 'note' => 'Set type, project, environment, server_uuid.'], + ['tool' => 'manage_service', 'action' => 'update_application', 'note' => 'Set domains on the service application.'], + ['tool' => 'control', 'note' => 'Start it (resource=service, action=start).'], + ], + ], + 'add_server' => [ + 'description' => 'Connect a new server over SSH', + 'steps' => [ + ['tool' => 'manage_private_key', 'action' => 'create', 'note' => 'Add the SSH private key (never returned again).'], + ['tool' => 'manage_server', 'action' => 'create', 'note' => 'Set name, ip, port, user, private_key_uuid.'], + ['tool' => 'manage_server', 'action' => 'validate', 'note' => 'Check the connection and install Docker if needed.'], + ['tool' => 'get_server', 'note' => 'Check that the server is reachable and usable.'], + ['tool' => 'manage_proxy', 'action' => 'get', 'note' => 'Check the proxy.'], + ], + ], + 'debug_failed_deploy' => [ + 'description' => 'Find why a deployment failed', + 'steps' => [ + ['tool' => 'list_deployments', 'note' => 'application_uuid=... for the history.'], + ['tool' => 'get_deployment', 'note' => 'include_log_summary=true (needs read:sensitive).'], + ['tool' => 'list_env_keys', 'note' => 'Check for missing variables (names only).'], + ['tool' => 'get_logs', 'note' => 'Only if the container runs (needs read:sensitive).'], + ['tool' => 'deploy', 'note' => 'Redeploy after the fix; force=true to rebuild.'], + ], + ], + 'backup_database' => [ + 'description' => 'Back up a database to S3', + 'steps' => [ + ['tool' => 'manage_s3_storage', 'action' => 'create', 'note' => 'Add the S3 storage (the secret is never returned).'], + ['tool' => 'manage_s3_storage', 'action' => 'validate', 'note' => 'Check the credentials.'], + ['tool' => 'manage_backup', 'action' => 'create', 'note' => 'Set frequency, s3_storage_uuid, save_s3=true; backup_now=true runs it at once.'], + ['tool' => 'list_backup_executions', 'note' => 'Check the result.'], + ], + ], + ]; + + /** + * @var array + */ + private const START_HERE = ['get_infrastructure_overview', 'search_resources', 'list_unhealthy_resources']; + public function handle(Request $request): Response { if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } - $teamId = $this->resolveTeamId($request); - if (is_null($teamId)) { + if (is_null($this->resolveTeamId($request))) { return $this->mcpError($request, 'Invalid token.'); } - $intent = $request->get('intent'); - if ($intent !== null && (! is_string($intent) || trim($intent) === '')) { - return $this->mcpError($request, 'intent must be a non-empty string when provided.'); - } - $intent = is_string($intent) ? strtolower(trim($intent)) : null; + $tools = $this->visibleTools(); - $catalog = [ - 'overview' => [ - 'description' => 'Bird\'s-eye view and "what is broken?"', - 'tools' => [ - 'get_infrastructure_overview', - 'list_unhealthy_resources', - 'search_resources', - 'get_current_team', - ], - ], - 'search' => [ - 'description' => 'Find a resource by name/UUID/domain without knowing the type', - 'tools' => [ - 'search_resources', - 'list_resources', - 'list_tags', - ], - ], - 'inventory' => [ - 'description' => 'Browse servers, projects, apps, DBs, services', - 'tools' => [ - 'list_servers', - 'get_server', - 'list_projects', - 'get_project', - 'get_environment', - 'list_applications', - 'get_application', - 'list_databases', - 'get_database', - 'list_services', - 'get_service', - 'list_destinations', - 'get_destination', - ], - ], - 'debug' => [ - 'description' => 'Diagnose failures (prefer DB-backed tools before live logs)', - 'tools' => [ - 'get_application', - 'list_deployments', - 'get_deployment', - 'list_env_keys', - 'list_shared_env_keys', - 'list_application_previews', - 'list_storages', - 'list_scheduled_tasks', - 'get_logs', - ], - 'notes' => [ - 'get_logs needs a running container on a reachable server; on failure it returns reason + next_tools.', - 'Use get_deployment with include_log_summary=true for build failures.', - 'Prompts: troubleshoot_application, explain_failed_deploy.', - ], - ], - 'deploy' => [ - 'description' => 'Lifecycle actions (requires token ability: deploy)', - 'tools' => [ - 'control', - 'deploy', - 'cancel_deployment', - 'list_deployments', - 'get_deployment', - ], - 'notes' => [ - 'stop requires confirm=true.', - 'Read-only tokens receive a clear missing_ability error.', - ], - ], - 'github' => [ - 'description' => 'GitHub apps / repos / branches (list_github_apps is DB-only; repos/branches call GitHub)', - 'tools' => [ - 'list_github_apps', - 'list_github_repositories', - 'list_github_branches', - ], - ], - 'team' => [ - 'description' => 'Team identity', - 'tools' => [ - 'get_current_team', - 'list_team_members', - ], - ], - 'essentials' => [ - 'description' => 'Minimal set for most questions', - 'tools' => [ - 'coolify_help', - 'get_infrastructure_overview', - 'search_resources', - 'list_unhealthy_resources', - 'list_applications', - 'get_application', - 'list_deployments', - 'get_deployment', - 'list_env_keys', - 'get_logs', - 'control', - 'deploy', - ], - ], - ]; - - if ($intent !== null) { - if (! isset($catalog[$intent])) { - return $this->mcpError($request, 'Unknown intent. Use: '.implode(', ', array_keys($catalog))); + $topic = $request->get('topic'); + if ($topic !== null) { + if (! is_string($topic) || ! isset(self::RECIPES[$topic])) { + return $this->mcpError($request, 'Unknown topic. Use: '.implode(', ', array_keys(self::RECIPES)).'.'); } return $this->mcpSuccess($request, $this->respond([ - 'intent' => $intent, - 'catalog' => [$intent => $catalog[$intent]], - 'workflow' => $this->workflowHints(), + 'topic' => $topic, + 'description' => self::RECIPES[$topic]['description'], + 'steps' => collect(self::RECIPES[$topic]['steps']) + ->map(fn (array $step): array => $step + ['available' => isset($tools[$step['tool']])]) + ->all(), + 'notes' => ['Steps with available=false need more token abilities.', 'Ask the user before every action that needs confirm=true.'], ])); } + $search = $request->get('search'); + if ($search !== null && (! is_string($search) || trim($search) === '')) { + return $this->mcpError($request, 'search must be a non-empty string when provided.'); + } + if (is_string($search)) { + $needle = Str::lower(trim($search)); + $tools = array_filter($tools, fn (array $tool, string $name): bool => str_contains($name, $needle) || str_contains(Str::lower($tool['summary']), $needle), ARRAY_FILTER_USE_BOTH); + } + return $this->mcpSuccess($request, $this->respond([ - 'intents' => array_keys($catalog), - 'catalog' => $catalog, - 'workflow' => $this->workflowHints(), + 'start_here' => array_values(array_filter(self::START_HERE, fn (string $name): bool => isset($tools[$name]))), + 'read_tools' => $this->summaries(array_filter($tools, fn (array $tool): bool => $tool['read_only'])), + 'change_tools' => $this->summaries(array_filter($tools, fn (array $tool): bool => ! $tool['read_only'])), + 'recipes' => collect(self::RECIPES)->map(fn (array $recipe): string => $recipe['description'])->all(), + 'workflow' => [ + 'Resolve a UUID with search_resources or list_*, then get_* for details.', + 'Debug with list_deployments / get_deployment before get_logs.', + 'Change tools use the same validation and permissions as the REST API.', + 'Ask the user before every action that needs confirm=true.', + 'Call coolify_help with topic= for step-by-step guides.', + ], ])); } /** - * @return array + * Registered tools that this token can see, keyed by name. + * + * @return array */ - private function workflowHints(): array + private function visibleTools(): array { - return [ - 'Start with coolify_help, get_infrastructure_overview, search_resources, or list_unhealthy_resources (sample_only=true).', - 'Resolve a UUID, then get_* for details.', - 'Debug with list_deployments / get_deployment(include_log_summary) before get_logs.', - 'get_logs is optional and fails structured when not running or server unreachable.', - 'Never request env values or secrets over MCP.', - ]; + return collect(CoolifyServer::TOOLS) + ->map(fn (string $class): Tool => app($class)) + ->filter(fn (Tool $tool): bool => $tool->eligibleForRegistration()) + ->mapWithKeys(fn (Tool $tool): array => [$tool->name() => [ + 'summary' => Str::before($tool->description(), '. ').'.', + 'read_only' => ($tool->annotations()['readOnlyHint'] ?? false) === true, + ]]) + ->all(); + } + + /** + * @param array $tools + * @return array + */ + private function summaries(array $tools): array + { + return array_map(fn (array $tool): string => Str::limit(rtrim($tool['summary'], '.').'.', 140), $tools); } public function schema(JsonSchema $schema): array { return [ - 'intent' => $schema->string()->description('Optional: overview | search | inventory | debug | deploy | github | team | essentials.'), + 'topic' => $schema->string()->enum(array_keys(self::RECIPES))->description('Optional recipe: step-by-step guide for a multi-step task.'), + 'search' => $schema->string()->description('Optional: only list tools whose name or summary contains this text.'), ]; } } diff --git a/app/Mcp/Tools/CreateApplication.php b/app/Mcp/Tools/CreateApplication.php new file mode 100644 index 0000000000..249c0b4b1c --- /dev/null +++ b/app/Mcp/Tools/CreateApplication.php @@ -0,0 +1,39 @@ + ['method' => 'POST', 'path' => 'applications/public', 'ability' => 'write', 'description' => 'create from a public Git repository (data: git_repository, git_branch, build_pack, ports_exposes)'], + 'github_app' => ['method' => 'POST', 'path' => 'applications/private-github-app', 'ability' => 'write', 'description' => 'create from a private repository through a GitHub App (data: github_app_uuid, git_repository, git_branch, build_pack, ports_exposes)'], + 'deploy_key' => ['method' => 'POST', 'path' => 'applications/private-deploy-key', 'ability' => 'write', 'description' => 'create from a private repository with a deploy key (data: private_key_uuid, git_repository, git_branch, build_pack, ports_exposes)'], + 'dockerfile' => ['method' => 'POST', 'path' => 'applications/dockerfile', 'ability' => 'write', 'description' => 'create from a base64 encoded Dockerfile (data: dockerfile, ports_exposes)'], + 'docker_image' => ['method' => 'POST', 'path' => 'applications/dockerimage', 'ability' => 'write', 'description' => 'create from a Docker image (data: docker_registry_image_name, docker_registry_image_tag, ports_exposes)'], + ]; + } + + protected function pathParams(): array + { + return []; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API. Always: project_uuid, environment_name or environment_uuid, server_uuid, destination_uuid (required when the server has several destinations). ' + .'public / github_app / deploy_key: git_repository, git_branch, build_pack (nixpacks | railpack | static | dockerfile | dockercompose), ports_exposes (not for dockercompose); github_app also needs github_app_uuid, deploy_key needs private_key_uuid. ' + .'dockerfile: dockerfile (base64). docker_image: docker_registry_image_name, docker_registry_image_tag, ports_exposes. ' + .'Optional: name, description, domains, autogenerate_domain, git_commit_sha, base_directory, publish_directory, install_command, build_command, start_command, docker_compose_location, docker_compose_domains, is_static, is_spa, health_check_*, limits_*, tags, instant_deploy (needs deploy ability).'; + } +} diff --git a/app/Mcp/Tools/CreateDatabase.php b/app/Mcp/Tools/CreateDatabase.php new file mode 100644 index 0000000000..39985acded --- /dev/null +++ b/app/Mcp/Tools/CreateDatabase.php @@ -0,0 +1,39 @@ + 'POST', 'path' => "databases/{$type}", 'ability' => 'write', 'description' => "create a {$type} database"]; + } + + return $actions; + } + + protected function pathParams(): array + { + return []; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API. Required: project_uuid, environment_name or environment_uuid, server_uuid; destination_uuid when the server has several destinations. ' + .'Optional: name, description, image, is_public, public_port (1024-65535), public_port_timeout, limits_memory, limits_memory_swap, limits_memory_swappiness, limits_memory_reservation, limits_cpus, limits_cpuset, limits_cpu_shares, tags, instant_deploy (needs deploy ability). ' + .'Type fields: postgresql: postgres_user, postgres_password, postgres_db, postgres_initdb_args, postgres_host_auth_method, postgres_conf (base64). mysql: mysql_root_password, mysql_user, mysql_password, mysql_database, mysql_conf (base64). ' + .'mariadb: mariadb_root_password, mariadb_user, mariadb_password, mariadb_database, mariadb_conf (base64). mongodb: mongo_initdb_root_username, mongo_initdb_root_password, mongo_initdb_database, mongo_conf (base64). ' + .'redis: redis_password, redis_conf (base64). keydb: keydb_password, keydb_conf (base64). dragonfly: dragonfly_password. clickhouse: clickhouse_admin_user, clickhouse_admin_password. sqlite: sqlite_databases.'; + } +} diff --git a/app/Mcp/Tools/CreateService.php b/app/Mcp/Tools/CreateService.php new file mode 100644 index 0000000000..debc76282f --- /dev/null +++ b/app/Mcp/Tools/CreateService.php @@ -0,0 +1,34 @@ + ['method' => 'POST', 'path' => 'services', 'ability' => 'write', 'description' => 'create from a one-click template (data: type)'], + 'compose' => ['method' => 'POST', 'path' => 'services', 'ability' => 'write', 'description' => 'create from a Docker Compose file (data: docker_compose_raw base64, connect_to_docker_network)'], + ]; + } + + protected function pathParams(): array + { + return []; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API. Required: project_uuid, environment_name or environment_uuid, server_uuid; destination_uuid when the server has several destinations; type (template) or docker_compose_raw (compose, base64). ' + .'Optional: name, description, urls (list of {name, url}), force_domain_override, is_container_label_escape_enabled, redact_all_env_values_in_logs, tags, instant_deploy (needs deploy ability).'; + } +} diff --git a/app/Mcp/Tools/Deploy.php b/app/Mcp/Tools/Deploy.php index 980e9e051d..061e29a985 100644 --- a/app/Mcp/Tools/Deploy.php +++ b/app/Mcp/Tools/Deploy.php @@ -3,22 +3,35 @@ namespace App\Mcp\Tools; use App\Mcp\Concerns\BuildsResponse; +use App\Mcp\Concerns\CallsRestApi; +use App\Mcp\Concerns\ChecksTokenAbilities; 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\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly(false)] +#[IsDestructive] class Deploy extends Tool { protected string $name = 'deploy'; - protected string $description = 'Queue a deployment for a team-owned application by UUID. Requires deploy ability. Optional force rebuild and pull_request_id for previews.'; + protected string $description = 'Queue a deployment for a team-owned application by UUID, or deploy every resource of the authenticated team that has a tag (tag + confirm=true). Give exactly one of uuid or tag. Requires deploy ability. Optional force rebuild and pull_request_id for previews (uuid only).'; use BuildsResponse; + use CallsRestApi; + use ChecksTokenAbilities; use ResolvesTeam; + public function shouldRegister(): bool + { + return $this->currentTokenCan('deploy'); + } + public function handle(Request $request): Response { if ($error = $this->ensureAbility($request, 'deploy', $this->name)) { @@ -31,7 +44,16 @@ class Deploy extends Tool } $uuid = $request->get('uuid'); - if (! is_string($uuid) || $uuid === '') { + $tag = $request->get('tag'); + $hasUuid = $uuid !== null && $uuid !== ''; + $hasTag = $tag !== null && $tag !== ''; + if ($hasUuid === $hasTag) { + return $this->mcpError($request, 'Give exactly one of uuid or tag.'); + } + if ($hasTag) { + return $this->deployByTag($request, $teamId, $tag); + } + if (! is_string($uuid)) { return $this->mcpError($request, 'uuid argument is required.'); } @@ -85,12 +107,58 @@ class Deploy extends Tool ]), ['resource_uuid' => $uuid, 'deployment_uuid' => $deploymentUuid]); } + /** + * Deploys every resource of the token team with the tag through the REST deploy route. + */ + private function deployByTag(Request $request, int $teamId, mixed $tag): Response + { + if (! is_string($tag)) { + return $this->mcpError($request, 'tag must be a string.'); + } + if ($request->get('pull_request_id') !== null) { + return $this->mcpError($request, 'pull_request_id cannot be used with tag.'); + } + if ($error = $this->ensureConfirmed($request, 'deploy by tag')) { + return $error; + } + + $force = filter_var($request->get('force'), FILTER_VALIDATE_BOOLEAN); + + try { + $result = $this->callRestApi('POST', 'deploy', [], ['tag' => $tag, 'force' => $force]); + } catch (\InvalidArgumentException $e) { + return $this->mcpError($request, $e->getMessage(), ['tag' => $tag]); + } + + $body = is_array($result['body']) ? $result['body'] : []; + $context = ['tag' => $tag, 'status' => $result['status']]; + if ($result['status'] >= 400) { + return $this->mcpError($request, (string) ($body['message'] ?? 'Request failed.'), $context); + } + + auditLog('mcp.deploy', [ + 'team_id' => $teamId, + 'tag' => $tag, + 'force_rebuild' => $force, + ]); + + return $this->mcpSuccess($request, $this->respond([ + 'ok' => true, + 'tag' => $tag, + 'force' => $force, + 'messages' => $body['message'] ?? [], + 'deployments' => $body['details'] ?? [], + ]), $context); + } + public function schema(JsonSchema $schema): array { return [ - 'uuid' => $schema->string()->description('Application UUID.')->required(), + 'uuid' => $schema->string()->description('Application UUID. Give either uuid or tag.'), + 'tag' => $schema->string()->description('Tag name: deploys every application and service of the authenticated team with this tag. Needs confirm=true.'), 'force' => $schema->boolean()->description('Force rebuild (default false).'), - 'pull_request_id' => $schema->integer()->description('Optional PR id for preview deploy.'), + 'pull_request_id' => $schema->integer()->description('Optional PR id for preview deploy (uuid only).'), + 'confirm' => $schema->boolean()->description('Required true when deploying by tag.'), ]; } } diff --git a/app/Mcp/Tools/DockerCleanup.php b/app/Mcp/Tools/DockerCleanup.php new file mode 100644 index 0000000000..1136958250 --- /dev/null +++ b/app/Mcp/Tools/DockerCleanup.php @@ -0,0 +1,37 @@ + ['method' => 'GET', 'path' => 'servers/{uuid}/docker-cleanup', 'ability' => 'read', 'description' => 'cleanup settings'], + 'update' => ['method' => 'PATCH', 'path' => 'servers/{uuid}/docker-cleanup', 'ability' => 'write', 'description' => 'change cleanup settings (data: docker_cleanup_frequency, docker_cleanup_threshold, force_docker_cleanup, delete_unused_volumes, delete_unused_networks, disable_application_image_retention)'], + 'executions' => ['method' => 'GET', 'path' => 'servers/{uuid}/docker-cleanup/executions', 'ability' => 'read', 'description' => 'last 20 cleanup runs'], + 'run' => ['method' => 'POST', 'path' => 'servers/{uuid}/docker-cleanup/run', 'ability' => 'write', 'confirm' => true, 'description' => 'start a cleanup now; removes unused images, and volumes/networks when enabled (data: delete_unused_volumes, delete_unused_networks)'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Server UUID.', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: docker_cleanup_frequency, docker_cleanup_threshold, force_docker_cleanup, delete_unused_volumes, delete_unused_networks, disable_application_image_retention.'; + } +} diff --git a/app/Mcp/Tools/GetApplication.php b/app/Mcp/Tools/GetApplication.php index 1d2f9f0144..2e45eec30a 100644 --- a/app/Mcp/Tools/GetApplication.php +++ b/app/Mcp/Tools/GetApplication.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetApplication extends Tool { protected string $name = 'get_application'; diff --git a/app/Mcp/Tools/GetApplicationPreview.php b/app/Mcp/Tools/GetApplicationPreview.php new file mode 100644 index 0000000000..2059bf78c1 --- /dev/null +++ b/app/Mcp/Tools/GetApplicationPreview.php @@ -0,0 +1,34 @@ + ['method' => 'GET', 'path' => 'applications/{uuid}/previews/{pull_request_id}', 'ability' => 'read', 'description' => 'preview details'], + 'logs' => ['method' => 'GET', 'path' => 'applications/{uuid}/previews/{pull_request_id}/logs', 'ability' => 'read:sensitive', 'description' => 'preview container logs (data: lines, show_timestamps, service_name)'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application UUID.', + 'pull_request_id' => 'Pull request ID of the preview.', + ]; + } + + protected function dataDescription(): string + { + return 'Query fields for logs, same as the REST API: lines, show_timestamps, service_name.'; + } +} diff --git a/app/Mcp/Tools/GetCurrentTeam.php b/app/Mcp/Tools/GetCurrentTeam.php index 43f622646a..80c571f482 100644 --- a/app/Mcp/Tools/GetCurrentTeam.php +++ b/app/Mcp/Tools/GetCurrentTeam.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetCurrentTeam extends Tool { protected string $name = 'get_current_team'; diff --git a/app/Mcp/Tools/GetDatabase.php b/app/Mcp/Tools/GetDatabase.php index cafe8c2659..63b1f0e85d 100644 --- a/app/Mcp/Tools/GetDatabase.php +++ b/app/Mcp/Tools/GetDatabase.php @@ -8,7 +8,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetDatabase extends Tool { protected string $name = 'get_database'; diff --git a/app/Mcp/Tools/GetDatabaseImport.php b/app/Mcp/Tools/GetDatabaseImport.php new file mode 100644 index 0000000000..924974e339 --- /dev/null +++ b/app/Mcp/Tools/GetDatabaseImport.php @@ -0,0 +1,32 @@ + ['method' => 'GET', 'paths' => [ + 'database' => 'databases/{uuid}/imports/{activity_id}', + 'service_database' => 'services/{uuid}/databases/{database_uuid}/imports/{activity_id}', + ], 'ability' => 'read', 'description' => 'import status'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Database UUID (resource=database) or service UUID (resource=service_database).', + 'database_uuid' => 'Service database UUID (resource=service_database).', + 'activity_id' => 'Import ID returned by the import action.', + ]; + } +} diff --git a/app/Mcp/Tools/GetDeployment.php b/app/Mcp/Tools/GetDeployment.php index b07894462e..b056cb760b 100644 --- a/app/Mcp/Tools/GetDeployment.php +++ b/app/Mcp/Tools/GetDeployment.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetDeployment extends Tool { protected string $name = 'get_deployment'; diff --git a/app/Mcp/Tools/GetDestination.php b/app/Mcp/Tools/GetDestination.php index 5065f799a3..91f3973f80 100644 --- a/app/Mcp/Tools/GetDestination.php +++ b/app/Mcp/Tools/GetDestination.php @@ -10,7 +10,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetDestination extends Tool { protected string $name = 'get_destination'; diff --git a/app/Mcp/Tools/GetEnvValues.php b/app/Mcp/Tools/GetEnvValues.php new file mode 100644 index 0000000000..ba4271bf9d --- /dev/null +++ b/app/Mcp/Tools/GetEnvValues.php @@ -0,0 +1,36 @@ + ['method' => 'GET', 'paths' => [ + 'application' => 'applications/{uuid}/envs', + 'database' => 'databases/{uuid}/envs', + 'service' => 'services/{uuid}/envs', + 'team' => 'team/envs', + 'project' => 'projects/{uuid}/envs', + 'environment' => 'projects/{uuid}/environments/{environment_name_or_uuid}/envs', + 'server' => 'servers/{uuid}/envs', + ], 'ability' => 'read:sensitive', 'description' => 'variables with values'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application, database, service, project, or server UUID. Not used for team.', + 'environment_name_or_uuid' => 'Environment name or UUID (resource environment).', + ]; + } +} diff --git a/app/Mcp/Tools/GetEnvironment.php b/app/Mcp/Tools/GetEnvironment.php index 4ae9073e84..d1110a2a95 100644 --- a/app/Mcp/Tools/GetEnvironment.php +++ b/app/Mcp/Tools/GetEnvironment.php @@ -10,7 +10,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetEnvironment extends Tool { protected string $name = 'get_environment'; diff --git a/app/Mcp/Tools/GetInfrastructureOverview.php b/app/Mcp/Tools/GetInfrastructureOverview.php index b897c57005..dea213425f 100644 --- a/app/Mcp/Tools/GetInfrastructureOverview.php +++ b/app/Mcp/Tools/GetInfrastructureOverview.php @@ -16,7 +16,9 @@ use Illuminate\Support\Collection; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetInfrastructureOverview extends Tool { protected string $name = 'get_infrastructure_overview'; diff --git a/app/Mcp/Tools/GetLogs.php b/app/Mcp/Tools/GetLogs.php index 4d65c0c38f..8bc84fb22a 100644 --- a/app/Mcp/Tools/GetLogs.php +++ b/app/Mcp/Tools/GetLogs.php @@ -3,6 +3,7 @@ namespace App\Mcp\Tools; use App\Mcp\Concerns\BuildsResponse; +use App\Mcp\Concerns\ChecksTokenAbilities; use App\Mcp\Concerns\ResolvesResource; use App\Mcp\Concerns\ResolvesTeam; use App\Models\Application; @@ -14,7 +15,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetLogs extends Tool { protected string $name = 'get_logs'; @@ -22,9 +25,15 @@ class GetLogs extends Tool protected string $description = 'Fetch recent container logs for an application, database, service, or service child. Requires read:sensitive ability. Output is best-effort redacted (not a guarantee). Multi-container services require resource=service_application|service_database. Requires a running container on a reachable server. On failure returns structured reason + next_tools (DB-only follow-ups). Default 100 lines, max 500.'; use BuildsResponse; + use ChecksTokenAbilities; use ResolvesResource; use ResolvesTeam; + public function shouldRegister(): bool + { + return $this->currentTokenCan('read:sensitive'); + } + public function handle(Request $request): Response { if ($error = $this->ensureAbility($request, 'read:sensitive', $this->name)) { diff --git a/app/Mcp/Tools/GetProject.php b/app/Mcp/Tools/GetProject.php index 8c967f8144..e875019348 100644 --- a/app/Mcp/Tools/GetProject.php +++ b/app/Mcp/Tools/GetProject.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetProject extends Tool { protected string $name = 'get_project'; diff --git a/app/Mcp/Tools/GetServer.php b/app/Mcp/Tools/GetServer.php index 771aa7d36e..9466f27d62 100644 --- a/app/Mcp/Tools/GetServer.php +++ b/app/Mcp/Tools/GetServer.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetServer extends Tool { protected string $name = 'get_server'; diff --git a/app/Mcp/Tools/GetServerDomains.php b/app/Mcp/Tools/GetServerDomains.php index 9378549220..b103be84e6 100644 --- a/app/Mcp/Tools/GetServerDomains.php +++ b/app/Mcp/Tools/GetServerDomains.php @@ -13,7 +13,9 @@ use Illuminate\Support\Stringable; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetServerDomains extends Tool { protected string $name = 'get_server_domains'; diff --git a/app/Mcp/Tools/GetServerMetrics.php b/app/Mcp/Tools/GetServerMetrics.php new file mode 100644 index 0000000000..3b34a40bb7 --- /dev/null +++ b/app/Mcp/Tools/GetServerMetrics.php @@ -0,0 +1,136 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + $teamId = $this->resolveTeamId($request); + if (is_null($teamId)) { + return $this->mcpError($request, 'Invalid token.'); + } + + $uuid = $request->get('server_uuid'); + if (! is_string($uuid) || $uuid === '') { + return $this->mcpError($request, 'server_uuid argument is required.'); + } + + $minutes = filter_var($request->get('minutes', self::DEFAULT_MINUTES), FILTER_VALIDATE_INT); + if ($minutes === false || $minutes < 1) { + return $this->mcpError($request, 'minutes must be a positive integer.'); + } + $minutes = min($minutes, self::MAX_MINUTES); + + $server = Server::whereTeamId($teamId)->where('uuid', $uuid)->with('settings')->first(); + if (! $server) { + return $this->mcpError($request, "Server [{$uuid}] not found.", ['resource_uuid' => $uuid]); + } + + if (! $server->isMetricsEnabled()) { + return $this->mcpError($request, 'Metrics are not enabled for this server. Enable Sentinel metrics in the server settings first.', ['resource_uuid' => $uuid]); + } + + /** @var SentinelMetricsClient $client */ + $client = app(SentinelMetricsClient::class, ['server' => $server]); + $range = match (true) { + $minutes <= 1440 => '24h', + $minutes <= 10080 => '7d', + default => '30d', + }; + $sinceMs = now()->subMinutes($minutes)->getTimestampMs(); + + $summary = $this->safely(fn () => $client->summary()); + $network = $this->safely(fn () => $client->networkHistory($range)) ?? ['rx' => [], 'tx' => []]; + + $series = [ + 'cpu_percent' => $this->recent($this->safely(fn () => $client->history('cpu', $range)), $sinceMs), + 'memory_used_bytes' => $this->recent($this->safely(fn () => $client->history('memory', $range)), $sinceMs), + 'disk_used_percent' => $this->recent($this->safely(fn () => $client->history('disk', $range)), $sinceMs), + 'load1' => $this->recent($this->safely(fn () => $client->history('load', $range)), $sinceMs), + 'network_rx_bytes_per_sec' => $this->recent($network['rx'] ?? [], $sinceMs), + 'network_tx_bytes_per_sec' => $this->recent($network['tx'] ?? [], $sinceMs), + ]; + + $data = [ + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'online' => $summary !== null, + 'minutes' => $minutes, + 'bucket_seconds' => intdiv(SentinelMetricsClient::bucketMs($range), 1000), + 'current' => $summary === null ? null : [ + 'cpu_percent' => $summary['cpu'] ?? null, + 'memory_used_bytes' => $summary['memUsed'] ?? null, + 'memory_total_bytes' => $summary['memTotal'] ?? null, + 'disk_used_bytes' => $summary['diskUsed'] ?? null, + 'disk_total_bytes' => $summary['diskTotal'] ?? null, + 'disk_used_percent' => $summary['diskPercent'] ?? null, + 'load1' => $summary['load1'] ?? null, + 'network_rx_bytes_per_sec' => $summary['netRx'] ?? null, + 'network_tx_bytes_per_sec' => $summary['netTx'] ?? null, + ], + 'series' => $series, + 'series_format' => '[timestamp_ms, value] pairs, averaged per bucket', + ]; + + return $this->mcpSuccess($request, $this->respond($data), ['resource_uuid' => $uuid, 'minutes' => $minutes]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'server_uuid' => $schema->string()->description('Server UUID.')->required(), + 'minutes' => $schema->integer()->description('How many minutes of history to return (default 60, max 43200 = 30 days).'), + ]; + } + + /** + * @template T + * + * @param callable(): T $callback + * @return T|null + */ + private function safely(callable $callback): mixed + { + try { + return $callback(); + } catch (\Throwable) { + return null; + } + } + + /** + * @param array|null $points + * @return array + */ + private function recent(?array $points, int $sinceMs): array + { + return array_values(array_filter($points ?? [], fn ($point): bool => is_array($point) && (int) ($point[0] ?? 0) >= $sinceMs)); + } +} diff --git a/app/Mcp/Tools/GetServerResources.php b/app/Mcp/Tools/GetServerResources.php index d796e9315a..ee614938fb 100644 --- a/app/Mcp/Tools/GetServerResources.php +++ b/app/Mcp/Tools/GetServerResources.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetServerResources extends Tool { protected string $name = 'get_server_resources'; diff --git a/app/Mcp/Tools/GetService.php b/app/Mcp/Tools/GetService.php index ad14ddb497..ba5725e902 100644 --- a/app/Mcp/Tools/GetService.php +++ b/app/Mcp/Tools/GetService.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetService extends Tool { protected string $name = 'get_service'; diff --git a/app/Mcp/Tools/GetServiceApplication.php b/app/Mcp/Tools/GetServiceApplication.php index 0d86d03159..b357170dbc 100644 --- a/app/Mcp/Tools/GetServiceApplication.php +++ b/app/Mcp/Tools/GetServiceApplication.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetServiceApplication extends Tool { protected string $name = 'get_service_application'; diff --git a/app/Mcp/Tools/GetServiceDatabase.php b/app/Mcp/Tools/GetServiceDatabase.php index fa3da38634..4eed5c7763 100644 --- a/app/Mcp/Tools/GetServiceDatabase.php +++ b/app/Mcp/Tools/GetServiceDatabase.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class GetServiceDatabase extends Tool { protected string $name = 'get_service_database'; diff --git a/app/Mcp/Tools/ListApplicationPreviews.php b/app/Mcp/Tools/ListApplicationPreviews.php index 8a4e7fc21b..af8bbc064c 100644 --- a/app/Mcp/Tools/ListApplicationPreviews.php +++ b/app/Mcp/Tools/ListApplicationPreviews.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListApplicationPreviews extends Tool { protected string $name = 'list_application_previews'; diff --git a/app/Mcp/Tools/ListApplications.php b/app/Mcp/Tools/ListApplications.php index 3bab877ebc..2191f289e3 100644 --- a/app/Mcp/Tools/ListApplications.php +++ b/app/Mcp/Tools/ListApplications.php @@ -14,7 +14,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListApplications extends Tool { protected string $name = 'list_applications'; diff --git a/app/Mcp/Tools/ListAuditEvents.php b/app/Mcp/Tools/ListAuditEvents.php new file mode 100644 index 0000000000..9fad17d30c --- /dev/null +++ b/app/Mcp/Tools/ListAuditEvents.php @@ -0,0 +1,30 @@ + ['method' => 'GET', 'path' => 'audit-events', 'ability' => 'read', 'description' => 'audit events (data: per_page max 100, page, search, action, source all|ui|api|mcp|webhook|system|scheduler)'], + ]; + } + + protected function pathParams(): array + { + return []; + } + + protected function dataDescription(): string + { + return 'Query filters, same as the REST API: per_page, page, search, action, source.'; + } +} diff --git a/app/Mcp/Tools/ListBackupExecutions.php b/app/Mcp/Tools/ListBackupExecutions.php index b1880babad..5673966b32 100644 --- a/app/Mcp/Tools/ListBackupExecutions.php +++ b/app/Mcp/Tools/ListBackupExecutions.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListBackupExecutions extends Tool { protected string $name = 'list_backup_executions'; diff --git a/app/Mcp/Tools/ListDatabaseBackups.php b/app/Mcp/Tools/ListDatabaseBackups.php index 92bee0da01..8e36faf9d6 100644 --- a/app/Mcp/Tools/ListDatabaseBackups.php +++ b/app/Mcp/Tools/ListDatabaseBackups.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListDatabaseBackups extends Tool { protected string $name = 'list_database_backups'; diff --git a/app/Mcp/Tools/ListDatabases.php b/app/Mcp/Tools/ListDatabases.php index c05efd81eb..e24af38fc9 100644 --- a/app/Mcp/Tools/ListDatabases.php +++ b/app/Mcp/Tools/ListDatabases.php @@ -16,7 +16,9 @@ use Illuminate\Support\Facades\DB; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListDatabases extends Tool { protected string $name = 'list_databases'; diff --git a/app/Mcp/Tools/ListDeployments.php b/app/Mcp/Tools/ListDeployments.php index 8af839a58b..e958cc0c32 100644 --- a/app/Mcp/Tools/ListDeployments.php +++ b/app/Mcp/Tools/ListDeployments.php @@ -10,7 +10,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListDeployments extends Tool { protected string $name = 'list_deployments'; diff --git a/app/Mcp/Tools/ListDestinations.php b/app/Mcp/Tools/ListDestinations.php index dc98020de7..5ccf959d14 100644 --- a/app/Mcp/Tools/ListDestinations.php +++ b/app/Mcp/Tools/ListDestinations.php @@ -11,7 +11,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListDestinations extends Tool { protected string $name = 'list_destinations'; diff --git a/app/Mcp/Tools/ListEnvKeys.php b/app/Mcp/Tools/ListEnvKeys.php index 98fe4281dc..331060c555 100644 --- a/app/Mcp/Tools/ListEnvKeys.php +++ b/app/Mcp/Tools/ListEnvKeys.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListEnvKeys extends Tool { protected string $name = 'list_env_keys'; diff --git a/app/Mcp/Tools/ListGithubApps.php b/app/Mcp/Tools/ListGithubApps.php index 0283bbc0dc..f3048f0140 100644 --- a/app/Mcp/Tools/ListGithubApps.php +++ b/app/Mcp/Tools/ListGithubApps.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListGithubApps extends Tool { protected string $name = 'list_github_apps'; diff --git a/app/Mcp/Tools/ListGithubBranches.php b/app/Mcp/Tools/ListGithubBranches.php index 7954cecfa9..849c401051 100644 --- a/app/Mcp/Tools/ListGithubBranches.php +++ b/app/Mcp/Tools/ListGithubBranches.php @@ -10,7 +10,11 @@ use Illuminate\Support\Facades\Http; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsOpenWorld; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] +#[IsOpenWorld] class ListGithubBranches extends Tool { protected string $name = 'list_github_branches'; diff --git a/app/Mcp/Tools/ListGithubRepositories.php b/app/Mcp/Tools/ListGithubRepositories.php index 7c639f509f..59c5ef273e 100644 --- a/app/Mcp/Tools/ListGithubRepositories.php +++ b/app/Mcp/Tools/ListGithubRepositories.php @@ -10,7 +10,11 @@ use Illuminate\Support\Facades\Http; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsOpenWorld; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] +#[IsOpenWorld] class ListGithubRepositories extends Tool { protected string $name = 'list_github_repositories'; diff --git a/app/Mcp/Tools/ListProjects.php b/app/Mcp/Tools/ListProjects.php index 0a6de7f608..e9b363c709 100644 --- a/app/Mcp/Tools/ListProjects.php +++ b/app/Mcp/Tools/ListProjects.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListProjects extends Tool { protected string $name = 'list_projects'; diff --git a/app/Mcp/Tools/ListResourceTags.php b/app/Mcp/Tools/ListResourceTags.php index 523a524177..481a373e4c 100644 --- a/app/Mcp/Tools/ListResourceTags.php +++ b/app/Mcp/Tools/ListResourceTags.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListResourceTags extends Tool { protected string $name = 'list_resource_tags'; diff --git a/app/Mcp/Tools/ListResources.php b/app/Mcp/Tools/ListResources.php index f6cde3e125..c3f23e3ab1 100644 --- a/app/Mcp/Tools/ListResources.php +++ b/app/Mcp/Tools/ListResources.php @@ -15,7 +15,9 @@ use Illuminate\Support\Facades\DB; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListResources extends Tool { protected string $name = 'list_resources'; diff --git a/app/Mcp/Tools/ListRollbackImages.php b/app/Mcp/Tools/ListRollbackImages.php new file mode 100644 index 0000000000..a77890344d --- /dev/null +++ b/app/Mcp/Tools/ListRollbackImages.php @@ -0,0 +1,27 @@ + ['method' => 'GET', 'path' => 'applications/{uuid}/rollback-images', 'ability' => 'read', 'description' => 'list rollback images and the current tag'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application UUID.', + ]; + } +} diff --git a/app/Mcp/Tools/ListScheduledTaskExecutions.php b/app/Mcp/Tools/ListScheduledTaskExecutions.php index d88636f8fb..8f4ad75b93 100644 --- a/app/Mcp/Tools/ListScheduledTaskExecutions.php +++ b/app/Mcp/Tools/ListScheduledTaskExecutions.php @@ -10,7 +10,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListScheduledTaskExecutions extends Tool { protected string $name = 'list_scheduled_task_executions'; diff --git a/app/Mcp/Tools/ListScheduledTasks.php b/app/Mcp/Tools/ListScheduledTasks.php index cdefe73137..4062d47d21 100644 --- a/app/Mcp/Tools/ListScheduledTasks.php +++ b/app/Mcp/Tools/ListScheduledTasks.php @@ -10,7 +10,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListScheduledTasks extends Tool { protected string $name = 'list_scheduled_tasks'; diff --git a/app/Mcp/Tools/ListServers.php b/app/Mcp/Tools/ListServers.php index 98846b33f6..6fc38962e8 100644 --- a/app/Mcp/Tools/ListServers.php +++ b/app/Mcp/Tools/ListServers.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListServers extends Tool { protected string $name = 'list_servers'; diff --git a/app/Mcp/Tools/ListServiceApplications.php b/app/Mcp/Tools/ListServiceApplications.php index bfe16cdea6..15c1d8b4da 100644 --- a/app/Mcp/Tools/ListServiceApplications.php +++ b/app/Mcp/Tools/ListServiceApplications.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListServiceApplications extends Tool { protected string $name = 'list_service_applications'; diff --git a/app/Mcp/Tools/ListServiceDatabases.php b/app/Mcp/Tools/ListServiceDatabases.php index 1a90ca866e..1d6dd165f7 100644 --- a/app/Mcp/Tools/ListServiceDatabases.php +++ b/app/Mcp/Tools/ListServiceDatabases.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListServiceDatabases extends Tool { protected string $name = 'list_service_databases'; diff --git a/app/Mcp/Tools/ListServiceTemplates.php b/app/Mcp/Tools/ListServiceTemplates.php new file mode 100644 index 0000000000..f52623f70d --- /dev/null +++ b/app/Mcp/Tools/ListServiceTemplates.php @@ -0,0 +1,72 @@ +ensureAbility($request, 'read', $this->name)) { + return $error; + } + + if (is_null($this->resolveTeamId($request))) { + return $this->mcpError($request, 'Invalid token.'); + } + + $search = $request->get('search'); + $search = is_string($search) ? mb_strtolower(trim($search)) : ''; + + $templates = get_service_templates() + ->map(fn ($template, $type): array => [ + 'type' => (string) $type, + 'slogan' => is_string(data_get($template, 'slogan')) ? data_get($template, 'slogan') : null, + 'category' => is_string(data_get($template, 'category')) ? data_get($template, 'category') : null, + 'tags' => collect(data_get($template, 'tags', []))->filter(fn ($tag): bool => is_string($tag))->values()->all(), + 'documentation' => is_string(data_get($template, 'documentation')) ? data_get($template, 'documentation') : null, + 'port' => is_scalar(data_get($template, 'port')) ? (string) data_get($template, 'port') : null, + ]) + ->when($search !== '', fn ($collection) => $collection->filter(function (array $template) use ($search): bool { + $haystack = mb_strtolower(implode(' ', [$template['type'], $template['slogan'] ?? '', $template['category'] ?? '', implode(' ', $template['tags'])])); + + return str_contains($haystack, $search); + })) + ->sortKeys() + ->values(); + + $args = $this->paginationArgs($request); + $total = $templates->count(); + $extraArgs = $search !== '' ? ['search' => $search] : []; + + return $this->mcpSuccess($request, $this->respond( + $templates->slice($args['offset'], $args['per_page'])->values()->all(), + [], + $this->paginationMeta($this->name, $args, $total, $extraArgs), + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Case-insensitive text to match in the template name, slogan, category, or tags.'), + '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 index e3a268cd29..2e9bb4d1b7 100644 --- a/app/Mcp/Tools/ListServices.php +++ b/app/Mcp/Tools/ListServices.php @@ -16,7 +16,9 @@ use Illuminate\Support\Collection; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListServices extends Tool { protected string $name = 'list_services'; diff --git a/app/Mcp/Tools/ListSharedEnvKeys.php b/app/Mcp/Tools/ListSharedEnvKeys.php index cfd64435f5..beb8f7abc4 100644 --- a/app/Mcp/Tools/ListSharedEnvKeys.php +++ b/app/Mcp/Tools/ListSharedEnvKeys.php @@ -11,7 +11,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListSharedEnvKeys extends Tool { protected string $name = 'list_shared_env_keys'; diff --git a/app/Mcp/Tools/ListStorages.php b/app/Mcp/Tools/ListStorages.php index e788f3c8c6..1ba32eb552 100644 --- a/app/Mcp/Tools/ListStorages.php +++ b/app/Mcp/Tools/ListStorages.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListStorages extends Tool { protected string $name = 'list_storages'; diff --git a/app/Mcp/Tools/ListTags.php b/app/Mcp/Tools/ListTags.php index c3ba212116..66fb991e72 100644 --- a/app/Mcp/Tools/ListTags.php +++ b/app/Mcp/Tools/ListTags.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListTags extends Tool { protected string $name = 'list_tags'; diff --git a/app/Mcp/Tools/ListTeamMembers.php b/app/Mcp/Tools/ListTeamMembers.php index c4bc3f5ed9..d1d44e64d5 100644 --- a/app/Mcp/Tools/ListTeamMembers.php +++ b/app/Mcp/Tools/ListTeamMembers.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListTeamMembers extends Tool { protected string $name = 'list_team_members'; diff --git a/app/Mcp/Tools/ListUnhealthyResources.php b/app/Mcp/Tools/ListUnhealthyResources.php index a394d8ce05..3fd3ad56ed 100644 --- a/app/Mcp/Tools/ListUnhealthyResources.php +++ b/app/Mcp/Tools/ListUnhealthyResources.php @@ -16,7 +16,9 @@ use Illuminate\Support\Collection; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class ListUnhealthyResources extends Tool { protected string $name = 'list_unhealthy_resources'; diff --git a/app/Mcp/Tools/ManageApplication.php b/app/Mcp/Tools/ManageApplication.php new file mode 100644 index 0000000000..bdb1bf898a --- /dev/null +++ b/app/Mcp/Tools/ManageApplication.php @@ -0,0 +1,40 @@ + ['method' => 'PATCH', 'path' => 'applications/{uuid}', 'ability' => 'write', 'description' => 'update application settings (data: same fields as the REST update, e.g. name, domains, git_branch, ports_exposes, build_command)'], + 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete the application and its containers (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'], + 'clone' => ['method' => 'POST', 'path' => 'applications/{uuid}/clone', 'ability' => 'write', 'description' => 'clone the application to a destination of the team (data: destination_uuid, name, clone_volumes)'], + 'move' => ['method' => 'POST', 'path' => 'applications/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the application to another environment of the team (data: environment_uuid)'], + 'add_server' => ['method' => 'POST', 'path' => 'applications/{uuid}/destinations', 'ability' => 'write', 'description' => 'attach an additional destination on another server (data: destination_uuid)'], + 'remove_server' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/destinations/{destination_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'detach an additional destination'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application UUID.', + 'destination_uuid' => 'Destination UUID to detach (remove_server only).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API. update: application fields; delete: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup; clone: destination_uuid, name, clone_volumes; move: environment_uuid; add_server: destination_uuid.'; + } +} diff --git a/app/Mcp/Tools/ManageBackup.php b/app/Mcp/Tools/ManageBackup.php new file mode 100644 index 0000000000..244485fee5 --- /dev/null +++ b/app/Mcp/Tools/ManageBackup.php @@ -0,0 +1,39 @@ + ['method' => 'POST', 'path' => 'databases/{uuid}/backups', 'ability' => 'write', 'description' => 'create a backup schedule (data: frequency, enabled, save_s3, s3_storage_uuid(s), retention fields, backup_now ...)'], + 'update' => ['method' => 'PATCH', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}', 'ability' => 'write', 'description' => 'update a backup schedule; data.backup_now=true runs a backup now'], + 'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a backup schedule and all its backup files (data.delete_s3=true also deletes S3 copies)'], + 'delete_execution' => ['method' => 'DELETE', 'path' => 'databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete one backup execution and its file (data.delete_s3=true also deletes the S3 copy)'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Database UUID.', + 'scheduled_backup_uuid' => 'Backup schedule UUID (not needed for create).', + 'execution_uuid' => 'Backup execution UUID (delete_execution only).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: frequency, enabled, save_s3, dump_all, backup_now, s3_storage_uuid, s3_storage_uuids, databases_to_backup, database_backup_retention_amount_locally, database_backup_retention_days_locally, database_backup_retention_max_storage_locally, database_backup_retention_amount_s3, database_backup_retention_days_s3, database_backup_retention_max_storage_s3, timeout, missing_backup_notification_days; delete_s3 for deletes.'; + } +} diff --git a/app/Mcp/Tools/ManageDatabase.php b/app/Mcp/Tools/ManageDatabase.php new file mode 100644 index 0000000000..3dadf67e26 --- /dev/null +++ b/app/Mcp/Tools/ManageDatabase.php @@ -0,0 +1,38 @@ + ['method' => 'PATCH', 'path' => 'databases/{uuid}', 'ability' => 'write', 'description' => 'update database settings (data: name, description, image, is_public, public_port, public_port_timeout, limits_*, type credentials and *_conf fields, redact_all_env_values_in_logs)'], + 'delete' => ['method' => 'DELETE', 'path' => 'databases/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete the database (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup; all default true)'], + 'clone' => ['method' => 'POST', 'path' => 'databases/{uuid}/clone', 'ability' => 'write', 'confirm' => true, 'description' => 'clone the database to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes stops the source database while volumes are copied)'], + 'move' => ['method' => 'POST', 'path' => 'databases/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the database to another environment of the team (data: environment_uuid)'], + 'import' => ['method' => 'POST', 'path' => 'databases/{uuid}/imports', 'ability' => 'write', 'confirm' => true, 'description' => 'import a backup into the database, can overwrite data (data: source s3 | server, s3_storage_uuid, path, dump_all, replace_existing, keep_owners, restore_mysql_users, sqlite_database); check progress with get_database_import'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Database UUID.', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API (see the action descriptions). File uploads are not supported: import only from an S3 storage of the team (source=s3, s3_storage_uuid, path) or a server path (source=server, path; needs deploy ability).'; + } +} diff --git a/app/Mcp/Tools/ManageDestination.php b/app/Mcp/Tools/ManageDestination.php new file mode 100644 index 0000000000..fa3f65f107 --- /dev/null +++ b/app/Mcp/Tools/ManageDestination.php @@ -0,0 +1,37 @@ + ['method' => 'POST', 'path' => 'servers/{server_uuid}/destinations', 'ability' => 'write', 'description' => 'create a destination on a server (data: network, name, type standalone|swarm)'], + 'update' => ['method' => 'PATCH', 'path' => 'destinations/{uuid}', 'ability' => 'write', 'description' => 'rename a destination (data: name)'], + 'delete' => ['method' => 'DELETE', 'path' => 'destinations/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a destination without resources and remove its Docker network'], + ]; + } + + protected function pathParams(): array + { + return [ + 'server_uuid' => 'Server UUID (create only).', + 'uuid' => 'Destination UUID (update and delete).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: network, name, type (create); name (update).'; + } +} diff --git a/app/Mcp/Tools/ManageEnv.php b/app/Mcp/Tools/ManageEnv.php new file mode 100644 index 0000000000..676afb81c2 --- /dev/null +++ b/app/Mcp/Tools/ManageEnv.php @@ -0,0 +1,54 @@ + ['method' => 'POST', 'paths' => [ + 'application' => 'applications/{uuid}/envs', + 'database' => 'databases/{uuid}/envs', + 'service' => 'services/{uuid}/envs', + ], 'ability' => 'write', 'description' => 'create a variable (data: key, value, is_literal, is_multiline, is_shown_once, comment; applications also is_preview, is_runtime, is_buildtime)'], + 'update' => ['method' => 'PATCH', 'paths' => [ + 'application' => 'applications/{uuid}/envs', + 'database' => 'databases/{uuid}/envs', + 'service' => 'services/{uuid}/envs', + ], 'ability' => 'write', 'description' => 'update the variable with this key (data: key, value and the create flags)'], + 'bulk_update' => ['method' => 'PATCH', 'paths' => [ + 'application' => 'applications/{uuid}/envs/bulk', + 'database' => 'databases/{uuid}/envs/bulk', + 'service' => 'services/{uuid}/envs/bulk', + ], 'ability' => 'write', 'description' => 'create or update several variables (data: {"data": [{key, value, ...}]})'], + 'delete' => ['method' => 'DELETE', 'paths' => [ + 'application' => 'applications/{uuid}/envs/{env_uuid}', + 'database' => 'databases/{uuid}/envs/{env_uuid}', + 'service' => 'services/{uuid}/envs/{env_uuid}', + ], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a variable by UUID'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application, database, or service UUID.', + 'env_uuid' => 'Environment variable UUID (delete). See list_env_keys.', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: key, value, is_literal, is_multiline, is_shown_once, comment; applications also is_preview, is_runtime, is_buildtime. bulk_update: {"data": [ ...variables ]}.'; + } +} diff --git a/app/Mcp/Tools/ManageGitSource.php b/app/Mcp/Tools/ManageGitSource.php new file mode 100644 index 0000000000..435d8950ff --- /dev/null +++ b/app/Mcp/Tools/ManageGitSource.php @@ -0,0 +1,37 @@ + ['method' => 'GET', 'paths' => ['github' => 'github-apps', 'gitlab' => 'gitlab-apps'], 'ability' => 'read', 'description' => 'git apps of the team and system-wide apps'], + 'create' => ['method' => 'POST', 'paths' => ['github' => 'github-apps', 'gitlab' => 'gitlab-apps'], 'ability' => 'write', 'description' => 'add a git app (github data: name, html_url, api_url, app_id, installation_id, client_id, client_secret, webhook_secret, private_key_uuid, organization, custom_user, custom_port; gitlab data: name, html_url, api_url, group_name, client_id, client_secret, webhook_token, redirect_uri, custom_user, custom_port)'], + 'update' => ['method' => 'PATCH', 'paths' => ['github' => 'github-apps/{source_id}', 'gitlab' => 'gitlab-apps/{source_id}'], 'ability' => 'write', 'description' => 'update a git app of the team (data: same fields as create, all optional)'], + 'delete' => ['method' => 'DELETE', 'paths' => ['github' => 'github-apps/{source_id}', 'gitlab' => 'gitlab-apps/{source_id}'], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a git app of the team that no application uses'], + ]; + } + + protected function pathParams(): array + { + return [ + 'source_id' => 'Numeric id of the git app, from the list action (update and delete).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API for GitHub or GitLab apps.'; + } +} diff --git a/app/Mcp/Tools/ManageNotifications.php b/app/Mcp/Tools/ManageNotifications.php new file mode 100644 index 0000000000..9a879930e5 --- /dev/null +++ b/app/Mcp/Tools/ManageNotifications.php @@ -0,0 +1,44 @@ + 'notifications/email', + 'discord' => 'notifications/discord', + 'slack' => 'notifications/slack', + 'telegram' => 'notifications/telegram', + 'pushover' => 'notifications/pushover', + 'webhook' => 'notifications/webhook', + ]; + + protected function actions(): array + { + return [ + 'get' => ['method' => 'GET', 'paths' => self::CHANNEL_PATHS, 'ability' => 'read', 'description' => 'settings of one channel'], + 'update' => ['method' => 'PATCH', 'paths' => self::CHANNEL_PATHS, 'ability' => 'write', 'description' => 'change settings of one channel (data: fields of that channel, for example discord_enabled, discord_webhook_url, deployment_failure_discord_notifications)'], + ]; + } + + protected function pathParams(): array + { + return []; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API for the channel (for example smtp_*, resend_*, *_enabled, *_webhook_url, telegram_token, telegram_chat_id, pushover_user_key, pushover_api_token, and the per-event *_notifications flags).'; + } +} diff --git a/app/Mcp/Tools/ManagePreview.php b/app/Mcp/Tools/ManagePreview.php new file mode 100644 index 0000000000..0f4b92b339 --- /dev/null +++ b/app/Mcp/Tools/ManagePreview.php @@ -0,0 +1,37 @@ + ['method' => 'POST', 'path' => 'applications/{uuid}/previews', 'ability' => 'deploy', 'description' => 'open or redeploy a preview (data: pull_request_id, pull_request_html_url, git_type, commit, docker_tag, force, instant_deploy)'], + 'update' => ['method' => 'PATCH', 'path' => 'applications/{uuid}/previews/{pull_request_id}', 'ability' => 'write', 'description' => 'replace preview domains (data: domains, or docker_compose_domains for compose apps, force_domain_override)'], + 'delete' => ['method' => 'DELETE', 'path' => 'applications/{uuid}/previews/{pull_request_id}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete the preview and its containers'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application UUID.', + 'pull_request_id' => 'Pull request ID of the preview (update and delete).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API. create: pull_request_id (required), pull_request_html_url, git_type (github | gitlab | gitea | bitbucket), commit, docker_tag (Docker image apps), force, instant_deploy; update: domains or docker_compose_domains, force_domain_override.'; + } +} diff --git a/app/Mcp/Tools/ManagePrivateKey.php b/app/Mcp/Tools/ManagePrivateKey.php new file mode 100644 index 0000000000..36a9ced91b --- /dev/null +++ b/app/Mcp/Tools/ManagePrivateKey.php @@ -0,0 +1,38 @@ + ['method' => 'GET', 'path' => 'security/keys', 'ability' => 'read', 'description' => 'private keys of the team (no key material)'], + 'get' => ['method' => 'GET', 'path' => 'security/keys/{uuid}', 'ability' => 'read', 'description' => 'one private key (no key material)'], + 'create' => ['method' => 'POST', 'path' => 'security/keys', 'ability' => 'write', 'description' => 'add a private key (data: private_key PEM or base64, name, description)'], + 'update' => ['method' => 'PATCH', 'path' => 'security/keys/{uuid}', 'ability' => 'write', 'description' => 'update a private key (data: name, description; private_key only to replace the key)'], + 'delete' => ['method' => 'DELETE', 'path' => 'security/keys/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a private key that is not in use'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Private key UUID (get, update, delete).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: private_key, name, description.'; + } +} diff --git a/app/Mcp/Tools/ManageProject.php b/app/Mcp/Tools/ManageProject.php new file mode 100644 index 0000000000..6d84a61c5d --- /dev/null +++ b/app/Mcp/Tools/ManageProject.php @@ -0,0 +1,40 @@ + ['method' => 'POST', 'path' => 'projects', 'ability' => 'write', 'description' => 'create a project (data: name, description)'], + 'update' => ['method' => 'PATCH', 'path' => 'projects/{uuid}', 'ability' => 'write', 'description' => 'update a project (data: name, description)'], + 'delete' => ['method' => 'DELETE', 'path' => 'projects/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete an empty project'], + 'create_environment' => ['method' => 'POST', 'path' => 'projects/{uuid}/environments', 'ability' => 'write', 'description' => 'create an environment (data: name)'], + 'update_environment' => ['method' => 'PATCH', 'path' => 'projects/{uuid}/environments/{environment_name_or_uuid}', 'ability' => 'write', 'description' => 'rename an environment (data: name)'], + 'delete_environment' => ['method' => 'DELETE', 'path' => 'projects/{uuid}/environments/{environment_name_or_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete an empty environment'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Project UUID (not needed for create).', + 'environment_name_or_uuid' => 'Environment name or UUID (environment actions except create_environment).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: name, description.'; + } +} diff --git a/app/Mcp/Tools/ManageProxy.php b/app/Mcp/Tools/ManageProxy.php new file mode 100644 index 0000000000..64cb1faa40 --- /dev/null +++ b/app/Mcp/Tools/ManageProxy.php @@ -0,0 +1,37 @@ + ['method' => 'GET', 'path' => 'servers/{uuid}/proxy', 'ability' => 'read', 'description' => 'proxy type, status, and settings (the configuration file only with read:sensitive)'], + 'update' => ['method' => 'PATCH', 'path' => 'servers/{uuid}/proxy', 'ability' => 'write', 'confirm' => true, 'description' => 'change proxy settings (data: proxy_type, redirect_enabled, redirect_url, generate_exact_labels, traefik_certificate_resolver)'], + 'set_configuration' => ['method' => 'PUT', 'path' => 'servers/{uuid}/proxy/configuration', 'ability' => 'write', 'confirm' => true, 'description' => 'replace the proxy configuration file (data: configuration, plain or base64)'], + 'restart' => ['method' => 'POST', 'path' => 'servers/{uuid}/proxy/restart', 'ability' => 'write', 'confirm' => true, 'description' => 'restart the proxy; routed apps are briefly unreachable'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Server UUID.', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: proxy_type, redirect_enabled, redirect_url, generate_exact_labels, traefik_certificate_resolver (update); configuration (set_configuration).'; + } +} diff --git a/app/Mcp/Tools/ManageRegistry.php b/app/Mcp/Tools/ManageRegistry.php new file mode 100644 index 0000000000..59749acbcd --- /dev/null +++ b/app/Mcp/Tools/ManageRegistry.php @@ -0,0 +1,38 @@ + ['method' => 'GET', 'path' => 'servers/{uuid}/registries', 'ability' => 'read:sensitive', 'description' => 'registry logins on the server (registry and username only)'], + 'create' => ['method' => 'POST', 'path' => 'servers/{uuid}/registries', 'ability' => 'write', 'description' => 'log in to a registry on the server (data: registry, username, password)'], + 'check' => ['method' => 'POST', 'path' => 'servers/{uuid}/registries/{registry}/check', 'ability' => 'write', 'description' => 'check that a registry login works'], + 'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}/registries/{registry}', 'ability' => 'write', 'confirm' => true, 'description' => 'log out of a registry; private image pulls can fail afterwards'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Server UUID.', + 'registry' => 'Registry host, for example ghcr.io (check and delete). Hosts with a port are not supported here.', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: registry, username, password (create).'; + } +} diff --git a/app/Mcp/Tools/ManageS3Storage.php b/app/Mcp/Tools/ManageS3Storage.php new file mode 100644 index 0000000000..2f5a92970d --- /dev/null +++ b/app/Mcp/Tools/ManageS3Storage.php @@ -0,0 +1,39 @@ + ['method' => 'GET', 'path' => 's3-storages', 'ability' => 'read', 'description' => 'S3 storages of the team'], + 'get' => ['method' => 'GET', 'path' => 's3-storages/{uuid}', 'ability' => 'read', 'description' => 'one S3 storage'], + 'create' => ['method' => 'POST', 'path' => 's3-storages', 'ability' => 'write', 'description' => 'add an S3 storage (data: name, endpoint, bucket, region, key, secret, description, is_usable)'], + 'update' => ['method' => 'PATCH', 'path' => 's3-storages/{uuid}', 'ability' => 'write', 'description' => 'update an S3 storage (data: same fields as create, all optional)'], + 'delete' => ['method' => 'DELETE', 'path' => 's3-storages/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete an S3 storage'], + 'validate' => ['method' => 'POST', 'path' => 's3-storages/{uuid}/validate', 'ability' => 'write', 'description' => 'test the connection and save the result'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'S3 storage UUID (not needed for list and create).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: name, description, endpoint, bucket, region, key, secret, is_usable.'; + } +} diff --git a/app/Mcp/Tools/ManageScheduledTask.php b/app/Mcp/Tools/ManageScheduledTask.php new file mode 100644 index 0000000000..d5e0527404 --- /dev/null +++ b/app/Mcp/Tools/ManageScheduledTask.php @@ -0,0 +1,50 @@ + ['method' => 'POST', 'paths' => [ + 'application' => 'applications/{uuid}/scheduled-tasks', + 'service' => 'services/{uuid}/scheduled-tasks', + ], 'ability' => 'write', 'description' => 'create a scheduled task (data: name, command, frequency, container, timeout, enabled)'], + 'update' => ['method' => 'PATCH', 'paths' => [ + 'application' => 'applications/{uuid}/scheduled-tasks/{task_uuid}', + 'service' => 'services/{uuid}/scheduled-tasks/{task_uuid}', + ], 'ability' => 'write', 'description' => 'update a scheduled task (data: any of name, command, frequency, container, timeout, enabled)'], + 'delete' => ['method' => 'DELETE', 'paths' => [ + 'application' => 'applications/{uuid}/scheduled-tasks/{task_uuid}', + 'service' => 'services/{uuid}/scheduled-tasks/{task_uuid}', + ], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a scheduled task'], + 'execute' => ['method' => 'POST', 'paths' => [ + 'application' => 'applications/{uuid}/scheduled-tasks/{task_uuid}/execute', + 'service' => 'services/{uuid}/scheduled-tasks/{task_uuid}/execute', + ], 'ability' => 'write', 'confirm' => true, 'description' => 'run the task command now'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application or service UUID.', + 'task_uuid' => 'Scheduled task UUID (not needed for create).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: name, command, frequency (cron or every_minute, hourly, daily, weekly, monthly, yearly), container, timeout (seconds), enabled.'; + } +} diff --git a/app/Mcp/Tools/ManageServer.php b/app/Mcp/Tools/ManageServer.php new file mode 100644 index 0000000000..2a036e07f9 --- /dev/null +++ b/app/Mcp/Tools/ManageServer.php @@ -0,0 +1,37 @@ + ['method' => 'POST', 'path' => 'servers', 'ability' => 'write', 'description' => 'add a server (data: ip, private_key_uuid, name, description, port, user, server_role, proxy_type, instant_validate)'], + 'update' => ['method' => 'PATCH', 'path' => 'servers/{uuid}', 'ability' => 'write', 'description' => 'update a server (data: name, description, ip, port, user, private_key_uuid, server_role, proxy_type, concurrent_builds, dynamic_timeout, deployment_queue_limit, connection_timeout, is_terminal_enabled, server_disk_usage_*)'], + 'delete' => ['method' => 'DELETE', 'path' => 'servers/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a server without resources (the Coolify host cannot be deleted)'], + 'validate' => ['method' => 'POST', 'path' => 'servers/{uuid}/validate', 'ability' => 'write', 'description' => 'start a server validation (data: install=true also installs Docker)'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Server UUID (not needed for create).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: name, description, ip, port, user, private_key_uuid, server_role (deployment|build|both), proxy_type, instant_validate, install (validate only).'; + } +} diff --git a/app/Mcp/Tools/ManageService.php b/app/Mcp/Tools/ManageService.php new file mode 100644 index 0000000000..bdbc6c0050 --- /dev/null +++ b/app/Mcp/Tools/ManageService.php @@ -0,0 +1,42 @@ + ['method' => 'PATCH', 'path' => 'services/{uuid}', 'ability' => 'write', 'description' => 'update the service (data: name, description, docker_compose_raw base64, connect_to_docker_network, urls, force_domain_override, is_container_label_escape_enabled, redact_all_env_values_in_logs, instant_deploy)'], + 'delete' => ['method' => 'DELETE', 'path' => 'services/{uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete the service (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup, delete_from_coolify_only)'], + 'clone' => ['method' => 'POST', 'path' => 'services/{uuid}/clone', 'ability' => 'write', 'confirm' => true, 'description' => 'clone the service to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes stops the source service while volumes are copied)'], + 'move' => ['method' => 'POST', 'path' => 'services/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the service to another environment of the team (data: environment_uuid)'], + 'update_application' => ['method' => 'PATCH', 'path' => 'services/{uuid}/applications/{app_uuid}', 'ability' => 'write', 'description' => 'update a service application (data: url, noindex_domains, human_name, description, image, exclude_from_status, is_log_drain_enabled, is_gzip_enabled, traefik_certificate_resolver, is_stripprefix_enabled, is_force_https_enabled, max_restart_count)'], + 'update_database' => ['method' => 'PATCH', 'path' => 'services/{uuid}/databases/{database_uuid}', 'ability' => 'write', 'description' => 'update a service database (data: human_name, description, image, exclude_from_status, is_log_drain_enabled, is_public, public_port, public_port_timeout)'], + 'import_database' => ['method' => 'POST', 'path' => 'services/{uuid}/databases/{database_uuid}/imports', 'ability' => 'write', 'confirm' => true, 'description' => 'import a backup into a service database, can overwrite data (data: source s3 | server, s3_storage_uuid, path, dump_all, replace_existing, keep_owners, restore_mysql_users); check progress with get_database_import'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Service UUID.', + 'app_uuid' => 'Service application UUID (update_application).', + 'database_uuid' => 'Service database UUID (update_database, import_database).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API (see the action descriptions). File uploads are not supported: import only from an S3 storage of the team (source=s3, s3_storage_uuid, path) or a server path (source=server, path; needs deploy ability).'; + } +} diff --git a/app/Mcp/Tools/ManageSharedEnv.php b/app/Mcp/Tools/ManageSharedEnv.php new file mode 100644 index 0000000000..d21acd18d5 --- /dev/null +++ b/app/Mcp/Tools/ManageSharedEnv.php @@ -0,0 +1,53 @@ + ['method' => 'POST', 'paths' => [ + 'team' => 'team/envs', + 'project' => 'projects/{uuid}/envs', + 'environment' => 'projects/{uuid}/environments/{environment_name_or_uuid}/envs', + 'server' => 'servers/{uuid}/envs', + ], 'ability' => 'write', 'description' => 'create a shared variable (data: key, value, is_literal, is_multiline, is_shown_once, comment); returns its id'], + 'update' => ['method' => 'PATCH', 'paths' => [ + 'team' => 'team/envs/{env_id}', + 'project' => 'projects/{uuid}/envs/{env_id}', + 'environment' => 'projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', + 'server' => 'servers/{uuid}/envs/{env_id}', + ], 'ability' => 'write', 'description' => 'update a shared variable (data: key, value, is_literal, is_multiline, is_shown_once, comment)'], + 'delete' => ['method' => 'DELETE', 'paths' => [ + 'team' => 'team/envs/{env_id}', + 'project' => 'projects/{uuid}/envs/{env_id}', + 'environment' => 'projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', + 'server' => 'servers/{uuid}/envs/{env_id}', + ], 'ability' => 'write', 'confirm' => true, 'description' => 'delete a shared variable'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Project UUID (resource project or environment) or server UUID (resource server). Not used for team.', + 'environment_name_or_uuid' => 'Environment name or UUID (resource environment).', + 'env_id' => 'Shared variable id (update, delete).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: key, value, is_literal, is_multiline, is_shown_once, comment.'; + } +} diff --git a/app/Mcp/Tools/ManageStorage.php b/app/Mcp/Tools/ManageStorage.php new file mode 100644 index 0000000000..d59d95ca14 --- /dev/null +++ b/app/Mcp/Tools/ManageStorage.php @@ -0,0 +1,52 @@ + ['method' => 'POST', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'create a storage (data: type persistent|file, name, mount_path, content, is_directory, is_host_file, fs_path; services also resource_uuid)'], + 'update' => ['method' => 'PATCH', 'paths' => $this->paths('storages'), 'ability' => 'write', 'description' => 'update a storage (data: uuid of the storage, type, name, mount_path, content, is_preview_suffix_enabled)'], + 'delete' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}'), 'ability' => 'write', 'confirm' => true, 'description' => 'delete a storage'], + 'set_backup' => ['method' => 'PUT', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'write', 'description' => 'create or replace the volume backup schedule (data: frequency, enabled, save_s3, s3_storage_uuid(s), stop_during_backup, retention fields ...)'], + 'remove_backup' => ['method' => 'DELETE', 'paths' => $this->paths('storages/{storage_uuid}/backups'), 'ability' => 'write', 'confirm' => true, 'description' => 'delete the volume backup schedule and its local and S3 archives'], + 'run_backup' => ['method' => 'POST', 'paths' => $this->paths('storages/{storage_uuid}/backups/run'), 'ability' => 'write', 'confirm' => true, 'description' => 'run the volume backup now (can stop the resource when stop_during_backup is set)'], + ]; + } + + /** + * @return array + */ + private function paths(string $suffix): array + { + return [ + 'application' => "applications/{uuid}/{$suffix}", + 'database' => "databases/{uuid}/{$suffix}", + 'service' => "services/{uuid}/{$suffix}", + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application, database, or service UUID.', + 'storage_uuid' => 'Storage UUID (delete and backup actions).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API. Storage: type, name, mount_path, content, is_directory, is_host_file, fs_path, resource_uuid (service), uuid (update). Backup: frequency, enabled, save_s3, disable_local_backup, stop_during_backup, s3_storage_uuid, s3_storage_uuids, retention_amount_locally, retention_days_locally, retention_max_storage_locally, retention_amount_s3, retention_days_s3, retention_max_storage_s3, timeout, missing_backup_notification_days.'; + } +} diff --git a/app/Mcp/Tools/ManageTags.php b/app/Mcp/Tools/ManageTags.php new file mode 100644 index 0000000000..37059bfe58 --- /dev/null +++ b/app/Mcp/Tools/ManageTags.php @@ -0,0 +1,47 @@ + ['method' => 'POST', 'path' => 'tags', 'ability' => 'write', 'description' => 'create a team tag (data: name)'], + 'update' => ['method' => 'PATCH', 'path' => 'tags/{tag_uuid}', 'ability' => 'write', 'description' => 'rename a team tag (data: name)'], + 'delete' => ['method' => 'DELETE', 'path' => 'tags/{tag_uuid}', 'ability' => 'write', 'confirm' => true, 'description' => 'delete a team tag and detach it from all resources'], + 'attach' => ['method' => 'POST', 'paths' => [ + 'application' => 'applications/{uuid}/tags', + 'database' => 'databases/{uuid}/tags', + 'service' => 'services/{uuid}/tags', + ], 'ability' => 'write', 'description' => 'attach tags by name, missing team tags are created (data: tag_name or tag_names)'], + 'detach' => ['method' => 'DELETE', 'paths' => [ + 'application' => 'applications/{uuid}/tags/{tag_uuid}', + 'database' => 'databases/{uuid}/tags/{tag_uuid}', + 'service' => 'services/{uuid}/tags/{tag_uuid}', + ], 'ability' => 'write', 'description' => 'detach a tag from the resource'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application, database, or service UUID (attach and detach).', + 'tag_uuid' => 'Tag UUID (update, delete, detach).', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: name (create, update), tag_name or tag_names (attach).'; + } +} diff --git a/app/Mcp/Tools/RestActionTool.php b/app/Mcp/Tools/RestActionTool.php new file mode 100644 index 0000000000..feccc6a864 --- /dev/null +++ b/app/Mcp/Tools/RestActionTool.php @@ -0,0 +1,164 @@ + 'applications/{uuid}/envs']). + * + * @return array, ability: string, confirm?: bool, description: string}> + */ + abstract protected function actions(): array; + + /** + * Path parameters this tool accepts, with descriptions. + * + * @return array + */ + abstract protected function pathParams(): array; + + /** + * Description of the fields accepted in `data`. Empty when the tool takes no body. + */ + protected function dataDescription(): string + { + return ''; + } + + /** + * Actions that the current token has the ability for. + * + * @return array, ability: string, confirm?: bool, description: string}> + */ + protected function availableActions(): array + { + return array_filter($this->actions(), fn (array $action): bool => $this->currentTokenCan($action['ability'])); + } + + public function shouldRegister(): bool + { + return $this->availableActions() !== []; + } + + public function handle(Request $request): Response + { + $actions = $this->availableActions(); + $actionName = $request->get('action'); + + $allActions = $this->actions(); + if (! is_string($actionName) || ! isset($allActions[$actionName])) { + return $this->mcpError($request, 'action must be one of: '.implode(', ', array_keys($actions)).'.'); + } + $action = $allActions[$actionName]; + + // Hidden actions still reach this check, so the agent gets the missing ability, not "unknown action". + if ($error = $this->ensureAbility($request, $action['ability'], $this->name)) { + return $error; + } + + if (is_null($this->resolveTeamId($request))) { + return $this->mcpError($request, 'Invalid token.'); + } + + if (($action['confirm'] ?? false) && $error = $this->ensureConfirmed($request, "{$this->name} {$actionName}")) { + return $error; + } + + $path = $action['path'] ?? null; + if (isset($action['paths'])) { + $resource = $request->get('resource'); + if (! is_string($resource) || ! isset($action['paths'][$resource])) { + return $this->mcpError($request, 'resource must be one of: '.implode(', ', array_keys($action['paths'])).'.'); + } + $path = $action['paths'][$resource]; + } + + $data = $request->get('data') ?? []; + if (! is_array($data) || ($data !== [] && array_is_list($data))) { + return $this->mcpError($request, 'data must be a JSON object.'); + } + + $pathParams = []; + foreach (array_keys($this->pathParams()) as $param) { + $value = $request->get($param); + if ($value !== null) { + if (! is_scalar($value)) { + return $this->mcpError($request, "{$param} must be a string."); + } + $pathParams[$param] = $value; + } + } + + try { + $result = $this->callRestApi($action['method'], $path, $pathParams, $data); + } catch (\InvalidArgumentException $e) { + return $this->mcpError($request, $e->getMessage()); + } + + $context = ['action' => $actionName, 'resource' => $request->get('resource'), 'status' => $result['status']] + array_map('strval', $pathParams); + + if ($result['status'] >= 400) { + $body = is_array($result['body']) ? $result['body'] : []; + $message = (string) ($body['message'] ?? 'Request failed.'); + if (isset($body['errors']) && is_array($body['errors'])) { + $message .= ' '.json_encode($body['errors']); + } + + return $this->mcpError($request, $message, $context); + } + + return $this->mcpSuccess($request, $this->respond(is_array($result['body']) ? $result['body'] : []), $context); + } + + public function schema(JsonSchema $schema): array + { + $actionLines = collect($this->availableActions()) + ->map(fn (array $action, string $name): string => "{$name}: {$action['description']}".(($action['confirm'] ?? false) ? ' (needs confirm=true)' : '')) + ->implode('; '); + + $fields = [ + 'action' => $schema->string()->enum(array_keys($this->availableActions()))->description($actionLines)->required(), + ]; + + $resources = collect($this->availableActions())->pluck('paths')->filter()->flatMap(fn (array $paths): array => array_keys($paths))->unique()->values()->all(); + if ($resources !== []) { + $fields['resource'] = $schema->string()->enum($resources)->description('Resource type for actions that support several ('.implode(' | ', $resources).').'); + } + + foreach ($this->pathParams() as $param => $description) { + $fields[$param] = $schema->string()->description($description); + } + + if ($this->dataDescription() !== '') { + $fields['data'] = $schema->object()->description($this->dataDescription()); + } + + if (collect($this->availableActions())->contains(fn (array $action): bool => $action['confirm'] ?? false)) { + $fields['confirm'] = $schema->boolean()->description('Must be true for destructive actions.'); + } + + return $fields; + } +} diff --git a/app/Mcp/Tools/RollbackApplication.php b/app/Mcp/Tools/RollbackApplication.php new file mode 100644 index 0000000000..e4fb644594 --- /dev/null +++ b/app/Mcp/Tools/RollbackApplication.php @@ -0,0 +1,34 @@ + ['method' => 'POST', 'path' => 'applications/{uuid}/rollback', 'ability' => 'deploy', 'confirm' => true, 'description' => 'roll back to an image tag (data: commit)'], + ]; + } + + protected function pathParams(): array + { + return [ + 'uuid' => 'Application UUID.', + ]; + } + + protected function dataDescription(): string + { + return 'Request body, same fields as the REST API: commit (image tag / commit SHA from list_rollback_images).'; + } +} diff --git a/app/Mcp/Tools/SearchResources.php b/app/Mcp/Tools/SearchResources.php index 4db76bfd28..28ea617e38 100644 --- a/app/Mcp/Tools/SearchResources.php +++ b/app/Mcp/Tools/SearchResources.php @@ -13,7 +13,9 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +#[IsReadOnly] class SearchResources extends Tool { protected string $name = 'search_resources'; diff --git a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php index bdc37974d0..61c4d2af9e 100644 --- a/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php +++ b/app/Support/DatabaseImport/DatabaseImportCommandBuilder.php @@ -3,6 +3,7 @@ namespace App\Support\DatabaseImport; use App\Models\ServiceDatabase; +use App\Support\PostgresUser; use InvalidArgumentException; class DatabaseImportCommandBuilder @@ -64,7 +65,7 @@ AWK; public function buildRestoreCommand(object $resource, string $path, bool $dumpAll, bool $replaceExisting = false, bool $keepOwners = false, ?string $sqliteDatabase = null, bool $restoreMysqlUsers = false): string { $script = match ($this->databaseType($resource)) { - 'postgresql' => $dumpAll ? $this->postgresqlDumpAll() : $this->postgresqlSingle($replaceExisting, $keepOwners), + 'postgresql' => $this->postgresqlPrelude().($dumpAll ? $this->postgresqlDumpAll() : $this->postgresqlSingle($replaceExisting, $keepOwners)), 'mysql' => $this->mysql('mysql', 'MYSQL', $dumpAll, $restoreMysqlUsers), 'mariadb' => $this->mysql('mariadb', 'MARIADB', $dumpAll, $restoreMysqlUsers), 'mongodb' => $this->mongodb($replaceExisting), @@ -177,6 +178,24 @@ SH; }; } + /** + * Sets the PostgreSQL user once (see PostgresUser) and defines the checks that run after the + * backup format is accepted and before the first change. When no user variable is set, + * "postgres" is a guess, so a failed connection must stop the restore with a clear error. + */ + private function postgresqlPrelude(): string + { + return 'pg_user='.PostgresUser::shellExpression()."\n".<<<'SH' +check_connection() { + psql -U "$pg_user" -d template1 -Atc 'SELECT 1' >/dev/null 2>&1 || fail "Cannot connect to PostgreSQL as user \"$pg_user\". Set POSTGRES_USER on the database container to its superuser. Nothing was changed." +} +check_superuser() { + [ "$(psql -U "$pg_user" -d template1 -Atc 'SELECT rolsuper FROM pg_roles WHERE rolname = current_user' 2>/dev/null)" = t ] || fail "Restoring all databases needs a PostgreSQL superuser, and \"$pg_user\" is not one or cannot connect. Set POSTGRES_USER on the database container to its superuser. Nothing was changed." +} + +SH; + } + /** * pg_dump custom and tar archives are restored with pg_restore, SQL dumps with psql. * pg_restore cannot read gzip files, so both clients receive the backup on stdin. @@ -193,32 +212,35 @@ SH; $suffix = bin2hex(random_bytes(16)); $sqlRestore = $replaceExisting ? "\n new=coolify_restore_new_{$suffix}\n old=coolify_restore_old_{$suffix}\n".<<<'SH' + check_connection echo 'SQL backups cannot replace single objects. The backup is restored into a new database first; the current database is replaced only when that restore succeeds.' - if ! existing=$(printf '%s\n' "SELECT 1 FROM pg_database WHERE datname IN (:'new', :'old');" | psql -v ON_ERROR_STOP=1 -At -v new="$new" -v old="$old" -U $POSTGRES_USER -d template1); then + if ! existing=$(printf '%s\n' "SELECT 1 FROM pg_database WHERE datname IN (:'new', :'old');" | psql -v ON_ERROR_STOP=1 -At -v new="$new" -v old="$old" -U "$pg_user" -d template1); then fail 'The temporary database names could not be checked. Nothing was changed.' fi [ -z "$existing" ] || fail 'A temporary database name is already in use. Nothing was changed.' - createdb -U $POSTGRES_USER "$new" || exit 1 - if ! stream | psql -v ON_ERROR_STOP=1 --single-transaction -U $POSTGRES_USER -d "$new"; then - PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$new" + createdb -U "$pg_user" "$new" || exit 1 + if ! stream | psql -v ON_ERROR_STOP=1 --single-transaction -U "$pg_user" -d "$new"; then + PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U "$pg_user" --if-exists "$new" fail 'The SQL restore failed. The current database was not changed.' fi - if ! printf '%s\n' "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = :'db' AND pid <> pg_backend_pid();" 'ALTER DATABASE :"db" RENAME TO :"old";' 'ALTER DATABASE :"new" RENAME TO :"db";' | psql -v ON_ERROR_STOP=1 -v db="$db" -v old="$old" -v new="$new" -U $POSTGRES_USER -d template1 >/dev/null; then + if ! printf '%s\n' "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = :'db' AND pid <> pg_backend_pid();" 'ALTER DATABASE :"db" RENAME TO :"old";' 'ALTER DATABASE :"new" RENAME TO :"db";' | psql -v ON_ERROR_STOP=1 -v db="$db" -v old="$old" -v new="$new" -U "$pg_user" -d template1 >/dev/null; then # Undo a half-done swap: the current database keeps its name and data. - printf '%s\n' 'ALTER DATABASE :"old" RENAME TO :"db";' | psql -v db="$db" -v old="$old" -U $POSTGRES_USER -d template1 >/dev/null 2>&1 - PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$new" + printf '%s\n' 'ALTER DATABASE :"old" RENAME TO :"db";' | psql -v db="$db" -v old="$old" -U "$pg_user" -d template1 >/dev/null 2>&1 + PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U "$pg_user" --if-exists "$new" fail 'The current database is in use and could not be replaced. Nothing was changed.' fi - PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U $POSTGRES_USER --if-exists "$old" || exit 1 + PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U "$pg_user" --if-exists "$old" || exit 1 SH : <<<'SH' - stream | psql -v ON_ERROR_STOP=1 --single-transaction -U $POSTGRES_USER -d "$db" + check_connection + stream | psql -v ON_ERROR_STOP=1 --single-transaction -U "$pg_user" -d "$db" SH; return <</dev/null 2>&1 || fail 'pg_restore cannot read this backup archive. Nothing was changed.' @@ -244,13 +266,14 @@ elif is_text; then else fail 'Unsupported PostgreSQL backup format. Use a pg_dump archive (custom or tar format) or an SQL file. Nothing was changed.' fi -psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" || exit 1 -psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} || exit 1 -createdb -U ${POSTGRES_USER} "$db" || exit 1 +check_superuser +psql -U "$pg_user" -d template1 -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" || exit 1 +psql -U "$pg_user" -d template1 -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U "$pg_user" --maintenance-db=template1 --if-exists {} || exit 1 +createdb -U "$pg_user" "$db" || exit 1 if [ "$kind" = archive ]; then - stream | pg_restore -U ${POSTGRES_USER} -d "$db" + stream | pg_restore -U "$pg_user" -d "$db" else - stream | psql -U ${POSTGRES_USER} -d "$db" + stream | psql -U "$pg_user" -d "$db" fi SH; } diff --git a/app/Support/PostgresUser.php b/app/Support/PostgresUser.php new file mode 100644 index 0000000000..24aaefe0a1 --- /dev/null +++ b/app/Support/PostgresUser.php @@ -0,0 +1,54 @@ + + */ + public const ENV_NAMES = ['POSTGRES_USER', 'PGUSER', 'POSTGRESQL_USERNAME']; + + public const DEFAULT = 'postgres'; + + /** + * Shell parameter expansion that resolves the user from the container environment. + */ + public static function shellExpression(): string + { + return array_reduce( + array_reverse(self::ENV_NAMES), + fn (string $fallback, string $name): string => '${'.$name.':-'.$fallback.'}', + self::DEFAULT, + ); + } + + /** + * Resolves the user from `env` output lines (NAME=value) of the container. + * + * @param iterable $envLines + */ + public static function fromEnvLines(iterable $envLines): string + { + $values = []; + foreach ($envLines as $line) { + [$name, $value] = array_pad(explode('=', trim((string) $line), 2), 2, ''); + $values[$name] = $value; + } + + foreach (self::ENV_NAMES as $name) { + if (($values[$name] ?? '') !== '') { + return $values[$name]; + } + } + + return self::DEFAULT; + } +} diff --git a/docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/horizon b/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/horizon similarity index 100% rename from docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/horizon rename to docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/horizon diff --git a/docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/init-setup b/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/init-setup similarity index 100% rename from docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/init-setup rename to docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/init-setup diff --git a/docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/reverb b/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/reverb similarity index 100% rename from docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/reverb rename to docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/reverb diff --git a/docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/scheduler-worker b/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/scheduler-worker similarity index 100% rename from docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/scheduler-worker rename to docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/scheduler-worker diff --git a/docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/terminal-server b/docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/terminal-server similarity index 100% rename from docker/development/etc/s6-overlay/user-bundles.d/user/contents.d/terminal-server rename to docker/development/etc/s6-overlay/s6-rc.d/user/contents.d/terminal-server diff --git a/openapi.json b/openapi.json index 2521a0fdb7..6b0bd8e12a 100644 --- a/openapi.json +++ b/openapi.json @@ -13617,58 +13617,6 @@ ] } }, - "\/enable": { - "post": { - "summary": "Enable API", - "description": "Enable API (only with root permissions).", - "operationId": "enable-api", - "responses": { - "200": { - "description": "Enable API.", - "content": { - "application\/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "API enabled." - } - }, - "type": "object" - } - } - } - }, - "403": { - "description": "You are not allowed to enable the API.", - "content": { - "application\/json": { - "schema": { - "properties": { - "message": { - "type": "string", - "example": "You are not allowed to enable the API." - } - }, - "type": "object" - } - } - } - }, - "401": { - "$ref": "#\/components\/responses\/401" - }, - "400": { - "$ref": "#\/components\/responses\/400" - } - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, "\/disable": { "post": { "summary": "Disable API", @@ -15984,9 +15932,6 @@ "content": { "application\/json": { "schema": { - "required": [ - "private_key" - ], "properties": { "name": { "type": "string" diff --git a/openapi.yaml b/openapi.yaml index 598df0a082..d5ec8bbd2d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8734,35 +8734,6 @@ paths: security: - bearerAuth: [] - /enable: - post: - summary: 'Enable API' - description: 'Enable API (only with root permissions).' - operationId: enable-api - responses: - '200': - description: 'Enable API.' - content: - application/json: - schema: - properties: - message: { type: string, example: 'API enabled.' } - type: object - '403': - description: 'You are not allowed to enable the API.' - content: - application/json: - schema: - properties: - message: { type: string, example: 'You are not allowed to enable the API.' } - type: object - '401': - $ref: '#/components/responses/401' - '400': - $ref: '#/components/responses/400' - security: - - - bearerAuth: [] /disable: post: summary: 'Disable API' @@ -10242,8 +10213,6 @@ paths: content: application/json: schema: - required: - - private_key properties: name: type: string diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 8b05a0679b..1a1509485e 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -5,7 +5,7 @@ - @if (!$isApiEnabled) + @if (!$isApiEnabled && !$isMcpEnabled)
@@ -32,6 +32,11 @@ @endphp
+ @if (!$isApiEnabled) + + Tokens work only for the MCP endpoint {{ url('/mcp') }} until API access is enabled. + + @endif @can('create', App\Models\PersonalAccessToken::class)
diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index f437936faa..53a30369cd 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -107,7 +107,9 @@ @endif @if ($is_mcp_server_enabled) - {{ url('/mcp') }} uses Sanctum bearer tokens from Security → API Tokens. + {{ url('/mcp') }} + uses bearer tokens from + Security → API Tokens. @endif diff --git a/routes/ai.php b/routes/ai.php index d79ff0ebae..154866edcc 100644 --- a/routes/ai.php +++ b/routes/ai.php @@ -4,4 +4,4 @@ use App\Mcp\Servers\CoolifyServer; use Laravel\Mcp\Facades\Mcp; Mcp::web('/mcp', CoolifyServer::class) - ->middleware(['mcp.enabled', 'auth:sanctum', 'api.token.team', 'mcp.team.enabled']); + ->middleware(['mcp.enabled', 'api.ip.allowed', 'auth:sanctum', 'api.token.team', 'mcp.team.enabled']); diff --git a/routes/api.php b/routes/api.php index 54d500973b..d372dd144f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -54,12 +54,10 @@ Route::post('/feedback', [OtherController::class, 'feedback']) ->middleware('throttle:feedback'); Route::group([ - 'middleware' => ['auth:sanctum', 'api.token.team', 'api.ability:write'], + 'middleware' => ['api.ip.allowed', 'auth:sanctum', 'api.token.team', 'api.ability:write'], 'prefix' => 'v1', ], function () { - Route::get('/enable', [OtherController::class, 'post_required']); Route::get('/disable', [OtherController::class, 'post_required']); - Route::post('/enable', [OtherController::class, 'enable_api']); Route::post('/disable', [OtherController::class, 'disable_api']); Route::post('/mcp/enable', [OtherController::class, 'enable_mcp']); Route::post('/mcp/disable', [OtherController::class, 'disable_mcp']); diff --git a/tests/Feature/Api/PrivateKeyUpdateApiTest.php b/tests/Feature/Api/PrivateKeyUpdateApiTest.php new file mode 100644 index 0000000000..a525d872d4 --- /dev/null +++ b/tests/Feature/Api/PrivateKeyUpdateApiTest.php @@ -0,0 +1,64 @@ +set('app.maintenance.store', 'array'); + config()->set('cache.default', 'array'); + + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->keyMaterial = generateSSHKey('ed25519')['private']; + $this->key = PrivateKey::create(['name' => 'Old name', 'private_key' => $this->keyMaterial, 'team_id' => $this->team->id]); + + $token = $this->user->createToken('write-token', ['write']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $this->bearerToken = $token->plainTextToken; +}); + +function patchPrivateKeyUpdateApi(object $test, string $uuid, string $bearerToken, array $payload): TestResponse +{ + return $test->withHeaders(['Authorization' => 'Bearer '.$bearerToken]) + ->patchJson('/api/v1/security/keys/'.$uuid, $payload); +} + +test('a private key can be renamed without sending the key again', function () { + patchPrivateKeyUpdateApi($this, $this->key->uuid, $this->bearerToken, ['name' => 'New name', 'description' => 'Renamed']) + ->assertCreated(); + + $fresh = $this->key->fresh(); + expect($fresh->name)->toBe('New name') + ->and($fresh->description)->toBe('Renamed') + ->and($fresh->private_key)->toBe($this->key->private_key) + ->and($fresh->fingerprint)->toBe($this->key->fingerprint); +}); + +test('a private key update still replaces and validates the key when it is sent', function () { + $newKey = generateSSHKey('ed25519')['private']; + + patchPrivateKeyUpdateApi($this, $this->key->uuid, $this->bearerToken, ['private_key' => $newKey])->assertCreated(); + expect($this->key->fresh()->fingerprint)->toBe(PrivateKey::generateFingerprint($newKey)); + + patchPrivateKeyUpdateApi($this, $this->key->uuid, $this->bearerToken, ['private_key' => 'not a key'])->assertUnprocessable(); + expect($this->key->fresh()->fingerprint)->toBe(PrivateKey::generateFingerprint($newKey)); +}); + +test('a private key of another team cannot be renamed', function () { + $otherTeam = Team::factory()->create(); + $foreign = PrivateKey::create(['name' => 'Theirs', 'private_key' => generateSSHKey('ed25519')['private'], 'team_id' => $otherTeam->id]); + + patchPrivateKeyUpdateApi($this, $foreign->uuid, $this->bearerToken, ['name' => 'Hijacked'])->assertNotFound(); + expect($foreign->fresh()->name)->toBe('Theirs'); +}); diff --git a/tests/Feature/Api/StateChangingRoutesUsePostTest.php b/tests/Feature/Api/StateChangingRoutesUsePostTest.php index f48722d8fd..a44eb7d7ec 100644 --- a/tests/Feature/Api/StateChangingRoutesUsePostTest.php +++ b/tests/Feature/Api/StateChangingRoutesUsePostTest.php @@ -14,7 +14,6 @@ it('uses POST for state changes and keeps GET as a non-mutating compatibility re ->and($getRoute)->not->toBeNull() ->and($getRoute->getActionName())->toBe(OtherController::class.'@post_required'); })->with([ - 'enable API' => 'api/v1/enable', 'disable API' => 'api/v1/disable', 'deploy' => 'api/v1/deploy', 'validate server' => 'api/v1/servers/{uuid}/validate', diff --git a/tests/Feature/ApiDisabledCalloutTest.php b/tests/Feature/ApiDisabledCalloutTest.php index 7f6ca8f6f8..555ce5db2d 100644 --- a/tests/Feature/ApiDisabledCalloutTest.php +++ b/tests/Feature/ApiDisabledCalloutTest.php @@ -77,3 +77,20 @@ it('does not show the API notice on the webhooks page when the API is enabled', Livewire::test(Webhooks::class, ['resource' => $this->application]) ->assertDontSee('API access is turned off'); }); + +it('lets users create API tokens for MCP when only the REST API is disabled', function () { + InstanceSettings::query()->whereKey(0)->update(['is_mcp_server_enabled' => true]); + Once::flush(); + $this->actingAs($this->teamAdmin); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->assertDontSee('API access is turned off') + ->assertSee('REST API disabled') + ->set('description', 'mcp-token') + ->set('permissions', ['read']) + ->call('addNewToken') + ->assertHasNoErrors(); + + expect($this->teamAdmin->tokens()->where('name', 'mcp-token')->where('team_id', $this->team->id)->exists())->toBeTrue(); +}); diff --git a/tests/Feature/BackupRunSafetyTest.php b/tests/Feature/BackupRunSafetyTest.php index 0348fc1858..b80ea23d91 100644 --- a/tests/Feature/BackupRunSafetyTest.php +++ b/tests/Feature/BackupRunSafetyTest.php @@ -269,7 +269,7 @@ it('stores service database backups in the folder of earlier releases', function 'status' => 'running:healthy', ]); $backup = backupRunSafetySchedule($database, $this->team); - backupRunSafetyFakeRemoteCommands(['env | grep POSTGRES_' => "POSTGRES_USER=rallly\nPOSTGRES_DB=rallly"]); + backupRunSafetyFakeRemoteCommands(['| grep -E' => "POSTGRES_USER=rallly\nPOSTGRES_DB=rallly"]); (new DatabaseBackupJob($backup))->handle(); @@ -277,6 +277,32 @@ it('stores service database backups in the folder of earlier releases', function ->toContain("/rallly-rallly_db-{$service->uuid}/pg-dump-rallly-"); }); +it('backs up a service PostgreSQL database as the user from PGUSER or POSTGRESQL_USERNAME', function (string $env, string $user) { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + $database = ServiceDatabase::create([ + 'service_id' => $service->id, + 'name' => 'app_db', + 'image' => 'postgres:16-alpine', + 'status' => 'running:healthy', + ]); + $backup = backupRunSafetySchedule($database, $this->team); + $commands = backupRunSafetyFakeRemoteCommands(['| grep -E' => $env."\nPOSTGRES_DB=app"]); + + (new DatabaseBackupJob($backup))->handle(); + + expect($commands->first(fn (string $command) => str_contains($command, 'pg_dump'))) + ->toContain("--username '\\''{$user}'\\''"); +})->with([ + 'PGUSER' => ['PGUSER=pguser', 'pguser'], + 'Bitnami POSTGRESQL_USERNAME' => ['POSTGRESQL_USERNAME=bitnami', 'bitnami'], + 'POSTGRES_USER wins' => ["POSTGRES_USER=app\nPGUSER=pguser", 'app'], +]); + // --- Worker timeout (M16) --- it('stops the backup command at the backup timeout, before the worker timeout', function () { diff --git a/tests/Feature/Mcp/CallsRestApiTest.php b/tests/Feature/Mcp/CallsRestApiTest.php new file mode 100644 index 0000000000..618e63db6d --- /dev/null +++ b/tests/Feature/Mcp/CallsRestApiTest.php @@ -0,0 +1,41 @@ +attributes->set('can_read_sensitive', true); + + return response()->json(['data' => [ + ['uuid' => 'database-uuid', $key => 'credential'], + ['uuid' => 'another-uuid', $key => ['credential']], + ]]); + }); + + $bridge = new class + { + use BuildsResponse; + use CallsRestApi { + callRestApi as public call; + } + }; + + expect($bridge->call('POST', 'mcp-credential-test'))->toBe([ + 'status' => 200, + 'body' => ['data' => [ + ['uuid' => 'database-uuid'], + ['uuid' => 'another-uuid'], + ]], + ]); +})->with([ + 'internal_db_url', 'external_db_url', 'password', + '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', +]); diff --git a/tests/Feature/Mcp/McpApplicationToolsTest.php b/tests/Feature/Mcp/McpApplicationToolsTest.php new file mode 100644 index 0000000000..00c4e4d6dc --- /dev/null +++ b/tests/Feature/Mcp/McpApplicationToolsTest.php @@ -0,0 +1,520 @@ +set('app.maintenance.store', 'array'); + Queue::fake(); + Bus::fake(); + Process::fake(); + Server::flushIdentityMap(); + + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true, 'is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->otherTeam = Team::factory()->create(); + $this->otherUser = User::factory()->create(); + $this->otherTeam->members()->attach($this->otherUser->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first() + ?? Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = mcpAppCreateApplication($this->environment, $this->destination, 'mine'); + + $this->otherServer = Server::factory()->create(['team_id' => $this->otherTeam->id]); + $this->otherDestination = StandaloneDocker::where('server_id', $this->otherServer->id)->firstOrFail(); + $this->otherProject = Project::factory()->create(['team_id' => $this->otherTeam->id]); + $this->otherEnvironment = $this->otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $this->otherProject->id]); + $this->otherApplication = mcpAppCreateApplication($this->otherEnvironment, $this->otherDestination, 'theirs'); +}); + +function mcpAppCreateApplication(Environment $environment, StandaloneDocker $destination, string $name): Application +{ + $application = Application::factory()->create([ + 'name' => $name, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $application->settings->fill(['is_container_label_readonly_enabled' => false])->save(); + + return $application; +} + +function mcpAppToken(User $user, array $abilities = ['write']): string +{ + return $user->createToken('mcp-app', $abilities)->plainTextToken; +} + +function mcpAppCall(string $token, string $tool, array $arguments = []) +{ + app('auth')->forgetGuards(); + + return test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json, text/event-stream', + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => $tool, 'arguments' => (object) $arguments], + ]); +} + +function mcpAppText($response): string +{ + return (string) ($response->json('result.content.0.text') ?? $response->json('error.message')); +} + +function mcpAppIsError($response): bool +{ + return $response->json('result.isError') === true || $response->json('error') !== null; +} + +/** + * @return array + */ +function mcpAppPublicData(array $overrides = []): array +{ + return array_merge([ + 'project_uuid' => test()->project->uuid, + 'environment_uuid' => test()->environment->uuid, + 'server_uuid' => test()->server->uuid, + 'git_repository' => 'https://gitlab.com/coolify/test-static-app', + 'git_branch' => 'main', + 'build_pack' => 'static', + 'ports_exposes' => '80', + 'autogenerate_domain' => false, + 'name' => 'mcp-created', + ], $overrides); +} + +function mcpAppCreatedCount(): int +{ + return Application::where('name', 'mcp-created')->count(); +} + +// create_application + +test('create_application creates a public app in the token team, not the session team', function () { + $token = mcpAppToken($this->user); + $sessionTeam = Team::factory()->create(); + $sessionTeam->members()->attach($this->user->id, ['role' => 'owner']); + $sessionProject = Project::factory()->create(['team_id' => $sessionTeam->id]); + session(['currentTeam' => $sessionTeam]); + + $response = mcpAppCall($token, 'create_application', ['action' => 'public', 'data' => mcpAppPublicData()]); + + expect(mcpAppIsError($response))->toBeFalse(mcpAppText($response)); + $application = Application::where('name', 'mcp-created')->sole(); + expect($application->environment->project->team_id)->toBe($this->team->id) + ->and($application->destination_id)->toBe($this->destination->id); + + $intoSessionTeam = mcpAppCall($token, 'create_application', ['action' => 'public', 'data' => mcpAppPublicData([ + 'project_uuid' => $sessionProject->uuid, + 'environment_name' => 'production', + 'name' => 'session-team-app', + ])]); + expect(mcpAppIsError($intoSessionTeam))->toBeTrue() + ->and(Application::where('name', 'session-team-app')->exists())->toBeFalse(); +}); + +test('create_application creates a docker image app', function () { + $response = mcpAppCall(mcpAppToken($this->user), 'create_application', ['action' => 'docker_image', 'data' => [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_registry_image_name' => 'nginx', + 'docker_registry_image_tag' => 'latest', + 'ports_exposes' => '80', + 'autogenerate_domain' => false, + 'name' => 'mcp-created', + ]]); + + expect(mcpAppIsError($response))->toBeFalse(mcpAppText($response)) + ->and(Application::where('name', 'mcp-created')->sole()->build_pack)->toBe('dockerimage'); +}); + +test('create_application rejects a project or server of another team', function (string $field) { + $data = mcpAppPublicData([$field => $field === 'project_uuid' ? $this->otherProject->uuid : $this->otherServer->uuid]); + + $response = mcpAppCall(mcpAppToken($this->user), 'create_application', ['action' => 'public', 'data' => $data]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(mcpAppCreatedCount())->toBe(0); +})->with(['project_uuid', 'server_uuid']); + +test('create_application rejects a destination of another team', function () { + $extra = StandaloneDocker::forceCreate([ + 'name' => 'second', + 'network' => 'second-network', + 'server_id' => $this->server->id, + ]); + expect($this->server->destinations()->count())->toBeGreaterThan(1); + + $response = mcpAppCall(mcpAppToken($this->user), 'create_application', ['action' => 'public', 'data' => mcpAppPublicData([ + 'destination_uuid' => $this->otherDestination->uuid, + ])]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(mcpAppText($response))->toContain('destination_uuid') + ->and(mcpAppCreatedCount())->toBe(0); + + $ok = mcpAppCall(mcpAppToken($this->user), 'create_application', ['action' => 'public', 'data' => mcpAppPublicData([ + 'destination_uuid' => $extra->uuid, + ])]); + expect(mcpAppIsError($ok))->toBeFalse(mcpAppText($ok)) + ->and(Application::where('name', 'mcp-created')->sole()->destination_id)->toBe($extra->id); +}); + +test('create_application rejects a private key of another team', function () { + $foreignKey = PrivateKey::factory()->create(['team_id' => $this->otherTeam->id]); + + $response = mcpAppCall(mcpAppToken($this->user), 'create_application', ['action' => 'deploy_key', 'data' => mcpAppPublicData([ + 'git_repository' => 'git@gitlab.com:coolify/private-app.git', + 'private_key_uuid' => $foreignKey->uuid, + ])]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(mcpAppText($response))->toContain('Private Key not found') + ->and(mcpAppCreatedCount())->toBe(0) + ->and(mcpAppText($response))->not->toContain('PRIVATE KEY'); +}); + +test('create_application rejects a GitHub App of another team', function () { + $foreignApp = GithubApp::forceCreate([ + 'name' => 'Their GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'team_id' => $this->otherTeam->id, + 'is_system_wide' => false, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->otherTeam->id])->id, + ]); + + $response = mcpAppCall(mcpAppToken($this->user), 'create_application', ['action' => 'github_app', 'data' => mcpAppPublicData([ + 'git_repository' => 'their-org/their-repo', + 'github_app_uuid' => $foreignApp->uuid, + ])]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(mcpAppText($response))->toContain('Github App not found') + ->and(mcpAppCreatedCount())->toBe(0); +}); + +test('create_application rejects read-only and member tokens', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + + foreach ([mcpAppToken($this->user, ['read']), mcpAppToken($member, ['read']), mcpAppToken($member, ['write'])] as $token) { + $response = mcpAppCall($token, 'create_application', ['action' => 'public', 'data' => mcpAppPublicData()]); + expect(mcpAppIsError($response))->toBeTrue(); + } + + expect(mcpAppCreatedCount())->toBe(0); +}); + +test('create_application instant_deploy needs the deploy ability', function () { + $response = mcpAppCall(mcpAppToken($this->user), 'create_application', ['action' => 'public', 'data' => mcpAppPublicData(['instant_deploy' => true])]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(mcpAppText($response))->toContain('deploy') + ->and(mcpAppCreatedCount())->toBe(0) + ->and(ApplicationDeploymentQueue::count())->toBe(0); +}); + +// manage_application + +test('manage_application accepts scalar and null unused path parameters', function (mixed $destinationUuid) { + $response = mcpAppCall(mcpAppToken($this->user), 'manage_application', [ + 'action' => 'update', + 'uuid' => $this->application->uuid, + 'destination_uuid' => $destinationUuid, + 'data' => ['name' => 'changed'], + ]); + + $response->assertOk()->assertJsonMissingPath('error'); + expect(mcpAppIsError($response))->toBeFalse() + ->and($this->application->fresh()->name)->toBe('changed'); +})->with([ + 'string' => ['unused'], + 'integer' => [123], + 'float' => [1.5], + 'boolean' => [false], + 'null' => [null], +]); + +test('manage_application rejects non-scalar unused path parameters before updating', function (mixed $destinationUuid) { + $response = mcpAppCall(mcpAppToken($this->user), 'manage_application', [ + 'action' => 'update', + 'uuid' => $this->application->uuid, + 'destination_uuid' => $destinationUuid, + 'data' => ['name' => 'changed'], + ]); + + $response->assertOk()->assertJsonPath('result.isError', true)->assertJsonMissingPath('error'); + expect(mcpAppText($response))->toContain('destination_uuid must be a string.') + ->and($this->application->fresh()->name)->toBe('mine'); +})->with([ + 'array' => [['invalid']], + 'object' => [(object) ['uuid' => 'invalid']], +]); + +test('manage_application updates, clones and moves an application of the token team', function () { + $token = mcpAppToken($this->user); + + $updated = mcpAppCall($token, 'manage_application', ['action' => 'update', 'uuid' => $this->application->uuid, 'data' => ['name' => 'renamed']]); + expect(mcpAppIsError($updated))->toBeFalse(mcpAppText($updated)) + ->and($this->application->fresh()->name)->toBe('renamed'); + + $cloned = mcpAppCall($token, 'manage_application', ['action' => 'clone', 'uuid' => $this->application->uuid, 'data' => ['destination_uuid' => $this->destination->uuid, 'name' => 'cloned']]); + expect(mcpAppIsError($cloned))->toBeFalse(mcpAppText($cloned)) + ->and(Application::where('name', 'cloned')->sole()->environment_id)->toBe($this->environment->id); + + $target = Environment::factory()->create(['project_id' => $this->project->id, 'name' => 'staging']); + $moved = mcpAppCall($token, 'manage_application', ['action' => 'move', 'uuid' => $this->application->uuid, 'confirm' => true, 'data' => ['environment_uuid' => $target->uuid]]); + expect(mcpAppIsError($moved))->toBeFalse(mcpAppText($moved)) + ->and($this->application->fresh()->environment_id)->toBe($target->id); +}); + +test('manage_application deletes an application only with confirm=true', function () { + $token = mcpAppToken($this->user); + + $denied = mcpAppCall($token, 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid]); + expect(mcpAppIsError($denied))->toBeTrue() + ->and(mcpAppText($denied))->toContain('confirm=true') + ->and(Application::find($this->application->id))->not->toBeNull(); + + $deleted = mcpAppCall($token, 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid, 'confirm' => true]); + expect(mcpAppIsError($deleted))->toBeFalse(mcpAppText($deleted)) + ->and(Application::find($this->application->id))->toBeNull(); + Bus::assertDispatched(DeleteResourceJob::class); +}); + +test('manage_application needs confirm for move and remove_server', function (string $action) { + $response = mcpAppCall(mcpAppToken($this->user), 'manage_application', [ + 'action' => $action, + 'uuid' => $this->application->uuid, + 'destination_uuid' => $this->destination->uuid, + 'data' => [], + ]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(mcpAppText($response))->toContain('confirm=true') + ->and($this->application->fresh()->environment_id)->toBe($this->environment->id); +})->with(['move', 'remove_server']); + +test('manage_application cannot touch an application of another team', function (string $action, array $data) { + $before = $this->otherApplication->fresh()->toArray(); + + $response = mcpAppCall(mcpAppToken($this->user), 'manage_application', [ + 'action' => $action, + 'uuid' => $this->otherApplication->uuid, + 'destination_uuid' => $this->otherDestination->uuid, + 'confirm' => true, + 'data' => $data, + ]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(Application::find($this->otherApplication->id))->not->toBeNull() + ->and($this->otherApplication->fresh()->name)->toBe($before['name']) + ->and($this->otherApplication->fresh()->environment_id)->toBe($before['environment_id']) + ->and(Application::where('name', 'hijacked')->exists())->toBeFalse(); +})->with([ + 'update' => ['update', ['name' => 'hijacked']], + 'delete' => ['delete', []], + 'clone' => ['clone', ['destination_uuid' => 'placeholder', 'name' => 'hijacked']], + 'move' => ['move', ['environment_uuid' => 'placeholder']], + 'add_server' => ['add_server', ['destination_uuid' => 'placeholder']], + 'remove_server' => ['remove_server', []], +]); + +test('manage_application rejects a destination or environment of another team', function () { + $token = mcpAppToken($this->user); + + $clone = mcpAppCall($token, 'manage_application', ['action' => 'clone', 'uuid' => $this->application->uuid, 'data' => ['destination_uuid' => $this->otherDestination->uuid, 'name' => 'hijacked']]); + expect(mcpAppIsError($clone))->toBeTrue() + ->and(Application::where('name', 'hijacked')->exists())->toBeFalse(); + + $move = mcpAppCall($token, 'manage_application', ['action' => 'move', 'uuid' => $this->application->uuid, 'confirm' => true, 'data' => ['environment_uuid' => $this->otherEnvironment->uuid]]); + expect(mcpAppIsError($move))->toBeTrue() + ->and($this->application->fresh()->environment_id)->toBe($this->environment->id); + + $addServer = mcpAppCall($token, 'manage_application', ['action' => 'add_server', 'uuid' => $this->application->uuid, 'data' => ['destination_uuid' => $this->otherDestination->uuid]]); + expect(mcpAppIsError($addServer))->toBeTrue() + ->and($this->application->fresh()->additional_networks)->toHaveCount(0); +}); + +test('manage_application adds and removes an additional server of the token team', function () { + $token = mcpAppToken($this->user); + $secondServer = Server::factory()->create(['team_id' => $this->team->id]); + $secondDestination = StandaloneDocker::where('server_id', $secondServer->id)->firstOrFail(); + + $added = mcpAppCall($token, 'manage_application', ['action' => 'add_server', 'uuid' => $this->application->uuid, 'data' => ['destination_uuid' => $secondDestination->uuid]]); + expect(mcpAppIsError($added))->toBeFalse(mcpAppText($added)) + ->and($this->application->fresh()->additional_networks)->toHaveCount(1); + + $removed = mcpAppCall($token, 'manage_application', ['action' => 'remove_server', 'uuid' => $this->application->uuid, 'destination_uuid' => $secondDestination->uuid, 'confirm' => true]); + expect(mcpAppIsError($removed))->toBeFalse(mcpAppText($removed)) + ->and($this->application->fresh()->additional_networks)->toHaveCount(0); +}); + +test('manage_application rejects read-only and member tokens', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + + foreach ([mcpAppToken($this->user, ['read']), mcpAppToken($member, ['read']), mcpAppToken($member, ['write'])] as $token) { + $update = mcpAppCall($token, 'manage_application', ['action' => 'update', 'uuid' => $this->application->uuid, 'data' => ['name' => 'nope']]); + $delete = mcpAppCall($token, 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid, 'confirm' => true]); + expect(mcpAppIsError($update))->toBeTrue() + ->and(mcpAppIsError($delete))->toBeTrue(); + } + + expect($this->application->fresh()->name)->toBe('mine'); +}); + +// manage_preview and get_application_preview + +test('manage_preview creates, updates and deletes a preview of the token team', function () { + $writeToken = mcpAppToken($this->user, ['write']); + $deployToken = mcpAppToken($this->user, ['deploy']); + + $writeOnly = mcpAppCall($writeToken, 'manage_preview', ['action' => 'create', 'uuid' => $this->application->uuid, 'data' => ['pull_request_id' => 7, 'git_type' => 'github', 'instant_deploy' => false]]); + expect(mcpAppIsError($writeOnly))->toBeTrue() + ->and(mcpAppText($writeOnly))->toContain('deploy'); + + $created = mcpAppCall($deployToken, 'manage_preview', ['action' => 'create', 'uuid' => $this->application->uuid, 'data' => ['pull_request_id' => 7, 'git_type' => 'github', 'instant_deploy' => false]]); + expect(mcpAppIsError($created))->toBeFalse(mcpAppText($created)) + ->and(ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', 7)->exists())->toBeTrue(); + + $updated = mcpAppCall($writeToken, 'manage_preview', ['action' => 'update', 'uuid' => $this->application->uuid, 'pull_request_id' => '7', 'data' => ['domains' => 'https://pr7.example.com']]); + expect(mcpAppIsError($updated))->toBeFalse(mcpAppText($updated)) + ->and(ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', 7)->sole()->fqdn)->toBe('https://pr7.example.com'); + + $denied = mcpAppCall($writeToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7']); + expect(mcpAppIsError($denied))->toBeTrue() + ->and(mcpAppText($denied))->toContain('confirm=true'); + + $deleted = mcpAppCall($writeToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7', 'confirm' => true]); + expect(mcpAppIsError($deleted))->toBeFalse(mcpAppText($deleted)) + ->and(ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', 7)->exists())->toBeFalse(); +}); + +test('manage_preview cannot touch previews of another team', function () { + $foreignPreview = ApplicationPreview::create(['application_id' => $this->otherApplication->id, 'pull_request_id' => 3, 'pull_request_html_url' => '', 'fqdn' => 'https://theirs.example.com']); + $token = mcpAppToken($this->user, ['write', 'deploy']); + + $create = mcpAppCall($token, 'manage_preview', ['action' => 'create', 'uuid' => $this->otherApplication->uuid, 'data' => ['pull_request_id' => 9, 'git_type' => 'github', 'instant_deploy' => false]]); + $update = mcpAppCall($token, 'manage_preview', ['action' => 'update', 'uuid' => $this->otherApplication->uuid, 'pull_request_id' => '3', 'data' => ['domains' => 'https://hijacked.example.com']]); + $delete = mcpAppCall($token, 'manage_preview', ['action' => 'delete', 'uuid' => $this->otherApplication->uuid, 'pull_request_id' => '3', 'confirm' => true]); + + expect(mcpAppIsError($create))->toBeTrue() + ->and(mcpAppIsError($update))->toBeTrue() + ->and(mcpAppIsError($delete))->toBeTrue() + ->and(ApplicationPreview::where('application_id', $this->otherApplication->id)->where('pull_request_id', 9)->exists())->toBeFalse() + ->and($foreignPreview->fresh())->not->toBeNull() + ->and($foreignPreview->fresh()->fqdn)->toBe('https://theirs.example.com'); +}); + +test('manage_preview rejects read-only tokens', function () { + ApplicationPreview::create(['application_id' => $this->application->id, 'pull_request_id' => 4, 'pull_request_html_url' => '']); + $token = mcpAppToken($this->user, ['read']); + + expect(mcpAppIsError(mcpAppCall($token, 'manage_preview', ['action' => 'create', 'uuid' => $this->application->uuid, 'data' => ['pull_request_id' => 5, 'git_type' => 'github']])))->toBeTrue() + ->and(mcpAppIsError(mcpAppCall($token, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '4', 'confirm' => true])))->toBeTrue() + ->and(ApplicationPreview::where('application_id', $this->application->id)->count())->toBe(1); +}); + +test('get_application_preview returns a preview of the token team only', function () { + ApplicationPreview::create(['application_id' => $this->application->id, 'pull_request_id' => 12, 'pull_request_html_url' => '', 'fqdn' => 'https://pr12.example.com']); + ApplicationPreview::create(['application_id' => $this->otherApplication->id, 'pull_request_id' => 12, 'pull_request_html_url' => '', 'fqdn' => 'https://theirs.example.com']); + $token = mcpAppToken($this->user, ['read']); + + $mine = mcpAppCall($token, 'get_application_preview', ['action' => 'get', 'uuid' => $this->application->uuid, 'pull_request_id' => '12']); + expect(mcpAppIsError($mine))->toBeFalse(mcpAppText($mine)) + ->and(mcpAppText($mine))->toContain('pr12.example.com'); + + $theirs = mcpAppCall($token, 'get_application_preview', ['action' => 'get', 'uuid' => $this->otherApplication->uuid, 'pull_request_id' => '12']); + expect(mcpAppIsError($theirs))->toBeTrue() + ->and(mcpAppText($theirs))->not->toContain('theirs.example.com'); +}); + +test('get_application_preview logs need read:sensitive and stay inside the token team', function () { + ApplicationPreview::create(['application_id' => $this->otherApplication->id, 'pull_request_id' => 12, 'pull_request_html_url' => '']); + + $readOnly = mcpAppCall(mcpAppToken($this->user, ['read']), 'get_application_preview', ['action' => 'logs', 'uuid' => $this->application->uuid, 'pull_request_id' => '12']); + expect(mcpAppIsError($readOnly))->toBeTrue() + ->and(mcpAppText($readOnly))->toContain('read:sensitive'); + + $foreign = mcpAppCall(mcpAppToken($this->user, ['read', 'read:sensitive']), 'get_application_preview', ['action' => 'logs', 'uuid' => $this->otherApplication->uuid, 'pull_request_id' => '12']); + expect(mcpAppIsError($foreign))->toBeTrue() + ->and(mcpAppText($foreign))->toContain('not found'); + Process::assertNothingRan(); +}); + +// rollback_application and list_rollback_images + +test('rollback_application needs confirm and the deploy ability, then queues a rollback', function () { + $writeOnly = mcpAppCall(mcpAppToken($this->user, ['write']), 'rollback_application', ['action' => 'rollback', 'uuid' => $this->application->uuid, 'confirm' => true, 'data' => ['commit' => 'abc1234']]); + expect(mcpAppIsError($writeOnly))->toBeTrue() + ->and(mcpAppText($writeOnly))->toContain('Tool [rollback_application] not found'); + + $deployToken = mcpAppToken($this->user, ['deploy']); + $unconfirmed = mcpAppCall($deployToken, 'rollback_application', ['action' => 'rollback', 'uuid' => $this->application->uuid, 'data' => ['commit' => 'abc1234']]); + expect(mcpAppIsError($unconfirmed))->toBeTrue() + ->and(mcpAppText($unconfirmed))->toContain('confirm=true') + ->and(ApplicationDeploymentQueue::count())->toBe(0); + + $queued = mcpAppCall($deployToken, 'rollback_application', ['action' => 'rollback', 'uuid' => $this->application->uuid, 'confirm' => true, 'data' => ['commit' => 'abc1234']]); + expect(mcpAppIsError($queued))->toBeFalse(mcpAppText($queued)) + ->and(ApplicationDeploymentQueue::where('application_id', $this->application->id)->where('commit', 'abc1234')->where('rollback', true)->exists())->toBeTrue(); +}); + +test('rollback_application cannot roll back an application of another team', function () { + $response = mcpAppCall(mcpAppToken($this->user, ['deploy']), 'rollback_application', ['action' => 'rollback', 'uuid' => $this->otherApplication->uuid, 'confirm' => true, 'data' => ['commit' => 'abc1234']]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(ApplicationDeploymentQueue::count())->toBe(0); +}); + +test('list_rollback_images lists images for the token team only', function () { + $token = mcpAppToken($this->user, ['read']); + + $mine = mcpAppCall($token, 'list_rollback_images', ['action' => 'list', 'uuid' => $this->application->uuid]); + expect(mcpAppIsError($mine))->toBeFalse(mcpAppText($mine)) + ->and(mcpAppText($mine))->toContain('images'); + + $theirs = mcpAppCall($token, 'list_rollback_images', ['action' => 'list', 'uuid' => $this->otherApplication->uuid]); + expect(mcpAppIsError($theirs))->toBeTrue() + ->and(mcpAppText($theirs))->toContain('not found'); +}); diff --git a/tests/Feature/Mcp/McpDataToolsTest.php b/tests/Feature/Mcp/McpDataToolsTest.php new file mode 100644 index 0000000000..6e14c17ff2 --- /dev/null +++ b/tests/Feature/Mcp/McpDataToolsTest.php @@ -0,0 +1,787 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true, 'is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + Server::flushIdentityMap(); + Queue::fake(); + Process::fake(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->otherTeam = Team::factory()->create(); + $this->otherUser = User::factory()->create(); + $this->otherTeam->members()->attach($this->otherUser->id, ['role' => 'owner']); + + $this->mine = mcpDataSeedTeam($this->team); + $this->theirs = mcpDataSeedTeam($this->otherTeam); + + session(['currentTeam' => $this->team]); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +/** + * @return array{server: Server, destination: StandaloneDocker, project: Project, environment: Environment, database: StandalonePostgresql, service: Service, service_app: ServiceApplication, service_db: ServiceDatabase, application: Application} + */ +function mcpDataSeedTeam(Team $team): array +{ + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'db-'.$team->id, + 'postgres_user' => 'postgres', + 'postgres_password' => 'pg-secret-'.$team->id, + 'postgres_db' => 'app', + 'image' => 'postgres:17', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $service = Service::factory()->create([ + 'name' => 'svc-'.$team->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + $serviceApp = ServiceApplication::create(['uuid' => (string) Str::uuid(), 'name' => 'web', 'service_id' => $service->id, 'image' => 'nginx:alpine']); + $serviceDb = ServiceDatabase::create(['uuid' => (string) Str::uuid(), 'name' => 'postgres', 'service_id' => $service->id, 'image' => 'postgres:17']); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + return [ + 'server' => $server, + 'destination' => $destination, + 'project' => $project, + 'environment' => $environment, + 'database' => $database, + 'service' => $service, + 'service_app' => $serviceApp, + 'service_db' => $serviceDb, + 'application' => $application, + ]; +} + +function mcpDataToken(User $user, array $abilities = ['write']): string +{ + return $user->createToken('mcp-data', $abilities)->plainTextToken; +} + +function mcpDataCall(string $token, string $tool, array $arguments = []) +{ + // Each call authenticates its own token; the guard would otherwise keep the user of the previous call. + app('auth')->forgetGuards(); + + return test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json, text/event-stream', + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => $tool, 'arguments' => (object) $arguments], + ]); +} + +function mcpDataText($response): string +{ + return (string) ($response->json('result.content.0.text') ?? $response->json('error.message')); +} + +/** + * A tool error (result.isError) or a JSON-RPC error, for example the MCP endpoint rejecting a member token with write abilities. + */ +function mcpDataIsError($response): bool +{ + return $response->json('result.isError') === true || $response->json('error') !== null; +} + +function mcpDataJson($response): array +{ + return json_decode(mcpDataText($response), true) ?? []; +} + +function mcpDataMember(Team $team): User +{ + $member = User::factory()->create(); + $team->members()->attach($member->id, ['role' => 'member']); + + return $member; +} + +function mcpDataDbEnv(StandalonePostgresql $database, string $key, string $value, bool $shownOnce = false): EnvironmentVariable +{ + return EnvironmentVariable::create([ + 'key' => $key, + 'value' => $value, + 'is_shown_once' => $shownOnce, + 'resourceable_type' => $database->getMorphClass(), + 'resourceable_id' => $database->id, + ]); +} + +function mcpDataS3(Team $team): S3Storage +{ + return S3Storage::create([ + 'name' => 'backups-'.$team->id, + 'region' => 'us-east-1', + 'key' => 'AKIADATATEST', + 'secret' => 's3-secret-'.$team->id, + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); +} + +// create_database + +test('create_database creates a database in the token team and never returns connection URLs', function (array $abilities) { + $response = mcpDataCall(mcpDataToken($this->user, $abilities), 'create_database', ['action' => 'postgresql', 'data' => [ + 'project_uuid' => $this->mine['project']->uuid, + 'environment_uuid' => $this->mine['environment']->uuid, + 'server_uuid' => $this->mine['server']->uuid, + 'name' => 'from-mcp', + 'postgres_password' => 'VeryS3cretPass', + 'is_public' => true, + 'public_port' => 15432, + ]]); + + expect(mcpDataIsError($response))->toBeFalse(); + $uuid = mcpDataJson($response)['data']['uuid']; + $database = StandalonePostgresql::whereUuid($uuid)->sole(); + expect($database->name)->toBe('from-mcp') + ->and($database->environment_id)->toBe($this->mine['environment']->id) + ->and(mcpDataText($response))->not->toContain('VeryS3cretPass') + ->and(mcpDataText($response))->not->toContain('internal_db_url') + ->and(mcpDataText($response))->not->toContain('external_db_url') + ->and(mcpDataText($response))->not->toContain('postgres_password'); +})->with([ + 'write' => [['write']], + 'root' => [['root']], + 'write and read:sensitive' => [['write', 'read:sensitive']], +]); + +test('create_database rejects a project, server, or destination of another team', function (string $field) { + $own = $this->mine; + StandaloneDocker::create(['name' => 'second', 'network' => 'second-net', 'server_id' => $own['server']->id]); + $data = [ + 'project_uuid' => $own['project']->uuid, + 'environment_uuid' => $own['environment']->uuid, + 'server_uuid' => $own['server']->uuid, + 'destination_uuid' => $own['destination']->uuid, + 'name' => 'cross-team', + ]; + $data[$field] = match ($field) { + 'project_uuid' => $this->theirs['project']->uuid, + 'environment_uuid' => $this->theirs['environment']->uuid, + 'server_uuid' => $this->theirs['server']->uuid, + 'destination_uuid' => $this->theirs['destination']->uuid, + }; + + $response = mcpDataCall(mcpDataToken($this->user), 'create_database', ['action' => 'postgresql', 'data' => $data]); + + expect(mcpDataIsError($response))->toBeTrue() + ->and(StandalonePostgresql::where('name', 'cross-team')->exists())->toBeFalse(); +})->with(['project_uuid', 'environment_uuid', 'server_uuid', 'destination_uuid']); + +test('create_database rejects read-only and member tokens, and instant_deploy without deploy', function () { + $data = [ + 'project_uuid' => $this->mine['project']->uuid, + 'environment_uuid' => $this->mine['environment']->uuid, + 'server_uuid' => $this->mine['server']->uuid, + 'name' => 'denied', + ]; + $member = mcpDataMember($this->team); + + $read = mcpDataCall(mcpDataToken($this->user, ['read']), 'create_database', ['action' => 'redis', 'data' => $data]); + $memberResponse = mcpDataCall(mcpDataToken($member, ['write']), 'create_database', ['action' => 'redis', 'data' => $data]); + $instant = mcpDataCall(mcpDataToken($this->user), 'create_database', ['action' => 'redis', 'data' => $data + ['instant_deploy' => true]]); + + expect(mcpDataIsError($read))->toBeTrue() + ->and(mcpDataText($read))->toContain('Tool [create_database] not found') + ->and(mcpDataIsError($memberResponse))->toBeTrue() + ->and(mcpDataIsError($instant))->toBeTrue() + ->and(mcpDataText($instant))->toContain('deploy'); + expect(StandalonePostgresql::where('name', 'denied')->exists())->toBeFalse(); +}); + +// manage_database + +test('manage_database updates a database of the token team', function () { + $response = mcpDataCall(mcpDataToken($this->user), 'manage_database', ['action' => 'update', 'uuid' => $this->mine['database']->uuid, 'data' => ['name' => 'renamed']]); + + expect(mcpDataIsError($response))->toBeFalse() + ->and($this->mine['database']->fresh()->name)->toBe('renamed'); +}); + +test('manage_database cannot touch a database of another team', function (string $action, array $data) { + $foreign = $this->theirs['database']; + + $response = mcpDataCall(mcpDataToken($this->user), 'manage_database', ['action' => $action, 'uuid' => $foreign->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpDataIsError($response))->toBeTrue(); + $fresh = StandalonePostgresql::withTrashed()->find($foreign->id); + expect($fresh->trashed())->toBeFalse() + ->and($fresh->name)->toBe($foreign->name) + ->and($fresh->environment_id)->toBe($foreign->environment_id) + ->and(StandalonePostgresql::count())->toBe(2); +})->with([ + 'update' => ['update', ['name' => 'hijacked']], + 'delete' => ['delete', []], + 'clone' => ['clone', ['destination_uuid' => 'placeholder']], + 'move' => ['move', ['environment_uuid' => 'placeholder']], + 'import' => ['import', ['source' => 's3', 's3_storage_uuid' => 'x', 'path' => '/a.sql']], +]); + +test('manage_database rejects a destination, environment, or S3 storage of another team', function () { + $own = $this->mine['database']; + $token = mcpDataToken($this->user); + $foreignS3 = mcpDataS3($this->otherTeam); + + $clone = mcpDataCall($token, 'manage_database', ['action' => 'clone', 'uuid' => $own->uuid, 'confirm' => true, 'data' => ['destination_uuid' => $this->theirs['destination']->uuid]]); + $move = mcpDataCall($token, 'manage_database', ['action' => 'move', 'uuid' => $own->uuid, 'confirm' => true, 'data' => ['environment_uuid' => $this->theirs['environment']->uuid]]); + $import = mcpDataCall($token, 'manage_database', ['action' => 'import', 'uuid' => $own->uuid, 'confirm' => true, 'data' => ['source' => 's3', 's3_storage_uuid' => $foreignS3->uuid, 'path' => '/backup.sql']]); + + expect(mcpDataIsError($clone))->toBeTrue() + ->and(mcpDataIsError($move))->toBeTrue() + ->and(mcpDataIsError($import))->toBeTrue() + ->and(StandalonePostgresql::count())->toBe(2) + ->and($own->fresh()->environment_id)->toBe($this->mine['environment']->id) + ->and(mcpDataText($import))->not->toContain('s3-secret'); +}); + +test('manage_database clones to an own destination and moves to an own environment', function () { + $own = $this->mine['database']; + $token = mcpDataToken($this->user); + $target = Environment::factory()->create(['project_id' => $this->mine['project']->id]); + + $clone = mcpDataCall($token, 'manage_database', ['action' => 'clone', 'uuid' => $own->uuid, 'confirm' => true, 'data' => ['destination_uuid' => $this->mine['destination']->uuid, 'name' => 'the-clone']]); + $move = mcpDataCall($token, 'manage_database', ['action' => 'move', 'uuid' => $own->uuid, 'confirm' => true, 'data' => ['environment_uuid' => $target->uuid]]); + + expect(mcpDataIsError($clone))->toBeFalse() + ->and(StandalonePostgresql::where('name', 'the-clone')->sole()->environment_id)->toBe($this->mine['environment']->id) + ->and(mcpDataIsError($move))->toBeFalse() + ->and($own->fresh()->environment_id)->toBe($target->id); +}); + +test('manage_database destructive actions need confirm=true', function (string $action, array $data) { + $own = $this->mine['database']; + + $denied = mcpDataCall(mcpDataToken($this->user), 'manage_database', ['action' => $action, 'uuid' => $own->uuid, 'data' => $data]); + + expect(mcpDataIsError($denied))->toBeTrue() + ->and(mcpDataText($denied))->toContain('confirm=true') + ->and(StandalonePostgresql::count())->toBe(2) + ->and($own->fresh())->not->toBeNull(); +})->with([ + 'delete' => ['delete', []], + 'clone' => ['clone', ['destination_uuid' => 'x']], + 'move' => ['move', ['environment_uuid' => 'x']], + 'import' => ['import', ['source' => 'server', 'path' => '/tmp/a.sql']], +]); + +test('manage_database delete with confirm=true deletes an own database', function () { + $own = $this->mine['database']; + + $response = mcpDataCall(mcpDataToken($this->user), 'manage_database', ['action' => 'delete', 'uuid' => $own->uuid, 'confirm' => true]); + + expect(mcpDataIsError($response))->toBeFalse() + ->and(StandalonePostgresql::find($own->id))->toBeNull(); +}); + +test('manage_database import runs REST validation and needs deploy for a server path', function () { + $own = $this->mine['database']; + $token = mcpDataToken($this->user); + + $invalid = mcpDataCall($token, 'manage_database', ['action' => 'import', 'uuid' => $own->uuid, 'confirm' => true, 'data' => ['source' => 's3']]); + $serverPath = mcpDataCall($token, 'manage_database', ['action' => 'import', 'uuid' => $own->uuid, 'confirm' => true, 'data' => ['source' => 'server', 'path' => '/etc/passwd']]); + + expect(mcpDataIsError($invalid))->toBeTrue() + ->and(mcpDataText($invalid))->toContain('s3_storage_uuid') + ->and(mcpDataIsError($serverPath))->toBeTrue() + ->and(mcpDataText($serverPath))->toContain('deploy'); +}); + +test('manage_database rejects read-only and member tokens', function () { + $own = $this->mine['database']; + $member = mcpDataMember($this->team); + + $read = mcpDataCall(mcpDataToken($this->user, ['read']), 'manage_database', ['action' => 'update', 'uuid' => $own->uuid, 'data' => ['name' => 'nope']]); + $memberResponse = mcpDataCall(mcpDataToken($member, ['read']), 'manage_database', ['action' => 'update', 'uuid' => $own->uuid, 'data' => ['name' => 'nope']]); + + expect(mcpDataIsError($read))->toBeTrue() + ->and(mcpDataIsError($memberResponse))->toBeTrue() + ->and($own->fresh()->name)->not->toBe('nope'); +}); + +// get_database_import + +test('get_database_import returns an own import and hides imports of other teams', function () { + $own = $this->mine['database']; + $foreign = $this->theirs['database']; + $makeActivity = fn (Team $team, StandalonePostgresql $database): Activity => Activity::create([ + 'log_name' => 'default', + 'description' => 'import', + 'properties' => ['team_id' => $team->id, 'type_uuid' => $database->uuid, 'operation' => 'database_import', 'status' => 'finished'], + ]); + $ownActivity = $makeActivity($this->team, $own); + $foreignActivity = $makeActivity($this->otherTeam, $foreign); + $token = mcpDataToken($this->user, ['read']); + + $ok = mcpDataCall($token, 'get_database_import', ['action' => 'get', 'resource' => 'database', 'uuid' => $own->uuid, 'activity_id' => (string) $ownActivity->id]); + $foreignUuid = mcpDataCall($token, 'get_database_import', ['action' => 'get', 'resource' => 'database', 'uuid' => $foreign->uuid, 'activity_id' => (string) $foreignActivity->id]); + $foreignId = mcpDataCall($token, 'get_database_import', ['action' => 'get', 'resource' => 'database', 'uuid' => $own->uuid, 'activity_id' => (string) $foreignActivity->id]); + + expect(mcpDataIsError($ok))->toBeFalse() + ->and(mcpDataJson($ok)['data']['status'])->toBe('finished') + ->and(mcpDataJson($ok)['data']['output'])->toBeNull() + ->and(mcpDataIsError($foreignUuid))->toBeTrue() + ->and(mcpDataIsError($foreignId))->toBeTrue(); +}); + +test('get_database_import rejects a service database of another team', function () { + $response = mcpDataCall(mcpDataToken($this->user, ['read']), 'get_database_import', [ + 'action' => 'get', + 'resource' => 'service_database', + 'uuid' => $this->mine['service']->uuid, + 'database_uuid' => $this->theirs['service_db']->uuid, + 'activity_id' => '1', + ]); + + expect(mcpDataIsError($response))->toBeTrue() + ->and(mcpDataText($response))->toContain('not found'); +}); + +// list_service_templates + +test('list_service_templates lists template metadata without Compose content', function () { + $templates = get_service_templates(); + $type = (string) $templates->keys()->first(); + + $response = mcpDataCall(mcpDataToken($this->user, ['read']), 'list_service_templates', ['search' => $type, 'per_page' => 100]); + + expect(mcpDataIsError($response))->toBeFalse(); + $items = collect(mcpDataJson($response)['data']); + expect($items->pluck('type'))->toContain($type) + ->and(array_keys($items->first()))->toEqualCanonicalizing(['type', 'slogan', 'category', 'tags', 'documentation', 'port']) + ->and(mcpDataText($response))->not->toContain((string) data_get($templates->get($type), 'compose')); +}); + +test('list_service_templates paginates and requires a valid token', function () { + $response = mcpDataCall(mcpDataToken($this->user, ['read']), 'list_service_templates', ['per_page' => 2]); + $payload = mcpDataJson($response); + + expect($payload['data'])->toHaveCount(2) + ->and($payload['_pagination']['total'])->toBe(get_service_templates()->count()) + ->and($payload['_pagination']['next']['args']['page'])->toBe(2); + + $none = mcpDataCall(mcpDataToken($this->user, []), 'list_service_templates'); + expect(mcpDataIsError($none))->toBeTrue(); +}); + +// create_service + +test('create_service creates a Compose service in the token team', function () { + $response = mcpDataCall(mcpDataToken($this->user), 'create_service', ['action' => 'compose', 'data' => [ + 'project_uuid' => $this->mine['project']->uuid, + 'environment_uuid' => $this->mine['environment']->uuid, + 'server_uuid' => $this->mine['server']->uuid, + 'name' => 'mcp-service', + 'docker_compose_raw' => base64_encode("services:\n app:\n image: nginx:alpine\n"), + ]]); + + expect(mcpDataIsError($response))->toBeFalse(); + $service = Service::whereUuid(mcpDataJson($response)['data']['uuid'])->sole(); + expect($service->name)->toBe('mcp-service') + ->and($service->environment_id)->toBe($this->mine['environment']->id) + ->and($service->server_id)->toBe($this->mine['server']->id); +}); + +test('create_service rejects a project, server, or destination of another team', function (string $field) { + $own = $this->mine; + StandaloneDocker::create(['name' => 'second', 'network' => 'second-net', 'server_id' => $own['server']->id]); + $data = [ + 'project_uuid' => $own['project']->uuid, + 'environment_uuid' => $own['environment']->uuid, + 'server_uuid' => $own['server']->uuid, + 'destination_uuid' => $own['destination']->uuid, + 'name' => 'cross-team-service', + 'docker_compose_raw' => base64_encode("services:\n app:\n image: nginx:alpine\n"), + ]; + $data[$field] = match ($field) { + 'project_uuid' => $this->theirs['project']->uuid, + 'environment_uuid' => $this->theirs['environment']->uuid, + 'server_uuid' => $this->theirs['server']->uuid, + 'destination_uuid' => $this->theirs['destination']->uuid, + }; + + $response = mcpDataCall(mcpDataToken($this->user), 'create_service', ['action' => 'compose', 'data' => $data]); + + expect(mcpDataIsError($response))->toBeTrue() + ->and(Service::where('name', 'cross-team-service')->exists())->toBeFalse(); +})->with(['project_uuid', 'environment_uuid', 'server_uuid', 'destination_uuid']); + +test('create_service rejects read-only and member tokens', function () { + $data = [ + 'project_uuid' => $this->mine['project']->uuid, + 'environment_uuid' => $this->mine['environment']->uuid, + 'server_uuid' => $this->mine['server']->uuid, + 'name' => 'denied-service', + 'docker_compose_raw' => base64_encode("services:\n app:\n image: nginx:alpine\n"), + ]; + + $read = mcpDataCall(mcpDataToken($this->user, ['read']), 'create_service', ['action' => 'compose', 'data' => $data]); + $member = mcpDataCall(mcpDataToken(mcpDataMember($this->team), ['write']), 'create_service', ['action' => 'compose', 'data' => $data]); + + expect(mcpDataIsError($read))->toBeTrue() + ->and(mcpDataIsError($member))->toBeTrue() + ->and(Service::where('name', 'denied-service')->exists())->toBeFalse(); +}); + +// manage_service + +test('manage_service updates a service, its application, and its database', function () { + $own = $this->mine; + $token = mcpDataToken($this->user); + + $service = mcpDataCall($token, 'manage_service', ['action' => 'update', 'uuid' => $own['service']->uuid, 'data' => ['name' => 'renamed-service']]); + $app = mcpDataCall($token, 'manage_service', ['action' => 'update_application', 'uuid' => $own['service']->uuid, 'app_uuid' => $own['service_app']->uuid, 'data' => ['human_name' => 'Web']]); + $db = mcpDataCall($token, 'manage_service', ['action' => 'update_database', 'uuid' => $own['service']->uuid, 'database_uuid' => $own['service_db']->uuid, 'data' => ['human_name' => 'DB']]); + + expect(mcpDataIsError($service))->toBeFalse() + ->and($own['service']->fresh()->name)->toBe('renamed-service') + ->and(mcpDataIsError($app))->toBeFalse() + ->and($own['service_app']->fresh()->human_name)->toBe('Web') + ->and(mcpDataIsError($db))->toBeFalse() + ->and($own['service_db']->fresh()->human_name)->toBe('DB'); +}); + +test('manage_service cannot touch a service of another team', function (string $action, array $data) { + $foreign = $this->theirs; + + $response = mcpDataCall(mcpDataToken($this->user), 'manage_service', [ + 'action' => $action, + 'uuid' => $foreign['service']->uuid, + 'app_uuid' => $foreign['service_app']->uuid, + 'database_uuid' => $foreign['service_db']->uuid, + 'confirm' => true, + 'data' => $data, + ]); + + expect(mcpDataIsError($response))->toBeTrue(); + $fresh = Service::withTrashed()->find($foreign['service']->id); + expect($fresh->trashed())->toBeFalse() + ->and($fresh->name)->toBe($foreign['service']->name) + ->and($fresh->environment_id)->toBe($foreign['environment']->id) + ->and($foreign['service_app']->fresh()->human_name)->toBeNull() + ->and($foreign['service_db']->fresh()->human_name)->toBeNull() + ->and(Service::count())->toBe(2); +})->with([ + 'update' => ['update', ['name' => 'hijacked']], + 'delete' => ['delete', []], + 'clone' => ['clone', ['destination_uuid' => 'placeholder']], + 'move' => ['move', ['environment_uuid' => 'placeholder']], + 'update_application' => ['update_application', ['human_name' => 'hijacked']], + 'update_database' => ['update_database', ['human_name' => 'hijacked']], + 'import_database' => ['import_database', ['source' => 's3', 's3_storage_uuid' => 'x', 'path' => '/a.sql']], +]); + +test('manage_service cannot reach an application or database of another team through an own service', function () { + $token = mcpDataToken($this->user); + + $app = mcpDataCall($token, 'manage_service', ['action' => 'update_application', 'uuid' => $this->mine['service']->uuid, 'app_uuid' => $this->theirs['service_app']->uuid, 'data' => ['human_name' => 'hijacked']]); + $db = mcpDataCall($token, 'manage_service', ['action' => 'update_database', 'uuid' => $this->mine['service']->uuid, 'database_uuid' => $this->theirs['service_db']->uuid, 'data' => ['human_name' => 'hijacked']]); + + expect(mcpDataIsError($app))->toBeTrue() + ->and(mcpDataIsError($db))->toBeTrue() + ->and($this->theirs['service_app']->fresh()->human_name)->toBeNull() + ->and($this->theirs['service_db']->fresh()->human_name)->toBeNull(); +}); + +test('manage_service rejects a destination, environment, or S3 storage of another team', function () { + $own = $this->mine; + $token = mcpDataToken($this->user); + $foreignS3 = mcpDataS3($this->otherTeam); + + $clone = mcpDataCall($token, 'manage_service', ['action' => 'clone', 'uuid' => $own['service']->uuid, 'confirm' => true, 'data' => ['destination_uuid' => $this->theirs['destination']->uuid]]); + $move = mcpDataCall($token, 'manage_service', ['action' => 'move', 'uuid' => $own['service']->uuid, 'confirm' => true, 'data' => ['environment_uuid' => $this->theirs['environment']->uuid]]); + $import = mcpDataCall($token, 'manage_service', ['action' => 'import_database', 'uuid' => $own['service']->uuid, 'database_uuid' => $own['service_db']->uuid, 'confirm' => true, 'data' => ['source' => 's3', 's3_storage_uuid' => $foreignS3->uuid, 'path' => '/a.sql']]); + + expect(mcpDataIsError($clone))->toBeTrue() + ->and(mcpDataIsError($move))->toBeTrue() + ->and(mcpDataIsError($import))->toBeTrue() + ->and(Service::count())->toBe(2) + ->and($own['service']->fresh()->environment_id)->toBe($own['environment']->id); +}); + +test('manage_service destructive actions need confirm=true', function (string $action) { + $own = $this->mine; + + $denied = mcpDataCall(mcpDataToken($this->user), 'manage_service', ['action' => $action, 'uuid' => $own['service']->uuid, 'database_uuid' => $own['service_db']->uuid, 'data' => ['destination_uuid' => 'x']]); + + expect(mcpDataIsError($denied))->toBeTrue() + ->and(mcpDataText($denied))->toContain('confirm=true') + ->and($own['service']->fresh())->not->toBeNull(); +})->with(['delete', 'clone', 'move', 'import_database']); + +test('manage_service delete with confirm=true deletes an own service', function () { + $response = mcpDataCall(mcpDataToken($this->user), 'manage_service', ['action' => 'delete', 'uuid' => $this->mine['service']->uuid, 'confirm' => true]); + + expect(mcpDataIsError($response))->toBeFalse() + ->and(Service::find($this->mine['service']->id))->toBeNull(); +}); + +test('manage_service rejects read-only tokens', function () { + $response = mcpDataCall(mcpDataToken($this->user, ['read']), 'manage_service', ['action' => 'update', 'uuid' => $this->mine['service']->uuid, 'data' => ['name' => 'nope']]); + + expect(mcpDataIsError($response))->toBeTrue() + ->and($this->mine['service']->fresh()->name)->not->toBe('nope'); +}); + +// manage_env + +test('manage_env creates, updates, and deletes variables without returning values', function (string $resource) { + $target = match ($resource) { + 'application' => $this->mine['application'], + 'database' => $this->mine['database'], + 'service' => $this->mine['service'], + }; + $token = mcpDataToken($this->user); + $env = fn (string $key) => EnvironmentVariable::where('resourceable_type', $target->getMorphClass())->where('resourceable_id', $target->id)->where('key', $key)->where('is_preview', false)->first(); + + $create = mcpDataCall($token, 'manage_env', ['action' => 'create', 'resource' => $resource, 'uuid' => $target->uuid, 'data' => ['key' => 'MCP_KEY', 'value' => 'first-secret-value']]); + $update = mcpDataCall($token, 'manage_env', ['action' => 'update', 'resource' => $resource, 'uuid' => $target->uuid, 'data' => ['key' => 'MCP_KEY', 'value' => 'second-secret-value']]); + + expect(mcpDataIsError($create))->toBeFalse() + ->and(mcpDataIsError($update))->toBeFalse() + ->and($env('MCP_KEY')->value)->toBe('second-secret-value'); + foreach ([$create, $update] as $response) { + expect(mcpDataText($response))->not->toContain('secret-value'); + } + + $uuid = $env('MCP_KEY')->uuid; + $denied = mcpDataCall($token, 'manage_env', ['action' => 'delete', 'resource' => $resource, 'uuid' => $target->uuid, 'env_uuid' => $uuid]); + expect(mcpDataIsError($denied))->toBeTrue() + ->and(mcpDataText($denied))->toContain('confirm=true') + ->and($env('MCP_KEY'))->not->toBeNull(); + + $deleted = mcpDataCall($token, 'manage_env', ['action' => 'delete', 'resource' => $resource, 'uuid' => $target->uuid, 'env_uuid' => $uuid, 'confirm' => true]); + expect(mcpDataIsError($deleted))->toBeFalse() + ->and($env('MCP_KEY'))->toBeNull(); +})->with(['application', 'database', 'service']); + +test('manage_env bulk updates variables without returning values', function (string $resource) { + $target = match ($resource) { + 'application' => $this->mine['application'], + 'database' => $this->mine['database'], + 'service' => $this->mine['service'], + }; + + $bulk = mcpDataCall(mcpDataToken($this->user), 'manage_env', ['action' => 'bulk_update', 'resource' => $resource, 'uuid' => $target->uuid, 'data' => ['data' => [['key' => 'MCP_BULK', 'value' => 'bulk-secret-value']]]]); + + expect(mcpDataIsError($bulk))->toBeFalse() + ->and(mcpDataText($bulk))->not->toContain('bulk-secret-value') + ->and(EnvironmentVariable::where('resourceable_type', $target->getMorphClass())->where('resourceable_id', $target->id)->where('key', 'MCP_BULK')->where('is_preview', false)->sole()->value)->toBe('bulk-secret-value'); +})->with(['application', 'database', 'service']); + +test('manage_env cannot change variables of another team', function (string $action) { + $foreign = $this->theirs['database']; + $foreignEnv = mcpDataDbEnv($foreign, 'THEIR_KEY', 'their-value'); + $token = mcpDataToken($this->user); + + $viaForeignUuid = mcpDataCall($token, 'manage_env', ['action' => $action, 'resource' => 'database', 'uuid' => $foreign->uuid, 'env_uuid' => $foreignEnv->uuid, 'confirm' => true, 'data' => ['key' => 'THEIR_KEY', 'value' => 'hijacked', 'data' => [['key' => 'THEIR_KEY', 'value' => 'hijacked']]]]); + $viaOwnUuid = mcpDataCall($token, 'manage_env', ['action' => 'delete', 'resource' => 'database', 'uuid' => $this->mine['database']->uuid, 'env_uuid' => $foreignEnv->uuid, 'confirm' => true]); + + expect(mcpDataIsError($viaForeignUuid))->toBeTrue() + ->and(mcpDataIsError($viaOwnUuid))->toBeTrue() + ->and($foreignEnv->fresh()->value)->toBe('their-value') + ->and(EnvironmentVariable::where('resourceable_id', $foreign->id)->where('resourceable_type', $foreign->getMorphClass())->count())->toBe(1); +})->with(['create', 'update', 'bulk_update', 'delete']); + +test('manage_env rejects read-only and member tokens', function () { + $own = $this->mine['database']; + $data = ['action' => 'create', 'resource' => 'database', 'uuid' => $own->uuid, 'data' => ['key' => 'DENIED', 'value' => 'x']]; + + $read = mcpDataCall(mcpDataToken($this->user, ['read']), 'manage_env', $data); + $member = mcpDataCall(mcpDataToken(mcpDataMember($this->team), ['write']), 'manage_env', $data); + + expect(mcpDataIsError($read))->toBeTrue() + ->and(mcpDataIsError($member))->toBeTrue() + ->and(EnvironmentVariable::where('key', 'DENIED')->exists())->toBeFalse(); +}); + +// manage_shared_env + +test('manage_shared_env creates, updates, and deletes shared variables in the token team', function (string $resource) { + $args = match ($resource) { + 'team' => [], + 'project' => ['uuid' => $this->mine['project']->uuid], + 'environment' => ['uuid' => $this->mine['project']->uuid, 'environment_name_or_uuid' => $this->mine['environment']->uuid], + 'server' => ['uuid' => $this->mine['server']->uuid], + }; + $token = mcpDataToken($this->user); + + $create = mcpDataCall($token, 'manage_shared_env', ['action' => 'create', 'resource' => $resource, 'data' => ['key' => 'SHARED_MCP', 'value' => 'shared-value']] + $args); + expect(mcpDataIsError($create))->toBeFalse(); + $envId = mcpDataJson($create)['data']['id']; + expect($envId)->not->toBeNull(); + $env = SharedEnvironmentVariable::where('key', 'SHARED_MCP')->sole(); + expect($env->team_id)->toBe($this->team->id) + ->and($env->type)->toBe($resource); + + $update = mcpDataCall($token, 'manage_shared_env', ['action' => 'update', 'resource' => $resource, 'env_id' => (string) $envId, 'data' => ['value' => 'updated-value']] + $args); + expect(mcpDataIsError($update))->toBeFalse() + ->and($env->fresh()->value)->toBe('updated-value') + ->and(mcpDataText($update))->not->toContain('updated-value'); + + $denied = mcpDataCall($token, 'manage_shared_env', ['action' => 'delete', 'resource' => $resource, 'env_id' => (string) $envId] + $args); + expect(mcpDataIsError($denied))->toBeTrue() + ->and(mcpDataText($denied))->toContain('confirm=true') + ->and($env->fresh())->not->toBeNull(); + + $deleted = mcpDataCall($token, 'manage_shared_env', ['action' => 'delete', 'resource' => $resource, 'env_id' => (string) $envId, 'confirm' => true] + $args); + expect(mcpDataIsError($deleted))->toBeFalse() + ->and(SharedEnvironmentVariable::find($env->id))->toBeNull(); +})->with(['team', 'project', 'environment', 'server']); + +test('manage_shared_env cannot create in or change shared variables of another team', function (string $resource) { + $foreignArgs = match ($resource) { + 'team' => [], + 'project' => ['uuid' => $this->theirs['project']->uuid], + 'environment' => ['uuid' => $this->theirs['project']->uuid, 'environment_name_or_uuid' => $this->theirs['environment']->uuid], + 'server' => ['uuid' => $this->theirs['server']->uuid], + }; + $foreignEnv = SharedEnvironmentVariable::create([ + 'key' => 'THEIR_SHARED', + 'value' => 'their-shared-value', + 'type' => $resource, + 'team_id' => $this->otherTeam->id, + 'project_id' => in_array($resource, ['project', 'environment'], true) ? $this->theirs['project']->id : null, + 'environment_id' => $resource === 'environment' ? $this->theirs['environment']->id : null, + 'server_id' => $resource === 'server' ? $this->theirs['server']->id : null, + ]); + $token = mcpDataToken($this->user); + + $update = mcpDataCall($token, 'manage_shared_env', ['action' => 'update', 'resource' => $resource, 'env_id' => (string) $foreignEnv->id, 'data' => ['value' => 'hijacked']] + $foreignArgs); + $delete = mcpDataCall($token, 'manage_shared_env', ['action' => 'delete', 'resource' => $resource, 'env_id' => (string) $foreignEnv->id, 'confirm' => true] + $foreignArgs); + + expect(mcpDataIsError($update))->toBeTrue() + ->and(mcpDataIsError($delete))->toBeTrue() + ->and($foreignEnv->fresh()->value)->toBe('their-shared-value'); + + if ($resource !== 'team') { + $create = mcpDataCall($token, 'manage_shared_env', ['action' => 'create', 'resource' => $resource, 'data' => ['key' => 'INJECTED', 'value' => 'x']] + $foreignArgs); + expect(mcpDataIsError($create))->toBeTrue() + ->and(SharedEnvironmentVariable::where('key', 'INJECTED')->exists())->toBeFalse(); + } +})->with(['team', 'project', 'environment', 'server']); + +test('manage_shared_env rejects read-only tokens', function () { + $response = mcpDataCall(mcpDataToken($this->user, ['read']), 'manage_shared_env', ['action' => 'create', 'resource' => 'team', 'data' => ['key' => 'DENIED_SHARED', 'value' => 'x']]); + + expect(mcpDataIsError($response))->toBeTrue() + ->and(SharedEnvironmentVariable::where('key', 'DENIED_SHARED')->exists())->toBeFalse(); +}); + +// get_env_values + +test('get_env_values returns values for an owner token with read:sensitive', function () { + $own = $this->mine['database']; + mcpDataDbEnv($own, 'DB_VISIBLE', 'visible-value'); + mcpDataDbEnv($own, 'DB_ONCE', 'shown-once-value', shownOnce: true); + SharedEnvironmentVariable::create(['key' => 'TEAM_VISIBLE', 'value' => 'team-value', 'type' => 'team', 'team_id' => $this->team->id]); + $token = mcpDataToken($this->user, ['read', 'read:sensitive']); + + $database = mcpDataCall($token, 'get_env_values', ['action' => 'list', 'resource' => 'database', 'uuid' => $own->uuid]); + $team = mcpDataCall($token, 'get_env_values', ['action' => 'list', 'resource' => 'team']); + + expect(mcpDataIsError($database))->toBeFalse() + ->and(collect(mcpDataJson($database)['data'])->firstWhere('key', 'DB_VISIBLE')['value'])->toBe('visible-value') + ->and(mcpDataText($database))->not->toContain('shown-once-value') + ->and(mcpDataIsError($team))->toBeFalse() + ->and(collect(mcpDataJson($team)['data'])->firstWhere('key', 'TEAM_VISIBLE')['value'])->toBe('team-value'); +}); + +test('get_env_values never returns values without read:sensitive or to members', function (array $abilities, bool $asMember) { + $own = $this->mine['database']; + mcpDataDbEnv($own, 'DB_HIDDEN', 'hidden-value'); + $user = $asMember ? mcpDataMember($this->team) : $this->user; + + $response = mcpDataCall(mcpDataToken($user, $abilities), 'get_env_values', ['action' => 'list', 'resource' => 'database', 'uuid' => $own->uuid]); + + expect(mcpDataIsError($response))->toBeTrue() + ->and(mcpDataText($response))->not->toContain('hidden-value'); +})->with([ + 'read token' => [['read'], false], + 'write token' => [['read', 'write'], false], + 'member with read:sensitive' => [['read', 'read:sensitive'], true], +]); + +test('get_env_values cannot read variables of another team', function (string $resource) { + $foreign = $this->theirs; + mcpDataDbEnv($foreign['database'], 'THEIR_DB', 'their-db-value'); + SharedEnvironmentVariable::create(['key' => 'THEIR_PROJECT', 'value' => 'their-project-value', 'type' => 'project', 'team_id' => $this->otherTeam->id, 'project_id' => $foreign['project']->id]); + $args = match ($resource) { + 'application' => ['uuid' => $foreign['application']->uuid], + 'database' => ['uuid' => $foreign['database']->uuid], + 'service' => ['uuid' => $foreign['service']->uuid], + 'project' => ['uuid' => $foreign['project']->uuid], + 'environment' => ['uuid' => $foreign['project']->uuid, 'environment_name_or_uuid' => $foreign['environment']->uuid], + 'server' => ['uuid' => $foreign['server']->uuid], + }; + + $response = mcpDataCall(mcpDataToken($this->user, ['read', 'read:sensitive']), 'get_env_values', ['action' => 'list', 'resource' => $resource] + $args); + + expect(mcpDataIsError($response))->toBeTrue() + ->and(mcpDataText($response))->not->toContain('their-'); +})->with(['application', 'database', 'service', 'project', 'environment', 'server']); + +test('get_env_values for the team returns only the token team variables', function () { + SharedEnvironmentVariable::create(['key' => 'THEIR_TEAM', 'value' => 'their-team-value', 'type' => 'team', 'team_id' => $this->otherTeam->id]); + + $response = mcpDataCall(mcpDataToken($this->user, ['read', 'read:sensitive']), 'get_env_values', ['action' => 'list', 'resource' => 'team']); + + expect(mcpDataIsError($response))->toBeFalse() + ->and(mcpDataText($response))->not->toContain('their-team-value'); +}); diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php index f09caca03d..55f788ddca 100644 --- a/tests/Feature/Mcp/McpEndpointTest.php +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -117,15 +117,23 @@ test('MCP endpoint rejects unauthenticated requests', function () { $response->assertStatus(401); }); -test('MCP endpoint works when the REST API is disabled and its IP allow-list excludes the client', function () { - InstanceSettings::query()->where('id', 0)->update(['is_api_enabled' => false, 'allowed_ips' => '192.0.2.10']); +test('MCP endpoint works when the REST API is disabled', function () { + InstanceSettings::query()->where('id', 0)->update(['is_api_enabled' => false]); Once::flush(); $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; mcpListTools($token)->assertOk(); - test()->withHeader('Authorization', 'Bearer '.$token) - ->getJson('/api/v1/version') - ->assertForbidden(); +}); + +test('MCP endpoint enforces the allowed API IPs', function () { + InstanceSettings::query()->where('id', 0)->update(['is_api_enabled' => false, 'allowed_ips' => '192.0.2.10']); + Once::flush(); + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + mcpListTools($token)->assertForbidden(); + + $this->withServerVariables(['REMOTE_ADDR' => '192.0.2.10']); + mcpListTools($token)->assertOk(); }); test('MCP endpoint lists tools for an authenticated token', function () { @@ -149,7 +157,6 @@ test('MCP endpoint lists tools for an authenticated token', function () { 'get_service', 'get_project', 'list_deployments', - 'get_logs', 'list_env_keys', ); expect($toolNames)->not->toContain( @@ -157,7 +164,61 @@ test('MCP endpoint lists tools for an authenticated token', function () { 'create_storage', 'update_storage', ); - expect($toolNames)->toContain('coolify_help', 'control', 'deploy'); + expect($toolNames)->toContain('coolify_help') + ->and($toolNames)->not->toContain('control', 'deploy', 'get_logs', 'manage_project'); +}); + +test('MCP tools/list shows write tools only to tokens with the ability', function () { + $names = fn (array $abilities): array => collect(mcpListTools($this->user->createToken('t', $abilities)->plainTextToken)->assertOk()->json('result.tools'))->pluck('name')->all(); + + app('auth')->forgetGuards(); + $read = $names(['read']); + app('auth')->forgetGuards(); + $deploy = $names(['read', 'deploy']); + app('auth')->forgetGuards(); + $root = $names(['root']); + + expect($read)->not->toContain('control', 'manage_project', 'create_application', 'get_logs', 'get_env_values') + ->and($deploy)->toContain('control', 'deploy', 'rollback_application') + ->and($deploy)->not->toContain('manage_project', 'get_logs') + ->and($root)->toContain('control', 'manage_project', 'get_logs', 'get_env_values') + ->and(count($read))->toBeLessThan(count($root)); +}); + +test('MCP grouped tools only list the actions the token can use', function () { + $token = $this->user->createToken('read', ['read'])->plainTextToken; + + $proxy = collect(mcpListTools($token)->json('result.tools'))->firstWhere('name', 'manage_proxy'); + + expect($proxy['inputSchema']['properties']['action']['enum'])->toBe(['get']); +}); + +test('MCP hidden tools cannot be called', function () { + $project = Project::create(['name' => 'Mine', 'team_id' => $this->team->id]); + $token = $this->user->createToken('read', ['read'])->plainTextToken; + + $response = mcpCallTool($token, 'manage_project', ['action' => 'delete', 'uuid' => $project->uuid, 'confirm' => true]); + + expect($response->json('error.message'))->toBe('Tool [manage_project] not found.') + ->and($project->fresh())->not->toBeNull(); +}); + +test('MCP tools declare read-only and destructive annotations', function () { + $token = $this->user->createToken('mcp-root', ['root'])->plainTextToken; + + $tools = collect(mcpListTools($token)->assertOk()->json('result.tools'))->keyBy('name'); + $writePrefixes = ['manage_', 'create_', 'rollback_', 'docker_cleanup', 'cloud_provider', 'control', 'deploy', 'cancel_deployment']; + + foreach ($tools as $name => $tool) { + $isWriteTool = collect($writePrefixes)->contains(fn (string $prefix): bool => str_starts_with($name, $prefix)); + + expect($tool['annotations'])->toHaveKey('readOnlyHint', ! $isWriteTool); + if ($isWriteTool) { + expect($tool['annotations'])->toHaveKey('destructiveHint', true); + } + } + expect($tools->has('list_projects'))->toBeTrue() + ->and($tools->has('manage_project'))->toBeTrue(); }); test('MCP endpoint accepts the legacy initialize handshake', function () { @@ -178,6 +239,25 @@ test('MCP endpoint accepts the legacy initialize handshake', function () { expect($response->json('result.protocolVersion'))->toBe('2025-06-18'); }); +test('MCP initialize negotiates when the client requests an unsupported protocol version', function () { + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpPost([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2026-07-28', + 'capabilities' => (object) [], + 'clientInfo' => ['name' => 'test', 'version' => '1'], + ], + ], $token); + + $response->assertOk(); + expect($response->json('error'))->toBeNull(); + expect($response->json('result.protocolVersion'))->toBe('2025-11-25'); +}); + test('MCP endpoint serves 2026-07-28 protocol requests with MCP headers', function () { Project::create(['name' => 'Mine', 'team_id' => $this->team->id]); $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; diff --git a/tests/Feature/Mcp/McpImproveTest.php b/tests/Feature/Mcp/McpImproveTest.php index 2847b37e26..62caecfe3a 100644 --- a/tests/Feature/Mcp/McpImproveTest.php +++ b/tests/Feature/Mcp/McpImproveTest.php @@ -83,10 +83,12 @@ test('get_logs returns structured next_tools when not running', function () { }); test('coolify_help returns essentials catalog', function () { - $response = mcpImproveCall('coolify_help', ['intent' => 'essentials']); + $response = mcpImproveCall('coolify_help'); $response->assertOk(); $body = mcpImproveJson($response); - expect($body['data']['catalog']['essentials']['tools'])->toContain('coolify_help', 'control', 'search_resources'); + expect($body['data']['start_here'])->toContain('search_resources') + ->and($body['data']['read_tools'])->toHaveKey('list_applications') + ->and($body['data']['change_tools'])->not->toHaveKey('control'); }); test('list_unhealthy_resources sample_only returns summary', function () { @@ -107,9 +109,9 @@ test('control requires deploy ability and stop requires confirm', function () { 'action' => 'start', 'uuid' => $this->application->uuid, ]); - expect($denied->json('result.isError'))->toBeTrue(); - expect($denied->json('result.content.0.text'))->toContain('Missing required permissions'); + expect($denied->json('error.message'))->toBe('Tool [control] not found.'); + app('auth')->forgetGuards(); $deployToken = $this->user->createToken('mcp-deploy-stop', ['read', 'deploy'])->plainTextToken; $stop = mcpImproveCall('control', [ 'resource' => 'application', @@ -135,5 +137,6 @@ test('tools list includes coolify_help and control', function () { ]); $response->assertOk(); $names = collect($response->json('result.tools'))->pluck('name')->all(); - expect($names)->toContain('coolify_help', 'control', 'deploy', 'cancel_deployment'); + expect($names)->toContain('coolify_help') + ->and($names)->not->toContain('control', 'deploy', 'cancel_deployment'); }); diff --git a/tests/Feature/Mcp/McpOperationsToolsTest.php b/tests/Feature/Mcp/McpOperationsToolsTest.php new file mode 100644 index 0000000000..193639dfe9 --- /dev/null +++ b/tests/Feature/Mcp/McpOperationsToolsTest.php @@ -0,0 +1,618 @@ +set('app.maintenance.store', 'array'); + config()->set('cache.default', 'array'); + Server::flushIdentityMap(); + Process::fake(); + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true, 'is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->own = mcpOpsSeedTeam($this->team); + + $this->otherTeam = Team::factory()->create(); + $this->otherUser = User::factory()->create(); + $this->otherTeam->members()->attach($this->otherUser->id, ['role' => 'owner']); + $this->other = mcpOpsSeedTeam($this->otherTeam); + + session(['currentTeam' => $this->team]); + Queue::fake(); +}); + +/** + * @return array + */ +function mcpOpsSeedTeam(Team $team): array +{ + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $destinationAttributes = [ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]; + + $application = Application::factory()->create($destinationAttributes); + $database = StandalonePostgresql::create([ + 'name' => 'ops-postgres-'.$team->id, + 'image' => 'postgres:17-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'db-password-'.$team->id, + 'postgres_db' => 'postgres', + ...$destinationAttributes, + ]); + $service = Service::factory()->create($destinationAttributes); + $serviceApplication = ServiceApplication::create([ + 'uuid' => new_public_id(), + 'name' => 'ops-service-app', + 'image' => 'nginx:alpine', + 'service_id' => $service->id, + ]); + $serviceDatabase = ServiceDatabase::create([ + 'uuid' => new_public_id(), + 'name' => 'ops-service-db', + 'image' => 'postgres:17-alpine', + 'service_id' => $service->id, + ]); + $volume = LocalPersistentVolume::create([ + 'name' => $application->uuid.'-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + $s3 = S3Storage::create([ + 'name' => 'ops-s3-'.$team->id, + 'region' => 'us-east-1', + 'key' => 'S3KEY'.$team->id, + 'secret' => 'S3SECRET'.$team->id, + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + + return compact('server', 'destination', 'project', 'environment', 'application', 'database', 'service', 'serviceApplication', 'serviceDatabase', 'volume', 's3'); +} + +function mcpOpsToken(User $user, array $abilities = ['read', 'write']): string +{ + return $user->createToken('mcp-ops', $abilities)->plainTextToken; +} + +function mcpOpsCall(string $token, string $tool, array $arguments = []) +{ + auth()->forgetGuards(); + + return test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json, text/event-stream', + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => $tool, 'arguments' => (object) $arguments], + ]); +} + +function mcpOpsText($response): string +{ + return (string) ($response->json('result.content.0.text') ?? $response->json('error.message')); +} + +function mcpOpsIsError($response): bool +{ + return $response->json('result.isError') === true || $response->json('error') !== null; +} + +function mcpOpsMember(Team $team): User +{ + $member = User::factory()->create(); + $team->members()->attach($member->id, ['role' => 'member']); + + return $member; +} + +// ---------------------------------------------------------------- control + +test('control starts, restarts, and stops a service application and service database of the token team', function () { + $token = mcpOpsToken($this->user, ['read', 'deploy']); + + $start = mcpOpsCall($token, 'control', ['resource' => 'service_application', 'action' => 'start', 'service_uuid' => $this->own['service']->uuid, 'uuid' => $this->own['serviceApplication']->uuid]); + expect(mcpOpsIsError($start))->toBeFalse() + ->and(mcpOpsText($start))->toContain('deploy request queued'); + DeployServiceApplication::assertPushed(); + + $restart = mcpOpsCall($token, 'control', ['resource' => 'service_database', 'action' => 'restart', 'service_uuid' => $this->own['service']->uuid, 'uuid' => $this->own['serviceDatabase']->uuid]); + expect(mcpOpsIsError($restart))->toBeFalse() + ->and(mcpOpsText($restart))->toContain('restart request queued'); + RestartServiceApplication::assertPushed(); + + $stop = mcpOpsCall($token, 'control', ['resource' => 'service_application', 'action' => 'stop', 'service_uuid' => $this->own['service']->uuid, 'uuid' => $this->own['serviceApplication']->uuid, 'confirm' => true]); + expect(mcpOpsIsError($stop))->toBeFalse(); + StopServiceApplication::assertPushed(); +}); + +test('control rejects service children of another team, also under an own service_uuid', function (string $resource, string $childKey, bool $ownParent) { + $token = mcpOpsToken($this->user, ['read', 'deploy']); + $parent = $ownParent ? $this->own['service'] : $this->other['service']; + + $response = mcpOpsCall($token, 'control', ['resource' => $resource, 'action' => 'restart', 'service_uuid' => $parent->uuid, 'uuid' => $this->other[$childKey]->uuid]); + + expect(mcpOpsIsError($response))->toBeTrue() + ->and(mcpOpsText($response))->toContain('not found'); + Queue::assertNothingPushed(); +})->with([ + 'foreign app, own service' => ['service_application', 'serviceApplication', true], + 'foreign app, foreign service' => ['service_application', 'serviceApplication', false], + 'foreign db, own service' => ['service_database', 'serviceDatabase', true], + 'foreign db, foreign service' => ['service_database', 'serviceDatabase', false], +]); + +test('control service child stop needs confirm and service_uuid', function () { + $token = mcpOpsToken($this->user, ['read', 'deploy']); + + $noConfirm = mcpOpsCall($token, 'control', ['resource' => 'service_application', 'action' => 'stop', 'service_uuid' => $this->own['service']->uuid, 'uuid' => $this->own['serviceApplication']->uuid]); + expect(mcpOpsIsError($noConfirm))->toBeTrue() + ->and(mcpOpsText($noConfirm))->toContain('confirm=true'); + + $noParent = mcpOpsCall($token, 'control', ['resource' => 'service_database', 'action' => 'start', 'uuid' => $this->own['serviceDatabase']->uuid]); + expect(mcpOpsIsError($noParent))->toBeTrue() + ->and(mcpOpsText($noParent))->toContain('service_uuid'); + + Queue::assertNothingPushed(); +}); + +test('control service child rejects read-only and member tokens', function () { + $args = ['resource' => 'service_application', 'action' => 'restart', 'service_uuid' => $this->own['service']->uuid, 'uuid' => $this->own['serviceApplication']->uuid]; + + expect(mcpOpsIsError(mcpOpsCall(mcpOpsToken($this->user, ['read', 'write']), 'control', $args)))->toBeTrue(); + expect(mcpOpsIsError(mcpOpsCall(mcpOpsToken(mcpOpsMember($this->team), ['read', 'deploy']), 'control', $args)))->toBeTrue(); + + Queue::assertNothingPushed(); +}); + +// ---------------------------------------------------------------- deploy by tag + +test('deploy by tag deploys only the token team resources with that tag', function () { + $ownTag = Tag::create(['name' => 'prod', 'team_id' => $this->team->id]); + $this->own['application']->tags()->attach($ownTag->id); + $foreignTag = Tag::create(['name' => 'prod', 'team_id' => $this->otherTeam->id]); + $this->other['application']->tags()->attach($foreignTag->id); + + $response = mcpOpsCall(mcpOpsToken($this->user, ['read', 'deploy']), 'deploy', ['tag' => 'prod', 'confirm' => true]); + + expect(mcpOpsIsError($response))->toBeFalse() + ->and(mcpOpsText($response))->toContain($this->own['application']->uuid) + ->and(ApplicationDeploymentQueue::where('application_id', $this->own['application']->id)->count())->toBe(1) + ->and(ApplicationDeploymentQueue::where('application_id', $this->other['application']->id)->count())->toBe(0); +}); + +test('deploy by a tag that only another team has deploys nothing', function () { + $foreignTag = Tag::create(['name' => 'theirs', 'team_id' => $this->otherTeam->id]); + $this->other['application']->tags()->attach($foreignTag->id); + + $response = mcpOpsCall(mcpOpsToken($this->user, ['read', 'deploy']), 'deploy', ['tag' => 'theirs', 'confirm' => true]); + + expect(mcpOpsIsError($response))->toBeTrue() + ->and(ApplicationDeploymentQueue::count())->toBe(0); +}); + +test('deploy by tag needs confirm and exactly one of uuid or tag', function (array $args, string $message) { + $tag = Tag::create(['name' => 'prod', 'team_id' => $this->team->id]); + $this->own['application']->tags()->attach($tag->id); + $args = array_map(fn ($value) => $value === 'APP' ? $this->own['application']->uuid : $value, $args); + + $response = mcpOpsCall(mcpOpsToken($this->user, ['read', 'deploy']), 'deploy', $args); + + expect(mcpOpsIsError($response))->toBeTrue() + ->and(mcpOpsText($response))->toContain($message) + ->and(ApplicationDeploymentQueue::count())->toBe(0); +})->with([ + 'no confirm' => [['tag' => 'prod'], 'confirm=true'], + 'both' => [['tag' => 'prod', 'uuid' => 'APP', 'confirm' => true], 'exactly one'], + 'neither' => [[], 'exactly one'], + 'tag with pull request' => [['tag' => 'prod', 'pull_request_id' => 3, 'confirm' => true], 'pull_request_id'], +]); + +test('deploy by tag rejects read-only and member tokens', function () { + $tag = Tag::create(['name' => 'prod', 'team_id' => $this->team->id]); + $this->own['application']->tags()->attach($tag->id); + + expect(mcpOpsIsError(mcpOpsCall(mcpOpsToken($this->user, ['read', 'write']), 'deploy', ['tag' => 'prod', 'confirm' => true])))->toBeTrue(); + expect(mcpOpsIsError(mcpOpsCall(mcpOpsToken(mcpOpsMember($this->team), ['read', 'deploy']), 'deploy', ['tag' => 'prod', 'confirm' => true])))->toBeTrue(); + expect(ApplicationDeploymentQueue::count())->toBe(0); +}); + +// ---------------------------------------------------------------- manage_scheduled_task + +test('manage_scheduled_task creates, updates, executes, and deletes tasks of the token team', function (string $resource, string $key) { + $token = mcpOpsToken($this->user); + $owner = $this->own[$key]; + + $created = mcpOpsCall($token, 'manage_scheduled_task', ['action' => 'create', 'resource' => $resource, 'uuid' => $owner->uuid, 'data' => ['name' => 'cleanup', 'command' => 'echo SECRETCMD', 'frequency' => 'daily']]); + expect(mcpOpsIsError($created))->toBeFalse() + ->and(mcpOpsText($created))->not->toContain('SECRETCMD'); + $task = ScheduledTask::where('name', 'cleanup')->sole(); + expect($task->team_id)->toBe($this->team->id) + ->and($task->{$resource.'_id'})->toBe($owner->id); + + $updated = mcpOpsCall($token, 'manage_scheduled_task', ['action' => 'update', 'resource' => $resource, 'uuid' => $owner->uuid, 'task_uuid' => $task->uuid, 'data' => ['frequency' => 'hourly']]); + expect(mcpOpsIsError($updated))->toBeFalse() + ->and($task->fresh()->frequency)->toBe('hourly'); + + $executed = mcpOpsCall($token, 'manage_scheduled_task', ['action' => 'execute', 'resource' => $resource, 'uuid' => $owner->uuid, 'task_uuid' => $task->uuid, 'confirm' => true]); + expect(mcpOpsIsError($executed))->toBeFalse(); + Queue::assertPushed(ScheduledTaskJob::class); + + $deleted = mcpOpsCall($token, 'manage_scheduled_task', ['action' => 'delete', 'resource' => $resource, 'uuid' => $owner->uuid, 'task_uuid' => $task->uuid, 'confirm' => true]); + expect(mcpOpsIsError($deleted))->toBeFalse() + ->and($task->fresh())->toBeNull(); +})->with([ + 'application' => ['application', 'application'], + 'service' => ['service', 'service'], +]); + +test('manage_scheduled_task cannot reach tasks or resources of another team', function (string $action, bool $ownParent) { + $foreignTask = ScheduledTask::factory()->create(['team_id' => $this->otherTeam->id, 'application_id' => $this->other['application']->id, 'name' => 'theirs']); + $parent = $ownParent ? $this->own['application'] : $this->other['application']; + + $response = mcpOpsCall(mcpOpsToken($this->user), 'manage_scheduled_task', [ + 'action' => $action, 'resource' => 'application', 'uuid' => $parent->uuid, 'task_uuid' => $foreignTask->uuid, + 'confirm' => true, 'data' => $action === 'create' ? ['name' => 'evil', 'command' => 'id', 'frequency' => 'daily'] : ($action === 'update' ? ['name' => 'hijacked'] : []), + ]); + + expect(mcpOpsIsError($response))->toBeTrue() + ->and($foreignTask->fresh()?->name)->toBe('theirs') + ->and(ScheduledTask::where('name', 'evil')->exists())->toBeFalse(); + Queue::assertNotPushed(ScheduledTaskJob::class); +})->with([ + 'create on foreign app' => ['create', false], + 'update via own app' => ['update', true], + 'update via foreign app' => ['update', false], + 'delete via own app' => ['delete', true], + 'execute via own app' => ['execute', true], + 'execute via foreign app' => ['execute', false], +]); + +test('manage_scheduled_task needs confirm for delete and execute and rejects read-only tokens', function () { + $task = ScheduledTask::factory()->create(['team_id' => $this->team->id, 'application_id' => $this->own['application']->id]); + $token = mcpOpsToken($this->user); + + foreach (['delete', 'execute'] as $action) { + $response = mcpOpsCall($token, 'manage_scheduled_task', ['action' => $action, 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'task_uuid' => $task->uuid]); + expect(mcpOpsIsError($response))->toBeTrue() + ->and(mcpOpsText($response))->toContain('confirm=true'); + } + + $readOnly = mcpOpsCall(mcpOpsToken($this->user, ['read']), 'manage_scheduled_task', ['action' => 'execute', 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'task_uuid' => $task->uuid, 'confirm' => true]); + expect(mcpOpsIsError($readOnly))->toBeTrue(); + + $member = mcpOpsCall(mcpOpsToken(mcpOpsMember($this->team), ['read']), 'manage_scheduled_task', ['action' => 'delete', 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'task_uuid' => $task->uuid, 'confirm' => true]); + expect(mcpOpsIsError($member))->toBeTrue(); + + expect($task->fresh())->not->toBeNull(); + Queue::assertNotPushed(ScheduledTaskJob::class); +}); + +// ---------------------------------------------------------------- manage_backup + +test('manage_backup creates, updates, runs, and deletes a backup schedule of the token team', function () { + $token = mcpOpsToken($this->user); + $database = $this->own['database']; + + $created = mcpOpsCall($token, 'manage_backup', ['action' => 'create', 'uuid' => $database->uuid, 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->own['s3']->uuid]]); + expect(mcpOpsIsError($created))->toBeFalse() + ->and(mcpOpsText($created))->not->toContain('S3SECRET') + ->and(mcpOpsText($created))->not->toContain('S3KEY'); + $backup = ScheduledDatabaseBackup::where('database_id', $database->id)->sole(); + expect($backup->team_id)->toBe($this->team->id) + ->and($backup->s3_storage_id)->toBe($this->own['s3']->id); + + $updated = mcpOpsCall($token, 'manage_backup', ['action' => 'update', 'uuid' => $database->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'data' => ['frequency' => 'hourly', 'backup_now' => true]]); + expect(mcpOpsIsError($updated))->toBeFalse() + ->and($backup->fresh()->frequency)->toBe('hourly'); + Queue::assertPushed(DatabaseBackupJob::class); + + $execution = ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $backup->id, 'database_name' => 'postgres', 'status' => 'success']); + $deletedExecution = mcpOpsCall($token, 'manage_backup', ['action' => 'delete_execution', 'uuid' => $database->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'execution_uuid' => $execution->uuid, 'confirm' => true]); + expect(mcpOpsIsError($deletedExecution))->toBeFalse() + ->and($execution->fresh())->toBeNull(); + + $deleted = mcpOpsCall($token, 'manage_backup', ['action' => 'delete', 'uuid' => $database->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'confirm' => true]); + expect(mcpOpsIsError($deleted))->toBeFalse() + ->and($backup->fresh())->toBeNull(); +}); + +test('manage_backup rejects an S3 storage of another team', function () { + $token = mcpOpsToken($this->user); + $database = $this->own['database']; + + $created = mcpOpsCall($token, 'manage_backup', ['action' => 'create', 'uuid' => $database->uuid, 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->other['s3']->uuid]]); + expect(mcpOpsIsError($created))->toBeTrue() + ->and(ScheduledDatabaseBackup::where('database_id', $database->id)->exists())->toBeFalse(); + + $backup = ScheduledDatabaseBackup::create(['team_id' => $this->team->id, 'frequency' => 'daily', 'database_id' => $database->id, 'database_type' => $database->getMorphClass()]); + $updated = mcpOpsCall($token, 'manage_backup', ['action' => 'update', 'uuid' => $database->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'data' => ['save_s3' => true, 's3_storage_uuids' => [$this->other['s3']->uuid], 'frequency' => 'hourly']]); + expect(mcpOpsIsError($updated))->toBeTrue() + ->and($backup->fresh()->s3_storage_id)->toBeNull() + ->and($backup->fresh()->frequency)->toBe('daily') + ->and($backup->fresh()->s3Storages()->exists())->toBeFalse(); +}); + +test('manage_backup cannot reach backups of another team', function (string $action, bool $ownParent) { + $foreignDatabase = $this->other['database']; + $foreignBackup = ScheduledDatabaseBackup::create(['team_id' => $this->otherTeam->id, 'frequency' => 'daily', 'database_id' => $foreignDatabase->id, 'database_type' => $foreignDatabase->getMorphClass()]); + $foreignExecution = ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $foreignBackup->id, 'database_name' => 'postgres', 'status' => 'success']); + $parent = $ownParent ? $this->own['database'] : $foreignDatabase; + + $response = mcpOpsCall(mcpOpsToken($this->user), 'manage_backup', [ + 'action' => $action, 'uuid' => $parent->uuid, 'scheduled_backup_uuid' => $foreignBackup->uuid, 'execution_uuid' => $foreignExecution->uuid, + 'confirm' => true, 'data' => $action === 'delete' || $action === 'delete_execution' ? [] : ['frequency' => 'hourly', 'backup_now' => true], + ]); + + expect(mcpOpsIsError($response))->toBeTrue() + ->and($foreignBackup->fresh()?->frequency)->toBe('daily') + ->and($foreignExecution->fresh())->not->toBeNull() + ->and(ScheduledDatabaseBackup::where('team_id', $this->team->id)->exists())->toBeFalse(); + Queue::assertNotPushed(DatabaseBackupJob::class); +})->with([ + 'create on foreign db' => ['create', false], + 'update via own db' => ['update', true], + 'update via foreign db' => ['update', false], + 'delete via own db' => ['delete', true], + 'delete execution via own db' => ['delete_execution', true], + 'delete execution via foreign db' => ['delete_execution', false], +]); + +test('manage_backup needs confirm for deletes and rejects read-only tokens', function () { + $database = $this->own['database']; + $backup = ScheduledDatabaseBackup::create(['team_id' => $this->team->id, 'frequency' => 'daily', 'database_id' => $database->id, 'database_type' => $database->getMorphClass()]); + $execution = ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $backup->id, 'database_name' => 'postgres', 'status' => 'success']); + $token = mcpOpsToken($this->user); + + foreach (['delete', 'delete_execution'] as $action) { + $response = mcpOpsCall($token, 'manage_backup', ['action' => $action, 'uuid' => $database->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'execution_uuid' => $execution->uuid]); + expect(mcpOpsIsError($response))->toBeTrue() + ->and(mcpOpsText($response))->toContain('confirm=true'); + } + + $readOnly = mcpOpsCall(mcpOpsToken($this->user, ['read']), 'manage_backup', ['action' => 'create', 'uuid' => $database->uuid, 'data' => ['frequency' => 'daily']]); + expect(mcpOpsIsError($readOnly))->toBeTrue() + ->and($backup->fresh())->not->toBeNull() + ->and($execution->fresh())->not->toBeNull() + ->and(ScheduledDatabaseBackup::count())->toBe(1); +}); + +// ---------------------------------------------------------------- manage_storage + +test('manage_storage creates, updates, and deletes storages of the token team', function () { + $token = mcpOpsToken($this->user); + $application = $this->own['application']; + + $created = mcpOpsCall($token, 'manage_storage', ['action' => 'create', 'resource' => 'application', 'uuid' => $application->uuid, 'data' => ['type' => 'persistent', 'name' => 'cache', 'mount_path' => '/cache']]); + expect(mcpOpsIsError($created))->toBeFalse(); + $volume = LocalPersistentVolume::where('name', $application->uuid.'-cache')->sole(); + + $updated = mcpOpsCall($token, 'manage_storage', ['action' => 'update', 'resource' => 'application', 'uuid' => $application->uuid, 'data' => ['type' => 'persistent', 'uuid' => $volume->uuid, 'mount_path' => '/cache2']]); + expect(mcpOpsIsError($updated))->toBeFalse() + ->and($volume->fresh()->mount_path)->toBe('/cache2'); + + $dbCreated = mcpOpsCall($token, 'manage_storage', ['action' => 'create', 'resource' => 'database', 'uuid' => $this->own['database']->uuid, 'data' => ['type' => 'persistent', 'name' => 'extra', 'mount_path' => '/extra']]); + expect(mcpOpsIsError($dbCreated))->toBeFalse(); + + $serviceCreated = mcpOpsCall($token, 'manage_storage', ['action' => 'create', 'resource' => 'service', 'uuid' => $this->own['service']->uuid, 'data' => ['type' => 'persistent', 'name' => 'svc', 'mount_path' => '/svc', 'resource_uuid' => $this->own['serviceApplication']->uuid]]); + expect(mcpOpsIsError($serviceCreated))->toBeFalse() + ->and(LocalPersistentVolume::where('resource_id', $this->own['serviceApplication']->id)->where('mount_path', '/svc')->exists())->toBeTrue(); + + $deleted = mcpOpsCall($token, 'manage_storage', ['action' => 'delete', 'resource' => 'application', 'uuid' => $application->uuid, 'storage_uuid' => $volume->uuid, 'confirm' => true]); + expect(mcpOpsIsError($deleted))->toBeFalse() + ->and($volume->fresh())->toBeNull(); +}); + +test('manage_storage sets, runs, and removes a volume backup without returning S3 secrets', function () { + $token = mcpOpsToken($this->user); + $args = ['resource' => 'application', 'uuid' => $this->own['application']->uuid, 'storage_uuid' => $this->own['volume']->uuid]; + + $set = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => 'set_backup', 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->own['s3']->uuid]]); + expect(mcpOpsIsError($set))->toBeFalse() + ->and(mcpOpsText($set))->not->toContain('S3SECRET') + ->and(mcpOpsText($set))->not->toContain('S3KEY'); + $backup = ScheduledVolumeBackup::query()->sole(); + expect($backup->s3_storage_id)->toBe($this->own['s3']->id); + + $run = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => 'run_backup', 'confirm' => true]); + expect(mcpOpsIsError($run))->toBeFalse(); + Queue::assertPushed(VolumeBackupJob::class); + + $removed = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => 'remove_backup', 'confirm' => true]); + expect(mcpOpsIsError($removed))->toBeFalse() + ->and($backup->fresh())->toBeNull(); +}); + +test('manage_storage rejects an S3 storage and a service child of another team', function () { + $token = mcpOpsToken($this->user); + + $set = mcpOpsCall($token, 'manage_storage', ['action' => 'set_backup', 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'storage_uuid' => $this->own['volume']->uuid, 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->other['s3']->uuid]]); + expect(mcpOpsIsError($set))->toBeTrue() + ->and(ScheduledVolumeBackup::query()->exists())->toBeFalse(); + + $create = mcpOpsCall($token, 'manage_storage', ['action' => 'create', 'resource' => 'service', 'uuid' => $this->own['service']->uuid, 'data' => ['type' => 'persistent', 'name' => 'evil', 'mount_path' => '/evil', 'resource_uuid' => $this->other['serviceApplication']->uuid]]); + expect(mcpOpsIsError($create))->toBeTrue() + ->and(LocalPersistentVolume::where('mount_path', '/evil')->exists())->toBeFalse(); +}); + +test('manage_storage cannot reach storages of another team', function (string $action, bool $ownParent) { + $foreignVolume = $this->other['volume']; + $foreignVolume->scheduledBackups()->create(['team_id' => $this->otherTeam->id, 'frequency' => 'daily']); + $parent = $ownParent ? $this->own['application'] : $this->other['application']; + + $response = mcpOpsCall(mcpOpsToken($this->user), 'manage_storage', [ + 'action' => $action, 'resource' => 'application', 'uuid' => $parent->uuid, 'storage_uuid' => $foreignVolume->uuid, 'confirm' => true, + 'data' => match ($action) { + 'create' => ['type' => 'persistent', 'name' => 'evil', 'mount_path' => '/evil'], + 'update' => ['type' => 'persistent', 'uuid' => $foreignVolume->uuid, 'mount_path' => '/hijacked'], + 'set_backup' => ['frequency' => 'hourly'], + default => [], + }, + ]); + + expect(mcpOpsIsError($response))->toBeTrue() + ->and($foreignVolume->fresh()?->mount_path)->toBe('/data') + ->and($foreignVolume->scheduledBackups()->sole()->frequency)->toBe('daily') + ->and(LocalPersistentVolume::where('mount_path', '/evil')->exists())->toBeFalse(); + Queue::assertNotPushed(VolumeBackupJob::class); +})->with([ + 'create on foreign app' => ['create', false], + 'update via own app' => ['update', true], + 'update via foreign app' => ['update', false], + 'delete via own app' => ['delete', true], + 'set backup via own app' => ['set_backup', true], + 'run backup via own app' => ['run_backup', true], + 'run backup via foreign app' => ['run_backup', false], + 'remove backup via own app' => ['remove_backup', true], +]); + +test('manage_storage needs confirm for delete, remove_backup, and run_backup and rejects read-only tokens', function () { + $this->own['volume']->scheduledBackups()->create(['team_id' => $this->team->id, 'frequency' => 'daily']); + $args = ['resource' => 'application', 'uuid' => $this->own['application']->uuid, 'storage_uuid' => $this->own['volume']->uuid]; + $token = mcpOpsToken($this->user); + + foreach (['delete', 'remove_backup', 'run_backup'] as $action) { + $response = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => $action]); + expect(mcpOpsIsError($response))->toBeTrue() + ->and(mcpOpsText($response))->toContain('confirm=true'); + } + + $readOnly = mcpOpsCall(mcpOpsToken($this->user, ['read']), 'manage_storage', [...$args, 'action' => 'delete', 'confirm' => true]); + expect(mcpOpsIsError($readOnly))->toBeTrue() + ->and($this->own['volume']->fresh())->not->toBeNull() + ->and(ScheduledVolumeBackup::count())->toBe(1); + Queue::assertNotPushed(VolumeBackupJob::class); +}); + +// ---------------------------------------------------------------- manage_tags + +test('manage_tags creates, renames, attaches, detaches, and deletes tags of the token team', function () { + $token = mcpOpsToken($this->user); + + $created = mcpOpsCall($token, 'manage_tags', ['action' => 'create', 'data' => ['name' => 'Web']]); + expect(mcpOpsIsError($created))->toBeFalse(); + $tag = Tag::where('team_id', $this->team->id)->where('name', 'web')->sole(); + + $renamed = mcpOpsCall($token, 'manage_tags', ['action' => 'update', 'tag_uuid' => $tag->uuid, 'data' => ['name' => 'frontend']]); + expect(mcpOpsIsError($renamed))->toBeFalse() + ->and($tag->fresh()->name)->toBe('frontend'); + + foreach (['application', 'database', 'service'] as $resource) { + $attached = mcpOpsCall($token, 'manage_tags', ['action' => 'attach', 'resource' => $resource, 'uuid' => $this->own[$resource]->uuid, 'data' => ['tag_name' => 'frontend']]); + expect(mcpOpsIsError($attached))->toBeFalse() + ->and($this->own[$resource]->tags()->whereKey($tag->id)->exists())->toBeTrue(); + } + + $detached = mcpOpsCall($token, 'manage_tags', ['action' => 'detach', 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'tag_uuid' => $tag->uuid]); + expect(mcpOpsIsError($detached))->toBeFalse() + ->and($this->own['application']->tags()->whereKey($tag->id)->exists())->toBeFalse(); + + $deleted = mcpOpsCall($token, 'manage_tags', ['action' => 'delete', 'tag_uuid' => $tag->uuid, 'confirm' => true]); + expect(mcpOpsIsError($deleted))->toBeFalse() + ->and($tag->fresh())->toBeNull(); +}); + +test('manage_tags attach never links a tag of another team', function () { + $foreignTag = Tag::create(['name' => 'shared', 'team_id' => $this->otherTeam->id]); + + $response = mcpOpsCall(mcpOpsToken($this->user), 'manage_tags', ['action' => 'attach', 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'data' => ['tag_name' => 'shared']]); + + expect(mcpOpsIsError($response))->toBeFalse() + ->and(mcpOpsText($response))->not->toContain($foreignTag->uuid) + ->and($this->own['application']->tags()->whereKey($foreignTag->id)->exists())->toBeFalse() + ->and($this->own['application']->tags()->sole()->team_id)->toBe($this->team->id); +}); + +test('manage_tags cannot change tags or resources of another team', function (string $action) { + $foreignTag = Tag::create(['name' => 'theirs', 'team_id' => $this->otherTeam->id]); + $this->other['application']->tags()->attach($foreignTag->id); + $ownTag = Tag::create(['name' => 'mine', 'team_id' => $this->team->id]); + $this->own['application']->tags()->attach($ownTag->id); + + $args = match ($action) { + 'update' => ['tag_uuid' => $foreignTag->uuid, 'data' => ['name' => 'hijacked']], + 'delete' => ['tag_uuid' => $foreignTag->uuid], + 'attach to foreign app' => ['resource' => 'application', 'uuid' => $this->other['application']->uuid, 'data' => ['tag_name' => 'evil']], + 'detach foreign tag from own app' => ['resource' => 'application', 'uuid' => $this->own['application']->uuid, 'tag_uuid' => $foreignTag->uuid], + 'detach from foreign app' => ['resource' => 'application', 'uuid' => $this->other['application']->uuid, 'tag_uuid' => $foreignTag->uuid], + }; + $toolAction = str_starts_with($action, 'attach') ? 'attach' : (str_starts_with($action, 'detach') ? 'detach' : $action); + + $response = mcpOpsCall(mcpOpsToken($this->user), 'manage_tags', ['action' => $toolAction, 'confirm' => true, ...$args]); + + expect(mcpOpsIsError($response))->toBeTrue() + ->and($foreignTag->fresh()?->name)->toBe('theirs') + ->and($this->other['application']->tags()->whereKey($foreignTag->id)->exists())->toBeTrue() + ->and(Tag::where('name', 'evil')->exists())->toBeFalse(); +})->with([ + 'update', + 'delete', + 'attach to foreign app', + 'detach foreign tag from own app', + 'detach from foreign app', +]); + +test('manage_tags delete needs confirm and write actions reject read-only and member tokens', function () { + $tag = Tag::create(['name' => 'mine', 'team_id' => $this->team->id]); + + $noConfirm = mcpOpsCall(mcpOpsToken($this->user), 'manage_tags', ['action' => 'delete', 'tag_uuid' => $tag->uuid]); + expect(mcpOpsIsError($noConfirm))->toBeTrue() + ->and(mcpOpsText($noConfirm))->toContain('confirm=true'); + + $readOnly = mcpOpsCall(mcpOpsToken($this->user, ['read']), 'manage_tags', ['action' => 'create', 'data' => ['name' => 'nope']]); + expect(mcpOpsIsError($readOnly))->toBeTrue(); + + $member = mcpOpsCall(mcpOpsToken(mcpOpsMember($this->team), ['read', 'write']), 'manage_tags', ['action' => 'create', 'data' => ['name' => 'nope']]); + expect(mcpOpsIsError($member))->toBeTrue(); + + expect($tag->fresh())->not->toBeNull() + ->and(Tag::where('name', 'nope')->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Mcp/McpReadToolsTest.php b/tests/Feature/Mcp/McpReadToolsTest.php index a29c5fba95..155941ffd7 100644 --- a/tests/Feature/Mcp/McpReadToolsTest.php +++ b/tests/Feature/Mcp/McpReadToolsTest.php @@ -2,6 +2,8 @@ use App\Jobs\ApplicationDeploymentJob; use App\Mcp\Concerns\BuildsResponse; +use App\Mcp\Servers\CoolifyServer; +use App\Mcp\Tools\CoolifyHelp; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; @@ -145,7 +147,6 @@ test('tools/list includes new read tools and lifecycle tools', function () { 'list_resources', 'list_deployments', 'get_deployment', - 'get_logs', 'list_env_keys', 'list_storages', 'list_destinations', @@ -164,10 +165,8 @@ test('tools/list includes new read tools and lifecycle tools', function () { 'list_application_previews', 'list_shared_env_keys', 'coolify_help', - 'control', - 'deploy', - 'cancel_deployment', ); + expect($names)->not->toContain('control', 'deploy', 'cancel_deployment', 'get_logs'); }); test('database backup tools scope schedules by database type and id', function () { @@ -1621,9 +1620,7 @@ test('get_logs requires sensitive read ability', function () { 'uuid' => $this->application->uuid, ]); - $response->assertOk(); - expect($response->json('result.isError'))->toBeTrue() - ->and($response->json('result.content.0.text'))->toContain('read:sensitive'); + expect($response->json('error.message'))->toBe('Tool [get_logs] not found.'); }); test('team members cannot retrieve logs with sensitive read ability', function () { @@ -1696,11 +1693,48 @@ test('get_logs returns structured choices when service has multiple containers', ->and(json_encode($body['data']['message'] ?? ''))->not->toContain('"uuid"'); }); -test('coolify_help returns catalog intents', function () { - $response = mcpReadCall('coolify_help', ['intent' => 'essentials']); +test('coolify_help lists only the tools this token can see', function () { + $response = mcpReadCall('coolify_help'); $response->assertOk(); $body = mcpReadJson($response); - expect($body['data']['catalog']['essentials']['tools'])->toContain('search_resources', 'control'); + + expect($body['data']['read_tools'])->toHaveKeys(['search_resources', 'list_applications']) + ->and($body['data']['read_tools'])->not->toHaveKey('get_logs') + ->and($body['data']['change_tools'])->not->toHaveKeys(['control', 'manage_project', 'create_application']) + ->and($body['data']['recipes'])->toHaveKey('deploy_app_from_git'); +}); + +test('coolify_help topic returns recipe steps and marks tools the token cannot use', function () { + $body = mcpReadJson(mcpReadCall('coolify_help', ['topic' => 'deploy_app_from_git'])); + $steps = collect($body['data']['steps'])->keyBy('tool'); + + expect($steps['list_projects']['available'])->toBeTrue() + ->and($steps['create_application']['available'])->toBeFalse() + ->and($steps['create_application']['action'])->toBe('public'); +}); + +test('coolify_help search filters tools and rejects unknown topics', function () { + $body = mcpReadJson(mcpReadCall('coolify_help', ['search' => 'deployment'])); + expect($body['data']['read_tools'])->toHaveKeys(['list_deployments', 'get_deployment']) + ->and($body['data']['read_tools'])->not->toHaveKey('list_servers'); + + app('auth')->forgetGuards(); + $unknown = mcpReadCall('coolify_help', ['topic' => 'nope']); + expect($unknown->json('result.isError'))->toBeTrue(); +}); + +test('coolify_help recipes only name registered tools and actions', function () { + $tools = collect(CoolifyServer::TOOLS)->mapWithKeys(fn (string $class) => [app($class)->name() => app($class)]); + + foreach (CoolifyHelp::RECIPES as $recipe) { + foreach ($recipe['steps'] as $step) { + expect($tools)->toHaveKey($step['tool']); + if (isset($step['action'])) { + $actions = (new ReflectionMethod($tools[$step['tool']], 'actions'))->invoke($tools[$step['tool']]); + expect($actions)->toHaveKey($step['action']); + } + } + } }); test('list_unhealthy_resources sample_only returns summary and samples', function () { @@ -1720,12 +1754,11 @@ test('control and deploy require deploy ability', function () { 'action' => 'start', 'uuid' => $this->application->uuid, ]); - $denied->assertOk(); - expect($denied->json('result.isError'))->toBeTrue(); - expect($denied->json('result.content.0.text'))->toContain('Missing required permissions'); + expect($denied->json('error.message'))->toBe('Tool [control] not found.'); + app('auth')->forgetGuards(); $deployDenied = mcpReadCall('deploy', ['uuid' => $this->application->uuid]); - expect($deployDenied->json('result.isError'))->toBeTrue(); + expect($deployDenied->json('error.message'))->toBe('Tool [deploy] not found.'); }); test('control stop requires confirm', function () { diff --git a/tests/Feature/Mcp/McpServerToolsTest.php b/tests/Feature/Mcp/McpServerToolsTest.php new file mode 100644 index 0000000000..ca962a5391 --- /dev/null +++ b/tests/Feature/Mcp/McpServerToolsTest.php @@ -0,0 +1,863 @@ + true, 'cpu' => 42.5, 'memUsed' => 1024, 'memTotal' => 4096, + 'diskUsed' => 10, 'diskTotal' => 100, 'diskPercent' => 10.0, + 'load1' => 0.5, 'netRx' => 5.0, 'netTx' => 1.0, + ]; + } + + public function history(string $metric, string $range): array + { + self::$calls++; + if ($metric === 'disk') { + throw new RuntimeException('Disk endpoint unavailable.'); + } + + return [ + [now()->subHours(3)->getTimestampMs(), 1.0], + [now()->subMinutes(10)->getTimestampMs(), $metric === 'cpu' ? 42.5 : 1024.0], + ]; + } + + public function networkHistory(string $range): array + { + self::$calls++; + + return ['rx' => [[now()->subMinutes(5)->getTimestampMs(), 5.0]], 'tx' => [[now()->subMinutes(5)->getTimestampMs(), 1.0]]]; + } +} + +beforeEach(function () { + Server::flushIdentityMap(); + Queue::fake(); + Process::fake(); + Storage::fake('ssh-keys'); + Storage::fake('ssh-mux'); + Http::preventStrayRequests(); + config()->set('app.maintenance.store', 'array'); + config()->set('cache.default', 'array'); + config()->set('constants.ssh.mux_enabled', false); + + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true, 'is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->otherTeam = Team::factory()->create(); + $this->otherUser = User::factory()->create(); + $this->otherTeam->members()->attach($this->otherUser->id, ['role' => 'owner']); + + $this->key = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->otherKey = PrivateKey::factory()->create(['team_id' => $this->otherTeam->id]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->key->id]); + $this->otherServer = Server::factory()->create(['team_id' => $this->otherTeam->id, 'private_key_id' => $this->otherKey->id, 'name' => 'Theirs']); + + session(['currentTeam' => $this->team]); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +function mcpSrvToken(User $user, array $abilities = ['read', 'write']): string +{ + return $user->createToken('mcp-srv', $abilities)->plainTextToken; +} + +function mcpSrvCall(string $token, string $tool, array $arguments = []) +{ + return test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json, text/event-stream', + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => $tool, 'arguments' => (object) $arguments], + ]); +} + +function mcpSrvText($response): string +{ + return (string) ($response->json('result.content.0.text') ?? $response->json('error.message')); +} + +function mcpSrvIsError($response): bool +{ + return $response->json('result.isError') === true || $response->json('error') !== null; +} + +function mcpSrvData($response): mixed +{ + return json_decode(mcpSrvText($response), true)['data'] ?? null; +} + +function mcpSrvMakeFunctional(Server $server): void +{ + $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + Server::flushIdentityMap(); +} + +function mcpSrvGithubApp(Team $team, PrivateKey $key, array $overrides = []): GithubApp +{ + return GithubApp::create(array_merge([ + 'name' => 'gh-'.$team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 1234, + 'installation_id' => 5678, + 'client_id' => 'client-id', + 'client_secret' => 'GH_CLIENT_SECRET_VALUE', + 'webhook_secret' => 'GH_WEBHOOK_SECRET_VALUE', + 'private_key_id' => $key->id, + 'team_id' => $team->id, + 'is_system_wide' => false, + ], $overrides)); +} + +function mcpSrvS3(Team $team, array $overrides = []): S3Storage +{ + return S3Storage::create(array_merge([ + 'team_id' => $team->id, + 'name' => 'S3 '.$team->id, + 'region' => 'us-east-1', + 'key' => 'S3_ACCESS_KEY_VALUE', + 'secret' => 'S3_SECRET_KEY_VALUE', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.amazonaws.com', + 'is_usable' => false, + ], $overrides)); +} + +// ---------------------------------------------------------------- shared checks + +test('server group write actions reject read-only tokens', function (string $tool, array $arguments) { + $response = mcpSrvCall(mcpSrvToken($this->user, ['read']), $tool, $arguments + ['confirm' => true]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->toMatch('/Missing required permissions: write|Tool \[\w+\] not found/'); +})->with([ + 'manage_server create' => ['manage_server', ['action' => 'create', 'data' => ['ip' => '203.0.113.50']]], + 'manage_proxy restart' => ['manage_proxy', ['action' => 'restart', 'uuid' => 'x']], + 'docker_cleanup run' => ['docker_cleanup', ['action' => 'run', 'uuid' => 'x']], + 'manage_destination create' => ['manage_destination', ['action' => 'create', 'server_uuid' => 'x']], + 'manage_registry create' => ['manage_registry', ['action' => 'create', 'uuid' => 'x']], + 'manage_private_key create' => ['manage_private_key', ['action' => 'create']], + 'manage_s3_storage create' => ['manage_s3_storage', ['action' => 'create']], + 'manage_git_source create' => ['manage_git_source', ['action' => 'create', 'resource' => 'github']], + 'cloud_provider provision_server' => ['cloud_provider', ['action' => 'provision_server', 'resource' => 'hetzner']], + 'manage_notifications update' => ['manage_notifications', ['action' => 'update', 'resource' => 'discord']], +]); + +test('server group write actions reject member tokens', function (string $tool, array $arguments) { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + + $response = mcpSrvCall(mcpSrvToken($member, ['read', 'write']), $tool, $arguments + ['confirm' => true]); + + expect($response->json('result'))->toBeNull() + ->and($response->json('error.message'))->toBe('Missing required team role.'); +})->with([ + 'manage_server update' => ['manage_server', ['action' => 'update', 'data' => ['name' => 'x']]], + 'manage_private_key create' => ['manage_private_key', ['action' => 'create']], + 'manage_registry list' => ['manage_registry', ['action' => 'list']], + 'cloud_provider create_token' => ['cloud_provider', ['action' => 'create_token']], + 'manage_notifications update' => ['manage_notifications', ['action' => 'update', 'resource' => 'email']], +]); + +test('server group destructive actions need confirm=true', function (string $tool, array $arguments) { + $response = mcpSrvCall(mcpSrvToken($this->user), $tool, $arguments); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->toContain('requires confirm=true'); + Queue::assertNothingPushed(); +})->with([ + 'manage_server delete' => ['manage_server', ['action' => 'delete', 'uuid' => 'x']], + 'manage_proxy update' => ['manage_proxy', ['action' => 'update', 'uuid' => 'x']], + 'manage_proxy set_configuration' => ['manage_proxy', ['action' => 'set_configuration', 'uuid' => 'x']], + 'manage_proxy restart' => ['manage_proxy', ['action' => 'restart', 'uuid' => 'x']], + 'docker_cleanup run' => ['docker_cleanup', ['action' => 'run', 'uuid' => 'x']], + 'manage_destination delete' => ['manage_destination', ['action' => 'delete', 'uuid' => 'x']], + 'manage_registry delete' => ['manage_registry', ['action' => 'delete', 'uuid' => 'x', 'registry' => 'ghcr.io']], + 'manage_private_key delete' => ['manage_private_key', ['action' => 'delete', 'uuid' => 'x']], + 'manage_s3_storage delete' => ['manage_s3_storage', ['action' => 'delete', 'uuid' => 'x']], + 'manage_git_source delete' => ['manage_git_source', ['action' => 'delete', 'resource' => 'github', 'source_id' => '1']], + 'cloud_provider delete_token' => ['cloud_provider', ['action' => 'delete_token', 'uuid' => 'x']], + 'cloud_provider provision_server' => ['cloud_provider', ['action' => 'provision_server', 'resource' => 'hetzner']], +]); + +// ---------------------------------------------------------------- manage_server + +test('manage_server creates a server in the token team with a team private key', function () { + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_server', ['action' => 'create', 'data' => [ + 'name' => 'mcp-server', 'ip' => '203.0.113.50', 'private_key_uuid' => $this->key->uuid, + ]]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)); + $server = Server::where('name', 'mcp-server')->sole(); + expect($server->team_id)->toBe($this->team->id) + ->and($server->private_key_id)->toBe($this->key->id); +}); + +test('manage_server rejects a private key of another team', function () { + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_server', ['action' => 'create', 'data' => [ + 'name' => 'stolen-key', 'ip' => '203.0.113.51', 'private_key_uuid' => $this->otherKey->uuid, + ]]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(Server::where('name', 'stolen-key')->exists())->toBeFalse(); + + $update = mcpSrvCall(mcpSrvToken($this->user), 'manage_server', ['action' => 'update', 'uuid' => $this->server->uuid, 'data' => ['private_key_uuid' => $this->otherKey->uuid]]); + expect(mcpSrvIsError($update))->toBeTrue() + ->and($this->server->fresh()->private_key_id)->toBe($this->key->id); +}); + +test('manage_server cannot change, validate, or delete a server of another team', function (string $action, array $data) { + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_server', ['action' => $action, 'uuid' => $this->otherServer->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpSrvIsError($response))->toBeTrue(); + $fresh = Server::find($this->otherServer->id); + expect($fresh)->not->toBeNull()->and($fresh->name)->toBe('Theirs'); + Queue::assertNothingPushed(); +})->with([ + 'update' => ['update', ['name' => 'Hijacked']], + 'validate' => ['validate', []], + 'delete' => ['delete', []], +]); + +test('manage_server updates, validates, and deletes its own server', function () { + $token = mcpSrvToken($this->user); + + $update = mcpSrvCall($token, 'manage_server', ['action' => 'update', 'uuid' => $this->server->uuid, 'data' => ['name' => 'renamed']]); + expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update)) + ->and($this->server->fresh()->name)->toBe('renamed'); + + $validate = mcpSrvCall($token, 'manage_server', ['action' => 'validate', 'uuid' => $this->server->uuid]); + expect(mcpSrvIsError($validate))->toBeFalse(mcpSrvText($validate)); + + $delete = mcpSrvCall($token, 'manage_server', ['action' => 'delete', 'uuid' => $this->server->uuid, 'confirm' => true]); + expect(mcpSrvIsError($delete))->toBeFalse(mcpSrvText($delete)) + ->and(Server::find($this->server->id))->toBeNull(); +}); + +// ---------------------------------------------------------------- manage_proxy + +test('manage_proxy reads and updates the proxy of its own server and queues a restart', function () { + $token = mcpSrvToken($this->user); + + $get = mcpSrvCall($token, 'manage_proxy', ['action' => 'get', 'uuid' => $this->server->uuid]); + expect(mcpSrvIsError($get))->toBeFalse(mcpSrvText($get)) + ->and(mcpSrvData($get))->toHaveKey('proxy_type'); + + $update = mcpSrvCall($token, 'manage_proxy', ['action' => 'update', 'uuid' => $this->server->uuid, 'confirm' => true, 'data' => ['generate_exact_labels' => true]]); + expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update)) + ->and((bool) $this->server->settings()->first()->generate_exact_labels)->toBeTrue(); + + $restart = mcpSrvCall($token, 'manage_proxy', ['action' => 'restart', 'uuid' => $this->server->uuid, 'confirm' => true]); + expect(mcpSrvIsError($restart))->toBeFalse(mcpSrvText($restart)); + Queue::assertPushed(RestartProxyJob::class); +}); + +test('manage_proxy cannot read or change the proxy of another team', function (string $action, array $data) { + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_proxy', ['action' => $action, 'uuid' => $this->otherServer->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and((bool) $this->otherServer->settings()->first()->generate_exact_labels)->toBeFalse(); + Queue::assertNothingPushed(); +})->with([ + 'get' => ['get', []], + 'update' => ['update', ['generate_exact_labels' => true]], + 'set_configuration' => ['set_configuration', ['configuration' => "services:\n traefik:\n image: evil\n"]], + 'restart' => ['restart', []], +]); + +// ---------------------------------------------------------------- docker_cleanup + +test('docker_cleanup reads, updates, lists, and runs cleanup on its own server', function () { + $token = mcpSrvToken($this->user); + + $get = mcpSrvCall($token, 'docker_cleanup', ['action' => 'get', 'uuid' => $this->server->uuid]); + expect(mcpSrvIsError($get))->toBeFalse(mcpSrvText($get)) + ->and(mcpSrvData($get))->toHaveKey('docker_cleanup_threshold'); + + $update = mcpSrvCall($token, 'docker_cleanup', ['action' => 'update', 'uuid' => $this->server->uuid, 'data' => ['docker_cleanup_threshold' => 55]]); + expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update)) + ->and((int) $this->server->settings()->first()->docker_cleanup_threshold)->toBe(55); + + $executions = mcpSrvCall($token, 'docker_cleanup', ['action' => 'executions', 'uuid' => $this->server->uuid]); + expect(mcpSrvIsError($executions))->toBeFalse(mcpSrvText($executions)); + + $run = mcpSrvCall($token, 'docker_cleanup', ['action' => 'run', 'uuid' => $this->server->uuid, 'confirm' => true]); + expect(mcpSrvIsError($run))->toBeFalse(mcpSrvText($run)); + Queue::assertPushed(DockerCleanupJob::class); +}); + +test('docker_cleanup cannot touch a server of another team', function (string $action, array $data) { + $before = (int) $this->otherServer->settings()->first()->docker_cleanup_threshold; + + $response = mcpSrvCall(mcpSrvToken($this->user), 'docker_cleanup', ['action' => $action, 'uuid' => $this->otherServer->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and((int) $this->otherServer->settings()->first()->docker_cleanup_threshold)->toBe($before); + Queue::assertNothingPushed(); +})->with([ + 'get' => ['get', []], + 'update' => ['update', ['docker_cleanup_threshold' => 12]], + 'executions' => ['executions', []], + 'run' => ['run', []], +]); + +// ---------------------------------------------------------------- manage_destination + +test('manage_destination creates, renames, and deletes a destination on its own server', function () { + $cleanup = Mockery::mock(RemoveStandaloneDockerNetwork::class); + $cleanup->shouldReceive('handle')->once(); + $this->app->instance(RemoveStandaloneDockerNetwork::class, $cleanup); + $token = mcpSrvToken($this->user); + + $create = mcpSrvCall($token, 'manage_destination', ['action' => 'create', 'server_uuid' => $this->server->uuid, 'data' => ['network' => 'mcp-net', 'name' => 'mcp-dest']]); + expect(mcpSrvIsError($create))->toBeFalse(mcpSrvText($create)); + $destination = StandaloneDocker::where('network', 'mcp-net')->sole(); + expect($destination->server_id)->toBe($this->server->id); + + $update = mcpSrvCall($token, 'manage_destination', ['action' => 'update', 'uuid' => $destination->uuid, 'data' => ['name' => 'renamed-dest']]); + expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update)) + ->and($destination->fresh()->name)->toBe('renamed-dest'); + + $delete = mcpSrvCall($token, 'manage_destination', ['action' => 'delete', 'uuid' => $destination->uuid, 'confirm' => true]); + expect(mcpSrvIsError($delete))->toBeFalse(mcpSrvText($delete)) + ->and($destination->fresh())->toBeNull(); +}); + +test('manage_destination cannot create a destination on a server of another team', function () { + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_destination', ['action' => 'create', 'server_uuid' => $this->otherServer->uuid, 'data' => ['network' => 'evil-net']]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(StandaloneDocker::where('network', 'evil-net')->exists())->toBeFalse(); +}); + +test('manage_destination cannot rename or delete a destination of another team', function (string $action, array $data) { + $cleanup = Mockery::mock(RemoveStandaloneDockerNetwork::class); + $cleanup->shouldNotReceive('handle'); + $this->app->instance(RemoveStandaloneDockerNetwork::class, $cleanup); + $foreign = StandaloneDocker::where('server_id', $this->otherServer->id)->firstOrFail(); + $name = $foreign->name; + + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_destination', ['action' => $action, 'uuid' => $foreign->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and($foreign->fresh())->not->toBeNull() + ->and($foreign->fresh()->name)->toBe($name); +})->with([ + 'update' => ['update', ['name' => 'Hijacked']], + 'delete' => ['delete', []], +]); + +// ---------------------------------------------------------------- manage_registry + +test('manage_registry lists registry logins without passwords', function () { + mcpSrvMakeFunctional($this->server); + Process::fake(fn () => Process::result(output: json_encode([ + 'auths' => ['ghcr.io' => ['auth' => base64_encode('octocat:REGISTRY_PASSWORD_VALUE')]], + ]))); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'manage_registry', ['action' => 'list', 'uuid' => $this->server->uuid]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(mcpSrvText($response))->toContain('ghcr.io') + ->and(mcpSrvText($response))->not->toContain('REGISTRY_PASSWORD_VALUE') + ->and(mcpSrvText($response))->not->toContain(base64_encode('octocat:REGISTRY_PASSWORD_VALUE')); +}); + +test('manage_registry list needs read:sensitive', function () { + $response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write']), 'manage_registry', ['action' => 'list', 'uuid' => $this->server->uuid]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->toContain('read:sensitive'); +}); + +test('manage_registry logs in on its own server without echoing the password', function () { + mcpSrvMakeFunctional($this->server); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_registry', ['action' => 'create', 'uuid' => $this->server->uuid, 'data' => [ + 'registry' => 'ghcr.io', 'username' => 'octocat', 'password' => 'REGISTRY_PASSWORD_VALUE', + ]]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(mcpSrvText($response))->not->toContain('REGISTRY_PASSWORD_VALUE'); + Process::assertRan(fn ($process) => str_contains(is_array($process->command) ? implode(' ', $process->command) : $process->command, 'docker')); +}); + +test('manage_registry cannot touch registries on a server of another team', function (string $action, string $ability, array $extra) { + mcpSrvMakeFunctional($this->otherServer); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['read', $ability]), 'manage_registry', ['action' => $action, 'uuid' => $this->otherServer->uuid, 'confirm' => true] + $extra); + + expect(mcpSrvIsError($response))->toBeTrue(); + Process::assertNothingRan(); +})->with([ + 'list' => ['list', 'read:sensitive', []], + 'create' => ['create', 'write', ['data' => ['registry' => 'ghcr.io', 'username' => 'evil', 'password' => 'x']]], + 'check' => ['check', 'write', ['registry' => 'ghcr.io']], + 'delete' => ['delete', 'write', ['registry' => 'ghcr.io']], +]); + +// ---------------------------------------------------------------- manage_private_key + +test('manage_private_key never returns key material, even to root and read:sensitive tokens', function (array $abilities) { + $token = mcpSrvToken($this->user, $abilities); + $keyBody = 'QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi'; + + $list = mcpSrvCall($token, 'manage_private_key', ['action' => 'list']); + expect(mcpSrvIsError($list))->toBeFalse(mcpSrvText($list)) + ->and(mcpSrvText($list))->toContain($this->key->uuid) + ->and(mcpSrvText($list))->not->toContain('PRIVATE KEY') + ->and(mcpSrvText($list))->not->toContain($keyBody) + ->and(mcpSrvText($list))->not->toContain($this->otherKey->uuid); + + $get = mcpSrvCall($token, 'manage_private_key', ['action' => 'get', 'uuid' => $this->key->uuid]); + expect(mcpSrvIsError($get))->toBeFalse(mcpSrvText($get)) + ->and(mcpSrvText($get))->not->toContain('PRIVATE KEY') + ->and(mcpSrvText($get))->not->toContain($keyBody); +})->with([ + 'root' => [['root']], + 'read:sensitive' => [['read', 'read:sensitive']], +]); + +test('manage_private_key create does not return or audit the key material', function () { + $response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'manage_private_key', ['action' => 'create', 'data' => [ + 'name' => 'mcp-key', 'private_key' => MCP_SRV_NEW_PRIVATE_KEY, + ]]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(mcpSrvText($response))->not->toContain('PRIVATE KEY') + ->and(mcpSrvText($response))->not->toContain('AAAECKtKd242pj0qdKWaOpXAQFI2ZGLQOg9vCP2'); + expect(PrivateKey::where('name', 'mcp-key')->sole()->team_id)->toBe($this->team->id); + + $audit = AuditEvent::query()->get()->toJson(); + expect($audit)->not->toContain('PRIVATE KEY') + ->and($audit)->not->toContain('AAAECKtKd242pj0qdKWaOpXAQFI2ZGLQOg9vCP2'); +}); + +test('manage_private_key cannot read, change, or delete a key of another team', function (string $action, array $data) { + $original = $this->otherKey->fresh()->name; + + $response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'manage_private_key', ['action' => $action, 'uuid' => $this->otherKey->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(PrivateKey::find($this->otherKey->id))->not->toBeNull() + ->and(PrivateKey::find($this->otherKey->id)->name)->toBe($original); +})->with([ + 'get' => ['get', []], + 'update' => ['update', ['name' => 'Hijacked', 'private_key' => MCP_SRV_NEW_PRIVATE_KEY]], + 'delete' => ['delete', []], +]); + +test('manage_private_key deletes an unused key of its own team', function () { + $unused = PrivateKey::create(['team_id' => $this->team->id, 'name' => 'unused', 'private_key' => MCP_SRV_NEW_PRIVATE_KEY]); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_private_key', ['action' => 'delete', 'uuid' => $unused->uuid, 'confirm' => true]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(PrivateKey::find($unused->id))->toBeNull(); +}); + +// ---------------------------------------------------------------- manage_s3_storage + +test('manage_s3_storage creates a storage and never returns the secret', function () { + $token = mcpSrvToken($this->user, ['root']); + + $create = mcpSrvCall($token, 'manage_s3_storage', ['action' => 'create', 'data' => [ + 'name' => 'mcp-s3', 'endpoint' => 'https://s3.amazonaws.com', 'bucket' => 'mcp-bucket', 'region' => 'us-east-1', + 'key' => 'S3_ACCESS_KEY_VALUE', 'secret' => 'S3_SECRET_KEY_VALUE', + ]]); + expect(mcpSrvIsError($create))->toBeFalse(mcpSrvText($create)) + ->and(mcpSrvText($create))->not->toContain('S3_SECRET_KEY_VALUE'); + $storage = S3Storage::where('name', 'mcp-s3')->sole(); + expect($storage->team_id)->toBe($this->team->id); + + foreach ([['action' => 'list'], ['action' => 'get', 'uuid' => $storage->uuid]] as $arguments) { + $read = mcpSrvCall($token, 'manage_s3_storage', $arguments); + expect(mcpSrvIsError($read))->toBeFalse(mcpSrvText($read)) + ->and(mcpSrvText($read))->toContain($storage->uuid) + ->and(mcpSrvText($read))->not->toContain('S3_SECRET_KEY_VALUE'); + } +}); + +test('manage_s3_storage cannot read, change, test, or delete a storage of another team', function (string $action, array $data) { + $foreign = mcpSrvS3($this->otherTeam, ['name' => 'Theirs']); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'manage_s3_storage', ['action' => $action, 'uuid' => $foreign->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->not->toContain('S3_SECRET_KEY_VALUE') + ->and($foreign->fresh())->not->toBeNull() + ->and($foreign->fresh()->name)->toBe('Theirs'); +})->with([ + 'get' => ['get', []], + 'update' => ['update', ['name' => 'Hijacked']], + 'validate' => ['validate', []], + 'delete' => ['delete', []], +]); + +test('manage_s3_storage list does not include storages of another team', function () { + $foreign = mcpSrvS3($this->otherTeam); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_s3_storage', ['action' => 'list']); + + expect(mcpSrvIsError($response))->toBeFalse() + ->and(mcpSrvText($response))->not->toContain($foreign->uuid); +}); + +// ---------------------------------------------------------------- manage_git_source + +test('manage_git_source lists apps without client or webhook secrets', function () { + $own = mcpSrvGithubApp($this->team, $this->key); + $shared = mcpSrvGithubApp($this->otherTeam, $this->otherKey, ['name' => 'shared', 'is_system_wide' => true]); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'manage_git_source', ['action' => 'list', 'resource' => 'github']); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(mcpSrvText($response))->toContain($own->uuid) + ->and(mcpSrvText($response))->not->toContain('GH_CLIENT_SECRET_VALUE') + ->and(mcpSrvText($response))->not->toContain('GH_WEBHOOK_SECRET_VALUE') + ->and(mcpSrvText($response))->not->toContain('PRIVATE KEY'); +}); + +test('manage_git_source rejects a GitHub app with a private key of another team', function () { + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_git_source', ['action' => 'create', 'resource' => 'github', 'data' => [ + 'name' => 'stolen', 'html_url' => 'https://github.com', 'app_id' => 1, 'installation_id' => 2, + 'client_id' => 'id', 'client_secret' => 'secret', 'webhook_secret' => 'hook', 'private_key_uuid' => $this->otherKey->uuid, + ]]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(GithubApp::where('name', 'stolen')->exists())->toBeFalse(); + + $own = mcpSrvGithubApp($this->team, $this->key); + $update = mcpSrvCall(mcpSrvToken($this->user), 'manage_git_source', ['action' => 'update', 'resource' => 'github', 'source_id' => (string) $own->id, 'data' => ['private_key_uuid' => $this->otherKey->uuid]]); + expect(mcpSrvIsError($update))->toBeTrue() + ->and($own->fresh()->private_key_id)->toBe($this->key->id); +}); + +test('manage_git_source cannot change or delete GitHub apps of another team, including system-wide ones', function (bool $systemWide, string $action) { + $foreign = mcpSrvGithubApp($this->otherTeam, $this->otherKey, ['name' => 'Theirs', 'is_system_wide' => $systemWide]); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_git_source', ['action' => $action, 'resource' => 'github', 'source_id' => (string) $foreign->id, 'confirm' => true, 'data' => ['name' => 'Hijacked']]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and($foreign->fresh())->not->toBeNull() + ->and($foreign->fresh()->name)->toBe('Theirs'); +})->with([ + 'team app' => false, + 'system-wide app' => true, +])->with(['update', 'delete']); + +test('manage_git_source creates, updates, and deletes a GitLab app without returning secrets', function () { + $token = mcpSrvToken($this->user, ['read', 'write']); + + $create = mcpSrvCall($token, 'manage_git_source', ['action' => 'create', 'resource' => 'gitlab', 'data' => [ + 'name' => 'mcp-gitlab', 'html_url' => 'https://gitlab.com', 'client_secret' => 'GL_CLIENT_SECRET_VALUE', 'webhook_token' => 'GL_WEBHOOK_TOKEN_VALUE', + ]]); + expect(mcpSrvIsError($create))->toBeFalse(mcpSrvText($create)) + ->and(mcpSrvText($create))->not->toContain('GL_CLIENT_SECRET_VALUE') + ->and(mcpSrvText($create))->not->toContain('GL_WEBHOOK_TOKEN_VALUE'); + $app = GitlabApp::where('name', 'mcp-gitlab')->sole(); + expect($app->team_id)->toBe($this->team->id); + + $list = mcpSrvCall($token, 'manage_git_source', ['action' => 'list', 'resource' => 'gitlab']); + expect(mcpSrvIsError($list))->toBeFalse() + ->and(mcpSrvText($list))->not->toContain('GL_CLIENT_SECRET_VALUE') + ->and(mcpSrvText($list))->not->toContain('GL_WEBHOOK_TOKEN_VALUE'); + $sourceId = collect(mcpSrvData($list))->firstWhere('name', 'mcp-gitlab')['id']; + expect($sourceId)->not->toBeNull(); + + $update = mcpSrvCall($token, 'manage_git_source', ['action' => 'update', 'resource' => 'gitlab', 'source_id' => (string) $sourceId, 'data' => ['name' => 'renamed-gitlab']]); + expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update)) + ->and(mcpSrvText($update))->not->toContain('GL_CLIENT_SECRET_VALUE') + ->and(mcpSrvText($update))->not->toContain('GL_WEBHOOK_TOKEN_VALUE') + ->and($app->fresh()->name)->toBe('renamed-gitlab'); + + $delete = mcpSrvCall($token, 'manage_git_source', ['action' => 'delete', 'resource' => 'gitlab', 'source_id' => (string) $sourceId, 'confirm' => true]); + expect(mcpSrvIsError($delete))->toBeFalse(mcpSrvText($delete)) + ->and(GitlabApp::find($app->id))->toBeNull(); +}); + +test('manage_git_source cannot change or delete a GitLab app of another team', function (string $action) { + $foreign = GitlabApp::create(['name' => 'Theirs', 'html_url' => 'https://gitlab.com', 'api_url' => 'https://gitlab.com/api/v4', 'team_id' => $this->otherTeam->id]); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_git_source', ['action' => $action, 'resource' => 'gitlab', 'source_id' => (string) $foreign->id, 'confirm' => true, 'data' => ['name' => 'Hijacked']]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and($foreign->fresh())->not->toBeNull() + ->and($foreign->fresh()->name)->toBe('Theirs'); +})->with(['update', 'delete']); + +test('manage_git_source requires a known resource', function () { + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_git_source', ['action' => 'list', 'resource' => 'bitbucket']); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->toContain('resource must be one of: github, gitlab'); +}); + +// ---------------------------------------------------------------- cloud_provider + +test('cloud_provider adds and lists tokens without returning the token value', function () { + Http::fake(['https://api.hetzner.cloud/v1/servers' => Http::response([], 200)]); + $token = mcpSrvToken($this->user, ['root']); + + $create = mcpSrvCall($token, 'cloud_provider', ['action' => 'create_token', 'data' => ['provider' => 'hetzner', 'token' => 'HETZNER_TOKEN_VALUE', 'name' => 'mcp-hetzner']]); + expect(mcpSrvIsError($create))->toBeFalse(mcpSrvText($create)) + ->and(mcpSrvText($create))->not->toContain('HETZNER_TOKEN_VALUE'); + $stored = CloudProviderToken::where('name', 'mcp-hetzner')->sole(); + expect($stored->team_id)->toBe($this->team->id); + + foreach ([['action' => 'list_tokens'], ['action' => 'get_token', 'uuid' => $stored->uuid]] as $arguments) { + $read = mcpSrvCall($token, 'cloud_provider', $arguments); + expect(mcpSrvIsError($read))->toBeFalse(mcpSrvText($read)) + ->and(mcpSrvText($read))->toContain($stored->uuid) + ->and(mcpSrvText($read))->not->toContain('HETZNER_TOKEN_VALUE'); + } +}); + +test('cloud_provider cannot read, change, validate, or delete a token of another team', function (string $action, array $data) { + Http::fake(); + $foreign = CloudProviderToken::create(['team_id' => $this->otherTeam->id, 'provider' => 'hetzner', 'token' => 'FOREIGN_TOKEN_VALUE', 'name' => 'Theirs']); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'cloud_provider', ['action' => $action, 'uuid' => $foreign->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->not->toContain('FOREIGN_TOKEN_VALUE') + ->and($foreign->fresh())->not->toBeNull() + ->and($foreign->fresh()->name)->toBe('Theirs'); + Http::assertNothingSent(); +})->with([ + 'get_token' => ['get_token', []], + 'update_token' => ['update_token', ['name' => 'Hijacked']], + 'validate_token' => ['validate_token', []], + 'delete_token' => ['delete_token', []], +]); + +test('cloud_provider reads a catalog with a token of its own team', function () { + Http::fake(['https://api.hetzner.cloud/v1/locations*' => Http::response([ + 'locations' => [['id' => 1, 'name' => 'nbg1', 'description' => 'Nuremberg', 'country' => 'DE', 'city' => 'Nuremberg']], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200)]); + $cloudToken = CloudProviderToken::create(['team_id' => $this->team->id, 'provider' => 'hetzner', 'token' => 'OWN_TOKEN_VALUE', 'name' => 'Mine']); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'cloud_provider', ['action' => 'catalog', 'resource' => 'hetzner_locations', 'data' => ['cloud_provider_token_uuid' => $cloudToken->uuid]]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(mcpSrvText($response))->toContain('nbg1') + ->and(mcpSrvText($response))->not->toContain('OWN_TOKEN_VALUE'); +}); + +test('cloud_provider cannot use a token of another team for catalogs or provisioning', function (string $action, string $resource, array $data) { + Http::fake(); + $foreign = CloudProviderToken::create(['team_id' => $this->otherTeam->id, 'provider' => 'hetzner', 'token' => 'FOREIGN_TOKEN_VALUE', 'name' => 'Theirs']); + $serverCount = Server::count(); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'cloud_provider', ['action' => $action, 'resource' => $resource, 'confirm' => true, 'data' => ['cloud_provider_token_uuid' => $foreign->uuid] + $data]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(Server::count())->toBe($serverCount); + Http::assertNothingSent(); +})->with([ + 'catalog' => ['catalog', 'hetzner_server_types', []], + 'provision' => ['provision_server', 'hetzner', ['location' => 'nbg1', 'server_type' => 'cx22', 'image' => 1, 'name' => 'evil']], +]); + +test('cloud_provider provisioning rejects a private key of another team', function () { + Http::fake(); + $cloudToken = CloudProviderToken::create(['team_id' => $this->team->id, 'provider' => 'hetzner', 'token' => 'OWN_TOKEN_VALUE', 'name' => 'Mine']); + $serverCount = Server::count(); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'cloud_provider', ['action' => 'provision_server', 'resource' => 'hetzner', 'confirm' => true, 'data' => [ + 'cloud_provider_token_uuid' => $cloudToken->uuid, 'private_key_uuid' => $this->otherKey->uuid, + 'location' => 'nbg1', 'server_type' => 'cx22', 'image' => 1, 'name' => 'evil', + ]]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(Server::count())->toBe($serverCount); + Http::assertNothingSent(); +}); + +// ---------------------------------------------------------------- manage_notifications + +test('manage_notifications updates a channel of the token team and never returns webhook URLs or tokens', function () { + $token = mcpSrvToken($this->user, ['root']); + $otherSettings = DiscordNotificationSettings::query()->firstOrCreate(['team_id' => $this->otherTeam->id]); + + $update = mcpSrvCall($token, 'manage_notifications', ['action' => 'update', 'resource' => 'discord', 'data' => [ + 'discord_enabled' => true, 'discord_webhook_url' => 'https://discord.com/api/webhooks/123/DISCORD_WEBHOOK_SECRET', + ]]); + expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update)) + ->and(mcpSrvText($update))->not->toContain('DISCORD_WEBHOOK_SECRET'); + + expect(DiscordNotificationSettings::where('team_id', $this->team->id)->sole()->discord_webhook_url)->toContain('DISCORD_WEBHOOK_SECRET') + ->and($otherSettings->fresh()->discord_webhook_url)->toBeNull(); + + $get = mcpSrvCall($token, 'manage_notifications', ['action' => 'get', 'resource' => 'discord']); + expect(mcpSrvIsError($get))->toBeFalse(mcpSrvText($get)) + ->and(mcpSrvText($get))->toContain('discord_enabled') + ->and(mcpSrvText($get))->not->toContain('DISCORD_WEBHOOK_SECRET'); +}); + +test('manage_notifications never returns channel secrets', function (string $channel, array $data, string $secret) { + $token = mcpSrvToken($this->user, ['root']); + + $update = mcpSrvCall($token, 'manage_notifications', ['action' => 'update', 'resource' => $channel, 'data' => $data]); + expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update)) + ->and(mcpSrvText($update))->not->toContain($secret); + + $get = mcpSrvCall($token, 'manage_notifications', ['action' => 'get', 'resource' => $channel]); + expect(mcpSrvIsError($get))->toBeFalse(mcpSrvText($get)) + ->and(mcpSrvText($get))->not->toContain($secret); +})->with([ + 'email smtp password' => ['email', ['smtp_password' => 'SMTP_PASSWORD_VALUE', 'resend_api_key' => 'RESEND_KEY_VALUE'], 'SMTP_PASSWORD_VALUE'], + 'email resend key' => ['email', ['resend_api_key' => 'RESEND_KEY_VALUE'], 'RESEND_KEY_VALUE'], + 'slack webhook' => ['slack', ['slack_webhook_url' => 'https://hooks.slack.com/services/SLACK_SECRET_PATH'], 'SLACK_SECRET_PATH'], + 'telegram token' => ['telegram', ['telegram_token' => 'TELEGRAM_TOKEN_VALUE', 'telegram_chat_id' => '123'], 'TELEGRAM_TOKEN_VALUE'], + 'pushover keys' => ['pushover', ['pushover_user_key' => 'PUSHOVER_USER_VALUE', 'pushover_api_token' => 'PUSHOVER_TOKEN_VALUE'], 'PUSHOVER_'], + 'webhook url' => ['webhook', ['webhook_url' => 'https://example.com/hook/WEBHOOK_SECRET_PATH'], 'WEBHOOK_SECRET_PATH'], +]); + +test('manage_notifications only changes the token team', function () { + TelegramNotificationSettings::query()->firstOrCreate(['team_id' => $this->otherTeam->id]); + $sessionSwitch = Team::factory()->create(); + $sessionSwitch->members()->attach($this->user->id, ['role' => 'owner']); + $token = mcpSrvToken($this->user); + session(['currentTeam' => $sessionSwitch]); + + $response = mcpSrvCall($token, 'manage_notifications', ['action' => 'update', 'resource' => 'telegram', 'data' => ['telegram_chat_id' => '999']]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(TelegramNotificationSettings::where('team_id', $this->team->id)->sole()->telegram_chat_id)->toBe('999') + ->and(TelegramNotificationSettings::where('team_id', $this->otherTeam->id)->sole()->telegram_chat_id)->toBeNull() + ->and(TelegramNotificationSettings::where('team_id', $sessionSwitch->id)->first()?->telegram_chat_id)->toBeNull(); +}); + +// ---------------------------------------------------------------- list_audit_events + +test('list_audit_events returns only events of the token team', function () { + AuditEvent::factory()->create(['team_id' => $this->team->id, 'description' => 'own-team-event']); + AuditEvent::factory()->create(['team_id' => $this->otherTeam->id, 'description' => 'other-team-event']); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['read']), 'list_audit_events', ['action' => 'list', 'data' => ['per_page' => 50]]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(mcpSrvText($response))->toContain('own-team-event') + ->and(mcpSrvText($response))->not->toContain('other-team-event'); +}); + +test('list_audit_events is denied for team members', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + AuditEvent::factory()->create(['team_id' => $this->team->id, 'description' => 'own-team-event']); + + $response = mcpSrvCall(mcpSrvToken($member, ['read']), 'list_audit_events', ['action' => 'list']); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->not->toContain('own-team-event'); +}); + +// ---------------------------------------------------------------- get_server_metrics + +test('get_server_metrics returns recent metrics for a server of the token team', function () { + McpSrvStubMetricsClient::$calls = 0; + $this->app->bind(SentinelMetricsClient::class, fn ($app, array $parameters) => new McpSrvStubMetricsClient($parameters['server'])); + $this->server->settings->update(['is_metrics_enabled' => true]); + Server::flushIdentityMap(); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['read']), 'get_server_metrics', ['server_uuid' => $this->server->uuid, 'minutes' => 60]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)); + $data = mcpSrvData($response); + expect($data['online'])->toBeTrue() + ->and($data['current']['cpu_percent'])->toBe(42.5) + ->and($data['series']['cpu_percent'])->toHaveCount(1) + ->and($data['series']['disk_used_percent'])->toBe([]) + ->and($data['series']['network_rx_bytes_per_sec'])->toHaveCount(1) + ->and(mcpSrvText($response))->not->toContain('sentinel_token'); +}); + +test('get_server_metrics caps minutes and rejects invalid values', function () { + $this->app->bind(SentinelMetricsClient::class, fn ($app, array $parameters) => new McpSrvStubMetricsClient($parameters['server'])); + $this->server->settings->update(['is_metrics_enabled' => true]); + Server::flushIdentityMap(); + $token = mcpSrvToken($this->user, ['read']); + + $capped = mcpSrvCall($token, 'get_server_metrics', ['server_uuid' => $this->server->uuid, 'minutes' => 999999]); + expect(mcpSrvData($capped)['minutes'])->toBe(43200); + + $invalid = mcpSrvCall($token, 'get_server_metrics', ['server_uuid' => $this->server->uuid, 'minutes' => 0]); + expect(mcpSrvIsError($invalid))->toBeTrue(); +}); + +test('get_server_metrics does not read metrics of a server of another team', function () { + McpSrvStubMetricsClient::$calls = 0; + $this->app->bind(SentinelMetricsClient::class, fn ($app, array $parameters) => new McpSrvStubMetricsClient($parameters['server'])); + $this->otherServer->settings->update(['is_metrics_enabled' => true]); + Server::flushIdentityMap(); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['read']), 'get_server_metrics', ['server_uuid' => $this->otherServer->uuid]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->toContain('not found') + ->and(McpSrvStubMetricsClient::$calls)->toBe(0); +}); + +test('get_server_metrics explains when metrics are disabled', function () { + $response = mcpSrvCall(mcpSrvToken($this->user, ['read']), 'get_server_metrics', ['server_uuid' => $this->server->uuid]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->toContain('Metrics are not enabled'); +}); diff --git a/tests/Feature/Mcp/McpToggleApiTest.php b/tests/Feature/Mcp/McpToggleApiTest.php index 0de306cd01..2af122e26d 100644 --- a/tests/Feature/Mcp/McpToggleApiTest.php +++ b/tests/Feature/Mcp/McpToggleApiTest.php @@ -5,6 +5,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Once; uses(RefreshDatabase::class); @@ -94,7 +95,8 @@ test('non-root token cannot disable MCP server', function () { }); test('root token can enable MCP server when the REST API is disabled', function () { - InstanceSettings::query()->where('id', 0)->update(['is_api_enabled' => false, 'allowed_ips' => '192.0.2.10']); + InstanceSettings::query()->where('id', 0)->update(['is_api_enabled' => false]); + Once::flush(); $token = makeRootMcpToken($this->user); test()->withHeaders(['Authorization' => 'Bearer '.$token]) @@ -104,6 +106,24 @@ test('root token can enable MCP server when the REST API is disabled', function expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue(); }); +test('MCP switch routes enforce the allowed API IPs', function (string $path) { + InstanceSettings::query()->where('id', 0)->update(['allowed_ips' => '192.0.2.10']); + Once::flush(); + $token = makeRootMcpToken($this->user); + + test()->withHeaders(['Authorization' => 'Bearer '.$token]) + ->postJson($path) + ->assertForbidden() + ->assertJson(['message' => 'You are not allowed to access the API.']); + + expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse(); + + test()->withServerVariables(['REMOTE_ADDR' => '192.0.2.10']) + ->withHeaders(['Authorization' => 'Bearer '.$token]) + ->postJson($path) + ->assertOk(); +})->with(['/api/v1/mcp/enable', '/api/v1/mcp/disable']); + test('unauthenticated request to /api/v1/mcp/enable returns 401', function () { $response = test()->postJson('/api/v1/mcp/enable'); $response->assertStatus(401); diff --git a/tests/Feature/Mcp/McpWriteToolsTest.php b/tests/Feature/Mcp/McpWriteToolsTest.php new file mode 100644 index 0000000000..3db6c4ff08 --- /dev/null +++ b/tests/Feature/Mcp/McpWriteToolsTest.php @@ -0,0 +1,150 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true, 'is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->otherTeam = Team::factory()->create(); + $this->otherUser = User::factory()->create(); + $this->otherTeam->members()->attach($this->otherUser->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); +}); + +function mcpWriteToken(User $user, array $abilities = ['write']): string +{ + return $user->createToken('mcp-write', $abilities)->plainTextToken; +} + +function mcpWriteCall(string $token, string $tool, array $arguments = []) +{ + // Each MCP call is a new HTTP request; do not reuse the user and token of an earlier call. + app('auth')->forgetGuards(); + + return test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json, text/event-stream', + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => $tool, 'arguments' => (object) $arguments], + ]); +} + +function mcpWriteText($response): string +{ + return (string) ($response->json('result.content.0.text') ?? $response->json('error.message')); +} + +function mcpWriteIsError($response): bool +{ + return $response->json('result.isError') === true || $response->json('error') !== null; +} + +test('manage_project creates a project in the token team, not the session team', function () { + $sessionTeam = Team::factory()->create(); + $sessionTeam->members()->attach($this->user->id, ['role' => 'owner']); + $token = mcpWriteToken($this->user); + session(['currentTeam' => $sessionTeam]); + + $response = mcpWriteCall($token, 'manage_project', ['action' => 'create', 'data' => ['name' => 'From MCP']]); + + $response->assertOk(); + expect(mcpWriteIsError($response))->toBeFalse(); + expect(Project::where('name', 'From MCP')->sole()->team_id)->toBe($this->team->id); +}); + +test('manage_project cannot change or delete a project of another team', function (string $action, array $data) { + $foreign = Project::create(['name' => 'Theirs', 'team_id' => $this->otherTeam->id]); + $token = mcpWriteToken($this->user); + + $response = mcpWriteCall($token, 'manage_project', ['action' => $action, 'uuid' => $foreign->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpWriteIsError($response))->toBeTrue(); + expect($foreign->fresh())->not->toBeNull() + ->and($foreign->fresh()->name)->toBe('Theirs'); +})->with([ + 'update' => ['update', ['name' => 'Hijacked']], + 'delete' => ['delete', []], + 'create environment' => ['create_environment', ['name' => 'evil']], +]); + +test('manage_project rejects read-only tokens and member tokens', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + + $readResponse = mcpWriteCall(mcpWriteToken($this->user, ['read']), 'manage_project', ['action' => 'create', 'data' => ['name' => 'Nope']]); + expect(mcpWriteIsError($readResponse))->toBeTrue() + ->and(mcpWriteText($readResponse))->toContain('Tool [manage_project] not found'); + + foreach ([['read'], ['write']] as $abilities) { + $memberToken = $member->createToken('member', $abilities)->plainTextToken; + $memberResponse = mcpWriteCall($memberToken, 'manage_project', ['action' => 'create', 'data' => ['name' => 'Nope']]); + expect(mcpWriteIsError($memberResponse))->toBeTrue(); + } + + expect(Project::where('name', 'Nope')->exists())->toBeFalse(); +}); + +test('manage_project delete needs confirm=true', function () { + $project = Project::create(['name' => 'Mine', 'team_id' => $this->team->id]); + $token = mcpWriteToken($this->user); + + $denied = mcpWriteCall($token, 'manage_project', ['action' => 'delete', 'uuid' => $project->uuid]); + expect(mcpWriteIsError($denied))->toBeTrue() + ->and(mcpWriteText($denied))->toContain('confirm=true') + ->and($project->fresh())->not->toBeNull(); + + $deleted = mcpWriteCall($token, 'manage_project', ['action' => 'delete', 'uuid' => $project->uuid, 'confirm' => true]); + expect(mcpWriteIsError($deleted))->toBeFalse() + ->and($project->fresh())->toBeNull(); +}); + +test('manage_project returns REST validation errors', function () { + $response = mcpWriteCall(mcpWriteToken($this->user), 'manage_project', ['action' => 'create', 'data' => ['name' => '']]); + + expect(mcpWriteIsError($response))->toBeTrue() + ->and(mcpWriteText($response))->toContain('name'); +}); + +test('write tools reject path parameters that try to reach other routes', function (string $uuid) { + $response = mcpWriteCall(mcpWriteToken($this->user), 'manage_project', ['action' => 'update', 'uuid' => $uuid, 'data' => ['name' => 'x']]); + + expect(mcpWriteIsError($response))->toBeTrue() + ->and(mcpWriteText($response))->toContain('invalid characters'); +})->with(['../enable', 'a/b', '..', '%2e%2e', 'x?y=1']); + +test('write tools reject an unknown action', function () { + $response = mcpWriteCall(mcpWriteToken($this->user), 'manage_project', ['action' => 'drop_everything']); + + expect(mcpWriteIsError($response))->toBeTrue() + ->and(mcpWriteText($response))->toContain('action must be one of'); +}); + +test('write tools work when the REST API is disabled', function () { + InstanceSettings::query()->whereKey(0)->update(['is_api_enabled' => false]); + Once::flush(); + + $response = mcpWriteCall(mcpWriteToken($this->user), 'manage_project', ['action' => 'create', 'data' => ['name' => 'Api Off']]); + + expect(mcpWriteIsError($response))->toBeFalse() + ->and(Project::where('name', 'Api Off')->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Security/AuditLogTest.php b/tests/Feature/Security/AuditLogTest.php index dc2408ab25..b75b0801a8 100644 --- a/tests/Feature/Security/AuditLogTest.php +++ b/tests/Feature/Security/AuditLogTest.php @@ -376,27 +376,14 @@ describe('API mutation audit logging', function () { expect($response->status())->toBeIn([201, 422]); }); - test('enable_api denial for non-root team emits warning audit event', function () { + test('POST /api/v1/enable no longer exists', function () { [$team, $user] = makeAuditTeamUser(); $token = makeAuditApiToken($user, $team); auth()->forgetGuards(); - $auditChannel = Mockery::mock(); - $auditChannel->shouldReceive('warning') - ->atLeast() - ->once() - ->with('api.instance.enable_denied', Mockery::any()); - - Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); - Log::shouldReceive('warning')->andReturnNull(); - Log::shouldReceive('info')->andReturnNull(); - Log::shouldReceive('error')->andReturnNull(); - - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$token, - ])->postJson('/api/v1/enable'); - - $response->assertStatus(403); + $this->withHeaders(['Authorization' => 'Bearer '.$token]) + ->postJson('/api/v1/enable') + ->assertNotFound(); }); test('project creation records api.project.created audit event', function () { diff --git a/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php b/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php index 9307689c47..c6966c1706 100644 --- a/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php +++ b/tests/Unit/DatabaseImport/DatabaseImportCommandBuilderTest.php @@ -83,8 +83,8 @@ test('restores dump-all PostgreSQL custom archives with pg_restore from the deco // pg_restore cannot read gzip files, so it must receive the decompressed archive on stdin. expect($command) - ->toContain('stream | pg_restore -U ${POSTGRES_USER} -d "$db"') - ->toContain('stream | psql -U ${POSTGRES_USER} -d "$db"') + ->toContain('stream | pg_restore -U "$pg_user" -d "$db"') + ->toContain('stream | psql -U "$pg_user" -d "$db"') ->not->toContain("-d \"\$db\" '/tmp/backup.dump.gz'"); }); diff --git a/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php b/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php index d5cb871d95..f7007689d6 100644 --- a/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php +++ b/tests/Unit/DatabaseImport/DatabaseImportRestoreScriptTest.php @@ -235,7 +235,7 @@ function restoreScriptResource(string $engine): object /** * @return array{exit: int, stdout: string, stderr: string, calls: list, dir: string, backup: string, leftovers: list} */ -function restoreScriptRun(string $engine, string $contents, bool $dumpAll = false, bool $replaceExisting = false, bool $keepOwners = false, bool $restoreMysqlUsers = false): array +function restoreScriptRun(string $engine, string $contents, bool $dumpAll = false, bool $replaceExisting = false, bool $keepOwners = false, bool $restoreMysqlUsers = false, array $env = []): array { $dir = restoreScriptTempDir(); @@ -257,6 +257,11 @@ if [ ! -t 0 ]; then header=\$(head -c 5 {$escapedStdin}/\$call) fi printf '%s|%s|%s\\n' '{$client}' "\$*" "\$header" >> {$escapedLog} +if [ -n "\${STUB_FAIL_MATCH:-}" ]; then + case "\$*" in *"\$STUB_FAIL_MATCH"*) exit 1 ;; esac +fi +case "\$*" in *"SELECT datname FROM pg_database"*) printf '%s\\n' "\${STUB_DATABASES:-}" ;; esac +case "\$*" in *rolsuper*) printf '%s\\n' "\${STUB_ROLSUPER-t}" ;; esac exit 0 SH); chmod($dir.'/bin/'.$client, 0755); @@ -267,7 +272,7 @@ SH); $script = (new DatabaseImportCommandBuilder)->buildRestoreCommand(restoreScriptResource($engine), $backup, $dumpAll, $replaceExisting, $keepOwners, restoreMysqlUsers: $restoreMysqlUsers); - $result = restoreScriptProcess(['sh', '-c', $script], $dir, [ + $environment = array_merge([ 'PATH' => $dir.'/bin'.PATH_SEPARATOR.(getenv('PATH') ?: '/usr/bin:/bin'), 'TMPDIR' => $dir, 'POSTGRES_USER' => 'postgres', @@ -282,7 +287,8 @@ SH); 'MARIADB_ROOT_PASSWORD' => 'root_pass', 'MONGO_INITDB_ROOT_USERNAME' => 'root', 'MONGO_INITDB_ROOT_PASSWORD' => 'mongo_pass', - ]); + ], $env); + $result = restoreScriptProcess(['sh', '-c', $script], $dir, array_filter($environment, fn (?string $value): bool => $value !== null)); $lines = array_values(array_filter(explode("\n", (string) file_get_contents($log)), fn (string $line): bool => $line !== '')); $calls = array_map(function (string $line, int $index) use ($dir): array { @@ -385,6 +391,10 @@ if ($name === 'dropdb') { $database = end($args); if (isset($databases[$database])) { exit(1); } $databases[$database] = 'empty'; +} elseif (in_array('-Atc', $args, true)) { + // Connection and superuser checks run their query as an argument and change nothing. + echo str_contains(end($args), 'rolsuper') ? "t\n" : "1\n"; + exit(0); } else { $variables = []; foreach ($args as $index => $arg) { @@ -496,17 +506,18 @@ test('restores single PostgreSQL archives with pg_restore and SQL with psql', fu expect($run['exit'])->toBe(0, $run['stderr']); restoreScriptExpectCalls($run['calls'], $expectedCalls); })->with([ - 'custom archive' => ['pg-custom', false, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]], - 'custom archive replacing existing objects' => ['pg-custom', true, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl --clean --if-exists -U postgres -d app']]]], - 'gzip custom archive' => ['pg-custom-gz', false, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]], - 'gzip custom archive replacing existing objects' => ['pg-custom-gz', true, [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl --clean --if-exists -U postgres -d app']]]], - 'tar archive' => ['pg-tar', false, [['pg_restore', 'toc.d', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]], - 'gzip tar archive' => ['pg-tar-gz', false, [['pg_restore', 'toc.d', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]], - 'plain SQL' => ['pg-sql', false, [['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d app']]]], - 'gzip SQL' => ['pg-sql-gz', false, [['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d app']]]], + 'custom archive' => ['pg-custom', false, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]], + 'custom archive replacing existing objects' => ['pg-custom', true, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl --clean --if-exists -U postgres -d app']]]], + 'gzip custom archive' => ['pg-custom-gz', false, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]], + 'gzip custom archive replacing existing objects' => ['pg-custom-gz', true, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction --no-owner --no-acl --clean --if-exists -U postgres -d app']]]], + 'tar archive' => ['pg-tar', false, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['pg_restore', 'toc.d', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]], + 'gzip tar archive' => ['pg-tar-gz', false, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['pg_restore', 'toc.d', ['--exit-on-error --single-transaction --no-owner --no-acl -U postgres -d app']]]], + 'plain SQL' => ['pg-sql', false, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d app']]]], + 'gzip SQL' => ['pg-sql-gz', false, [['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d app']]]], // SQL cannot replace single objects: it restores into a new database, and only a // successful restore replaces the current one, so a failure changes nothing. 'plain SQL replacing existing objects' => ['pg-sql', true, [ + ['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['psql', 'SELEC', ['-v ON_ERROR_STOP=1 -At', '-v new=coolify_restore_new_', '-v old=coolify_restore_old_']], ['createdb', '', ['-U postgres coolify_restore_new_']], ['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d coolify_restore_new_']], @@ -514,6 +525,7 @@ test('restores single PostgreSQL archives with pg_restore and SQL with psql', fu ['dropdb', '', ['--maintenance-db=template1 -U postgres --if-exists coolify_restore_old_']], ]], 'gzip SQL replacing existing objects' => ['pg-sql-gz', true, [ + ['psql', '', ['-U postgres -d template1 -Atc', 'SELECT 1']], ['psql', 'SELEC', ['-v ON_ERROR_STOP=1 -At', '-v new=coolify_restore_new_', '-v old=coolify_restore_old_']], ['createdb', '', ['-U postgres coolify_restore_new_']], ['psql', '-- Po', ['-v ON_ERROR_STOP=1 --single-transaction -U postgres -d coolify_restore_new_']], @@ -526,8 +538,8 @@ test('keeps PostgreSQL owners and privileges only when requested', function (str $run = restoreScriptRun('postgresql', restoreScriptFixture($fixture), keepOwners: true); expect($run['exit'])->toBe(0, $run['stderr']); - restoreScriptExpectCalls($run['calls'], [['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction -U postgres -d app']]]); - expect($run['calls'][0]['args'])->not->toContain('--no-owner')->not->toContain('--no-acl'); + restoreScriptExpectCalls($run['calls'], [['psql', '', ['SELECT 1']], ['pg_restore', 'PGDMP', ['--exit-on-error --single-transaction -U postgres -d app']]]); + expect($run['calls'][1]['args'])->not->toContain('--no-owner')->not->toContain('--no-acl'); })->with(['custom archive' => ['pg-custom'], 'gzip custom archive' => ['pg-custom-gz']]); test('rejects unsupported single PostgreSQL backups before calling any client', function (string $fixture, string $message) { @@ -541,23 +553,30 @@ test('rejects unsupported single PostgreSQL backups before calling any client', 'binary garbage' => ['garbage', 'Unsupported PostgreSQL backup format'], ]); -test('restores PostgreSQL backups containing all databases after checking the format', function (string $fixture, array $restoreCall) { - $run = restoreScriptRun('postgresql', restoreScriptFixture($fixture), dumpAll: true); +test('restores PostgreSQL backups containing all databases after checking the format', function (string $fixture, array $restoreCall, array $environment, string $user) { + $run = restoreScriptRun('postgresql', restoreScriptFixture($fixture), dumpAll: true, env: $environment + ['STUB_DATABASES' => 'app']); $recreate = [ - ['psql', '', ['-U postgres -c', 'pg_terminate_backend']], - ['psql', '', ['-U postgres -t -c', 'SELECT datname FROM pg_database']], - ['createdb', '', ['-U postgres app']], + ['psql', '', ["-U {$user} -d template1 -Atc", 'rolsuper']], + ['psql', '', ["-U {$user} -d template1 -c", 'pg_terminate_backend']], + ['psql', '', ["-U {$user} -d template1 -t -c", 'SELECT datname FROM pg_database']], + ['dropdb', '', ["-U {$user} --maintenance-db=template1 --if-exists app"]], + ['createdb', '', ["-U {$user} app"]], ]; $inspect = $restoreCall[0] === 'pg_restore' ? [['pg_restore', 'PGDMP', ['-l']]] : []; expect($run['exit'])->toBe(0, $run['stderr']); + $restoreCall[2] = ["-U {$user} -d app"]; restoreScriptExpectCalls($run['calls'], [...$inspect, ...$recreate, $restoreCall]); })->with([ 'custom archive' => ['pg-custom', ['pg_restore', 'PGDMP', ['-U postgres -d app']]], 'gzip custom archive' => ['pg-custom-gz', ['pg_restore', 'PGDMP', ['-U postgres -d app']]], 'plain SQL' => ['pg-sql', ['psql', '-- Po', ['-U postgres -d app']]], 'gzip SQL' => ['pg-sql-gz', ['psql', '-- Po', ['-U postgres -d app']]], +])->with([ + 'POSTGRES_USER' => [[], 'postgres'], + 'PGUSER' => [['POSTGRES_USER' => null, 'PGUSER' => 'custom_user'], 'custom_user'], + 'POSTGRESQL_USERNAME' => [['POSTGRES_USER' => null, 'POSTGRESQL_USERNAME' => 'bitnami'], 'bitnami'], ]); test('rejects unsupported PostgreSQL backups containing all databases before dropping anything', function () { @@ -568,6 +587,46 @@ test('rejects unsupported PostgreSQL backups containing all databases before dro ->and($run['calls'])->toBe([]); }); +test('finds the PostgreSQL user from POSTGRES_USER, PGUSER, or POSTGRESQL_USERNAME', function (array $env, string $user) { + $run = restoreScriptRun('postgresql', restoreScriptFixture('pg-custom'), env: ['POSTGRES_USER' => null, 'POSTGRES_DB' => null, ...$env]); + + expect($run['exit'])->toBe(0, $run['stderr']); + restoreScriptExpectCalls($run['calls'], [ + ['psql', '', ["-U {$user} -d template1 -Atc"]], + ['pg_restore', 'PGDMP', ["-U {$user} -d {$user}"]], + ]); +})->with([ + 'POSTGRES_USER' => [['POSTGRES_USER' => 'app'], 'app'], + 'PGUSER' => [['PGUSER' => 'pguser'], 'pguser'], + 'Bitnami POSTGRESQL_USERNAME' => [['POSTGRESQL_USERNAME' => 'bitnami'], 'bitnami'], + 'POSTGRES_USER wins over the others' => [['POSTGRES_USER' => 'app', 'PGUSER' => 'pguser', 'POSTGRESQL_USERNAME' => 'bitnami'], 'app'], + 'nothing set' => [[], 'postgres'], +]); + +test('stops a PostgreSQL restore before any change when it cannot connect as the user', function (string $fixture, bool $dumpAll, bool $replaceExisting, string $message) { + $run = restoreScriptRun('postgresql', restoreScriptFixture($fixture), $dumpAll, $replaceExisting, env: ['POSTGRES_USER' => null, 'STUB_FAIL_MATCH' => '-d template1 -Atc']); + + $changing = array_filter($run['calls'], fn (array $call): bool => ! str_contains($call['args'], '-d template1 -Atc') && ! ($call['name'] === 'pg_restore' && $call['args'] === '-l')); + + expect($run['exit'])->toBe(1) + ->and($run['stderr'])->toContain($message)->toContain('postgres')->toContain('Nothing was changed.') + ->and($changing)->toBe([]); +})->with([ + 'custom archive' => ['pg-custom', false, false, 'Cannot connect to PostgreSQL as user'], + 'SQL' => ['pg-sql', false, false, 'Cannot connect to PostgreSQL as user'], + 'SQL replacing existing objects' => ['pg-sql', false, true, 'Cannot connect to PostgreSQL as user'], + 'all databases' => ['pg-custom', true, false, 'needs a PostgreSQL superuser'], +]); + +test('refuses to restore all PostgreSQL databases as a user that is not a superuser', function () { + $run = restoreScriptRun('postgresql', restoreScriptFixture('pg-sql'), dumpAll: true, env: ['POSTGRES_USER' => null, 'POSTGRESQL_USERNAME' => 'bitnami', 'STUB_ROLSUPER' => 'f']); + + expect($run['exit'])->toBe(1) + ->and($run['stderr'])->toContain('needs a PostgreSQL superuser')->toContain('bitnami')->toContain('Nothing was changed.') + ->and(array_column($run['calls'], 'name'))->toBe(['psql']) + ->and($run['calls'][0]['args'])->toContain('rolsuper'); +}); + test('restores single MySQL and MariaDB SQL backups', function (string $engine, string $fixture) { $run = restoreScriptRun($engine, restoreScriptFixture($fixture)); diff --git a/tests/Unit/Livewire/Database/S3RestoreTest.php b/tests/Unit/Livewire/Database/S3RestoreTest.php index 8716fe5831..d620c737e8 100644 --- a/tests/Unit/Livewire/Database/S3RestoreTest.php +++ b/tests/Unit/Livewire/Database/S3RestoreTest.php @@ -40,6 +40,7 @@ if [ ! -t 0 ]; then cat >/dev/null fi printf '%s:%s\\n' '{$tool}' "\$header" >> '{$logFile}' +case "\$*" in *rolsuper*) echo t ;; esac exit 0 SH; $path = $binDir.'/'.$tool; @@ -107,7 +108,7 @@ test('buildRestoreCommand handles PostgreSQL with dumpAll', function () { $result = $component->buildRestoreCommand('/tmp/test.dump'); expect($result)->toStartWith("backup='/tmp/test.dump'"); - expect($result)->toContain('stream | psql -U ${POSTGRES_USER} -d "$db"'); + expect($result)->toContain('stream | psql -U "$pg_user" -d "$db"'); }); test('buildRestoreCommand dump-all PostgreSQL restore is pg_restore for PGDMP otherwise psql', function () { @@ -118,8 +119,8 @@ test('buildRestoreCommand dump-all PostgreSQL restore is pg_restore for PGDMP ot expect($command) ->toContain('if [ "$(stream | head -c 5)" = PGDMP ] || is_tar; then') - ->toContain('stream | pg_restore -U ${POSTGRES_USER} -d "$db"') - ->toContain('stream | psql -U ${POSTGRES_USER} -d "$db"') + ->toContain('stream | pg_restore -U "$pg_user" -d "$db"') + ->toContain('stream | psql -U "$pg_user" -d "$db"') ->and(strpos($command, 'kind=archive'))->toBeLessThan(strpos($command, 'dropdb')); }); @@ -132,7 +133,7 @@ test('dump-all PostgreSQL import text shows the client chosen by the dump format test('dump-all PostgreSQL restore selects the client for the dump format', function (string $contents, bool $gzip, string $restore, array $preflight) { // Check the archive, terminate sessions, list databases, recreate the target database, then restore. - expect(invokedPostgresRestoreClients($contents, $gzip))->toBe([...$preflight, 'psql:', 'psql:', 'createdb:', $restore]); + expect(invokedPostgresRestoreClients($contents, $gzip))->toBe([...$preflight, 'psql:', 'psql:', 'psql:', 'createdb:', $restore]); })->with([ 'custom archive' => ['PGDMP'.str_repeat("\0", 16), false, 'pg_restore:PGDMP', ['pg_restore:PGDMP']], 'gzip custom archive' => ['PGDMP'.str_repeat("\0", 16), true, 'pg_restore:PGDMP', ['pg_restore:PGDMP']],