@masonator/coolify-mcp 2.12.1 → 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 +37 -3
- package/dist/__tests__/coolify-client.test.js +84 -1
- package/dist/__tests__/mcp-server.test.js +332 -0
- package/dist/lib/coolify-client.d.ts +15 -2
- package/dist/lib/coolify-client.js +40 -0
- package/dist/lib/mcp-server.d.ts +33 -0
- package/dist/lib/mcp-server.js +304 -4
- package/dist/types/coolify.d.ts +14 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ This MCP server provides **42 token-optimized tools** for **debugging, managemen
|
|
|
41
41
|
| **Control** | `control` (start/stop/restart for apps, databases, services) |
|
|
42
42
|
| **Env Vars** | `env_vars` (CRUD + bulk_update for application, service, and database env vars) |
|
|
43
43
|
| **Storages** | `storages` (list, create, update, delete persistent/file storages for apps, databases, services) |
|
|
44
|
-
| **Scheduled Tasks** | `scheduled_tasks` (list, create, update, delete, list_executions for apps and services)
|
|
44
|
+
| **Scheduled Tasks** | `scheduled_tasks` (list, create, update, delete, list_executions, run_once for apps and services) |
|
|
45
45
|
| **Deployments** | `list_deployments`, `deploy`, `deployment` (get, cancel, list_for_app) |
|
|
46
46
|
| **Private Keys** | `private_keys` (list, get, create, update, delete via action param) |
|
|
47
47
|
| **GitHub Apps** | `github_apps` (list, get, create, update, delete, list_repos, list_branches) |
|
|
@@ -126,6 +126,39 @@ If your Coolify instance sits behind a Cloudflare Access tunnel or other auth-pr
|
|
|
126
126
|
|
|
127
127
|
Multiple `--header` flags can be combined. The reserved headers `Authorization` and `Content-Type` are filtered (with a warning) to prevent silently overriding the Coolify bearer token.
|
|
128
128
|
|
|
129
|
+
### Multiple Coolify servers
|
|
130
|
+
|
|
131
|
+
Each running instance of this MCP server is bound to one Coolify (one `COOLIFY_BASE_URL` + token). To work with several Coolify instances, pick whichever of these fits your workflow:
|
|
132
|
+
|
|
133
|
+
**Per-workspace config (recommended).** Most MCP clients support project-scoped config files (`.mcp.json` / `.cursor/mcp.json` / `.vscode/mcp.json` in the repo root) alongside the global one. Put the Coolify credentials for _that project's_ estate in the project's own config, and "deploy this" automatically routes to the right Coolify whenever you're working in that repo — no server names to remember in conversation.
|
|
134
|
+
|
|
135
|
+
**Named instances in global config.** Register the server twice (or more) under distinct names, and address them by name in conversation ("deploy this on staging"):
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"mcpServers": {
|
|
140
|
+
"coolify-prod": {
|
|
141
|
+
"command": "npx",
|
|
142
|
+
"args": ["-y", "@masonator/coolify-mcp"],
|
|
143
|
+
"env": {
|
|
144
|
+
"COOLIFY_BASE_URL": "https://prod.coolify.example",
|
|
145
|
+
"COOLIFY_ACCESS_TOKEN": "..."
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
"coolify-staging": {
|
|
149
|
+
"command": "npx",
|
|
150
|
+
"args": ["-y", "@masonator/coolify-mcp"],
|
|
151
|
+
"env": {
|
|
152
|
+
"COOLIFY_BASE_URL": "https://staging.coolify.example",
|
|
153
|
+
"COOLIFY_ACCESS_TOKEN": "..."
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The MCP server itself can't offer a settings screen or auto-detect the current repo — MCP servers are headless child processes and clients don't pass repo context — so routing lives in your client config, where both patterns above work today (see #164).
|
|
161
|
+
|
|
129
162
|
## Context-Optimized Responses
|
|
130
163
|
|
|
131
164
|
### Why This Matters
|
|
@@ -315,8 +348,9 @@ These tools accept human-friendly identifiers instead of just UUIDs:
|
|
|
315
348
|
- `list_applications` - List all applications (returns summary)
|
|
316
349
|
- `get_application` - Get application details
|
|
317
350
|
- `application_logs` - Get application logs
|
|
318
|
-
- `application` - Create, update, or delete apps with `action: create_public|create_github|create_key|create_dockerimage|update|delete`
|
|
319
|
-
- Deploy from public repos, private GitHub, SSH keys, or
|
|
351
|
+
- `application` - Create, update, or delete apps with `action: create_public|create_github|create_key|create_dockerimage|create_dockerfile|update|delete`
|
|
352
|
+
- Deploy from public repos, private GitHub, SSH keys, Docker images, or a raw Dockerfile
|
|
353
|
+
- For docker-compose apps use the `service` tool — Coolify removed `POST /applications/dockercompose` in v4.1.0 in favour of services
|
|
320
354
|
- Configure health checks (path, interval, retries, etc.)
|
|
321
355
|
- `env_vars` - Manage env vars with `resource: application, action: list|create|update|delete`
|
|
322
356
|
- `control` - Start/stop/restart with `resource: application, action: start|stop|restart`
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
|
2
|
-
import { CoolifyClient } from '../lib/coolify-client.js';
|
|
2
|
+
import { CoolifyClient, errorHint } from '../lib/coolify-client.js';
|
|
3
3
|
// Helper to create mock response
|
|
4
4
|
function mockResponse(data, ok = true, status = 200, contentType = 'application/json') {
|
|
5
5
|
const body = contentType.includes('application/json') ? JSON.stringify(data) : String(data);
|
|
@@ -403,6 +403,23 @@ describe('CoolifyClient', () => {
|
|
|
403
403
|
expect(result).toEqual({ message: 'Deployed' });
|
|
404
404
|
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deploy?tag=my-tag&force=true', expect.any(Object));
|
|
405
405
|
});
|
|
406
|
+
it('should parse the deployments envelope Coolify returns for /deploy (#238)', async () => {
|
|
407
|
+
// Real Coolify returns { deployments: [{ message, resource_uuid, deployment_uuid }] },
|
|
408
|
+
// one entry per triggered deployment (a tag can match several apps).
|
|
409
|
+
mockFetch.mockResolvedValueOnce(mockResponse({
|
|
410
|
+
deployments: [
|
|
411
|
+
{ message: 'Deployment queued', resource_uuid: 'app-1', deployment_uuid: 'dep-1' },
|
|
412
|
+
{ message: 'Deployment queued', resource_uuid: 'app-2', deployment_uuid: 'dep-2' },
|
|
413
|
+
],
|
|
414
|
+
}));
|
|
415
|
+
const result = await client.deployByTagOrUuid('my-tag', true);
|
|
416
|
+
expect(result).toEqual({
|
|
417
|
+
deployments: [
|
|
418
|
+
{ message: 'Deployment queued', resource_uuid: 'app-1', deployment_uuid: 'dep-1' },
|
|
419
|
+
{ message: 'Deployment queued', resource_uuid: 'app-2', deployment_uuid: 'dep-2' },
|
|
420
|
+
],
|
|
421
|
+
});
|
|
422
|
+
});
|
|
406
423
|
it('should deploy by Coolify UUID (24 char alphanumeric)', async () => {
|
|
407
424
|
mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Deployed' }));
|
|
408
425
|
// Coolify-style UUID: 24 lowercase alphanumeric chars
|
|
@@ -571,6 +588,14 @@ describe('CoolifyClient', () => {
|
|
|
571
588
|
}, false, 422));
|
|
572
589
|
await expect(client.listServers()).rejects.toThrow('Validation failed. - name: The name field is required.; docker_compose_raw: The docker compose raw field is required.');
|
|
573
590
|
});
|
|
591
|
+
it('should append a command-length hint on a bodyless 500 from a scheduled-tasks path', async () => {
|
|
592
|
+
mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
|
|
593
|
+
await expect(client.createApplicationScheduledTask('app-uuid', {
|
|
594
|
+
name: 'task',
|
|
595
|
+
command: 'a'.repeat(300),
|
|
596
|
+
frequency: '* * * * *',
|
|
597
|
+
})).rejects.toThrow(/varchar\(255\)/);
|
|
598
|
+
});
|
|
574
599
|
});
|
|
575
600
|
// =========================================================================
|
|
576
601
|
// Server endpoints - additional coverage
|
|
@@ -2462,6 +2487,43 @@ describe('CoolifyClient', () => {
|
|
|
2462
2487
|
const result = await client.diagnoseApplication(testAppUuid);
|
|
2463
2488
|
expect(result.health.issues).toContain('2 failed deployment(s) in last 5');
|
|
2464
2489
|
});
|
|
2490
|
+
it('should flag stale code when app is running but the latest deployment failed', async () => {
|
|
2491
|
+
const staleDeployments = [
|
|
2492
|
+
{ ...mockDeployments[0], uuid: 'deploy-latest', status: 'failed' },
|
|
2493
|
+
{ ...mockDeployments[1], status: 'finished' },
|
|
2494
|
+
];
|
|
2495
|
+
mockFetch
|
|
2496
|
+
.mockResolvedValueOnce(mockResponse(mockApp)) // status: running:healthy
|
|
2497
|
+
.mockResolvedValueOnce(mockResponse(mockLogs))
|
|
2498
|
+
.mockResolvedValueOnce(mockResponse(mockEnvVars))
|
|
2499
|
+
.mockResolvedValueOnce(mockResponse({ count: staleDeployments.length, deployments: staleDeployments }));
|
|
2500
|
+
const result = await client.diagnoseApplication(testAppUuid);
|
|
2501
|
+
expect(result.health.status).toBe('unhealthy');
|
|
2502
|
+
expect(result.health.issues).toContain('Running container predates the last (failed) deployment (deploy-latest) — the app is serving stale code. Use the deployment tool (action: get, uuid: deploy-latest, lines) to see why it failed.');
|
|
2503
|
+
});
|
|
2504
|
+
it('should not flag stale code when the latest deployment succeeded', async () => {
|
|
2505
|
+
mockFetch
|
|
2506
|
+
.mockResolvedValueOnce(mockResponse(mockApp))
|
|
2507
|
+
.mockResolvedValueOnce(mockResponse(mockLogs))
|
|
2508
|
+
.mockResolvedValueOnce(mockResponse(mockEnvVars))
|
|
2509
|
+
.mockResolvedValueOnce(mockResponse({ count: mockDeployments.length, deployments: mockDeployments }));
|
|
2510
|
+
const result = await client.diagnoseApplication(testAppUuid);
|
|
2511
|
+
expect(result.health.status).toBe('healthy');
|
|
2512
|
+
expect(result.health.issues.some((issue) => issue.includes('stale code'))).toBe(false);
|
|
2513
|
+
});
|
|
2514
|
+
it('should skip the deployment-freshness check without breaking diagnosis when deployments are unavailable', async () => {
|
|
2515
|
+
mockFetch
|
|
2516
|
+
.mockResolvedValueOnce(mockResponse(mockApp))
|
|
2517
|
+
.mockResolvedValueOnce(mockResponse(mockLogs))
|
|
2518
|
+
.mockResolvedValueOnce(mockResponse(mockEnvVars))
|
|
2519
|
+
.mockRejectedValueOnce(new Error('Deployments unavailable'));
|
|
2520
|
+
const result = await client.diagnoseApplication(testAppUuid);
|
|
2521
|
+
expect(result.application).not.toBeNull();
|
|
2522
|
+
expect(result.health.status).toBe('healthy');
|
|
2523
|
+
expect(result.recent_deployments).toEqual([]);
|
|
2524
|
+
expect(result.errors).toContain('deployments: Deployments unavailable');
|
|
2525
|
+
expect(result.health.issues.some((issue) => issue.includes('stale code'))).toBe(false);
|
|
2526
|
+
});
|
|
2465
2527
|
it('should handle partial failures gracefully', async () => {
|
|
2466
2528
|
mockFetch
|
|
2467
2529
|
.mockResolvedValueOnce(mockResponse(mockApp))
|
|
@@ -3723,3 +3785,24 @@ describe('CoolifyClient', () => {
|
|
|
3723
3785
|
});
|
|
3724
3786
|
});
|
|
3725
3787
|
});
|
|
3788
|
+
describe('errorHint', () => {
|
|
3789
|
+
it('hints at the command-length limit for a 500 on a scheduled-tasks path', () => {
|
|
3790
|
+
expect(errorHint(500, '/applications/app-uuid/scheduled-tasks')).toMatch(/varchar\(255\)/);
|
|
3791
|
+
expect(errorHint(500, '/services/svc-uuid/scheduled-tasks/task-uuid')).toMatch(/varchar\(255\)/);
|
|
3792
|
+
});
|
|
3793
|
+
it('does not hint at the command-length limit for a 500 on unrelated paths', () => {
|
|
3794
|
+
expect(errorHint(500, '/servers')).toBeUndefined();
|
|
3795
|
+
});
|
|
3796
|
+
it('hints at the access token for 401 and 403', () => {
|
|
3797
|
+
expect(errorHint(401, '/version')).toMatch(/COOLIFY_ACCESS_TOKEN/);
|
|
3798
|
+
expect(errorHint(403, '/servers')).toMatch(/COOLIFY_ACCESS_TOKEN/);
|
|
3799
|
+
});
|
|
3800
|
+
it('hints at a possible resource-type mismatch for a 404 on a uuid route', () => {
|
|
3801
|
+
expect(errorHint(404, '/applications/app-uuid')).toMatch(/different resource type/);
|
|
3802
|
+
});
|
|
3803
|
+
it('returns undefined for anything else (default passthrough)', () => {
|
|
3804
|
+
expect(errorHint(200, '/servers')).toBeUndefined();
|
|
3805
|
+
expect(errorHint(422, '/applications/app-uuid')).toBeUndefined();
|
|
3806
|
+
expect(errorHint(404, '/health')).toBeUndefined();
|
|
3807
|
+
});
|
|
3808
|
+
});
|
|
@@ -63,6 +63,7 @@ describe('CoolifyMcpServer v2', () => {
|
|
|
63
63
|
expect(typeof client.createApplicationPrivateGH).toBe('function');
|
|
64
64
|
expect(typeof client.createApplicationPrivateKey).toBe('function');
|
|
65
65
|
expect(typeof client.createApplicationDockerImage).toBe('function');
|
|
66
|
+
expect(typeof client.createApplicationDockerfile).toBe('function');
|
|
66
67
|
expect(typeof client.updateApplication).toBe('function');
|
|
67
68
|
expect(typeof client.deleteApplication).toBe('function');
|
|
68
69
|
expect(typeof client.getApplicationLogs).toBe('function');
|
|
@@ -474,6 +475,36 @@ describe('CoolifyMcpServer v2', () => {
|
|
|
474
475
|
expect(forwarded).not.toHaveProperty('install_command');
|
|
475
476
|
expect(forwarded).not.toHaveProperty('dockerfile_location');
|
|
476
477
|
});
|
|
478
|
+
it('forwards fields in create_dockerfile', async () => {
|
|
479
|
+
const spy = jest
|
|
480
|
+
.spyOn(server['client'], 'createApplicationDockerfile')
|
|
481
|
+
.mockResolvedValue({ uuid: 'app-5' });
|
|
482
|
+
await callApplication(server, {
|
|
483
|
+
action: 'create_dockerfile',
|
|
484
|
+
project_uuid: 'proj-uuid',
|
|
485
|
+
server_uuid: 'server-uuid',
|
|
486
|
+
dockerfile: 'FROM node:20\nCMD ["node", "index.js"]',
|
|
487
|
+
dockerfile_location: '/Dockerfile',
|
|
488
|
+
ports_exposes: '3000',
|
|
489
|
+
base_directory: '/apps/api',
|
|
490
|
+
});
|
|
491
|
+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
|
|
492
|
+
project_uuid: 'proj-uuid',
|
|
493
|
+
server_uuid: 'server-uuid',
|
|
494
|
+
dockerfile: 'FROM node:20\nCMD ["node", "index.js"]',
|
|
495
|
+
dockerfile_location: '/Dockerfile',
|
|
496
|
+
ports_exposes: '3000',
|
|
497
|
+
base_directory: '/apps/api',
|
|
498
|
+
}));
|
|
499
|
+
});
|
|
500
|
+
it('returns required-param error when create_dockerfile is missing dockerfile', async () => {
|
|
501
|
+
const result = (await callApplication(server, {
|
|
502
|
+
action: 'create_dockerfile',
|
|
503
|
+
project_uuid: 'proj-uuid',
|
|
504
|
+
server_uuid: 'server-uuid',
|
|
505
|
+
}));
|
|
506
|
+
expect(result.content[0].text).toContain('project_uuid, server_uuid, dockerfile required');
|
|
507
|
+
});
|
|
477
508
|
it('forwards dockerfile_target_build through update (PATCH-only)', async () => {
|
|
478
509
|
const spy = jest.spyOn(server['client'], 'updateApplication').mockResolvedValue({});
|
|
479
510
|
await callApplication(server, {
|
|
@@ -642,6 +673,307 @@ describe('CoolifyMcpServer v2', () => {
|
|
|
642
673
|
expect(text.length).toBeLessThan(20_000);
|
|
643
674
|
});
|
|
644
675
|
});
|
|
676
|
+
describe('scheduled_tasks tool handler', () => {
|
|
677
|
+
// Regression for #234 — Coolify's `command` column is a 255-char varchar and
|
|
678
|
+
// rejects longer commands with a bodyless HTTP 500. The zod schema must reject
|
|
679
|
+
// an over-long command locally, before any HTTP call is attempted.
|
|
680
|
+
const getScheduledTasksTool = (srv) => srv._registeredTools['scheduled_tasks'];
|
|
681
|
+
const baseArgs = {
|
|
682
|
+
resource: 'application',
|
|
683
|
+
action: 'create',
|
|
684
|
+
uuid: 'app-uuid',
|
|
685
|
+
name: 'my-task',
|
|
686
|
+
frequency: '* * * * *',
|
|
687
|
+
};
|
|
688
|
+
it('rejects a command over 255 chars locally, with an actionable message', () => {
|
|
689
|
+
const createSpy = jest.spyOn(server['client'], 'createApplicationScheduledTask');
|
|
690
|
+
const updateSpy = jest.spyOn(server['client'], 'updateApplicationScheduledTask');
|
|
691
|
+
const tool = getScheduledTasksTool(server);
|
|
692
|
+
const result = tool.inputSchema.safeParse({ ...baseArgs, command: 'a'.repeat(256) });
|
|
693
|
+
expect(result.success).toBe(false);
|
|
694
|
+
const error = result.error;
|
|
695
|
+
expect(error.issues[0]?.message).toContain('Coolify rejects scheduled-task commands longer than 255 chars');
|
|
696
|
+
// No HTTP call should have been attempted.
|
|
697
|
+
expect(createSpy).not.toHaveBeenCalled();
|
|
698
|
+
expect(updateSpy).not.toHaveBeenCalled();
|
|
699
|
+
});
|
|
700
|
+
it('accepts a command at exactly 255 chars', () => {
|
|
701
|
+
const tool = getScheduledTasksTool(server);
|
|
702
|
+
const result = tool.inputSchema.safeParse({ ...baseArgs, command: 'a'.repeat(255) });
|
|
703
|
+
expect(result.success).toBe(true);
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
describe('scheduled_tasks tool handler - run_once', () => {
|
|
707
|
+
const callScheduledTasks = async (srv, args) => {
|
|
708
|
+
const tool = srv._registeredTools['scheduled_tasks'];
|
|
709
|
+
return tool.handler(args, {});
|
|
710
|
+
};
|
|
711
|
+
const baseArgs = {
|
|
712
|
+
resource: 'application',
|
|
713
|
+
action: 'run_once',
|
|
714
|
+
uuid: 'app-uuid',
|
|
715
|
+
command: 'php artisan migrate',
|
|
716
|
+
container: 'app',
|
|
717
|
+
wait_seconds: 10, // small budget -> few poll attempts in tests
|
|
718
|
+
};
|
|
719
|
+
const mockTask = {
|
|
720
|
+
id: 1,
|
|
721
|
+
uuid: 'task-uuid',
|
|
722
|
+
enabled: true,
|
|
723
|
+
name: 'oneoff-abc123',
|
|
724
|
+
command: 'php artisan migrate',
|
|
725
|
+
frequency: '* * * * *',
|
|
726
|
+
timeout: 0,
|
|
727
|
+
created_at: '',
|
|
728
|
+
updated_at: '',
|
|
729
|
+
};
|
|
730
|
+
beforeEach(() => {
|
|
731
|
+
// Poll loop uses a real setTimeout by default; override the instance method
|
|
732
|
+
// directly so tests are instant (jest.spyOn's generic inference struggles
|
|
733
|
+
// with private methods here, so a plain shadowing assignment is simpler).
|
|
734
|
+
server.sleep = () => Promise.resolve();
|
|
735
|
+
});
|
|
736
|
+
it('validates command and container are required', async () => {
|
|
737
|
+
const result = await callScheduledTasks(server, {
|
|
738
|
+
resource: 'application',
|
|
739
|
+
action: 'run_once',
|
|
740
|
+
uuid: 'app-uuid',
|
|
741
|
+
});
|
|
742
|
+
expect(result.content[0].text).toBe('Error: command, container required');
|
|
743
|
+
});
|
|
744
|
+
it('creates a task, polls until a terminal execution, returns its output, and deletes the task', async () => {
|
|
745
|
+
const createSpy = jest
|
|
746
|
+
.spyOn(server['client'], 'createApplicationScheduledTask')
|
|
747
|
+
.mockResolvedValue(mockTask);
|
|
748
|
+
const listSpy = jest
|
|
749
|
+
.spyOn(server['client'], 'listApplicationScheduledTaskExecutions')
|
|
750
|
+
.mockResolvedValueOnce([]) // first poll: nothing yet
|
|
751
|
+
.mockResolvedValueOnce([
|
|
752
|
+
{
|
|
753
|
+
uuid: 'exec-uuid',
|
|
754
|
+
status: 'success',
|
|
755
|
+
message: 'Migrated: 2026_01_01_000000_add_col',
|
|
756
|
+
retry_count: 0,
|
|
757
|
+
created_at: '',
|
|
758
|
+
updated_at: '',
|
|
759
|
+
},
|
|
760
|
+
]);
|
|
761
|
+
const deleteSpy = jest
|
|
762
|
+
.spyOn(server['client'], 'deleteApplicationScheduledTask')
|
|
763
|
+
.mockResolvedValue({ message: 'deleted' });
|
|
764
|
+
const result = await callScheduledTasks(server, baseArgs);
|
|
765
|
+
expect(createSpy).toHaveBeenCalledWith('app-uuid', expect.objectContaining({
|
|
766
|
+
command: 'php artisan migrate',
|
|
767
|
+
frequency: '* * * * *',
|
|
768
|
+
container: 'app',
|
|
769
|
+
enabled: true,
|
|
770
|
+
}));
|
|
771
|
+
expect(listSpy).toHaveBeenCalledTimes(2);
|
|
772
|
+
expect(listSpy).toHaveBeenCalledWith('app-uuid', 'task-uuid');
|
|
773
|
+
expect(deleteSpy).toHaveBeenCalledWith('app-uuid', 'task-uuid');
|
|
774
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
775
|
+
expect(parsed.status).toBe('success');
|
|
776
|
+
expect(parsed.message).toBe('Migrated: 2026_01_01_000000_add_col');
|
|
777
|
+
expect(parsed.task_uuid).toBe('task-uuid');
|
|
778
|
+
expect(parsed.cleanup).toContain('deleted');
|
|
779
|
+
});
|
|
780
|
+
it('times out when no execution ever appears, and still deletes the task', async () => {
|
|
781
|
+
jest.spyOn(server['client'], 'createApplicationScheduledTask').mockResolvedValue(mockTask);
|
|
782
|
+
jest.spyOn(server['client'], 'listApplicationScheduledTaskExecutions').mockResolvedValue([]);
|
|
783
|
+
const deleteSpy = jest
|
|
784
|
+
.spyOn(server['client'], 'deleteApplicationScheduledTask')
|
|
785
|
+
.mockResolvedValue({ message: 'deleted' });
|
|
786
|
+
const result = await callScheduledTasks(server, baseArgs);
|
|
787
|
+
expect(deleteSpy).toHaveBeenCalledWith('app-uuid', 'task-uuid');
|
|
788
|
+
expect(result.content[0].text).toContain('Timed out');
|
|
789
|
+
expect(result.content[0].text).toContain('task-uuid');
|
|
790
|
+
expect(result.content[0].text).toContain('deleted');
|
|
791
|
+
});
|
|
792
|
+
it('still deletes the task when polling throws, and surfaces the poll error', async () => {
|
|
793
|
+
jest.spyOn(server['client'], 'createApplicationScheduledTask').mockResolvedValue(mockTask);
|
|
794
|
+
jest
|
|
795
|
+
.spyOn(server['client'], 'listApplicationScheduledTaskExecutions')
|
|
796
|
+
.mockRejectedValue(new Error('network blip'));
|
|
797
|
+
const deleteSpy = jest
|
|
798
|
+
.spyOn(server['client'], 'deleteApplicationScheduledTask')
|
|
799
|
+
.mockResolvedValue({ message: 'deleted' });
|
|
800
|
+
const result = await callScheduledTasks(server, baseArgs);
|
|
801
|
+
expect(deleteSpy).toHaveBeenCalledWith('app-uuid', 'task-uuid');
|
|
802
|
+
expect(result.content[0].text).toContain('network blip');
|
|
803
|
+
expect(result.content[0].text).toContain('task-uuid');
|
|
804
|
+
});
|
|
805
|
+
it('warns loudly with the task UUID when the cleanup delete itself fails', async () => {
|
|
806
|
+
jest.spyOn(server['client'], 'createApplicationScheduledTask').mockResolvedValue(mockTask);
|
|
807
|
+
jest.spyOn(server['client'], 'listApplicationScheduledTaskExecutions').mockResolvedValue([
|
|
808
|
+
{
|
|
809
|
+
uuid: 'exec-uuid',
|
|
810
|
+
status: 'success',
|
|
811
|
+
message: 'ok',
|
|
812
|
+
retry_count: 0,
|
|
813
|
+
created_at: '',
|
|
814
|
+
updated_at: '',
|
|
815
|
+
},
|
|
816
|
+
]);
|
|
817
|
+
jest
|
|
818
|
+
.spyOn(server['client'], 'deleteApplicationScheduledTask')
|
|
819
|
+
.mockRejectedValue(new Error('403 forbidden'));
|
|
820
|
+
const result = await callScheduledTasks(server, baseArgs);
|
|
821
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
822
|
+
expect(parsed.cleanup).toContain('WARNING');
|
|
823
|
+
expect(parsed.cleanup).toContain('task-uuid');
|
|
824
|
+
expect(parsed.cleanup).toContain('403 forbidden');
|
|
825
|
+
});
|
|
826
|
+
it('supports the service resource', async () => {
|
|
827
|
+
const createSpy = jest
|
|
828
|
+
.spyOn(server['client'], 'createServiceScheduledTask')
|
|
829
|
+
.mockResolvedValue(mockTask);
|
|
830
|
+
jest.spyOn(server['client'], 'listServiceScheduledTaskExecutions').mockResolvedValue([
|
|
831
|
+
{
|
|
832
|
+
uuid: 'e',
|
|
833
|
+
status: 'success',
|
|
834
|
+
message: 'ok',
|
|
835
|
+
retry_count: 0,
|
|
836
|
+
created_at: '',
|
|
837
|
+
updated_at: '',
|
|
838
|
+
},
|
|
839
|
+
]);
|
|
840
|
+
const deleteSpy = jest
|
|
841
|
+
.spyOn(server['client'], 'deleteServiceScheduledTask')
|
|
842
|
+
.mockResolvedValue({ message: 'deleted' });
|
|
843
|
+
await callScheduledTasks(server, { ...baseArgs, resource: 'service', uuid: 'svc-uuid' });
|
|
844
|
+
expect(createSpy).toHaveBeenCalledWith('svc-uuid', expect.any(Object));
|
|
845
|
+
expect(deleteSpy).toHaveBeenCalledWith('svc-uuid', 'task-uuid');
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
describe('deploy tool handler', () => {
|
|
849
|
+
// #238 — opt-in `wait` polls the deployment to a terminal status instead
|
|
850
|
+
// of firing-and-forgetting. The no-wait path must stay byte-for-byte
|
|
851
|
+
// identical to the pre-#238 behaviour.
|
|
852
|
+
const callDeploy = async (srv, args) => {
|
|
853
|
+
const tool = srv._registeredTools['deploy'];
|
|
854
|
+
return tool.handler(args, {});
|
|
855
|
+
};
|
|
856
|
+
const essentialDeployment = (overrides = {}) => ({
|
|
857
|
+
uuid: 'dep-uuid',
|
|
858
|
+
deployment_uuid: 'dep-uuid',
|
|
859
|
+
application_uuid: 'app-uuid',
|
|
860
|
+
application_name: 'my-app',
|
|
861
|
+
status: 'in_progress',
|
|
862
|
+
commit: 'abc123',
|
|
863
|
+
force_rebuild: false,
|
|
864
|
+
is_webhook: false,
|
|
865
|
+
is_api: true,
|
|
866
|
+
created_at: '2026-01-01T10:00:00Z',
|
|
867
|
+
updated_at: '2026-01-01T10:00:00Z',
|
|
868
|
+
...overrides,
|
|
869
|
+
});
|
|
870
|
+
afterEach(() => {
|
|
871
|
+
jest.useRealTimers();
|
|
872
|
+
});
|
|
873
|
+
it('no-wait path is unchanged: triggers deploy and returns immediately', async () => {
|
|
874
|
+
const spy = jest
|
|
875
|
+
.spyOn(server['client'], 'deployByTagOrUuid')
|
|
876
|
+
.mockResolvedValue({ deployments: [{ deployment_uuid: 'dep-uuid' }] });
|
|
877
|
+
const pollSpy = jest.spyOn(server['client'], 'getDeployment');
|
|
878
|
+
const result = (await callDeploy(server, { tag_or_uuid: 'my-tag', force: true }));
|
|
879
|
+
expect(spy).toHaveBeenCalledWith('my-tag', true);
|
|
880
|
+
expect(pollSpy).not.toHaveBeenCalled();
|
|
881
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
882
|
+
expect(parsed.data).toEqual({ deployments: [{ deployment_uuid: 'dep-uuid' }] });
|
|
883
|
+
expect(parsed._actions).toEqual([
|
|
884
|
+
{ tool: 'list_deployments', args: {}, hint: 'Check deployment status' },
|
|
885
|
+
]);
|
|
886
|
+
});
|
|
887
|
+
it('wait: true polls until finished', async () => {
|
|
888
|
+
jest.useFakeTimers();
|
|
889
|
+
jest
|
|
890
|
+
.spyOn(server['client'], 'deployByTagOrUuid')
|
|
891
|
+
.mockResolvedValue({ deployments: [{ deployment_uuid: 'dep-uuid' }] });
|
|
892
|
+
const getDeploymentSpy = jest
|
|
893
|
+
.spyOn(server['client'], 'getDeployment')
|
|
894
|
+
.mockResolvedValueOnce(essentialDeployment({ status: 'in_progress' }))
|
|
895
|
+
.mockResolvedValueOnce(essentialDeployment({ status: 'finished' }));
|
|
896
|
+
const resultPromise = callDeploy(server, { tag_or_uuid: 'my-tag', wait: true });
|
|
897
|
+
await jest.advanceTimersByTimeAsync(5000);
|
|
898
|
+
const result = await resultPromise;
|
|
899
|
+
expect(getDeploymentSpy).toHaveBeenCalledTimes(2);
|
|
900
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
901
|
+
expect(parsed.data.status).toBe('finished');
|
|
902
|
+
expect(parsed.data.deployment_uuid).toBe('dep-uuid');
|
|
903
|
+
expect(parsed.data.logs_tail).toBeUndefined();
|
|
904
|
+
});
|
|
905
|
+
it('wait: true returns a bounded log tail on failure, never the raw payload', async () => {
|
|
906
|
+
jest.useFakeTimers();
|
|
907
|
+
jest
|
|
908
|
+
.spyOn(server['client'], 'deployByTagOrUuid')
|
|
909
|
+
.mockResolvedValue({ deployments: [{ deployment_uuid: 'dep-uuid' }] });
|
|
910
|
+
jest
|
|
911
|
+
.spyOn(server['client'], 'getDeployment')
|
|
912
|
+
.mockImplementation(async (uuid, options) => {
|
|
913
|
+
if (options?.includeLogs) {
|
|
914
|
+
return {
|
|
915
|
+
...essentialDeployment({ status: 'failed' }),
|
|
916
|
+
logs: JSON.stringify([{ output: 'build failed: OOM', timestamp: 't1' }]),
|
|
917
|
+
// Fields that would only appear on the raw upstream object —
|
|
918
|
+
// must never leak into the tool response.
|
|
919
|
+
server: { ip: '10.0.0.1', private_key: 'super-secret' },
|
|
920
|
+
application: { env_secret: 'shh' },
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
return essentialDeployment({ status: 'failed' });
|
|
924
|
+
});
|
|
925
|
+
const resultPromise = callDeploy(server, { tag_or_uuid: 'my-tag', wait: true });
|
|
926
|
+
const result = await resultPromise;
|
|
927
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
928
|
+
expect(parsed.data.status).toBe('failed');
|
|
929
|
+
expect(parsed.data.deployment_uuid).toBe('dep-uuid');
|
|
930
|
+
expect(parsed.data.logs_tail).toContain('build failed: OOM');
|
|
931
|
+
expect(result.content[0].text).not.toContain('private_key');
|
|
932
|
+
expect(result.content[0].text).not.toContain('env_secret');
|
|
933
|
+
expect(parsed.data).not.toHaveProperty('server');
|
|
934
|
+
expect(parsed.data).not.toHaveProperty('application');
|
|
935
|
+
});
|
|
936
|
+
it('wait: true returns an explicit timeout with a next-action hint', async () => {
|
|
937
|
+
jest.useFakeTimers();
|
|
938
|
+
jest
|
|
939
|
+
.spyOn(server['client'], 'deployByTagOrUuid')
|
|
940
|
+
.mockResolvedValue({ deployments: [{ deployment_uuid: 'dep-uuid' }] });
|
|
941
|
+
jest
|
|
942
|
+
.spyOn(server['client'], 'getDeployment')
|
|
943
|
+
.mockResolvedValue(essentialDeployment({ status: 'in_progress' }));
|
|
944
|
+
const resultPromise = callDeploy(server, {
|
|
945
|
+
tag_or_uuid: 'my-tag',
|
|
946
|
+
wait: true,
|
|
947
|
+
timeout_seconds: 10,
|
|
948
|
+
});
|
|
949
|
+
// Let the poll loop exceed the 10s timeout.
|
|
950
|
+
await jest.advanceTimersByTimeAsync(15_000);
|
|
951
|
+
const result = await resultPromise;
|
|
952
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
953
|
+
expect(parsed.data.status).toBe('in_progress');
|
|
954
|
+
expect(parsed.data.timed_out).toBe(true);
|
|
955
|
+
expect(parsed.data.deployment_uuid).toBe('dep-uuid');
|
|
956
|
+
expect(parsed.data.next_action).toEqual(expect.stringContaining('deployment'));
|
|
957
|
+
});
|
|
958
|
+
it('wait: true watches only the first deployment when a tag triggers several', async () => {
|
|
959
|
+
jest.useFakeTimers();
|
|
960
|
+
jest.spyOn(server['client'], 'deployByTagOrUuid').mockResolvedValue({
|
|
961
|
+
deployments: [{ deployment_uuid: 'dep-1' }, { deployment_uuid: 'dep-2' }],
|
|
962
|
+
});
|
|
963
|
+
const getDeploymentSpy = jest.spyOn(server['client'], 'getDeployment').mockResolvedValue(essentialDeployment({
|
|
964
|
+
status: 'finished',
|
|
965
|
+
deployment_uuid: 'dep-1',
|
|
966
|
+
uuid: 'dep-1',
|
|
967
|
+
}));
|
|
968
|
+
const resultPromise = callDeploy(server, { tag_or_uuid: 'my-tag', wait: true });
|
|
969
|
+
const result = await resultPromise;
|
|
970
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
971
|
+
expect(getDeploymentSpy).toHaveBeenCalledWith('dep-1');
|
|
972
|
+
expect(getDeploymentSpy).not.toHaveBeenCalledWith('dep-2');
|
|
973
|
+
expect(parsed.data.deployment_uuid).toBe('dep-1');
|
|
974
|
+
expect(parsed.data.additional_deployment_uuids).toEqual(['dep-2']);
|
|
975
|
+
});
|
|
976
|
+
});
|
|
645
977
|
});
|
|
646
978
|
describe('truncateLogs', () => {
|
|
647
979
|
// Plain text log tests
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Coolify API Client
|
|
3
3
|
* Complete HTTP client for the Coolify API v1
|
|
4
4
|
*/
|
|
5
|
-
import type { CoolifyConfig, DeleteOptions, MessageResponse, UuidResponse, Server, ServerResource, ServerDomain, ServerValidation, CreateServerRequest, UpdateServerRequest, Project, CreateProjectRequest, UpdateProjectRequest, Environment, CreateEnvironmentRequest, Application, CreateApplicationPublicRequest, CreateApplicationPrivateGHRequest, CreateApplicationPrivateKeyRequest, CreateApplicationDockerfileRequest, CreateApplicationDockerImageRequest, CreateApplicationDockerComposeRequest, UpdateApplicationRequest, ApplicationActionResponse, EnvironmentVariable, EnvVarSummary, CreateEnvVarRequest, UpdateEnvVarRequest, BulkUpdateEnvVarsRequest, Database, UpdateDatabaseRequest, CreatePostgresqlRequest, CreateMysqlRequest, CreateMariadbRequest, CreateMongodbRequest, CreateRedisRequest, CreateKeydbRequest, CreateClickhouseRequest, CreateDragonflyRequest, CreateDatabaseResponse, DatabaseBackup, BackupExecution, CreateDatabaseBackupRequest, UpdateDatabaseBackupRequest, Service, CreateServiceRequest, UpdateServiceRequest, ServiceCreateResponse, Deployment, DeploymentEssential, Team, TeamMember, PrivateKey, CreatePrivateKeyRequest, UpdatePrivateKeyRequest, GitHubApp, CreateGitHubAppRequest, UpdateGitHubAppRequest, GitHubAppUpdateResponse, CloudToken, CreateCloudTokenRequest, UpdateCloudTokenRequest, CloudTokenValidation, Version, StorageListResponse, CreateStorageRequest, UpdateStorageRequest, ScheduledTask, ScheduledTaskExecution, CreateScheduledTaskRequest, UpdateScheduledTaskRequest, HetznerLocation, HetznerServerType, HetznerImage, HetznerSSHKey, CreateHetznerServerRequest, CreateHetznerServerResponse, GitHubRepository, GitHubBranch, ApplicationDiagnostic, ServerDiagnostic, InfrastructureIssuesReport, BatchOperationResult, ResourceListItem, ResourceListItemFull } from '../types/coolify.js';
|
|
5
|
+
import type { CoolifyConfig, DeleteOptions, MessageResponse, UuidResponse, Server, ServerResource, ServerDomain, ServerValidation, CreateServerRequest, UpdateServerRequest, Project, CreateProjectRequest, UpdateProjectRequest, Environment, CreateEnvironmentRequest, Application, CreateApplicationPublicRequest, CreateApplicationPrivateGHRequest, CreateApplicationPrivateKeyRequest, CreateApplicationDockerfileRequest, CreateApplicationDockerImageRequest, CreateApplicationDockerComposeRequest, UpdateApplicationRequest, ApplicationActionResponse, EnvironmentVariable, EnvVarSummary, CreateEnvVarRequest, UpdateEnvVarRequest, BulkUpdateEnvVarsRequest, Database, UpdateDatabaseRequest, CreatePostgresqlRequest, CreateMysqlRequest, CreateMariadbRequest, CreateMongodbRequest, CreateRedisRequest, CreateKeydbRequest, CreateClickhouseRequest, CreateDragonflyRequest, CreateDatabaseResponse, DatabaseBackup, BackupExecution, CreateDatabaseBackupRequest, UpdateDatabaseBackupRequest, Service, CreateServiceRequest, UpdateServiceRequest, ServiceCreateResponse, Deployment, DeploymentEssential, DeployTriggerResponse, Team, TeamMember, PrivateKey, CreatePrivateKeyRequest, UpdatePrivateKeyRequest, GitHubApp, CreateGitHubAppRequest, UpdateGitHubAppRequest, GitHubAppUpdateResponse, CloudToken, CreateCloudTokenRequest, UpdateCloudTokenRequest, CloudTokenValidation, Version, StorageListResponse, CreateStorageRequest, UpdateStorageRequest, ScheduledTask, ScheduledTaskExecution, CreateScheduledTaskRequest, UpdateScheduledTaskRequest, HetznerLocation, HetznerServerType, HetznerImage, HetznerSSHKey, CreateHetznerServerRequest, CreateHetznerServerResponse, GitHubRepository, GitHubBranch, ApplicationDiagnostic, ServerDiagnostic, InfrastructureIssuesReport, BatchOperationResult, ResourceListItem, ResourceListItemFull } from '../types/coolify.js';
|
|
6
6
|
export interface ListOptions {
|
|
7
7
|
page?: number;
|
|
8
8
|
per_page?: number;
|
|
@@ -66,6 +66,13 @@ export interface GitHubAppSummary {
|
|
|
66
66
|
is_public: boolean;
|
|
67
67
|
app_id: number | null;
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Map a failed response's status/path to an actionable hint for known Coolify quirks.
|
|
71
|
+
* Coolify sometimes returns bodyless errors (e.g. bare `HTTP 500: Internal Server Error`)
|
|
72
|
+
* that leave the caller guessing at the cause — this appends a short, testable hint for
|
|
73
|
+
* the cases we've hit in practice. Returns undefined when no known case matches.
|
|
74
|
+
*/
|
|
75
|
+
export declare function errorHint(status: number, path: string): string | undefined;
|
|
69
76
|
/**
|
|
70
77
|
* HTTP client for the Coolify API
|
|
71
78
|
*/
|
|
@@ -115,6 +122,12 @@ export declare class CoolifyClient {
|
|
|
115
122
|
createApplicationPrivateKey(data: CreateApplicationPrivateKeyRequest): Promise<UuidResponse>;
|
|
116
123
|
createApplicationDockerfile(data: CreateApplicationDockerfileRequest): Promise<UuidResponse>;
|
|
117
124
|
createApplicationDockerImage(data: CreateApplicationDockerImageRequest): Promise<UuidResponse>;
|
|
125
|
+
/**
|
|
126
|
+
* @deprecated Coolify removed POST /applications/dockercompose upstream in
|
|
127
|
+
* v4.1.0 (coollabsio/coolify commit 6ee75cfa) in favour of POST /services.
|
|
128
|
+
* This 404s against current Coolify releases; use createService instead.
|
|
129
|
+
* Not exposed via any MCP tool — see #235.
|
|
130
|
+
*/
|
|
118
131
|
createApplicationDockerCompose(data: CreateApplicationDockerComposeRequest): Promise<UuidResponse>;
|
|
119
132
|
updateApplication(uuid: string, data: UpdateApplicationRequest): Promise<Application>;
|
|
120
133
|
deleteApplication(uuid: string, options?: DeleteOptions): Promise<MessageResponse>;
|
|
@@ -180,7 +193,7 @@ export declare class CoolifyClient {
|
|
|
180
193
|
getDeployment(uuid: string, options?: {
|
|
181
194
|
includeLogs?: boolean;
|
|
182
195
|
}): Promise<DeploymentEssential>;
|
|
183
|
-
deployByTagOrUuid(tagOrUuid: string, force?: boolean): Promise<
|
|
196
|
+
deployByTagOrUuid(tagOrUuid: string, force?: boolean): Promise<DeployTriggerResponse>;
|
|
184
197
|
/**
|
|
185
198
|
* List deployments for an application.
|
|
186
199
|
*
|
|
@@ -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 };
|
|
@@ -1387,6 +1415,18 @@ export class CoolifyClient {
|
|
|
1387
1415
|
healthStatus = 'unhealthy';
|
|
1388
1416
|
}
|
|
1389
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
|
+
}
|
|
1390
1430
|
return {
|
|
1391
1431
|
application: app
|
|
1392
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' }] };
|
|
@@ -887,7 +1022,25 @@ export class CoolifyMcpServer extends McpServer {
|
|
|
887
1022
|
// Deployments (3 tools)
|
|
888
1023
|
// =========================================================================
|
|
889
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)));
|
|
890
|
-
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
|
+
});
|
|
891
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.', {
|
|
892
1045
|
action: z.enum(['get', 'cancel', 'list_for_app']),
|
|
893
1046
|
uuid: z.string(),
|
|
@@ -1323,17 +1476,31 @@ export class CoolifyMcpServer extends McpServer {
|
|
|
1323
1476
|
// =========================================================================
|
|
1324
1477
|
// Scheduled Tasks (1 tool - consolidated for app/service)
|
|
1325
1478
|
// =========================================================================
|
|
1326
|
-
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).', {
|
|
1327
1487
|
resource: z.enum(['application', 'service']),
|
|
1328
|
-
action: z.enum(['list', 'create', 'update', 'delete', 'list_executions']),
|
|
1488
|
+
action: z.enum(['list', 'create', 'update', 'delete', 'list_executions', 'run_once']),
|
|
1329
1489
|
uuid: z.string(),
|
|
1330
1490
|
task_uuid: z.string().optional(),
|
|
1331
1491
|
name: z.string().optional(),
|
|
1332
|
-
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(),
|
|
1333
1496
|
frequency: z.string().optional(),
|
|
1334
1497
|
container: z.string().optional(),
|
|
1335
1498
|
timeout: z.number().optional(),
|
|
1336
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)'),
|
|
1337
1504
|
}, async (args) => {
|
|
1338
1505
|
const { resource, action, uuid, task_uuid } = args;
|
|
1339
1506
|
const isApp = resource === 'application';
|
|
@@ -1390,6 +1557,12 @@ export class CoolifyMcpServer extends McpServer {
|
|
|
1390
1557
|
return wrap(() => isApp
|
|
1391
1558
|
? this.client.listApplicationScheduledTaskExecutions(uuid, task_uuid)
|
|
1392
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);
|
|
1393
1566
|
}
|
|
1394
1567
|
});
|
|
1395
1568
|
// =========================================================================
|
|
@@ -1524,4 +1697,131 @@ export class CoolifyMcpServer extends McpServer {
|
|
|
1524
1697
|
});
|
|
1525
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)));
|
|
1526
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
|
+
}
|
|
1527
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;
|
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",
|