From 040f0c6256cc91e8f9dd4b831761b5e1d294789f Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Thu, 23 Apr 2026 17:02:21 -0700 Subject: [PATCH] feat(mcp): proper Coolify Services support for composeRaw pathway Coolify's /applications/dockercompose creates a Service (not Application) with its own API surface. Wire it up correctly: lib/coolify.ts - createDockerComposeApp returns { uuid, resourceType: 'service' } - Add startService, stopService, getService, listAllServices helpers - Add listServiceEnvs, upsertServiceEnv, bulkUpsertServiceEnvs for the /services/{uuid}/envs endpoint app/api/mcp/route.ts - toolAppsList: includes Services (compose stacks) alongside Applications - toolAppsDeploy: falls back to /services/{uuid}/start for service UUIDs - toolAppsCreate composeRaw path: uses upsertServiceEnv + startService instead of Application deploy; notes that domain routing must be configured post-startup via SERVER_URL env Made-with: Cursor --- app/api/mcp/route.ts | 130 +++++++++++++++++++++++++++++++++++-------- lib/coolify.ts | 81 +++++++++++++++++++-------- 2 files changed, 164 insertions(+), 47 deletions(-) diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index a298709..65be40e 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -44,6 +44,12 @@ import { createPublicApp, createDockerImageApp, createDockerComposeApp, + startService, + getService, + listAllServices, + listServiceEnvs, + upsertServiceEnv, + type ServiceEnvVar, updateApplication, deleteApplication, setApplicationDomains, @@ -369,17 +375,45 @@ function requireCoolifyProject(principal: Principal): string | NextResponse { async function toolAppsList(principal: Principal) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; - const apps = await listApplicationsInProject(projectUuid); + + // Fetch Applications and Services in parallel. + // Services are compose stacks created via the composeRaw pathway; + // they live at /services not /applications. + const [apps, allServices] = await Promise.allSettled([ + listApplicationsInProject(projectUuid), + listAllServices(), + ]); + + const appList = apps.status === 'fulfilled' ? apps.value : []; + const serviceList = (allServices.status === 'fulfilled' && Array.isArray(allServices.value) + ? (allServices.value as Array>) + : [] + ).filter(s => { + const proj = s.project as Record | undefined; + return proj?.uuid === projectUuid; + }); + return NextResponse.json({ - result: apps.map(a => ({ - uuid: a.uuid, - name: a.name, - status: a.status, - fqdn: a.fqdn ?? null, - gitRepository: a.git_repository ?? null, - gitBranch: a.git_branch ?? null, - projectUuid: projectUuidOf(a), - })), + result: [ + ...appList.map(a => ({ + uuid: a.uuid, + name: a.name, + status: a.status, + fqdn: a.fqdn ?? null, + gitRepository: a.git_repository ?? null, + gitBranch: a.git_branch ?? null, + resourceType: 'application', + })), + ...serviceList.map(s => ({ + uuid: String(s.uuid), + name: String(s.name ?? ''), + status: String(s.status ?? 'unknown'), + fqdn: null, + gitRepository: null, + gitBranch: null, + resourceType: 'service', + })), + ], }); } @@ -401,9 +435,28 @@ async function toolAppsDeploy(principal: Principal, params: Record) if (!appUuid) { return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); } - await getApplicationInProject(appUuid, projectUuid); - const { deployment_uuid } = await deployApplication(appUuid); - return NextResponse.json({ result: { deploymentUuid: deployment_uuid, appUuid } }); + + // Try Application deploy first; fall back to Service start + try { + await getApplicationInProject(appUuid, projectUuid); + const { deployment_uuid } = await deployApplication(appUuid); + return NextResponse.json({ result: { deploymentUuid: deployment_uuid, appUuid, resourceType: 'application' } }); + } catch (appErr: unknown) { + // Check if it's a Service (compose stack) + try { + const svc = await getService(appUuid); + // Verify it belongs to this workspace's project + const proj = (svc.project as Record | undefined); + if (proj?.uuid !== projectUuid) { + return NextResponse.json({ error: 'Service not found in this workspace' }, { status: 404 }); + } + await startService(appUuid); + return NextResponse.json({ result: { appUuid, resourceType: 'service', message: 'Service start queued' } }); + } catch { + // Re-throw original error + throw appErr; + } + } } async function toolAppsDeployments(principal: Principal, params: Record) { @@ -751,29 +804,60 @@ async function toolAppsCreate(principal: Principal, params: Record) return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}` } }); } - // ── Pathway 3: Inline Docker Compose ───────────────────────────────── + // ── Pathway 3: Inline Docker Compose (creates a Coolify Service) ──── if (params.composeRaw) { const composeRaw = String(params.composeRaw).trim(); const appName = slugify(String(params.name ?? 'app')); const fqdn = resolveFqdn(params.domain, ws.slug, appName); if (fqdn instanceof NextResponse) return fqdn; - // composeDomains: array of { service, domain } or derive from fqdn - const composeDomains: Array<{ service: string; domain: string }> = - Array.isArray(params.composeDomains) && params.composeDomains.length > 0 - ? params.composeDomains - : [{ service: params.composeService ?? 'server', domain: fqdn }]; - const created = await createDockerComposeApp({ ...commonOpts, composeRaw, name: appName, description: params.description ? String(params.description) : undefined, - composeDomains, }); - await applyEnvsAndDeploy(created.uuid, params); - return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}` } }); + // Services use /services/{uuid}/envs — upsert each env var + 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) })); + if (envEntries.length > 0) { + try { + // Wait briefly for Coolify to commit the service to DB + await new Promise(r => setTimeout(r, 2000)); + for (const env of envEntries) { + await upsertServiceEnv(created.uuid, env); + } + } catch (e) { + console.warn('[mcp apps.create/composeRaw] upsert service env failed', e); + } + } + } + + // Optionally start the service + let started = false; + if (params.instantDeploy !== false) { + try { + await startService(created.uuid); + started = true; + } catch (e) { + console.warn('[mcp apps.create/composeRaw] service start failed', e); + } + } + + return NextResponse.json({ + result: { + uuid: created.uuid, + name: appName, + domain: fqdn, + url: `https://${fqdn}`, + resourceType: 'service', + started, + note: 'Domain routing for compose services must be configured in Coolify after initial startup — set SERVER_URL env to the desired URL.', + }, + }); } // ── Pathway 1: Gitea repo (original behaviour) ──────────────────────── diff --git a/lib/coolify.ts b/lib/coolify.ts index 6b9433d..69c76b9 100644 --- a/lib/coolify.ts +++ b/lib/coolify.ts @@ -513,13 +513,22 @@ export interface CreateDockerComposeAppOpts { composeDomains?: Array<{ service: string; domain: string }>; } +/** + * Create an inline Docker Compose stack. + * + * Coolify's /applications/dockercompose endpoint creates a **Service** + * resource (not an Application). Services have their own API: + * /services/{uuid} GET/PATCH + * /services/{uuid}/envs GET/POST + * /services/{uuid}/start POST + * /services/{uuid}/stop POST + * /services/{uuid} DELETE + * The UUID returned here is a service UUID; callers should treat it as + * such and use the service helpers below for lifecycle operations. + */ export async function createDockerComposeApp( opts: CreateDockerComposeAppOpts, -): Promise<{ uuid: string }> { - // NOTE: the /applications/dockercompose endpoint has a restricted - // field allowlist vs the PATCH endpoint. It rejects `build_pack` - // (hardcoded to "dockercompose"), `is_force_https_enabled`, and - // `docker_compose_domains` — those must be set via PATCH afterward. +): Promise<{ uuid: string; resourceType: 'service' }> { const body = stripUndefined({ project_uuid: opts.projectUuid, server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID, @@ -527,7 +536,6 @@ export async function createDockerComposeApp( destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID, name: opts.name, description: opts.description, - // Coolify requires docker_compose_raw to be base64-encoded docker_compose_raw: Buffer.from(opts.composeRaw, 'utf8').toString('base64'), instant_deploy: opts.instantDeploy ?? false, }); @@ -535,26 +543,51 @@ export async function createDockerComposeApp( method: 'POST', body: JSON.stringify(body), }) as { uuid: string }; + return { uuid: created.uuid, resourceType: 'service' }; +} - // Coolify creates apps asynchronously. Wait briefly before PATCHing - // so the record is committed to the DB. - if (opts.composeDomains && opts.composeDomains.length > 0) { - await new Promise(r => setTimeout(r, 2500)); - await coolifyFetch(`/applications/${created.uuid}`, { - method: 'PATCH', - body: JSON.stringify(stripUndefined({ - is_force_https_enabled: opts.isForceHttpsEnabled ?? true, - docker_compose_domains: JSON.stringify( - opts.composeDomains.map(({ service, domain }) => ({ - name: service, - domain: `https://${domain.replace(/^https?:\/\//, '')}`, - })), - ), - })), - }); - } +// ── Coolify Services API ───────────────────────────────────────────── - return created; +export async function startService(uuid: string): Promise { + await coolifyFetch(`/services/${uuid}/start`, { method: 'POST' }); +} + +export async function stopService(uuid: string): Promise { + await coolifyFetch(`/services/${uuid}/stop`, { method: 'POST' }); +} + +export async function getService(uuid: string): Promise> { + return coolifyFetch(`/services/${uuid}`); +} + +export interface ServiceEnvVar { key: string; value: string; is_preview?: boolean; is_literal?: boolean; } + +export async function listServiceEnvs(uuid: string): Promise { + return coolifyFetch(`/services/${uuid}/envs`); +} + +export async function bulkUpsertServiceEnvs( + uuid: string, + envs: ServiceEnvVar[], +): Promise { + await coolifyFetch(`/services/${uuid}/envs/bulk`, { + method: 'PUT', + body: JSON.stringify({ data: envs }), + }); +} + +export async function upsertServiceEnv( + uuid: string, + env: ServiceEnvVar, +): Promise { + await coolifyFetch(`/services/${uuid}/envs`, { + method: 'POST', + body: JSON.stringify(env), + }); +} + +export async function listAllServices(): Promise>> { + return coolifyFetch('/services') as Promise>>; } export async function updateApplication(