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
This commit is contained in:
2026-04-23 17:02:21 -07:00
parent f27e572fdb
commit 040f0c6256
2 changed files with 164 additions and 47 deletions

View File

@@ -44,6 +44,12 @@ import {
createPublicApp, createPublicApp,
createDockerImageApp, createDockerImageApp,
createDockerComposeApp, createDockerComposeApp,
startService,
getService,
listAllServices,
listServiceEnvs,
upsertServiceEnv,
type ServiceEnvVar,
updateApplication, updateApplication,
deleteApplication, deleteApplication,
setApplicationDomains, setApplicationDomains,
@@ -369,17 +375,45 @@ function requireCoolifyProject(principal: Principal): string | NextResponse {
async function toolAppsList(principal: Principal) { async function toolAppsList(principal: Principal) {
const projectUuid = requireCoolifyProject(principal); const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid; 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<Record<string, unknown>>)
: []
).filter(s => {
const proj = s.project as Record<string, unknown> | undefined;
return proj?.uuid === projectUuid;
});
return NextResponse.json({ return NextResponse.json({
result: apps.map(a => ({ result: [
uuid: a.uuid, ...appList.map(a => ({
name: a.name, uuid: a.uuid,
status: a.status, name: a.name,
fqdn: a.fqdn ?? null, status: a.status,
gitRepository: a.git_repository ?? null, fqdn: a.fqdn ?? null,
gitBranch: a.git_branch ?? null, gitRepository: a.git_repository ?? null,
projectUuid: projectUuidOf(a), 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<string, any>)
if (!appUuid) { if (!appUuid) {
return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 }); return NextResponse.json({ error: 'Param "uuid" is required' }, { status: 400 });
} }
await getApplicationInProject(appUuid, projectUuid);
const { deployment_uuid } = await deployApplication(appUuid); // Try Application deploy first; fall back to Service start
return NextResponse.json({ result: { deploymentUuid: deployment_uuid, appUuid } }); 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<string, unknown> | 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<string, any>) { async function toolAppsDeployments(principal: Principal, params: Record<string, any>) {
@@ -751,29 +804,60 @@ async function toolAppsCreate(principal: Principal, params: Record<string, any>)
return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}` } }); 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) { if (params.composeRaw) {
const composeRaw = String(params.composeRaw).trim(); const composeRaw = String(params.composeRaw).trim();
const appName = slugify(String(params.name ?? 'app')); const appName = slugify(String(params.name ?? 'app'));
const fqdn = resolveFqdn(params.domain, ws.slug, appName); const fqdn = resolveFqdn(params.domain, ws.slug, appName);
if (fqdn instanceof NextResponse) return fqdn; 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({ const created = await createDockerComposeApp({
...commonOpts, ...commonOpts,
composeRaw, composeRaw,
name: appName, name: appName,
description: params.description ? String(params.description) : undefined, description: params.description ? String(params.description) : undefined,
composeDomains,
}); });
await applyEnvsAndDeploy(created.uuid, params); // Services use /services/{uuid}/envs — upsert each env var
return NextResponse.json({ result: { uuid: created.uuid, name: appName, domain: fqdn, url: `https://${fqdn}` } }); 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) }));
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) ──────────────────────── // ── Pathway 1: Gitea repo (original behaviour) ────────────────────────

View File

@@ -513,13 +513,22 @@ export interface CreateDockerComposeAppOpts {
composeDomains?: Array<{ service: string; domain: string }>; 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( export async function createDockerComposeApp(
opts: CreateDockerComposeAppOpts, opts: CreateDockerComposeAppOpts,
): Promise<{ uuid: string }> { ): Promise<{ uuid: string; resourceType: 'service' }> {
// 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.
const body = stripUndefined({ const body = stripUndefined({
project_uuid: opts.projectUuid, project_uuid: opts.projectUuid,
server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID, server_uuid: opts.serverUuid ?? COOLIFY_DEFAULT_SERVER_UUID,
@@ -527,7 +536,6 @@ export async function createDockerComposeApp(
destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID, destination_uuid: opts.destinationUuid ?? COOLIFY_DEFAULT_DESTINATION_UUID,
name: opts.name, name: opts.name,
description: opts.description, description: opts.description,
// Coolify requires docker_compose_raw to be base64-encoded
docker_compose_raw: Buffer.from(opts.composeRaw, 'utf8').toString('base64'), docker_compose_raw: Buffer.from(opts.composeRaw, 'utf8').toString('base64'),
instant_deploy: opts.instantDeploy ?? false, instant_deploy: opts.instantDeploy ?? false,
}); });
@@ -535,26 +543,51 @@ export async function createDockerComposeApp(
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}) as { uuid: string }; }) as { uuid: string };
return { uuid: created.uuid, resourceType: 'service' };
}
// Coolify creates apps asynchronously. Wait briefly before PATCHing // ── Coolify Services API ─────────────────────────────────────────────
// 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?:\/\//, '')}`,
})),
),
})),
});
}
return created; export async function startService(uuid: string): Promise<void> {
await coolifyFetch(`/services/${uuid}/start`, { method: 'POST' });
}
export async function stopService(uuid: string): Promise<void> {
await coolifyFetch(`/services/${uuid}/stop`, { method: 'POST' });
}
export async function getService(uuid: string): Promise<Record<string, unknown>> {
return coolifyFetch(`/services/${uuid}`);
}
export interface ServiceEnvVar { key: string; value: string; is_preview?: boolean; is_literal?: boolean; }
export async function listServiceEnvs(uuid: string): Promise<ServiceEnvVar[]> {
return coolifyFetch(`/services/${uuid}/envs`);
}
export async function bulkUpsertServiceEnvs(
uuid: string,
envs: ServiceEnvVar[],
): Promise<void> {
await coolifyFetch(`/services/${uuid}/envs/bulk`, {
method: 'PUT',
body: JSON.stringify({ data: envs }),
});
}
export async function upsertServiceEnv(
uuid: string,
env: ServiceEnvVar,
): Promise<void> {
await coolifyFetch(`/services/${uuid}/envs`, {
method: 'POST',
body: JSON.stringify(env),
});
}
export async function listAllServices(): Promise<Array<Record<string, unknown>>> {
return coolifyFetch('/services') as Promise<Array<Record<string, unknown>>>;
} }
export async function updateApplication( export async function updateApplication(