Files
coolify/tests/Feature/Mcp/McpReadToolsTest.php

2438 lines
96 KiB
PHP

<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Mcp\Concerns\BuildsResponse;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneDocker;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\SwarmDocker;
use App\Models\Tag;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'cache.default' => 'array',
'session.driver' => 'array',
'queue.default' => 'sync',
'app.maintenance.driver' => 'file',
]);
InstanceSettings::query()->where('id', 0)->delete();
InstanceSettings::query()->delete();
$settings = new InstanceSettings(['is_mcp_server_enabled' => true]);
$settings->id = 0;
$settings->save();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
// Server::created auto-provisions a default StandaloneDocker (network=coolify).
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
// Project::created auto-creates a production environment.
$this->environment = $this->project->environments()->first()
?? Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'fqdn' => 'https://app.example.com',
]);
});
function mcpReadToken(): string
{
return test()->user->createToken('mcp-read', ['read'])->plainTextToken;
}
function mcpReadCall(string $name, array $arguments = [])
{
// Ensure each call resolves the Bearer token freshly (no guard bleed between tokens).
auth()->forgetGuards();
return test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.mcpReadToken(),
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => $name,
'arguments' => (object) $arguments,
],
]);
}
function mcpSensitiveReadCall(string $name, array $arguments = [])
{
// Ensure each call resolves the Bearer token freshly (no guard bleed between tokens).
auth()->forgetGuards();
$token = test()->user->createToken('mcp-sensitive-read', ['read', 'read:sensitive'])->plainTextToken;
return test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$token,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => $name,
'arguments' => (object) $arguments,
],
]);
}
function mcpReadJson($response): array
{
return json_decode($response->json('result.content.0.text'), true);
}
test('tools/list includes new read tools and lifecycle tools', function () {
$token = mcpReadToken();
$response = test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$token,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/list',
'params' => (object) [],
]);
$response->assertOk();
$names = collect($response->json('result.tools'))->pluck('name')->all();
expect($names)->toContain(
'get_project',
'get_environment',
'list_resources',
'list_deployments',
'get_deployment',
'get_logs',
'list_env_keys',
'list_storages',
'list_destinations',
'get_destination',
'get_server_domains',
'get_server_resources',
'list_tags',
'list_github_apps',
'get_current_team',
'list_team_members',
'list_database_backups',
'list_service_applications',
'list_service_databases',
'search_resources',
'list_unhealthy_resources',
'list_application_previews',
'list_shared_env_keys',
'coolify_help',
'control',
'deploy',
'cancel_deployment',
);
});
test('database backup tools scope schedules by database type and id', function () {
$postgres = StandalonePostgresql::create([
'name' => 'postgres',
'postgres_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$mysql = StandaloneMysql::create([
'name' => 'mysql',
'mysql_root_password' => 'password',
'mysql_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
expect($mysql->id)->toBe($postgres->id);
$postgresBackup = ScheduledDatabaseBackup::create([
'team_id' => $this->team->id,
'frequency' => '0 0 * * *',
'database_id' => $postgres->id,
'database_type' => $postgres->getMorphClass(),
]);
$mysqlBackup = ScheduledDatabaseBackup::create([
'team_id' => $this->team->id,
'frequency' => '0 0 * * *',
'database_id' => $mysql->id,
'database_type' => $mysql->getMorphClass(),
]);
$response = mcpReadCall('list_database_backups', ['uuid' => $postgres->uuid]);
$response->assertOk();
$backupUuids = collect(mcpReadJson($response)['data']['backups'])->pluck('uuid');
expect($backupUuids)
->toContain($postgresBackup->uuid)
->not->toContain($mysqlBackup->uuid);
$response = mcpReadCall('list_backup_executions', [
'database_uuid' => $postgres->uuid,
'scheduled_backup_uuid' => $mysqlBackup->uuid,
]);
$response->assertOk();
expect($response->json('result.isError'))->toBeTrue();
});
test('list_backup_executions omits messages without sensitive read and redacts when included', function () {
$postgres = StandalonePostgresql::create([
'name' => 'backup-msg-db',
'postgres_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$backup = ScheduledDatabaseBackup::create([
'team_id' => $this->team->id,
'frequency' => '0 0 * * *',
'database_id' => $postgres->id,
'database_type' => $postgres->getMorphClass(),
]);
$older = ScheduledDatabaseBackupExecution::create([
'uuid' => (string) Str::uuid(),
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
'message' => 'older backup ok',
'size' => 100,
'filename' => 'old.sql.gz',
'created_at' => now()->subHours(2),
'updated_at' => now()->subHours(2),
]);
$newer = ScheduledDatabaseBackupExecution::create([
'uuid' => (string) Str::uuid(),
'scheduled_database_backup_id' => $backup->id,
'status' => 'failed',
'message' => "backup failed password=redactme01\n",
'size' => 0,
'filename' => 'new.sql.gz',
'created_at' => now()->subHour(),
'updated_at' => now()->subHour(),
]);
$readOnly = mcpReadCall('list_backup_executions', [
'database_uuid' => $postgres->uuid,
'scheduled_backup_uuid' => $backup->uuid,
'page' => 1,
'per_page' => 1,
]);
$readOnly->assertOk();
$readBody = mcpReadJson($readOnly);
expect($readBody['data']['message_included'])->toBeFalse()
->and($readBody['data']['executions'])->toHaveCount(1)
->and($readBody['data']['executions'][0]['status'])->toBe('failed')
->and($readBody['data']['executions'][0]['filename'])->toBe('new.sql.gz')
->and($readBody['data']['executions'][0])->not->toHaveKey('message')
->and($readBody['_pagination']['total'])->toBe(2);
$page2 = mcpReadCall('list_backup_executions', [
'database_uuid' => $postgres->uuid,
'scheduled_backup_uuid' => $backup->uuid,
'page' => 2,
'per_page' => 1,
]);
$page2->assertOk();
expect(mcpReadJson($page2)['data']['executions'][0]['status'])->toBe('success');
$sensitive = mcpSensitiveReadCall('list_backup_executions', [
'database_uuid' => $postgres->uuid,
'scheduled_backup_uuid' => $backup->uuid,
'page' => 1,
'per_page' => 1,
]);
$sensitive->assertOk();
$sensitiveBody = mcpReadJson($sensitive);
$message = $sensitiveBody['data']['executions'][0]['message'] ?? '';
expect($sensitiveBody['data']['message_included'])->toBeTrue()
->and($message)->not->toContain('redactme01')
->and($message)->toContain('password=')
->and($message)->toContain(REDACTED);
expect($older->id)->not->toBe($newer->id);
});
test('get_project returns environments and counts for team project only', function () {
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$response = mcpReadCall('get_project', ['uuid' => $this->project->uuid]);
$response->assertOk();
$body = mcpReadJson($response);
expect($body['data']['uuid'])->toBe($this->project->uuid);
expect($body['data']['counts']['applications'])->toBeGreaterThanOrEqual(1);
expect($body['data']['environments'])->not->toBeEmpty();
$denied = mcpReadCall('get_project', ['uuid' => $otherProject->uuid]);
expect($denied->json('result.isError'))->toBeTrue();
});
test('get_environment is team scoped via project', function () {
$response = mcpReadCall('get_environment', [
'project_uuid' => $this->project->uuid,
'environment_name_or_uuid' => $this->environment->name,
]);
$response->assertOk();
$body = mcpReadJson($response);
expect($body['data']['uuid'])->toBe($this->environment->uuid);
expect(collect($body['data']['applications'])->pluck('uuid'))->toContain($this->application->uuid);
expect($body['data']['counts']['applications'])->toBeGreaterThanOrEqual(1);
expect($body['data']['truncated']['applications'])->toBeFalse();
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnv = Environment::factory()->create(['project_id' => $otherProject->id]);
$denied = mcpReadCall('get_environment', [
'project_uuid' => $otherProject->uuid,
'environment_name_or_uuid' => $otherEnv->uuid,
]);
expect($denied->json('result.isError'))->toBeTrue();
});
test('get_environment caps resource samples and points to list tools when truncated', function () {
foreach (['env-app-a', 'env-app-b', 'env-app-c'] as $name) {
Application::factory()->create([
'name' => $name,
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
}
$response = mcpReadCall('get_environment', [
'project_uuid' => $this->project->uuid,
'environment_name_or_uuid' => $this->environment->uuid,
'sample_per_type' => 2,
]);
$response->assertOk();
$body = mcpReadJson($response);
// beforeEach already has one application in this environment.
expect($body['data']['counts']['applications'])->toBeGreaterThanOrEqual(4)
->and($body['data']['applications'])->toHaveCount(2)
->and($body['data']['truncated']['applications'])->toBeTrue()
->and(collect($body['data']['next_tools'])->pluck('tool')->all())->toContain('list_applications');
});
test('list_resources only returns team resources', function () {
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnv = Environment::factory()->create(['project_id' => $otherProject->id]);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
Application::factory()->create([
'name' => 'OtherTeamApp',
'environment_id' => $otherEnv->id,
'destination_id' => $otherDest->id,
'destination_type' => $otherDest->getMorphClass(),
]);
$response = mcpReadCall('list_resources');
$response->assertOk();
$body = mcpReadJson($response);
$uuids = collect($body['data'])->pluck('uuid');
$names = collect($body['data'])->pluck('name');
expect($uuids)->toContain($this->application->uuid);
expect($names)->not->toContain('OtherTeamApp');
});
test('list_resources paginates sorts and filters at the query layer', function () {
$this->application->update(['name' => 'Charlie App']);
$alphaApp = Application::factory()->create([
'name' => 'Alpha App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$bravoService = Service::factory()->create([
'name' => 'Bravo Service',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$deltaDb = StandalonePostgresql::create([
'name' => 'Delta DB',
'postgres_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$tag = Tag::create([
'name' => 'mcp-listed',
'team_id' => $this->team->id,
]);
$alphaApp->tags()->attach($tag->id);
$bravoService->tags()->attach($tag->id);
$otherProject = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Other Project']);
$otherEnv = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
Application::factory()->create([
'name' => 'Zed Other Project App',
'environment_id' => $otherEnv->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$page1 = mcpReadCall('list_resources', ['page' => 1, 'per_page' => 2]);
$page1->assertOk();
$body1 = mcpReadJson($page1);
expect($body1['_pagination']['total'])->toBe(5)
->and($body1['_pagination']['per_page'])->toBe(2)
->and($body1['_pagination']['page'])->toBe(1)
->and(collect($body1['data'])->pluck('name')->all())->toBe(['Alpha App', 'Bravo Service']);
$page2 = mcpReadCall('list_resources', ['page' => 2, 'per_page' => 2]);
$page2->assertOk();
$body2 = mcpReadJson($page2);
expect(collect($body2['data'])->pluck('name')->all())->toBe(['Charlie App', 'Delta DB']);
$appsOnly = mcpReadCall('list_resources', ['type' => 'application']);
$appsOnly->assertOk();
$appsBody = mcpReadJson($appsOnly);
expect(collect($appsBody['data'])->pluck('type')->unique()->values()->all())->toBe(['application'])
->and($appsBody['_pagination']['total'])->toBe(3);
$dbsOnly = mcpReadCall('list_resources', ['type' => 'database']);
$dbsOnly->assertOk();
$dbsBody = mcpReadJson($dbsOnly);
expect($dbsBody['_pagination']['total'])->toBe(1)
->and($dbsBody['data'][0]['uuid'])->toBe($deltaDb->uuid)
->and($dbsBody['data'][0]['type'])->toBe('standalone-postgresql');
$tagged = mcpReadCall('list_resources', ['tag' => 'mcp-listed']);
$tagged->assertOk();
$taggedBody = mcpReadJson($tagged);
expect(collect($taggedBody['data'])->pluck('uuid')->sort()->values()->all())
->toBe(collect([$alphaApp->uuid, $bravoService->uuid])->sort()->values()->all());
$byProject = mcpReadCall('list_resources', ['project_uuid' => $otherProject->uuid]);
$byProject->assertOk();
$projectBody = mcpReadJson($byProject);
expect($projectBody['_pagination']['total'])->toBe(1)
->and($projectBody['data'][0]['name'])->toBe('Zed Other Project App')
->and($projectBody['data'][0]['project_uuid'])->toBe($otherProject->uuid);
});
test('list_deployments and get_deployment are team scoped and scrub logs', function () {
$deployment = ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'dep-'.fake()->uuid(),
'status' => 'in_progress',
'server_id' => $this->server->id,
'application_name' => $this->application->name,
'server_name' => $this->server->name,
'commit' => 'abc123',
'logs' => json_encode([['name' => 'build', 'output' => 'SECRET_TOKEN=redactme01']]),
]);
$list = mcpReadCall('list_deployments');
$list->assertOk();
$listBody = mcpReadJson($list);
expect(collect($listBody['data'])->pluck('deployment_uuid'))->toContain($deployment->deployment_uuid);
expect(json_encode($listBody))->not->toContain('redactme01');
expect(json_encode($listBody))->not->toContain('"logs"');
$get = mcpReadCall('get_deployment', ['uuid' => $deployment->deployment_uuid]);
$get->assertOk();
$getBody = mcpReadJson($get);
expect($getBody['data']['deployment_uuid'])->toBe($deployment->deployment_uuid);
expect($getBody['data']['application_uuid'])->toBe($this->application->uuid);
expect(json_encode($getBody))->not->toContain('redactme01');
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnv = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
$otherApp = Application::factory()->create([
'environment_id' => $otherEnv->id,
'destination_id' => $otherDest->id,
'destination_type' => $otherDest->getMorphClass(),
]);
$otherDep = ApplicationDeploymentQueue::create([
'application_id' => $otherApp->id,
'deployment_uuid' => 'dep-other-'.fake()->uuid(),
'status' => 'in_progress',
'server_id' => $otherServer->id,
'application_name' => $otherApp->name,
'server_name' => $otherServer->name,
]);
$denied = mcpReadCall('get_deployment', ['uuid' => $otherDep->deployment_uuid]);
expect($denied->json('result.isError'))->toBeTrue();
});
test('deployment listings and overview scope shared server deployments by application team', function () {
$teamDeployment = ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'dep-team-'.fake()->uuid(),
'status' => 'in_progress',
'server_id' => $this->server->id,
'application_name' => $this->application->name,
'server_name' => $this->server->name,
]);
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$otherApplication = Application::factory()->create([
'environment_id' => $otherEnvironment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherDeployment = ApplicationDeploymentQueue::create([
'application_id' => $otherApplication->id,
'deployment_uuid' => 'dep-other-shared-server-'.fake()->uuid(),
'status' => 'in_progress',
'server_id' => $this->server->id,
'application_name' => $otherApplication->name,
'server_name' => $this->server->name,
]);
$listBody = mcpReadJson(mcpReadCall('list_deployments'));
expect(collect($listBody['data'])->pluck('deployment_uuid'))
->toContain($teamDeployment->deployment_uuid)
->not->toContain($otherDeployment->deployment_uuid);
$overviewBody = mcpReadJson(mcpReadCall('get_infrastructure_overview'));
expect($overviewBody['data']['counts']['open_deployments'])->toBe(1);
});
test('list_env_keys never returns values and is team scoped', function () {
EnvironmentVariable::create([
'key' => 'DATABASE_URL',
'value' => 'postgres://secret@localhost/db',
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
'is_preview' => false,
]);
$response = mcpReadCall('list_env_keys', [
'resource' => 'application',
'uuid' => $this->application->uuid,
]);
$response->assertOk();
$body = mcpReadJson($response);
$raw = json_encode($body);
expect(collect($body['data']['keys'])->pluck('key'))->toContain('DATABASE_URL');
expect($raw)->not->toContain('postgres://secret');
expect($raw)->not->toContain('"value"');
expect($raw)->not->toContain('real_value');
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnv = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
$otherApp = Application::factory()->create([
'environment_id' => $otherEnv->id,
'destination_id' => $otherDest->id,
'destination_type' => $otherDest->getMorphClass(),
]);
$denied = mcpReadCall('list_env_keys', [
'resource' => 'application',
'uuid' => $otherApp->uuid,
]);
expect($denied->json('result.isError'))->toBeTrue();
});
test('list_destinations and get_destination are team scoped', function () {
$response = mcpReadCall('list_destinations');
$response->assertOk();
$body = mcpReadJson($response);
expect(collect($body['data'])->pluck('uuid'))->toContain($this->destination->uuid);
$get = mcpReadCall('get_destination', ['uuid' => $this->destination->uuid]);
$get->assertOk();
expect(mcpReadJson($get)['data']['uuid'])->toBe($this->destination->uuid);
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
$denied = mcpReadCall('get_destination', ['uuid' => $otherDest->uuid]);
expect($denied->json('result.isError'))->toBeTrue();
});
test('get_server_domains and get_server_resources are team scoped', function () {
$domains = mcpReadCall('get_server_domains', ['uuid' => $this->server->uuid]);
$domains->assertOk();
$domainBody = mcpReadJson($domains);
expect($domainBody['data']['server_uuid'])->toBe($this->server->uuid);
expect($domainBody['data']['domains'])->toHaveCount(1);
expect($domainBody['data']['domains'][0]['resource_uuid'])->toBe($this->application->uuid);
expect($domainBody['data']['domains'][0]['domains'])->toContain('app.example.com');
$resources = mcpReadCall('get_server_resources', ['uuid' => $this->server->uuid]);
$resources->assertOk();
$otherServer = Server::factory()->create(['team_id' => Team::factory()->create()->id]);
expect(mcpReadCall('get_server_domains', ['uuid' => $otherServer->uuid])->json('result.isError'))->toBeTrue();
expect(mcpReadCall('get_server_resources', ['uuid' => $otherServer->uuid])->json('result.isError'))->toBeTrue();
});
test('get_server_domains filters polymorphic destinations by type and id', function () {
// Other server gets the next standalone_dockers id (typically 2).
$otherServer = Server::factory()->create(['team_id' => $this->team->id]);
$otherStandalone = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
// Swarm on this server with the same numeric id as the other server's
// standalone docker. Untyped whereIn(destination_id) would merge that id
// and wrongly attribute the other server's app to this server.
DB::table('swarm_dockers')->insert([
'id' => $otherStandalone->id,
'uuid' => (string) Str::uuid(),
'server_id' => $this->server->id,
'name' => 'swarm-network',
'network' => 'swarm-network',
'created_at' => now(),
'updated_at' => now(),
]);
$swarmOnThisServer = SwarmDocker::query()->findOrFail($otherStandalone->id);
expect($swarmOnThisServer->id)->toBe($otherStandalone->id);
expect($this->destination->id)->not->toBe($otherStandalone->id);
$swarmApp = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $swarmOnThisServer->id,
'destination_type' => SwarmDocker::class,
'fqdn' => 'https://swarm.example.com',
'name' => 'swarm-app',
]);
$otherServerApp = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $otherStandalone->id,
'destination_type' => StandaloneDocker::class,
'fqdn' => 'https://other-server.example.com',
'name' => 'other-server-app',
]);
$domains = mcpReadCall('get_server_domains', ['uuid' => $this->server->uuid]);
$domains->assertOk();
$domainBody = mcpReadJson($domains);
$resourceUuids = collect($domainBody['data']['domains'])->pluck('resource_uuid');
expect($resourceUuids)->toContain($this->application->uuid);
expect($resourceUuids)->toContain($swarmApp->uuid);
expect($resourceUuids)->not->toContain($otherServerApp->uuid);
});
test('list_applications list_databases list_services server_uuid filters use destination type', function () {
// Other server gets the next standalone_dockers id (typically 2).
$otherServer = Server::factory()->create(['team_id' => $this->team->id]);
$otherStandalone = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
// Swarm on this server with the same numeric id as the other server's standalone docker.
DB::table('swarm_dockers')->insert([
'id' => $otherStandalone->id,
'uuid' => (string) Str::uuid(),
'server_id' => $this->server->id,
'name' => 'swarm-network-list-filter',
'network' => 'swarm-network-list-filter',
'created_at' => now(),
'updated_at' => now(),
]);
$swarmOnThisServer = SwarmDocker::query()->findOrFail($otherStandalone->id);
expect($swarmOnThisServer->id)->toBe($otherStandalone->id);
$swarmApp = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $swarmOnThisServer->id,
'destination_type' => SwarmDocker::class,
'name' => 'swarm-list-app',
]);
$otherServerApp = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $otherStandalone->id,
'destination_type' => StandaloneDocker::class,
'name' => 'other-server-list-app',
]);
$swarmDb = StandalonePostgresql::create([
'name' => 'swarm-list-db',
'status' => 'running:healthy',
'postgres_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $swarmOnThisServer->id,
'destination_type' => SwarmDocker::class,
]);
$otherServerDb = StandalonePostgresql::create([
'name' => 'other-server-list-db',
'status' => 'running:healthy',
'postgres_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $otherStandalone->id,
'destination_type' => StandaloneDocker::class,
]);
$swarmService = Service::factory()->create([
'name' => 'swarm-list-svc',
'environment_id' => $this->environment->id,
'server_id' => null,
'destination_id' => $swarmOnThisServer->id,
'destination_type' => SwarmDocker::class,
]);
$otherServerService = Service::factory()->create([
'name' => 'other-server-list-svc',
'environment_id' => $this->environment->id,
'server_id' => null,
'destination_id' => $otherStandalone->id,
'destination_type' => StandaloneDocker::class,
]);
$apps = mcpReadCall('list_applications', ['server_uuid' => $this->server->uuid]);
$apps->assertOk();
$appUuids = collect(mcpReadJson($apps)['data'])->pluck('uuid');
expect($appUuids)->toContain($this->application->uuid, $swarmApp->uuid)
->not->toContain($otherServerApp->uuid);
$dbs = mcpReadCall('list_databases', ['server_uuid' => $this->server->uuid]);
$dbs->assertOk();
$dbUuids = collect(mcpReadJson($dbs)['data'])->pluck('uuid');
expect($dbUuids)->toContain($swarmDb->uuid)
->not->toContain($otherServerDb->uuid);
$services = mcpReadCall('list_services', ['server_uuid' => $this->server->uuid]);
$services->assertOk();
$serviceUuids = collect(mcpReadJson($services)['data'])->pluck('uuid');
expect($serviceUuids)->toContain($swarmService->uuid)
->not->toContain($otherServerService->uuid);
});
test('list_tags and get_current_team and list_team_members are team scoped', function () {
Tag::create(['name' => 'prod', 'team_id' => $this->team->id]);
Tag::create(['name' => 'theirs', 'team_id' => Team::factory()->create()->id]);
$tags = mcpReadCall('list_tags');
$tags->assertOk();
$tagNames = collect(mcpReadJson($tags)['data'])->pluck('name');
expect($tagNames)->toContain('prod');
expect($tagNames)->not->toContain('theirs');
$team = mcpReadCall('get_current_team');
$team->assertOk();
expect(mcpReadJson($team)['data']['name'])->toBe($this->team->name);
$members = mcpReadCall('list_team_members');
$members->assertOk();
expect(collect(mcpReadJson($members)['data'])->pluck('email'))->toContain($this->user->email);
});
test('list_github_apps is team scoped and scrubs secrets', function () {
$app = GithubApp::create([
'name' => 'Mine',
'team_id' => $this->team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 1,
'installation_id' => 1,
'client_id' => 'client-id',
'client_secret' => 'super-client-secret',
'webhook_secret' => 'super-webhook-secret',
'is_public' => false,
'is_system_wide' => false,
]);
GithubApp::create([
'name' => 'Theirs',
'team_id' => Team::factory()->create()->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 2,
'installation_id' => 2,
'client_id' => 'other-client',
'client_secret' => 'other-secret',
'webhook_secret' => 'other-webhook',
'is_public' => false,
'is_system_wide' => false,
]);
$response = mcpReadCall('list_github_apps');
$response->assertOk();
$body = mcpReadJson($response);
$names = collect($body['data'])->pluck('name');
$raw = json_encode($body);
expect($names)->toContain('Mine');
expect($names)->not->toContain('Theirs');
expect($raw)->not->toContain('super-client-secret');
expect($raw)->not->toContain('super-webhook-secret');
expect(collect($body['data'])->pluck('uuid'))->toContain($app->uuid);
});
test('list_github_repositories rejects public github sources cleanly', function () {
$publicApp = GithubApp::create([
'name' => 'Public Source',
'uuid' => 'github-public-test',
'team_id' => $this->team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'is_public' => true,
'is_system_wide' => false,
]);
$response = mcpReadCall('list_github_repositories', [
'github_app_uuid' => $publicApp->uuid,
]);
$response->assertOk();
expect($response->json('result.isError'))->toBeTrue();
expect($response->json('result.content.0.text'))
->toContain('public or missing app installation credentials')
->not->toContain('private_key');
});
test('list_github_branches uses anonymous github api for public sources', function () {
$publicApp = GithubApp::create([
'name' => 'Public Source',
'uuid' => 'github-public-branches',
'team_id' => $this->team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'is_public' => true,
'is_system_wide' => false,
]);
Http::fake([
'https://api.github.com/repos/coollabsio/coolify/branches*' => Http::response([
['name' => 'v4.x', 'protected' => true, 'commit' => ['sha' => 'abc123']],
['name' => 'next', 'protected' => false, 'commit' => ['sha' => 'def456']],
], 200),
]);
$response = mcpReadCall('list_github_branches', [
'github_app_uuid' => $publicApp->uuid,
'owner' => 'coollabsio',
'repo' => 'coolify',
]);
$response->assertOk();
expect($response->json('result.isError'))->toBeFalse();
$body = mcpReadJson($response);
expect(collect($body['data']['branches'])->pluck('name')->all())->toContain('v4.x', 'next');
expect($body['data']['branches'][0]['commit_sha'])->toBe('abc123');
});
test('list_github_branches rejects private apps missing installation credentials', function () {
$privateApp = GithubApp::create([
'name' => 'Incomplete Private App',
'uuid' => 'github-private-incomplete',
'team_id' => $this->team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => null,
'installation_id' => null,
'private_key_id' => null,
'is_public' => false,
'is_system_wide' => false,
]);
Http::fake();
$response = mcpReadCall('list_github_branches', [
'github_app_uuid' => $privateApp->uuid,
'owner' => 'coollabsio',
'repo' => 'coolify',
]);
$response->assertOk();
expect($response->json('result.isError'))->toBeTrue();
expect($response->json('result.content.0.text'))
->toContain('missing installation credentials')
->not->toContain('private_key');
Http::assertNothingSent();
});
test('list_github_branches rejects owner or repo path segment injection', function () {
$publicApp = GithubApp::create([
'name' => 'Public Source Path Check',
'uuid' => 'github-public-path-check',
'team_id' => $this->team->id,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'is_public' => true,
'is_system_wide' => false,
]);
Http::fake();
$badOwner = mcpReadCall('list_github_branches', [
'github_app_uuid' => $publicApp->uuid,
'owner' => 'cool/../labs',
'repo' => 'coolify',
]);
$badOwner->assertOk();
expect($badOwner->json('result.isError'))->toBeTrue();
expect($badOwner->json('result.content.0.text'))->toContain('valid GitHub login');
$badRepo = mcpReadCall('list_github_branches', [
'github_app_uuid' => $publicApp->uuid,
'owner' => 'coollabsio',
'repo' => 'coolify/extra',
]);
$badRepo->assertOk();
expect($badRepo->json('result.isError'))->toBeTrue();
expect($badRepo->json('result.content.0.text'))->toContain('valid GitHub repository');
Http::assertNothingSent();
});
test('list_applications project_uuid filter is team scoped', function () {
$otherProject = Project::factory()->create(['team_id' => $this->team->id]);
$otherEnv = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
Application::factory()->create([
'name' => 'OtherProjectApp',
'environment_id' => $otherEnv->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = mcpReadCall('list_applications', ['project_uuid' => $this->project->uuid]);
$response->assertOk();
$body = mcpReadJson($response);
$names = collect($body['data'])->pluck('name');
expect($names)->toContain($this->application->name);
expect($names)->not->toContain('OtherProjectApp');
});
test('get_logs rejects other team application uuid', function () {
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnv = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
$otherApp = Application::factory()->create([
'environment_id' => $otherEnv->id,
'destination_id' => $otherDest->id,
'destination_type' => $otherDest->getMorphClass(),
]);
$response = mcpSensitiveReadCall('get_logs', [
'resource' => 'application',
'uuid' => $otherApp->uuid,
]);
expect($response->json('result.isError'))->toBeTrue();
});
test('search_resources finds team app by name and domain and excludes other team', function () {
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnv = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
Application::factory()->create([
'name' => 'SecretOtherApp',
'fqdn' => 'https://app.example.com',
'environment_id' => $otherEnv->id,
'destination_id' => $otherDest->id,
'destination_type' => $otherDest->getMorphClass(),
]);
$byName = mcpReadCall('search_resources', ['query' => $this->application->name]);
$byName->assertOk();
$names = collect(mcpReadJson($byName)['data']['results'])->pluck('name');
expect($names)->toContain($this->application->name);
expect($names)->not->toContain('SecretOtherApp');
$byDomain = mcpReadCall('search_resources', ['query' => 'app.example.com', 'types' => 'application']);
$byDomain->assertOk();
$uuids = collect(mcpReadJson($byDomain)['data']['results'])->pluck('uuid');
expect($uuids)->toContain($this->application->uuid);
});
test('list_unhealthy_resources includes non-running apps and is team scoped', function () {
$this->application->update(['status' => 'exited:unhealthy']);
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnv = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
Application::factory()->create([
'name' => 'OtherDown',
'status' => 'exited:unhealthy',
'environment_id' => $otherEnv->id,
'destination_id' => $otherDest->id,
'destination_type' => $otherDest->getMorphClass(),
]);
$response = mcpReadCall('list_unhealthy_resources');
$response->assertOk();
$body = mcpReadJson($response);
$names = collect($body['data']['unhealthy'])->pluck('name');
expect($names)->toContain($this->application->name);
expect($names)->not->toContain('OtherDown');
});
test('get_infrastructure_overview health_hints and project counts stay accurate', function () {
$this->application->update(['status' => 'exited:unhealthy']);
Application::factory()->create([
'name' => 'HealthyApp',
'status' => 'running:healthy',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
StandalonePostgresql::create([
'name' => 'DownDb',
'postgres_password' => 'password',
'status' => 'exited:unhealthy',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
StandalonePostgresql::create([
'name' => 'UpDb',
'postgres_password' => 'password',
'status' => 'running:healthy',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
Service::factory()->create([
'name' => 'EmptyService',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnv = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
Application::factory()->create([
'name' => 'OtherTeamDown',
'status' => 'exited:unhealthy',
'environment_id' => $otherEnv->id,
'destination_id' => $otherDest->id,
'destination_type' => $otherDest->getMorphClass(),
]);
StandalonePostgresql::create([
'name' => 'OtherTeamDb',
'postgres_password' => 'password',
'status' => 'exited:unhealthy',
'environment_id' => $otherEnv->id,
'destination_id' => $otherDest->id,
'destination_type' => $otherDest->getMorphClass(),
]);
$response = mcpReadCall('get_infrastructure_overview');
$response->assertOk();
$body = mcpReadJson($response);
$data = $body['data'];
expect($data['counts']['applications'])->toBe(2)
->and($data['counts']['services'])->toBe(1)
->and($data['counts']['databases'])->toBe(2)
->and($data['projects'][0]['counts']['applications'])->toBe(2)
->and($data['projects'][0]['counts']['services'])->toBe(1)
->and($data['projects'][0]['counts']['databases'])->toBe(2)
->and($data['health_hints']['applications_not_running'])->toBe(1)
->and($data['health_hints']['databases_not_running'])->toBe(1)
// Empty service has no containers → aggregated status is not healthy.
->and($data['health_hints']['services_not_running'])->toBe(1);
});
test('list_application_previews is team scoped', function () {
$preview = ApplicationPreview::create([
'application_id' => $this->application->id,
'pull_request_id' => 42,
'pull_request_html_url' => 'https://github.com/org/repo/pull/42',
'fqdn' => 'https://pr-42.example.com',
'status' => 'running:healthy',
]);
$response = mcpReadCall('list_application_previews', ['uuid' => $this->application->uuid]);
$response->assertOk();
$body = mcpReadJson($response);
expect(collect($body['data']['previews'])->pluck('uuid'))->toContain($preview->uuid);
expect(collect($body['data']['previews'])->pluck('pull_request_id'))->toContain(42);
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnv = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
$otherApp = Application::factory()->create([
'environment_id' => $otherEnv->id,
'destination_id' => $otherDest->id,
'destination_type' => $otherDest->getMorphClass(),
]);
expect(mcpReadCall('list_application_previews', ['uuid' => $otherApp->uuid])->json('result.isError'))->toBeTrue();
});
test('list_shared_env_keys returns names without values and is team scoped', function () {
SharedEnvironmentVariable::create([
'key' => 'SHARED_API_URL',
'value' => 'https://secret.example.com',
'type' => 'project',
'team_id' => $this->team->id,
'project_id' => $this->project->id,
]);
$response = mcpReadCall('list_shared_env_keys', [
'scope' => 'project',
'uuid' => $this->project->uuid,
]);
$response->assertOk();
$body = mcpReadJson($response);
$raw = json_encode($body);
expect(collect($body['data']['keys'])->pluck('key'))->toContain('SHARED_API_URL');
expect($raw)->not->toContain('secret.example.com');
expect($raw)->not->toContain('"value"');
$otherProject = Project::factory()->create(['team_id' => Team::factory()->create()->id]);
expect(mcpReadCall('list_shared_env_keys', [
'scope' => 'project',
'uuid' => $otherProject->uuid,
])->json('result.isError'))->toBeTrue();
});
test('get_deployment include_log_summary requires sensitive read ability', function () {
$deployment = ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'dep-log-'.fake()->uuid(),
'status' => 'failed',
'server_id' => $this->server->id,
'application_name' => $this->application->name,
'server_name' => $this->server->name,
'commit' => 'deadbeef',
'logs' => json_encode([
['output' => 'unstructured-sensitive-build-output', 'type' => 'stdout', 'hidden' => false],
]),
]);
$response = mcpReadCall('get_deployment', [
'uuid' => $deployment->deployment_uuid,
'include_log_summary' => true,
]);
$response->assertOk();
expect($response->json('result.isError'))->toBeTrue()
->and($response->json('result.content.0.text'))->toContain('read:sensitive')
->and($response->json('result.content.0.text'))->not->toContain('unstructured-sensitive-build-output');
});
test('get_deployment include_log_summary returns capped redacted text with sensitive read ability', function () {
$logs = json_encode([
['output' => 'step 1 ok', 'type' => 'stdout', 'hidden' => false],
['output' => 'password=redactme01', 'type' => 'stderr', 'hidden' => false],
['output' => 'done', 'type' => 'stdout', 'hidden' => false],
]);
$deployment = ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'dep-log-'.fake()->uuid(),
'status' => 'failed',
'server_id' => $this->server->id,
'application_name' => $this->application->name,
'server_name' => $this->server->name,
'commit' => 'deadbeef',
'logs' => $logs,
]);
$response = mcpSensitiveReadCall('get_deployment', [
'uuid' => $deployment->deployment_uuid,
'include_log_summary' => true,
'log_lines' => 10,
]);
$response->assertOk();
$body = mcpReadJson($response);
expect($body['data']['log_summary']['available'])->toBeTrue();
expect($body['data']['log_summary']['text'])->toContain('step 1 ok');
expect($body['data']['log_summary']['text'])->not->toContain('redactme01');
expect($body['data']['log_summary']['text'])->toContain('password=');
// Full logs field still scrubbed from root payload
expect(json_encode($body))->not->toContain('"logs":');
});
test('get_deployment plain-text log summary respects the requested line limit', function () {
$deployment = ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'dep-log-'.fake()->uuid(),
'status' => 'failed',
'server_id' => $this->server->id,
'application_name' => $this->application->name,
'server_name' => $this->server->name,
'commit' => 'deadbeef',
'logs' => "first line\nsecond line token=redactme01\nlast line",
]);
$response = mcpSensitiveReadCall('get_deployment', [
'uuid' => $deployment->deployment_uuid,
'include_log_summary' => true,
'log_lines' => 1,
]);
$response->assertOk();
$summary = mcpReadJson($response)['data']['log_summary'];
expect($summary['lines'])->toBe(1)
->and($summary['truncated'])->toBeTrue()
->and($summary['text'])->toBe('last line')
->and($summary['text'])->not->toContain('redactme01');
});
test('list_servers reachable filter works', function () {
$this->server->settings->forceFill(['is_reachable' => true])->saveQuietly();
$response = mcpReadCall('list_servers', ['reachable' => true]);
$response->assertOk();
$uuids = collect(mcpReadJson($response)['data'])->pluck('uuid');
expect($uuids)->toContain($this->server->uuid);
$none = mcpReadCall('list_servers', ['reachable' => false]);
$none->assertOk();
expect(collect(mcpReadJson($none)['data'])->pluck('uuid'))->not->toContain($this->server->uuid);
});
test('list_applications status and server_uuid filters work', function () {
$this->application->update(['status' => 'running:healthy']);
$byStatus = mcpReadCall('list_applications', ['status' => 'running']);
$byStatus->assertOk();
expect(collect(mcpReadJson($byStatus)['data'])->pluck('uuid'))->toContain($this->application->uuid);
$byServer = mcpReadCall('list_applications', ['server_uuid' => $this->server->uuid]);
$byServer->assertOk();
expect(collect(mcpReadJson($byServer)['data'])->pluck('uuid'))->toContain($this->application->uuid);
$missingServer = mcpReadCall('list_applications', ['server_uuid' => 'no-such-server']);
$missingServer->assertOk();
expect(mcpReadJson($missingServer)['data'])->toBe([]);
});
test('list_applications paginates with stable name order and disjoint pages', function () {
$this->application->update(['name' => 'app-z-original']);
foreach (['app-a', 'app-b', 'app-c'] as $name) {
Application::factory()->create([
'name' => $name,
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
}
$page1 = mcpReadCall('list_applications', ['page' => 1, 'per_page' => 2]);
$page1->assertOk();
$page1Body = mcpReadJson($page1);
$page1Names = collect($page1Body['data'])->pluck('name')->all();
$page1Uuids = collect($page1Body['data'])->pluck('uuid')->all();
expect($page1Names)->toBe(collect($page1Names)->sort()->values()->all())
->and($page1Body['_pagination']['total'])->toBeGreaterThanOrEqual(4)
->and($page1Body['_pagination']['next']['args']['page'] ?? null)->toBe(2);
$page2 = mcpReadCall('list_applications', ['page' => 2, 'per_page' => 2]);
$page2->assertOk();
$page2Uuids = collect(mcpReadJson($page2)['data'])->pluck('uuid')->all();
expect(array_intersect($page1Uuids, $page2Uuids))->toBe([]);
});
test('list_databases filters by project, name, status, and server and is team scoped', function () {
$matching = StandalonePostgresql::create([
'name' => 'prod-postgres',
'status' => 'running:healthy',
'postgres_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherName = StandalonePostgresql::create([
'name' => 'dev-redis-like',
'status' => 'exited:unhealthy',
'postgres_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherProject = Project::factory()->create(['team_id' => $this->team->id]);
$otherEnv = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
StandalonePostgresql::create([
'name' => 'other-project-db',
'status' => 'running:healthy',
'postgres_password' => 'password',
'environment_id' => $otherEnv->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherTeam = Team::factory()->create();
$otherTeamProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherTeamEnv = $otherTeamProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherTeamProject->id]);
$otherTeamServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherTeamDest = StandaloneDocker::query()->where('server_id', $otherTeamServer->id)->firstOrFail();
StandalonePostgresql::create([
'name' => 'foreign-db',
'status' => 'running:healthy',
'postgres_password' => 'password',
'environment_id' => $otherTeamEnv->id,
'destination_id' => $otherTeamDest->id,
'destination_type' => $otherTeamDest->getMorphClass(),
]);
$all = mcpReadCall('list_databases');
$all->assertOk();
$allUuids = collect(mcpReadJson($all)['data'])->pluck('uuid');
expect($allUuids)
->toContain($matching->uuid, $otherName->uuid)
->not->toContain(StandalonePostgresql::where('name', 'foreign-db')->value('uuid'));
$byProject = mcpReadCall('list_databases', ['project_uuid' => $this->project->uuid]);
$byProject->assertOk();
$projectUuids = collect(mcpReadJson($byProject)['data'])->pluck('uuid');
expect($projectUuids)
->toContain($matching->uuid)
->not->toContain(StandalonePostgresql::where('name', 'other-project-db')->value('uuid'));
$byName = mcpReadCall('list_databases', ['name' => 'prod-']);
$byName->assertOk();
expect(collect(mcpReadJson($byName)['data'])->pluck('uuid'))
->toContain($matching->uuid)
->not->toContain($otherName->uuid);
$byStatus = mcpReadCall('list_databases', ['status' => 'exited']);
$byStatus->assertOk();
expect(collect(mcpReadJson($byStatus)['data'])->pluck('uuid'))
->toContain($otherName->uuid)
->not->toContain($matching->uuid);
$byServer = mcpReadCall('list_databases', ['server_uuid' => $this->server->uuid]);
$byServer->assertOk();
expect(collect(mcpReadJson($byServer)['data'])->pluck('uuid'))->toContain($matching->uuid);
$missingServer = mcpReadCall('list_databases', ['server_uuid' => 'no-such-server']);
$missingServer->assertOk();
expect(mcpReadJson($missingServer)['data'])->toBe([]);
$row = collect(mcpReadJson($byProject)['data'])->firstWhere('uuid', $matching->uuid);
expect($row)
->toHaveKeys(['uuid', 'name', 'status', 'type', 'project_uuid', 'project_name'])
->and($row['project_uuid'])->toBe($this->project->uuid)
->and($row['type'])->toBe('standalone-postgresql');
});
test('list_services filters by its computed status before pagination', function () {
Service::factory()->create([
'name' => 'Matching service',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
Service::factory()->create([
'name' => 'Another service',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = mcpReadCall('list_services', [
'status' => 'unknown',
'per_page' => 1,
]);
$response->assertOk();
$body = mcpReadJson($response);
expect($body['_pagination']['total'])->toBe(2)
->and($body['data'])->toHaveCount(1)
->and($body['data'][0]['status'])->toContain('unknown');
});
test('get_service_application returns a field whitelist and is team scoped', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
]);
$app = ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'name' => 'web',
'human_name' => 'Web',
'description' => 'Frontend container',
'service_id' => $service->id,
'image' => 'nginx:alpine',
'fqdn' => 'https://web.example.com',
'status' => 'running:healthy',
]);
$response = mcpReadCall('get_service_application', [
'service_uuid' => $service->uuid,
'uuid' => $app->uuid,
]);
$response->assertOk();
$body = mcpReadJson($response);
$data = $body['data'];
expect($data['uuid'])->toBe($app->uuid)
->and($data['service_uuid'])->toBe($service->uuid)
->and($data['name'])->toBe('web')
->and($data['human_name'])->toBe('Web')
->and($data['status'])->toBe('running:healthy')
->and($data['fqdn'])->toBe('https://web.example.com')
->and($data['image'])->toBe('nginx:alpine')
->and($data)->toHaveKeys([
'uuid',
'service_uuid',
'name',
'human_name',
'description',
'status',
'fqdn',
'ports',
'exposes',
'image',
'exclude_from_status',
'required_fqdn',
'is_log_drain_enabled',
'is_include_timestamps',
'is_gzip_enabled',
'is_stripprefix_enabled',
'last_online_at',
'created_at',
'updated_at',
])
->and($data)->not->toHaveKey('id')
->and($data)->not->toHaveKey('service_id')
->and($data)->not->toHaveKey('is_migrated');
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnv = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
$otherService = Service::factory()->create([
'environment_id' => $otherEnv->id,
'server_id' => $otherServer->id,
'destination_id' => $otherDest->id,
'destination_type' => $otherDest->getMorphClass(),
]);
$otherApp = ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'name' => 'theirs',
'service_id' => $otherService->id,
'image' => 'nginx:alpine',
]);
$denied = mcpReadCall('get_service_application', [
'service_uuid' => $otherService->uuid,
'uuid' => $otherApp->uuid,
]);
expect($denied->json('result.isError'))->toBeTrue();
});
test('MCP lists prompts for troubleshooting workflows', function () {
$token = mcpReadToken();
$response = test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$token,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'prompts/list',
'params' => (object) [],
]);
$response->assertOk();
$names = collect($response->json('result.prompts'))->pluck('name')->all();
expect($names)->toContain('troubleshoot_application', 'explain_failed_deploy');
});
test('get_logs requires sensitive read ability', function () {
$this->application->update(['status' => 'running:healthy']);
$response = mcpReadCall('get_logs', [
'resource' => 'application',
'uuid' => $this->application->uuid,
]);
$response->assertOk();
expect($response->json('result.isError'))->toBeTrue()
->and($response->json('result.content.0.text'))->toContain('read:sensitive');
});
test('team members cannot retrieve logs with sensitive read ability', function () {
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
$response = mcpSensitiveReadCall('get_logs', [
'resource' => 'application',
'uuid' => $this->application->uuid,
]);
// Elevated member tokens are rejected as JSON-RPC errors (HTTP 200) so MCP clients can parse them.
$response->assertOk();
expect($response->json('error.message') ?? $response->json('result.content.0.text') ?? '')
->toMatch('/team role|Missing required/i');
});
test('get_logs returns structured next_tools when application is not running', function () {
$this->application->update(['status' => 'exited:unhealthy']);
$response = mcpSensitiveReadCall('get_logs', [
'resource' => 'application',
'uuid' => $this->application->uuid,
]);
$response->assertOk();
$body = mcpReadJson($response);
expect($body['data']['ok'])->toBeFalse();
expect($body['data']['reason'])->toBe('not_running');
expect($body['data']['next_tools'])->not->toBeEmpty();
expect(collect($body['data']['next_tools'])->pluck('tool'))->toContain('list_deployments', 'list_unhealthy_resources');
});
test('get_logs returns structured choices when service has multiple containers', function () {
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
$service = Service::factory()->create([
'name' => 'multi-container-svc',
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$childApp = ServiceApplication::create([
'uuid' => (string) Str::uuid(),
'name' => 'web',
'service_id' => $service->id,
'status' => 'running:healthy',
'image' => 'nginx:latest',
]);
$childDb = ServiceDatabase::create([
'uuid' => (string) Str::uuid(),
'name' => 'db',
'service_id' => $service->id,
'status' => 'running:healthy',
'image' => 'postgres:16',
]);
$response = mcpSensitiveReadCall('get_logs', [
'resource' => 'service',
'uuid' => $service->uuid,
]);
$response->assertOk();
$body = mcpReadJson($response);
expect($body['data']['ok'])->toBeFalse()
->and($body['data']['reason'])->toBe('multiple_containers')
->and($body['data']['choices'])->toBeArray()
->and(collect($body['data']['choices'])->pluck('uuid')->all())
->toContain($childApp->uuid, $childDb->uuid)
->and(json_encode($body['data']['message'] ?? ''))->not->toContain('"uuid"');
});
test('coolify_help returns catalog intents', function () {
$response = mcpReadCall('coolify_help', ['intent' => 'essentials']);
$response->assertOk();
$body = mcpReadJson($response);
expect($body['data']['catalog']['essentials']['tools'])->toContain('search_resources', 'control');
});
test('list_unhealthy_resources sample_only returns summary and samples', function () {
$this->application->update(['status' => 'exited:unhealthy']);
$response = mcpReadCall('list_unhealthy_resources', ['sample_only' => true, 'sample_per_type' => 3]);
$response->assertOk();
$body = mcpReadJson($response);
expect($body['data']['sample_only'])->toBeTrue();
expect($body['data']['summary'])->toHaveKeys(['total', 'applications', 'servers']);
expect($body['data']['samples'])->toHaveKeys(['applications', 'servers', 'services', 'databases']);
});
test('control and deploy require deploy ability', function () {
$denied = mcpReadCall('control', [
'resource' => 'application',
'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');
$deployDenied = mcpReadCall('deploy', ['uuid' => $this->application->uuid]);
expect($deployDenied->json('result.isError'))->toBeTrue();
});
test('control stop requires confirm', function () {
$token = test()->user->createToken('mcp-deploy', ['read', 'deploy'])->plainTextToken;
$response = test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$token,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => 'control',
'arguments' => (object) [
'resource' => 'application',
'action' => 'stop',
'uuid' => $this->application->uuid,
],
],
]);
expect($response->json('result.isError'))->toBeTrue();
expect($response->json('result.content.0.text'))->toContain('confirm=true');
});
test('team member with deploy ability cannot call lifecycle tools', function () {
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
$token = $this->user->createToken('mcp-member-deploy', ['read', 'deploy'])->plainTextToken;
$response = test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$token,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => 'control',
'arguments' => (object) [
'resource' => 'application',
'action' => 'start',
'uuid' => $this->application->uuid,
],
],
]);
// Middleware returns a JSON-RPC error envelope (HTTP 200) for MCP clients.
$response->assertOk();
expect($response->json('jsonrpc'))->toBe('2.0');
expect($response->json('error.message') ?? $response->json('result.content.0.text') ?? '')
->toMatch('/team role|Missing required/i');
});
test('control start with deploy ability queues application deployment', function () {
Bus::fake();
$token = $this->user->createToken('mcp-deploy-start', ['read', 'deploy'])->plainTextToken;
$response = test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$token,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => 'control',
'arguments' => (object) [
'resource' => 'application',
'action' => 'start',
'uuid' => $this->application->uuid,
],
],
]);
$response->assertOk();
expect($response->json('result.isError'))->toBeFalse();
$body = mcpReadJson($response);
expect($body['data']['ok'])->toBeTrue()
->and($body['data']['action'])->toBe('start')
->and($body['data']['deployment_uuid'])->not->toBeEmpty();
});
test('deploy tool queues application deployment', function () {
Bus::fake();
$token = $this->user->createToken('mcp-deploy-tool', ['read', 'deploy'])->plainTextToken;
$response = test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$token,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => 'deploy',
'arguments' => (object) [
'uuid' => $this->application->uuid,
'force' => false,
],
],
]);
$response->assertOk();
expect($response->json('result.isError'))->toBeFalse();
$body = mcpReadJson($response);
expect($body['data']['ok'])->toBeTrue()
->and($body['data']['deployment_uuid'])->not->toBeEmpty();
expect(ApplicationDeploymentQueue::where('deployment_uuid', $body['data']['deployment_uuid'])->exists())->toBeTrue();
});
test('cancel_deployment cancels team deployment and rejects other team', function () {
// Avoid real SSH via instant_remote_process during cancellation cleanup.
Process::fake([
'*' => Process::result(output: ''),
]);
Queue::fake();
$deployment = ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'dep-cancel-'.fake()->uuid(),
'status' => 'in_progress',
'server_id' => $this->server->id,
'application_name' => $this->application->name,
'server_name' => $this->server->name,
'commit' => 'abc',
'current_process_id' => '12345',
]);
$nextDeployment = ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'dep-next-'.fake()->uuid(),
'status' => 'queued',
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'application_name' => $this->application->name,
'server_name' => $this->server->name,
'commit' => 'def',
'pull_request_id' => 0,
]);
$token = $this->user->createToken('mcp-cancel', ['read', 'deploy'])->plainTextToken;
$ok = test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$token,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => 'cancel_deployment',
'arguments' => (object) ['uuid' => $deployment->deployment_uuid],
],
]);
$ok->assertOk();
expect($ok->json('result.isError'))->toBeFalse();
$body = mcpReadJson($ok);
expect($body['data']['ok'])->toBeTrue()
->and($body['data']['status'])->toBe('cancelled-by-user');
expect($deployment->fresh()->status)->toBe('cancelled-by-user');
expect($nextDeployment->fresh()->status)->toBe('in_progress');
Queue::assertPushed(ApplicationDeploymentJob::class, fn (ApplicationDeploymentJob $job) => $job->application_deployment_queue_id === $nextDeployment->id);
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnv = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
$otherApp = Application::factory()->create([
'environment_id' => $otherEnv->id,
'destination_id' => $otherDest->id,
'destination_type' => $otherDest->getMorphClass(),
]);
$otherDep = ApplicationDeploymentQueue::create([
'application_id' => $otherApp->id,
'deployment_uuid' => 'dep-other-cancel-'.fake()->uuid(),
'status' => 'in_progress',
'server_id' => $otherServer->id,
'application_name' => $otherApp->name,
'server_name' => $otherServer->name,
]);
$denied = test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$token,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => 'cancel_deployment',
'arguments' => (object) ['uuid' => $otherDep->deployment_uuid],
],
]);
expect($denied->json('result.isError'))->toBeTrue();
expect($otherDep->fresh()->status)->toBe('in_progress');
});
test('cancel_deployment rejects other team deployment even on owned server', function () {
// Shared-server case: caller's team owns the host server, but the application belongs to another team.
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnv = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$otherApp = Application::factory()->create([
'environment_id' => $otherEnv->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$sharedServerDep = ApplicationDeploymentQueue::create([
'application_id' => $otherApp->id,
'deployment_uuid' => 'dep-shared-server-cancel-'.fake()->uuid(),
'status' => 'in_progress',
'server_id' => $this->server->id,
'application_name' => $otherApp->name,
'server_name' => $this->server->name,
]);
$token = $this->user->createToken('mcp-shared-server-cancel', ['read', 'deploy'])->plainTextToken;
$denied = test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$token,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => 'cancel_deployment',
'arguments' => (object) ['uuid' => $sharedServerDep->deployment_uuid],
],
]);
expect($denied->json('result.isError'))->toBeTrue();
expect($sharedServerDep->fresh()->status)->toBe('in_progress');
});
test('cancel_deployment updates only a still cancellable deployment', function () {
// Avoid real SSH via instant_remote_process during cancellation cleanup.
Process::fake([
'*' => Process::result(output: ''),
]);
$deployment = ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'dep-atomic-cancel-'.fake()->uuid(),
'status' => 'in_progress',
'server_id' => $this->server->id,
'application_name' => $this->application->name,
'server_name' => $this->server->name,
]);
$updates = [];
DB::listen(function ($query) use (&$updates) {
if (str_starts_with(strtolower(ltrim($query->sql)), 'update')) {
$updates[] = strtolower($query->sql);
}
});
$token = $this->user->createToken('mcp-atomic-cancel', ['read', 'deploy'])->plainTextToken;
$response = test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$token,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => 'cancel_deployment',
'arguments' => (object) ['uuid' => $deployment->deployment_uuid],
],
]);
$response->assertOk();
expect(collect($updates)->contains(
fn (string $sql) => str_contains($sql, 'application_deployment_queues')
&& str_contains($sql, 'status')
&& str_contains($sql, ' in '),
))->toBeTrue();
});
test('MCP resources list includes overview and application template', function () {
$token = mcpReadToken();
$response = test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$token,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'resources/list',
'params' => (object) [],
]);
$response->assertOk();
$uris = collect($response->json('result.resources'))->pluck('uri')->filter()->all();
$templates = collect($response->json('result.resources'))->pluck('uriTemplate')->filter()->all();
// Static resource may appear under resources; templates under list or templates/list depending on server.
$all = collect($uris)->merge($templates)->implode(' ');
expect($all)->toContain('coolify://');
});
test('MCP overview resource returns batched project resource counts', function () {
StandalonePostgresql::create([
'name' => 'overview-postgres',
'postgres_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
Service::create([
'name' => 'overview-service',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'docker_compose_raw' => 'services: {}',
]);
$token = mcpReadToken();
$response = test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$token,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'resources/read',
'params' => [
'uri' => 'coolify://overview',
],
]);
$response->assertOk();
$text = collect($response->json('result.contents'))->pluck('text')->first();
expect($text)->not->toBeNull();
$body = json_decode($text, true);
expect($body)->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts']);
expect($body['counts']['projects'])->toBe(1);
$project = collect($body['projects'])->firstWhere('uuid', $this->project->uuid);
expect($project)->not->toBeNull();
expect($project['counts'])->toMatchArray([
'applications' => 1,
'services' => 1,
'databases' => 1,
]);
});
test('get_deployment and cancel_deployment work for soft-deleted applications', function () {
Process::fake([
'*' => Process::result(output: ''),
]);
$deployment = ApplicationDeploymentQueue::create([
'application_id' => $this->application->id,
'deployment_uuid' => 'dep-soft-delete-'.fake()->uuid(),
'status' => 'in_progress',
'server_id' => $this->server->id,
'application_name' => $this->application->name,
'server_name' => $this->server->name,
'commit' => 'abc123',
]);
$deployToken = $this->user->createToken('mcp-soft-cancel', ['read', 'deploy'])->plainTextToken;
$this->application->delete();
expect(Application::withTrashed()->find($this->application->id))->not->toBeNull();
expect(Application::find($this->application->id))->toBeNull();
// get_deployment still resolves soft-deleted applications for the team.
$get = test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$deployToken,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => 'get_deployment',
'arguments' => (object) ['uuid' => $deployment->deployment_uuid],
],
]);
$get->assertOk();
expect($get->json('result.isError'))->toBeFalse();
$getBody = mcpReadJson($get);
expect($getBody['data']['deployment_uuid'])->toBe($deployment->deployment_uuid)
->and($getBody['data']['application_uuid'])->toBe($this->application->uuid);
$cancel = test()->withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'Authorization' => 'Bearer '.$deployToken,
])->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => 'cancel_deployment',
'arguments' => (object) ['uuid' => $deployment->deployment_uuid],
],
]);
$cancel->assertOk();
expect($cancel->json('result.isError'))->toBeFalse();
expect($deployment->fresh()->status)->toBe('cancelled-by-user');
});
test('list_databases paginates at the query layer', function () {
foreach (['alpha-db', 'beta-db', 'gamma-db'] as $name) {
StandalonePostgresql::create([
'name' => $name,
'status' => 'running:healthy',
'postgres_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
}
$page1 = mcpReadCall('list_databases', ['per_page' => 2, 'page' => 1]);
$page1->assertOk();
$body1 = mcpReadJson($page1);
expect($body1['_pagination']['total'])->toBe(3)
->and($body1['data'])->toHaveCount(2)
->and($body1['data'][0]['name'])->toBe('alpha-db')
->and($body1['data'][1]['name'])->toBe('beta-db');
$page2 = mcpReadCall('list_databases', ['per_page' => 2, 'page' => 2]);
$page2->assertOk();
$body2 = mcpReadJson($page2);
expect($body2['data'])->toHaveCount(1)
->and($body2['data'][0]['name'])->toBe('gamma-db');
$page1Uuids = collect($body1['data'])->pluck('uuid')->all();
$page2Uuids = collect($body2['data'])->pluck('uuid')->all();
expect(array_intersect($page1Uuids, $page2Uuids))->toBe([]);
});
test('list_unhealthy_resources full mode paginates without dropping summary totals', function () {
$this->application->update(['name' => 'AppA', 'status' => 'exited:unhealthy']);
Application::factory()->create([
'name' => 'AppB',
'status' => 'exited:unhealthy',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
Application::factory()->create([
'name' => 'AppC',
'status' => 'exited:unhealthy',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
// Ensure the default server is not counted as unhealthy (factory settings vary).
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
$page1 = mcpReadCall('list_unhealthy_resources', [
'sample_only' => false,
'per_page' => 2,
'page' => 1,
]);
$page1->assertOk();
$body1 = mcpReadJson($page1);
expect($body1['data']['summary']['applications'])->toBe(3)
->and($body1['data']['summary']['servers'])->toBe(0)
->and($body1['_pagination']['total'])->toBe(3)
->and($body1['data']['unhealthy'])->toHaveCount(2);
$page1Names = collect($body1['data']['unhealthy'])->pluck('name')->all();
expect($page1Names)->toBe(['AppA', 'AppB']);
$page2 = mcpReadCall('list_unhealthy_resources', [
'sample_only' => false,
'per_page' => 2,
'page' => 2,
]);
$page2->assertOk();
$body2 = mcpReadJson($page2);
expect($body2['data']['unhealthy'])->toHaveCount(1)
->and($body2['data']['unhealthy'][0]['name'])->toBe('AppC');
});
test('list_unhealthy_resources full mode paginates services without dropping summary totals', function () {
// Keep apps/servers healthy so the page window is pure services.
$this->application->update(['status' => 'running:healthy']);
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
// Empty services have no running status → treated as unhealthy by the status scan.
foreach (['SvcA', 'SvcB', 'SvcC', 'SvcD', 'SvcE'] as $name) {
Service::factory()->create([
'name' => $name,
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
}
$page1 = mcpReadCall('list_unhealthy_resources', [
'sample_only' => false,
'per_page' => 2,
'page' => 1,
]);
$page1->assertOk();
$body1 = mcpReadJson($page1);
expect($body1['data']['summary']['services'])->toBe(5)
->and($body1['data']['summary']['total'])->toBe(5)
->and($body1['_pagination']['total'])->toBe(5)
->and($body1['data']['unhealthy'])->toHaveCount(2)
->and(collect($body1['data']['unhealthy'])->pluck('name')->all())->toBe(['SvcA', 'SvcB'])
->and(collect($body1['data']['unhealthy'])->pluck('type')->unique()->all())->toBe(['service']);
$page3 = mcpReadCall('list_unhealthy_resources', [
'sample_only' => false,
'per_page' => 2,
'page' => 3,
]);
$page3->assertOk();
$body3 = mcpReadJson($page3);
expect($body3['data']['summary']['services'])->toBe(5)
->and($body3['data']['unhealthy'])->toHaveCount(1)
->and($body3['data']['unhealthy'][0]['name'])->toBe('SvcE');
});
test('get_service_database returns a field whitelist and is team scoped', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'docker_compose_raw' => "services:\n db:\n image: postgres:16\n",
]);
$db = ServiceDatabase::create([
'uuid' => (string) Str::uuid(),
'name' => 'db',
'human_name' => 'Database',
'description' => 'Primary DB',
'service_id' => $service->id,
'image' => 'postgres:16',
'status' => 'running:healthy',
]);
$response = mcpReadCall('get_service_database', [
'service_uuid' => $service->uuid,
'uuid' => $db->uuid,
]);
$response->assertOk();
$data = mcpReadJson($response)['data'];
expect($data['uuid'])->toBe($db->uuid)
->and($data['service_uuid'])->toBe($service->uuid)
->and($data['name'])->toBe('db')
->and($data)->toHaveKeys([
'uuid',
'service_uuid',
'name',
'human_name',
'description',
'status',
'image',
'created_at',
'updated_at',
])
->and($data)->not->toHaveKey('id')
->and($data)->not->toHaveKey('service_id')
->and($data)->not->toHaveKey('is_migrated');
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnv = $otherProject->environments()->first()
?? Environment::factory()->create(['project_id' => $otherProject->id]);
$otherDest = StandaloneDocker::query()->where('server_id', $otherServer->id)->firstOrFail();
$otherService = Service::factory()->create([
'environment_id' => $otherEnv->id,
'server_id' => $otherServer->id,
'destination_id' => $otherDest->id,
'destination_type' => $otherDest->getMorphClass(),
]);
$otherDb = ServiceDatabase::create([
'uuid' => (string) Str::uuid(),
'name' => 'theirs',
'service_id' => $otherService->id,
'image' => 'postgres:16',
]);
$denied = mcpReadCall('get_service_database', [
'service_uuid' => $otherService->uuid,
'uuid' => $otherDb->uuid,
]);
expect($denied->json('result.isError'))->toBeTrue();
});
test('list_scheduled_tasks omits command without sensitive read ability', function () {
ScheduledTask::create([
'uuid' => (string) Str::uuid(),
'name' => 'nightly-backup',
'command' => 'pg_dump --flag=redactme01',
'frequency' => '0 2 * * *',
'enabled' => true,
'timeout' => 3600,
'team_id' => $this->team->id,
'application_id' => $this->application->id,
]);
$readOnly = mcpReadCall('list_scheduled_tasks', [
'resource' => 'application',
'uuid' => $this->application->uuid,
]);
$readOnly->assertOk();
$tasks = mcpReadJson($readOnly)['data']['tasks'];
expect($tasks)->toHaveCount(1)
->and($tasks[0]['name'])->toBe('nightly-backup')
->and($tasks[0]['command_included'])->toBeFalse()
->and($tasks[0])->not->toHaveKey('command');
expect(json_encode($tasks))->not->toContain('redactme01');
$sensitive = mcpSensitiveReadCall('list_scheduled_tasks', [
'resource' => 'application',
'uuid' => $this->application->uuid,
]);
$sensitive->assertOk();
expect($sensitive->json('result.isError'))->toBeFalse();
$sensitiveBody = mcpReadJson($sensitive);
expect($sensitiveBody['data']['command_included'])->toBeTrue();
$sensitiveTasks = $sensitiveBody['data']['tasks'];
expect($sensitiveTasks[0]['command_included'])->toBeTrue()
->and($sensitiveTasks[0]['command'])->toContain('pg_dump');
});
test('list_scheduled_task_executions returns newest first across pages', function () {
$task = ScheduledTask::create([
'uuid' => (string) Str::uuid(),
'name' => 'exec-history',
'command' => 'echo ok',
'frequency' => '0 1 * * *',
'enabled' => true,
'timeout' => 60,
'team_id' => $this->team->id,
'application_id' => $this->application->id,
]);
$older = ScheduledTaskExecution::create([
'scheduled_task_id' => $task->id,
'status' => 'success',
'message' => 'older-run',
'started_at' => now()->subHours(2),
'finished_at' => now()->subHours(2)->addMinute(),
'created_at' => now()->subHours(2),
'updated_at' => now()->subHours(2),
]);
$newer = ScheduledTaskExecution::create([
'scheduled_task_id' => $task->id,
'status' => 'failed',
'message' => 'newer-run',
'started_at' => now()->subHour(),
'finished_at' => now()->subHour()->addMinute(),
'created_at' => now()->subHour(),
'updated_at' => now()->subHour(),
]);
$page1 = mcpReadCall('list_scheduled_task_executions', [
'resource' => 'application',
'uuid' => $this->application->uuid,
'task_uuid' => $task->uuid,
'page' => 1,
'per_page' => 1,
]);
$page1->assertOk();
$page1Body = mcpReadJson($page1);
expect($page1Body['data']['executions'])->toHaveCount(1)
->and($page1Body['data']['message_included'])->toBeFalse()
->and($page1Body['data']['executions'][0])->not->toHaveKey('message')
->and($page1Body['data']['executions'][0]['status'])->toBe('failed')
->and($page1Body['_pagination']['total'])->toBe(2);
$page2 = mcpReadCall('list_scheduled_task_executions', [
'resource' => 'application',
'uuid' => $this->application->uuid,
'task_uuid' => $task->uuid,
'page' => 2,
'per_page' => 1,
]);
$page2->assertOk();
$page2Body = mcpReadJson($page2);
expect($page2Body['data']['executions'][0]['status'])->toBe('success')
->and($page2Body['data']['executions'][0])->not->toHaveKey('message');
$sensitive = mcpSensitiveReadCall('list_scheduled_task_executions', [
'resource' => 'application',
'uuid' => $this->application->uuid,
'task_uuid' => $task->uuid,
'page' => 1,
'per_page' => 1,
]);
$sensitive->assertOk();
$sensitiveBody = mcpReadJson($sensitive);
expect($sensitiveBody['data']['message_included'])->toBeTrue()
->and($sensitiveBody['data']['executions'][0]['message'])->toBe('newer-run');
// Silence unused variable analysis when timestamps are forced via create attributes.
expect($older->id)->not->toBe($newer->id);
});
test('list_scheduled_task_executions redacts secret-like values in messages with sensitive read', function () {
$task = ScheduledTask::create([
'uuid' => (string) Str::uuid(),
'name' => 'exec-redact',
'command' => 'echo ok',
'frequency' => '0 1 * * *',
'enabled' => true,
'timeout' => 60,
'team_id' => $this->team->id,
'application_id' => $this->application->id,
]);
ScheduledTaskExecution::create([
'scheduled_task_id' => $task->id,
'status' => 'failed',
'message' => "backup failed password=redactme01\n",
'started_at' => now()->subMinute(),
'finished_at' => now(),
]);
$response = mcpSensitiveReadCall('list_scheduled_task_executions', [
'resource' => 'application',
'uuid' => $this->application->uuid,
'task_uuid' => $task->uuid,
]);
$response->assertOk();
$body = mcpReadJson($response);
$message = $body['data']['executions'][0]['message'] ?? '';
expect($body['data']['message_included'])->toBeTrue()
->and($message)->not->toContain('redactme01')
->and($message)->toContain('password=')
->and($message)->toContain(REDACTED);
});
test('get_logs redacts secret-like values in container output', function () {
// Preflight fails for non-running apps, so exercise redaction via the shared helper path
// through get_deployment (already covered) and unit-level BuildsResponse redaction.
// Use low-entropy test markers so secret scanners do not flag fixtures.
$trait = new class
{
use BuildsResponse;
public function redact(string $text): string
{
return $this->redactLogText($text);
}
};
$redacted = $trait->redact("boot ok\npassword=redactme01\nAPI_TOKEN=redactme02\n");
expect($redacted)->toContain('boot ok')
->and($redacted)->not->toContain('redactme01')
->and($redacted)->not->toContain('redactme02')
->and($redacted)->toContain('password=')
->and($redacted)->toContain(REDACTED);
});
test('redactLogText redacts JSON secret fields in log lines', function () {
$trait = new class
{
use BuildsResponse;
public function redact(string $text): string
{
return $this->redactLogText($text);
}
};
$jsonLine = '{"token":"redactme01","API_KEY":"redactme02","status":"ok"}';
$redacted = $trait->redact("request failed: {$jsonLine}");
expect($redacted)->toContain('status')
->and($redacted)->toContain('ok')
->and($redacted)->not->toContain('redactme01')
->and($redacted)->not->toContain('redactme02')
->and($redacted)->toContain('token=')
->and($redacted)->toContain('API_KEY=')
->and($redacted)->toContain(REDACTED);
// Shell-style still works alongside JSON
$mixed = $trait->redact('password=redactme01 {"client_secret":"redactme02"} export DB_PASSWORD=redactme03');
expect($mixed)->not->toContain('redactme01')
->and($mixed)->not->toContain('redactme02')
->and($mixed)->not->toContain('redactme03')
->and($mixed)->toContain(REDACTED);
});