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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user