feat(mcp): workspace-set-aware tenant safety + richer chat system prompt

Stage 2 of per-project Coolify isolation:
- Add getApplicationInWorkspace / getDatabaseInWorkspace / getServiceInWorkspace
  helpers that verify a resource belongs to ANY of the workspace's owned
  Coolify projects (legacy workspace project + per-Vibn-project projects).
- Replace all single-resource MCP lookups (apps.get/delete/deploy/exec/logs/
  domains/envs/volumes/repair, databases.*, services) to use the new
  workspace-set-aware variants. Single-resource tools now correctly find
  apps deployed under per-project Coolify namespaces.
- Fix missing queryOne import.

Chat system prompt overhaul:
- Add deployment recipes (third-party app, custom Docker image, database, domain)
- Add troubleshooting playbook (stuck deploys, 502s, tenant errors, repair)
- Restate hard rules: always pass projectId, always search templates first,
  destructive ops require name confirm, surface long-running op timing.

Made-with: Cursor
This commit is contained in:
2026-04-27 19:21:20 -07:00
parent b9c8457eb3
commit 766352ec00
4 changed files with 275 additions and 57 deletions

View File

@@ -63,38 +63,59 @@ function buildSystemPrompt(projects: any[], workspace: string): string {
You are talking to the owner of the "${workspace}" workspace.
## How Vibn is structured
- **Workspace** ("${workspace}") — the tenant boundary. One per user. Owns the Gitea org and a fleet of Coolify projects.
- **Project** — an initiative the user is building (e.g. "Twenty CRM", "My Blog"). Each project has its OWN isolated Coolify project so all its apps + databases + services are grouped together. A project has both:
- **Workspace** ("${workspace}") — the tenant boundary. One per user. Owns the Gitea org and a fleet of Coolify projects. You can ONLY see and touch resources in this workspace.
- **Project** — an initiative the user is building (e.g. "Twenty CRM", "My Blog"). Each project has its OWN isolated Coolify project, so all its apps + databases + services are grouped together. A project has two facets that are part of ONE thing — never describe them as separate:
- Planning side: name, vision/objectives, requirements (from \`projects_get\`)
- Live side: deployed apps + services (in \`projects_get → possibleDeployments[]\` and \`apps_list { projectId }\`)
These are facets of ONE thing — never describe them as separate.
- Live side: deployed apps + services (from \`projects_get → possibleDeployments[]\` and \`apps_list { projectId }\`)
When the user asks about a project, call \`projects_get\` first — it returns both the planning details and the linked deployments. Use \`apps_list { projectId }\` to drill into running services for a specific project, or \`apps_list\` (no args) to see everything in the workspace.
## How to answer questions
- "What is project X?" → \`projects_get { id }\`. The result includes both planning details and the linked deployments.
- "What's running / what has a domain?" → \`apps_list\` (no args) for everything in the workspace, or \`apps_list { projectId }\` for one project.
- "Show me logs / containers / env" → resolve the app uuid first via \`apps_list\`, then call \`apps_logs\` / \`apps_containers_list\` / \`apps_envs_list\`.
- "Find an open source X" → \`github_search\` (always include \`license:mit\` unless the user says otherwise), then \`github_file\` to read READMEs / docker-compose.yml / design system entry points before recommending.
- "What's our docs say about Y?" → \`http_fetch\` against the relevant URL.
When deploying with \`apps_create\`, ALWAYS pass \`projectId\` so the new app/service lands inside the right project's isolated Coolify namespace. If the user hasn't specified a project, ask them which one.
## How to deploy
**Third-party app (Twenty CRM, n8n, Ghost, Supabase, Pocketbase, etc.)**
1. \`apps_templates_search { query }\` — find the official one-click template.
2. \`apps_create { projectId, name, template, domain }\` — deploy from template into the right project's Coolify namespace.
3. Watch \`apps_get { uuid }\` for status; surface the live URL once \`fqdn\` is set.
**Custom Docker image**
1. \`apps_create { projectId, name, dockerImage, domain, envsJson }\`.
2. \`apps_deploy { uuid }\` if it doesn't auto-deploy.
**Database**
1. \`databases_create { projectId, name, type }\` (type: postgres, mysql, redis, mongodb, mariadb, dragonfly, clickhouse, keydb).
2. \`databases_get { uuid }\` returns the internal connection URL — inject it into the app via \`apps_envs_set\`.
**Domain**
1. \`domains_search { query }\` to check availability + price.
2. \`domains_register { domain }\` to buy it (uses workspace billing).
3. \`apps_domains_set { uuid, domains }\` to attach. DNS + Traefik are wired automatically.
## Troubleshooting
- Deploy stuck or "exited (1)" → \`apps_logs { uuid }\` and \`apps_containers_list { uuid }\`. Common causes: missing env var, wrong port, image pull failure.
- 502 / "no available server" → app probably has no public domain yet. Check \`apps_get\`; if \`fqdn\` is empty, attach a domain.
- "tenant" / "does not belong to" errors → the uuid you passed isn't in this workspace. Re-list with \`apps_list\` to grab a valid one.
- Compose stack acting weird → \`apps_repair { uuid }\` to re-apply post-deploy fixes (Traefik labels, port forwarding).
- Need to nuke and re-deploy → \`apps_delete { uuid, confirm }\` (confirm must equal the app's exact name; fetch via \`apps_get\` first), then re-create.
## Hard rules
- ALWAYS pass \`projectId\` to \`apps_create\` and \`databases_create\`. If the user didn't say which project, ask once, then proceed.
- ALWAYS call \`apps_templates_search\` BEFORE \`apps_create\` when the user names a known third-party app — don't hand-roll a Docker image when a maintained template exists.
- Destructive ops (\`*_delete\`, \`*_volumes_wipe\`) require \`confirm\` equal to the resource's exact name. Always fetch the name first with a \`*_get\` call.
- Long-running ops (deploys, DNS provisioning, db provisioning) take 15 min. Tell the user up front so they don't think you're stuck.
- Be concise and action-oriented. If the user says "deploy X", do it — don't write a tutorial.
- After every tool call, summarize the result in 12 sentences. Don't dump raw JSON unless asked.
- Format app names, URLs, env keys, UUIDs, and file paths in backticks.
- If a tool errors and you don't understand why, say so honestly and suggest the next diagnostic call.
## Current workspace projects
${projectsText}
## Your capabilities
You have full access to the Vibn platform. You can:
- **Apps**: list, create, update, delete, deploy, get logs, exec commands, manage domains, manage env vars, manage volumes, repair broken deploys
- **Databases**: provision Postgres/MySQL/Redis/MongoDB and 5 other flavors, get connection URLs
- **Auth providers**: deploy Pocketbase, Authentik, Keycloak, Logto, SuperTokens, and others
- **Domains**: search availability, register, attach to apps with full DNS wiring
- **Storage**: provision GCS buckets, inject S3-compatible credentials into apps
- **Templates**: search and deploy from 320+ one-click app templates (n8n, Ghost, Supabase, etc.)
- **GitHub**: search open-source repos, read any file from a public repo
- **Web**: fetch any public URL or documentation page
## Key rules
- Call \`apps_list\` (not \`projects_list\`) when the user asks what is running or what has a domain.
- Always call \`apps_templates_search\` before \`apps_create\` for any popular third-party app.
- Be concise and action-oriented — if the user wants to deploy something, do it, don't just describe how.
- Confirm app name and domain before deploying unless the user has been explicit.
- After tool calls, summarize in plain language what happened.
- Format code, URLs, and technical values in backticks.
- Today's date: ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}.`;
Today's date: ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}.`;
}
export async function POST(request: Request) {

View File

@@ -44,7 +44,9 @@ import {
import { listContainersForApp } from '@/lib/coolify-containers';
import {
deployApplication,
getApplicationInProject,
getApplicationInWorkspace,
getDatabaseInWorkspace,
getServiceInWorkspace,
listApplicationDeployments,
listApplicationEnvs,
listApplicationsInProject,
@@ -68,18 +70,16 @@ import {
setApplicationDomains,
listDatabasesInProject,
createDatabase,
getDatabaseInProject,
updateDatabase,
deleteDatabase,
listServicesInProject,
createService,
getServiceInProject,
deleteService,
listServiceTemplates,
searchServiceTemplates,
type CoolifyDatabaseType,
} from '@/lib/coolify';
import { query } from '@/lib/db-postgres';
import { query, queryOne } from '@/lib/db-postgres';
import { getRepo } from '@/lib/gitea';
import {
giteaHttpsUrl,
@@ -540,8 +540,8 @@ async function toolAppsList(principal: Principal, params: Record<string, any> =
});
const serviceList = (Array.isArray(allServicesRes) ? allServicesRes : [])
.filter((s) => envToProject.has(Number(s.environment_id)))
.map((s) => ({ ...s, _coolifyProjectUuid: envToProject.get(Number(s.environment_id))! }));
.filter((s: any) => envToProject.has(Number(s.environment_id)))
.map((s: any) => ({ ...s, _coolifyProjectUuid: envToProject.get(Number(s.environment_id))! }));
return NextResponse.json({
result: [
@@ -576,17 +576,19 @@ async function toolAppsList(principal: Principal, params: Record<string, any> =
async function toolAppsGet(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
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 app = await getApplicationInWorkspace(appUuid, ownedUuids);
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 ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) {
return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
@@ -594,7 +596,7 @@ async function toolAppsDeploy(principal: Principal, params: Record<string, any>)
// Try Application deploy first; fall back to Service start
try {
await getApplicationInProject(appUuid, projectUuid);
await getApplicationInWorkspace(appUuid, ownedUuids);
const { deployment_uuid } = await deployApplication(appUuid);
return NextResponse.json({ result: { deploymentUuid: deployment_uuid, appUuid, resourceType: 'application' } });
} catch (appErr: unknown) {
@@ -620,11 +622,12 @@ async function toolAppsDeploy(principal: Principal, params: Record<string, any>)
async function toolAppsDeployments(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) {
return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
}
await getApplicationInProject(appUuid, projectUuid);
await getApplicationInWorkspace(appUuid, ownedUuids);
const deployments = await listApplicationDeployments(appUuid);
return NextResponse.json({ result: deployments });
}
@@ -642,11 +645,12 @@ async function toolAppsDeployments(principal: Principal, params: Record<string,
async function toolAppsLogs(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) {
return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
}
await getApplicationInProject(appUuid, projectUuid);
await getApplicationInWorkspace(appUuid, ownedUuids);
const linesRaw = Number(params.lines ?? 200);
const lines = Number.isFinite(linesRaw) ? linesRaw : 200;
@@ -675,6 +679,7 @@ async function toolAppsLogs(principal: Principal, params: Record<string, any>) {
async function toolAppsExec(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
if (!isCoolifySshConfigured()) {
return NextResponse.json(
@@ -694,7 +699,7 @@ async function toolAppsExec(principal: Principal, params: Record<string, any>) {
{ status: 400 },
);
}
await getApplicationInProject(appUuid, projectUuid);
await getApplicationInWorkspace(appUuid, ownedUuids);
const service = typeof params.service === 'string' && params.service.trim()
? params.service.trim()
@@ -739,12 +744,13 @@ async function toolAppsExec(principal: Principal, params: Record<string, any>) {
async function toolAppsVolumesList(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
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);
await getApplicationInWorkspace(appUuid, ownedUuids);
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)"'`,
@@ -781,6 +787,7 @@ async function toolAppsVolumesList(principal: Principal, params: Record<string,
async function toolAppsVolumesWipe(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
if (!isCoolifySshConfigured()) {
return NextResponse.json({ error: 'apps.volumes.wipe requires SSH to the Coolify host' }, { status: 501 });
}
@@ -803,7 +810,7 @@ async function toolAppsVolumesWipe(principal: Principal, params: Record<string,
{ status: 403 },
);
}
await getApplicationInProject(appUuid, projectUuid);
await getApplicationInWorkspace(appUuid, ownedUuids);
// Stop + remove all containers using this volume, then remove the volume
const cmd = [
@@ -839,11 +846,12 @@ function sq(s: string): string {
async function toolAppsEnvsList(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) {
return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
}
await getApplicationInProject(appUuid, projectUuid);
await getApplicationInWorkspace(appUuid, ownedUuids);
const envs = await listApplicationEnvs(appUuid);
return NextResponse.json({ result: envs });
}
@@ -851,6 +859,7 @@ async function toolAppsEnvsList(principal: Principal, params: Record<string, any
async function toolAppsEnvsUpsert(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
const key = typeof params.key === 'string' ? params.key : '';
const value = typeof params.value === 'string' ? params.value : '';
@@ -860,7 +869,7 @@ async function toolAppsEnvsUpsert(principal: Principal, params: Record<string, a
{ status: 400 }
);
}
await getApplicationInProject(appUuid, projectUuid);
await getApplicationInWorkspace(appUuid, ownedUuids);
// 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
@@ -885,6 +894,7 @@ async function toolAppsEnvsUpsert(principal: Principal, params: Record<string, a
async function toolAppsEnvsDelete(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
const key = typeof params.key === 'string' ? params.key : '';
if (!appUuid || !key) {
@@ -893,7 +903,7 @@ async function toolAppsEnvsDelete(principal: Principal, params: Record<string, a
{ status: 400 }
);
}
await getApplicationInProject(appUuid, projectUuid);
await getApplicationInWorkspace(appUuid, ownedUuids);
await deleteApplicationEnv(appUuid, key);
return NextResponse.json({ result: { ok: true, key } });
}
@@ -1245,8 +1255,8 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
// 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
// Tenant safety: the uuid is resolved via getApplicationInWorkspace /
// getServiceInWorkspace, so a workspace can't drive containers it
// doesn't own.
/** Resolve a uuid to either an Application or a compose Service in the
@@ -1258,8 +1268,9 @@ async function resolveAppOrService(
): Promise<{ uuid: string; kind: ResourceKind } | NextResponse> {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
try {
await getApplicationInProject(uuid, projectUuid);
await getApplicationInWorkspace(uuid, ownedUuids);
return { uuid, kind: 'application' };
} catch (e) {
if (!(e instanceof Error && /404|not found/i.test(e.message))) {
@@ -1269,7 +1280,7 @@ async function resolveAppOrService(
}
}
try {
await getServiceInProject(uuid, projectUuid);
await getServiceInWorkspace(uuid, ownedUuids);
return { uuid, kind: 'service' };
} catch (e) {
if (e instanceof TenantError) {
@@ -1674,10 +1685,11 @@ async function applyEnvsAndDeploy(
async function toolAppsUpdate(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
await getApplicationInProject(appUuid, projectUuid);
await getApplicationInWorkspace(appUuid, ownedUuids);
const allowed = new Set([
'name', 'description', 'git_branch', 'git_commit_sha', 'build_pack', 'ports_exposes',
@@ -1757,6 +1769,7 @@ async function toolAppsUpdate(principal: Principal, params: Record<string, any>)
async function toolAppsRewireGit(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
@@ -1769,7 +1782,7 @@ async function toolAppsRewireGit(principal: Principal, params: Record<string, an
);
}
const app = await getApplicationInProject(appUuid, projectUuid);
const app = await getApplicationInWorkspace(appUuid, ownedUuids);
let repoOrg: string;
let repoName: string;
@@ -1813,10 +1826,11 @@ async function toolAppsRewireGit(principal: Principal, params: Record<string, an
async function toolAppsDelete(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
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 app = await getApplicationInWorkspace(appUuid, ownedUuids);
const confirm = String(params.confirm ?? '');
if (confirm !== app.name) {
return NextResponse.json(
@@ -1839,9 +1853,10 @@ async function toolAppsDelete(principal: Principal, params: Record<string, any>)
async function toolAppsDomainsList(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
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 app = await getApplicationInWorkspace(appUuid, ownedUuids);
const raw = (app.domains ?? app.fqdn ?? '') as string;
const list = raw
.split(/[,\s]+/)
@@ -1855,12 +1870,13 @@ async function toolAppsDomainsSet(principal: Principal, params: Record<string, a
const ws = principal.workspace;
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
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 app = await getApplicationInWorkspace(appUuid, ownedUuids);
const normalized: string[] = [];
for (const d of domainsIn) {
if (typeof d !== 'string' || !d.trim()) continue;
@@ -1910,6 +1926,7 @@ const DB_TYPES: readonly CoolifyDatabaseType[] = [
async function toolDatabasesList(principal: Principal) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const dbs = await listDatabasesInProject(projectUuid);
return NextResponse.json({
result: dbs.map(d => ({
@@ -1927,6 +1944,7 @@ async function toolDatabasesCreate(principal: Principal, params: Record<string,
const ws = principal.workspace;
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const type = String(params.type ?? '').toLowerCase() as CoolifyDatabaseType;
if (!DB_TYPES.includes(type)) {
return NextResponse.json(
@@ -1950,7 +1968,7 @@ async function toolDatabasesCreate(principal: Principal, params: Record<string,
limits: params.limits && typeof params.limits === 'object' ? params.limits : undefined,
instantDeploy: params.instantDeploy !== false,
});
const db = await getDatabaseInProject(uuid, projectUuid);
const db = await getDatabaseInWorkspace(uuid, ownedUuids);
return NextResponse.json({
result: {
uuid: db.uuid,
@@ -1966,9 +1984,10 @@ async function toolDatabasesCreate(principal: Principal, params: Record<string,
async function toolDatabasesGet(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
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 db = await getDatabaseInWorkspace(uuid, ownedUuids);
return NextResponse.json({
result: {
uuid: db.uuid,
@@ -1986,9 +2005,10 @@ async function toolDatabasesGet(principal: Principal, params: Record<string, any
async function toolDatabasesUpdate(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const uuid = String(params.uuid ?? '').trim();
if (!uuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
await getDatabaseInProject(uuid, projectUuid);
await getDatabaseInWorkspace(uuid, ownedUuids);
const allowed = new Set(['name', 'description', 'is_public', 'public_port', 'image', 'limits_memory', 'limits_cpus']);
const patch: Record<string, unknown> = {};
for (const [k, v] of Object.entries(params)) {
@@ -2004,9 +2024,10 @@ async function toolDatabasesUpdate(principal: Principal, params: Record<string,
async function toolDatabasesDelete(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
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 db = await getDatabaseInWorkspace(uuid, ownedUuids);
const confirm = String(params.confirm ?? '');
if (confirm !== db.name) {
return NextResponse.json(
@@ -2044,6 +2065,7 @@ const AUTH_PROVIDERS_MCP: Record<string, string> = {
async function toolAuthList(principal: Principal) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const all = await listServicesInProject(projectUuid);
const slugs = new Set(Object.values(AUTH_PROVIDERS_MCP));
return NextResponse.json({
@@ -2065,6 +2087,7 @@ async function toolAuthCreate(principal: Principal, params: Record<string, any>)
const ws = principal.workspace;
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const key = String(params.provider ?? '').toLowerCase().trim();
const coolifyType = AUTH_PROVIDERS_MCP[key];
if (!coolifyType) {
@@ -2087,7 +2110,7 @@ async function toolAuthCreate(principal: Principal, params: Record<string, any>)
destinationUuid: ws.coolify_destination_uuid ?? undefined,
instantDeploy: params.instantDeploy !== false,
});
const svc = await getServiceInProject(uuid, projectUuid);
const svc = await getServiceInWorkspace(uuid, ownedUuids);
return NextResponse.json({
result: { uuid: svc.uuid, name: svc.name, provider: key, status: svc.status ?? null },
});
@@ -2096,9 +2119,10 @@ async function toolAuthCreate(principal: Principal, params: Record<string, any>)
async function toolAuthDelete(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
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 svc = await getServiceInWorkspace(uuid, ownedUuids);
const confirm = String(params.confirm ?? '');
if (confirm !== svc.name) {
return NextResponse.json(
@@ -2393,9 +2417,10 @@ async function toolStorageProvision(principal: Principal) {
async function toolStorageInjectEnv(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
const ownedUuids = await getOwnedCoolifyProjectUuids(principal.workspace);
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
if (!appUuid) return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
await getApplicationInProject(appUuid, projectUuid);
await getApplicationInWorkspace(appUuid, ownedUuids);
const prefix = String(params.prefix ?? 'STORAGE_');
if (!/^[A-Z][A-Z0-9_]*$/.test(prefix)) {

View File

@@ -1152,6 +1152,82 @@ export async function getServiceInProject(
return svc;
}
// ──────────────────────────────────────────────────
// Workspace-set-aware variants
// ──────────────────────────────────────────────────
// Each Vibn workspace owns multiple Coolify projects (the legacy
// `vibn-ws-{slug}` plus one per Vibn project: `vibn-{slug}-{project-slug}`).
// These helpers verify a resource belongs to ANY of the workspace's owned
// projects — used by single-resource MCP tools (apps.get/delete/exec/etc.)
// so they keep working after we shifted to per-project Coolify projects.
async function ensureResourceInWorkspaceProjects(
resource: CoolifyApplication | CoolifyDatabase | CoolifyService,
resourceKind: string,
ownedProjectUuids: Set<string>,
): Promise<void> {
if (ownedProjectUuids.size === 0) {
throw new TenantError(`${resourceKind} ${resource.uuid}: workspace owns no Coolify projects`);
}
const explicit = explicitProjectUuidOf(resource);
if (explicit && ownedProjectUuids.has(explicit)) return;
if (explicit && !ownedProjectUuids.has(explicit)) {
throw new TenantError(
`${resourceKind} ${resource.uuid} does not belong to this workspace`,
);
}
const envId = envIdOf(resource);
if (envId == null) {
throw new TenantError(
`${resourceKind} ${resource.uuid} has no environment_id; cannot verify workspace membership`,
);
}
// Build env_id → project_uuid map from all owned projects (parallel fetch).
const projects = await Promise.allSettled(
Array.from(ownedProjectUuids).map((uuid) => getProject(uuid)),
);
const allowedEnvIds = new Set<number>();
for (const r of projects) {
if (r.status === 'fulfilled') {
for (const env of r.value.environments ?? []) {
if (typeof env.id === 'number') allowedEnvIds.add(env.id);
}
}
}
if (!allowedEnvIds.has(envId)) {
throw new TenantError(
`${resourceKind} ${resource.uuid} does not belong to this workspace`,
);
}
}
export async function getApplicationInWorkspace(
appUuid: string,
ownedProjectUuids: Set<string>,
): Promise<CoolifyApplication> {
const app = await getApplication(appUuid);
await ensureResourceInWorkspaceProjects(app, 'Application', ownedProjectUuids);
return app;
}
export async function getDatabaseInWorkspace(
dbUuid: string,
ownedProjectUuids: Set<string>,
): Promise<CoolifyDatabase> {
const db = await getDatabase(dbUuid);
await ensureResourceInWorkspaceProjects(db, 'Database', ownedProjectUuids);
return db;
}
export async function getServiceInWorkspace(
serviceUuid: string,
ownedProjectUuids: Set<string>,
): Promise<CoolifyService> {
const svc = await getService(serviceUuid);
await ensureResourceInWorkspaceProjects(svc, 'Service', ownedProjectUuids);
return svc;
}
/**
* Response shape of GET /projects/{uuid}/{envName}.
* Coolify splits databases by flavor across sibling arrays.

View File

@@ -0,0 +1,96 @@
/**
* Smoke test: validate VIBN_TOOL_DEFINITIONS against the live Gemini API.
*
* Sends the full tool list with a trivial prompt and checks whether Gemini
* accepts the schemas. Catches schema validation errors (ARRAY without items,
* free OBJECT params, etc.) before we deploy.
*
* Usage: GOOGLE_API_KEY=... npx tsx scripts/smoke-chat-tools.ts
* (also picks up GOOGLE_API_KEY from .env.local automatically)
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { VIBN_TOOL_DEFINITIONS } from '../lib/ai/vibn-tools';
// Load .env.local manually to avoid needing dotenv as a dep
try {
const envPath = join(process.cwd(), '.env.local');
const envText = readFileSync(envPath, 'utf-8');
for (const line of envText.split('\n')) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim();
}
} catch {}
const API_KEY = process.env.GOOGLE_API_KEY;
const MODEL = process.env.VIBN_CHAT_MODEL || 'gemini-3.1-pro-preview';
if (!API_KEY) {
console.error('Missing GOOGLE_API_KEY');
process.exit(1);
}
async function validateAll() {
console.log(`Validating ${VIBN_TOOL_DEFINITIONS.length} tool definitions against ${MODEL}...\n`);
const url = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${API_KEY}`;
const body = {
contents: [{ role: 'user', parts: [{ text: 'Show me what is running in my workspace.' }] }],
tools: [{ functionDeclarations: VIBN_TOOL_DEFINITIONS }],
generationConfig: { temperature: 0.0, maxOutputTokens: 200 },
};
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
console.error(`\n❌ FAIL: HTTP ${res.status}`);
console.error(JSON.stringify(data, null, 2));
// Try to identify which tools are bad by sending them one at a time
console.log('\n🔍 Bisecting to find broken tools...\n');
const bad: { name: string; error: string }[] = [];
for (const tool of VIBN_TOOL_DEFINITIONS) {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
tools: [{ functionDeclarations: [tool] }],
generationConfig: { temperature: 0.0, maxOutputTokens: 10 },
}),
});
if (!r.ok) {
const err = await r.json();
const msg = err?.error?.message || JSON.stringify(err).slice(0, 200);
bad.push({ name: tool.name, error: msg });
console.log(`${tool.name}: ${msg.slice(0, 150)}`);
} else {
console.log(`${tool.name}`);
}
}
console.log(`\n${bad.length} broken tool(s) out of ${VIBN_TOOL_DEFINITIONS.length}`);
process.exit(1);
}
console.log('✅ All tool definitions accepted by Gemini.');
const calls = data?.candidates?.[0]?.content?.parts
?.filter((p: any) => p.functionCall)
.map((p: any) => `${p.functionCall.name}(${Object.keys(p.functionCall.args || {}).join(',')})`);
if (calls?.length) {
console.log(`\nGemini chose to call: ${calls.join(', ')}`);
} else {
console.log('\n(No tool calls produced — model responded with text)');
}
}
validateAll().catch((e) => {
console.error('Test crashed:', e);
process.exit(1);
});