feat(mcp v2.4): apps.create template pathway + apps.templates.{list,search}

Adds Coolify one-click template support — 320+ vetted apps deployable
in one MCP call (Twenty, n8n, Supabase, Ghost, etc).

- apps.create gains a 4th pathway: { template: "<slug>", ... }. Auto-
  rewrites the Coolify-assigned sslip URL to the workspace FQDN and
  applies user envs before starting.
- apps.templates.list / apps.templates.search expose the catalog so
  agents can discover slugs. Catalog is fetched from upstream GitHub
  and cached in-memory for 1h.
- lib/coolify.ts: + setServiceDomains, updateService, listService-
  Templates, searchServiceTemplates. Reuses existing createService.
- next.config.ts: externalize ssh2 + cpu-features from turbopack so
  `next build` can complete (native .node binaries can't be ESM-bundled).

Made-with: Cursor
This commit is contained in:
2026-04-23 18:08:05 -07:00
parent 7944db8ba4
commit e453e780cc
3 changed files with 342 additions and 10 deletions

View File

@@ -49,6 +49,7 @@ import {
listAllServices,
listServiceEnvs,
upsertServiceEnv,
setServiceDomains,
updateApplication,
deleteApplication,
setApplicationDomains,
@@ -61,6 +62,8 @@ import {
createService,
getServiceInProject,
deleteService,
listServiceTemplates,
searchServiceTemplates,
type CoolifyDatabaseType,
} from '@/lib/coolify';
import { query } from '@/lib/db-postgres';
@@ -82,7 +85,7 @@ const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
export async function GET() {
return NextResponse.json({
name: 'vibn-mcp',
version: '2.3.0',
version: '2.4.0',
authentication: {
scheme: 'Bearer',
tokenPrefix: 'vibn_sk_',
@@ -112,6 +115,8 @@ export async function GET() {
'apps.exec',
'apps.volumes.list',
'apps.volumes.wipe',
'apps.templates.list',
'apps.templates.search',
'apps.envs.list',
'apps.envs.upsert',
'apps.envs.delete',
@@ -212,6 +217,10 @@ export async function POST(request: Request) {
return await toolAppsVolumesList(principal, params);
case 'apps.volumes.wipe':
return await toolAppsVolumesWipe(principal, params);
case 'apps.templates.list':
return await toolAppsTemplatesList(params);
case 'apps.templates.search':
return await toolAppsTemplatesSearch(params);
case 'databases.list':
return await toolDatabasesList(principal);
@@ -746,9 +755,9 @@ async function toolAppsEnvsDelete(principal: Principal, params: Record<string, a
// ──────────────────────────────────────────────────
/**
* apps.create — three distinct pathways depending on what you pass:
* apps.create — four distinct pathways depending on what you pass:
*
* 1. Gitea repo (existing behaviour — for user-owned custom apps)
* 1. Gitea repo (for user-owned custom apps)
* Required: repo
* Optional: branch, buildPack, ports, domain, envs, …
*
@@ -760,12 +769,17 @@ async function toolAppsEnvsDelete(principal: Principal, params: Record<string, a
* Required: composeRaw (the full docker-compose.yml contents as a string)
* Optional: name, domain, composeDomains, envs
*
* Pathways 2 and 3 do NOT create a Gitea repo. They deploy directly
* from Docker Hub / any public registry, or from the raw YAML you
* supply. Use these for third-party apps (Twenty, Directus, Cal.com…).
* 4. **Coolify one-click template** (RECOMMENDED for popular apps)
* Required: template e.g. "twenty", "n8n", "supabase", "ghost"
* Optional: name, domain, envs
* Discoverable via apps.templates.list / apps.templates.search.
* Coolify ships 320+ vetted templates (CRMs, AI tools, CMSes, etc).
* Each template has battle-tested env defaults, healthchecks, and
* `depends_on` graphs — far more reliable than hand-rolling a
* composeRaw payload for the same app.
*
* Use pathway 1 for user's own code that lives in the workspace's
* Gitea org.
* Pathway 1 is for code in the workspace's Gitea org. Pathways 2/3/4
* deploy third-party apps without creating a Gitea repo.
*/
async function toolAppsCreate(principal: Principal, params: Record<string, any>) {
const ws = principal.workspace;
@@ -785,6 +799,100 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
instantDeploy: false,
};
// ── Pathway 4: Coolify one-click template ─────────────────────────────
// Most reliable path for popular third-party apps. Coolify maintains
// a curated catalog at templates/service-templates.json — each entry
// has tested env defaults and a working compose graph.
if (params.template) {
const templateSlug = String(params.template).trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9_-]*$/.test(templateSlug)) {
return NextResponse.json({ error: 'Invalid template slug' }, { status: 400 });
}
// Validate slug exists so we fail fast with a useful error rather
// than relaying Coolify's generic "Service not found".
const catalog = await listServiceTemplates();
if (!catalog[templateSlug]) {
return NextResponse.json({
error: `Unknown template "${templateSlug}". Use apps.templates.search to find valid slugs.`,
}, { status: 404 });
}
const appName = slugify(String(params.name ?? templateSlug));
const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn;
const created = await createService({
projectUuid: commonOpts.projectUuid,
serverUuid: commonOpts.serverUuid,
environmentName: commonOpts.environmentName,
destinationUuid: commonOpts.destinationUuid,
type: templateSlug,
name: appName,
description: params.description ? String(params.description) : undefined,
// Don't ask Coolify to instantly deploy — its queued worker has
// intermittent issues and we want to set the FQDN + envs first.
instantDeploy: false,
});
// Coolify auto-assigns sslip.io URLs. Replace them with the
// user's FQDN. We rebuild the urls array by reading the service
// back to learn the docker-compose service names (template-specific).
let urlsApplied = false;
try {
// Brief settle so the service is fully committed
await new Promise(r => setTimeout(r, 1500));
const svc = await getService(created.uuid) as Record<string, unknown>;
// Coolify stores per-service urls under different shapes across versions:
// - service.fqdn : "https://x.sslip.io,https://y.sslip.io"
// - service.urls : [{ name, url }]
// For simplicity, target the docker-compose service named after
// the template slug (covers ~90% of templates: twenty, n8n, ghost,
// wordpress, etc). Users can adjust later via apps.domains.set.
await setServiceDomains(created.uuid, [{ name: templateSlug, url: `https://${fqdn}` }]);
urlsApplied = true;
void svc; // reserved for future heuristic
} catch (e) {
console.warn('[mcp apps.create/template] setServiceDomains failed', e);
}
// Apply user-provided envs (e.g. POSTGRES_PASSWORD overrides)
if (params.envs && typeof params.envs === 'object') {
const envEntries = Object.entries(params.envs as Record<string, unknown>)
.filter(([k]) => /^[A-Z_][A-Z0-9_]*$/i.test(k))
.map(([key, value]) => ({ key, value: String(value) }));
for (const env of envEntries) {
try { await upsertServiceEnv(created.uuid, env); }
catch (e) { console.warn('[mcp apps.create/template] upsert env failed', env.key, e); }
}
}
let started = false;
if (params.instantDeploy !== false) {
try {
await startService(created.uuid);
started = true;
} catch (e) {
console.warn('[mcp apps.create/template] service start failed', e);
}
}
return NextResponse.json({
result: {
uuid: created.uuid,
name: appName,
domain: fqdn,
url: `https://${fqdn}`,
resourceType: 'service',
template: templateSlug,
urlsApplied,
started,
note: started
? 'Service start was queued. First boot may take 1-5 min while Coolify pulls images and runs migrations. Use apps.logs to monitor.'
: 'Service created but not yet started. Call apps.deploy to start it.',
},
});
}
// ── Pathway 2: Docker image ───────────────────────────────────────────
if (params.image) {
const image = String(params.image).trim();
@@ -864,7 +972,7 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
// ── Pathway 1: Gitea repo (original behaviour) ────────────────────────
if (!ws.gitea_org) {
return NextResponse.json(
{ error: 'Workspace not fully provisioned (need Gitea org). For third-party apps, use `image` or `composeRaw` instead of `repo`.' },
{ error: 'Workspace not fully provisioned (need Gitea org). For third-party apps, use `template` (recommended), `image`, or `composeRaw` instead of `repo`.' },
{ status: 503 }
);
}
@@ -928,6 +1036,76 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
});
}
// ──────────────────────────────────────────────────
// apps.templates.* — Coolify one-click catalog browse
// ──────────────────────────────────────────────────
//
// Coolify ships ~320 vetted service templates (CRMs, AI, CMS, etc).
// These tools let agents discover what's available so they can pass
// the right slug to apps.create({ template: "..." }).
/**
* apps.templates.list — paginate the full catalog.
*
* Params:
* limit number, default 50, max 500
* offset number, default 0
* tag string, optional — restrict to templates whose tags include this substring
*
* Result: { total, items: CoolifyServiceTemplate[] }
*
* The catalog is large (~320 entries), so use apps.templates.search
* when you know what you're looking for.
*/
async function toolAppsTemplatesList(params: Record<string, any>) {
const all = await listServiceTemplates();
const tagFilter = (params.tag ? String(params.tag).trim().toLowerCase() : '');
const limit = Math.max(1, Math.min(Number(params.limit ?? 50) || 50, 500));
const offset = Math.max(0, Number(params.offset ?? 0) || 0);
let entries = Object.values(all);
if (tagFilter) {
entries = entries.filter(t => (t.tags ?? []).some(x => x.toLowerCase().includes(tagFilter)));
}
entries.sort((a, b) => a.slug.localeCompare(b.slug));
return NextResponse.json({
result: {
total: entries.length,
offset,
limit,
items: entries.slice(offset, offset + limit),
},
});
}
/**
* apps.templates.search — find templates by name, tag, or slogan.
*
* Params:
* query string (required) — case-insensitive substring; matches
* slug > tag > slogan in priority order
* tag string, optional — additional tag filter
* limit number, default 25, max 100
*
* Result: { items: CoolifyServiceTemplate[] }
*
* Examples:
* { query: "twenty" } → [{ slug: "twenty", ... }]
* { query: "wordpress" } → 4 wordpress variants
* { query: "", tag: "crm" } → all CRM templates
* { query: "ai", tag: "vector" } → vector DBs
*/
async function toolAppsTemplatesSearch(params: Record<string, any>) {
const query = String(params.query ?? '').trim();
const tag = params.tag ? String(params.tag).trim() : undefined;
if (!query && !tag) {
return NextResponse.json({ error: 'Either `query` or `tag` is required' }, { status: 400 });
}
const limit = Math.max(1, Math.min(Number(params.limit ?? 25) || 25, 100));
const items = await searchServiceTemplates(query, { tag, limit });
return NextResponse.json({ result: { items } });
}
/** Resolve fqdn from params.domain or auto-generate. Returns NextResponse on policy error. */
function resolveFqdn(domainParam: unknown, slug: string, appName: string): string | NextResponse {
const fqdn = String(domainParam ?? '').trim()