/** * 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, type ResourceKind } from '@/lib/coolify-compose'; import { listContainersForApp } from '@/lib/coolify-containers'; import { deployApplication, getApplicationInProject, listApplicationDeployments, listApplicationEnvs, listApplicationsInProject, 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.1', 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.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.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]; return NextResponse.json({ result: { id: r.id, data: r.data, 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. const [apps, allServices] = await Promise.allSettled([ listApplicationsInProject(projectUuid), listAllServices(), ]); const appList = apps.status === 'fulfilled' ? apps.value : []; const serviceList = (allServices.status === 'fulfilled' && Array.isArray(allServices.value) ? (allServices.value as Array>) : [] ).filter(s => { const proj = s.project as Record | undefined; return proj?.uuid === projectUuid; }); 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 => ({ uuid: String(s.uuid), name: String(s.name ?? ''), status: String(s.status ?? 'unknown'), 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; 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. We rebuild the urls array by reading the service // back to learn the docker-compose service names (template-specific). let urlsApplied = false; try { // Brief settle so the service is fully committed await new Promise(r => setTimeout(r, 1500)); const svc = await getService(created.uuid) as Record; // Coolify stores per-service urls under different shapes across versions: // - service.fqdn : "https://x.sslip.io,https://y.sslip.io" // - service.urls : [{ name, url }] // For simplicity, target the docker-compose service named after // the template slug (covers ~90% of templates: twenty, n8n, ghost, // wordpress, etc). Users can adjust later via apps.domains.set. await setServiceDomains(created.uuid, [{ name: templateSlug, url: `https://${fqdn}` }]); urlsApplied = true; void svc; // reserved for future heuristic } 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 startMethod: 'coolify-queue' | 'compose-up' | 'failed' = 'failed'; let startDiag = ''; if (params.instantDeploy !== false) { ({ started, startMethod, diag: startDiag } = await ensureServiceUp(created.uuid)); } return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}`, resourceType: 'service', template: templateSlug, urlsApplied, started, startMethod, ...(startDiag ? { startDiag } : {}), note: started ? 'Containers are up. First boot may take 1-5 min while images finish pulling and migrations run. Use apps.logs to monitor.' : 'Service created but containers did not start. Call apps.containers.up to retry, or apps.logs to diagnose.', }, }); } // ── 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); } } } let started = false; let startMethod: 'coolify-queue' | 'compose-up' | 'failed' = 'failed'; let startDiag = ''; if (params.instantDeploy !== false) { ({ started, startMethod, diag: startDiag } = await ensureServiceUp(created.uuid)); } return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}`, resourceType: 'service', started, startMethod, ...(startDiag ? { startDiag } : {}), note: 'Domain routing for compose services must be configured after initial startup — set SERVER_URL env to the desired URL, then call apps.containers.up to apply.', }, }); } // ── 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.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 } }); } /** * Ensure a Coolify Service is actually running (containers exist and * are healthy/starting), with a fallback path for Coolify's flaky * queued-start worker. * * Strategy: * 1. Call POST /services/{uuid}/start so Coolify's records show * "starting" and any internal hooks fire. * 2. Wait briefly, then probe the host for any container belonging * to this service via `docker ps --filter name={uuid}`. * 3. If no containers materialised, run `docker compose up -d` * directly via SSH against the rendered compose dir. This is * the same command Coolify's worker would run; we just bypass * the unreliable queue. * * Returns: * started true if at least one container is running for this service * startMethod which path got us there * diag human-readable note for failures (truncated stderr) */ async function ensureServiceUp(uuid: string): Promise<{ started: boolean; startMethod: 'coolify-queue' | 'compose-up' | 'failed'; diag: string; }> { // 1. Ask Coolify nicely try { await startService(uuid); } catch (e) { console.warn('[ensureServiceUp] startService failed (will fall back)', e); } // 2. Probe — has the queue actually started anything? if (!isCoolifySshConfigured()) { return { started: true, startMethod: 'coolify-queue', diag: '' }; } // Allow up to ~12s for the worker to wake up; checking every 3s. for (let i = 0; i < 4; i++) { await new Promise(r => setTimeout(r, 3_000)); try { const probe = await runOnCoolifyHost( `docker ps --filter name=${uuid} --format '{{.Names}}'`, { timeoutMs: 8_000 }, ); if (probe.stdout.trim().length > 0) { return { started: true, startMethod: 'coolify-queue', diag: '' }; } } catch (e) { console.warn('[ensureServiceUp] probe failed', e); } } // 3. Fallback — run docker compose up -d ourselves try { const r = await composeUp('service', uuid, { timeoutMs: 600_000 }); if (r.code === 0) { return { started: true, startMethod: 'compose-up', diag: '' }; } // Non-zero exit but compose ran — capture the tail for diagnosis const tail = (r.stderr || r.stdout).trim().slice(-400); return { started: false, startMethod: 'failed', diag: tail }; } catch (e) { return { started: false, startMethod: 'failed', diag: e instanceof Error ? e.message : String(e) }; } } /** 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, }, }); }