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