Files
coolify/app/Mcp/Tools/Control.php
T
Andras BacsaiandClaude Opus 5.5 bb6dfd147e fix(mcp): queue MCP deployments behind a deployment in progress
The MCP deploy tool and control start skipped the deployment queue, so
two rolling updates of one application could run at once and remove the
new container of each other. A rolling update now also removes only the
containers that ran before it started.

Closes #12442

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:12:58 +01:00

264 lines
9.8 KiB
PHP

<?php
namespace App\Mcp\Tools;
use App\Actions\Application\StopApplication;
use App\Actions\Database\RestartDatabase;
use App\Actions\Database\StartDatabase;
use App\Actions\Database\StopDatabase;
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;
use App\Models\EnvironmentVariable;
use App\Support\ResourceStartActivity;
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, 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)) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return $this->mcpError($request, 'Invalid token.');
}
$resourceType = $request->get('resource');
$action = $request->get('action');
$uuid = $request->get('uuid');
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.');
}
if (! is_string($uuid) || $uuid === '') {
return $this->mcpError($request, 'uuid argument is required.');
}
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);
if (! $resource) {
return $this->mcpError($request, ucfirst($resourceType)." [{$uuid}] not found.", ['resource_uuid' => $uuid]);
}
try {
$result = match ($resourceType) {
'application' => $this->controlApplication($resource, $action),
'database' => $this->controlDatabase($resource, $action),
'service' => $this->controlService($resource, $action),
};
} catch (\Throwable $e) {
$secrets = EnvironmentVariable::logRedactionSecretsFor($resource);
return $this->mcpError($request, $this->redactLogText($e->getMessage(), $secrets), ['resource_uuid' => $uuid]);
}
auditLog('mcp.control', [
'team_id' => $teamId,
'resource' => $resourceType,
'action' => $action,
'resource_uuid' => $uuid,
]);
return $this->mcpSuccess($request, $this->respond([
'ok' => true,
'resource' => $resourceType,
'uuid' => $uuid,
'action' => $action,
...$result,
]), ['resource_uuid' => $uuid, 'action' => $action]);
}
/**
* @return array<string, mixed>
*/
private function controlApplication(Application $application, string $action): array
{
if ($action === 'stop') {
StopApplication::dispatch($application, false, true);
return ['message' => 'Application stopping request queued.'];
}
$deploymentUuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deploymentUuid,
force_rebuild: false,
restart_only: $action === 'restart',
is_mcp: true,
);
if (($result['status'] ?? null) === 'skipped' || ($result['status'] ?? null) === 'queue_full') {
return [
'message' => $result['message'] ?? 'Deployment skipped.',
'deployment_uuid' => $result['deployment_uuid'] ?? null,
];
}
return [
'message' => $action === 'restart' ? 'Restart request queued.' : 'Deployment request queued.',
'deployment_uuid' => $deploymentUuid,
'next_tools' => [
['tool' => 'get_deployment', 'args' => ['uuid' => $deploymentUuid], 'hint' => 'Poll deployment status'],
],
];
}
/**
* @return array<string, mixed>
*/
private function controlDatabase(mixed $database, string $action): array
{
if ($action === 'stop') {
StopDatabase::dispatch($database);
return ['message' => 'Database stopping request queued.'];
}
if ($action === 'start' && str($database->status ?? '')->contains('running')) {
return ['message' => 'Database is already running.'];
}
$prerequisiteError = StartDatabase::prerequisiteError($database);
if ($prerequisiteError !== null) {
throw new \RuntimeException($prerequisiteError);
}
$reservation = StartDatabase::reserveOperation($database);
if ($reservation === null) {
throw new \RuntimeException(ResourceStartActivity::DATABASE_OPERATION_IN_PROGRESS_MESSAGE);
}
if ($action === 'restart') {
StartDatabase::dispatchReserved(RestartDatabase::class, $database, $reservation);
return ['message' => 'Database restart request queued.'];
}
StartDatabase::dispatchReserved(StartDatabase::class, $database, $reservation);
return ['message' => 'Database starting request queued.'];
}
/**
* @return array<string, mixed>
*/
private function controlService(mixed $service, string $action): array
{
if ($action === 'stop') {
StopService::dispatch($service);
return ['message' => 'Service stopping request queued.'];
}
if ($action === 'start' && str($service->status ?? '')->contains('running')) {
return ['message' => 'Service is already running.'];
}
if ($action === 'restart') {
RestartService::dispatch($service, false);
return ['message' => 'Service restart request queued.'];
}
StartService::dispatch($service);
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 | service_application | service_database')->required(),
'action' => $schema->string()->description('start | stop | restart')->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.'),
];
}
}