diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php index d77455b170..eea345315e 100644 --- a/app/Mcp/Concerns/ResolvesTeam.php +++ b/app/Mcp/Concerns/ResolvesTeam.php @@ -80,6 +80,20 @@ trait ResolvesTeam return $this->mcpError($request, "{$action} requires confirm=true."); } + /** + * Same rule as the REST API (ApiSensitiveData): a root or read:sensitive token of a team admin or owner. + */ + protected function canReadSensitiveData(Request $request): bool + { + $user = $request->user(); + $token = $user?->currentAccessToken(); + if (! $token || ! ($token->can('root') || $token->can('read:sensitive'))) { + return false; + } + + return $token->team_id !== null && $user->isAdminOfTeam((int) $token->team_id); + } + protected function resolveTeamId(Request $request): ?int { $user = $request->user(); diff --git a/app/Mcp/Tools/GetApplication.php b/app/Mcp/Tools/GetApplication.php index 2e45eec30a..b473cf24fc 100644 --- a/app/Mcp/Tools/GetApplication.php +++ b/app/Mcp/Tools/GetApplication.php @@ -16,7 +16,7 @@ class GetApplication extends Tool { protected string $name = 'get_application'; - protected string $description = 'Get full details for a single application by UUID.'; + protected string $description = 'Get full details for a single application by UUID. Commands and custom configuration (build, start, deployment, and healthcheck commands, docker run options, nginx configuration) need read:sensitive.'; use BuildsResponse; use ResolvesTeam; @@ -47,8 +47,11 @@ class GetApplication extends Tool $application->setRelations([]); $application->makeHidden(['destination', 'source', 'additional_servers', 'environment', 'tags', 'environmentVariables']); + $visibleSensitiveKeys = $this->canReadSensitiveData($request) ? Application::COMMAND_AND_CONFIGURATION_ATTRIBUTES : []; + $application->makeVisible($visibleSensitiveKeys); + return $this->mcpSuccess($request, $this->respond( - $this->scrubSensitive($application->toArray()), + $this->scrubSensitive($application->toArray(), $visibleSensitiveKeys), $this->actionsForApplication($uuid, $application->status), ), ['resource_uuid' => $uuid]); } diff --git a/app/Mcp/Tools/GetDatabase.php b/app/Mcp/Tools/GetDatabase.php index 63b1f0e85d..cc189daee3 100644 --- a/app/Mcp/Tools/GetDatabase.php +++ b/app/Mcp/Tools/GetDatabase.php @@ -15,7 +15,7 @@ class GetDatabase extends Tool { protected string $name = 'get_database'; - protected string $description = 'Get full details for a standalone database by UUID. Detects type across postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse, sqlite.'; + protected string $description = 'Get full details for a standalone database by UUID. Detects type across postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse, sqlite. Custom configuration (database config file, docker run options) needs read:sensitive.'; use BuildsResponse; use ResolvesTeam; @@ -45,8 +45,11 @@ class GetDatabase extends Tool $database->setRelations([]); $database->makeHidden(['destination', 'source', 'environment', 'environment_variables', 'environment_variables_preview']); + $visibleSensitiveKeys = $this->canReadSensitiveData($request) ? $database::CONFIGURATION_ATTRIBUTES : []; + $database->makeVisible($visibleSensitiveKeys); + return $this->mcpSuccess($request, $this->respond( - $this->scrubSensitive($database->toArray()), + $this->scrubSensitive($database->toArray(), $visibleSensitiveKeys), $this->actionsForDatabase($uuid, $database->status ?? null), ), ['resource_uuid' => $uuid]); } diff --git a/tests/Feature/Mcp/McpReadToolsTest.php b/tests/Feature/Mcp/McpReadToolsTest.php index eec03a226d..d919540bd3 100644 --- a/tests/Feature/Mcp/McpReadToolsTest.php +++ b/tests/Feature/Mcp/McpReadToolsTest.php @@ -644,6 +644,66 @@ test('get_application omits free-form configuration that can contain secrets', f ->and($raw)->not->toContain('custom_nginx_configuration'); }); +function mcpReadCallWithAbilities(array $abilities, string $name, array $arguments = []) +{ + auth()->forgetGuards(); + + return test()->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json, text/event-stream', + 'Authorization' => 'Bearer '.test()->user->createToken('mcp-abilities', $abilities)->plainTextToken, + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => $name, 'arguments' => (object) $arguments], + ]); +} + +test('get_application returns commands and configuration to a read:sensitive or root token of an owner', function (array $abilities) { + $this->application->update([ + 'build_command' => 'npm run build', + 'post_deployment_command' => 'php artisan migrate', + 'custom_docker_run_options' => '--cap-add SYS_ADMIN', + ]); + + $response = mcpReadCallWithAbilities($abilities, 'get_application', ['uuid' => $this->application->uuid]); + $response->assertOk(); + + expect(mcpReadJson($response)['data']) + ->toMatchArray([ + 'build_command' => 'npm run build', + 'post_deployment_command' => 'php artisan migrate', + 'custom_docker_run_options' => '--cap-add SYS_ADMIN', + ]) + ->not->toHaveKey('git_full_url'); +})->with([ + 'read:sensitive' => [['read', 'read:sensitive']], + 'root' => [['root']], +]); + +test('get_database returns custom configuration to a read:sensitive token of an owner', function () { + $database = StandalonePostgresql::create([ + 'name' => 'Configured database', + 'postgres_password' => 'database-password', + 'postgres_conf' => 'max_connections = 200', + 'custom_docker_run_options' => '--shm-size 1g', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = mcpSensitiveReadCall('get_database', ['uuid' => $database->uuid]); + $response->assertOk(); + + expect(mcpReadJson($response)['data']) + ->toMatchArray([ + 'postgres_conf' => 'max_connections = 200', + 'custom_docker_run_options' => '--shm-size 1g', + ]) + ->not->toHaveKey('postgres_password'); +}); + test('get_database omits configuration blobs that can contain secrets', function () { $database = StandalonePostgresql::create([ 'name' => 'Secret-bearing config',