From e453e780cc7d7af082d36c702717120a65d7d547 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Thu, 23 Apr 2026 18:08:05 -0700 Subject: [PATCH] feat(mcp v2.4): apps.create template pathway + apps.templates.{list,search} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: "", ... }. 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 --- app/api/mcp/route.ts | 196 +++++++++++++++++++++++++++++++++++++++++-- lib/coolify.ts | 151 +++++++++++++++++++++++++++++++++ next.config.ts | 5 +- 3 files changed, 342 insertions(+), 10 deletions(-) diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index bcbe3bf..4029703 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -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) { const ws = principal.workspace; @@ -785,6 +799,100 @@ async function toolAppsCreate(principal: Principal, params: Record) 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; + // 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) + .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) // ── 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) }); } +// ────────────────────────────────────────────────── +// 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) { + 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) { + 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() diff --git a/lib/coolify.ts b/lib/coolify.ts index 42671c0..533c353 100644 --- a/lib/coolify.ts +++ b/lib/coolify.ts @@ -875,6 +875,157 @@ export async function createService(opts: { return coolifyFetch('/services', { method: 'POST', body: JSON.stringify(body) }); } +/** + * Patch a Coolify Service with arbitrary fields. Used for things like + * `urls` (custom domains), `name`, `description`, etc. + */ +export async function updateService( + uuid: string, + patch: Record, +): Promise<{ uuid: string }> { + return coolifyFetch(`/services/${uuid}`, { + method: 'PATCH', + body: JSON.stringify(stripUndefined(patch)), + }); +} + +/** + * Set custom URLs on a Coolify Service. Coolify auto-assigns sslip.io + * domains at service-creation time; this PATCH replaces them with the + * caller-supplied FQDN. + * + * Each entry in `urls` maps a compose service name (the YAML key) to a + * full URL. For most one-click templates the primary HTTP service is + * named after the template slug (e.g. template "twenty" → service + * "twenty"). If a template uses a different name, callers should pass + * the explicit mapping. + * + * Reasoning behind the shape: Coolify expects + * { urls: [{ name: "twenty", url: "https://my.example.com" }, ...] } + * where `name` is the docker-compose service key. Passing only `url` + * silently no-ops. + */ +export async function setServiceDomains( + uuid: string, + urls: Array<{ name: string; url: string }>, +): Promise<{ uuid: string }> { + const normalized = urls.map(u => ({ + name: u.name, + url: /^https?:\/\//i.test(u.url.trim()) ? u.url.trim() : `https://${u.url.trim()}`, + })); + return updateService(uuid, { urls: normalized }); +} + +// ── Coolify Service Templates (one-click catalog) ──────────────────── +// +// Coolify maintains a curated catalog of 320+ deployable services +// (CRMs, AI tools, CMSes, dashboards, databases, etc). Each entry has +// a slug ("twenty", "n8n", "supabase"), a slogan, tags, a logo path +// and a base64-encoded docker-compose.yml. +// +// Coolify itself does NOT expose this catalog over its REST API — the +// Laravel helper `get_service_templates()` reads it directly from +// `/var/www/html/templates/service-templates.json`. To avoid SSH-ing +// into the Coolify host every time, we fetch the canonical JSON from +// the upstream repo and cache it in memory for an hour. +// +// Source of truth: +// https://github.com/coollabsio/coolify/blob/main/templates/service-templates.json + +export interface CoolifyServiceTemplate { + /** Slug used as the `type` in `POST /services` (e.g. "twenty"). */ + slug: string; + /** One-line marketing description from upstream. */ + slogan?: string; + /** Free-form tags ("crm", "ai", "wiki", …). */ + tags?: string[]; + /** Coarse category (often missing). */ + category?: string; + /** Path under coollabsio/coolify-static/svgs (no full URL). */ + logo?: string; + /** Default HTTP port the primary service listens on. */ + port?: number; + /** Documentation URL provided by the template author. */ + documentation?: string; + /** Minimum Coolify version required. */ + minversion?: string; +} + +const TEMPLATES_URL = + process.env.COOLIFY_TEMPLATES_URL ?? + 'https://raw.githubusercontent.com/coollabsio/coolify/main/templates/service-templates.json'; + +let templatesCache: { fetchedAt: number; data: Record } | null = null; +const TEMPLATES_TTL_MS = 60 * 60 * 1000; + +/** + * Fetch the full Coolify service template catalog. In-memory cache for + * one hour because the upstream JSON is ~1MB and only changes when + * Coolify ships new templates. + */ +export async function listServiceTemplates(opts: { force?: boolean } = {}): Promise> { + const now = Date.now(); + if (!opts.force && templatesCache && now - templatesCache.fetchedAt < TEMPLATES_TTL_MS) { + return templatesCache.data; + } + const res = await fetch(TEMPLATES_URL, { cache: 'no-store' }); + if (!res.ok) throw new Error(`Failed to fetch service templates: ${res.status} ${res.statusText}`); + const raw = await res.json() as Record>; + const data: Record = {}; + for (const [slug, t] of Object.entries(raw)) { + data[slug] = { + slug, + slogan: typeof t.slogan === 'string' ? t.slogan : undefined, + tags: Array.isArray(t.tags) ? t.tags.filter((x): x is string => typeof x === 'string') : undefined, + category: typeof t.category === 'string' ? t.category : undefined, + logo: typeof t.logo === 'string' ? t.logo : undefined, + port: typeof t.port === 'number' ? t.port : undefined, + documentation: typeof t.documentation === 'string' ? t.documentation : undefined, + minversion: typeof t.minversion === 'string' ? t.minversion : undefined, + }; + } + templatesCache = { fetchedAt: now, data }; + return data; +} + +/** + * Search the template catalog by slug, slogan, or tag (case-insensitive + * substring match). Returns at most `limit` matches sorted by: + * 1. exact slug match + * 2. slug starts-with + * 3. tag match + * 4. slogan substring + */ +export async function searchServiceTemplates( + query: string, + opts: { limit?: number; tag?: string } = {}, +): Promise { + const all = await listServiceTemplates(); + const q = query.trim().toLowerCase(); + const tagFilter = opts.tag?.trim().toLowerCase(); + const limit = Math.max(1, Math.min(opts.limit ?? 25, 100)); + + const scored: Array<{ score: number; t: CoolifyServiceTemplate }> = []; + for (const t of Object.values(all)) { + if (tagFilter && !(t.tags ?? []).some(x => x.toLowerCase().includes(tagFilter))) continue; + let score = 0; + const slug = t.slug.toLowerCase(); + const slogan = (t.slogan ?? '').toLowerCase(); + const tags = (t.tags ?? []).map(x => x.toLowerCase()); + if (!q) { + score = 1; // tag-only filter — include everything that passed + } else if (slug === q) score = 1000; + else if (slug.startsWith(q)) score = 500; + else if (slug.includes(q)) score = 300; + else if (tags.includes(q)) score = 200; + else if (tags.some(x => x.includes(q))) score = 100; + else if (slogan.includes(q)) score = 50; + if (score > 0) scored.push({ score, t }); + } + scored.sort((a, b) => b.score - a.score || a.t.slug.localeCompare(b.t.slug)); + return scored.slice(0, limit).map(s => s.t); +} + export async function deleteService( uuid: string, opts: { diff --git a/next.config.ts b/next.config.ts index 0791260..451131d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -17,7 +17,10 @@ const nextConfig: NextConfig = { root: turbopackRoot, }, output: "standalone", - serverExternalPackages: ["@prisma/client", "prisma"], + // ssh2 ships native .node binaries; turbopack can't bundle them + // ("non-ecmascript placeable asset"). Externalize so they're loaded + // at runtime via Node's require, the same way @prisma/client works. + serverExternalPackages: ["@prisma/client", "prisma", "ssh2", "cpu-features"], typescript: { ignoreBuildErrors: true, },