feat(mcp): apps.create image/composeRaw pathways + apps.volumes.list/wipe

Third-party apps (Twenty, Directus, Cal.com, Plane…) should never need
a Gitea repo. This adds two new apps.create pathways:

  image: "twentyhq/twenty:1.23.0"   → Coolify /applications/dockerimage
  composeRaw: "services:\n..."       → Coolify /applications/dockercompose

No repo is created, no git clone, no PAT embedding. Agents can fetch
the official docker-compose.yml and pass it inline, or just give an
image name. Pathway 1 (repo) is unchanged.

Also adds volume management tools so agents can self-recover from the
most common compose failure (stale DB volume blocking fresh migrations):

  apps.volumes.list { uuid }                        → list volumes + sizes
  apps.volumes.wipe { uuid, volume, confirm }       → stop containers,
                                                       rm volume, done

Both volume tools go through the same vibn-logs SSH channel. The wipe
tool requires confirm == volume name to prevent accidents and verifies
the volume belongs to the target app (uuid in name).

lib/coolify.ts: createDockerImageApp + createDockerComposeApp helpers,
  dockerimage added to CoolifyBuildPack union.
app/api/mcp/route.ts: resolveFqdn + applyEnvsAndDeploy extracted as
  shared helpers; toolAppsCreate now dispatches on image/composeRaw/repo.
  toolAppsVolumesList + toolAppsVolumesWipe added.
  sq() moved to module scope (shared by exec + volumes tools).
  Version bumped to 2.3.0.

Made-with: Cursor
This commit is contained in:
2026-04-23 16:21:28 -07:00
parent 8c83f8c490
commit 6d71c63053
2 changed files with 342 additions and 54 deletions

View File

