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