@masonator/coolify-mcp 2.8.1 → 2.10.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.
@@ -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
- text: async () => JSON.stringify(data),
10
+ headers: new Headers({ 'Content-Type': contentType }),
11
+ text: async () => body,
10
12
  };
11
13
  }
12
14
  const mockFetch = jest.fn();
@@ -521,6 +523,21 @@ describe('CoolifyClient', () => {
521
523
  const result = await client.deleteServer('test-uuid');
522
524
  expect(result).toEqual({});
523
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
+ });
524
541
  it('should handle API errors without message', async () => {
525
542
  mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
526
543
  await expect(client.listServers()).rejects.toThrow('HTTP 500: Error');
@@ -952,6 +969,131 @@ describe('CoolifyClient', () => {
952
969
  expect(callBody.custom_docker_run_options).toBe('--network=my-net');
953
970
  expect(callBody.custom_labels).toBe('dHJhZWZpaw==');
954
971
  });
972
+ // Regression for #178 — verify build-config and health_check_* fields reach
973
+ // the wire, not just zod-accepted then silently stripped by the hand-pick.
974
+ it('should pass build-config and health_check fields through createApplicationPublic', async () => {
975
+ mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-app-uuid' }));
976
+ await client.createApplicationPublic({
977
+ project_uuid: 'proj-uuid',
978
+ server_uuid: 'server-uuid',
979
+ git_repository: 'https://github.com/user/monorepo',
980
+ git_branch: 'main',
981
+ build_pack: 'dockerfile',
982
+ ports_exposes: '3000',
983
+ base_directory: '/apps/api',
984
+ publish_directory: '/dist',
985
+ install_command: 'pnpm install',
986
+ build_command: 'pnpm build',
987
+ start_command: 'node dist/main.js',
988
+ dockerfile_location: '/apps/api/Dockerfile',
989
+ watch_paths: 'apps/api/**',
990
+ health_check_enabled: true,
991
+ health_check_path: '/health',
992
+ health_check_port: 3000,
993
+ health_check_start_period: 60,
994
+ });
995
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1]?.body);
996
+ expect(callBody.base_directory).toBe('/apps/api');
997
+ expect(callBody.publish_directory).toBe('/dist');
998
+ expect(callBody.install_command).toBe('pnpm install');
999
+ expect(callBody.build_command).toBe('pnpm build');
1000
+ expect(callBody.start_command).toBe('node dist/main.js');
1001
+ expect(callBody.dockerfile_location).toBe('/apps/api/Dockerfile');
1002
+ expect(callBody.watch_paths).toBe('apps/api/**');
1003
+ expect(callBody.health_check_enabled).toBe(true);
1004
+ expect(callBody.health_check_path).toBe('/health');
1005
+ expect(callBody.health_check_port).toBe(3000);
1006
+ expect(callBody.health_check_start_period).toBe(60);
1007
+ });
1008
+ it('should pass build-config and health_check fields through createApplicationPrivateGH', async () => {
1009
+ mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-app-uuid' }));
1010
+ await client.createApplicationPrivateGH({
1011
+ project_uuid: 'proj-uuid',
1012
+ server_uuid: 'server-uuid',
1013
+ github_app_uuid: 'gh-app-uuid',
1014
+ git_repository: 'org/monorepo',
1015
+ git_branch: 'main',
1016
+ base_directory: '/apps/api',
1017
+ dockerfile_location: '/apps/api/Dockerfile',
1018
+ watch_paths: 'apps/api/**',
1019
+ health_check_enabled: true,
1020
+ health_check_path: '/health',
1021
+ });
1022
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1]?.body);
1023
+ expect(callBody.base_directory).toBe('/apps/api');
1024
+ expect(callBody.dockerfile_location).toBe('/apps/api/Dockerfile');
1025
+ expect(callBody.watch_paths).toBe('apps/api/**');
1026
+ expect(callBody.health_check_enabled).toBe(true);
1027
+ expect(callBody.health_check_path).toBe('/health');
1028
+ });
1029
+ it('should pass build-config and health_check fields through createApplicationPrivateKey', async () => {
1030
+ mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-app-uuid' }));
1031
+ await client.createApplicationPrivateKey({
1032
+ project_uuid: 'proj-uuid',
1033
+ server_uuid: 'server-uuid',
1034
+ private_key_uuid: 'key-uuid',
1035
+ git_repository: 'git@github.com:org/monorepo.git',
1036
+ git_branch: 'main',
1037
+ base_directory: '/apps/api',
1038
+ publish_directory: '/dist',
1039
+ install_command: 'pnpm install',
1040
+ build_command: 'pnpm build',
1041
+ start_command: 'node dist/main.js',
1042
+ dockerfile_location: '/apps/api/Dockerfile',
1043
+ watch_paths: 'apps/api/**',
1044
+ health_check_enabled: true,
1045
+ health_check_path: '/health',
1046
+ health_check_port: 3000,
1047
+ });
1048
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1]?.body);
1049
+ expect(callBody.base_directory).toBe('/apps/api');
1050
+ expect(callBody.dockerfile_location).toBe('/apps/api/Dockerfile');
1051
+ expect(callBody.watch_paths).toBe('apps/api/**');
1052
+ expect(callBody.health_check_path).toBe('/health');
1053
+ expect(callBody.health_check_port).toBe(3000);
1054
+ });
1055
+ it('should pass health_check fields through createApplicationDockerImage (build-config N/A)', async () => {
1056
+ mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-app-uuid' }));
1057
+ await client.createApplicationDockerImage({
1058
+ project_uuid: 'proj-uuid',
1059
+ server_uuid: 'server-uuid',
1060
+ docker_registry_image_name: 'traefik/whoami',
1061
+ ports_exposes: '80',
1062
+ health_check_enabled: true,
1063
+ health_check_path: '/health',
1064
+ health_check_port: 80,
1065
+ health_check_method: 'GET',
1066
+ health_check_scheme: 'http',
1067
+ health_check_return_code: 200,
1068
+ health_check_interval: 30,
1069
+ health_check_timeout: 5,
1070
+ health_check_retries: 3,
1071
+ health_check_start_period: 60,
1072
+ });
1073
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1]?.body);
1074
+ expect(callBody.health_check_enabled).toBe(true);
1075
+ expect(callBody.health_check_path).toBe('/health');
1076
+ expect(callBody.health_check_port).toBe(80);
1077
+ expect(callBody.health_check_method).toBe('GET');
1078
+ expect(callBody.health_check_scheme).toBe('http');
1079
+ expect(callBody.health_check_return_code).toBe(200);
1080
+ expect(callBody.health_check_interval).toBe(30);
1081
+ expect(callBody.health_check_timeout).toBe(5);
1082
+ expect(callBody.health_check_retries).toBe(3);
1083
+ expect(callBody.health_check_start_period).toBe(60);
1084
+ });
1085
+ it('should pass dockerfile_target_build through updateApplication (PATCH-only field)', async () => {
1086
+ mockFetch.mockResolvedValueOnce(mockResponse(mockApplication));
1087
+ await client.updateApplication('app-uuid', {
1088
+ dockerfile_location: '/apps/api/Dockerfile',
1089
+ dockerfile_target_build: 'production',
1090
+ base_directory: '/apps/api',
1091
+ });
1092
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1]?.body);
1093
+ expect(callBody.dockerfile_location).toBe('/apps/api/Dockerfile');
1094
+ expect(callBody.dockerfile_target_build).toBe('production');
1095
+ expect(callBody.base_directory).toBe('/apps/api');
1096
+ });
955
1097
  it('should pass destination_uuid through in createApplicationPublic', async () => {
956
1098
  mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-app-uuid' }));
957
1099
  await client.createApplicationPublic({
@@ -1154,21 +1296,72 @@ describe('CoolifyClient', () => {
1154
1296
  uuid: 'env-var-uuid',
1155
1297
  key: 'API_KEY',
1156
1298
  value: 'secret123',
1157
- is_build_time: false,
1299
+ is_buildtime: false,
1300
+ is_runtime: true,
1158
1301
  };
1159
- it('should list application env vars', async () => {
1302
+ it('should list application env vars with values masked by default (#159)', async () => {
1160
1303
  mockFetch.mockResolvedValueOnce(mockResponse([mockEnvVar]));
1161
1304
  const result = await client.listApplicationEnvVars('app-uuid');
1162
- expect(result).toEqual([mockEnvVar]);
1305
+ // value masked, metadata preserved
1306
+ expect(result).toEqual([
1307
+ {
1308
+ uuid: 'env-var-uuid',
1309
+ key: 'API_KEY',
1310
+ value: '***',
1311
+ is_buildtime: false,
1312
+ is_runtime: true,
1313
+ },
1314
+ ]);
1163
1315
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-uuid/envs', expect.any(Object));
1164
1316
  });
1165
- it('should list application env vars with summary', async () => {
1317
+ it('should list application env vars with real_value also masked on the full projection (#159)', async () => {
1166
1318
  const fullEnvVar = {
1167
1319
  id: 1,
1168
1320
  uuid: 'env-var-uuid',
1169
1321
  key: 'API_KEY',
1170
1322
  value: 'secret123',
1171
- is_build_time: false,
1323
+ real_value: 'secret123',
1324
+ is_buildtime: false,
1325
+ is_runtime: true,
1326
+ is_literal: true,
1327
+ is_multiline: false,
1328
+ is_preview: false,
1329
+ is_shared: false,
1330
+ is_shown_once: false,
1331
+ application_id: 1,
1332
+ created_at: '2024-01-01',
1333
+ updated_at: '2024-01-01',
1334
+ };
1335
+ mockFetch.mockResolvedValueOnce(mockResponse([fullEnvVar]));
1336
+ const result = (await client.listApplicationEnvVars('app-uuid'));
1337
+ expect(result[0].value).toBe('***');
1338
+ expect(result[0].real_value).toBe('***');
1339
+ // Metadata stays intact
1340
+ expect(result[0]).toMatchObject({
1341
+ uuid: 'env-var-uuid',
1342
+ key: 'API_KEY',
1343
+ is_buildtime: false,
1344
+ is_runtime: true,
1345
+ is_literal: true,
1346
+ is_preview: false,
1347
+ application_id: 1,
1348
+ created_at: '2024-01-01',
1349
+ updated_at: '2024-01-01',
1350
+ });
1351
+ });
1352
+ it('should list application env vars with real values when reveal=true (#159)', async () => {
1353
+ mockFetch.mockResolvedValueOnce(mockResponse([mockEnvVar]));
1354
+ const result = await client.listApplicationEnvVars('app-uuid', { reveal: true });
1355
+ expect(result).toEqual([mockEnvVar]);
1356
+ });
1357
+ it('should list application env vars with summary, masked by default (#159)', async () => {
1358
+ const fullEnvVar = {
1359
+ id: 1,
1360
+ uuid: 'env-var-uuid',
1361
+ key: 'API_KEY',
1362
+ value: 'secret123',
1363
+ is_buildtime: false,
1364
+ is_runtime: true,
1172
1365
  is_literal: true,
1173
1366
  is_multiline: false,
1174
1367
  is_preview: false,
@@ -1180,13 +1373,46 @@ describe('CoolifyClient', () => {
1180
1373
  };
1181
1374
  mockFetch.mockResolvedValueOnce(mockResponse([fullEnvVar]));
1182
1375
  const result = await client.listApplicationEnvVars('app-uuid', { summary: true });
1183
- // Summary should only include uuid, key, value, is_build_time
1376
+ // Summary should only include uuid, key, value, is_buildtime, is_runtime — and value masked
1377
+ expect(result).toEqual([
1378
+ {
1379
+ uuid: 'env-var-uuid',
1380
+ key: 'API_KEY',
1381
+ value: '***',
1382
+ is_buildtime: false,
1383
+ is_runtime: true,
1384
+ },
1385
+ ]);
1386
+ });
1387
+ it('should list application env vars with summary and reveal=true returning real values (#159)', async () => {
1388
+ const fullEnvVar = {
1389
+ id: 1,
1390
+ uuid: 'env-var-uuid',
1391
+ key: 'API_KEY',
1392
+ value: 'secret123',
1393
+ is_buildtime: false,
1394
+ is_runtime: true,
1395
+ is_literal: true,
1396
+ is_multiline: false,
1397
+ is_preview: false,
1398
+ is_shared: false,
1399
+ is_shown_once: false,
1400
+ application_id: 1,
1401
+ created_at: '2024-01-01',
1402
+ updated_at: '2024-01-01',
1403
+ };
1404
+ mockFetch.mockResolvedValueOnce(mockResponse([fullEnvVar]));
1405
+ const result = await client.listApplicationEnvVars('app-uuid', {
1406
+ summary: true,
1407
+ reveal: true,
1408
+ });
1184
1409
  expect(result).toEqual([
1185
1410
  {
1186
1411
  uuid: 'env-var-uuid',
1187
1412
  key: 'API_KEY',
1188
1413
  value: 'secret123',
1189
- is_build_time: false,
1414
+ is_buildtime: false,
1415
+ is_runtime: true,
1190
1416
  },
1191
1417
  ]);
1192
1418
  });
@@ -1195,10 +1421,31 @@ describe('CoolifyClient', () => {
1195
1421
  const result = await client.createApplicationEnvVar('app-uuid', {
1196
1422
  key: 'NEW_VAR',
1197
1423
  value: 'new-value',
1198
- is_build_time: true,
1424
+ is_buildtime: true,
1199
1425
  });
1200
1426
  expect(result).toEqual({ uuid: 'new-env-uuid' });
1201
1427
  });
1428
+ it('should create runtime-only env var (no Dockerfile ARG injection)', async () => {
1429
+ // Regression for #135: setting is_buildtime=false avoids multiline values
1430
+ // (PEM keys, etc.) being injected as Dockerfile ARG and breaking the build.
1431
+ mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-env-uuid' }));
1432
+ await client.createApplicationEnvVar('app-uuid', {
1433
+ key: 'PASSPORT_PRIVATE_KEY',
1434
+ value: '-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----',
1435
+ is_buildtime: false,
1436
+ is_runtime: true,
1437
+ });
1438
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-uuid/envs', expect.objectContaining({
1439
+ method: 'POST',
1440
+ body: expect.stringContaining('"is_buildtime":false'),
1441
+ }));
1442
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
1443
+ expect(body).toMatchObject({
1444
+ key: 'PASSPORT_PRIVATE_KEY',
1445
+ is_buildtime: false,
1446
+ is_runtime: true,
1447
+ });
1448
+ });
1202
1449
  it('should update application env var', async () => {
1203
1450
  mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Updated' }));
1204
1451
  const result = await client.updateApplicationEnvVar('app-uuid', {
@@ -1207,6 +1454,22 @@ describe('CoolifyClient', () => {
1207
1454
  });
1208
1455
  expect(result).toEqual({ message: 'Updated' });
1209
1456
  });
1457
+ it('should update env var to runtime-only (flip is_buildtime=false)', async () => {
1458
+ mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Updated' }));
1459
+ await client.updateApplicationEnvVar('app-uuid', {
1460
+ key: 'NODE_ENV',
1461
+ value: 'production',
1462
+ is_buildtime: false,
1463
+ is_runtime: true,
1464
+ });
1465
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
1466
+ expect(body).toEqual({
1467
+ key: 'NODE_ENV',
1468
+ value: 'production',
1469
+ is_buildtime: false,
1470
+ is_runtime: true,
1471
+ });
1472
+ });
1210
1473
  it('should bulk update application env vars', async () => {
1211
1474
  mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Updated' }));
1212
1475
  const result = await client.bulkUpdateApplicationEnvVars('app-uuid', {
@@ -1482,12 +1745,54 @@ describe('CoolifyClient', () => {
1482
1745
  key: 'SVC_KEY',
1483
1746
  value: 'svc-value',
1484
1747
  };
1485
- it('should list service env vars', async () => {
1748
+ it('should list service env vars with values masked by default (#159)', async () => {
1486
1749
  mockFetch.mockResolvedValueOnce(mockResponse([mockEnvVar]));
1487
1750
  const result = await client.listServiceEnvVars('test-uuid');
1488
- expect(result).toEqual([mockEnvVar]);
1751
+ // value masked, metadata (uuid, key) preserved
1752
+ expect(result).toEqual([
1753
+ {
1754
+ uuid: 'svc-env-uuid',
1755
+ key: 'SVC_KEY',
1756
+ value: '***',
1757
+ },
1758
+ ]);
1489
1759
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/services/test-uuid/envs', expect.any(Object));
1490
1760
  });
1761
+ it('should list service env vars with real_value masked on the full projection (#159)', async () => {
1762
+ const fullEnvVar = {
1763
+ id: 1,
1764
+ uuid: 'svc-env-uuid',
1765
+ key: 'SVC_KEY',
1766
+ value: 'svc-value',
1767
+ real_value: 'svc-value',
1768
+ is_buildtime: false,
1769
+ is_runtime: true,
1770
+ is_literal: true,
1771
+ is_multiline: false,
1772
+ is_preview: false,
1773
+ is_shared: false,
1774
+ is_shown_once: false,
1775
+ service_id: 42,
1776
+ created_at: '2024-01-01',
1777
+ updated_at: '2024-01-01',
1778
+ };
1779
+ mockFetch.mockResolvedValueOnce(mockResponse([fullEnvVar]));
1780
+ const result = await client.listServiceEnvVars('test-uuid');
1781
+ expect(result[0].value).toBe('***');
1782
+ expect(result[0].real_value).toBe('***');
1783
+ expect(result[0]).toMatchObject({
1784
+ uuid: 'svc-env-uuid',
1785
+ key: 'SVC_KEY',
1786
+ is_buildtime: false,
1787
+ is_runtime: true,
1788
+ service_id: 42,
1789
+ });
1790
+ });
1791
+ it('should list service env vars with real values when reveal=true (#159)', async () => {
1792
+ mockFetch.mockResolvedValueOnce(mockResponse([mockEnvVar]));
1793
+ const result = await client.listServiceEnvVars('test-uuid', { reveal: true });
1794
+ expect(result).toEqual([mockEnvVar]);
1795
+ });
1491
1796
  it('should create service env var', async () => {
1492
1797
  mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-env-uuid' }));
1493
1798
  const result = await client.createServiceEnvVar('test-uuid', {
@@ -2008,9 +2313,17 @@ describe('CoolifyClient', () => {
2008
2313
  uuid: 'env-1',
2009
2314
  key: 'DATABASE_URL',
2010
2315
  value: 'postgres://...',
2011
- is_build_time: false,
2316
+ is_buildtime: false,
2317
+ is_runtime: true,
2318
+ },
2319
+ {
2320
+ id: 2,
2321
+ uuid: 'env-2',
2322
+ key: 'NODE_ENV',
2323
+ value: 'production',
2324
+ is_buildtime: true,
2325
+ is_runtime: true,
2012
2326
  },
2013
- { id: 2, uuid: 'env-2', key: 'NODE_ENV', value: 'production', is_build_time: true },
2014
2327
  ];
2015
2328
  const mockDeployments = [
2016
2329
  {
@@ -2057,12 +2370,28 @@ describe('CoolifyClient', () => {
2057
2370
  expect(result.logs).toBe(mockLogs);
2058
2371
  expect(result.environment_variables.count).toBe(2);
2059
2372
  expect(result.environment_variables.variables).toEqual([
2060
- { key: 'DATABASE_URL', is_build_time: false },
2061
- { key: 'NODE_ENV', is_build_time: true },
2373
+ { key: 'DATABASE_URL', is_buildtime: false, is_runtime: true },
2374
+ { key: 'NODE_ENV', is_buildtime: true, is_runtime: true },
2062
2375
  ]);
2063
2376
  expect(result.recent_deployments).toHaveLength(2);
2064
2377
  expect(result.errors).toBeUndefined();
2065
2378
  });
2379
+ it('should apply default flags when env var omits is_buildtime/is_runtime', async () => {
2380
+ // Hits the `?? false` / `?? true` fallback branches in the diagnose mapping
2381
+ // for legacy Coolify responses that don't carry both flags explicitly.
2382
+ const envVarsMissingFlags = [
2383
+ { id: 1, uuid: 'env-1', key: 'LEGACY_VAR', value: 'x' },
2384
+ ];
2385
+ mockFetch
2386
+ .mockResolvedValueOnce(mockResponse(mockApp))
2387
+ .mockResolvedValueOnce(mockResponse(mockLogs))
2388
+ .mockResolvedValueOnce(mockResponse(envVarsMissingFlags))
2389
+ .mockResolvedValueOnce(mockResponse({ count: 0, deployments: [] }));
2390
+ const result = await client.diagnoseApplication(testAppUuid);
2391
+ expect(result.environment_variables.variables).toEqual([
2392
+ { key: 'LEGACY_VAR', is_buildtime: false, is_runtime: true },
2393
+ ]);
2394
+ });
2066
2395
  it('should detect unhealthy application status', async () => {
2067
2396
  const unhealthyApp = { ...mockApp, status: 'exited:unhealthy' };
2068
2397
  mockFetch
@@ -2543,15 +2872,40 @@ describe('CoolifyClient', () => {
2543
2872
  // No API calls should be made
2544
2873
  expect(mockFetch).not.toHaveBeenCalled();
2545
2874
  });
2546
- it('should send build time flag when specified', async () => {
2875
+ it('should send buildtime flag when specified', async () => {
2547
2876
  mockFetch
2548
2877
  .mockResolvedValueOnce(mockResponse(mockApps))
2549
2878
  .mockResolvedValueOnce(mockResponse({ message: 'Updated' }));
2550
2879
  await client.bulkEnvUpdate(['app-1'], 'BUILD_VAR', 'value', true);
2551
- // Verify the PATCH call was made with is_build_time
2880
+ // Verify the PATCH call was made with is_buildtime (one word — Coolify API field name)
2881
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-1/envs', expect.objectContaining({
2882
+ method: 'PATCH',
2883
+ body: JSON.stringify({ key: 'BUILD_VAR', value: 'value', is_buildtime: true }),
2884
+ }));
2885
+ });
2886
+ it('should send both buildtime and runtime flags for runtime-only vars', async () => {
2887
+ mockFetch
2888
+ .mockResolvedValueOnce(mockResponse(mockApps))
2889
+ .mockResolvedValueOnce(mockResponse({ message: 'Updated' }));
2890
+ await client.bulkEnvUpdate(['app-1'], 'PEM_KEY', 'multiline-value', false, true);
2891
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-1/envs', expect.objectContaining({
2892
+ method: 'PATCH',
2893
+ body: JSON.stringify({
2894
+ key: 'PEM_KEY',
2895
+ value: 'multiline-value',
2896
+ is_buildtime: false,
2897
+ is_runtime: true,
2898
+ }),
2899
+ }));
2900
+ });
2901
+ it('should send only is_runtime when buildtime is left undefined', async () => {
2902
+ mockFetch
2903
+ .mockResolvedValueOnce(mockResponse(mockApps))
2904
+ .mockResolvedValueOnce(mockResponse({ message: 'Updated' }));
2905
+ await client.bulkEnvUpdate(['app-1'], 'API_KEY', 'val', undefined, false);
2552
2906
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-1/envs', expect.objectContaining({
2553
2907
  method: 'PATCH',
2554
- body: JSON.stringify({ key: 'BUILD_VAR', value: 'value', is_build_time: true }),
2908
+ body: JSON.stringify({ key: 'API_KEY', value: 'val', is_runtime: false }),
2555
2909
  }));
2556
2910
  });
2557
2911
  });
@@ -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 and is_build_time exposed)
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('is_build_time');
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,268 @@ 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
+ });
266
+ describe('application tool handler', () => {
267
+ // Regression for #178 — verify the application tool's create_* hand-picks
268
+ // forward build-config and health_check_* fields to the client. Previously
269
+ // these fields were accepted by zod but silently dropped by the hand-pick.
270
+ const callApplication = async (srv, args) => {
271
+ const tool = srv._registeredTools['application'];
272
+ return tool.handler(args, {});
273
+ };
274
+ const baseCreatePublic = {
275
+ action: 'create_public',
276
+ project_uuid: 'proj-uuid',
277
+ server_uuid: 'server-uuid',
278
+ git_repository: 'https://github.com/org/monorepo',
279
+ git_branch: 'main',
280
+ build_pack: 'dockerfile',
281
+ ports_exposes: '3000',
282
+ };
283
+ it('forwards build-config and health_check fields in create_public', async () => {
284
+ const spy = jest
285
+ .spyOn(server['client'], 'createApplicationPublic')
286
+ .mockResolvedValue({ uuid: 'app-1' });
287
+ await callApplication(server, {
288
+ ...baseCreatePublic,
289
+ base_directory: '/apps/api',
290
+ publish_directory: '/dist',
291
+ install_command: 'pnpm install',
292
+ build_command: 'pnpm build',
293
+ start_command: 'node dist/main.js',
294
+ dockerfile_location: '/apps/api/Dockerfile',
295
+ watch_paths: 'apps/api/**',
296
+ health_check_enabled: true,
297
+ health_check_path: '/health',
298
+ health_check_port: 3000,
299
+ health_check_start_period: 60,
300
+ });
301
+ expect(spy).toHaveBeenCalledWith(expect.objectContaining({
302
+ base_directory: '/apps/api',
303
+ publish_directory: '/dist',
304
+ install_command: 'pnpm install',
305
+ build_command: 'pnpm build',
306
+ start_command: 'node dist/main.js',
307
+ dockerfile_location: '/apps/api/Dockerfile',
308
+ watch_paths: 'apps/api/**',
309
+ health_check_enabled: true,
310
+ health_check_path: '/health',
311
+ health_check_port: 3000,
312
+ health_check_start_period: 60,
313
+ }));
314
+ });
315
+ it('forwards build-config and health_check fields in create_github', async () => {
316
+ const spy = jest
317
+ .spyOn(server['client'], 'createApplicationPrivateGH')
318
+ .mockResolvedValue({ uuid: 'app-2' });
319
+ await callApplication(server, {
320
+ action: 'create_github',
321
+ project_uuid: 'proj-uuid',
322
+ server_uuid: 'server-uuid',
323
+ github_app_uuid: 'gh-app-uuid',
324
+ git_repository: 'org/monorepo',
325
+ git_branch: 'main',
326
+ base_directory: '/apps/api',
327
+ dockerfile_location: '/apps/api/Dockerfile',
328
+ watch_paths: 'apps/api/**',
329
+ health_check_enabled: true,
330
+ health_check_path: '/health',
331
+ });
332
+ expect(spy).toHaveBeenCalledWith(expect.objectContaining({
333
+ base_directory: '/apps/api',
334
+ dockerfile_location: '/apps/api/Dockerfile',
335
+ watch_paths: 'apps/api/**',
336
+ health_check_enabled: true,
337
+ health_check_path: '/health',
338
+ }));
339
+ });
340
+ it('forwards build-config and health_check fields in create_key', async () => {
341
+ const spy = jest
342
+ .spyOn(server['client'], 'createApplicationPrivateKey')
343
+ .mockResolvedValue({ uuid: 'app-3' });
344
+ await callApplication(server, {
345
+ action: 'create_key',
346
+ project_uuid: 'proj-uuid',
347
+ server_uuid: 'server-uuid',
348
+ private_key_uuid: 'key-uuid',
349
+ git_repository: 'git@github.com:org/monorepo.git',
350
+ git_branch: 'main',
351
+ base_directory: '/apps/api',
352
+ publish_directory: '/dist',
353
+ install_command: 'pnpm install',
354
+ build_command: 'pnpm build',
355
+ start_command: 'node dist/main.js',
356
+ dockerfile_location: '/apps/api/Dockerfile',
357
+ watch_paths: 'apps/api/**',
358
+ health_check_enabled: true,
359
+ health_check_path: '/health',
360
+ health_check_port: 3000,
361
+ });
362
+ expect(spy).toHaveBeenCalledWith(expect.objectContaining({
363
+ base_directory: '/apps/api',
364
+ publish_directory: '/dist',
365
+ install_command: 'pnpm install',
366
+ build_command: 'pnpm build',
367
+ start_command: 'node dist/main.js',
368
+ dockerfile_location: '/apps/api/Dockerfile',
369
+ watch_paths: 'apps/api/**',
370
+ health_check_enabled: true,
371
+ health_check_path: '/health',
372
+ health_check_port: 3000,
373
+ }));
374
+ });
375
+ it('forwards health_check fields in create_dockerimage (build-config intentionally dropped)', async () => {
376
+ const spy = jest
377
+ .spyOn(server['client'], 'createApplicationDockerImage')
378
+ .mockResolvedValue({ uuid: 'app-4' });
379
+ // Caller passes both healthcheck AND build-config. Coolify's /applications/dockerimage
380
+ // endpoint doesn't accept build-config (pre-built image), so handler must drop those.
381
+ await callApplication(server, {
382
+ action: 'create_dockerimage',
383
+ project_uuid: 'proj-uuid',
384
+ server_uuid: 'server-uuid',
385
+ docker_registry_image_name: 'traefik/whoami',
386
+ ports_exposes: '80',
387
+ // Should be forwarded:
388
+ health_check_enabled: true,
389
+ health_check_path: '/health',
390
+ health_check_port: 80,
391
+ // Should NOT be forwarded (build-config not applicable to prebuilt image):
392
+ base_directory: '/should-be-dropped',
393
+ install_command: 'should-be-dropped',
394
+ dockerfile_location: '/should-be-dropped',
395
+ });
396
+ const forwarded = spy.mock.calls[0]?.[0];
397
+ expect(forwarded).toEqual(expect.objectContaining({
398
+ health_check_enabled: true,
399
+ health_check_path: '/health',
400
+ health_check_port: 80,
401
+ }));
402
+ expect(forwarded).not.toHaveProperty('base_directory');
403
+ expect(forwarded).not.toHaveProperty('install_command');
404
+ expect(forwarded).not.toHaveProperty('dockerfile_location');
405
+ });
406
+ it('forwards dockerfile_target_build through update (PATCH-only)', async () => {
407
+ const spy = jest.spyOn(server['client'], 'updateApplication').mockResolvedValue({});
408
+ await callApplication(server, {
409
+ action: 'update',
410
+ uuid: 'app-uuid',
411
+ dockerfile_location: '/apps/api/Dockerfile',
412
+ dockerfile_target_build: 'production',
413
+ base_directory: '/apps/api',
414
+ });
415
+ expect(spy).toHaveBeenCalledWith('app-uuid', expect.objectContaining({
416
+ dockerfile_location: '/apps/api/Dockerfile',
417
+ dockerfile_target_build: 'production',
418
+ base_directory: '/apps/api',
419
+ }));
420
+ // Confirm the update spread strips routing fields.
421
+ const updateData = spy.mock.calls[0]?.[1];
422
+ expect(updateData).not.toHaveProperty('action');
423
+ expect(updateData).not.toHaveProperty('uuid');
424
+ });
425
+ });
164
426
  });
165
427
  describe('truncateLogs', () => {
166
428
  // Plain text log tests
@@ -125,8 +125,16 @@ export declare class CoolifyClient {
125
125
  }): Promise<ApplicationActionResponse>;
126
126
  stopApplication(uuid: string): Promise<ApplicationActionResponse>;
127
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
+ */
128
135
  listApplicationEnvVars(uuid: string, options?: {
129
136
  summary?: boolean;
137
+ reveal?: boolean;
130
138
  }): Promise<EnvironmentVariable[] | EnvVarSummary[]>;
131
139
  createApplicationEnvVar(uuid: string, data: CreateEnvVarRequest): Promise<UuidResponse>;
132
140
  updateApplicationEnvVar(uuid: string, data: UpdateEnvVarRequest): Promise<MessageResponse>;
@@ -155,7 +163,16 @@ export declare class CoolifyClient {
155
163
  startService(uuid: string): Promise<MessageResponse>;
156
164
  stopService(uuid: string): Promise<MessageResponse>;
157
165
  restartService(uuid: string): Promise<MessageResponse>;
158
- listServiceEnvVars(uuid: string): Promise<EnvironmentVariable[]>;
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[]>;
159
176
  createServiceEnvVar(uuid: string, data: CreateEnvVarRequest): Promise<UuidResponse>;
160
177
  updateServiceEnvVar(uuid: string, data: UpdateEnvVarRequest): Promise<MessageResponse>;
161
178
  deleteServiceEnvVar(uuid: string, envUuid: string): Promise<MessageResponse>;
@@ -257,9 +274,10 @@ export declare class CoolifyClient {
257
274
  * @param appUuids - Array of application UUIDs
258
275
  * @param key - Environment variable key
259
276
  * @param value - Environment variable value
260
- * @param isBuildTime - Whether this is a build-time variable (default: false)
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
261
279
  */
262
- bulkEnvUpdate(appUuids: string[], key: string, value: string, isBuildTime?: boolean): Promise<BatchOperationResult>;
280
+ bulkEnvUpdate(appUuids: string[], key: string, value: string, isBuildtime?: boolean, isRuntime?: boolean): Promise<BatchOperationResult>;
263
281
  /**
264
282
  * Emergency stop all running applications across entire infrastructure.
265
283
  */
@@ -142,7 +142,42 @@ function toEnvVarSummary(envVar) {
142
142
  uuid: envVar.uuid,
143
143
  key: envVar.key,
144
144
  value: envVar.value,
145
- is_build_time: envVar.is_build_time,
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
  /**
@@ -192,7 +227,22 @@ export class CoolifyClient {
192
227
  });
193
228
  // Handle empty responses (204 No Content, etc.)
194
229
  const text = await response.text();
195
- const data = text ? JSON.parse(text) : {};
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
+ }
196
246
  if (!response.ok) {
197
247
  const error = data;
198
248
  // Include validation errors if present
@@ -476,9 +526,21 @@ export class CoolifyClient {
476
526
  // ===========================================================================
477
527
  // Application Environment Variables
478
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
+ */
479
536
  async listApplicationEnvVars(uuid, options) {
480
537
  const envVars = await this.request(`/applications/${uuid}/envs`);
481
- return options?.summary ? envVars.map(toEnvVarSummary) : envVars;
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);
482
544
  }
483
545
  async createApplicationEnvVar(uuid, data) {
484
546
  return this.request(`/applications/${uuid}/envs`, {
@@ -661,8 +723,16 @@ export class CoolifyClient {
661
723
  // ===========================================================================
662
724
  // Service Environment Variables
663
725
  // ===========================================================================
664
- async listServiceEnvVars(uuid) {
665
- return this.request(`/services/${uuid}/envs`);
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);
666
736
  }
667
737
  async createServiceEnvVar(uuid, data) {
668
738
  return this.request(`/services/${uuid}/envs`, {
@@ -1026,7 +1096,8 @@ export class CoolifyClient {
1026
1096
  count: envVars?.length || 0,
1027
1097
  variables: (envVars || []).map((v) => ({
1028
1098
  key: v.key,
1029
- is_build_time: v.is_build_time ?? false,
1099
+ is_buildtime: v.is_buildtime ?? false,
1100
+ is_runtime: v.is_runtime ?? true,
1030
1101
  })),
1031
1102
  },
1032
1103
  recent_deployments: (deployments || []).slice(0, 5).map((d) => ({
@@ -1292,9 +1363,10 @@ export class CoolifyClient {
1292
1363
  * @param appUuids - Array of application UUIDs
1293
1364
  * @param key - Environment variable key
1294
1365
  * @param value - Environment variable value
1295
- * @param isBuildTime - Whether this is a build-time variable (default: false)
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
1296
1368
  */
1297
- async bulkEnvUpdate(appUuids, key, value, isBuildTime = false) {
1369
+ async bulkEnvUpdate(appUuids, key, value, isBuildtime, isRuntime) {
1298
1370
  // Early return for empty array - avoid unnecessary API call
1299
1371
  if (appUuids.length === 0) {
1300
1372
  return {
@@ -1311,7 +1383,12 @@ export class CoolifyClient {
1311
1383
  uuid,
1312
1384
  name: appMap.get(uuid) || uuid,
1313
1385
  }));
1314
- const results = await Promise.allSettled(appUuids.map((uuid) => this.updateApplicationEnvVar(uuid, { key, value, is_build_time: isBuildTime })));
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
+ })));
1315
1392
  return this.aggregateBatchResults(resources, results);
1316
1393
  }
1317
1394
  /**
@@ -340,6 +340,18 @@ export class CoolifyMcpServer extends McpServer {
340
340
  health_check_timeout: z.number().optional(),
341
341
  health_check_retries: z.number().optional(),
342
342
  health_check_start_period: z.number().optional(),
343
+ // Build configuration fields (accepted on create_public/github/key + update;
344
+ // create_dockerimage ignores these — pre-built image, no build step)
345
+ base_directory: z.string().optional(),
346
+ publish_directory: z.string().optional(),
347
+ install_command: z.string().optional(),
348
+ build_command: z.string().optional(),
349
+ start_command: z.string().optional(),
350
+ dockerfile_location: z.string().optional(),
351
+ watch_paths: z.string().optional(),
352
+ // Update-only: Coolify strips dockerfile_target_build on every create endpoint
353
+ // (controller $allowedFields line 1014) but accepts on PATCH (line 2497).
354
+ dockerfile_target_build: z.string().optional(),
343
355
  // Delete fields
344
356
  delete_volumes: z.boolean().optional(),
345
357
  }, async (args) => {
@@ -375,6 +387,25 @@ export class CoolifyMcpServer extends McpServer {
375
387
  description: args.description,
376
388
  fqdn: args.fqdn,
377
389
  domains: args.domains,
390
+ base_directory: args.base_directory,
391
+ publish_directory: args.publish_directory,
392
+ install_command: args.install_command,
393
+ build_command: args.build_command,
394
+ start_command: args.start_command,
395
+ dockerfile_location: args.dockerfile_location,
396
+ watch_paths: args.watch_paths,
397
+ health_check_enabled: args.health_check_enabled,
398
+ health_check_path: args.health_check_path,
399
+ health_check_port: args.health_check_port,
400
+ health_check_host: args.health_check_host,
401
+ health_check_method: args.health_check_method,
402
+ health_check_return_code: args.health_check_return_code,
403
+ health_check_scheme: args.health_check_scheme,
404
+ health_check_response_text: args.health_check_response_text,
405
+ health_check_interval: args.health_check_interval,
406
+ health_check_timeout: args.health_check_timeout,
407
+ health_check_retries: args.health_check_retries,
408
+ health_check_start_period: args.health_check_start_period,
378
409
  custom_docker_run_options: args.custom_docker_run_options,
379
410
  custom_labels: args.custom_labels,
380
411
  instant_deploy: args.instant_deploy,
@@ -409,6 +440,25 @@ export class CoolifyMcpServer extends McpServer {
409
440
  description: args.description,
410
441
  fqdn: args.fqdn,
411
442
  domains: args.domains,
443
+ base_directory: args.base_directory,
444
+ publish_directory: args.publish_directory,
445
+ install_command: args.install_command,
446
+ build_command: args.build_command,
447
+ start_command: args.start_command,
448
+ dockerfile_location: args.dockerfile_location,
449
+ watch_paths: args.watch_paths,
450
+ health_check_enabled: args.health_check_enabled,
451
+ health_check_path: args.health_check_path,
452
+ health_check_port: args.health_check_port,
453
+ health_check_host: args.health_check_host,
454
+ health_check_method: args.health_check_method,
455
+ health_check_return_code: args.health_check_return_code,
456
+ health_check_scheme: args.health_check_scheme,
457
+ health_check_response_text: args.health_check_response_text,
458
+ health_check_interval: args.health_check_interval,
459
+ health_check_timeout: args.health_check_timeout,
460
+ health_check_retries: args.health_check_retries,
461
+ health_check_start_period: args.health_check_start_period,
412
462
  custom_docker_run_options: args.custom_docker_run_options,
413
463
  custom_labels: args.custom_labels,
414
464
  instant_deploy: args.instant_deploy,
@@ -443,6 +493,25 @@ export class CoolifyMcpServer extends McpServer {
443
493
  description: args.description,
444
494
  fqdn: args.fqdn,
445
495
  domains: args.domains,
496
+ base_directory: args.base_directory,
497
+ publish_directory: args.publish_directory,
498
+ install_command: args.install_command,
499
+ build_command: args.build_command,
500
+ start_command: args.start_command,
501
+ dockerfile_location: args.dockerfile_location,
502
+ watch_paths: args.watch_paths,
503
+ health_check_enabled: args.health_check_enabled,
504
+ health_check_path: args.health_check_path,
505
+ health_check_port: args.health_check_port,
506
+ health_check_host: args.health_check_host,
507
+ health_check_method: args.health_check_method,
508
+ health_check_return_code: args.health_check_return_code,
509
+ health_check_scheme: args.health_check_scheme,
510
+ health_check_response_text: args.health_check_response_text,
511
+ health_check_interval: args.health_check_interval,
512
+ health_check_timeout: args.health_check_timeout,
513
+ health_check_retries: args.health_check_retries,
514
+ health_check_start_period: args.health_check_start_period,
446
515
  custom_docker_run_options: args.custom_docker_run_options,
447
516
  custom_labels: args.custom_labels,
448
517
  instant_deploy: args.instant_deploy,
@@ -474,6 +543,21 @@ export class CoolifyMcpServer extends McpServer {
474
543
  description: args.description,
475
544
  fqdn: args.fqdn,
476
545
  domains: args.domains,
546
+ // Build-config fields (base_directory, install_command, etc.)
547
+ // are intentionally NOT forwarded: /applications/dockerimage is
548
+ // for pre-built registry images and has no build step.
549
+ health_check_enabled: args.health_check_enabled,
550
+ health_check_path: args.health_check_path,
551
+ health_check_port: args.health_check_port,
552
+ health_check_host: args.health_check_host,
553
+ health_check_method: args.health_check_method,
554
+ health_check_return_code: args.health_check_return_code,
555
+ health_check_scheme: args.health_check_scheme,
556
+ health_check_response_text: args.health_check_response_text,
557
+ health_check_interval: args.health_check_interval,
558
+ health_check_timeout: args.health_check_timeout,
559
+ health_check_retries: args.health_check_retries,
560
+ health_check_start_period: args.health_check_start_period,
477
561
  custom_docker_run_options: args.custom_docker_run_options,
478
562
  custom_labels: args.custom_labels,
479
563
  instant_deploy: args.instant_deploy,
@@ -682,27 +766,39 @@ export class CoolifyMcpServer extends McpServer {
682
766
  // =========================================================================
683
767
  // Environment Variables (1 tool - consolidated)
684
768
  // =========================================================================
685
- this.tool('env_vars', 'Manage env vars for app or service', {
769
+ 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
770
  resource: z.enum(['application', 'service']),
687
771
  action: z.enum(['list', 'create', 'update', 'delete']),
688
772
  uuid: z.string(),
689
773
  key: z.string().optional(),
690
774
  value: z.string().optional(),
691
775
  env_uuid: z.string().optional(),
692
- }, async ({ resource, action, uuid, key, value, env_uuid }) => {
776
+ is_buildtime: z.boolean().optional(),
777
+ is_runtime: z.boolean().optional(),
778
+ reveal: z.boolean().optional(),
779
+ }, async ({ resource, action, uuid, key, value, env_uuid, is_buildtime, is_runtime, reveal, }) => {
693
780
  if (resource === 'application') {
694
781
  switch (action) {
695
782
  case 'list':
696
- return wrap(() => this.client.listApplicationEnvVars(uuid, { summary: true }));
783
+ return wrap(() => this.client.listApplicationEnvVars(uuid, { summary: true, reveal }));
697
784
  case 'create':
698
785
  if (!key || !value)
699
786
  return { content: [{ type: 'text', text: 'Error: key, value required' }] };
700
- // Note: is_build_time is not passed - Coolify API rejects it for create action
701
- return wrap(() => this.client.createApplicationEnvVar(uuid, { key, value }));
787
+ return wrap(() => this.client.createApplicationEnvVar(uuid, {
788
+ key,
789
+ value,
790
+ is_buildtime,
791
+ is_runtime,
792
+ }));
702
793
  case 'update':
703
794
  if (!key || !value)
704
795
  return { content: [{ type: 'text', text: 'Error: key, value required' }] };
705
- return wrap(() => this.client.updateApplicationEnvVar(uuid, { key, value }));
796
+ return wrap(() => this.client.updateApplicationEnvVar(uuid, {
797
+ key,
798
+ value,
799
+ is_buildtime,
800
+ is_runtime,
801
+ }));
706
802
  case 'delete':
707
803
  if (!env_uuid)
708
804
  return { content: [{ type: 'text', text: 'Error: env_uuid required' }] };
@@ -712,11 +808,16 @@ export class CoolifyMcpServer extends McpServer {
712
808
  else {
713
809
  switch (action) {
714
810
  case 'list':
715
- return wrap(() => this.client.listServiceEnvVars(uuid));
811
+ return wrap(() => this.client.listServiceEnvVars(uuid, { reveal }));
716
812
  case 'create':
717
813
  if (!key || !value)
718
814
  return { content: [{ type: 'text', text: 'Error: key, value required' }] };
719
- return wrap(() => this.client.createServiceEnvVar(uuid, { key, value }));
815
+ return wrap(() => this.client.createServiceEnvVar(uuid, {
816
+ key,
817
+ value,
818
+ is_buildtime,
819
+ is_runtime,
820
+ }));
720
821
  case 'update':
721
822
  return {
722
823
  content: [
@@ -1056,8 +1157,9 @@ export class CoolifyMcpServer extends McpServer {
1056
1157
  app_uuids: z.array(z.string()),
1057
1158
  key: z.string(),
1058
1159
  value: z.string(),
1059
- is_build_time: z.boolean().optional(),
1060
- }, async ({ app_uuids, key, value, is_build_time }) => wrap(() => this.client.bulkEnvUpdate(app_uuids, key, value, is_build_time)));
1160
+ is_buildtime: z.boolean().optional(),
1161
+ is_runtime: z.boolean().optional(),
1162
+ }, async ({ app_uuids, key, value, is_buildtime, is_runtime }) => wrap(() => this.client.bulkEnvUpdate(app_uuids, key, value, is_buildtime, is_runtime)));
1061
1163
  this.tool('stop_all_apps', 'EMERGENCY: Stop all running apps', { confirm: z.literal(true) }, async ({ confirm }) => {
1062
1164
  if (!confirm)
1063
1165
  return { content: [{ type: 'text', text: 'Error: confirm=true required' }] };
@@ -224,6 +224,20 @@ export interface CreateApplicationPublicRequest {
224
224
  install_command?: string;
225
225
  build_command?: string;
226
226
  start_command?: string;
227
+ dockerfile_location?: string;
228
+ watch_paths?: string;
229
+ health_check_enabled?: boolean;
230
+ health_check_path?: string;
231
+ health_check_port?: number;
232
+ health_check_host?: string;
233
+ health_check_method?: string;
234
+ health_check_return_code?: number;
235
+ health_check_scheme?: string;
236
+ health_check_response_text?: string;
237
+ health_check_interval?: number;
238
+ health_check_timeout?: number;
239
+ health_check_retries?: number;
240
+ health_check_start_period?: number;
227
241
  custom_docker_run_options?: string;
228
242
  custom_labels?: string;
229
243
  instant_deploy?: boolean;
@@ -271,6 +285,18 @@ export interface CreateApplicationDockerImageRequest {
271
285
  docker_registry_image_tag?: string;
272
286
  ports_exposes: string;
273
287
  ports_mappings?: string;
288
+ health_check_enabled?: boolean;
289
+ health_check_path?: string;
290
+ health_check_port?: number;
291
+ health_check_host?: string;
292
+ health_check_method?: string;
293
+ health_check_return_code?: number;
294
+ health_check_scheme?: string;
295
+ health_check_response_text?: string;
296
+ health_check_interval?: number;
297
+ health_check_timeout?: number;
298
+ health_check_retries?: number;
299
+ health_check_start_period?: number;
274
300
  custom_docker_run_options?: string;
275
301
  custom_labels?: string;
276
302
  instant_deploy?: boolean;
@@ -316,6 +342,8 @@ export interface UpdateApplicationRequest {
316
342
  install_command?: string;
317
343
  build_command?: string;
318
344
  start_command?: string;
345
+ dockerfile_target_build?: string;
346
+ watch_paths?: string;
319
347
  health_check_enabled?: boolean;
320
348
  health_check_path?: string;
321
349
  health_check_port?: number;
@@ -344,7 +372,8 @@ export interface EnvironmentVariable {
344
372
  uuid: string;
345
373
  key: string;
346
374
  value: string;
347
- is_build_time: boolean;
375
+ is_buildtime: boolean;
376
+ is_runtime: boolean;
348
377
  is_literal: boolean;
349
378
  is_multiline: boolean;
350
379
  is_preview: boolean;
@@ -365,7 +394,8 @@ export interface CreateEnvVarRequest {
365
394
  is_literal?: boolean;
366
395
  is_multiline?: boolean;
367
396
  is_shown_once?: boolean;
368
- is_build_time?: boolean;
397
+ is_buildtime?: boolean;
398
+ is_runtime?: boolean;
369
399
  }
370
400
  export interface UpdateEnvVarRequest {
371
401
  key: string;
@@ -374,7 +404,8 @@ export interface UpdateEnvVarRequest {
374
404
  is_literal?: boolean;
375
405
  is_multiline?: boolean;
376
406
  is_shown_once?: boolean;
377
- is_build_time?: boolean;
407
+ is_buildtime?: boolean;
408
+ is_runtime?: boolean;
378
409
  }
379
410
  export interface BulkUpdateEnvVarsRequest {
380
411
  data: CreateEnvVarRequest[];
@@ -383,7 +414,8 @@ export interface EnvVarSummary {
383
414
  uuid: string;
384
415
  key: string;
385
416
  value: string;
386
- is_build_time: boolean;
417
+ is_buildtime: boolean;
418
+ is_runtime: boolean;
387
419
  }
388
420
  export type DatabaseType = 'postgresql' | 'mysql' | 'mariadb' | 'mongodb' | 'redis' | 'keydb' | 'clickhouse' | 'dragonfly';
389
421
  export interface DatabaseLimits {
@@ -844,7 +876,8 @@ export interface ApplicationDiagnostic {
844
876
  count: number;
845
877
  variables: Array<{
846
878
  key: string;
847
- is_build_time: boolean;
879
+ is_buildtime: boolean;
880
+ is_runtime: boolean;
848
881
  }>;
849
882
  };
850
883
  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.8.1",
4
+ "version": "2.10.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": "^16.0.0",
79
- "lint-staged": "^16.2.7",
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",