@@ -28,7 +28,8 @@ import {
import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage';
import { getApplicationRuntimeLogs } from '@/lib/coolify-logs';
import { execInCoolifyApp } from '@/lib/coolify-exec';
import { isCoolifySshConfigured } from '@/lib/coolify-ssh';
import { isCoolifySshConfigured, runOnCoolifyHost } from '@/lib/coolify-ssh';
import { listContainersForApp } from '@/lib/coolify-containers';
import {
deployApplication,
getApplicationInProject,
@@ -41,6 +42,8 @@ import {
deleteApplicationEnv,
// Phase 4 ── create/update/delete + domains + databases + services
createPublicApp,
createDockerImageApp,
createDockerComposeApp,
updateApplication,
deleteApplication,
setApplicationDomains,
@@ -74,7 +77,7 @@ const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
export async function GET() {
return NextResponse.json({
name: 'vibn-mcp',
version: '2.2.0',
version: '2.3.0',
authentication: {
scheme: 'Bearer',
tokenPrefix: 'vibn_sk_',
@@ -102,6 +105,8 @@ export async function GET() {
'apps.domains.set',
'apps.logs',
'apps.exec',
'apps.volumes.list',
'apps.volumes.wipe',
'apps.envs.list',
'apps.envs.upsert',
'apps.envs.delete',
@@ -198,6 +203,10 @@ export async function POST(request: Request) {
return await toolAppsLogs(principal, params);
case 'apps.exec':
return await toolAppsExec(principal, params);
case 'apps.volumes.list':
return await toolAppsVolumesList(principal, params);
case 'apps.volumes.wipe':
return await toolAppsVolumesWipe(principal, params);
case 'databases.list':
return await toolDatabasesList(principal);
@@ -509,6 +518,113 @@ async function toolAppsExec(principal: Principal, params: Record<string, any>) {
}
}
// ── Volume tools ────────────────────────────────────────────────────────
/**
* apps.volumes.list — list Docker volumes that belong to an app.
* Returns name, size (bytes), and which containers are currently
* using each volume (if any are running).
*/
async function toolAppsVolumesList(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
if (!isCoolifySshConfigured()) {
return NextResponse.json({ error: 'apps.volumes.list requires SSH to the Coolify host' }, { status: 501 });
}
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
await getApplicationInProject(appUuid, projectUuid);
const res = await runOnCoolifyHost(
`docker volume ls --filter name=${sq(appUuid)} --format '{{.Name}}' | xargs -r -I{} sh -c 'echo "{}|$(docker volume inspect {} --format "{{.UsageData.Size}}" 2>/dev/null || echo -1)"'`,
{ timeoutMs: 12_000 },
);
if (res.code !== 0) {
return NextResponse.json({ error: `docker volume ls failed: ${res.stderr.trim()}` }, { status: 502 });
}
const volumes = res.stdout
.split('\n')
.map(l => l.trim())
.filter(Boolean)
.map(l => {
const [name, sizeStr] = l.split('|');
const sizeBytes = parseInt(sizeStr ?? '-1', 10);
return { name, sizeBytes: isNaN(sizeBytes) ? -1 : sizeBytes };
});
return NextResponse.json({ result: { volumes } });
}
/**
* apps.volumes.wipe — destroy a Docker volume for this app.
*
* This is a destructive, irreversible operation. The agent MUST pass
* `confirm: "<volume-name>"` exactly matching the volume name, to
* prevent accidents. All containers using the volume are stopped and
* removed first (Coolify will restart them on the next deploy).
*
* Typical use: wipe a stale Postgres data volume before redeploying
* so the database is initialised fresh.
*/
async function toolAppsVolumesWipe(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
if (!isCoolifySshConfigured()) {
return NextResponse.json({ error: 'apps.volumes.wipe requires SSH to the Coolify host' }, { status: 501 });
}
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
const volumeName = String(params.volume ?? '').trim();
const confirm = String(params.confirm ?? '').trim();
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
if (!volumeName) return NextResponse.json({ error: 'Param "volume" is required (exact volume name from apps.volumes.list)' }, { status: 400 });
if (confirm !== volumeName) {
return NextResponse.json(
{ error: `Param "confirm" must equal the exact volume name "${volumeName}" to proceed` },
{ status: 400 },
);
}
// Security check: volume must belong to this app (name must contain the uuid)
if (!volumeName.includes(appUuid)) {
return NextResponse.json(
{ error: `Volume "${volumeName}" does not appear to belong to app ${appUuid}` },
{ status: 403 },
);
}
await getApplicationInProject(appUuid, projectUuid);
// Stop + remove all containers using this volume, then remove the volume
const cmd = [
// Stop and remove containers for this app (they'll be recreated on next deploy)
`CONTAINERS=$(docker ps -a --filter name=${sq(appUuid)} --format '{{.Names}}')`,
`[ -n "$CONTAINERS" ] && echo "$CONTAINERS" | xargs docker stop -t 10 || true`,
`[ -n "$CONTAINERS" ] && echo "$CONTAINERS" | xargs docker rm -f || true`,
// Remove the volume
`docker volume rm ${sq(volumeName)}`,
`echo "done"`,
].join(' && ');
const res = await runOnCoolifyHost(cmd, { timeoutMs: 30_000 });
if (res.code !== 0 || !res.stdout.includes('done')) {
return NextResponse.json(
{ error: `Volume removal failed (exit ${res.code}): ${res.stderr.trim() || res.stdout.trim()}` },
{ status: 502 },
);
}
return NextResponse.json({
result: {
wiped: volumeName,
message: 'Volume removed. Trigger apps.deploy to restart the app with a fresh volume.',
},
});
}
function sq(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
async function toolAppsEnvsList(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
@@ -575,19 +691,98 @@ async function toolAppsEnvsDelete(principal: Principal, params: Record<string, a
// Phase 4: apps create/update/delete + domains
// ──────────────────────────────────────────────────
/**
* apps.create — three distinct pathways depending on what you pass:
*
* 1. Gitea repo (existing behaviour — for user-owned custom apps)
* Required: repo
* Optional: branch, buildPack, ports, domain, envs, …
*
* 2. Docker image from a registry (no repo, no build)
* Required: image e.g. "nginx:alpine", "twentyhq/twenty:1.23.0"
* Optional: name, domain, ports, envs
*
* 3. Inline Docker Compose YAML (no repo, no build)
* Required: composeRaw (the full docker-compose.yml contents as a string)
* Optional: name, domain, composeDomains, envs
*
* Pathways 2 and 3 do NOT create a Gitea repo. They deploy directly
* from Docker Hub / any public registry, or from the raw YAML you
* supply. Use these for third-party apps (Twenty, Directus, Cal.com…).
*
* Use pathway 1 for user's own code that lives in the workspace's
* Gitea org.
*/
async function toolAppsCreate(principal: Principal, params: Record<string, any>) {
const ws = principal.workspace;
if (!ws.coolify_project_uuid || !ws.gitea_org) {
if (!ws.coolify_project_uuid) {
return NextResponse.json(
{ error: 'Workspace not fully provisioned (need Coolify project + Gitea org)' },
{ error: 'Workspace not fully provisioned (need Coolify project)' },
{ status: 503 }
);
}
// We clone via HTTPS with the workspace's bot PAT (NOT SSH) — Gitea's
// builtin SSH is on an internal-only port and port 22 hits the host's
// OpenSSH, so SSH clones fail. HTTPS+PAT works in all topologies and
// the PAT is scoped to the org via team membership.
const commonOpts = {
projectUuid: ws.coolify_project_uuid,
serverUuid: ws.coolify_server_uuid ?? undefined,
environmentName: ws.coolify_environment_name,
destinationUuid: ws.coolify_destination_uuid ?? undefined,
isForceHttpsEnabled: true,
instantDeploy: false,
};
// ── Pathway 2: Docker image ───────────────────────────────────────────
if (params.image) {
const image = String(params.image).trim();
const appName = slugify(String(params.name ?? image.split('/').pop()?.split(':')[0] ?? 'app'));
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn;
const created = await createDockerImageApp({
...commonOpts,
image,
name: appName,
portsExposes: String(params.ports ?? '80'),
domains: toDomainsString([fqdn]),
description: params.description ? String(params.description) : undefined,
});
await applyEnvsAndDeploy(created.uuid, params);
return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}` } });
}
// ── Pathway 3: Inline Docker Compose ─────────────────────────────────
if (params.composeRaw) {
const composeRaw = String(params.composeRaw).trim();
const appName = slugify(String(params.name ?? 'app'));
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn;
// composeDomains: array of { service, domain } or derive from fqdn
const composeDomains: Array<{ service: string; domain: string }> =
Array.isArray(params.composeDomains) && params.composeDomains.length > 0
? params.composeDomains
: [{ service: params.composeService ?? 'server', domain: fqdn }];
const created = await createDockerComposeApp({
...commonOpts,
composeRaw,
name: appName,
description: params.description ? String(params.description) : undefined,
composeDomains,
});
await applyEnvsAndDeploy(created.uuid, params);
return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}` } });
}
// ── Pathway 1: Gitea repo (original behaviour) ────────────────────────
if (!ws.gitea_org) {
return NextResponse.json(
{ error: 'Workspace not fully provisioned (need Gitea org). For third-party apps, use `image` or `composeRaw` instead of `repo`.' },
{ status: 503 }
);
}
const botCreds = getWorkspaceBotCredentials(ws);
if (!botCreds) {
return NextResponse.json(
@@ -597,7 +792,12 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
}
const repoIn = String(params.repo ?? '').trim();
if (!repoIn) return NextResponse.json({ error: 'Param "repo" is required' }, { status: 400 });
if (!repoIn) {
return NextResponse.json(
{ error: 'One of `repo`, `image`, or `composeRaw` is required' },
{ status: 400 }
);
}
const parts = repoIn.replace(/\.git$/, '').split('/');
const repoOrg = parts.length === 2 ? parts[0] : ws.gitea_org;
@@ -614,21 +814,11 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
}
const appName = slugify(String(params.name ?? repoName));
const fqdn = String(params.domain ?? '').trim()
? String(params.domain).replace(/^https?:\/\//, '')
: workspaceAppFqdn(ws.slug, appName);
if (!isDomainUnderWorkspace(fqdn, ws.slug)) {
return NextResponse.json(
{ error: `Domain ${fqdn} must end with .${ws.slug}.vibnai.com` },
{ status: 403 }
);
}
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn;
const created = await createPublicApp({
projectUuid: ws.coolify_project_uuid,
serverUuid: ws.coolify_server_uuid ?? undefined,
environmentName: ws.coolify_environment_name,
destinationUuid: ws.coolify_destination_uuid ?? undefined,
...commonOpts,
gitRepository: giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token),
gitBranch: String(params.branch ?? repo.default_branch ?? 'main'),
portsExposes: String(params.ports ?? '3000'),
@@ -636,50 +826,62 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
name: appName,
domains: toDomainsString([fqdn]),
isAutoDeployEnabled: true,
isForceHttpsEnabled: true,
instantDeploy: false,
dockerComposeLocation: params.dockerComposeLocation
? String(params.dockerComposeLocation)
: undefined,
dockerfileLocation: params.dockerfileLocation
? String(params.dockerfileLocation)
: undefined,
dockerComposeLocation: params.dockerComposeLocation ? String(params.dockerComposeLocation) : undefined,
dockerfileLocation: params.dockerfileLocation ? String(params.dockerfileLocation) : undefined,
baseDirectory: params.baseDirectory ? String(params.baseDirectory) : undefined,
});
// Attach envs
if (params.envs && typeof params.envs === 'object') {
for (const [k, v] of Object.entries(params.envs as Record<string, unknown>)) {
if (!/^[A-Z_][A-Z0-9_]*$/i.test(k)) continue;
try {
await upsertApplicationEnv(created.uuid, { key: k, value: String(v) });
} catch (e) {
console.warn('[mcp apps.create] upsert env failed', k, e);
}
}
}
let deploymentUuid: string | null = null;
if (params.instantDeploy !== false) {
try {
const dep = await deployApplication(created.uuid);
deploymentUuid = dep.deployment_uuid ?? null;
} catch (e) {
console.warn('[mcp apps.create] first deploy failed', e);
}
}
const dep = await applyEnvsAndDeploy(created.uuid, params);
return NextResponse.json({
result: {
uuid: created.uuid,
name: appName,
domain: fqdn,
url: `https://${fqdn}`,
deploymentUuid,
deploymentUuid: dep,
},
});
}
/** Resolve fqdn from params.domain or auto-generate. Returns NextResponse on policy error. */
function resolveFqdn(domainParam: unknown, slug: string, appName: string): string | NextResponse {
const fqdn = String(domainParam ?? '').trim()
? String(domainParam).replace(/^https?:\/\//, '')
: workspaceAppFqdn(slug, appName);
if (!isDomainUnderWorkspace(fqdn, slug)) {
return NextResponse.json(
{ error: `Domain ${fqdn} must end with .${slug}.vibnai.com` },
{ status: 403 }
);
}
return fqdn;
}
/** Upsert envs then optionally trigger deploy. Returns deploymentUuid or null. */
async function applyEnvsAndDeploy(
appUuid: string,
params: Record<string, any>,
): Promise<string | null> {
if (params.envs && typeof params.envs === 'object') {
for (const [k, v] of Object.entries(params.envs as Record<string, unknown>)) {
if (!/^[A-Z_][A-Z0-9_]*$/i.test(k)) continue;
try {
await upsertApplicationEnv(appUuid, { key: k, value: String(v) });
} catch (e) {
console.warn('[mcp apps.create] upsert env failed', k, e);
}
}
}
if (params.instantDeploy === false) return null;
try {
const dep = await deployApplication(appUuid);
return dep.deployment_uuid ?? null;
} catch (e) {
console.warn('[mcp apps.create] first deploy failed', e);
return null;
}
}
async function toolAppsUpdate(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;