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:
@@ -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 1–5 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 1–2 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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
96
scripts/smoke-chat-tools.ts
Normal file
96
scripts/smoke-chat-tools.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user