/** * 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, } from '@/lib/coolify'; import { query } from '@/lib/db-postgres'; 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.0.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.deploy', 'apps.deployments', 'apps.envs.list', 'apps.envs.upsert', 'apps.envs.delete', ], }, }, 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); 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 } }); }