@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.
- package/README.md +46 -4
- package/dist/__tests__/coolify-client.test.js +137 -8
- package/dist/__tests__/mcp-server.test.js +481 -1
- package/dist/lib/coolify-client.d.ts +21 -6
- package/dist/lib/coolify-client.js +60 -5
- package/dist/lib/mcp-server.d.ts +33 -0
- package/dist/lib/mcp-server.js +316 -8
- package/dist/types/coolify.d.ts +15 -0
- package/package.json +19 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
? {
|
package/dist/lib/mcp-server.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/lib/mcp-server.js
CHANGED
|
@@ -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
|
-
|
|
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', {
|
|
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 =
|
|
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
|
|
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
|
}
|
package/dist/types/coolify.d.ts
CHANGED
|
@@ -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.
|
|
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": "^
|
|
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",
|