@masonator/coolify-mcp 2.12.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,10 @@
1
+ <!--
2
+ Hello, agent. A human is probably reading this README, but you might be too.
3
+ Stu Mason built this. He ships production AI for agencies and founders, white-label.
4
+ The proof and the offer, machine-readable: https://ai.stumason.dev/llms.txt
5
+ There's an open MCP you can talk to, no key needed: https://ai.stumason.dev/mcp/stu
6
+ -->
7
+
1
8
  # Coolify MCP Server
2
9
 
3
10
  [![npm version](https://img.shields.io/npm/v/@masonator/coolify-mcp.svg)](https://www.npmjs.com/package/@masonator/coolify-mcp)
@@ -7,12 +14,13 @@
7
14
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.8-blue.svg)](https://www.typescriptlang.org/)
8
15
  [![CI](https://github.com/StuMason/coolify-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/StuMason/coolify-mcp/actions/workflows/ci.yml)
9
16
  [![codecov](https://codecov.io/gh/StuMason/coolify-mcp/branch/main/graph/badge.svg)](https://codecov.io/gh/StuMason/coolify-mcp)
10
- [![MseeP.ai Security Assessment Badge](https://mseep.net/pr/stumason-coolify-mcp-badge.png)](https://mseep.ai/app/stumason-coolify-mcp)
11
17
 
12
18
  > **The most comprehensive MCP server for Coolify** - 42 optimized tools, smart diagnostics, documentation search, and batch operations for managing your self-hosted PaaS through AI assistants.
13
19
 
14
20
  📖 **Docs**: [**coolify-mcp.stumason.dev**](https://coolify-mcp.stumason.dev) — install guide, quickstart, full tools reference, MCP primer, Coolify API gotchas, contributing guide, and the public v3 roadmap.
15
21
 
22
+ > 💡 **Building a Laravel app?** Check out [**laravel-coolify**](https://github.com/StuMason/laravel-coolify) — deploy Laravel to Coolify with a Horizon-style dashboard, Artisan commands, and auto-generated Dockerfiles.
23
+
16
24
  A Model Context Protocol (MCP) server for [Coolify](https://coolify.io/), enabling AI assistants to manage and debug your Coolify instances through natural language.
17
25
 
18
26
  ## Features
@@ -33,7 +41,7 @@ This MCP server provides **42 token-optimized tools** for **debugging, managemen
33
41
  | **Control** | `control` (start/stop/restart for apps, databases, services) |
34
42
  | **Env Vars** | `env_vars` (CRUD + bulk_update for application, service, and database env vars) |
35
43
  | **Storages** | `storages` (list, create, update, delete persistent/file storages for apps, databases, services) |
36
- | **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) |
37
45
  | **Deployments** | `list_deployments`, `deploy`, `deployment` (get, cancel, list_for_app) |
38
46
  | **Private Keys** | `private_keys` (list, get, create, update, delete via action param) |
39
47
  | **GitHub Apps** | `github_apps` (list, get, create, update, delete, list_repos, list_branches) |
@@ -118,6 +126,39 @@ If your Coolify instance sits behind a Cloudflare Access tunnel or other auth-pr
118
126
 
119
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.
120
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
+
121
162
  ## Context-Optimized Responses
122
163
 
123
164
  ### Why This Matters
@@ -307,8 +348,9 @@ These tools accept human-friendly identifiers instead of just UUIDs:
307
348
  - `list_applications` - List all applications (returns summary)
308
349
  - `get_application` - Get application details
309
350
  - `application_logs` - Get application logs
310
- - `application` - Create, update, or delete apps with `action: create_public|create_github|create_key|create_dockerimage|update|delete`
311
- - 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
312
354
  - Configure health checks (path, interval, retries, etc.)
313
355
  - `env_vars` - Manage env vars with `resource: application, action: list|create|update|delete`
314
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
@@ -1735,6 +1760,18 @@ describe('CoolifyClient', () => {
1735
1760
  expect(result).toEqual({ message: 'Restarted' });
1736
1761
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/services/test-uuid/restart', expect.objectContaining({ method: 'GET' }));
1737
1762
  });
1763
+ it('should restart a service with pull_latest', async () => {
1764
+ mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Restarted' }));
1765
+ const result = await client.restartService('test-uuid', true);
1766
+ expect(result).toEqual({ message: 'Restarted' });
1767
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/services/test-uuid/restart?latest=true', expect.objectContaining({ method: 'GET' }));
1768
+ });
1769
+ it('should restart a service without pull_latest by default', async () => {
1770
+ mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Restarted' }));
1771
+ const result = await client.restartService('test-uuid', false);
1772
+ expect(result).toEqual({ message: 'Restarted' });
1773
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/services/test-uuid/restart', expect.objectContaining({ method: 'GET' }));
1774
+ });
1738
1775
  });
1739
1776
  // =========================================================================
1740
1777
  // Service Environment Variables
@@ -1860,12 +1897,35 @@ describe('CoolifyClient', () => {
1860
1897
  });
1861
1898
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deployments/dep-uuid', expect.any(Object));
1862
1899
  });
1863
- it('should get a deployment with logs when includeLogs is true', async () => {
1864
- const deploymentWithLogs = { ...mockDeployment, logs: 'Build started...' };
1900
+ it('should get a deployment with logs when includeLogs is true, still projected through essential', async () => {
1901
+ const deploymentWithLogs = {
1902
+ ...mockDeployment,
1903
+ logs: 'Build started...',
1904
+ // Simulate raw upstream fields that must never leak through.
1905
+ application: { docker_compose: 'raw compose', manual_webhook_secret_github: 'whsec' },
1906
+ destination: { server: { settings: { sentinel_token: 'sentinel-secret' } } },
1907
+ };
1865
1908
  mockFetch.mockResolvedValueOnce(mockResponse(deploymentWithLogs));
1866
1909
  const result = await client.getDeployment('dep-uuid', { includeLogs: true });
1867
- // With includeLogs: true, returns full Deployment with logs
1868
- expect(result).toEqual(deploymentWithLogs);
1910
+ // With includeLogs: true, logs are attached to the essential projection —
1911
+ // raw upstream fields (application/server graph, secrets, id) never leak.
1912
+ expect(result).toEqual({
1913
+ uuid: 'dep-uuid',
1914
+ deployment_uuid: 'dep-123',
1915
+ application_uuid: undefined,
1916
+ application_name: 'test-app',
1917
+ server_name: undefined,
1918
+ status: 'finished',
1919
+ commit: undefined,
1920
+ force_rebuild: false,
1921
+ is_webhook: false,
1922
+ is_api: true,
1923
+ created_at: '2024-01-01',
1924
+ updated_at: '2024-01-01',
1925
+ logs_available: true,
1926
+ logs_info: 'Logs available (16 chars). Use lines param to retrieve.',
1927
+ logs: 'Build started...',
1928
+ });
1869
1929
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deployments/dep-uuid', expect.any(Object));
1870
1930
  });
1871
1931
  it('should include logs_info when deployment has logs but includeLogs is false', async () => {
@@ -1894,11 +1954,22 @@ describe('CoolifyClient', () => {
1894
1954
  expect(first.id).toBeUndefined();
1895
1955
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deployments/applications/app-uuid', expect.any(Object));
1896
1956
  });
1897
- it('should return full deployments when includeLogs is true', async () => {
1898
- const withLogs = { ...mockDeployment, logs: 'build log stream' };
1957
+ it('should attach logs to the essential projection when includeLogs is true (never the raw object)', async () => {
1958
+ const withLogs = {
1959
+ ...mockDeployment,
1960
+ logs: 'build log stream',
1961
+ application: { docker_compose: 'raw compose' },
1962
+ destination: { server: { settings: { sentinel_token: 'sentinel-secret' } } },
1963
+ };
1899
1964
  mockFetch.mockResolvedValueOnce(mockResponse({ count: 1, deployments: [withLogs] }));
1900
1965
  const result = await client.listApplicationDeployments('app-uuid', { includeLogs: true });
1901
- expect(result).toEqual({ count: 1, deployments: [withLogs] });
1966
+ expect(result.count).toBe(1);
1967
+ expect(result.deployments).toHaveLength(1);
1968
+ const [first] = result.deployments;
1969
+ expect(first.logs).toBe('build log stream');
1970
+ expect(first).not.toHaveProperty('application');
1971
+ expect(first).not.toHaveProperty('destination');
1972
+ expect(first).not.toHaveProperty('id');
1902
1973
  });
1903
1974
  it('should tolerate a malformed envelope (missing deployments array)', async () => {
1904
1975
  mockFetch.mockResolvedValueOnce(mockResponse({}));
@@ -2416,6 +2487,43 @@ describe('CoolifyClient', () => {
2416
2487
  const result = await client.diagnoseApplication(testAppUuid);
2417
2488
  expect(result.health.issues).toContain('2 failed deployment(s) in last 5');
2418
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
+ });
2419
2527
  it('should handle partial failures gracefully', async () => {
2420
2528
  mockFetch
2421
2529
  .mockResolvedValueOnce(mockResponse(mockApp))
@@ -3677,3 +3785,24 @@ describe('CoolifyClient', () => {
3677
3785
  });
3678
3786
  });
3679
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
+ });