@masonator/coolify-mcp 2.12.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,6 +46,24 @@ function mapFqdnToDomains(data) {
46
46
  }
47
47
  return { ...rest, domains: fqdn };
48
48
  }
49
+ /**
50
+ * Map a failed response's status/path to an actionable hint for known Coolify quirks.
51
+ * Coolify sometimes returns bodyless errors (e.g. bare `HTTP 500: Internal Server Error`)
52
+ * that leave the caller guessing at the cause — this appends a short, testable hint for
53
+ * the cases we've hit in practice. Returns undefined when no known case matches.
54
+ */
55
+ export function errorHint(status, path) {
56
+ if (status === 500 && /\/scheduled-tasks(\/|$)/.test(path)) {
57
+ return 'Known cause: Coolify stores scheduled-task `command` in a varchar(255) column and rejects longer commands with a bodyless 500 — check the command length (limit 255 chars).';
58
+ }
59
+ if (status === 401 || status === 403) {
60
+ return 'Check that COOLIFY_ACCESS_TOKEN is valid and has the required scopes for this operation.';
61
+ }
62
+ if (status === 404 && /\/[\w-]{8,}(\/|$)/.test(path)) {
63
+ return 'The uuid may belong to a different resource type than requested (e.g. an application uuid used on a service/database route).';
64
+ }
65
+ return undefined;
66
+ }
49
67
  // =============================================================================
50
68
  // Summary Transformers - reduce full objects to essential fields
51
69
  // =============================================================================
@@ -305,6 +323,10 @@ export class CoolifyClient {
305
323
  .join('; ');
306
324
  errorMessage = `${errorMessage} - ${validationDetails}`;
307
325
  }
326
+ const hint = errorHint(response.status, path);
327
+ if (hint) {
328
+ errorMessage = `${errorMessage} (${hint})`;
329
+ }
308
330
  throw new Error(errorMessage);
309
331
  }
310
332
  return data;
@@ -520,6 +542,12 @@ export class CoolifyClient {
520
542
  body: JSON.stringify(mapFqdnToDomains(data)),
521
543
  });
522
544
  }
545
+ /**
546
+ * @deprecated Coolify removed POST /applications/dockercompose upstream in
547
+ * v4.1.0 (coollabsio/coolify commit 6ee75cfa) in favour of POST /services.
548
+ * This 404s against current Coolify releases; use createService instead.
549
+ * Not exposed via any MCP tool — see #235.
550
+ */
523
551
  async createApplicationDockerCompose(data) {
524
552
  const mapped = mapFqdnToDomains(data);
525
553
  const payload = { ...mapped };
@@ -767,8 +795,9 @@ export class CoolifyClient {
767
795
  method: 'GET',
768
796
  });
769
797
  }
