@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 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 Docker images
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<MessageResponse>;
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
  ? {
@@ -30,5 +30,38 @@ export declare class CoolifyMcpServer extends McpServer {
30
30
  private readonly docsSearch;
31
31
  constructor(config: CoolifyConfig);
32
32
  connect(transport: Transport): Promise<void>;
33
+ /**
34
+ * Poll a single deployment until it reaches a terminal status or the
35
+ * timeout elapses. Uses `getDeployment`'s no-logs projection
36
+ * (`DeploymentEssential`) while polling, and only fetches logs (once,
37
+ * truncated) if the deployment failed.
38
+ */
39
+ private pollDeployment;
40
+ /**
41
+ * Trigger a deploy and wait for it to finish. A tag can resolve to
42
+ * multiple applications, so `deployByTagOrUuid` may return several
43
+ * `deployment_uuid`s — only the first is polled; any others are
44
+ * surfaced under `additional_deployment_uuids` for the caller to check
45
+ * separately via `deployment get`.
46
+ */
47
+ private triggerAndWaitForDeploy;
33
48
  private registerTools;
49
+ /**
50
+ * Injectable delay for the run_once poll loop. A real setTimeout in production;
51
+ * tests replace it with `jest.spyOn(server, 'sleep').mockResolvedValue(undefined)`
52
+ * so polling logic runs without waiting on the wall clock.
53
+ */
54
+ private sleep;
55
+ /**
56
+ * Composite one-off command execution (#233 / #208): there is no upstream
57
+ * "run now" endpoint for scheduled tasks, so this creates a throwaway
58
+ * `* * * * *` task, polls list_executions until the first execution reaches a
59
+ * terminal status (or the poll budget runs out), and returns its status+message.
60
+ *
61
+ * The task is deleted in a finally-equivalent (try/finally-style) block so cleanup
62
+ * always runs — on success, on timeout, and on a polling error. If cleanup itself
63
+ * fails, the returned message says so loudly with the task UUID so a human can
64
+ * remove it manually (it would otherwise keep firing every minute).
65
+ */
66
+ private runOnceScheduledTask;
34
67
  }
@@ -152,6 +152,33 @@ function wrapWithActions(fn, getActions, getPaginationFn) {
152
152
  ],
153
153
  }));
154
154
  }
