Files
vibn-frontend/app/api/mcp/route.ts
Mark Henderson b9511601bc feat(ai-access): per-workspace Gitea bot + tenant-safe Coolify proxy + MCP
Ship Phases 1–3 of the multi-tenant AI access plan so an AI agent can
act on a Vibn workspace with one bearer token and zero admin reach.

Phase 1 — Gitea bot per workspace
- Add gitea_bot_username / gitea_bot_user_id / gitea_bot_token_encrypted
  columns to vibn_workspaces (migrate route).
- New lib/auth/secret-box.ts (AES-256-GCM, VIBN_SECRETS_KEY) for PAT at rest.
- Extend lib/gitea.ts with createUser, createAccessTokenFor (Sudo PAT),
  createOrgTeam, addOrgTeamMember, ensureOrgTeamMembership.
- ensureWorkspaceProvisioned now mints a vibn-bot-<slug> user, adds it to
  a Writers team (write perms only) on the workspace's org, and stores
  its PAT encrypted.
- GET /api/workspaces/[slug]/gitea-credentials returns a workspace-scoped
  bot PAT + clone URL template; session or vibn_sk_ bearer auth.

Phase 2 — Tenant-safe Coolify proxy + real MCP
- lib/coolify.ts: projectUuidOf, listApplicationsInProject,
  getApplicationInProject, TenantError, env CRUD, deployments list.
- Workspace-scoped REST endpoints (all filtered by coolify_project_uuid):
  GET/POST /api/workspaces/[slug]/apps/[uuid](/deploy|/envs|/deployments),
  GET /api/workspaces/[slug]/deployments/[deploymentUuid]/logs.
- Full rewrite of /api/mcp off legacy Firebase onto Postgres vibn_sk_
  keys, exposing workspace.describe, gitea.credentials, projects.*,
  apps.* (list/get/deploy/deployments, envs.list/upsert/delete).

Phase 3 — Settings UI AI bundle
- GET /api/workspaces/[slug]/bootstrap.sh: curl|sh installer that writes
  .cursor/rules, .cursor/mcp.json and appends VIBN_* to .env.local.
  Embeds the caller's vibn_sk_ token when invoked with bearer auth.
- WorkspaceKeysPanel: single AiAccessBundleCard with system-prompt block,
  one-line bootstrap, Reveal-bot-PAT button, collapsible manual-setup
  fallback. Minted-key modal also shows the bootstrap one-liner.

Ops prerequisites:
  - Set VIBN_SECRETS_KEY (>=16 chars) on the frontend.
  - Run /api/admin/migrate to add the three bot columns.
  - GITEA_API_TOKEN must be a site-admin token (needed for admin/users
    + Sudo PAT mint); otherwise provision_status lands on 'partial'.

Made-with: Cursor
2026-04-21 10:49:17 -07:00

354 lines
12 KiB
TypeScript

/**
* 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<string, unknown> };
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<string, any>;
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<ReturnType<typeof requireWorkspacePrincipal>>,
{ 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<string, any>) {
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<string, any>) {
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<string, any>) {
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<string, any>) {
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<string, any>) {
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<string, any>) {
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<string, any>) {
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 } });
}