/** * Vibn MCP HTTP bridge. * * Authenticates via a workspace-scoped `vibn_sk_...` token (session * cookies also work for browser debugging). Every tool call is * executed inside the bound workspace's tenant boundary — Coolify * requests verify the app's project uuid, and git credentials are * pinned to the workspace's Gitea org/bot. * * Exposed tools are a stable subset of the Vibn REST API so agents * have one well-typed entry point regardless of deployment host. * * Protocol notes: * - This is a thin, JSON-over-HTTP MCP shim. The `mcp.json` in a * user's Cursor config points at this URL and stores the bearer * token. We keep the shape compatible with MCP clients that * speak `{ action, params }` calls. */ import { NextResponse } from 'next/server'; import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; import { getWorkspaceBotCredentials, ensureWorkspaceProvisioned } from '@/lib/workspaces'; import { ensureWorkspaceGcsProvisioned, getWorkspaceGcsState, getWorkspaceGcsHmacCredentials, } from '@/lib/workspace-gcs'; import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage'; import { getApplicationRuntimeLogs } from '@/lib/coolify-logs'; import { execInCoolifyApp } from '@/lib/coolify-exec'; import { isCoolifySshConfigured, runOnCoolifyHost } from '@/lib/coolify-ssh'; import { composeUp, composePs, applyCoolifyPostDeployFixes, type CoolifyPostDeployResult, type ResourceKind, } from '@/lib/coolify-compose'; import { listContainersForApp } from '@/lib/coolify-containers'; import { deployApplication, getApplicationInProject, listApplicationDeployments, listApplicationEnvs, listApplicationsInProject, getProject, projectUuidOf, TenantError, upsertApplicationEnv, deleteApplicationEnv, // Phase 4 ── create/update/delete + domains + databases + services createPublicApp, createDockerImageApp, createDockerComposeApp, startService, getService, listAllServices, listServiceEnvs, upsertServiceEnv, setServiceDomains, updateApplication, deleteApplication, setApplicationDomains, listDatabasesInProject, createDatabase, getDatabaseInProject, updateDatabase, deleteDatabase, listServicesInProject, createService, getServiceInProject, deleteService, listServiceTemplates, searchServiceTemplates, type CoolifyDatabaseType, } from '@/lib/coolify'; import { query } from '@/lib/db-postgres'; import { getRepo } from '@/lib/gitea'; import { giteaHttpsUrl, isDomainUnderWorkspace, slugify, toDomainsString, workspaceAppFqdn, } from '@/lib/naming'; const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; // ────────────────────────────────────────────────── // Capability descriptor // ────────────────────────────────────────────────── export async function GET() { return NextResponse.json({ name: 'vibn-mcp', version: '2.4.8', authentication: { scheme: 'Bearer', tokenPrefix: 'vibn_sk_', description: 'Workspace-scoped token minted at /settings. Every tool call is ' + 'automatically restricted to the workspace the token belongs to.', }, capabilities: { tools: { supported: true, available: [ 'workspace.describe', 'gitea.credentials', 'projects.list', 'projects.get', 'apps.list', 'apps.get', 'apps.create', 'apps.update', 'apps.rewire_git', 'apps.delete', 'apps.deploy', 'apps.deployments', 'apps.domains.list', 'apps.domains.set', 'apps.logs', 'apps.exec', 'apps.volumes.list', 'apps.volumes.wipe', 'apps.containers.up', 'apps.containers.ps', 'apps.repair', 'apps.templates.list', 'apps.templates.search', 'apps.envs.list', 'apps.envs.upsert', 'apps.envs.delete', 'databases.list', 'databases.create', 'databases.get', 'databases.update', 'databases.delete', 'auth.list', 'auth.create', 'auth.delete', 'domains.search', 'domains.list', 'domains.get', 'domains.register', 'domains.attach', 'storage.describe', 'storage.provision', 'storage.inject_env', ], }, }, documentation: 'https://vibnai.com/docs/mcp', }); } // ────────────────────────────────────────────────── // Tool dispatcher // ────────────────────────────────────────────────── export async function POST(request: Request) { const principal = await requireWorkspacePrincipal(request); if (principal instanceof NextResponse) return principal; let body: { action?: string; tool?: string; params?: Record }; try { body = await request.json(); } catch { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); } // Accept either `{ action, params }` or `{ tool, params }` shapes. const action = (body.tool ?? body.action ?? '') as string; const params = (body.params ?? {}) as Record; try { switch (action) { case 'workspace.describe': return NextResponse.json({ result: describeWorkspace(principal) }); case 'gitea.credentials': return await toolGiteaCredentials(principal); case 'projects.list': return await toolProjectsList(principal); case 'projects.get': return await toolProjectsGet(principal, params); case 'apps.list': return await toolAppsList(principal); case 'apps.get': return await toolAppsGet(principal, params); case 'apps.deploy': return await toolAppsDeploy(principal, params); case 'apps.deployments': return await toolAppsDeployments(principal, params); case 'apps.envs.list': return await toolAppsEnvsList(principal, params); case 'apps.envs.upsert': return await toolAppsEnvsUpsert(principal, params); case 'apps.envs.delete': return await toolAppsEnvsDelete(principal, params); case 'apps.create': return await toolAppsCreate(principal, params); case 'apps.update': return await toolAppsUpdate(principal, params); case 'apps.rewire_git': return await toolAppsRewireGit(principal, params); case 'apps.delete': return await toolAppsDelete(principal, params); case 'apps.domains.list': return await toolAppsDomainsList(principal, params); case 'apps.domains.set': return await toolAppsDomainsSet(principal, params); case 'apps.logs': 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 'apps.containers.up': return await toolAppsContainersUp(principal, params); case 'apps.containers.ps': return await toolAppsContainersPs(principal, params); case 'apps.repair': return await toolAppsRepair(principal, params); case 'apps.templates.list': return await toolAppsTemplatesList(params); case 'apps.templates.search': return await toolAppsTemplatesSearch(params); case 'databases.list': return await toolDatabasesList(principal); case 'databases.create': return await toolDatabasesCreate(principal, params); case 'databases.get': return await toolDatabasesGet(principal, params); case 'databases.update': return await toolDatabasesUpdate(principal, params); case 'databases.delete': return await toolDatabasesDelete(principal, params); case 'auth.list': return await toolAuthList(principal); case 'auth.create': return await toolAuthCreate(principal, params); case 'auth.delete': return await toolAuthDelete(principal, params); case 'domains.search': return await toolDomainsSearch(principal, params); case 'domains.list': return await toolDomainsList(principal); case 'domains.get': return await toolDomainsGet(principal, params); case 'domains.register': return await toolDomainsRegister(principal, params); case 'domains.attach': return await toolDomainsAttach(principal, params); case 'storage.describe': return await toolStorageDescribe(principal); case 'storage.provision': return await toolStorageProvision(principal); case 'storage.inject_env': return await toolStorageInjectEnv(principal, params); default: return NextResponse.json( { error: `Unknown tool "${action}"` }, { status: 404 } ); } } catch (err) { if (err instanceof TenantError) { return NextResponse.json({ error: err.message }, { status: 403 }); } console.error('[mcp] tool failed', action, err); return NextResponse.json( { error: 'Tool execution failed', details: err instanceof Error ? err.message : String(err) }, { status: 500 } ); } } // ────────────────────────────────────────────────── // Tool implementations // ────────────────────────────────────────────────── type Principal = Extract< Awaited>, { source: 'session' | 'api_key' } >; function describeWorkspace(principal: Principal) { const w = principal.workspace; return { slug: w.slug, name: w.name, coolifyProjectUuid: w.coolify_project_uuid, giteaOrg: w.gitea_org, giteaBotUsername: w.gitea_bot_username, provisionStatus: w.provision_status, provisionError: w.provision_error, principal: { source: principal.source, apiKeyId: principal.apiKeyId ?? null }, }; } async function toolGiteaCredentials(principal: Principal) { let ws = principal.workspace; if (!ws.gitea_bot_token_encrypted || !ws.gitea_org) { ws = await ensureWorkspaceProvisioned(ws); } const creds = getWorkspaceBotCredentials(ws); if (!creds) { return NextResponse.json( { error: 'Workspace has no Gitea bot yet', provisionStatus: ws.provision_status }, { status: 503 } ); } const apiBase = GITEA_API_URL.replace(/\/$/, ''); const host = new URL(apiBase).host; return NextResponse.json({ result: { org: creds.org, username: creds.username, token: creds.token, apiBase, host, cloneUrlTemplate: `https://${creds.username}:${creds.token}@${host}/${creds.org}/{{repo}}.git`, }, }); } async function toolProjectsList(principal: Principal) { const rows = await query<{ id: string; data: any; created_at: Date; updated_at: Date }>( `SELECT id, data, created_at, updated_at FROM fs_projects WHERE vibn_workspace_id = $1 OR workspace = $2 ORDER BY created_at DESC`, [principal.workspace.id, principal.workspace.slug] ); return NextResponse.json({ result: rows.map(r => ({ id: r.id, name: r.data?.name ?? null, repo: r.data?.repoName ?? null, giteaRepo: r.data?.giteaRepo ?? null, coolifyAppUuid: r.data?.coolifyAppUuid ?? null, createdAt: r.created_at, updatedAt: r.updated_at, })), }); } async function toolProjectsGet(principal: Principal, params: Record) { const projectId = String(params.projectId ?? params.id ?? '').trim(); if (!projectId) { return NextResponse.json({ error: 'Param "projectId" is required' }, { status: 400 }); } const rows = await query<{ id: string; data: any; created_at: Date; updated_at: Date }>( `SELECT id, data, created_at, updated_at FROM fs_projects WHERE id = $1 AND (vibn_workspace_id = $2 OR workspace = $3) LIMIT 1`, [projectId, principal.workspace.id, principal.workspace.slug] ); if (rows.length === 0) { return NextResponse.json({ error: 'Project not found in this workspace' }, { status: 404 }); } const r = rows[0]; const d = r.data || {}; const projectName = d.productName || d.name || d.title || 'Untitled'; // Auto-enrich: if no Coolify link is stored yet, scan apps + services in // the workspace and surface any whose name fuzzy-matches the project. Lets // the AI tell the user "this is probably your deployment" even when the // backend never wrote the link. let possibleDeployments: Array<{ uuid: string; name: string; status: string; fqdn: string | null; resourceType: 'application' | 'service'; }> = []; const linkedUuid = d.coolifyAppUuid || d.coolifyServiceUuid || null; const projectUuid = principal.workspace.coolify_project_uuid; if (projectUuid) { try { const [appsRes, servicesRes, projectRes] = await Promise.allSettled([ listApplicationsInProject(projectUuid), listAllServices(), getProject(projectUuid), ]); const envIds = new Set( projectRes.status === 'fulfilled' ? (projectRes.value.environments ?? []).map((e) => e.id) : [], ); const apps = appsRes.status === 'fulfilled' ? appsRes.value : []; const services = (servicesRes.status === 'fulfilled' && Array.isArray(servicesRes.value) ? (servicesRes.value as Array>) : [] ).filter((s) => envIds.has(Number(s.environment_id))); // Build searchable tokens from the project name (lowercased words, length >= 3) const tokens = projectName .toLowerCase() .replace(/[^a-z0-9 ]/g, ' ') .split(/\s+/) .filter((t: string) => t.length >= 3); const matches = (name: string) => { const n = name.toLowerCase(); return tokens.some((t: string) => n.includes(t)); }; for (const a of apps) { if (matches(a.name) || a.uuid === linkedUuid) { possibleDeployments.push({ uuid: a.uuid, name: a.name, status: a.status, fqdn: a.fqdn ?? null, resourceType: 'application', }); } } for (const s of services) { const name = String(s.name ?? ''); const uuid = String(s.uuid); if (matches(name) || uuid === linkedUuid) { const subApps = (s.applications as Array>) || []; const publicApp = subApps.find((a) => a.fqdn); possibleDeployments.push({ uuid, name, status: String(s.status ?? 'unknown'), fqdn: publicApp?.fqdn ? String(publicApp.fqdn) : null, resourceType: 'service', }); } } } catch { // Best-effort enrichment — never let it block the project read } } return NextResponse.json({ result: { id: r.id, name: projectName, status: d.status || 'defining', vision: d.productVision || d.vision || null, domain: d.domain || d.customDomain || null, coolifyAppUuid: linkedUuid, coolifyDomain: d.coolifyDomain || null, repositoryUrl: d.repositoryUrl || null, possibleDeployments, createdAt: r.created_at, updatedAt: r.updated_at, }, }); } function requireCoolifyProject(principal: Principal): string | NextResponse { const projectUuid = principal.workspace.coolify_project_uuid; if (!projectUuid) { return NextResponse.json( { error: 'Workspace has no Coolify project yet' }, { status: 503 } ); } return projectUuid; } async function toolAppsList(principal: Principal) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; // Fetch Applications and Services in parallel. // Services are compose stacks created via the composeRaw pathway; // they live at /services not /applications. // Coolify's /services response does NOT include a `project` field — services // belong to environments, and environments belong to projects. So we resolve // the project's environment IDs first, then filter services by environment_id. const [apps, allServices, project] = await Promise.allSettled([ listApplicationsInProject(projectUuid), listAllServices(), getProject(projectUuid), ]); const appList = apps.status === 'fulfilled' ? apps.value : []; const projectEnvIds = new Set( project.status === 'fulfilled' ? (project.value.environments ?? []).map((e) => e.id) : [], ); const serviceList = (allServices.status === 'fulfilled' && Array.isArray(allServices.value) ? (allServices.value as Array>) : [] ).filter((s) => { const envId = typeof s.environment_id === 'number' ? s.environment_id : Number(s.environment_id); return projectEnvIds.has(envId); }); return NextResponse.json({ result: [ ...appList.map(a => ({ uuid: a.uuid, name: a.name, status: a.status, fqdn: a.fqdn ?? null, gitRepository: a.git_repository ?? null, gitBranch: a.git_branch ?? null, resourceType: 'application', })), ...serviceList.map((s) => { // Try to extract a usable URL from the service's applications array // (compose stacks expose their public service's fqdn there). const apps = (s.applications as Array>) || []; const publicApp = apps.find((a) => a.fqdn); return { uuid: String(s.uuid), name: String(s.name ?? ''), status: String(s.status ?? 'unknown'), fqdn: publicApp?.fqdn ? String(publicApp.fqdn) : null, gitRepository: null, gitBranch: null, resourceType: 'service', }; }), ], }); } async function toolAppsGet(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) { return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); } const app = await getApplicationInProject(appUuid, projectUuid); return NextResponse.json({ result: app }); } async function toolAppsDeploy(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) { return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); } // Try Application deploy first; fall back to Service start try { await getApplicationInProject(appUuid, projectUuid); const { deployment_uuid } = await deployApplication(appUuid); return NextResponse.json({ result: { deploymentUuid: deployment_uuid, appUuid, resourceType: 'application' } }); } catch (appErr: unknown) { // Check if it's a Service (compose stack) try { const svc = await getService(appUuid); // Verify it belongs to this workspace's project const svcProjectUuid = svc.project_uuid ?? svc.environment?.project_uuid ?? svc.environment?.project?.uuid; if (svcProjectUuid !== projectUuid) { return NextResponse.json({ error: 'Service not found in this workspace' }, { status: 404 }); } await startService(appUuid); return NextResponse.json({ result: { appUuid, resourceType: 'service', message: 'Service start queued' } }); } catch { // Re-throw original error throw appErr; } } } async function toolAppsDeployments(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; 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 deployments = await listApplicationDeployments(appUuid); return NextResponse.json({ result: deployments }); } /** * Runtime logs for a Coolify app. Compose-aware: * - Dockerfile/nixpacks apps → Coolify's `/applications/{uuid}/logs` * - Compose apps → SSH into Coolify host, `docker logs` per service * * Params: * uuid – app uuid (required) * service – compose service filter (optional) * lines – tail lines per container, default 200, max 5000 */ async function toolAppsLogs(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; 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 linesRaw = Number(params.lines ?? 200); const lines = Number.isFinite(linesRaw) ? linesRaw : 200; const serviceRaw = params.service; const service = typeof serviceRaw === 'string' && serviceRaw.trim() ? serviceRaw.trim() : undefined; const result = await getApplicationRuntimeLogs(appUuid, { lines, service }); return NextResponse.json({ result }); } /** * apps.exec — run a one-shot command inside an app container. * * Requires COOLIFY_SSH_* env vars (same as apps.logs). The caller * provides `uuid`, an optional `service` (required for compose apps * with >1 container), and a `command` string. Output is capped at * 1MB by default and 10-minute wall-clock timeout. * * Note: the command is NOT parsed or validated. It's executed as a * single shell invocation inside the container via `sh -lc`. This is * deliberate — this tool is the platform's trust-the-agent escape * hatch for migrations, CLI invocations, and ad-hoc debugging. It's * authenticated per-workspace (tenant check above) and rate-limited * by the SSH session's timeout/byte caps. */ async function toolAppsExec(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; if (!isCoolifySshConfigured()) { return NextResponse.json( { error: 'apps.exec requires SSH access to the Coolify host, which is not configured on this deployment.', }, { status: 501 }, ); } const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); const command = typeof params.command === 'string' ? params.command : ''; if (!appUuid || !command) { return NextResponse.json( { error: 'Params "uuid" and "command" are required' }, { status: 400 }, ); } await getApplicationInProject(appUuid, projectUuid); const service = typeof params.service === 'string' && params.service.trim() ? params.service.trim() : undefined; const user = typeof params.user === 'string' && params.user.trim() ? params.user.trim() : undefined; const workdir = typeof params.workdir === 'string' && params.workdir.trim() ? params.workdir.trim() : undefined; const timeoutMs = Number.isFinite(Number(params.timeout_ms)) ? Number(params.timeout_ms) : undefined; const maxBytes = Number.isFinite(Number(params.max_bytes)) ? Number(params.max_bytes) : undefined; try { const result = await execInCoolifyApp({ appUuid, service, command, user, workdir, timeoutMs, maxBytes, }); return NextResponse.json({ result }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return NextResponse.json({ error: msg }, { status: 400 }); } } // ── 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) { 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: ""` 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) { 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) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; 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 envs = await listApplicationEnvs(appUuid); return NextResponse.json({ result: envs }); } async function toolAppsEnvsUpsert(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); const key = typeof params.key === 'string' ? params.key : ''; const value = typeof params.value === 'string' ? params.value : ''; if (!appUuid || !key) { return NextResponse.json( { error: 'Params "uuid" and "key" are required' }, { status: 400 } ); } await getApplicationInProject(appUuid, projectUuid); // Coolify v4 rejects `is_build_time` on POST/PATCH (it's a derived // read-only flag now). Silently drop it here so agents that still send // it don't get a surprise 422. See lib/coolify.ts upsertApplicationEnv // for the hard enforcement at the network boundary. const result = await upsertApplicationEnv(appUuid, { key, value, is_preview: !!params.is_preview, is_literal: !!params.is_literal, is_multiline: !!params.is_multiline, is_shown_once: !!params.is_shown_once, }); const body: Record = { result }; if (params.is_build_time !== undefined) { body.warnings = [ 'is_build_time is ignored — Coolify derives build-vs-runtime from Dockerfile ARG usage. Omit this field going forward.', ]; } return NextResponse.json(body); } async function toolAppsEnvsDelete(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); const key = typeof params.key === 'string' ? params.key : ''; if (!appUuid || !key) { return NextResponse.json( { error: 'Params "uuid" and "key" are required' }, { status: 400 } ); } await getApplicationInProject(appUuid, projectUuid); await deleteApplicationEnv(appUuid, key); return NextResponse.json({ result: { ok: true, key } }); } // ────────────────────────────────────────────────── // Phase 4: apps create/update/delete + domains // ────────────────────────────────────────────────── /** * apps.create — four distinct pathways depending on what you pass: * * 1. Gitea repo (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 * * 4. **Coolify one-click template** (RECOMMENDED for popular apps) * Required: template e.g. "twenty", "n8n", "supabase", "ghost" * Optional: name, domain, envs * Discoverable via apps.templates.list / apps.templates.search. * Coolify ships 320+ vetted templates (CRMs, AI tools, CMSes, etc). * Each template has battle-tested env defaults, healthchecks, and * `depends_on` graphs — far more reliable than hand-rolling a * composeRaw payload for the same app. * * Pathway 1 is for code in the workspace's Gitea org. Pathways 2/3/4 * deploy third-party apps without creating a Gitea repo. */ async function toolAppsCreate(principal: Principal, params: Record) { const ws = principal.workspace; if (!ws.coolify_project_uuid) { return NextResponse.json( { error: 'Workspace not fully provisioned (need Coolify project)' }, { status: 503 } ); } 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 4: Coolify one-click template ───────────────────────────── // Most reliable path for popular third-party apps. Coolify maintains // a curated catalog at templates/service-templates.json — each entry // has tested env defaults and a working compose graph. if (params.template) { const templateSlug = String(params.template).trim().toLowerCase(); if (!/^[a-z0-9][a-z0-9_-]*$/.test(templateSlug)) { return NextResponse.json({ error: 'Invalid template slug' }, { status: 400 }); } // Validate slug exists so we fail fast with a useful error rather // than relaying Coolify's generic "Service not found". const catalog = await listServiceTemplates(); if (!catalog[templateSlug]) { return NextResponse.json({ error: `Unknown template "${templateSlug}". Use apps.templates.search to find valid slugs.`, }, { status: 404 }); } const appName = slugify(String(params.name ?? templateSlug)); const fqdn = resolveFqdn(params.domain, ws.slug, appName); if (fqdn instanceof NextResponse) return fqdn; // Pull the template's required upstream port from the catalog. // Coolify's "Required Port" UI hint says: domains MUST be specified // as host:port for the template engine to wire up the right // SERVICE_FQDN__ magic env, the loadbalancer.server.port // Traefik label, and the SERVICE_URL__ env. Without it // we get the default sslip.io values everywhere and Traefik returns // 503 because the routing rules have no port to forward to. const templatePort = catalog[templateSlug]?.port ?? 3000; const created = await createService({ projectUuid: commonOpts.projectUuid, serverUuid: commonOpts.serverUuid, environmentName: commonOpts.environmentName, destinationUuid: commonOpts.destinationUuid, type: templateSlug, name: appName, description: params.description ? String(params.description) : undefined, // Don't ask Coolify to instantly deploy — its queued worker has // intermittent issues and we want to set the FQDN + envs first. instantDeploy: false, }); // Coolify auto-assigns sslip.io URLs. Replace them with the // user's FQDN, INCLUDING the required upstream port — see comment // on `templatePort` above. The :port suffix is what makes Coolify // generate the loadbalancer.server.port label and substitute the // SERVICE_FQDN_ env to the user's host (no sslip.io leak). let urlsApplied = false; try { await new Promise(r => setTimeout(r, 1500)); await setServiceDomains(created.uuid, [ { name: templateSlug, url: `https://${fqdn}:${templatePort}` }, ]); urlsApplied = true; } catch (e) { console.warn('[mcp apps.create/template] setServiceDomains failed', e); } // Apply user-provided envs (e.g. POSTGRES_PASSWORD overrides) if (params.envs && typeof params.envs === 'object') { const envEntries = Object.entries(params.envs as Record) .filter(([k]) => /^[A-Z_][A-Z0-9_]*$/i.test(k)) .map(([key, value]) => ({ key, value: String(value) })); for (const env of envEntries) { try { await upsertServiceEnv(created.uuid, env); } catch (e) { console.warn('[mcp apps.create/template] upsert env failed', env.key, e); } } } let started = false; let reachable = false; let appStatus = 'unknown'; let postDeploy: CoolifyPostDeployResult | null = null; let startDiag = ''; if (params.instantDeploy !== false) { ({ started, reachable, appStatus, postDeploy, diag: startDiag } = await ensureServiceReachable({ uuid: created.uuid, fqdn, publicAppName: templateSlug, port: templatePort, })); } return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}`, resourceType: 'service', template: templateSlug, urlsApplied, started, reachable, appStatus, ...(postDeploy ? { postDeploy } : {}), ...(startDiag ? { startDiag } : {}), note: reachable ? `Reachable on https://${fqdn}. First boot may continue migrations in the background — check apps.logs if any feature seems missing.` : started ? `Containers are healthy but https://${fqdn} did not return 2xx/3xx yet. Wait 30-60s for Traefik to fully discover labels, then retry. If still failing, inspect postDeploy.steps for which fix didn't apply, then call apps.logs and apps.containers.ps.` : `Public app did not become healthy. Use apps.containers.ps and apps.logs to diagnose. Most common cause: image pull is still in progress (first deploy can take 5-10 min for large images like twentycrm/twenty).`, }, }); } // ── 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 (creates a Coolify Service) ──── 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; const created = await createDockerComposeApp({ ...commonOpts, composeRaw, name: appName, description: params.description ? String(params.description) : undefined, }); // Services use /services/{uuid}/envs — upsert each env var if (params.envs && typeof params.envs === 'object') { const envEntries = Object.entries(params.envs as Record) .filter(([k]) => /^[A-Z_][A-Z0-9_]*$/i.test(k)) .map(([key, value]) => ({ key, value: String(value) })); if (envEntries.length > 0) { try { // Wait briefly for Coolify to commit the service to DB await new Promise(r => setTimeout(r, 2000)); for (const env of envEntries) { await upsertServiceEnv(created.uuid, env); } } catch (e) { console.warn('[mcp apps.create/composeRaw] upsert service env failed', e); } } } // composeRaw is user-supplied — we can't reliably guess the public // app name (the user may have any compose service layout). Best // effort: use the app name as the public app name, which works for // single-container composes. let started = false; let reachable = false; let appStatus = 'unknown'; let postDeploy: CoolifyPostDeployResult | null = null; let startDiag = ''; if (params.instantDeploy !== false) { const publicAppName = String(params.publicAppName ?? appName); ({ started, reachable, appStatus, postDeploy, diag: startDiag } = await ensureServiceReachable({ uuid: created.uuid, fqdn, publicAppName, port: params.port ? Number(params.port) : undefined, })); } return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}`, resourceType: 'service', started, reachable, appStatus, ...(postDeploy ? { postDeploy } : {}), ...(startDiag ? { startDiag } : {}), note: reachable ? `Reachable on https://${fqdn}.` : `Domain routing for custom compose services depends on knowing which docker-compose service is the public-facing one. Pass publicAppName= and port= on apps.create to enable post-deploy patching, or set them manually.`, }, }); } // ── 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 `template` (recommended), `image`, or `composeRaw` instead of `repo`.' }, { status: 503 } ); } const botCreds = getWorkspaceBotCredentials(ws); if (!botCreds) { return NextResponse.json( { error: 'Workspace Gitea bot credentials unavailable — re-run provisioning' }, { status: 503 } ); } const repoIn = String(params.repo ?? '').trim(); 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; const repoName = parts.length === 2 ? parts[1] : parts[0]; if (repoOrg !== ws.gitea_org) { return NextResponse.json( { error: `Repo owner ${repoOrg} is not this workspace's org ${ws.gitea_org}` }, { status: 403 } ); } const repo = await getRepo(repoOrg, repoName); if (!repo) { return NextResponse.json({ error: `Repo ${repoOrg}/${repoName} not found in Gitea` }, { status: 404 }); } const appName = slugify(String(params.name ?? repoName)); const fqdn = resolveFqdn(params.domain, ws.slug, appName); if (fqdn instanceof NextResponse) return fqdn; const created = await createPublicApp({ ...commonOpts, gitRepository: giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token), gitBranch: String(params.branch ?? repo.default_branch ?? 'main'), portsExposes: String(params.ports ?? '3000'), buildPack: (params.buildPack as any) ?? 'nixpacks', name: appName, domains: toDomainsString([fqdn]), isAutoDeployEnabled: true, dockerComposeLocation: params.dockerComposeLocation ? String(params.dockerComposeLocation) : undefined, dockerfileLocation: params.dockerfileLocation ? String(params.dockerfileLocation) : undefined, baseDirectory: params.baseDirectory ? String(params.baseDirectory) : undefined, }); const dep = await applyEnvsAndDeploy(created.uuid, params); return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}`, deploymentUuid: dep, }, }); } // ────────────────────────────────────────────────── // apps.containers.* — direct lifecycle for compose stacks // ────────────────────────────────────────────────── // // These bypass Coolify's queued-start worker (which is unreliable for // compose Services) and run `docker compose up -d` / `ps` against the // rendered compose dir on the Coolify host. Used as the recovery // path when Coolify's start API returns "queued" but no containers // materialise. // // Tenant safety: the uuid is resolved via getApplicationInProject / // getServiceInProject, so a workspace can't drive containers it // doesn't own. /** Resolve a uuid to either an Application or a compose Service in the * caller's project. Returns the canonical resource kind for * coolify-compose helpers. NextResponse on policy error / not found. */ async function resolveAppOrService( principal: Principal, uuid: string, ): Promise<{ uuid: string; kind: ResourceKind } | NextResponse> { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; try { await getApplicationInProject(uuid, projectUuid); return { uuid, kind: 'application' }; } catch (e) { if (!(e instanceof Error && /404|not found/i.test(e.message))) { // Tenant errors and other unexpected ones — surface them if (e instanceof TenantError) return NextResponse.json({ error: e.message }, { status: 403 }); throw e; } } try { await getServiceInProject(uuid, projectUuid); return { uuid, kind: 'service' }; } catch (e) { if (e instanceof TenantError) { return NextResponse.json({ error: e.message }, { status: 403 }); } return NextResponse.json({ error: `App or service ${uuid} not found in this workspace` }, { status: 404 }); } } /** * apps.containers.up — `docker compose up -d` against the rendered * compose dir on the Coolify host. * * Use when Coolify's queued-start left the stack in "Created" or * "no containers" state, or after editing env vars / domains to * apply the changes (compose env file is regenerated; containers * need to be recreated to pick it up). * * Idempotent — already-running containers are no-op'd. Returns * `{ ok, code, stdout, stderr, durationMs }` so agents can show the * user what happened. */ async function toolAppsContainersUp(principal: Principal, params: Record) { const uuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); if (!isCoolifySshConfigured()) { return NextResponse.json({ error: 'apps.containers.up requires SSH to the Coolify host' }, { status: 501 }); } const resolved = await resolveAppOrService(principal, uuid); if (resolved instanceof NextResponse) return resolved; const t0 = Date.now(); const r = await composeUp(resolved.kind, resolved.uuid, { timeoutMs: 600_000 }); return NextResponse.json({ result: { ok: r.code === 0, code: r.code, stdout: r.stdout.slice(-4000), stderr: r.stderr.slice(-4000), truncated: r.truncated, durationMs: Date.now() - t0, }, }); } /** * apps.containers.ps — `docker compose ps -a` for diagnostics. * * Returns a one-line-per-container summary including names, image, * state, and exit codes. Use to check whether containers are stuck * in `Created` (Coolify queued-start failure) vs `Exited` (app crash) * vs `Restarting` (boot loop). */ async function toolAppsContainersPs(principal: Principal, params: Record) { const uuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); if (!isCoolifySshConfigured()) { return NextResponse.json({ error: 'apps.containers.ps requires SSH to the Coolify host' }, { status: 501 }); } const resolved = await resolveAppOrService(principal, uuid); if (resolved instanceof NextResponse) return resolved; const r = await composePs(resolved.kind, resolved.uuid); return NextResponse.json({ result: { ok: r.code === 0, stdout: r.stdout.slice(-4000), stderr: r.stderr.slice(-2000), }, }); } /** * apps.repair — re-run post-deploy patches against an existing service. * * Use this when a service is running but unreachable on its custom * domain (typical Traefik 503 / Mixed Content symptoms). It applies * the same three fixes apps.create runs on a fresh deploy: * * 1. Rewrite SERVICE_FQDN_* / SERVICE_URL_* in the service .env so * Coolify regen no longer overwrites them with sslip.io defaults. * 2. Inject the missing traefik.http.services..loadbalancer. * server.port label into docker-compose.yml. * 3. Connect coolify-proxy to the service's project network. * 4. Force-recreate the public-facing app container. * 5. Restart coolify-proxy so Traefik re-discovers labels. * * Params: * uuid required — service uuid (the resource, not a single container) * fqdn required — the public hostname (e.g. "crm.mark.vibnai.com") * publicAppName required — docker-compose service name of the public app * (usually equals the template slug: "twenty", "n8n", …) * port optional — internal port (default: derived per template) * * Returns the same { ok, steps } shape as the post-deploy block in * apps.create plus a final reachability probe. */ async function toolAppsRepair(_principal: Principal, params: Record) { const uuid = String(params.uuid ?? '').trim(); const fqdn = String(params.fqdn ?? '').trim(); const publicAppName = String(params.publicAppName ?? '').trim(); const port = params.port != null ? Number(params.port) : undefined; if (!uuid || !fqdn || !publicAppName) { return NextResponse.json( { error: 'apps.repair requires { uuid, fqdn, publicAppName }' }, { status: 400 } ); } if (!isCoolifySshConfigured()) { return NextResponse.json( { error: 'apps.repair requires SSH to the Coolify host (set COOLIFY_SSH_*)' }, { status: 501 } ); } const postDeploy = await applyCoolifyPostDeployFixes({ uuid, fqdn, publicAppName, port }); let reachable = false; let probeDiag = ''; try { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 12_000); const res = await fetch(`https://${fqdn}`, { signal: ctrl.signal, redirect: 'manual' }); clearTimeout(t); reachable = res.status >= 200 && res.status < 400; probeDiag = `GET https://${fqdn} → ${res.status}`; } catch (e) { probeDiag = `probe failed: ${e instanceof Error ? e.message : String(e)}`; } return NextResponse.json({ result: { uuid, fqdn, publicAppName, reachable, postDeploy, probe: probeDiag, note: reachable ? `Repaired and reachable on https://${fqdn}.` : `Repair steps applied but probe still failed. Check postDeploy.steps for any "ok: false" entries; otherwise wait 30s and retry the probe.`, }, }); } // ────────────────────────────────────────────────── // apps.templates.* — Coolify one-click catalog browse // ────────────────────────────────────────────────── // // Coolify ships ~320 vetted service templates (CRMs, AI, CMS, etc). // These tools let agents discover what's available so they can pass // the right slug to apps.create({ template: "..." }). /** * apps.templates.list — paginate the full catalog. * * Params: * limit number, default 50, max 500 * offset number, default 0 * tag string, optional — restrict to templates whose tags include this substring * * Result: { total, items: CoolifyServiceTemplate[] } * * The catalog is large (~320 entries), so use apps.templates.search * when you know what you're looking for. */ async function toolAppsTemplatesList(params: Record) { const all = await listServiceTemplates(); const tagFilter = (params.tag ? String(params.tag).trim().toLowerCase() : ''); const limit = Math.max(1, Math.min(Number(params.limit ?? 50) || 50, 500)); const offset = Math.max(0, Number(params.offset ?? 0) || 0); let entries = Object.values(all); if (tagFilter) { entries = entries.filter(t => (t.tags ?? []).some(x => x.toLowerCase().includes(tagFilter))); } entries.sort((a, b) => a.slug.localeCompare(b.slug)); return NextResponse.json({ result: { total: entries.length, offset, limit, items: entries.slice(offset, offset + limit), }, }); } /** * apps.templates.search — find templates by name, tag, or slogan. * * Params: * query string (required) — case-insensitive substring; matches * slug > tag > slogan in priority order * tag string, optional — additional tag filter * limit number, default 25, max 100 * * Result: { items: CoolifyServiceTemplate[] } * * Examples: * { query: "twenty" } → [{ slug: "twenty", ... }] * { query: "wordpress" } → 4 wordpress variants * { query: "", tag: "crm" } → all CRM templates * { query: "ai", tag: "vector" } → vector DBs */ async function toolAppsTemplatesSearch(params: Record) { const query = String(params.query ?? '').trim(); const tag = params.tag ? String(params.tag).trim() : undefined; if (!query && !tag) { return NextResponse.json({ error: 'Either `query` or `tag` is required' }, { status: 400 }); } const limit = Math.max(1, Math.min(Number(params.limit ?? 25) || 25, 100)); const items = await searchServiceTemplates(query, { tag, limit }); return NextResponse.json({ result: { items } }); } /** * Bring a Coolify Service to a publicly-reachable state. * * v2.4.5 architecture * -------------------- * Earlier versions ran `docker compose up -d` over SSH as a fallback * when Coolify's queue stalled. That worked for "containers running" * but caused two cascading bugs because it bypassed Coolify's full * deploy pipeline: * - Internal services (Postgres, Redis) ended up on the shared * `coolify` Docker network, where DNS aliases for `postgres`/ * `redis` collide with Coolify's own `coolify-db`/`coolify-redis` * containers — Twenty's `postgres://postgres:5432/twenty-db` * resolves to the wrong DB and fails auth. * - The proxy-network attach we did in our SSH path attached EVERY * container, magnifying the same DNS collision. * * The right model is: let Coolify's queue do the heavy lifting (it * handles compose generation, volumes, internal networking, env-var * substitution, healthchecks, etc.) and patch the three things its * REST API does NOT expose: * 1. SERVICE_FQDN_* / SERVICE_URL_* env vars in the rendered .env * 2. The missing traefik loadbalancer.server.port label * 3. coolify-proxy → project network attachment + Traefik nudge * * Steps: * 1. POST /services/{uuid}/start — Coolify's queue does its thing. * 2. Poll service.applications[*].status (the per-application * status is truthful; service.status is not). Wait until the * public app reports running:healthy or we time out. * 3. apply post-deploy fixes: rewrite .env, inject port label, * attach proxy to project net, recreate ONLY the public app, * restart proxy so Traefik re-discovers. * 4. (Optional) probe https:// for a 200/301/302 to confirm * end-to-end reachability. */ async function ensureServiceReachable(opts: { uuid: string; fqdn: string; publicAppName: string; port?: number; /** Max wall-clock time to wait for Coolify to bring containers healthy. */ healthTimeoutMs?: number; }): Promise<{ started: boolean; reachable: boolean; appStatus: string; postDeploy: CoolifyPostDeployResult | null; diag: string; }> { const { uuid, fqdn, publicAppName, port, healthTimeoutMs = 8 * 60_000 } = opts; try { await startService(uuid); } catch (e) { console.warn('[ensureServiceReachable] startService failed', e); } // Poll service.applications[*].status until the public app is // running:healthy. This field is truthful, unlike service.status // which routinely lies as "starting:unknown" while containers are // actually healthy. // Coolify's queue worker can take 60-120s to dequeue a start // request, during which time service.applications[*].status still // reports the stale `exited` state (= "never started"). We only // treat `exited` as terminal AFTER we've seen evidence of activity // (`starting:*` or `running:*`) — otherwise it's just queue lag. const startedAt = Date.now(); let appStatus = 'unknown'; let sawActivity = false; let lastExitObservedAt = 0; while (Date.now() - startedAt < healthTimeoutMs) { try { const svc = (await getService(uuid)) as unknown as { applications?: Array<{ name?: string; status?: string }>; }; const apps = svc.applications ?? []; const target = apps.find(a => a.name === publicAppName) ?? apps[0]; appStatus = target?.status ?? 'unknown'; if (/^running:healthy/i.test(appStatus)) break; if (/^starting|^running/i.test(appStatus)) { sawActivity = true; lastExitObservedAt = 0; } // Once we've seen activity, an exited status is terminal — // boot loop or compose failure. Wait 30s of consecutive // `exited` to be sure it's not a Compose recreate cycle. if (sawActivity && /^exited/i.test(appStatus)) { if (lastExitObservedAt === 0) lastExitObservedAt = Date.now(); if (Date.now() - lastExitObservedAt > 30_000) break; } else if (!/^exited/i.test(appStatus)) { lastExitObservedAt = 0; } } catch (e) { console.warn('[ensureServiceReachable] status probe failed', e); } await new Promise(r => setTimeout(r, 8_000)); } const started = /^running/i.test(appStatus); if (!started) { return { started: false, reachable: false, appStatus, postDeploy: null, diag: `Public app "${publicAppName}" did not become healthy within ${Math.round(healthTimeoutMs/1000)}s (status=${appStatus}). Use apps.containers.ps and apps.logs to diagnose.`, }; } // Apply post-deploy fixes. Only meaningful when SSH is configured — // without it we can't rewrite the .env or attach proxy networks. let postDeploy: CoolifyPostDeployResult | null = null; if (isCoolifySshConfigured()) { try { postDeploy = await applyCoolifyPostDeployFixes({ uuid, fqdn, publicAppName, port }); } catch (e) { console.warn('[ensureServiceReachable] post-deploy fix failed', e); } } // Best-effort reachability probe. Public DNS for the workspace // wildcard may not have propagated yet (esp. on first deploy in a // brand-new workspace), so a non-200 here doesn't mean failure — // it just means "agents should retry the URL in a few seconds". let reachable = false; let probeDiag = ''; try { const url = `https://${fqdn}`; const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 12_000); const res = await fetch(url, { signal: ctrl.signal, redirect: 'manual' }); clearTimeout(t); reachable = res.status >= 200 && res.status < 400; probeDiag = `GET ${url} → ${res.status}`; } catch (e) { probeDiag = `GET probe failed: ${e instanceof Error ? e.message : String(e)}`; } return { started: true, reachable, appStatus, postDeploy, diag: probeDiag, }; } /** 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, ): Promise { if (params.envs && typeof params.envs === 'object') { for (const [k, v] of Object.entries(params.envs as Record)) { 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) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; 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 allowed = new Set([ 'name', 'description', 'git_branch', 'git_commit_sha', 'build_pack', 'ports_exposes', 'install_command', 'build_command', 'start_command', 'base_directory', 'dockerfile_location', 'docker_compose_location', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'static_image', ]); // ── Control params (never forwarded to Coolify) ───────────────────── // `uuid`/`appUuid` identify the target; we've consumed them already. const control = new Set(['uuid', 'appUuid', 'patch']); // ── Fields we deliberately DO NOT forward from apps.update ───────── // Each maps to a different tool; silently dropping them the way we // used to caused real live-test bugs (PATCH returns ok, nothing // persists, agent thinks it worked). const redirected: Record = { fqdn: 'apps.domains.set', domains: 'apps.domains.set', docker_compose_domains: 'apps.domains.set', git_repository: 'apps.rewire_git', }; // Support both the flat `{ uuid, name, description, ... }` shape and // the explicit `{ uuid, patch: { name, description, ... } }` shape. const source: Record = params.patch && typeof params.patch === 'object' && !Array.isArray(params.patch) ? (params.patch as Record) : params; const patch: Record = {}; const ignored: string[] = []; const rerouted: Array<{ field: string; use: string }> = []; for (const [k, v] of Object.entries(source)) { if (v === undefined) continue; if (control.has(k) && source === params) continue; if (redirected[k]) { rerouted.push({ field: k, use: redirected[k] }); continue; } if (allowed.has(k)) { patch[k] = v; continue; } ignored.push(k); } if (Object.keys(patch).length === 0) { return NextResponse.json( { error: rerouted.length ? 'No updatable fields in params. Some fields must be set via other tools — see `rerouted`.' : 'No updatable fields in params. See `ignored` and `allowed`.', rerouted, ignored, allowed: [...allowed], }, { status: 400 }, ); } await updateApplication(appUuid, patch); return NextResponse.json({ result: { ok: true, uuid: appUuid, applied: Object.keys(patch), // Non-empty `ignored`/`rerouted` are NOT errors but callers need to // see them; silently dropping unrecognised keys was the original // "fqdn returns ok but doesn't persist" false-positive. ...(ignored.length ? { ignored } : {}), ...(rerouted.length ? { rerouted } : {}), }, }); } /** * Re-point an app's git_repository at the workspace's canonical * HTTPS+PAT clone URL. Useful to recover older apps that were created * with SSH URLs (which don't work on this Gitea topology), or to * rotate the bot PAT embedded in the URL after a credential cycle. * The repo name is inferred from the current URL unless `repo` is * passed explicitly (in `owner/name` form). */ async function toolAppsRewireGit(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); const ws = principal.workspace; const botCreds = getWorkspaceBotCredentials(ws); if (!botCreds) { return NextResponse.json( { error: 'Workspace Gitea bot credentials unavailable — re-run provisioning' }, { status: 503 } ); } const app = await getApplicationInProject(appUuid, projectUuid); let repoOrg: string; let repoName: string; if (params.repo) { const parts = String(params.repo).replace(/\.git$/, '').split('/'); if (parts.length !== 2) { return NextResponse.json({ error: 'Param "repo" must be "owner/name"' }, { status: 400 }); } [repoOrg, repoName] = parts; } else { const m = (app.git_repository ?? '').match( /(?:git@[^:]+:|https?:\/\/(?:[^/]+@)?[^/]+\/)([^/]+)\/([^/.]+)(?:\.git)?$/ ); if (!m) { return NextResponse.json( { error: 'Could not infer repo from current git_repository; pass repo="owner/name"' }, { status: 400 } ); } [, repoOrg, repoName] = m; } if (repoOrg !== ws.gitea_org) { return NextResponse.json( { error: `Repo owner ${repoOrg} is not this workspace's org ${ws.gitea_org}` }, { status: 403 } ); } const newUrl = giteaHttpsUrl(repoOrg, repoName, botCreds.username, botCreds.token); await updateApplication(appUuid, { git_repository: newUrl }); return NextResponse.json({ result: { ok: true, uuid: appUuid, repo: `${repoOrg}/${repoName}`, gitUrlScheme: 'https+pat', }, }); } async function toolAppsDelete(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); const app = await getApplicationInProject(appUuid, projectUuid); const confirm = String(params.confirm ?? ''); if (confirm !== app.name) { return NextResponse.json( { error: 'Confirmation required', hint: `Pass confirm=${app.name} to delete` }, { status: 409 } ); } const deleteVolumes = params.deleteVolumes === true; await deleteApplication(appUuid, { deleteConfigurations: true, deleteVolumes, deleteConnectedNetworks: true, dockerCleanup: true, }); return NextResponse.json({ result: { ok: true, deleted: { uuid: appUuid, name: app.name, volumesKept: !deleteVolumes } }, }); } async function toolAppsDomainsList(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); const app = await getApplicationInProject(appUuid, projectUuid); const raw = (app.domains ?? app.fqdn ?? '') as string; const list = raw .split(/[,\s]+/) .map(s => s.trim()) .filter(Boolean) .map(s => s.replace(/^https?:\/\//, '').replace(/\/+$/, '')); return NextResponse.json({ result: { uuid: appUuid, domains: list } }); } async function toolAppsDomainsSet(principal: Principal, params: Record) { const ws = principal.workspace; const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); const domainsIn = Array.isArray(params.domains) ? params.domains : []; if (!appUuid || domainsIn.length === 0) { return NextResponse.json({ error: 'Params "uuid" and "domains[]" are required' }, { status: 400 }); } const app = await getApplicationInProject(appUuid, projectUuid); const normalized: string[] = []; for (const d of domainsIn) { if (typeof d !== 'string' || !d.trim()) continue; const clean = d.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase(); if (!isDomainUnderWorkspace(clean, ws.slug)) { return NextResponse.json( { error: `Domain ${clean} must end with .${ws.slug}.vibnai.com` }, { status: 403 } ); } normalized.push(clean); } const buildPack = (app.build_pack ?? 'nixpacks') as string; const composeService = typeof params.service === 'string' && params.service.trim() ? params.service.trim() : typeof params.composeService === 'string' && params.composeService.trim() ? params.composeService.trim() : undefined; await setApplicationDomains(appUuid, normalized, { forceOverride: true, buildPack, composeService, }); return NextResponse.json({ result: { uuid: appUuid, domains: normalized, buildPack, routedTo: buildPack === 'dockercompose' ? { field: 'docker_compose_domains', service: composeService ?? 'server' } : { field: 'domains' }, }, }); } // ────────────────────────────────────────────────── // Phase 4: databases // ────────────────────────────────────────────────── const DB_TYPES: readonly CoolifyDatabaseType[] = [ 'postgresql', 'mysql', 'mariadb', 'mongodb', 'redis', 'keydb', 'dragonfly', 'clickhouse', ]; async function toolDatabasesList(principal: Principal) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const dbs = await listDatabasesInProject(projectUuid); return NextResponse.json({ result: dbs.map(d => ({ uuid: d.uuid, name: d.name, type: d.type ?? null, status: d.status, isPublic: d.is_public ?? false, publicPort: d.public_port ?? null, })), }); } async function toolDatabasesCreate(principal: Principal, params: Record) { const ws = principal.workspace; const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const type = String(params.type ?? '').toLowerCase() as CoolifyDatabaseType; if (!DB_TYPES.includes(type)) { return NextResponse.json( { error: `Param "type" must be one of: ${DB_TYPES.join(', ')}` }, { status: 400 } ); } const name = slugify(String(params.name ?? `${type}-${Date.now().toString(36)}`)); const { uuid } = await createDatabase({ type, name, description: params.description ? String(params.description) : undefined, projectUuid, serverUuid: ws.coolify_server_uuid ?? undefined, environmentName: ws.coolify_environment_name, destinationUuid: ws.coolify_destination_uuid ?? undefined, isPublic: params.isPublic === true, publicPort: typeof params.publicPort === 'number' ? params.publicPort : undefined, image: params.image ? String(params.image) : undefined, credentials: params.credentials && typeof params.credentials === 'object' ? params.credentials : {}, limits: params.limits && typeof params.limits === 'object' ? params.limits : undefined, instantDeploy: params.instantDeploy !== false, }); const db = await getDatabaseInProject(uuid, projectUuid); return NextResponse.json({ result: { uuid: db.uuid, name: db.name, type: db.type ?? type, status: db.status, internalUrl: db.internal_db_url ?? null, externalUrl: db.external_db_url ?? null, }, }); } async function toolDatabasesGet(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const uuid = String(params.uuid ?? '').trim(); if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); const db = await getDatabaseInProject(uuid, projectUuid); return NextResponse.json({ result: { uuid: db.uuid, name: db.name, type: db.type ?? null, status: db.status, isPublic: db.is_public ?? false, publicPort: db.public_port ?? null, internalUrl: db.internal_db_url ?? null, externalUrl: db.external_db_url ?? null, }, }); } async function toolDatabasesUpdate(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const uuid = String(params.uuid ?? '').trim(); if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); await getDatabaseInProject(uuid, projectUuid); const allowed = new Set(['name', 'description', 'is_public', 'public_port', 'image', 'limits_memory', 'limits_cpus']); const patch: Record = {}; for (const [k, v] of Object.entries(params)) { if (allowed.has(k) && v !== undefined) patch[k] = v; } if (Object.keys(patch).length === 0) { return NextResponse.json({ error: 'No updatable fields in params' }, { status: 400 }); } await updateDatabase(uuid, patch); return NextResponse.json({ result: { ok: true, uuid } }); } async function toolDatabasesDelete(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const uuid = String(params.uuid ?? '').trim(); if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); const db = await getDatabaseInProject(uuid, projectUuid); const confirm = String(params.confirm ?? ''); if (confirm !== db.name) { return NextResponse.json( { error: 'Confirmation required', hint: `Pass confirm=${db.name} to delete` }, { status: 409 } ); } const deleteVolumes = params.deleteVolumes === true; await deleteDatabase(uuid, { deleteConfigurations: true, deleteVolumes, deleteConnectedNetworks: true, dockerCleanup: true, }); return NextResponse.json({ result: { ok: true, deleted: { uuid, name: db.name, volumesKept: !deleteVolumes } }, }); } // ────────────────────────────────────────────────── // Phase 4: auth providers (Coolify services, curated allowlist) // ────────────────────────────────────────────────── const AUTH_PROVIDERS_MCP: Record = { pocketbase: 'pocketbase', authentik: 'authentik', keycloak: 'keycloak', 'keycloak-with-postgres': 'keycloak-with-postgres', 'pocket-id': 'pocket-id', 'pocket-id-with-postgresql': 'pocket-id-with-postgresql', logto: 'logto', 'supertokens-with-postgresql': 'supertokens-with-postgresql', }; async function toolAuthList(principal: Principal) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const all = await listServicesInProject(projectUuid); const slugs = new Set(Object.values(AUTH_PROVIDERS_MCP)); return NextResponse.json({ result: { providers: all .filter(s => { for (const slug of slugs) { if (s.name === slug || s.name.startsWith(`${slug}-`)) return true; } return false; }) .map(s => ({ uuid: s.uuid, name: s.name, status: s.status ?? null })), allowedProviders: Object.keys(AUTH_PROVIDERS_MCP), }, }); } async function toolAuthCreate(principal: Principal, params: Record) { const ws = principal.workspace; const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const key = String(params.provider ?? '').toLowerCase().trim(); const coolifyType = AUTH_PROVIDERS_MCP[key]; if (!coolifyType) { return NextResponse.json( { error: `Unsupported provider "${key}"`, allowed: Object.keys(AUTH_PROVIDERS_MCP), }, { status: 400 } ); } const name = slugify(String(params.name ?? key)); const { uuid } = await createService({ projectUuid, type: coolifyType, name, description: params.description ? String(params.description) : undefined, serverUuid: ws.coolify_server_uuid ?? undefined, environmentName: ws.coolify_environment_name, destinationUuid: ws.coolify_destination_uuid ?? undefined, instantDeploy: params.instantDeploy !== false, }); const svc = await getServiceInProject(uuid, projectUuid); return NextResponse.json({ result: { uuid: svc.uuid, name: svc.name, provider: key, status: svc.status ?? null }, }); } async function toolAuthDelete(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; const uuid = String(params.uuid ?? '').trim(); if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); const svc = await getServiceInProject(uuid, projectUuid); const confirm = String(params.confirm ?? ''); if (confirm !== svc.name) { return NextResponse.json( { error: 'Confirmation required', hint: `Pass confirm=${svc.name} to delete` }, { status: 409 } ); } const deleteVolumes = params.deleteVolumes === true; await deleteService(uuid, { deleteConfigurations: true, deleteVolumes, deleteConnectedNetworks: true, dockerCleanup: true, }); return NextResponse.json({ result: { ok: true, deleted: { uuid, name: svc.name, volumesKept: !deleteVolumes } }, }); } // ────────────────────────────────────────────────── // Phase 5.1: domains (OpenSRS) // ────────────────────────────────────────────────── async function toolDomainsSearch(principal: Principal, params: Record) { const namesIn = Array.isArray(params.names) ? params.names : [params.name]; const names = namesIn .filter((x: unknown): x is string => typeof x === 'string' && x.trim().length > 0) .map((s: string) => s.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '')); if (names.length === 0) { return NextResponse.json({ error: 'Params { names: string[] } or { name: string } required' }, { status: 400 }); } const period = typeof params.period === 'number' && params.period > 0 ? params.period : 1; const { checkDomain } = await import('@/lib/opensrs'); const results = await Promise.all(names.map(async (name: string) => { try { const r = await checkDomain(name, period); return { domain: name, available: r.available, price: r.price ?? null, currency: r.currency ?? (process.env.OPENSRS_CURRENCY ?? 'CAD'), period: r.period ?? period, }; } catch (err) { return { domain: name, available: false, error: err instanceof Error ? err.message : String(err) }; } })); return NextResponse.json({ result: { mode: process.env.OPENSRS_MODE ?? 'test', results } }); } async function toolDomainsList(principal: Principal) { const { listDomainsForWorkspace } = await import('@/lib/domains'); const rows = await listDomainsForWorkspace(principal.workspace.id); return NextResponse.json({ result: rows.map(r => ({ id: r.id, domain: r.domain, tld: r.tld, status: r.status, registeredAt: r.registered_at, expiresAt: r.expires_at, periodYears: r.period_years, dnsProvider: r.dns_provider, })), }); } async function toolDomainsGet(principal: Principal, params: Record) { const name = String(params.domain ?? params.name ?? '').trim().toLowerCase(); if (!name) return NextResponse.json({ error: 'Param "domain" is required' }, { status: 400 }); const { getDomainForWorkspace } = await import('@/lib/domains'); const row = await getDomainForWorkspace(principal.workspace.id, name); if (!row) return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 }); return NextResponse.json({ result: { id: row.id, domain: row.domain, tld: row.tld, status: row.status, registrarOrderId: row.registrar_order_id, periodYears: row.period_years, registeredAt: row.registered_at, expiresAt: row.expires_at, dnsProvider: row.dns_provider, dnsZoneId: row.dns_zone_id, dnsNameservers: row.dns_nameservers, }, }); } async function toolDomainsRegister(principal: Principal, params: Record) { const raw = String(params.domain ?? '').toLowerCase().trim() .replace(/^https?:\/\//, '').replace(/\/+$/, ''); if (!raw || !/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(raw)) { return NextResponse.json({ error: '`domain` is required and must be a valid hostname' }, { status: 400 }); } if (!params.contact || typeof params.contact !== 'object') { return NextResponse.json({ error: '`contact` object is required (see /api/workspaces/[slug]/domains POST schema)' }, { status: 400 }); } const { domainTld: tldOf, minPeriodFor, registerDomain, OpenSrsError } = await import('@/lib/opensrs'); const tld = tldOf(raw); if (tld === 'ca' && !params.ca) { return NextResponse.json({ error: '.ca requires `ca.cprCategory` and `ca.legalType`' }, { status: 400 }); } const period = minPeriodFor(tld, typeof params.period === 'number' ? params.period : 1); const { createDomainIntent, getDomainForWorkspace, markDomainFailed, markDomainRegistered, recordDomainEvent, } = await import('@/lib/domains'); let intent = await getDomainForWorkspace(principal.workspace.id, raw); if (intent && intent.status === 'active') { return NextResponse.json({ error: `Domain ${raw} is already registered`, domainId: intent.id }, { status: 409 }); } if (!intent) { intent = await createDomainIntent({ workspaceId: principal.workspace.id, domain: raw, createdBy: principal.userId, periodYears: period, whoisPrivacy: params.whoisPrivacy ?? true, }); } await recordDomainEvent({ domainId: intent.id, workspaceId: principal.workspace.id, type: 'register.attempt', payload: { period, via: 'mcp', mode: process.env.OPENSRS_MODE ?? 'test' }, }); try { const result = await registerDomain({ domain: raw, period, contact: params.contact, nameservers: params.nameservers, whoisPrivacy: params.whoisPrivacy ?? true, ca: params.ca, }); const updated = await markDomainRegistered({ domainId: intent.id, registrarOrderId: result.orderId, registrarUsername: result.regUsername, registrarPassword: result.regPassword, periodYears: period, pricePaidCents: null, priceCurrency: process.env.OPENSRS_CURRENCY ?? 'CAD', registeredAt: new Date(), expiresAt: new Date(Date.now() + period * 365 * 24 * 60 * 60 * 1000), }); await recordDomainEvent({ domainId: intent.id, workspaceId: principal.workspace.id, type: 'register.success', payload: { orderId: result.orderId, period, via: 'mcp' }, }); return NextResponse.json({ result: { ok: true, mode: process.env.OPENSRS_MODE ?? 'test', domain: { id: updated.id, domain: updated.domain, status: updated.status, registrarOrderId: updated.registrar_order_id, expiresAt: updated.expires_at, }, }, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); await markDomainFailed(intent.id, message); if (err instanceof OpenSrsError) { return NextResponse.json({ error: 'Registration failed', registrarCode: err.code, details: err.message }, { status: 502 }); } return NextResponse.json({ error: 'Registration failed', details: message }, { status: 500 }); } } async function toolDomainsAttach(principal: Principal, params: Record) { const apex = String(params.domain ?? params.name ?? '').trim().toLowerCase(); if (!apex) return NextResponse.json({ error: 'Param "domain" is required' }, { status: 400 }); const { getDomainForWorkspace } = await import('@/lib/domains'); const row = await getDomainForWorkspace(principal.workspace.id, apex); if (!row) return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 }); const { attachDomain, AttachError } = await import('@/lib/domain-attach'); try { const result = await attachDomain(principal.workspace, row, { appUuid: typeof params.appUuid === 'string' ? params.appUuid : undefined, ip: typeof params.ip === 'string' ? params.ip : undefined, cname: typeof params.cname === 'string' ? params.cname : undefined, subdomains: Array.isArray(params.subdomains) ? params.subdomains : undefined, updateRegistrarNs: params.updateRegistrarNs !== false, }); return NextResponse.json({ result: { ok: true, domain: { id: result.domain.id, domain: result.domain.domain, dnsProvider: result.domain.dns_provider, dnsZoneId: result.domain.dns_zone_id, dnsNameservers: result.domain.dns_nameservers, }, zone: result.zone, records: result.records, registrarNsUpdate: result.registrarNsUpdate, coolifyUpdate: result.coolifyUpdate, }, }); } catch (err) { if (err instanceof AttachError) { return NextResponse.json( { error: err.message, tag: err.tag, ...(err.extra ?? {}) }, { status: err.status }, ); } console.error('[mcp domains.attach] unexpected', err); return NextResponse.json( { error: 'Attach failed', details: err instanceof Error ? err.message : String(err) }, { status: 500 }, ); } } // ────────────────────────────────────────────────── // Phase 5.3: Object storage (GCS via S3-compatible HMAC) // ────────────────────────────────────────────────── /** * Shape of the S3-compatible credentials we expose to agents. * * The HMAC *secret* is never returned here — only the access id and * the bucket/region/endpoint. Use `storage.inject_env` to push the * full `{accessId, secret}` pair into a Coolify app's env vars * server-side, where the secret never leaves our network. */ function describeWorkspaceStorage(ws: { slug: string; gcs_default_bucket_name: string | null; gcs_hmac_access_id: string | null; gcp_service_account_email: string | null; gcp_provision_status: string | null; }) { return { status: ws.gcp_provision_status ?? 'pending', bucket: ws.gcs_default_bucket_name, region: VIBN_GCS_LOCATION, endpoint: 'https://storage.googleapis.com', accessKeyId: ws.gcs_hmac_access_id, serviceAccountEmail: ws.gcp_service_account_email, note: 'S3-compatible credentials. Use AWS SDKs with forcePathStyle=true and this endpoint. ' + 'The secret access key is not returned here; call storage.inject_env to push it into a Coolify app.', }; } async function toolStorageDescribe(principal: Principal) { const ws = await getWorkspaceGcsState(principal.workspace.id); if (!ws) { return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); } return NextResponse.json({ result: describeWorkspaceStorage(ws) }); } async function toolStorageProvision(principal: Principal) { const result = await ensureWorkspaceGcsProvisioned(principal.workspace); return NextResponse.json({ result }); } /** * Inject the workspace's storage credentials into a Coolify app as * env vars, so the app can reach the bucket with any S3 SDK. The * HMAC secret is read server-side and written directly to Coolify — * it never transits through the agent or our API response. * * Envs written (all tagged is_shown_once so Coolify hides the secret * in the UI after first render): * STORAGE_ENDPOINT = https://storage.googleapis.com * STORAGE_REGION = northamerica-northeast1 * STORAGE_BUCKET = vibn-ws-{slug}-{rand} * STORAGE_ACCESS_KEY_ID = GOOG1E... (HMAC access id) * STORAGE_SECRET_ACCESS_KEY = ... (HMAC secret — shown-once) * STORAGE_FORCE_PATH_STYLE = "true" (S3 SDKs need this for GCS) * * Agents can override the env var prefix via `params.prefix` * (e.g. "S3_" for apps that expect AWS-style names). */ async function toolStorageInjectEnv(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; 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 prefix = String(params.prefix ?? 'STORAGE_'); if (!/^[A-Z][A-Z0-9_]*$/.test(prefix)) { return NextResponse.json( { error: 'Param "prefix" must be uppercase ASCII (letters, digits, underscores)' }, { status: 400 }, ); } const ws = await getWorkspaceGcsState(principal.workspace.id); if (!ws) return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); if (ws.gcp_provision_status !== 'ready' || !ws.gcs_default_bucket_name) { return NextResponse.json( { error: `Workspace storage not ready (status=${ws.gcp_provision_status}). Call storage.provision first.`, }, { status: 409 }, ); } const creds = getWorkspaceGcsHmacCredentials(ws); if (!creds) { return NextResponse.json( { error: 'Storage HMAC secret unavailable (pre-rotation key, or decrypt failed). Rotate and retry.' }, { status: 409 }, ); } const entries: Array<{ key: string; value: string; shownOnce?: boolean }> = [ { key: `${prefix}ENDPOINT`, value: 'https://storage.googleapis.com' }, { key: `${prefix}REGION`, value: VIBN_GCS_LOCATION }, { key: `${prefix}BUCKET`, value: ws.gcs_default_bucket_name }, { key: `${prefix}ACCESS_KEY_ID`, value: creds.accessId }, { key: `${prefix}SECRET_ACCESS_KEY`, value: creds.secret, shownOnce: true }, { key: `${prefix}FORCE_PATH_STYLE`, value: 'true' }, ]; const written: string[] = []; const failed: Array<{ key: string; error: string }> = []; for (const e of entries) { try { await upsertApplicationEnv(appUuid, { key: e.key, value: e.value, is_shown_once: e.shownOnce ?? false, }); written.push(e.key); } catch (err) { failed.push({ key: e.key, error: err instanceof Error ? err.message : String(err) }); } } return NextResponse.json({ result: { uuid: appUuid, prefix, written, failed: failed.length ? failed : undefined, bucket: ws.gcs_default_bucket_name, }, }); }