diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 5323f5d..27222ce 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -617,15 +617,65 @@ async function toolAppsUpdate(principal: Principal, params: Record) 'base_directory', 'dockerfile_location', 'docker_compose_location', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'static_image', ]); + // ── Control params (never forwarded to Coolify) ───────────────────── + // `uuid`/`appUuid` identify the target; we've consumed them already. + const control = new Set(['uuid', 'appUuid', 'patch']); + // ── Fields we deliberately DO NOT forward from apps.update ───────── + // Each maps to a different tool; silently dropping them the way we + // used to caused real live-test bugs (PATCH returns ok, nothing + // persists, agent thinks it worked). + const redirected: Record = { + fqdn: 'apps.domains.set', + domains: 'apps.domains.set', + docker_compose_domains: 'apps.domains.set', + git_repository: 'apps.rewire_git', + }; + + // Support both the flat `{ uuid, name, description, ... }` shape and + // the explicit `{ uuid, patch: { name, description, ... } }` shape. + const source: Record = + params.patch && typeof params.patch === 'object' && !Array.isArray(params.patch) + ? (params.patch as Record) + : params; + const patch: Record = {}; - for (const [k, v] of Object.entries(params)) { - if (allowed.has(k) && v !== undefined) patch[k] = v; + const ignored: string[] = []; + const rerouted: Array<{ field: string; use: string }> = []; + for (const [k, v] of Object.entries(source)) { + if (v === undefined) continue; + if (control.has(k) && source === params) continue; + if (redirected[k]) { rerouted.push({ field: k, use: redirected[k] }); continue; } + if (allowed.has(k)) { patch[k] = v; continue; } + ignored.push(k); } + if (Object.keys(patch).length === 0) { - return NextResponse.json({ error: 'No updatable fields in params' }, { status: 400 }); + return NextResponse.json( + { + error: + rerouted.length + ? 'No updatable fields in params. Some fields must be set via other tools — see `rerouted`.' + : 'No updatable fields in params. See `ignored` and `allowed`.', + rerouted, + ignored, + allowed: [...allowed], + }, + { status: 400 }, + ); } await updateApplication(appUuid, patch); - return NextResponse.json({ result: { ok: true, uuid: appUuid } }); + return NextResponse.json({ + result: { + ok: true, + uuid: appUuid, + applied: Object.keys(patch), + // Non-empty `ignored`/`rerouted` are NOT errors but callers need to + // see them; silently dropping unrecognised keys was the original + // "fqdn returns ok but doesn't persist" false-positive. + ...(ignored.length ? { ignored } : {}), + ...(rerouted.length ? { rerouted } : {}), + }, + }); } /** @@ -742,7 +792,7 @@ async function toolAppsDomainsSet(principal: Principal, params: Record { // Coolify validates each entry as a URL, so bare hostnames need a scheme. const normalized = domains.map(d => { @@ -476,16 +492,34 @@ export async function setApplicationDomains( if (/^https?:\/\//i.test(trimmed)) return trimmed; return `https://${trimmed}`; }); - // Coolify API: send `domains` (NOT `fqdn`). The controller maps it to - // the DB's `fqdn` column internally, but only when the destination - // server has `proxy.type=TRAEFIK` (or CADDY) AND `is_build_server=false` - // — i.e. when Server::isProxyShouldRun() returns true. If either is - // misconfigured, the controller silently drops the field (PATCH returns - // 200, fqdn unchanged). We hit this on the missinglettr-test app on - // 2026-04-22; the underlying server had proxy.type=null and - // is_build_server=true. Fix is in Coolify server-config (UI/DB), not - // the client. Sending `fqdn` directly is rejected with 422 ("This - // field is not allowed"). + + let buildPack = opts.buildPack; + if (!buildPack) { + const app = await getApplication(uuid); + buildPack = (app.build_pack ?? 'nixpacks') as string; + } + + // ── Compose apps: set per-service domains ────────────────────────── + // Coolify hard-rejects the top-level `domains` field for dockercompose + // (HTTP 422: "Use docker_compose_domains instead"). Domains live per + // compose service — we default to the `server` service, which matches + // the majority of self-hostable apps (Twenty, Plane, Cal.com, etc.). + if (buildPack === 'dockercompose') { + const service = (opts.composeService ?? 'server').trim(); + // Coolify accepts an array of {name, domain}; ONE entry per service. + // Multiple domains → comma-join into the single service's `domain` + // field (Coolify splits on comma internally). + const payload = [{ name: service, domain: normalized.join(',') }]; + return updateApplication(uuid, { docker_compose_domains: payload }); + } + + // ── Single-container apps: top-level `domains` (maps to fqdn) ────── + // Coolify maps `domains` → the DB `fqdn` column, but only when the + // destination server has `proxy.type=TRAEFIK`/`CADDY` AND + // `is_build_server=false` (Server::isProxyShouldRun() returns true). + // If either is misconfigured, the PATCH silently drops the field + // (200 OK but fqdn unchanged). Fix that in Coolify's server config, + // not here. Sending `fqdn` directly returns 422. return updateApplication(uuid, { domains: normalized.join(','), force_domain_override: opts.forceOverride ?? true,