770
- async restartService(uuid) {
771
- return this.request(`/services/${uuid}/restart`, {
798
+ async restartService(uuid, pullLatest = false) {
799
+ const qs = pullLatest ? '?latest=true' : '';
800
+ return this.request(`/services/${uuid}/restart${qs}`, {
772
801
  method: 'GET',
773
802
  });
774
803
  }
@@ -818,7 +847,13 @@ export class CoolifyClient {
818
847
  }
819
848
  async getDeployment(uuid, options) {
820
849
  const deployment = await this.request(`/deployments/${uuid}`);
821
- return options?.includeLogs ? deployment : toDeploymentEssential(deployment);
850
+ const essential = toDeploymentEssential(deployment);
851
+ // Attach the raw log string (never the raw upstream object, which also embeds
852
+ // the full application/server graph and secrets) onto the essential projection.
853
+ if (options?.includeLogs && deployment.logs) {
854
+ essential.logs = deployment.logs;
855
+ }
856
+ return essential;
822
857
  }
823
858
  async deployByTagOrUuid(tagOrUuid, force = false) {
824
859
  // Detect if the value looks like a UUID or a tag name
@@ -834,14 +869,22 @@ export class CoolifyClient {
834
869
  * By default returns a DeploymentEssential summary (no `logs` field) because
835
870
  * each deployment's log blob can be 30–100KB, and a typical list has 20–35
836
871
  * deployments — exceeding MCP response token limits. Pass `includeLogs: true`
837
- * to get raw Deployment objects with full build logs.
872
+ * to also attach the raw log string to each essential projection (never the
873
+ * raw upstream deployment object, which also embeds the full application/server
874
+ * graph and secrets).
838
875
  */
839
876
  async listApplicationDeployments(appUuid, options) {
840
877
  const envelope = await this.request(`/deployments/applications/${appUuid}`);
841
878
  const deployments = Array.isArray(envelope?.deployments) ? envelope.deployments : [];
842
879
  return {
843
880
  count: typeof envelope?.count === 'number' ? envelope.count : deployments.length,
844
- deployments: options?.includeLogs ? deployments : deployments.map(toDeploymentEssential),
881
+ deployments: deployments.map((dep) => {
882
+ const essential = toDeploymentEssential(dep);
883
+ if (options?.includeLogs && dep.logs) {
884
+ essential.logs = dep.logs;
885
+ }
886
+ return essential;
887
+ }),
845
888
  };
846
889
  }
847
890
  // ===========================================================================
@@ -1372,6 +1415,18 @@ export class CoolifyClient {
1372
1415
  healthStatus = 'unhealthy';
1373
1416
  }
1374
1417
  }
1418
+ // Cross-check: the container can be running:healthy while the latest
1419
+ // deployment failed/was cancelled — old code still serving, new code
1420
+ // never arrived (#239). Skip silently if we have no app or no deployments.
1421
+ if (app && deployments && deployments.length > 0) {
1422
+ const latestDeployment = deployments[0];
1423
+ const appIsRunning = (app.status || '').includes('running');
1424
+ if (appIsRunning &&
1425
+ (latestDeployment.status === 'failed' || latestDeployment.status === 'cancelled')) {
1426
+ issues.push(`Running container predates the last (${latestDeployment.status}) deployment (${latestDeployment.uuid}) — the app is serving stale code. Use the deployment tool (action: get, uuid: ${latestDeployment.uuid}, lines) to see why it ${latestDeployment.status}.`);
1427
+ healthStatus = 'unhealthy';
1428
+ }
1429
+ }
1375
1430
  return {
1376
1431
  application: app
1377
1432
  ? {
@@ -30,5 +30,38 @@ export declare class CoolifyMcpServer extends McpServer {
30
30
  private readonly docsSearch;
31
31
  constructor(config: CoolifyConfig);
32
32
  connect(transport: Transport): Promise<void>;
33
+ /**
34
+ * Poll a single deployment until it reaches a terminal status or the
35
+ * timeout elapses. Uses `getDeployment`'s no-logs projection
36
+ * (`DeploymentEssential`) while polling, and only fetches logs (once,
37
+ * truncated) if the deployment failed.
38
+ */
39
+ private pollDeployment;
40
+ /**
41
+ * Trigger a deploy and wait for it to finish. A tag can resolve to
42
+ * multiple applications, so `deployByTagOrUuid` may return several
43
+ * `deployment_uuid`s — only the first is polled; any others are
44
+ * surfaced under `additional_deployment_uuids` for the caller to check
45
+ * separately via `deployment get`.
46
+ */
47
+ private triggerAndWaitForDeploy;
33
48
  private registerTools;
49
+ /**
50
+ * Injectable delay for the run_once poll loop. A real setTimeout in production;
51
+ * tests replace it with `jest.spyOn(server, 'sleep').mockResolvedValue(undefined)`
52
+ * so polling logic runs without waiting on the wall clock.
53
+ */
54
+ private sleep;
55
+ /**
56
+ * Composite one-off command execution (#233 / #208): there is no upstream
57
+ * "run now" endpoint for scheduled tasks, so this creates a throwaway
58
+ * `* * * * *` task, polls list_executions until the first execution reaches a
59
+ * terminal status (or the poll budget runs out), and returns its status+message.
60
+ *
61
+ * The task is deleted in a finally-equivalent (try/finally-style) block so cleanup
62
+ * always runs — on success, on timeout, and on a polling error. If cleanup itself
63
+ * fails, the returned message says so loudly with the task UUID so a human can
64
+ * remove it manually (it would otherwise keep firing every minute).
65
+ */
66
+ private runOnceScheduledTask;
34
67
  }
@@ -152,6 +152,33 @@ function wrapWithActions(fn, getActions, getPaginationFn) {
152
152
  ],
153
153
  }));
154
154
  }
155
+ // =============================================================================
156
+ // Deploy wait/poll helpers (#238)
157
+ // =============================================================================
158
+ /** Deployment statuses that end a run — polling stops once one of these is hit. */
159
+ const TERMINAL_DEPLOYMENT_STATUSES = new Set([
160
+ 'finished',
161
+ 'failed',
162
+ 'cancelled',
163
+ ]);
164
+ const DEFAULT_DEPLOY_TIMEOUT_SECONDS = 300;
165
+ const DEPLOY_POLL_INTERVAL_MS = 5000;
166
+ function isTerminalDeploymentStatus(status) {
167
+ return TERMINAL_DEPLOYMENT_STATUSES.has(status);
168
+ }
169
+ /** Isolated so tests can drive polling with jest fake timers instead of real waits. */
170
+ function sleep(ms) {
171
+ return new Promise((resolve) => setTimeout(resolve, ms));
172
+ }
173
+ function durationSeconds(createdAt, updatedAt) {
174
+ if (!createdAt || !updatedAt)
175
+ return undefined;
176
+ const start = Date.parse(createdAt);
177
+ const end = Date.parse(updatedAt);
178
+ if (Number.isNaN(start) || Number.isNaN(end))
179
+ return undefined;
180
+ return Math.max(0, Math.round((end - start) / 1000));
181
+ }
155
182
  export class CoolifyMcpServer extends McpServer {
156
183
  client;
157
184
  docsSearch = new DocsSearchEngine();
@@ -163,6 +190,82 @@ export class CoolifyMcpServer extends McpServer {
163
190
  async connect(transport) {
164
191
  await super.connect(transport);
165
192
  }
193
+ /**
194
+ * Poll a single deployment until it reaches a terminal status or the
195
+ * timeout elapses. Uses `getDeployment`'s no-logs projection
196
+ * (`DeploymentEssential`) while polling, and only fetches logs (once,
197
+ * truncated) if the deployment failed.
198
+ */
199
+ async pollDeployment(uuid, timeoutSeconds) {
200
+ const deadline = Date.now() + timeoutSeconds * 1000;
201
+ let current = (await this.client.getDeployment(uuid));
202
+ while (!isTerminalDeploymentStatus(current.status) && Date.now() < deadline) {
203
+ await sleep(DEPLOY_POLL_INTERVAL_MS);
204
+ current = (await this.client.getDeployment(uuid));
205
+ }
206
+ if (!isTerminalDeploymentStatus(current.status)) {
207
+ return {
208
+ status: current.status,
209
+ deployment_uuid: uuid,
210
+ application_uuid: current.application_uuid,
211
+ timed_out: true,
212
+ next_action: `Still "${current.status}" after ${timeoutSeconds}s — poll \`deployment\` (action: "get", uuid: "${uuid}") to keep watching.`,
213
+ };
214
+ }
215
+ if (current.status === 'failed') {
216
+ const withLogs = (await this.client.getDeployment(uuid, {
217
+ includeLogs: true,
218
+ }));
219
+ const tail = withLogs.logs ? truncateLogs(withLogs.logs, 30, 10_000) : undefined;
220
+ return {
221
+ status: current.status,
222
+ deployment_uuid: uuid,
223
+ application_uuid: current.application_uuid,
224
+ commit: current.commit,
225
+ created_at: current.created_at,
226
+ updated_at: current.updated_at,
227
+ duration_seconds: durationSeconds(current.created_at, current.updated_at),
228
+ logs_tail: tail?.logs,
229
+ logs_meta: tail
230
+ ? {
231
+ total_entries: tail.total,
232
+ showing: `${tail.showing_start}-${tail.showing_end} of ${tail.total}`,
233
+ }
234
+ : undefined,
235
+ next_action: `Deployment failed. See logs_tail above, or \`deployment\` (action: "get", uuid: "${uuid}", lines: N) for more.`,
236
+ };
237
+ }
238
+ return {
239
+ status: current.status,
240
+ deployment_uuid: uuid,
241
+ application_uuid: current.application_uuid,
242
+ commit: current.commit,
243
+ created_at: current.created_at,
244
+ updated_at: current.updated_at,
245
+ duration_seconds: durationSeconds(current.created_at, current.updated_at),
246
+ };
247
+ }
248
+ /**
249
+ * Trigger a deploy and wait for it to finish. A tag can resolve to
250
+ * multiple applications, so `deployByTagOrUuid` may return several
251
+ * `deployment_uuid`s — only the first is polled; any others are
252
+ * surfaced under `additional_deployment_uuids` for the caller to check
253
+ * separately via `deployment get`.
254
+ */
255
+ async triggerAndWaitForDeploy(tagOrUuid, force, timeoutSeconds) {
256
+ const triggered = await this.client.deployByTagOrUuid(tagOrUuid, force);
257
+ const [first, ...rest] = triggered.deployments ?? [];
258
+ if (!first?.deployment_uuid) {
259
+ // Nothing to poll against — hand back the trigger response as-is.
260
+ return triggered;
261
+ }
262
+ const result = await this.pollDeployment(first.deployment_uuid, timeoutSeconds);
263
+ const additional = rest.map((d) => d.deployment_uuid).filter((u) => !!u);
264
+ if (additional.length > 0) {
265
+ result.additional_deployment_uuids = additional;
266
+ }
267
+ return result;
268
+ }
166
269
  registerTools() {
167
270
  // =========================================================================
168
271
  // Meta (2 tools)
@@ -300,6 +403,7 @@ export class CoolifyMcpServer extends McpServer {
300
403
  'create_github',
301
404
  'create_key',
302
405
  'create_dockerimage',
406
+ 'create_dockerfile',
303
407
  'update',
304
408
  'delete',
305
409
  'delete_preview',
@@ -320,6 +424,8 @@ export class CoolifyMcpServer extends McpServer {
320
424
  // Docker image fields
321
425
  docker_registry_image_name: z.string().optional(),
322
426
  docker_registry_image_tag: z.string().optional(),
427
+ // Dockerfile fields (create_dockerfile)
428
+ dockerfile: z.string().optional(),
323
429
  // Update fields
324
430
  name: z.string().optional(),
325
431
  description: z.string().optional(),
@@ -565,6 +671,35 @@ export class CoolifyMcpServer extends McpServer {
565
671
  custom_labels: args.custom_labels,
566
672
  instant_deploy: args.instant_deploy,
567
673
  }));
674
+ case 'create_dockerfile':
675
+ if (!args.project_uuid || !args.server_uuid || !args.dockerfile) {
676
+ return {
677
+ content: [
678
+ {
679
+ type: 'text',
680
+ text: 'Error: project_uuid, server_uuid, dockerfile required',
681
+ },
682
+ ],
683
+ };
684
+ }
685
+ return wrap(() => this.client.createApplicationDockerfile({
686
+ project_uuid: args.project_uuid,
687
+ server_uuid: args.server_uuid,
688
+ destination_uuid: args.destination_uuid,
689
+ dockerfile: args.dockerfile,
690
+ dockerfile_location: args.dockerfile_location,
691
+ ports_exposes: args.ports_exposes,
692
+ base_directory: args.base_directory,
693
+ environment_name: args.environment_name,
694
+ environment_uuid: args.environment_uuid,
695
+ name: args.name,
696
+ description: args.description,
697
+ fqdn: args.fqdn,
698
+ domains: args.domains,
699
+ custom_docker_run_options: args.custom_docker_run_options,
700
+ custom_labels: args.custom_labels,
701
+ instant_deploy: args.instant_deploy,
702
+ }));
568
703
  case 'update': {
569
704
  if (!uuid)
570
705
  return { content: [{ type: 'text', text: 'Error: uuid required' }] };
@@ -608,6 +743,10 @@ export class CoolifyMcpServer extends McpServer {
608
743
  server_uuid: z.string().optional(),
609
744
  project_uuid: z.string().optional(),
610
745
  environment_name: z.string().optional(),
746
+ destination_uuid: z
747
+ .string()
748
+ .optional()
749
+ .describe('Destination UUID. Required if server has multiple destinations.'),
611
750
  name: z.string().optional(),
612
751
  description: z.string().optional(),
613
752
  image: z.string().optional(),
@@ -723,7 +862,11 @@ export class CoolifyMcpServer extends McpServer {
723
862
  resource: z.enum(['application', 'database', 'service']),
724
863
  action: z.enum(['start', 'stop', 'restart']),
725
864
  uuid: z.string(),
726
- }, async ({ resource, action, uuid }) => {
865
+ pull_latest: z
866
+ .boolean()
867
+ .optional()
868
+ .describe('Pull latest images before restarting (services only)'),
869
+ }, async ({ resource, action, uuid, pull_latest }) => {
727
870
  const methods = {
728
871
  application: {
729
872
  start: (u) => this.client.startApplication(u),
@@ -738,7 +881,7 @@ export class CoolifyMcpServer extends McpServer {
738
881
  service: {
739
882
  start: (u) => this.client.startService(u),
740
883
  stop: (u) => this.client.stopService(u),
741
- restart: (u) => this.client.restartService(u),
884
+ restart: (u) => this.client.restartService(u, pull_latest),
742
885
  },
743
886
  };
744
887
  // Generate contextual actions based on resource type and action taken
@@ -879,7 +1022,25 @@ export class CoolifyMcpServer extends McpServer {
879
1022
  // Deployments (3 tools)
880
1023
  // =========================================================================
881
1024
  this.tool('list_deployments', 'List deployments (summary)', { page: z.number().optional(), per_page: z.number().optional() }, async ({ page, per_page }) => wrapWithActions(() => this.client.listDeployments({ page, per_page, summary: true }), undefined, (result) => getPagination('list_deployments', page, per_page, result.length)));
882
- this.tool('deploy', 'Deploy by tag/UUID', { tag_or_uuid: z.string(), force: z.boolean().optional() }, async ({ tag_or_uuid, force }) => wrapWithActions(() => this.client.deployByTagOrUuid(tag_or_uuid, force), () => [{ tool: 'list_deployments', args: {}, hint: 'Check deployment status' }]));
1025
+ this.tool('deploy', 'Deploy by tag/UUID', {
1026
+ tag_or_uuid: z.string(),
1027
+ force: z.boolean().optional(),
1028
+ wait: z
1029
+ .boolean()
1030
+ .optional()
1031
+ .describe('Wait for the deployment to reach a terminal status (finished/failed/cancelled) instead of returning immediately, polling every ~5s. If tag_or_uuid matches multiple applications (a tag can trigger several deployments), only the first is watched — the rest are returned under additional_deployment_uuids for you to check separately via `deployment get`. On failure the response includes a bounded log tail. Default false (fire-and-forget, unchanged response).'),
1032
+ timeout_seconds: z
1033
+ .number()
1034
+ .optional()
1035
+ .describe('Max seconds to poll when wait is true before giving up and returning the current status plus a next-action hint (default 300). Ignored when wait is false.'),
1036
+ }, async ({ tag_or_uuid, force, wait, timeout_seconds }) => {
1037
+ if (!wait) {
1038
+ return wrapWithActions(() => this.client.deployByTagOrUuid(tag_or_uuid, force), () => [{ tool: 'list_deployments', args: {}, hint: 'Check deployment status' }]);
1039
+ }
1040
+ return wrapWithActions(() => this.triggerAndWaitForDeploy(tag_or_uuid, force, timeout_seconds ?? DEFAULT_DEPLOY_TIMEOUT_SECONDS), (result) => 'deployment_uuid' in result
1041
+ ? getDeploymentActions(result.deployment_uuid, result.status, result.application_uuid)
1042
+ : []);
1043
+ });
883
1044
  this.tool('deployment', 'Manage deployment: get/cancel/list_for_app. Logs excluded by default on all actions — for get use `lines` (paginated tail), for list_for_app use `include_logs: true` to include raw build-log blobs.', {
884
1045
  action: z.enum(['get', 'cancel', 'list_for_app']),
885
1046
  uuid: z.string(),
@@ -895,9 +1056,9 @@ export class CoolifyMcpServer extends McpServer {
895
1056
  const p = page ?? 1;
896
1057
  const ll = lines;
897
1058
  return wrapWithActions(async () => {
898
- const deployment = (await this.client.getDeployment(uuid, {
1059
+ const deployment = await this.client.getDeployment(uuid, {
899
1060
  includeLogs: true,
900
- }));
1061
+ });
901
1062
  if (deployment.logs) {
902
1063
  const result = truncateLogs(deployment.logs, ll, max_chars ?? 50000, p);
903
1064
  deployment.logs = result.logs;
@@ -1315,17 +1476,31 @@ export class CoolifyMcpServer extends McpServer {
1315
1476
  // =========================================================================
1316
1477
  // Scheduled Tasks (1 tool - consolidated for app/service)
1317
1478
  // =========================================================================
1318
- this.tool('scheduled_tasks', 'Manage scheduled tasks for app or service: list/create/update/delete/list_executions', {
1479
+ this.tool('scheduled_tasks', 'Manage scheduled tasks for app or service: list/create/update/delete/list_executions/run_once. ' +
1480
+ "list_executions: the command's stdout comes back in the execution's message field. " +
1481
+ 'run_once: composite that creates a throwaway "* * * * *" task, polls list_executions every ~5s ' +
1482
+ 'for the first terminal execution (or until wait_seconds elapses, default 90), deletes the task, ' +
1483
+ 'and returns status+message. WARNING: the underlying cron may fire more than once before cleanup ' +
1484
+ 'completes — make the command idempotent (e.g. `where not exists`) or tolerate re-execution. ' +
1485
+ 'Coolify stores `command` in a varchar(255) column and rejects longer commands with a bodyless ' +
1486
+ 'HTTP 500 — keep commands to 255 chars or fewer (#234).', {
1319
1487
  resource: z.enum(['application', 'service']),
1320
- action: z.enum(['list', 'create', 'update', 'delete', 'list_executions']),
1488
+ action: z.enum(['list', 'create', 'update', 'delete', 'list_executions', 'run_once']),
1321
1489
  uuid: z.string(),
1322
1490
  task_uuid: z.string().optional(),
1323
1491
  name: z.string().optional(),
1324
- command: z.string().optional(),
1492
+ command: z
1493
+ .string()
1494
+ .max(255, 'Coolify rejects scheduled-task commands longer than 255 chars — split the command or bake a script into the container image')
1495
+ .optional(),
1325
1496
  frequency: z.string().optional(),
1326
1497
  container: z.string().optional(),
1327
1498
  timeout: z.number().optional(),
1328
1499
  enabled: z.boolean().optional(),
1500
+ wait_seconds: z
1501
+ .number()
1502
+ .optional()
1503
+ .describe('run_once only: poll budget in seconds before giving up (default 90)'),
1329
1504
  }, async (args) => {
1330
1505
  const { resource, action, uuid, task_uuid } = args;
1331
1506
  const isApp = resource === 'application';
@@ -1382,6 +1557,12 @@ export class CoolifyMcpServer extends McpServer {
1382
1557
  return wrap(() => isApp
1383
1558
  ? this.client.listApplicationScheduledTaskExecutions(uuid, task_uuid)
1384
1559
  : this.client.listServiceScheduledTaskExecutions(uuid, task_uuid));
1560
+ case 'run_once':
1561
+ if (!args.command || !args.container)
1562
+ return {
1563
+ content: [{ type: 'text', text: 'Error: command, container required' }],
1564
+ };
1565
+ return this.runOnceScheduledTask(resource, uuid, args.command, args.container, args.timeout, args.wait_seconds);
1385
1566
  }
1386
1567
  });
1387
1568
  // =========================================================================
@@ -1516,4 +1697,131 @@ export class CoolifyMcpServer extends McpServer {
1516
1697
  });
1517
1698
  this.tool('redeploy_project', 'Redeploy all apps in project', { project_uuid: z.string(), force: z.boolean().optional() }, async ({ project_uuid, force }) => wrap(() => this.client.redeployProjectApps(project_uuid, force ?? true)));
1518
1699
  }
1700
+ /**
1701
+ * Injectable delay for the run_once poll loop. A real setTimeout in production;
1702
+ * tests replace it with `jest.spyOn(server, 'sleep').mockResolvedValue(undefined)`
1703
+ * so polling logic runs without waiting on the wall clock.
1704
+ */
1705
+ sleep(ms) {
1706
+ return new Promise((resolve) => setTimeout(resolve, ms));
1707
+ }
1708
+ /**
1709
+ * Composite one-off command execution (#233 / #208): there is no upstream
1710
+ * "run now" endpoint for scheduled tasks, so this creates a throwaway
1711
+ * `* * * * *` task, polls list_executions until the first execution reaches a
1712
+ * terminal status (or the poll budget runs out), and returns its status+message.
1713
+ *
1714
+ * The task is deleted in a finally-equivalent (try/finally-style) block so cleanup
1715
+ * always runs — on success, on timeout, and on a polling error. If cleanup itself
1716
+ * fails, the returned message says so loudly with the task UUID so a human can
1717
+ * remove it manually (it would otherwise keep firing every minute).
1718
+ */
1719
+ async runOnceScheduledTask(resource, uuid, command, container, timeout, waitSeconds) {
1720
+ const isApp = resource === 'application';
1721
+ const pollIntervalMs = 5000;
1722
+ const budgetSeconds = waitSeconds ?? 90;
1723
+ const maxAttempts = Math.max(1, Math.ceil((budgetSeconds * 1000) / pollIntervalMs));
1724
+ const name = `oneoff-${Math.random().toString(36).slice(2, 10)}`;
1725
+ let taskUuid;
1726
+ try {
1727
+ const task = isApp
1728
+ ? await this.client.createApplicationScheduledTask(uuid, {
1729
+ name,
1730
+ command,
1731
+ frequency: '* * * * *',
1732
+ container,
1733
+ timeout,
1734
+ enabled: true,
1735
+ })
1736
+ : await this.client.createServiceScheduledTask(uuid, {
1737
+ name,
1738
+ command,
1739
+ frequency: '* * * * *',
1740
+ container,
1741
+ timeout,
1742
+ enabled: true,
1743
+ });
1744
+ taskUuid = task.uuid;
1745
+ }
1746
+ catch (error) {
1747
+ return {
1748
+ content: [
1749
+ {
1750
+ type: 'text',
1751
+ text: `Error creating one-off task: ${error instanceof Error ? error.message : String(error)}`,
1752
+ },
1753
+ ],
1754
+ };
1755
+ }
1756
+ let execution;
1757
+ let pollErrorMessage;
1758
+ try {
1759
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1760
+ const executions = isApp
1761
+ ? await this.client.listApplicationScheduledTaskExecutions(uuid, taskUuid)
1762
+ : await this.client.listServiceScheduledTaskExecutions(uuid, taskUuid);
1763
+ const first = executions[0];
1764
+ if (first && first.status !== 'running') {
1765
+ execution = first;
1766
+ break;
1767
+ }
1768
+ if (attempt < maxAttempts - 1)
1769
+ await this.sleep(pollIntervalMs);
1770
+ }
1771
+ }
1772
+ catch (error) {
1773
+ pollErrorMessage = error instanceof Error ? error.message : String(error);
1774
+ }
1775
+ // Cleanup always runs, regardless of how the poll loop above ended.
1776
+ let deleteErrorMessage;
1777
+ try {
1778
+ if (isApp) {
1779
+ await this.client.deleteApplicationScheduledTask(uuid, taskUuid);
1780
+ }
1781
+ else {
1782
+ await this.client.deleteServiceScheduledTask(uuid, taskUuid);
1783
+ }
1784
+ }
1785
+ catch (error) {
1786
+ deleteErrorMessage = error instanceof Error ? error.message : String(error);
1787
+ }
1788
+ const cleanupNote = deleteErrorMessage
1789
+ ? `WARNING: failed to delete one-off task ${taskUuid} — it will keep firing every minute ` +
1790
+ `until it is removed manually. Delete error: ${deleteErrorMessage}`
1791
+ : `One-off task ${taskUuid} deleted.`;
1792
+ if (pollErrorMessage) {
1793
+ return {
1794
+ content: [
1795
+ {
1796
+ type: 'text',
1797
+ text: `Error polling one-off task ${taskUuid} executions: ${pollErrorMessage}. ${cleanupNote}`,
1798
+ },
1799
+ ],
1800
+ };
1801
+ }
1802
+ if (!execution) {
1803
+ return {
1804
+ content: [
1805
+ {
1806
+ type: 'text',
1807
+ text: `Timed out after ${budgetSeconds}s waiting for one-off task ${taskUuid} to produce ` +
1808
+ `an execution. ${cleanupNote}`,
1809
+ },
1810
+ ],
1811
+ };
1812
+ }
1813
+ return {
1814
+ content: [
1815
+ {
1816
+ type: 'text',
1817
+ text: JSON.stringify({
1818
+ status: execution.status,
1819
+ message: execution.message,
1820
+ task_uuid: taskUuid,
1821
+ cleanup: cleanupNote,
1822
+ }, null, 2),
1823
+ },
1824
+ ],
1825
+ };
1826
+ }
1519
1827
  }
@@ -726,6 +726,20 @@ export interface DeployByTagRequest {
726
726
  uuid?: string;
727
727
  force?: boolean;
728
728
  }
729
+ /**
730
+ * Response from `GET /deploy?tag=|uuid=`. A tag can match multiple
731
+ * applications, so Coolify returns one entry per triggered deployment.
732
+ * `message` at the top level is kept for backwards compatibility with
733
+ * older/mocked callers that only ever saw a bare `{ message }`.
734
+ */
735
+ export interface DeployTriggerResponse {
736
+ message?: string;
737
+ deployments?: Array<{
738
+ message?: string;
739
+ resource_uuid?: string;
740
+ deployment_uuid?: string;
741
+ }>;
742
+ }
729
743
  export interface Team {
730
744
  id: number;
731
745
  uuid?: string;
@@ -1131,4 +1145,5 @@ export interface DeploymentEssential {
1131
1145
  updated_at: string;
1132
1146
  logs_available?: boolean;
1133
1147
  logs_info?: string;
1148
+ logs?: string;
1134
1149
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@masonator/coolify-mcp",
3
3
  "scope": "@masonator",
4
- "version": "2.12.0",
4
+ "version": "2.13.0",
5
5
  "mcpName": "io.github.StuMason/coolify",
6
6
  "description": "MCP server for Coolify — 42 optimized tools for infrastructure management, diagnostics, and documentation search",
7
7
  "type": "module",
@@ -28,6 +28,7 @@
28
28
  "test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --testPathPattern=integration --testTimeout=60000",
29
29
  "lint": "eslint .",
30
30
  "lint:fix": "eslint . --fix",
31
+ "check:spec-drift": "node scripts/check-client-spec-drift.mjs",
31
32
  "format": "prettier --write .",
32
33
  "format:check": "prettier --check .",
33
34
  "prepare": "husky",
@@ -66,7 +67,7 @@
66
67
  "devDependencies": {
67
68
  "@eslint/js": "^10.0.1",
68
69
  "@types/jest": "^30.0.0",
69
- "@types/node": "^25.0.3",
70
+ "@types/node": "^26.0.0",
70
71
  "@typescript-eslint/eslint-plugin": "^8.51.0",
71
72
  "@typescript-eslint/parser": "^8.51.0",
72
73
  "dotenv": "^17.2.3",
@@ -88,7 +89,22 @@
88
89
  "node": ">=20"
89
90
  },
90
91
  "overrides": {
91
- "handlebars": "^4.7.9"
92
+ "handlebars": "^4.7.9",
93
+ "qs": "^6.15.2",
94
+ "hono": "^4.12.25",
95
+ "@hono/node-server": "^2.0.5",
96
+ "express-rate-limit": "^8.5.2",
97
+ "path-to-regexp": "^8.4.2",
98
+ "@modelcontextprotocol/sdk": {
99
+ "ajv": "^8.20.0"
100
+ },
101
+ "fast-uri": "^4.0.0",
102
+ "ip-address": "^10.2.0",
103
+ "flatted": "^3.4.2",
104
+ "brace-expansion": "^5.0.6",
105
+ "picomatch": "^4.0.4",
106
+ "js-yaml": "^4.2.0",
107
+ "markdown-it": "^14.2.0"
92
108
  },
93
109
  "lint-staged": {
94
110
  "*.{ts,js,json,md,yaml,yml}": "prettier --write",