155
+ // =============================================================================
156
+ // Deploy wait/poll helpers (#238)
157
+ // =============================================================================
158
+ /** Deployment statuses that end a run — polling stops once one of these is hit. */
159
+ const TERMINAL_DEPLOYMENT_STATUSES = new Set([
160
+ 'finished',
161
+ 'failed',
162
+ 'cancelled',
163
+ ]);
164
+ const DEFAULT_DEPLOY_TIMEOUT_SECONDS = 300;
165
+ const DEPLOY_POLL_INTERVAL_MS = 5000;
166
+ function isTerminalDeploymentStatus(status) {
167
+ return TERMINAL_DEPLOYMENT_STATUSES.has(status);
168
+ }
169
+ /** Isolated so tests can drive polling with jest fake timers instead of real waits. */
170
+ function sleep(ms) {
171
+ return new Promise((resolve) => setTimeout(resolve, ms));
172
+ }
173
+ function durationSeconds(createdAt, updatedAt) {
174
+ if (!createdAt || !updatedAt)
175
+ return undefined;
176
+ const start = Date.parse(createdAt);
177
+ const end = Date.parse(updatedAt);
178
+ if (Number.isNaN(start) || Number.isNaN(end))
179
+ return undefined;
180
+ return Math.max(0, Math.round((end - start) / 1000));
181
+ }
155
182
  export class CoolifyMcpServer extends McpServer {
156
183
  client;
157
184
  docsSearch = new DocsSearchEngine();
@@ -163,6 +190,82 @@ export class CoolifyMcpServer extends McpServer {
163
190
  async connect(transport) {
164
191
  await super.connect(transport);
165
192
  }
193
+ /**
194
+ * Poll a single deployment until it reaches a terminal status or the
195
+ * timeout elapses. Uses `getDeployment`'s no-logs projection
196
+ * (`DeploymentEssential`) while polling, and only fetches logs (once,
197
+ * truncated) if the deployment failed.
198
+ */
199
+ async pollDeployment(uuid, timeoutSeconds) {
200
+ const deadline = Date.now() + timeoutSeconds * 1000;
201
+ let current = (await this.client.getDeployment(uuid));
202
+ while (!isTerminalDeploymentStatus(current.status) && Date.now() < deadline) {
203
+ await sleep(DEPLOY_POLL_INTERVAL_MS);
204
+ current = (await this.client.getDeployment(uuid));
205
+ }
206
+ if (!isTerminalDeploymentStatus(current.status)) {
207
+ return {
208
+ status: current.status,
209
+ deployment_uuid: uuid,
210
+ application_uuid: current.application_uuid,
211
+ timed_out: true,
212
+ next_action: `Still "${current.status}" after ${timeoutSeconds}s — poll \`deployment\` (action: "get", uuid: "${uuid}") to keep watching.`,
213
+ };
214
+ }
215
+ if (current.status === 'failed') {
216
+ const withLogs = (await this.client.getDeployment(uuid, {
217
+ includeLogs: true,
218
+ }));
219
+ const tail = withLogs.logs ? truncateLogs(withLogs.logs, 30, 10_000) : undefined;
220
+ return {
221
+ status: current.status,
222
+ deployment_uuid: uuid,
223
+ application_uuid: current.application_uuid,
224
+ commit: current.commit,
225
+ created_at: current.created_at,
226
+ updated_at: current.updated_at,
227
+ duration_seconds: durationSeconds(current.created_at, current.updated_at),
228
+ logs_tail: tail?.logs,
229
+ logs_meta: tail
230
+ ? {
231
+ total_entries: tail.total,
232
+ showing: `${tail.showing_start}-${tail.showing_end} of ${tail.total}`,
233
+ }
234
+ : undefined,
235
+ next_action: `Deployment failed. See logs_tail above, or \`deployment\` (action: "get", uuid: "${uuid}", lines: N) for more.`,
236
+ };
237
+ }
238
+ return {
239
+ status: current.status,
240
+ deployment_uuid: uuid,
241
+ application_uuid: current.application_uuid,
242
+ commit: current.commit,
243
+ created_at: current.created_at,
244
+ updated_at: current.updated_at,
245
+ duration_seconds: durationSeconds(current.created_at, current.updated_at),
246
+ };
247
+ }
248
+ /**
249
+ * Trigger a deploy and wait for it to finish. A tag can resolve to
250
+ * multiple applications, so `deployByTagOrUuid` may return several
251
+ * `deployment_uuid`s — only the first is polled; any others are
252
+ * surfaced under `additional_deployment_uuids` for the caller to check
253
+ * separately via `deployment get`.
254
+ */
255
+ async triggerAndWaitForDeploy(tagOrUuid, force, timeoutSeconds) {
256
+ const triggered = await this.client.deployByTagOrUuid(tagOrUuid, force);
257
+ const [first, ...rest] = triggered.deployments ?? [];
258
+ if (!first?.deployment_uuid) {
259
+ // Nothing to poll against — hand back the trigger response as-is.
260
+ return triggered;
261
+ }
262
+ const result = await this.pollDeployment(first.deployment_uuid, timeoutSeconds);
263
+ const additional = rest.map((d) => d.deployment_uuid).filter((u) => !!u);
264
+ if (additional.length > 0) {
265
+ result.additional_deployment_uuids = additional;
266
+ }
267
+ return result;
268
+ }
166
269
  registerTools() {
167
270
  // =========================================================================
168
271
  // Meta (2 tools)
@@ -300,6 +403,7 @@ export class CoolifyMcpServer extends McpServer {
300
403
  'create_github',
301
404
  'create_key',
302
405
  'create_dockerimage',
406
+ 'create_dockerfile',
303
407
  'update',
304
408
  'delete',
305
409
  'delete_preview',
@@ -320,6 +424,8 @@ export class CoolifyMcpServer extends McpServer {
320
424
  // Docker image fields
321
425
  docker_registry_image_name: z.string().optional(),
322
426
  docker_registry_image_tag: z.string().optional(),
427
+ // Dockerfile fields (create_dockerfile)
428
+ dockerfile: z.string().optional(),
323
429
  // Update fields
324
430
  name: z.string().optional(),
325
431
  description: z.string().optional(),
@@ -565,6 +671,35 @@ export class CoolifyMcpServer extends McpServer {
565
671
  custom_labels: args.custom_labels,
566
672
  instant_deploy: args.instant_deploy,
567
673
  }));
674
+ case 'create_dockerfile':
675
+ if (!args.project_uuid || !args.server_uuid || !args.dockerfile) {
676
+ return {
677
+ content: [
678
+ {
679
+ type: 'text',
680
+ text: 'Error: project_uuid, server_uuid, dockerfile required',
681
+ },
682
+ ],
683
+ };
684
+ }
685
+ return wrap(() => this.client.createApplicationDockerfile({
686
+ project_uuid: args.project_uuid,
687
+ server_uuid: args.server_uuid,
688
+ destination_uuid: args.destination_uuid,
689
+ dockerfile: args.dockerfile,
690
+ dockerfile_location: args.dockerfile_location,
691
+ ports_exposes: args.ports_exposes,
692
+ base_directory: args.base_directory,
693
+ environment_name: args.environment_name,
694
+ environment_uuid: args.environment_uuid,
695
+ name: args.name,
696
+ description: args.description,
697
+ fqdn: args.fqdn,
698
+ domains: args.domains,
699
+ custom_docker_run_options: args.custom_docker_run_options,
700
+ custom_labels: args.custom_labels,
701
+ instant_deploy: args.instant_deploy,
702
+ }));
568
703
  case 'update': {
569
704
  if (!uuid)
570
705
  return { content: [{ type: 'text', text: 'Error: uuid required' }] };
@@ -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', { tag_or_uuid: z.string(), force: z.boolean().optional() }, async ({ tag_or_uuid, force }) => wrapWithActions(() => this.client.deployByTagOrUuid(tag_or_uuid, force), () => [{ tool: 'list_deployments', args: {}, hint: 'Check deployment status' }]));
1025
+ this.tool('deploy', 'Deploy by tag/UUID', {
1026
+ tag_or_uuid: z.string(),
1027
+ force: z.boolean().optional(),
1028
+ wait: z
1029
+ .boolean()
1030
+ .optional()
1031
+ .describe('Wait for the deployment to reach a terminal status (finished/failed/cancelled) instead of returning immediately, polling every ~5s. If tag_or_uuid matches multiple applications (a tag can trigger several deployments), only the first is watched — the rest are returned under additional_deployment_uuids for you to check separately via `deployment get`. On failure the response includes a bounded log tail. Default false (fire-and-forget, unchanged response).'),
1032
+ timeout_seconds: z
1033
+ .number()
1034
+ .optional()
1035
+ .describe('Max seconds to poll when wait is true before giving up and returning the current status plus a next-action hint (default 300). Ignored when wait is false.'),
1036
+ }, async ({ tag_or_uuid, force, wait, timeout_seconds }) => {
1037
+ if (!wait) {
1038
+ return wrapWithActions(() => this.client.deployByTagOrUuid(tag_or_uuid, force), () => [{ tool: 'list_deployments', args: {}, hint: 'Check deployment status' }]);
1039
+ }
1040
+ return wrapWithActions(() => this.triggerAndWaitForDeploy(tag_or_uuid, force, timeout_seconds ?? DEFAULT_DEPLOY_TIMEOUT_SECONDS), (result) => 'deployment_uuid' in result
1041
+ ? getDeploymentActions(result.deployment_uuid, result.status, result.application_uuid)
1042
+ : []);
1043
+ });
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.string().optional(),
1492
+ command: z
1493
+ .string()
1494
+ .max(255, 'Coolify rejects scheduled-task commands longer than 255 chars — split the command or bake a script into the container image')
1495
+ .optional(),
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
  }
@@ -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.12.1",
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",