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,
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<Record<string, unknown>>)
: []
).filter(s => {
const proj = s.project as Record<string, unknown> | 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<string, any>)
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<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>) {
@@ -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}` } });
}
// ── 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<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) ────────────────────────