/** * 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 { deployApplication, getApplicationInProject, listApplicationDeployments, listApplicationEnvs, listApplicationsInProject, projectUuidOf, TenantError, upsertApplicationEnv, deleteApplicationEnv, // Phase 4 ── create/update/delete + domains + databases + services createPrivateDeployKeyApp, updateApplication, deleteApplication, setApplicationDomains, listDatabasesInProject, createDatabase, getDatabaseInProject, updateDatabase, deleteDatabase, listServicesInProject, createService, getServiceInProject, deleteService, type CoolifyDatabaseType, } from '@/lib/coolify'; import { query } from '@/lib/db-postgres'; import { getRepo } from '@/lib/gitea'; import { giteaSshUrl, 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.2.0', 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.delete', 'apps.deploy', 'apps.deployments', 'apps.domains.list', 'apps.domains.set', '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', ], }, }, 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.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 '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); 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; const apps = await listApplicationsInProject(projectUuid); return NextResponse.json({ result: apps.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, projectUuid: projectUuidOf(a), })), }); } 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 }); } await getApplicationInProject(appUuid, projectUuid); const { deployment_uuid } = await deployApplication(appUuid); return NextResponse.json({ result: { deploymentUuid: deployment_uuid, appUuid } }); } 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 }); } 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); const result = await upsertApplicationEnv(appUuid, { key, value, is_preview: !!params.is_preview, is_build_time: !!params.is_build_time, is_literal: !!params.is_literal, is_multiline: !!params.is_multiline, }); return NextResponse.json({ result }); } 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 // ────────────────────────────────────────────────── async function toolAppsCreate(principal: Principal, params: Record) { const ws = principal.workspace; if (!ws.coolify_project_uuid || !ws.coolify_private_key_uuid || !ws.gitea_org) { return NextResponse.json( { error: 'Workspace not fully provisioned (need Coolify project + deploy key + Gitea org)' }, { status: 503 } ); } const repoIn = String(params.repo ?? '').trim(); if (!repoIn) return NextResponse.json({ error: 'Param "repo" 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 = String(params.domain ?? '').trim() ? String(params.domain).replace(/^https?:\/\//, '') : workspaceAppFqdn(ws.slug, appName); if (!isDomainUnderWorkspace(fqdn, ws.slug)) { return NextResponse.json( { error: `Domain ${fqdn} must end with .${ws.slug}.vibnai.com` }, { status: 403 } ); } const created = await createPrivateDeployKeyApp({ projectUuid: ws.coolify_project_uuid, serverUuid: ws.coolify_server_uuid ?? undefined, environmentName: ws.coolify_environment_name, destinationUuid: ws.coolify_destination_uuid ?? undefined, privateKeyUuid: ws.coolify_private_key_uuid, gitRepository: giteaSshUrl(repoOrg, repoName), 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, isForceHttpsEnabled: true, instantDeploy: false, }); // Attach envs 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(created.uuid, { key: k, value: String(v) }); } catch (e) { console.warn('[mcp apps.create] upsert env failed', k, e); } } } let deploymentUuid: string | null = null; if (params.instantDeploy !== false) { try { const dep = await deployApplication(created.uuid); deploymentUuid = dep.deployment_uuid ?? null; } catch (e) { console.warn('[mcp apps.create] first deploy failed', e); } } return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}`, deploymentUuid, }, }); } 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', 'build_pack', 'ports_exposes', 'install_command', 'build_command', 'start_command', 'base_directory', 'dockerfile_location', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'static_image', ]); 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 updateApplication(appUuid, patch); return NextResponse.json({ result: { ok: true, uuid: appUuid } }); } 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 }); } 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); } await setApplicationDomains(appUuid, normalized, { forceOverride: true }); return NextResponse.json({ result: { uuid: appUuid, domains: normalized } }); } // ────────────────────────────────────────────────── // 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 }, ); } }