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:
@@ -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) ────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user