@masonator/coolify-mcp 2.8.0 → 2.9.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 +28 -0
- package/dist/__tests__/coolify-client.test.js +284 -19
- package/dist/__tests__/integration/diagnostics.integration.test.js +3 -2
- package/dist/__tests__/mcp-server.test.js +103 -1
- package/dist/__tests__/parse-headers.test.d.ts +1 -0
- package/dist/__tests__/parse-headers.test.js +32 -0
- package/dist/index.js +3 -0
- package/dist/lib/coolify-client.d.ts +22 -3
- package/dist/lib/coolify-client.js +101 -9
- package/dist/lib/mcp-server.js +28 -10
- package/dist/lib/parse-headers.d.ts +1 -0
- package/dist/lib/parse-headers.js +14 -0
- package/dist/types/coolify.d.ts +11 -5
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -85,6 +85,34 @@ claude mcp add coolify \
|
|
|
85
85
|
env COOLIFY_ACCESS_TOKEN=your-api-token COOLIFY_BASE_URL=https://your-coolify-instance.com npx -y @masonator/coolify-mcp
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
### Custom HTTP Headers (Cloudflare Zero Trust, Auth Proxies)
|
|
89
|
+
|
|
90
|
+
If your Coolify instance sits behind a Cloudflare Access tunnel or other auth-proxy middleware, pass extra headers on every outbound request with `--header`:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"mcpServers": {
|
|
95
|
+
"coolify": {
|
|
96
|
+
"command": "npx",
|
|
97
|
+
"args": [
|
|
98
|
+
"-y",
|
|
99
|
+
"@masonator/coolify-mcp",
|
|
100
|
+
"--header",
|
|
101
|
+
"CF-Access-Client-Id: abc123.access",
|
|
102
|
+
"--header",
|
|
103
|
+
"CF-Access-Client-Secret: your-secret"
|
|
104
|
+
],
|
|
105
|
+
"env": {
|
|
106
|
+
"COOLIFY_ACCESS_TOKEN": "your-api-token",
|
|
107
|
+
"COOLIFY_BASE_URL": "https://your-coolify-instance.com"
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
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.
|
|
115
|
+
|
|
88
116
|
## Context-Optimized Responses
|
|
89
117
|
|
|
90
118
|
### Why This Matters
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
|
2
2
|
import { CoolifyClient } from '../lib/coolify-client.js';
|
|
3
3
|
// Helper to create mock response
|
|
4
|
-
function mockResponse(data, ok = true, status = 200) {
|
|
4
|
+
function mockResponse(data, ok = true, status = 200, contentType = 'application/json') {
|
|
5
|
+
const body = contentType.includes('application/json') ? JSON.stringify(data) : String(data);
|
|
5
6
|
return {
|
|
6
7
|
ok,
|
|
7
8
|
status,
|
|
8
9
|
statusText: ok ? 'OK' : 'Error',
|
|
9
|
-
|
|
10
|
+
headers: new Headers({ 'Content-Type': contentType }),
|
|
11
|
+
text: async () => body,
|
|
10
12
|
};
|
|
11
13
|
}
|
|
12
14
|
const mockFetch = jest.fn();
|
|
@@ -126,6 +128,42 @@ describe('CoolifyClient', () => {
|
|
|
126
128
|
c.getVersion();
|
|
127
129
|
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/version', expect.any(Object));
|
|
128
130
|
});
|
|
131
|
+
it('should merge customHeaders into outgoing requests', async () => {
|
|
132
|
+
const c = new CoolifyClient({
|
|
133
|
+
baseUrl: 'http://localhost:3000',
|
|
134
|
+
accessToken: 'test-token',
|
|
135
|
+
customHeaders: { 'CF-Access-Client-Id': 'abc', 'CF-Access-Client-Secret': 'xyz' },
|
|
136
|
+
});
|
|
137
|
+
mockFetch.mockResolvedValueOnce(mockResponse([{ uuid: 's1', name: 'srv' }]));
|
|
138
|
+
await c.listServers();
|
|
139
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
|
140
|
+
headers: expect.objectContaining({
|
|
141
|
+
'CF-Access-Client-Id': 'abc',
|
|
142
|
+
'CF-Access-Client-Secret': 'xyz',
|
|
143
|
+
Authorization: 'Bearer test-token',
|
|
144
|
+
}),
|
|
145
|
+
}));
|
|
146
|
+
});
|
|
147
|
+
it('should filter reserved headers from customHeaders', async () => {
|
|
148
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
|
|
149
|
+
const c = new CoolifyClient({
|
|
150
|
+
baseUrl: 'http://localhost:3000',
|
|
151
|
+
accessToken: 'test-token',
|
|
152
|
+
customHeaders: {
|
|
153
|
+
Authorization: 'Bearer override',
|
|
154
|
+
'Content-Type': 'text/plain',
|
|
155
|
+
'X-Safe-Header': 'allowed',
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
mockFetch.mockResolvedValueOnce(mockResponse([{ uuid: 's1', name: 'srv' }]));
|
|
159
|
+
await c.listServers();
|
|
160
|
+
const headers = mockFetch.mock.calls[0][1]?.headers;
|
|
161
|
+
expect(headers['Authorization']).toBe('Bearer test-token');
|
|
162
|
+
expect(headers['Content-Type']).toBe('application/json');
|
|
163
|
+
expect(headers['X-Safe-Header']).toBe('allowed');
|
|
164
|
+
expect(warnSpy).toHaveBeenCalledTimes(2);
|
|
165
|
+
warnSpy.mockRestore();
|
|
166
|
+
});
|
|
129
167
|
});
|
|
130
168
|
describe('listServers', () => {
|
|
131
169
|
it('should return a list of servers', async () => {
|
|
@@ -485,6 +523,21 @@ describe('CoolifyClient', () => {
|
|
|
485
523
|
const result = await client.deleteServer('test-uuid');
|
|
486
524
|
expect(result).toEqual({});
|
|
487
525
|
});
|
|
526
|
+
it('should return plain text responses without JSON parsing', async () => {
|
|
527
|
+
mockFetch.mockResolvedValueOnce(mockResponse('log line 1\nlog line 2', true, 200, 'text/plain; charset=utf-8'));
|
|
528
|
+
const result = await client.getApplicationLogs('app-uuid', 50);
|
|
529
|
+
expect(result).toBe('log line 1\nlog line 2');
|
|
530
|
+
});
|
|
531
|
+
it('should fall back to raw text when JSON responses are malformed', async () => {
|
|
532
|
+
mockFetch.mockResolvedValueOnce({
|
|
533
|
+
ok: true,
|
|
534
|
+
status: 200,
|
|
535
|
+
headers: new Headers({ 'Content-Type': 'application/json' }),
|
|
536
|
+
text: async () => 'not valid json',
|
|
537
|
+
});
|
|
538
|
+
const result = await client.getApplicationLogs('app-uuid', 50);
|
|
539
|
+
expect(result).toBe('not valid json');
|
|
540
|
+
});
|
|
488
541
|
it('should handle API errors without message', async () => {
|
|
489
542
|
mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
|
|
490
543
|
await expect(client.listServers()).rejects.toThrow('HTTP 500: Error');
|
|
@@ -1118,21 +1171,72 @@ describe('CoolifyClient', () => {
|
|
|
1118
1171
|
uuid: 'env-var-uuid',
|
|
1119
1172
|
key: 'API_KEY',
|
|
1120
1173
|
value: 'secret123',
|
|
1121
|
-
|
|
1174
|
+
is_buildtime: false,
|
|
1175
|
+
is_runtime: true,
|
|
1122
1176
|
};
|
|
1123
|
-
it('should list application env vars', async () => {
|
|
1177
|
+
it('should list application env vars with values masked by default (#159)', async () => {
|
|
1124
1178
|
mockFetch.mockResolvedValueOnce(mockResponse([mockEnvVar]));
|
|
1125
1179
|
const result = await client.listApplicationEnvVars('app-uuid');
|
|
1126
|
-
|
|
1180
|
+
// value masked, metadata preserved
|
|
1181
|
+
expect(result).toEqual([
|
|
1182
|
+
{
|
|
1183
|
+
uuid: 'env-var-uuid',
|
|
1184
|
+
key: 'API_KEY',
|
|
1185
|
+
value: '***',
|
|
1186
|
+
is_buildtime: false,
|
|
1187
|
+
is_runtime: true,
|
|
1188
|
+
},
|
|
1189
|
+
]);
|
|
1127
1190
|
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-uuid/envs', expect.any(Object));
|
|
1128
1191
|
});
|
|
1129
|
-
it('should list application env vars with
|
|
1192
|
+
it('should list application env vars with real_value also masked on the full projection (#159)', async () => {
|
|
1193
|
+
const fullEnvVar = {
|
|
1194
|
+
id: 1,
|
|
1195
|
+
uuid: 'env-var-uuid',
|
|
1196
|
+
key: 'API_KEY',
|
|
1197
|
+
value: 'secret123',
|
|
1198
|
+
real_value: 'secret123',
|
|
1199
|
+
is_buildtime: false,
|
|
1200
|
+
is_runtime: true,
|
|
1201
|
+
is_literal: true,
|
|
1202
|
+
is_multiline: false,
|
|
1203
|
+
is_preview: false,
|
|
1204
|
+
is_shared: false,
|
|
1205
|
+
is_shown_once: false,
|
|
1206
|
+
application_id: 1,
|
|
1207
|
+
created_at: '2024-01-01',
|
|
1208
|
+
updated_at: '2024-01-01',
|
|
1209
|
+
};
|
|
1210
|
+
mockFetch.mockResolvedValueOnce(mockResponse([fullEnvVar]));
|
|
1211
|
+
const result = (await client.listApplicationEnvVars('app-uuid'));
|
|
1212
|
+
expect(result[0].value).toBe('***');
|
|
1213
|
+
expect(result[0].real_value).toBe('***');
|
|
1214
|
+
// Metadata stays intact
|
|
1215
|
+
expect(result[0]).toMatchObject({
|
|
1216
|
+
uuid: 'env-var-uuid',
|
|
1217
|
+
key: 'API_KEY',
|
|
1218
|
+
is_buildtime: false,
|
|
1219
|
+
is_runtime: true,
|
|
1220
|
+
is_literal: true,
|
|
1221
|
+
is_preview: false,
|
|
1222
|
+
application_id: 1,
|
|
1223
|
+
created_at: '2024-01-01',
|
|
1224
|
+
updated_at: '2024-01-01',
|
|
1225
|
+
});
|
|
1226
|
+
});
|
|
1227
|
+
it('should list application env vars with real values when reveal=true (#159)', async () => {
|
|
1228
|
+
mockFetch.mockResolvedValueOnce(mockResponse([mockEnvVar]));
|
|
1229
|
+
const result = await client.listApplicationEnvVars('app-uuid', { reveal: true });
|
|
1230
|
+
expect(result).toEqual([mockEnvVar]);
|
|
1231
|
+
});
|
|
1232
|
+
it('should list application env vars with summary, masked by default (#159)', async () => {
|
|
1130
1233
|
const fullEnvVar = {
|
|
1131
1234
|
id: 1,
|
|
1132
1235
|
uuid: 'env-var-uuid',
|
|
1133
1236
|
key: 'API_KEY',
|
|
1134
1237
|
value: 'secret123',
|
|
1135
|
-
|
|
1238
|
+
is_buildtime: false,
|
|
1239
|
+
is_runtime: true,
|
|
1136
1240
|
is_literal: true,
|
|
1137
1241
|
is_multiline: false,
|
|
1138
1242
|
is_preview: false,
|
|
@@ -1144,13 +1248,46 @@ describe('CoolifyClient', () => {
|
|
|
1144
1248
|
};
|
|
1145
1249
|
mockFetch.mockResolvedValueOnce(mockResponse([fullEnvVar]));
|
|
1146
1250
|
const result = await client.listApplicationEnvVars('app-uuid', { summary: true });
|
|
1147
|
-
// Summary should only include uuid, key, value,
|
|
1251
|
+
// Summary should only include uuid, key, value, is_buildtime, is_runtime — and value masked
|
|
1252
|
+
expect(result).toEqual([
|
|
1253
|
+
{
|
|
1254
|
+
uuid: 'env-var-uuid',
|
|
1255
|
+
key: 'API_KEY',
|
|
1256
|
+
value: '***',
|
|
1257
|
+
is_buildtime: false,
|
|
1258
|
+
is_runtime: true,
|
|
1259
|
+
},
|
|
1260
|
+
]);
|
|
1261
|
+
});
|
|
1262
|
+
it('should list application env vars with summary and reveal=true returning real values (#159)', async () => {
|
|
1263
|
+
const fullEnvVar = {
|
|
1264
|
+
id: 1,
|
|
1265
|
+
uuid: 'env-var-uuid',
|
|
1266
|
+
key: 'API_KEY',
|
|
1267
|
+
value: 'secret123',
|
|
1268
|
+
is_buildtime: false,
|
|
1269
|
+
is_runtime: true,
|
|
1270
|
+
is_literal: true,
|
|
1271
|
+
is_multiline: false,
|
|
1272
|
+
is_preview: false,
|
|
1273
|
+
is_shared: false,
|
|
1274
|
+
is_shown_once: false,
|
|
1275
|
+
application_id: 1,
|
|
1276
|
+
created_at: '2024-01-01',
|
|
1277
|
+
updated_at: '2024-01-01',
|
|
1278
|
+
};
|
|
1279
|
+
mockFetch.mockResolvedValueOnce(mockResponse([fullEnvVar]));
|
|
1280
|
+
const result = await client.listApplicationEnvVars('app-uuid', {
|
|
1281
|
+
summary: true,
|
|
1282
|
+
reveal: true,
|
|
1283
|
+
});
|
|
1148
1284
|
expect(result).toEqual([
|
|
1149
1285
|
{
|
|
1150
1286
|
uuid: 'env-var-uuid',
|
|
1151
1287
|
key: 'API_KEY',
|
|
1152
1288
|
value: 'secret123',
|
|
1153
|
-
|
|
1289
|
+
is_buildtime: false,
|
|
1290
|
+
is_runtime: true,
|
|
1154
1291
|
},
|
|
1155
1292
|
]);
|
|
1156
1293
|
});
|
|
@@ -1159,10 +1296,31 @@ describe('CoolifyClient', () => {
|
|
|
1159
1296
|
const result = await client.createApplicationEnvVar('app-uuid', {
|
|
1160
1297
|
key: 'NEW_VAR',
|
|
1161
1298
|
value: 'new-value',
|
|
1162
|
-
|
|
1299
|
+
is_buildtime: true,
|
|
1163
1300
|
});
|
|
1164
1301
|
expect(result).toEqual({ uuid: 'new-env-uuid' });
|
|
1165
1302
|
});
|
|
1303
|
+
it('should create runtime-only env var (no Dockerfile ARG injection)', async () => {
|
|
1304
|
+
// Regression for #135: setting is_buildtime=false avoids multiline values
|
|
1305
|
+
// (PEM keys, etc.) being injected as Dockerfile ARG and breaking the build.
|
|
1306
|
+
mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-env-uuid' }));
|
|
1307
|
+
await client.createApplicationEnvVar('app-uuid', {
|
|
1308
|
+
key: 'PASSPORT_PRIVATE_KEY',
|
|
1309
|
+
value: '-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----',
|
|
1310
|
+
is_buildtime: false,
|
|
1311
|
+
is_runtime: true,
|
|
1312
|
+
});
|
|
1313
|
+
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-uuid/envs', expect.objectContaining({
|
|
1314
|
+
method: 'POST',
|
|
1315
|
+
body: expect.stringContaining('"is_buildtime":false'),
|
|
1316
|
+
}));
|
|
1317
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
1318
|
+
expect(body).toMatchObject({
|
|
1319
|
+
key: 'PASSPORT_PRIVATE_KEY',
|
|
1320
|
+
is_buildtime: false,
|
|
1321
|
+
is_runtime: true,
|
|
1322
|
+
});
|
|
1323
|
+
});
|
|
1166
1324
|
it('should update application env var', async () => {
|
|
1167
1325
|
mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Updated' }));
|
|
1168
1326
|
const result = await client.updateApplicationEnvVar('app-uuid', {
|
|
@@ -1171,6 +1329,22 @@ describe('CoolifyClient', () => {
|
|
|
1171
1329
|
});
|
|
1172
1330
|
expect(result).toEqual({ message: 'Updated' });
|
|
1173
1331
|
});
|
|
1332
|
+
it('should update env var to runtime-only (flip is_buildtime=false)', async () => {
|
|
1333
|
+
mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Updated' }));
|
|
1334
|
+
await client.updateApplicationEnvVar('app-uuid', {
|
|
1335
|
+
key: 'NODE_ENV',
|
|
1336
|
+
value: 'production',
|
|
1337
|
+
is_buildtime: false,
|
|
1338
|
+
is_runtime: true,
|
|
1339
|
+
});
|
|
1340
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
1341
|
+
expect(body).toEqual({
|
|
1342
|
+
key: 'NODE_ENV',
|
|
1343
|
+
value: 'production',
|
|
1344
|
+
is_buildtime: false,
|
|
1345
|
+
is_runtime: true,
|
|
1346
|
+
});
|
|
1347
|
+
});
|
|
1174
1348
|
it('should bulk update application env vars', async () => {
|
|
1175
1349
|
mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Updated' }));
|
|
1176
1350
|
const result = await client.bulkUpdateApplicationEnvVars('app-uuid', {
|
|
@@ -1446,12 +1620,54 @@ describe('CoolifyClient', () => {
|
|
|
1446
1620
|
key: 'SVC_KEY',
|
|
1447
1621
|
value: 'svc-value',
|
|
1448
1622
|
};
|
|
1449
|
-
it('should list service env vars', async () => {
|
|
1623
|
+
it('should list service env vars with values masked by default (#159)', async () => {
|
|
1450
1624
|
mockFetch.mockResolvedValueOnce(mockResponse([mockEnvVar]));
|
|
1451
1625
|
const result = await client.listServiceEnvVars('test-uuid');
|
|
1452
|
-
|
|
1626
|
+
// value masked, metadata (uuid, key) preserved
|
|
1627
|
+
expect(result).toEqual([
|
|
1628
|
+
{
|
|
1629
|
+
uuid: 'svc-env-uuid',
|
|
1630
|
+
key: 'SVC_KEY',
|
|
1631
|
+
value: '***',
|
|
1632
|
+
},
|
|
1633
|
+
]);
|
|
1453
1634
|
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/services/test-uuid/envs', expect.any(Object));
|
|
1454
1635
|
});
|
|
1636
|
+
it('should list service env vars with real_value masked on the full projection (#159)', async () => {
|
|
1637
|
+
const fullEnvVar = {
|
|
1638
|
+
id: 1,
|
|
1639
|
+
uuid: 'svc-env-uuid',
|
|
1640
|
+
key: 'SVC_KEY',
|
|
1641
|
+
value: 'svc-value',
|
|
1642
|
+
real_value: 'svc-value',
|
|
1643
|
+
is_buildtime: false,
|
|
1644
|
+
is_runtime: true,
|
|
1645
|
+
is_literal: true,
|
|
1646
|
+
is_multiline: false,
|
|
1647
|
+
is_preview: false,
|
|
1648
|
+
is_shared: false,
|
|
1649
|
+
is_shown_once: false,
|
|
1650
|
+
service_id: 42,
|
|
1651
|
+
created_at: '2024-01-01',
|
|
1652
|
+
updated_at: '2024-01-01',
|
|
1653
|
+
};
|
|
1654
|
+
mockFetch.mockResolvedValueOnce(mockResponse([fullEnvVar]));
|
|
1655
|
+
const result = await client.listServiceEnvVars('test-uuid');
|
|
1656
|
+
expect(result[0].value).toBe('***');
|
|
1657
|
+
expect(result[0].real_value).toBe('***');
|
|
1658
|
+
expect(result[0]).toMatchObject({
|
|
1659
|
+
uuid: 'svc-env-uuid',
|
|
1660
|
+
key: 'SVC_KEY',
|
|
1661
|
+
is_buildtime: false,
|
|
1662
|
+
is_runtime: true,
|
|
1663
|
+
service_id: 42,
|
|
1664
|
+
});
|
|
1665
|
+
});
|
|
1666
|
+
it('should list service env vars with real values when reveal=true (#159)', async () => {
|
|
1667
|
+
mockFetch.mockResolvedValueOnce(mockResponse([mockEnvVar]));
|
|
1668
|
+
const result = await client.listServiceEnvVars('test-uuid', { reveal: true });
|
|
1669
|
+
expect(result).toEqual([mockEnvVar]);
|
|
1670
|
+
});
|
|
1455
1671
|
it('should create service env var', async () => {
|
|
1456
1672
|
mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-env-uuid' }));
|
|
1457
1673
|
const result = await client.createServiceEnvVar('test-uuid', {
|
|
@@ -1972,9 +2188,17 @@ describe('CoolifyClient', () => {
|
|
|
1972
2188
|
uuid: 'env-1',
|
|
1973
2189
|
key: 'DATABASE_URL',
|
|
1974
2190
|
value: 'postgres://...',
|
|
1975
|
-
|
|
2191
|
+
is_buildtime: false,
|
|
2192
|
+
is_runtime: true,
|
|
2193
|
+
},
|
|
2194
|
+
{
|
|
2195
|
+
id: 2,
|
|
2196
|
+
uuid: 'env-2',
|
|
2197
|
+
key: 'NODE_ENV',
|
|
2198
|
+
value: 'production',
|
|
2199
|
+
is_buildtime: true,
|
|
2200
|
+
is_runtime: true,
|
|
1976
2201
|
},
|
|
1977
|
-
{ id: 2, uuid: 'env-2', key: 'NODE_ENV', value: 'production', is_build_time: true },
|
|
1978
2202
|
];
|
|
1979
2203
|
const mockDeployments = [
|
|
1980
2204
|
{
|
|
@@ -2021,12 +2245,28 @@ describe('CoolifyClient', () => {
|
|
|
2021
2245
|
expect(result.logs).toBe(mockLogs);
|
|
2022
2246
|
expect(result.environment_variables.count).toBe(2);
|
|
2023
2247
|
expect(result.environment_variables.variables).toEqual([
|
|
2024
|
-
{ key: 'DATABASE_URL',
|
|
2025
|
-
{ key: 'NODE_ENV',
|
|
2248
|
+
{ key: 'DATABASE_URL', is_buildtime: false, is_runtime: true },
|
|
2249
|
+
{ key: 'NODE_ENV', is_buildtime: true, is_runtime: true },
|
|
2026
2250
|
]);
|
|
2027
2251
|
expect(result.recent_deployments).toHaveLength(2);
|
|
2028
2252
|
expect(result.errors).toBeUndefined();
|
|
2029
2253
|
});
|
|
2254
|
+
it('should apply default flags when env var omits is_buildtime/is_runtime', async () => {
|
|
2255
|
+
// Hits the `?? false` / `?? true` fallback branches in the diagnose mapping
|
|
2256
|
+
// for legacy Coolify responses that don't carry both flags explicitly.
|
|
2257
|
+
const envVarsMissingFlags = [
|
|
2258
|
+
{ id: 1, uuid: 'env-1', key: 'LEGACY_VAR', value: 'x' },
|
|
2259
|
+
];
|
|
2260
|
+
mockFetch
|
|
2261
|
+
.mockResolvedValueOnce(mockResponse(mockApp))
|
|
2262
|
+
.mockResolvedValueOnce(mockResponse(mockLogs))
|
|
2263
|
+
.mockResolvedValueOnce(mockResponse(envVarsMissingFlags))
|
|
2264
|
+
.mockResolvedValueOnce(mockResponse({ count: 0, deployments: [] }));
|
|
2265
|
+
const result = await client.diagnoseApplication(testAppUuid);
|
|
2266
|
+
expect(result.environment_variables.variables).toEqual([
|
|
2267
|
+
{ key: 'LEGACY_VAR', is_buildtime: false, is_runtime: true },
|
|
2268
|
+
]);
|
|
2269
|
+
});
|
|
2030
2270
|
it('should detect unhealthy application status', async () => {
|
|
2031
2271
|
const unhealthyApp = { ...mockApp, status: 'exited:unhealthy' };
|
|
2032
2272
|
mockFetch
|
|
@@ -2507,15 +2747,40 @@ describe('CoolifyClient', () => {
|
|
|
2507
2747
|
// No API calls should be made
|
|
2508
2748
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
2509
2749
|
});
|
|
2510
|
-
it('should send
|
|
2750
|
+
it('should send buildtime flag when specified', async () => {
|
|
2511
2751
|
mockFetch
|
|
2512
2752
|
.mockResolvedValueOnce(mockResponse(mockApps))
|
|
2513
2753
|
.mockResolvedValueOnce(mockResponse({ message: 'Updated' }));
|
|
2514
2754
|
await client.bulkEnvUpdate(['app-1'], 'BUILD_VAR', 'value', true);
|
|
2515
|
-
// Verify the PATCH call was made with
|
|
2755
|
+
// Verify the PATCH call was made with is_buildtime (one word — Coolify API field name)
|
|
2756
|
+
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-1/envs', expect.objectContaining({
|
|
2757
|
+
method: 'PATCH',
|
|
2758
|
+
body: JSON.stringify({ key: 'BUILD_VAR', value: 'value', is_buildtime: true }),
|
|
2759
|
+
}));
|
|
2760
|
+
});
|
|
2761
|
+
it('should send both buildtime and runtime flags for runtime-only vars', async () => {
|
|
2762
|
+
mockFetch
|
|
2763
|
+
.mockResolvedValueOnce(mockResponse(mockApps))
|
|
2764
|
+
.mockResolvedValueOnce(mockResponse({ message: 'Updated' }));
|
|
2765
|
+
await client.bulkEnvUpdate(['app-1'], 'PEM_KEY', 'multiline-value', false, true);
|
|
2766
|
+
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-1/envs', expect.objectContaining({
|
|
2767
|
+
method: 'PATCH',
|
|
2768
|
+
body: JSON.stringify({
|
|
2769
|
+
key: 'PEM_KEY',
|
|
2770
|
+
value: 'multiline-value',
|
|
2771
|
+
is_buildtime: false,
|
|
2772
|
+
is_runtime: true,
|
|
2773
|
+
}),
|
|
2774
|
+
}));
|
|
2775
|
+
});
|
|
2776
|
+
it('should send only is_runtime when buildtime is left undefined', async () => {
|
|
2777
|
+
mockFetch
|
|
2778
|
+
.mockResolvedValueOnce(mockResponse(mockApps))
|
|
2779
|
+
.mockResolvedValueOnce(mockResponse({ message: 'Updated' }));
|
|
2780
|
+
await client.bulkEnvUpdate(['app-1'], 'API_KEY', 'val', undefined, false);
|
|
2516
2781
|
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-1/envs', expect.objectContaining({
|
|
2517
2782
|
method: 'PATCH',
|
|
2518
|
-
body: JSON.stringify({ key: '
|
|
2783
|
+
body: JSON.stringify({ key: 'API_KEY', value: 'val', is_runtime: false }),
|
|
2519
2784
|
}));
|
|
2520
2785
|
});
|
|
2521
2786
|
});
|
|
@@ -54,11 +54,12 @@ describeFn('Diagnostic Integration Tests', () => {
|
|
|
54
54
|
expect(result.environment_variables).toBeDefined();
|
|
55
55
|
expect(typeof result.environment_variables.count).toBe('number');
|
|
56
56
|
expect(Array.isArray(result.environment_variables.variables)).toBe(true);
|
|
57
|
-
// Values should be hidden (only key
|
|
57
|
+
// Values should be hidden (only key, is_buildtime, is_runtime exposed)
|
|
58
58
|
if (result.environment_variables.variables.length > 0) {
|
|
59
59
|
const firstVar = result.environment_variables.variables[0];
|
|
60
60
|
expect(firstVar).toHaveProperty('key');
|
|
61
|
-
expect(firstVar).toHaveProperty('
|
|
61
|
+
expect(firstVar).toHaveProperty('is_buildtime');
|
|
62
|
+
expect(firstVar).toHaveProperty('is_runtime');
|
|
62
63
|
expect(firstVar).not.toHaveProperty('value');
|
|
63
64
|
}
|
|
64
65
|
// Should have recent deployments array
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* These tests verify MCP server instantiation and structure.
|
|
7
7
|
*/
|
|
8
8
|
import { createRequire } from 'module';
|
|
9
|
-
import { describe, it, expect, beforeEach } from '@jest/globals';
|
|
9
|
+
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
|
10
10
|
import { CoolifyMcpServer, VERSION, truncateLogs, getApplicationActions, getDeploymentActions, getPagination, } from '../lib/mcp-server.js';
|
|
11
11
|
describe('CoolifyMcpServer v2', () => {
|
|
12
12
|
let server;
|
|
@@ -161,6 +161,108 @@ describe('CoolifyMcpServer v2', () => {
|
|
|
161
161
|
expect(client['accessToken']).toBe('test-token');
|
|
162
162
|
});
|
|
163
163
|
});
|
|
164
|
+
describe('env_vars tool handler', () => {
|
|
165
|
+
// Reach the SDK-registered handler so the is_buildtime / is_runtime
|
|
166
|
+
// passthrough lines are actually executed (not just type-checked).
|
|
167
|
+
const callEnvVars = async (srv, args) => {
|
|
168
|
+
const tool = srv._registeredTools['env_vars'];
|
|
169
|
+
return tool.handler(args, {});
|
|
170
|
+
};
|
|
171
|
+
it('forwards is_buildtime/is_runtime to createApplicationEnvVar', async () => {
|
|
172
|
+
const spy = jest
|
|
173
|
+
.spyOn(server['client'], 'createApplicationEnvVar')
|
|
174
|
+
.mockResolvedValue({ uuid: 'env-1' });
|
|
175
|
+
await callEnvVars(server, {
|
|
176
|
+
resource: 'application',
|
|
177
|
+
action: 'create',
|
|
178
|
+
uuid: 'app-uuid',
|
|
179
|
+
key: 'PEM_KEY',
|
|
180
|
+
value: '-----BEGIN-----',
|
|
181
|
+
is_buildtime: false,
|
|
182
|
+
is_runtime: true,
|
|
183
|
+
});
|
|
184
|
+
expect(spy).toHaveBeenCalledWith('app-uuid', {
|
|
185
|
+
key: 'PEM_KEY',
|
|
186
|
+
value: '-----BEGIN-----',
|
|
187
|
+
is_buildtime: false,
|
|
188
|
+
is_runtime: true,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
it('forwards is_buildtime/is_runtime to updateApplicationEnvVar', async () => {
|
|
192
|
+
const spy = jest
|
|
193
|
+
.spyOn(server['client'], 'updateApplicationEnvVar')
|
|
194
|
+
.mockResolvedValue({ message: 'Updated' });
|
|
195
|
+
await callEnvVars(server, {
|
|
196
|
+
resource: 'application',
|
|
197
|
+
action: 'update',
|
|
198
|
+
uuid: 'app-uuid',
|
|
199
|
+
key: 'NODE_ENV',
|
|
200
|
+
value: 'production',
|
|
201
|
+
is_buildtime: false,
|
|
202
|
+
is_runtime: true,
|
|
203
|
+
});
|
|
204
|
+
expect(spy).toHaveBeenCalledWith('app-uuid', {
|
|
205
|
+
key: 'NODE_ENV',
|
|
206
|
+
value: 'production',
|
|
207
|
+
is_buildtime: false,
|
|
208
|
+
is_runtime: true,
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
it('forwards is_buildtime/is_runtime to createServiceEnvVar', async () => {
|
|
212
|
+
const spy = jest
|
|
213
|
+
.spyOn(server['client'], 'createServiceEnvVar')
|
|
214
|
+
.mockResolvedValue({ uuid: 'env-1' });
|
|
215
|
+
await callEnvVars(server, {
|
|
216
|
+
resource: 'service',
|
|
217
|
+
action: 'create',
|
|
218
|
+
uuid: 'svc-uuid',
|
|
219
|
+
key: 'API_KEY',
|
|
220
|
+
value: 'secret',
|
|
221
|
+
is_buildtime: true,
|
|
222
|
+
is_runtime: undefined,
|
|
223
|
+
});
|
|
224
|
+
expect(spy).toHaveBeenCalledWith('svc-uuid', {
|
|
225
|
+
key: 'API_KEY',
|
|
226
|
+
value: 'secret',
|
|
227
|
+
is_buildtime: true,
|
|
228
|
+
is_runtime: undefined,
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
it('returns key/value error when create is missing required fields', async () => {
|
|
232
|
+
const result = (await callEnvVars(server, {
|
|
233
|
+
resource: 'application',
|
|
234
|
+
action: 'create',
|
|
235
|
+
uuid: 'app-uuid',
|
|
236
|
+
}));
|
|
237
|
+
expect(result.content[0].text).toContain('key, value required');
|
|
238
|
+
});
|
|
239
|
+
it('returns key/value error when service create is missing required fields', async () => {
|
|
240
|
+
const result = (await callEnvVars(server, {
|
|
241
|
+
resource: 'service',
|
|
242
|
+
action: 'create',
|
|
243
|
+
uuid: 'svc-uuid',
|
|
244
|
+
}));
|
|
245
|
+
expect(result.content[0].text).toContain('key, value required');
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
describe('bulk_env_update tool handler', () => {
|
|
249
|
+
it('forwards is_buildtime/is_runtime to bulkEnvUpdate', async () => {
|
|
250
|
+
const spy = jest.spyOn(server['client'], 'bulkEnvUpdate').mockResolvedValue({
|
|
251
|
+
summary: { total: 2, succeeded: 2, failed: 0 },
|
|
252
|
+
succeeded: [],
|
|
253
|
+
failed: [],
|
|
254
|
+
});
|
|
255
|
+
const tool = server._registeredTools['bulk_env_update'];
|
|
256
|
+
await tool.handler({
|
|
257
|
+
app_uuids: ['app-1', 'app-2'],
|
|
258
|
+
key: 'PEM_KEY',
|
|
259
|
+
value: 'multiline',
|
|
260
|
+
is_buildtime: false,
|
|
261
|
+
is_runtime: true,
|
|
262
|
+
}, {});
|
|
263
|
+
expect(spy).toHaveBeenCalledWith(['app-1', 'app-2'], 'PEM_KEY', 'multiline', false, true);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
164
266
|
});
|
|
165
267
|
describe('truncateLogs', () => {
|
|
166
268
|
// Plain text log tests
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import { parseHeaders } from '../lib/parse-headers.js';
|
|
3
|
+
describe('parseHeaders', () => {
|
|
4
|
+
it('should parse a single header', () => {
|
|
5
|
+
const result = parseHeaders(['--header', 'X-Custom: value']);
|
|
6
|
+
expect(result).toEqual({ 'X-Custom': 'value' });
|
|
7
|
+
});
|
|
8
|
+
it('should parse multiple headers', () => {
|
|
9
|
+
const result = parseHeaders(['--header', 'X-First: one', '--header', 'X-Second: two']);
|
|
10
|
+
expect(result).toEqual({ 'X-First': 'one', 'X-Second': 'two' });
|
|
11
|
+
});
|
|
12
|
+
it('should ignore malformed headers without a colon', () => {
|
|
13
|
+
const result = parseHeaders(['--header', 'no-colon-here']);
|
|
14
|
+
expect(result).toEqual({});
|
|
15
|
+
});
|
|
16
|
+
it('should trim whitespace from key and value', () => {
|
|
17
|
+
const result = parseHeaders(['--header', ' X-Spaced : some value ']);
|
|
18
|
+
expect(result).toEqual({ 'X-Spaced': 'some value' });
|
|
19
|
+
});
|
|
20
|
+
it('should handle header value containing colons', () => {
|
|
21
|
+
const result = parseHeaders(['--header', 'Authorization: Bearer abc:def:ghi']);
|
|
22
|
+
expect(result).toEqual({ Authorization: 'Bearer abc:def:ghi' });
|
|
23
|
+
});
|
|
24
|
+
it('should return empty object when no headers provided', () => {
|
|
25
|
+
expect(parseHeaders([])).toEqual({});
|
|
26
|
+
expect(parseHeaders(['--other', 'flag'])).toEqual({});
|
|
27
|
+
});
|
|
28
|
+
it('should ignore --header without a following value', () => {
|
|
29
|
+
const result = parseHeaders(['--header']);
|
|
30
|
+
expect(result).toEqual({});
|
|
31
|
+
});
|
|
32
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { CoolifyMcpServer } from './lib/mcp-server.js';
|
|
4
|
+
import { parseHeaders } from './lib/parse-headers.js';
|
|
4
5
|
async function main() {
|
|
6
|
+
const customHeaders = parseHeaders(process.argv);
|
|
5
7
|
const config = {
|
|
6
8
|
baseUrl: process.env.COOLIFY_BASE_URL || 'http://localhost:3000',
|
|
7
9
|
accessToken: process.env.COOLIFY_ACCESS_TOKEN || '',
|
|
10
|
+
customHeaders: Object.keys(customHeaders).length > 0 ? customHeaders : undefined,
|
|
8
11
|
};
|
|
9
12
|
if (!config.accessToken) {
|
|
10
13
|
throw new Error('COOLIFY_ACCESS_TOKEN environment variable is required');
|
|
@@ -72,6 +72,7 @@ export interface GitHubAppSummary {
|
|
|
72
72
|
export declare class CoolifyClient {
|
|
73
73
|
private readonly baseUrl;
|
|
74
74
|
private readonly accessToken;
|
|
75
|
+
private readonly customHeaders;
|
|
75
76
|
private cachedVersion;
|
|
76
77
|
constructor(config: CoolifyConfig);
|
|
77
78
|
private request;
|
|
@@ -124,8 +125,16 @@ export declare class CoolifyClient {
|
|
|
124
125
|
}): Promise<ApplicationActionResponse>;
|
|
125
126
|
stopApplication(uuid: string): Promise<ApplicationActionResponse>;
|
|
126
127
|
restartApplication(uuid: string): Promise<ApplicationActionResponse>;
|
|
128
|
+
/**
|
|
129
|
+
* List env vars for an application.
|
|
130
|
+
*
|
|
131
|
+
* Default behaviour masks `value` (and `real_value` on the full projection)
|
|
132
|
+
* with a sentinel string so secrets are not leaked to MCP clients. Pass
|
|
133
|
+
* `reveal: true` when the caller explicitly needs the plaintext value.
|
|
134
|
+
*/
|
|
127
135
|
listApplicationEnvVars(uuid: string, options?: {
|
|
128
136
|
summary?: boolean;
|
|
137
|
+
reveal?: boolean;
|
|
129
138
|
}): Promise<EnvironmentVariable[] | EnvVarSummary[]>;
|
|
130
139
|
createApplicationEnvVar(uuid: string, data: CreateEnvVarRequest): Promise<UuidResponse>;
|
|
131
140
|
updateApplicationEnvVar(uuid: string, data: UpdateEnvVarRequest): Promise<MessageResponse>;
|
|
@@ -154,7 +163,16 @@ export declare class CoolifyClient {
|
|
|
154
163
|
startService(uuid: string): Promise<MessageResponse>;
|
|
155
164
|
stopService(uuid: string): Promise<MessageResponse>;
|
|
156
165
|
restartService(uuid: string): Promise<MessageResponse>;
|
|
157
|
-
|
|
166
|
+
/**
|
|
167
|
+
* List env vars for a service.
|
|
168
|
+
*
|
|
169
|
+
* Default behaviour masks `value` (and `real_value`) with a sentinel string
|
|
170
|
+
* so secrets are not leaked to MCP clients. Pass `reveal: true` when the
|
|
171
|
+
* caller explicitly needs the plaintext value.
|
|
172
|
+
*/
|
|
173
|
+
listServiceEnvVars(uuid: string, options?: {
|
|
174
|
+
reveal?: boolean;
|
|
175
|
+
}): Promise<EnvironmentVariable[]>;
|
|
158
176
|
createServiceEnvVar(uuid: string, data: CreateEnvVarRequest): Promise<UuidResponse>;
|
|
159
177
|
updateServiceEnvVar(uuid: string, data: UpdateEnvVarRequest): Promise<MessageResponse>;
|
|
160
178
|
deleteServiceEnvVar(uuid: string, envUuid: string): Promise<MessageResponse>;
|
|
@@ -256,9 +274,10 @@ export declare class CoolifyClient {
|
|
|
256
274
|
* @param appUuids - Array of application UUIDs
|
|
257
275
|
* @param key - Environment variable key
|
|
258
276
|
* @param value - Environment variable value
|
|
259
|
-
* @param
|
|
277
|
+
* @param isBuildtime - Sets the build-time flag on the variable when provided
|
|
278
|
+
* @param isRuntime - Sets the runtime flag on the variable when provided
|
|
260
279
|
*/
|
|
261
|
-
bulkEnvUpdate(appUuids: string[], key: string, value: string,
|
|
280
|
+
bulkEnvUpdate(appUuids: string[], key: string, value: string, isBuildtime?: boolean, isRuntime?: boolean): Promise<BatchOperationResult>;
|
|
262
281
|
/**
|
|
263
282
|
* Emergency stop all running applications across entire infrastructure.
|
|
264
283
|
*/
|
|
@@ -142,7 +142,42 @@ function toEnvVarSummary(envVar) {
|
|
|
142
142
|
uuid: envVar.uuid,
|
|
143
143
|
key: envVar.key,
|
|
144
144
|
value: envVar.value,
|
|
145
|
-
|
|
145
|
+
is_buildtime: envVar.is_buildtime,
|
|
146
|
+
is_runtime: envVar.is_runtime,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Sentinel string used to replace plaintext env var values when masking.
|
|
151
|
+
* Exported via behaviour, not as a public API — clients should treat any
|
|
152
|
+
* non-real string as "value not returned".
|
|
153
|
+
*/
|
|
154
|
+
const MASKED_VALUE = '***';
|
|
155
|
+
/**
|
|
156
|
+
* Mask the `value` and `real_value` fields on a full {@link EnvironmentVariable}.
|
|
157
|
+
* All other metadata (uuid, key, flags, timestamps, ids) is preserved verbatim.
|
|
158
|
+
*
|
|
159
|
+
* Applied at the API boundary so callers cannot accidentally leak secrets to
|
|
160
|
+
* an LLM client by forgetting to strip values downstream. Pair with the
|
|
161
|
+
* `reveal: true` opt-in on list methods when the caller genuinely needs the
|
|
162
|
+
* plaintext (e.g. "what is FOO set to right now?").
|
|
163
|
+
*/
|
|
164
|
+
function maskEnvVar(envVar) {
|
|
165
|
+
const masked = {
|
|
166
|
+
...envVar,
|
|
167
|
+
value: MASKED_VALUE,
|
|
168
|
+
};
|
|
169
|
+
if (envVar.real_value !== undefined) {
|
|
170
|
+
masked.real_value = MASKED_VALUE;
|
|
171
|
+
}
|
|
172
|
+
return masked;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Mask the `value` field on an {@link EnvVarSummary}. Metadata is preserved.
|
|
176
|
+
*/
|
|
177
|
+
function maskEnvVarSummary(envVar) {
|
|
178
|
+
return {
|
|
179
|
+
...envVar,
|
|
180
|
+
value: MASKED_VALUE,
|
|
146
181
|
};
|
|
147
182
|
}
|
|
148
183
|
/**
|
|
@@ -151,6 +186,7 @@ function toEnvVarSummary(envVar) {
|
|
|
151
186
|
export class CoolifyClient {
|
|
152
187
|
baseUrl;
|
|
153
188
|
accessToken;
|
|
189
|
+
customHeaders;
|
|
154
190
|
cachedVersion = null;
|
|
155
191
|
constructor(config) {
|
|
156
192
|
if (!config.baseUrl) {
|
|
@@ -161,6 +197,18 @@ export class CoolifyClient {
|
|
|
161
197
|
}
|
|
162
198
|
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
|
163
199
|
this.accessToken = config.accessToken;
|
|
200
|
+
const reserved = new Set(['authorization', 'content-type']);
|
|
201
|
+
const raw = config.customHeaders ?? {};
|
|
202
|
+
const filtered = {};
|
|
203
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
204
|
+
if (reserved.has(key.toLowerCase())) {
|
|
205
|
+
console.warn(`Custom header "${key}" ignored: reserved by the Coolify client`);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
filtered[key] = value;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
this.customHeaders = filtered;
|
|
164
212
|
}
|
|
165
213
|
// ===========================================================================
|
|
166
214
|
// Private HTTP methods
|
|
@@ -173,12 +221,28 @@ export class CoolifyClient {
|
|
|
173
221
|
headers: {
|
|
174
222
|
'Content-Type': 'application/json',
|
|
175
223
|
Authorization: `Bearer ${this.accessToken}`,
|
|
224
|
+
...this.customHeaders,
|
|
176
225
|
...options.headers,
|
|
177
226
|
},
|
|
178
227
|
});
|
|
179
228
|
// Handle empty responses (204 No Content, etc.)
|
|
180
229
|
const text = await response.text();
|
|
181
|
-
const
|
|
230
|
+
const contentType = response.headers?.get('Content-Type')?.toLowerCase() ?? '';
|
|
231
|
+
const isJsonResponse = !contentType || contentType.includes('application/json') || contentType.includes('+json');
|
|
232
|
+
let data = {};
|
|
233
|
+
if (text) {
|
|
234
|
+
if (isJsonResponse) {
|
|
235
|
+
try {
|
|
236
|
+
data = JSON.parse(text);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
data = text;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
data = text;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
182
246
|
if (!response.ok) {
|
|
183
247
|
const error = data;
|
|
184
248
|
// Include validation errors if present
|
|
@@ -222,6 +286,7 @@ export class CoolifyClient {
|
|
|
222
286
|
const response = await fetch(url, {
|
|
223
287
|
headers: {
|
|
224
288
|
Authorization: `Bearer ${this.accessToken}`,
|
|
289
|
+
...this.customHeaders,
|
|
225
290
|
},
|
|
226
291
|
});
|
|
227
292
|
if (!response.ok) {
|
|
@@ -461,9 +526,21 @@ export class CoolifyClient {
|
|
|
461
526
|
// ===========================================================================
|
|
462
527
|
// Application Environment Variables
|
|
463
528
|
// ===========================================================================
|
|
529
|
+
/**
|
|
530
|
+
* List env vars for an application.
|
|
531
|
+
*
|
|
532
|
+
* Default behaviour masks `value` (and `real_value` on the full projection)
|
|
533
|
+
* with a sentinel string so secrets are not leaked to MCP clients. Pass
|
|
534
|
+
* `reveal: true` when the caller explicitly needs the plaintext value.
|
|
535
|
+
*/
|
|
464
536
|
async listApplicationEnvVars(uuid, options) {
|
|
465
537
|
const envVars = await this.request(`/applications/${uuid}/envs`);
|
|
466
|
-
|
|
538
|
+
const reveal = options?.reveal === true;
|
|
539
|
+
if (options?.summary) {
|
|
540
|
+
const summaries = envVars.map(toEnvVarSummary);
|
|
541
|
+
return reveal ? summaries : summaries.map(maskEnvVarSummary);
|
|
542
|
+
}
|
|
543
|
+
return reveal ? envVars : envVars.map(maskEnvVar);
|
|
467
544
|
}
|
|
468
545
|
async createApplicationEnvVar(uuid, data) {
|
|
469
546
|
return this.request(`/applications/${uuid}/envs`, {
|
|
@@ -646,8 +723,16 @@ export class CoolifyClient {
|
|
|
646
723
|
// ===========================================================================
|
|
647
724
|
// Service Environment Variables
|
|
648
725
|
// ===========================================================================
|
|
649
|
-
|
|
650
|
-
|
|
726
|
+
/**
|
|
727
|
+
* List env vars for a service.
|
|
728
|
+
*
|
|
729
|
+
* Default behaviour masks `value` (and `real_value`) with a sentinel string
|
|
730
|
+
* so secrets are not leaked to MCP clients. Pass `reveal: true` when the
|
|
731
|
+
* caller explicitly needs the plaintext value.
|
|
732
|
+
*/
|
|
733
|
+
async listServiceEnvVars(uuid, options) {
|
|
734
|
+
const envVars = await this.request(`/services/${uuid}/envs`);
|
|
735
|
+
return options?.reveal === true ? envVars : envVars.map(maskEnvVar);
|
|
651
736
|
}
|
|
652
737
|
async createServiceEnvVar(uuid, data) {
|
|
653
738
|
return this.request(`/services/${uuid}/envs`, {
|
|
@@ -1011,7 +1096,8 @@ export class CoolifyClient {
|
|
|
1011
1096
|
count: envVars?.length || 0,
|
|
1012
1097
|
variables: (envVars || []).map((v) => ({
|
|
1013
1098
|
key: v.key,
|
|
1014
|
-
|
|
1099
|
+
is_buildtime: v.is_buildtime ?? false,
|
|
1100
|
+
is_runtime: v.is_runtime ?? true,
|
|
1015
1101
|
})),
|
|
1016
1102
|
},
|
|
1017
1103
|
recent_deployments: (deployments || []).slice(0, 5).map((d) => ({
|
|
@@ -1277,9 +1363,10 @@ export class CoolifyClient {
|
|
|
1277
1363
|
* @param appUuids - Array of application UUIDs
|
|
1278
1364
|
* @param key - Environment variable key
|
|
1279
1365
|
* @param value - Environment variable value
|
|
1280
|
-
* @param
|
|
1366
|
+
* @param isBuildtime - Sets the build-time flag on the variable when provided
|
|
1367
|
+
* @param isRuntime - Sets the runtime flag on the variable when provided
|
|
1281
1368
|
*/
|
|
1282
|
-
async bulkEnvUpdate(appUuids, key, value,
|
|
1369
|
+
async bulkEnvUpdate(appUuids, key, value, isBuildtime, isRuntime) {
|
|
1283
1370
|
// Early return for empty array - avoid unnecessary API call
|
|
1284
1371
|
if (appUuids.length === 0) {
|
|
1285
1372
|
return {
|
|
@@ -1296,7 +1383,12 @@ export class CoolifyClient {
|
|
|
1296
1383
|
uuid,
|
|
1297
1384
|
name: appMap.get(uuid) || uuid,
|
|
1298
1385
|
}));
|
|
1299
|
-
const results = await Promise.allSettled(appUuids.map((uuid) => this.updateApplicationEnvVar(uuid, {
|
|
1386
|
+
const results = await Promise.allSettled(appUuids.map((uuid) => this.updateApplicationEnvVar(uuid, {
|
|
1387
|
+
key,
|
|
1388
|
+
value,
|
|
1389
|
+
is_buildtime: isBuildtime,
|
|
1390
|
+
is_runtime: isRuntime,
|
|
1391
|
+
})));
|
|
1300
1392
|
return this.aggregateBatchResults(resources, results);
|
|
1301
1393
|
}
|
|
1302
1394
|
/**
|
package/dist/lib/mcp-server.js
CHANGED
|
@@ -682,27 +682,39 @@ export class CoolifyMcpServer extends McpServer {
|
|
|
682
682
|
// =========================================================================
|
|
683
683
|
// Environment Variables (1 tool - consolidated)
|
|
684
684
|
// =========================================================================
|
|
685
|
-
this.tool('env_vars',
|
|
685
|
+
this.tool('env_vars', "Manage env vars for app or service. Values are masked by default (returned as '***') to avoid leaking secrets to MCP clients; pass reveal=true on the list action when the caller explicitly needs the plaintext (e.g. 'what is FOO set to?'). Set is_buildtime=false (and/or is_runtime=true) for runtime-only vars to avoid Dockerfile ARG issues with multiline values like PEM keys.", {
|
|
686
686
|
resource: z.enum(['application', 'service']),
|
|
687
687
|
action: z.enum(['list', 'create', 'update', 'delete']),
|
|
688
688
|
uuid: z.string(),
|
|
689
689
|
key: z.string().optional(),
|
|
690
690
|
value: z.string().optional(),
|
|
691
691
|
env_uuid: z.string().optional(),
|
|
692
|
-
|
|
692
|
+
is_buildtime: z.boolean().optional(),
|
|
693
|
+
is_runtime: z.boolean().optional(),
|
|
694
|
+
reveal: z.boolean().optional(),
|
|
695
|
+
}, async ({ resource, action, uuid, key, value, env_uuid, is_buildtime, is_runtime, reveal, }) => {
|
|
693
696
|
if (resource === 'application') {
|
|
694
697
|
switch (action) {
|
|
695
698
|
case 'list':
|
|
696
|
-
return wrap(() => this.client.listApplicationEnvVars(uuid, { summary: true }));
|
|
699
|
+
return wrap(() => this.client.listApplicationEnvVars(uuid, { summary: true, reveal }));
|
|
697
700
|
case 'create':
|
|
698
701
|
if (!key || !value)
|
|
699
702
|
return { content: [{ type: 'text', text: 'Error: key, value required' }] };
|
|
700
|
-
|
|
701
|
-
|
|
703
|
+
return wrap(() => this.client.createApplicationEnvVar(uuid, {
|
|
704
|
+
key,
|
|
705
|
+
value,
|
|
706
|
+
is_buildtime,
|
|
707
|
+
is_runtime,
|
|
708
|
+
}));
|
|
702
709
|
case 'update':
|
|
703
710
|
if (!key || !value)
|
|
704
711
|
return { content: [{ type: 'text', text: 'Error: key, value required' }] };
|
|
705
|
-
return wrap(() => this.client.updateApplicationEnvVar(uuid, {
|
|
712
|
+
return wrap(() => this.client.updateApplicationEnvVar(uuid, {
|
|
713
|
+
key,
|
|
714
|
+
value,
|
|
715
|
+
is_buildtime,
|
|
716
|
+
is_runtime,
|
|
717
|
+
}));
|
|
706
718
|
case 'delete':
|
|
707
719
|
if (!env_uuid)
|
|
708
720
|
return { content: [{ type: 'text', text: 'Error: env_uuid required' }] };
|
|
@@ -712,11 +724,16 @@ export class CoolifyMcpServer extends McpServer {
|
|
|
712
724
|
else {
|
|
713
725
|
switch (action) {
|
|
714
726
|
case 'list':
|
|
715
|
-
return wrap(() => this.client.listServiceEnvVars(uuid));
|
|
727
|
+
return wrap(() => this.client.listServiceEnvVars(uuid, { reveal }));
|
|
716
728
|
case 'create':
|
|
717
729
|
if (!key || !value)
|
|
718
730
|
return { content: [{ type: 'text', text: 'Error: key, value required' }] };
|
|
719
|
-
return wrap(() => this.client.createServiceEnvVar(uuid, {
|
|
731
|
+
return wrap(() => this.client.createServiceEnvVar(uuid, {
|
|
732
|
+
key,
|
|
733
|
+
value,
|
|
734
|
+
is_buildtime,
|
|
735
|
+
is_runtime,
|
|
736
|
+
}));
|
|
720
737
|
case 'update':
|
|
721
738
|
return {
|
|
722
739
|
content: [
|
|
@@ -1056,8 +1073,9 @@ export class CoolifyMcpServer extends McpServer {
|
|
|
1056
1073
|
app_uuids: z.array(z.string()),
|
|
1057
1074
|
key: z.string(),
|
|
1058
1075
|
value: z.string(),
|
|
1059
|
-
|
|
1060
|
-
|
|
1076
|
+
is_buildtime: z.boolean().optional(),
|
|
1077
|
+
is_runtime: z.boolean().optional(),
|
|
1078
|
+
}, async ({ app_uuids, key, value, is_buildtime, is_runtime }) => wrap(() => this.client.bulkEnvUpdate(app_uuids, key, value, is_buildtime, is_runtime)));
|
|
1061
1079
|
this.tool('stop_all_apps', 'EMERGENCY: Stop all running apps', { confirm: z.literal(true) }, async ({ confirm }) => {
|
|
1062
1080
|
if (!confirm)
|
|
1063
1081
|
return { content: [{ type: 'text', text: 'Error: confirm=true required' }] };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function parseHeaders(argv: string[]): Record<string, string>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function parseHeaders(argv) {
|
|
2
|
+
const headers = {};
|
|
3
|
+
for (let i = 0; i < argv.length; i++) {
|
|
4
|
+
if (argv[i] === '--header' && i + 1 < argv.length) {
|
|
5
|
+
const value = argv[i + 1];
|
|
6
|
+
const colonIndex = value.indexOf(':');
|
|
7
|
+
if (colonIndex > 0) {
|
|
8
|
+
headers[value.slice(0, colonIndex).trim()] = value.slice(colonIndex + 1).trim();
|
|
9
|
+
}
|
|
10
|
+
i++;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return headers;
|
|
14
|
+
}
|
package/dist/types/coolify.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
export interface CoolifyConfig {
|
|
6
6
|
baseUrl: string;
|
|
7
7
|
accessToken: string;
|
|
8
|
+
customHeaders?: Record<string, string>;
|
|
8
9
|
}
|
|
9
10
|
export interface ErrorResponse {
|
|
10
11
|
error?: string;
|
|
@@ -343,7 +344,8 @@ export interface EnvironmentVariable {
|
|
|
343
344
|
uuid: string;
|
|
344
345
|
key: string;
|
|
345
346
|
value: string;
|
|
346
|
-
|
|
347
|
+
is_buildtime: boolean;
|
|
348
|
+
is_runtime: boolean;
|
|
347
349
|
is_literal: boolean;
|
|
348
350
|
is_multiline: boolean;
|
|
349
351
|
is_preview: boolean;
|
|
@@ -364,7 +366,8 @@ export interface CreateEnvVarRequest {
|
|
|
364
366
|
is_literal?: boolean;
|
|
365
367
|
is_multiline?: boolean;
|
|
366
368
|
is_shown_once?: boolean;
|
|
367
|
-
|
|
369
|
+
is_buildtime?: boolean;
|
|
370
|
+
is_runtime?: boolean;
|
|
368
371
|
}
|
|
369
372
|
export interface UpdateEnvVarRequest {
|
|
370
373
|
key: string;
|
|
@@ -373,7 +376,8 @@ export interface UpdateEnvVarRequest {
|
|
|
373
376
|
is_literal?: boolean;
|
|
374
377
|
is_multiline?: boolean;
|
|
375
378
|
is_shown_once?: boolean;
|
|
376
|
-
|
|
379
|
+
is_buildtime?: boolean;
|
|
380
|
+
is_runtime?: boolean;
|
|
377
381
|
}
|
|
378
382
|
export interface BulkUpdateEnvVarsRequest {
|
|
379
383
|
data: CreateEnvVarRequest[];
|
|
@@ -382,7 +386,8 @@ export interface EnvVarSummary {
|
|
|
382
386
|
uuid: string;
|
|
383
387
|
key: string;
|
|
384
388
|
value: string;
|
|
385
|
-
|
|
389
|
+
is_buildtime: boolean;
|
|
390
|
+
is_runtime: boolean;
|
|
386
391
|
}
|
|
387
392
|
export type DatabaseType = 'postgresql' | 'mysql' | 'mariadb' | 'mongodb' | 'redis' | 'keydb' | 'clickhouse' | 'dragonfly';
|
|
388
393
|
export interface DatabaseLimits {
|
|
@@ -843,7 +848,8 @@ export interface ApplicationDiagnostic {
|
|
|
843
848
|
count: number;
|
|
844
849
|
variables: Array<{
|
|
845
850
|
key: string;
|
|
846
|
-
|
|
851
|
+
is_buildtime: boolean;
|
|
852
|
+
is_runtime: boolean;
|
|
847
853
|
}>;
|
|
848
854
|
};
|
|
849
855
|
recent_deployments: Array<{
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@masonator/coolify-mcp",
|
|
3
3
|
"scope": "@masonator",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.9.0",
|
|
5
5
|
"mcpName": "io.github.StuMason/coolify",
|
|
6
6
|
"description": "MCP server for Coolify — 38 optimized tools for infrastructure management, diagnostics, and documentation search",
|
|
7
7
|
"type": "module",
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
"globals": "^17.0.0",
|
|
76
76
|
"husky": "^9.0.11",
|
|
77
77
|
"jest": "^30.3.0",
|
|
78
|
-
"jest-junit": "^
|
|
79
|
-
"lint-staged": "^
|
|
78
|
+
"jest-junit": "^17.0.0",
|
|
79
|
+
"lint-staged": "^17.0.4",
|
|
80
80
|
"markdownlint-cli2": "^0.22.0",
|
|
81
81
|
"prettier": "^3.5.3",
|
|
82
82
|
"shx": "^0.4.0",
|