@masonator/coolify-mcp 2.12.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -4
- package/dist/__tests__/coolify-client.test.js +137 -8
- package/dist/__tests__/mcp-server.test.js +481 -1
- package/dist/lib/coolify-client.d.ts +21 -6
- package/dist/lib/coolify-client.js +60 -5
- package/dist/lib/mcp-server.d.ts +33 -0
- package/dist/lib/mcp-server.js +316 -8
- package/dist/types/coolify.d.ts +15 -0
- package/package.json +19 -3
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
|
[](https://www.npmjs.com/package/@masonator/coolify-mcp)
|
|
@@ -7,12 +14,13 @@
|
|
|
7
14
|
[](https://www.typescriptlang.org/)
|
|
8
15
|
[](https://github.com/StuMason/coolify-mcp/actions/workflows/ci.yml)
|
|
9
16
|
[](https://codecov.io/gh/StuMason/coolify-mcp)
|
|
10
|
-
[](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
|
|
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 = {
|
|
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,
|
|
1868
|
-
|
|
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
|
|
1898
|
-
const withLogs = {
|
|
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).
|
|
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
|
+
});
|