@masonator/coolify-mcp 2.5.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -106,6 +106,28 @@ The Coolify API returns extremely verbose responses - a single application can c
106
106
  | list_services | ~367KB | ~1.2KB | **99%** |
107
107
  | list_servers | ~4KB | ~0.4KB | **90%** |
108
108
  | list_application_envs | ~3KB/var | ~0.1KB/var | **97%** |
109
+ | deployment get | ~13KB | ~1KB | **92%** |
110
+
111
+ ### HATEOAS-style Response Actions
112
+
113
+ Responses include contextual `_actions` suggesting relevant next steps:
114
+
115
+ ```json
116
+ {
117
+ "data": { "uuid": "abc123", "status": "running" },
118
+ "_actions": [
119
+ { "tool": "application_logs", "args": { "uuid": "abc123" }, "hint": "View logs" },
120
+ {
121
+ "tool": "control",
122
+ "args": { "resource": "application", "action": "restart", "uuid": "abc123" },
123
+ "hint": "Restart"
124
+ }
125
+ ],
126
+ "_pagination": { "next": { "tool": "list_applications", "args": { "page": 2 } } }
127
+ }
128
+ ```
129
+
130
+ This helps AI assistants understand logical next steps without consuming extra tokens.
109
131
 
110
132
  ### Recommended Workflow
111
133
 
@@ -84,6 +84,10 @@ describe('CoolifyClient', () => {
84
84
  deployment_uuid: 'dep-123',
85
85
  application_name: 'test-app',
86
86
  status: 'finished',
87
+ force_rebuild: false,
88
+ is_webhook: false,
89
+ is_api: true,
90
+ restart_only: false,
87
91
  created_at: '2024-01-01',
88
92
  updated_at: '2024-01-01',
89
93
  };
@@ -219,18 +223,19 @@ describe('CoolifyClient', () => {
219
223
  body: JSON.stringify(createData),
220
224
  }));
221
225
  });
222
- it('should create a service with docker_compose_raw instead of type', async () => {
226
+ it('should pass through already base64-encoded docker_compose_raw', async () => {
223
227
  const responseData = {
224
228
  uuid: 'compose-uuid',
225
229
  domains: ['custom.example.com'],
226
230
  };
227
231
  mockFetch.mockResolvedValueOnce(mockResponse(responseData));
232
+ const base64Value = 'dmVyc2lvbjogIjMiCnNlcnZpY2VzOgogIGFwcDoKICAgIGltYWdlOiBuZ2lueA==';
228
233
  const createData = {
229
234
  name: 'custom-compose-service',
230
235
  project_uuid: 'project-uuid',
231
236
  environment_uuid: 'env-uuid',
232
237
  server_uuid: 'server-uuid',
233
- docker_compose_raw: 'dmVyc2lvbjogIjMiCnNlcnZpY2VzOgogIGFwcDoKICAgIGltYWdlOiBuZ2lueA==',
238
+ docker_compose_raw: base64Value,
234
239
  };
235
240
  const result = await client.createService(createData);
236
241
  expect(result).toEqual(responseData);
@@ -239,6 +244,24 @@ describe('CoolifyClient', () => {
239
244
  body: JSON.stringify(createData),
240
245
  }));
241
246
  });
247
+ it('should auto base64-encode raw YAML docker_compose_raw', async () => {
248
+ const responseData = { uuid: 'compose-uuid', domains: ['test.com'] };
249
+ mockFetch.mockResolvedValueOnce(mockResponse(responseData));
250
+ const rawYaml = 'services:\n test:\n image: nginx';
251
+ const createData = {
252
+ name: 'raw-compose',
253
+ project_uuid: 'project-uuid',
254
+ environment_uuid: 'env-uuid',
255
+ server_uuid: 'server-uuid',
256
+ docker_compose_raw: rawYaml,
257
+ };
258
+ await client.createService(createData);
259
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1]?.body);
260
+ // Should be base64-encoded in the request
261
+ expect(callBody.docker_compose_raw).toBe(Buffer.from(rawYaml, 'utf-8').toString('base64'));
262
+ // Should NOT be the raw YAML
263
+ expect(callBody.docker_compose_raw).not.toBe(rawYaml);
264
+ });
242
265
  });
243
266
  describe('deleteService', () => {
244
267
  it('should delete a service', async () => {
@@ -342,6 +365,18 @@ describe('CoolifyClient', () => {
342
365
  expect(result).toEqual({ message: 'Deployed' });
343
366
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deploy?tag=my-tag&force=true', expect.any(Object));
344
367
  });
368
+ it('should deploy by Coolify UUID (24 char alphanumeric)', async () => {
369
+ mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Deployed' }));
370
+ // Coolify-style UUID: 24 lowercase alphanumeric chars
371
+ await client.deployByTagOrUuid('xs0sgs4gog044s4k4c88kgsc', false);
372
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deploy?uuid=xs0sgs4gog044s4k4c88kgsc&force=false', expect.any(Object));
373
+ });
374
+ it('should deploy by standard UUID format', async () => {
375
+ mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Deployed' }));
376
+ // Standard UUID format with hyphens
377
+ await client.deployByTagOrUuid('a1b2c3d4-e5f6-7890-abcd-ef1234567890', true);
378
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deploy?uuid=a1b2c3d4-e5f6-7890-abcd-ef1234567890&force=true', expect.any(Object));
379
+ });
345
380
  });
346
381
  describe('private keys', () => {
347
382
  it('should list private keys', async () => {
@@ -464,6 +499,25 @@ describe('CoolifyClient', () => {
464
499
  }, false, 422));
465
500
  await expect(client.listServers()).rejects.toThrow('Validation failed. - name: The name field is required.; email: The email must be valid., The email is already taken.');
466
501
  });
502
+ it('should handle validation errors with string messages', async () => {
503
+ mockFetch.mockResolvedValueOnce(mockResponse({
504
+ message: 'Validation failed.',
505
+ errors: {
506
+ docker_compose_raw: 'The docker compose raw field is required.',
507
+ },
508
+ }, false, 422));
509
+ await expect(client.listServers()).rejects.toThrow('Validation failed. - docker_compose_raw: The docker compose raw field is required.');
510
+ });
511
+ it('should handle validation errors with mixed array and string messages', async () => {
512
+ mockFetch.mockResolvedValueOnce(mockResponse({
513
+ message: 'Validation failed.',
514
+ errors: {
515
+ name: ['The name field is required.'],
516
+ docker_compose_raw: 'The docker compose raw field is required.',
517
+ },
518
+ }, false, 422));
519
+ await expect(client.listServers()).rejects.toThrow('Validation failed. - name: The name field is required.; docker_compose_raw: The docker compose raw field is required.');
520
+ });
467
521
  });
468
522
  // =========================================================================
469
523
  // Server endpoints - additional coverage
@@ -574,6 +628,98 @@ describe('CoolifyClient', () => {
574
628
  expect(result).toEqual(mockEnvironment);
575
629
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/projects/proj-uuid/production', expect.any(Object));
576
630
  });
631
+ it('should get project environment with missing database types', async () => {
632
+ // Use environment_id to match (what real API uses)
633
+ const mockDbSummaries = [
634
+ {
635
+ uuid: 'pg-uuid',
636
+ name: 'pg-db',
637
+ type: 'postgresql',
638
+ status: 'running',
639
+ is_public: false,
640
+ environment_id: 1,
641
+ },
642
+ {
643
+ uuid: 'dragonfly-uuid',
644
+ name: 'dragonfly-cache',
645
+ type: 'standalone-dragonfly',
646
+ status: 'running',
647
+ is_public: false,
648
+ environment_id: 1,
649
+ },
650
+ {
651
+ uuid: 'other-env-db',
652
+ name: 'other-db',
653
+ type: 'standalone-keydb',
654
+ status: 'running',
655
+ is_public: false,
656
+ environment_id: 999, // different env
657
+ },
658
+ ];
659
+ mockFetch
660
+ .mockResolvedValueOnce(mockResponse(mockEnvironment))
661
+ .mockResolvedValueOnce(mockResponse(mockDbSummaries));
662
+ const result = await client.getProjectEnvironmentWithDatabases('proj-uuid', 'production');
663
+ expect(result.uuid).toBe('env-uuid');
664
+ expect(result.dragonflys).toHaveLength(1);
665
+ expect(result.dragonflys[0].uuid).toBe('dragonfly-uuid');
666
+ expect(result.keydbs).toBeUndefined(); // other-env-db is in different env
667
+ });
668
+ it('should match databases by environment_uuid fallback', async () => {
669
+ const mockDbSummaries = [
670
+ {
671
+ uuid: 'keydb-uuid',
672
+ name: 'keydb-cache',
673
+ type: 'standalone-keydb',
674
+ status: 'running',
675
+ is_public: false,
676
+ environment_uuid: 'env-uuid', // matching by uuid
677
+ },
678
+ ];
679
+ mockFetch
680
+ .mockResolvedValueOnce(mockResponse(mockEnvironment))
681
+ .mockResolvedValueOnce(mockResponse(mockDbSummaries));
682
+ const result = await client.getProjectEnvironmentWithDatabases('proj-uuid', 'production');
683
+ expect(result.keydbs).toHaveLength(1);
684
+ expect(result.keydbs[0].uuid).toBe('keydb-uuid');
685
+ });
686
+ it('should match databases by environment_name fallback', async () => {
687
+ const mockDbSummaries = [
688
+ {
689
+ uuid: 'clickhouse-uuid',
690
+ name: 'clickhouse-analytics',
691
+ type: 'standalone-clickhouse',
692
+ status: 'running',
693
+ is_public: false,
694
+ environment_name: 'production', // matching by name
695
+ },
696
+ ];
697
+ mockFetch
698
+ .mockResolvedValueOnce(mockResponse(mockEnvironment))
699
+ .mockResolvedValueOnce(mockResponse(mockDbSummaries));
700
+ const result = await client.getProjectEnvironmentWithDatabases('proj-uuid', 'production');
701
+ expect(result.clickhouses).toHaveLength(1);
702
+ expect(result.clickhouses[0].uuid).toBe('clickhouse-uuid');
703
+ });
704
+ it('should not add empty arrays when no missing DB types exist', async () => {
705
+ const mockDbSummaries = [
706
+ {
707
+ uuid: 'pg-uuid',
708
+ name: 'pg-db',
709
+ type: 'postgresql', // not a "missing" type
710
+ status: 'running',
711
+ is_public: false,
712
+ environment_id: 1,
713
+ },
714
+ ];
715
+ mockFetch
716
+ .mockResolvedValueOnce(mockResponse(mockEnvironment))
717
+ .mockResolvedValueOnce(mockResponse(mockDbSummaries));
718
+ const result = await client.getProjectEnvironmentWithDatabases('proj-uuid', 'production');
719
+ expect(result.dragonflys).toBeUndefined();
720
+ expect(result.keydbs).toBeUndefined();
721
+ expect(result.clickhouses).toBeUndefined();
722
+ });
577
723
  it('should create a project environment', async () => {
578
724
  mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-env-uuid' }));
579
725
  const result = await client.createProjectEnvironment('proj-uuid', { name: 'staging' });
@@ -687,6 +833,17 @@ describe('CoolifyClient', () => {
687
833
  expect(result).toEqual({ uuid: 'new-app-uuid' });
688
834
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/dockercompose', expect.objectContaining({ method: 'POST' }));
689
835
  });
836
+ it('should auto base64-encode docker_compose_raw in createApplicationDockerCompose', async () => {
837
+ mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-app-uuid' }));
838
+ const rawYaml = 'services:\n app:\n image: nginx';
839
+ await client.createApplicationDockerCompose({
840
+ project_uuid: 'proj-uuid',
841
+ server_uuid: 'server-uuid',
842
+ docker_compose_raw: rawYaml,
843
+ });
844
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1]?.body);
845
+ expect(callBody.docker_compose_raw).toBe(Buffer.from(rawYaml, 'utf-8').toString('base64'));
846
+ });
690
847
  /**
691
848
  * Issue #76 - Client Layer Behavior Test
692
849
  *
@@ -729,6 +886,13 @@ describe('CoolifyClient', () => {
729
886
  body: JSON.stringify({ name: 'updated-app', description: 'new desc' }),
730
887
  }));
731
888
  });
889
+ it('should auto base64-encode docker_compose_raw in updateApplication', async () => {
890
+ mockFetch.mockResolvedValueOnce(mockResponse(mockApplication));
891
+ const rawYaml = 'services:\n app:\n image: nginx';
892
+ await client.updateApplication('app-uuid', { docker_compose_raw: rawYaml });
893
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1]?.body);
894
+ expect(callBody.docker_compose_raw).toBe(Buffer.from(rawYaml, 'utf-8').toString('base64'));
895
+ });
732
896
  /**
733
897
  * Issue #76 - Client Layer Behavior Test
734
898
  *
@@ -1086,6 +1250,13 @@ describe('CoolifyClient', () => {
1086
1250
  const result = await client.updateService('test-uuid', { name: 'updated-service' });
1087
1251
  expect(result.name).toBe('updated-service');
1088
1252
  });
1253
+ it('should auto base64-encode docker_compose_raw in updateService', async () => {
1254
+ mockFetch.mockResolvedValueOnce(mockResponse(mockService));
1255
+ const rawYaml = 'services:\n app:\n image: nginx';
1256
+ await client.updateService('test-uuid', { docker_compose_raw: rawYaml });
1257
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1]?.body);
1258
+ expect(callBody.docker_compose_raw).toBe(Buffer.from(rawYaml, 'utf-8').toString('base64'));
1259
+ });
1089
1260
  it('should start a service', async () => {
1090
1261
  mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Started' }));
1091
1262
  const result = await client.startService('test-uuid');
@@ -1165,12 +1336,47 @@ describe('CoolifyClient', () => {
1165
1336
  },
1166
1337
  ]);
1167
1338
  });
1168
- it('should get a deployment', async () => {
1339
+ it('should get a deployment (essential by default, no logs)', async () => {
1169
1340
  mockFetch.mockResolvedValueOnce(mockResponse(mockDeployment));
1170
1341
  const result = await client.getDeployment('dep-uuid');
1171
- expect(result).toEqual(mockDeployment);
1342
+ // By default, returns DeploymentEssential without logs
1343
+ expect(result).toEqual({
1344
+ uuid: 'dep-uuid',
1345
+ deployment_uuid: 'dep-123',
1346
+ application_uuid: undefined,
1347
+ application_name: 'test-app',
1348
+ server_name: undefined,
1349
+ status: 'finished',
1350
+ commit: undefined,
1351
+ force_rebuild: false,
1352
+ is_webhook: false,
1353
+ is_api: true,
1354
+ created_at: '2024-01-01',
1355
+ updated_at: '2024-01-01',
1356
+ logs_available: false,
1357
+ logs_info: undefined,
1358
+ });
1172
1359
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deployments/dep-uuid', expect.any(Object));
1173
1360
  });
1361
+ it('should get a deployment with logs when includeLogs is true', async () => {
1362
+ const deploymentWithLogs = { ...mockDeployment, logs: 'Build started...' };
1363
+ mockFetch.mockResolvedValueOnce(mockResponse(deploymentWithLogs));
1364
+ const result = await client.getDeployment('dep-uuid', { includeLogs: true });
1365
+ // With includeLogs: true, returns full Deployment with logs
1366
+ expect(result).toEqual(deploymentWithLogs);
1367
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deployments/dep-uuid', expect.any(Object));
1368
+ });
1369
+ it('should include logs_info when deployment has logs but includeLogs is false', async () => {
1370
+ const deploymentWithLogs = { ...mockDeployment, logs: 'Build started...' };
1371
+ mockFetch.mockResolvedValueOnce(mockResponse(deploymentWithLogs));
1372
+ const result = await client.getDeployment('dep-uuid');
1373
+ // Should have logs_info indicating logs are available
1374
+ expect(result).toMatchObject({
1375
+ uuid: 'dep-uuid',
1376
+ logs_available: true,
1377
+ logs_info: 'Logs available (16 chars). Use lines param to retrieve.',
1378
+ });
1379
+ });
1174
1380
  it('should list application deployments', async () => {
1175
1381
  mockFetch.mockResolvedValueOnce(mockResponse([mockDeployment]));
1176
1382
  const result = await client.listApplicationDeployments('app-uuid');
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Smoke integration tests — quick sanity checks against a real Coolify instance.
3
+ *
4
+ * Run with: npm run test:integration
5
+ *
6
+ * Prerequisites:
7
+ * - COOLIFY_URL and COOLIFY_TOKEN environment variables set (from .env)
8
+ * - Access to a running Coolify instance
9
+ *
10
+ * NOTE: These tests make real API calls. The error handling tests rely on the
11
+ * API rejecting invalid input (nonexistent project_uuid). If Coolify changes
12
+ * its validation behaviour, these tests may need updating.
13
+ */
14
+ export {};
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Smoke integration tests — quick sanity checks against a real Coolify instance.
3
+ *
4
+ * Run with: npm run test:integration
5
+ *
6
+ * Prerequisites:
7
+ * - COOLIFY_URL and COOLIFY_TOKEN environment variables set (from .env)
8
+ * - Access to a running Coolify instance
9
+ *
10
+ * NOTE: These tests make real API calls. The error handling tests rely on the
11
+ * API rejecting invalid input (nonexistent project_uuid). If Coolify changes
12
+ * its validation behaviour, these tests may need updating.
13
+ */
14
+ import { config } from 'dotenv';
15
+ import { CoolifyClient } from '../../lib/coolify-client.js';
16
+ config();
17
+ const COOLIFY_URL = process.env.COOLIFY_URL;
18
+ const COOLIFY_TOKEN = process.env.COOLIFY_TOKEN;
19
+ const shouldRun = COOLIFY_URL && COOLIFY_TOKEN;
20
+ const describeFn = shouldRun ? describe : describe.skip;
21
+ describeFn('Smoke Integration Tests', () => {
22
+ let client;
23
+ beforeAll(() => {
24
+ if (!COOLIFY_URL || !COOLIFY_TOKEN) {
25
+ throw new Error('COOLIFY_URL and COOLIFY_TOKEN must be set');
26
+ }
27
+ client = new CoolifyClient({
28
+ baseUrl: COOLIFY_URL,
29
+ accessToken: COOLIFY_TOKEN,
30
+ });
31
+ });
32
+ describe('connectivity', () => {
33
+ it('should connect to Coolify API', async () => {
34
+ const version = await client.getVersion();
35
+ expect(version).toBeDefined();
36
+ }, 10000);
37
+ });
38
+ describe('error handling', () => {
39
+ it('should handle validation errors with string messages (issue #107)', async () => {
40
+ // Creating a service with docker_compose_raw but no type triggers
41
+ // a validation error where Coolify returns string values, not arrays.
42
+ const servers = await client.listServers();
43
+ expect(servers.length).toBeGreaterThan(0);
44
+ await expect(client.createService({
45
+ server_uuid: servers[0].uuid,
46
+ project_uuid: 'nonexistent',
47
+ environment_name: 'production',
48
+ docker_compose_raw: 'services:\n test:\n image: nginx',
49
+ })).rejects.toThrow(/./); // Should throw a readable error, not crash
50
+ }, 15000);
51
+ it('should produce a readable error message from string validation errors', async () => {
52
+ const servers = await client.listServers();
53
+ try {
54
+ await client.createService({
55
+ server_uuid: servers[0].uuid,
56
+ project_uuid: 'nonexistent',
57
+ environment_name: 'production',
58
+ docker_compose_raw: 'services:\n test:\n image: nginx',
59
+ });
60
+ throw new Error('Expected a validation error to be thrown');
61
+ }
62
+ catch (e) {
63
+ const msg = e.message;
64
+ // Should NOT contain "join is not a function"
65
+ expect(msg).not.toContain('join is not a function');
66
+ // Should contain something useful
67
+ expect(msg.length).toBeGreaterThan(0);
68
+ }
69
+ }, 15000);
70
+ });
71
+ });
@@ -5,8 +5,9 @@
5
5
  * CoolifyClient methods are fully tested in coolify-client.test.ts (174 tests).
6
6
  * These tests verify MCP server instantiation and structure.
7
7
  */
8
+ import { createRequire } from 'module';
8
9
  import { describe, it, expect, beforeEach } from '@jest/globals';
9
- import { CoolifyMcpServer, truncateLogs } from '../lib/mcp-server.js';
10
+ import { CoolifyMcpServer, VERSION, truncateLogs, getApplicationActions, getDeploymentActions, getPagination, } from '../lib/mcp-server.js';
10
11
  describe('CoolifyMcpServer v2', () => {
11
12
  let server;
12
13
  beforeEach(() => {
@@ -22,6 +23,11 @@ describe('CoolifyMcpServer v2', () => {
22
23
  it('should be an MCP server with connect method', () => {
23
24
  expect(typeof server.connect).toBe('function');
24
25
  });
26
+ it('should report version matching package.json', () => {
27
+ const _require = createRequire(import.meta.url);
28
+ const { version } = _require('../../package.json');
29
+ expect(VERSION).toBe(version);
30
+ });
25
31
  });
26
32
  describe('client', () => {
27
33
  it('should have client instance', () => {
@@ -47,6 +53,7 @@ describe('CoolifyMcpServer v2', () => {
47
53
  // Environment operations
48
54
  expect(typeof client.listProjectEnvironments).toBe('function');
49
55
  expect(typeof client.getProjectEnvironment).toBe('function');
56
+ expect(typeof client.getProjectEnvironmentWithDatabases).toBe('function');
50
57
  expect(typeof client.createProjectEnvironment).toBe('function');
51
58
  expect(typeof client.deleteProjectEnvironment).toBe('function');
52
59
  // Application operations
@@ -186,3 +193,124 @@ describe('truncateLogs', () => {
186
193
  expect(result.length).toBe(100);
187
194
  });
188
195
  });
196
+ // =============================================================================
197
+ // Action Generators Tests
198
+ // =============================================================================
199
+ describe('getApplicationActions', () => {
200
+ it('should return view logs action for all apps', () => {
201
+ const actions = getApplicationActions('app-uuid', 'stopped');
202
+ expect(actions).toContainEqual({
203
+ tool: 'application_logs',
204
+ args: { uuid: 'app-uuid' },
205
+ hint: 'View logs',
206
+ });
207
+ });
208
+ it('should return restart/stop actions for running apps', () => {
209
+ const actions = getApplicationActions('app-uuid', 'running');
210
+ expect(actions).toContainEqual({
211
+ tool: 'control',
212
+ args: { resource: 'application', action: 'restart', uuid: 'app-uuid' },
213
+ hint: 'Restart',
214
+ });
215
+ expect(actions).toContainEqual({
216
+ tool: 'control',
217
+ args: { resource: 'application', action: 'stop', uuid: 'app-uuid' },
218
+ hint: 'Stop',
219
+ });
220
+ });
221
+ it('should return start action for stopped apps', () => {
222
+ const actions = getApplicationActions('app-uuid', 'stopped');
223
+ expect(actions).toContainEqual({
224
+ tool: 'control',
225
+ args: { resource: 'application', action: 'start', uuid: 'app-uuid' },
226
+ hint: 'Start',
227
+ });
228
+ });
229
+ it('should handle running:healthy status', () => {
230
+ const actions = getApplicationActions('app-uuid', 'running:healthy');
231
+ expect(actions.some((a) => a.hint === 'Restart')).toBe(true);
232
+ expect(actions.some((a) => a.hint === 'Stop')).toBe(true);
233
+ });
234
+ it('should handle undefined status', () => {
235
+ const actions = getApplicationActions('app-uuid', undefined);
236
+ expect(actions).toContainEqual({
237
+ tool: 'control',
238
+ args: { resource: 'application', action: 'start', uuid: 'app-uuid' },
239
+ hint: 'Start',
240
+ });
241
+ });
242
+ });
243
+ describe('getDeploymentActions', () => {
244
+ it('should return cancel action for in_progress deployments', () => {
245
+ const actions = getDeploymentActions('dep-uuid', 'in_progress', 'app-uuid');
246
+ expect(actions).toContainEqual({
247
+ tool: 'deployment',
248
+ args: { action: 'cancel', uuid: 'dep-uuid' },
249
+ hint: 'Cancel',
250
+ });
251
+ });
252
+ it('should return cancel action for queued deployments', () => {
253
+ const actions = getDeploymentActions('dep-uuid', 'queued', 'app-uuid');
254
+ expect(actions).toContainEqual({
255
+ tool: 'deployment',
256
+ args: { action: 'cancel', uuid: 'dep-uuid' },
257
+ hint: 'Cancel',
258
+ });
259
+ });
260
+ it('should return app actions when appUuid provided', () => {
261
+ const actions = getDeploymentActions('dep-uuid', 'finished', 'app-uuid');
262
+ expect(actions).toContainEqual({
263
+ tool: 'get_application',
264
+ args: { uuid: 'app-uuid' },
265
+ hint: 'View app',
266
+ });
267
+ expect(actions).toContainEqual({
268
+ tool: 'application_logs',
269
+ args: { uuid: 'app-uuid' },
270
+ hint: 'App logs',
271
+ });
272
+ });
273
+ it('should not return cancel for finished deployments', () => {
274
+ const actions = getDeploymentActions('dep-uuid', 'finished', 'app-uuid');
275
+ expect(actions.some((a) => a.hint === 'Cancel')).toBe(false);
276
+ });
277
+ it('should return empty actions when no appUuid and not in_progress', () => {
278
+ const actions = getDeploymentActions('dep-uuid', 'finished', undefined);
279
+ expect(actions).toEqual([]);
280
+ });
281
+ });
282
+ describe('getPagination', () => {
283
+ it('should return undefined when count is less than perPage and page is 1', () => {
284
+ const result = getPagination('list_apps', 1, 50, 30);
285
+ expect(result).toBeUndefined();
286
+ });
287
+ it('should return next when count equals or exceeds perPage', () => {
288
+ const result = getPagination('list_apps', 1, 50, 50);
289
+ expect(result).toEqual({
290
+ next: { tool: 'list_apps', args: { page: 2, per_page: 50 } },
291
+ });
292
+ });
293
+ it('should return both prev and next for middle pages', () => {
294
+ const result = getPagination('list_apps', 2, 50, 50);
295
+ expect(result).toEqual({
296
+ prev: { tool: 'list_apps', args: { page: 1, per_page: 50 } },
297
+ next: { tool: 'list_apps', args: { page: 3, per_page: 50 } },
298
+ });
299
+ });
300
+ it('should return prev when page > 1 and count < perPage', () => {
301
+ const result = getPagination('list_apps', 3, 50, 20);
302
+ expect(result).toEqual({
303
+ prev: { tool: 'list_apps', args: { page: 2, per_page: 50 } },
304
+ });
305
+ });
306
+ it('should use default page and perPage when undefined', () => {
307
+ const result = getPagination('list_apps', undefined, undefined, 100);
308
+ expect(result).toEqual({
309
+ next: { tool: 'list_apps', args: { page: 2, per_page: 50 } },
310
+ });
311
+ });
312
+ it('should return undefined when count is undefined', () => {
313
+ const result = getPagination('list_apps', 1, 50, undefined);
314
+ expect(result).toBeUndefined();
315
+ });
316
+ });
@@ -2,7 +2,7 @@
2
2
  * Coolify API Client
3
3
  * Complete HTTP client for the Coolify API v1
4
4
  */
5
- import type { CoolifyConfig, DeleteOptions, MessageResponse, UuidResponse, Server, ServerResource, ServerDomain, ServerValidation, CreateServerRequest, UpdateServerRequest, Project, CreateProjectRequest, UpdateProjectRequest, Environment, CreateEnvironmentRequest, Application, CreateApplicationPublicRequest, CreateApplicationPrivateGHRequest, CreateApplicationPrivateKeyRequest, CreateApplicationDockerfileRequest, CreateApplicationDockerImageRequest, CreateApplicationDockerComposeRequest, UpdateApplicationRequest, ApplicationActionResponse, EnvironmentVariable, EnvVarSummary, CreateEnvVarRequest, UpdateEnvVarRequest, BulkUpdateEnvVarsRequest, Database, UpdateDatabaseRequest, CreatePostgresqlRequest, CreateMysqlRequest, CreateMariadbRequest, CreateMongodbRequest, CreateRedisRequest, CreateKeydbRequest, CreateClickhouseRequest, CreateDragonflyRequest, CreateDatabaseResponse, DatabaseBackup, BackupExecution, CreateDatabaseBackupRequest, UpdateDatabaseBackupRequest, Service, CreateServiceRequest, UpdateServiceRequest, ServiceCreateResponse, Deployment, Team, TeamMember, PrivateKey, CreatePrivateKeyRequest, UpdatePrivateKeyRequest, GitHubApp, CreateGitHubAppRequest, UpdateGitHubAppRequest, GitHubAppUpdateResponse, CloudToken, CreateCloudTokenRequest, UpdateCloudTokenRequest, CloudTokenValidation, Version, ApplicationDiagnostic, ServerDiagnostic, InfrastructureIssuesReport, BatchOperationResult } from '../types/coolify.js';
5
+ import type { CoolifyConfig, DeleteOptions, MessageResponse, UuidResponse, Server, ServerResource, ServerDomain, ServerValidation, CreateServerRequest, UpdateServerRequest, Project, CreateProjectRequest, UpdateProjectRequest, Environment, CreateEnvironmentRequest, Application, CreateApplicationPublicRequest, CreateApplicationPrivateGHRequest, CreateApplicationPrivateKeyRequest, CreateApplicationDockerfileRequest, CreateApplicationDockerImageRequest, CreateApplicationDockerComposeRequest, UpdateApplicationRequest, ApplicationActionResponse, EnvironmentVariable, EnvVarSummary, CreateEnvVarRequest, UpdateEnvVarRequest, BulkUpdateEnvVarsRequest, Database, UpdateDatabaseRequest, CreatePostgresqlRequest, CreateMysqlRequest, CreateMariadbRequest, CreateMongodbRequest, CreateRedisRequest, CreateKeydbRequest, CreateClickhouseRequest, CreateDragonflyRequest, CreateDatabaseResponse, DatabaseBackup, BackupExecution, CreateDatabaseBackupRequest, UpdateDatabaseBackupRequest, Service, CreateServiceRequest, UpdateServiceRequest, ServiceCreateResponse, Deployment, DeploymentEssential, Team, TeamMember, PrivateKey, CreatePrivateKeyRequest, UpdatePrivateKeyRequest, GitHubApp, CreateGitHubAppRequest, UpdateGitHubAppRequest, GitHubAppUpdateResponse, CloudToken, CreateCloudTokenRequest, UpdateCloudTokenRequest, CloudTokenValidation, Version, ApplicationDiagnostic, ServerDiagnostic, InfrastructureIssuesReport, BatchOperationResult } from '../types/coolify.js';
6
6
  export interface ListOptions {
7
7
  page?: number;
8
8
  per_page?: number;
@@ -35,6 +35,9 @@ export interface DatabaseSummary {
35
35
  type: string;
36
36
  status: string;
37
37
  is_public: boolean;
38
+ environment_uuid?: string;
39
+ environment_name?: string;
40
+ environment_id?: number;
38
41
  }
39
42
  export interface ServiceSummary {
40
43
  uuid: string;
@@ -89,6 +92,17 @@ export declare class CoolifyClient {
89
92
  deleteProject(uuid: string): Promise<MessageResponse>;
90
93
  listProjectEnvironments(projectUuid: string): Promise<Environment[]>;
91
94
  getProjectEnvironment(projectUuid: string, environmentNameOrUuid: string): Promise<Environment>;
95
+ /**
96
+ * Get environment with missing database types (dragonfly, keydb, clickhouse).
97
+ * Coolify API omits these from the environment endpoint - we cross-reference
98
+ * with listDatabases using lightweight summaries.
99
+ * @see https://github.com/StuMason/coolify-mcp/issues/88
100
+ */
101
+ getProjectEnvironmentWithDatabases(projectUuid: string, environmentNameOrUuid: string): Promise<Environment & {
102
+ dragonflys?: DatabaseSummary[];
103
+ keydbs?: DatabaseSummary[];
104
+ clickhouses?: DatabaseSummary[];
105
+ }>;
92
106
  createProjectEnvironment(projectUuid: string, data: CreateEnvironmentRequest): Promise<UuidResponse>;
93
107
  deleteProjectEnvironment(projectUuid: string, environmentNameOrUuid: string): Promise<MessageResponse>;
94
108
  listApplications(options?: ListOptions): Promise<Application[] | ApplicationSummary[]>;
@@ -143,7 +157,9 @@ export declare class CoolifyClient {
143
157
  updateServiceEnvVar(uuid: string, data: UpdateEnvVarRequest): Promise<MessageResponse>;
144
158
  deleteServiceEnvVar(uuid: string, envUuid: string): Promise<MessageResponse>;
145
159
  listDeployments(options?: ListOptions): Promise<Deployment[] | DeploymentSummary[]>;
146
- getDeployment(uuid: string): Promise<Deployment>;
160
+ getDeployment(uuid: string, options?: {
161
+ includeLogs?: boolean;
162
+ }): Promise<Deployment | DeploymentEssential>;
147
163
  deployByTagOrUuid(tagOrUuid: string, force?: boolean): Promise<MessageResponse>;
148
164
  listApplicationDeployments(appUuid: string): Promise<Deployment[]>;
149
165
  listTeams(): Promise<Team[]>;
@@ -15,6 +15,19 @@ function cleanRequestData(data) {
15
15
  }
16
16
  return cleaned;
17
17
  }
18
+ /** Base64-encode a string, passing through values that are already base64. */
19
+ function toBase64(value) {
20
+ try {
21
+ const decoded = Buffer.from(value, 'base64').toString('utf-8');
22
+ if (Buffer.from(decoded, 'utf-8').toString('base64') === value) {
23
+ return value; // Already valid base64
24
+ }
25
+ }
26
+ catch {
27
+ // Not base64, encode it
28
+ }
29
+ return Buffer.from(value, 'utf-8').toString('base64');
30
+ }
18
31
  // =============================================================================
19
32
  // Summary Transformers - reduce full objects to essential fields
20
33
  // =============================================================================
@@ -38,12 +51,17 @@ function toApplicationSummary(app) {
38
51
  };
39
52
  }
40
53
  function toDatabaseSummary(db) {
54
+ // API returns database_type not type, and environment_id not environment_uuid
55
+ const raw = db;
41
56
  return {
42
57
  uuid: db.uuid,
43
58
  name: db.name,
44
- type: db.type,
59
+ type: db.type || raw.database_type,
45
60
  status: db.status,
46
61
  is_public: db.is_public,
62
+ environment_uuid: db.environment_uuid,
63
+ environment_name: db.environment_name,
64
+ environment_id: raw.environment_id,
47
65
  };
48
66
  }
49
67
  function toServiceSummary(svc) {
@@ -64,6 +82,26 @@ function toDeploymentSummary(dep) {
64
82
  created_at: dep.created_at,
65
83
  };
66
84
  }
85
+ function toDeploymentEssential(dep) {
86
+ return {
87
+ uuid: dep.uuid,
88
+ deployment_uuid: dep.deployment_uuid,
89
+ application_uuid: dep.application_uuid,
90
+ application_name: dep.application_name,
91
+ server_name: dep.server_name,
92
+ status: dep.status,
93
+ commit: dep.commit,
94
+ force_rebuild: dep.force_rebuild,
95
+ is_webhook: dep.is_webhook,
96
+ is_api: dep.is_api,
97
+ created_at: dep.created_at,
98
+ updated_at: dep.updated_at,
99
+ logs_available: !!dep.logs,
100
+ logs_info: dep.logs
101
+ ? `Logs available (${dep.logs.length} chars). Use lines param to retrieve.`
102
+ : undefined,
103
+ };
104
+ }
67
105
  function toProjectSummary(proj) {
68
106
  return {
69
107
  uuid: proj.uuid,
@@ -126,7 +164,7 @@ export class CoolifyClient {
126
164
  let errorMessage = error.message || `HTTP ${response.status}: ${response.statusText}`;
127
165
  if (error.errors && Object.keys(error.errors).length > 0) {
128
166
  const validationDetails = Object.entries(error.errors)
129
- .map(([field, messages]) => `${field}: ${messages.join(', ')}`)
167
+ .map(([field, messages]) => `${field}: ${Array.isArray(messages) ? messages.join(', ') : String(messages)}`)
130
168
  .join('; ');
131
169
  errorMessage = `${errorMessage} - ${validationDetails}`;
132
170
  }
@@ -256,6 +294,32 @@ export class CoolifyClient {
256
294
  async getProjectEnvironment(projectUuid, environmentNameOrUuid) {
257
295
  return this.request(`/projects/${projectUuid}/${environmentNameOrUuid}`);
258
296
  }
297
+ /**
298
+ * Get environment with missing database types (dragonfly, keydb, clickhouse).
299
+ * Coolify API omits these from the environment endpoint - we cross-reference
300
+ * with listDatabases using lightweight summaries.
301
+ * @see https://github.com/StuMason/coolify-mcp/issues/88
302
+ */
303
+ async getProjectEnvironmentWithDatabases(projectUuid, environmentNameOrUuid) {
304
+ const [environment, dbSummaries] = await Promise.all([
305
+ this.getProjectEnvironment(projectUuid, environmentNameOrUuid),
306
+ this.listDatabases({ summary: true }),
307
+ ]);
308
+ // Filter for this environment's missing database types
309
+ // API uses environment_id, not environment_uuid
310
+ const envDbs = dbSummaries.filter((db) => db.environment_id === environment.id ||
311
+ db.environment_uuid === environment.uuid ||
312
+ db.environment_name === environment.name);
313
+ const dragonflys = envDbs.filter((db) => db.type?.includes('dragonfly'));
314
+ const keydbs = envDbs.filter((db) => db.type?.includes('keydb'));
315
+ const clickhouses = envDbs.filter((db) => db.type?.includes('clickhouse'));
316
+ return {
317
+ ...environment,
318
+ ...(dragonflys.length > 0 && { dragonflys }),
319
+ ...(keydbs.length > 0 && { keydbs }),
320
+ ...(clickhouses.length > 0 && { clickhouses }),
321
+ };
322
+ }
259
323
  async createProjectEnvironment(projectUuid, data) {
260
324
  return this.request(`/projects/${projectUuid}/environments`, {
261
325
  method: 'POST',
@@ -312,15 +376,23 @@ export class CoolifyClient {
312
376
  });
313
377
  }
314
378
  async createApplicationDockerCompose(data) {
379
+ const payload = { ...data };
380
+ if (payload.docker_compose_raw) {
381
+ payload.docker_compose_raw = toBase64(payload.docker_compose_raw);
382
+ }
315
383
  return this.request('/applications/dockercompose', {
316
384
  method: 'POST',
317
- body: JSON.stringify(data),
385
+ body: JSON.stringify(payload),
318
386
  });
319
387
  }
320
388
  async updateApplication(uuid, data) {
389
+ const payload = { ...data };
390
+ if (payload.docker_compose_raw) {
391
+ payload.docker_compose_raw = toBase64(payload.docker_compose_raw);
392
+ }
321
393
  return this.request(`/applications/${uuid}`, {
322
394
  method: 'PATCH',
323
- body: JSON.stringify(data),
395
+ body: JSON.stringify(payload),
324
396
  });
325
397
  }
326
398
  async deleteApplication(uuid, options) {
@@ -496,15 +568,23 @@ export class CoolifyClient {
496
568
  return this.request(`/services/${uuid}`);
497
569
  }
498
570
  async createService(data) {
571
+ const payload = { ...data };
572
+ if (payload.docker_compose_raw) {
573
+ payload.docker_compose_raw = toBase64(payload.docker_compose_raw);
574
+ }
499
575
  return this.request('/services', {
500
576
  method: 'POST',
501
- body: JSON.stringify(data),
577
+ body: JSON.stringify(payload),
502
578
  });
503
579
  }
504
580
  async updateService(uuid, data) {
581
+ const payload = { ...data };
582
+ if (payload.docker_compose_raw) {
583
+ payload.docker_compose_raw = toBase64(payload.docker_compose_raw);
584
+ }
505
585
  return this.request(`/services/${uuid}`, {
506
586
  method: 'PATCH',
507
- body: JSON.stringify(data),
587
+ body: JSON.stringify(payload),
508
588
  });
509
589
  }
510
590
  async deleteService(uuid, options) {
@@ -569,11 +649,14 @@ export class CoolifyClient {
569
649
  ? deployments.map(toDeploymentSummary)
570
650
  : deployments;
571
651
  }
572
- async getDeployment(uuid) {
573
- return this.request(`/deployments/${uuid}`);
652
+ async getDeployment(uuid, options) {
653
+ const deployment = await this.request(`/deployments/${uuid}`);
654
+ return options?.includeLogs ? deployment : toDeploymentEssential(deployment);
574
655
  }
575
656
  async deployByTagOrUuid(tagOrUuid, force = false) {
576
- return this.request(`/deploy?tag=${encodeURIComponent(tagOrUuid)}&force=${force}`, { method: 'GET' });
657
+ // Detect if the value looks like a UUID or a tag name
658
+ const param = this.isLikelyUuid(tagOrUuid) ? 'uuid' : 'tag';
659
+ return this.request(`/deploy?${param}=${encodeURIComponent(tagOrUuid)}&force=${force}`, { method: 'GET' });
577
660
  }
578
661
  async listApplicationDeployments(appUuid) {
579
662
  return this.request(`/applications/${appUuid}/deployments`);
@@ -1,15 +1,22 @@
1
1
  /**
2
- * Coolify MCP Server v2.4.0
2
+ * Coolify MCP Server
3
3
  * Consolidated tools for efficient token usage
4
4
  */
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
7
- import type { CoolifyConfig } from '../types/coolify.js';
7
+ import type { CoolifyConfig, ResponseAction, ResponsePagination } from '../types/coolify.js';
8
+ export declare const VERSION: string;
8
9
  /**
9
10
  * Truncate logs by line count and character count.
10
11
  * Exported for testing.
11
12
  */
12
13
  export declare function truncateLogs(logs: string, lineLimit?: number, charLimit?: number): string;
14
+ /** Generate contextual actions for an application based on its status */
15
+ export declare function getApplicationActions(uuid: string, status?: string): ResponseAction[];
16
+ /** Generate contextual actions for a deployment */
17
+ export declare function getDeploymentActions(uuid: string, status: string, appUuid?: string): ResponseAction[];
18
+ /** Generate pagination info for list endpoints */
19
+ export declare function getPagination(tool: string, page?: number, perPage?: number, count?: number): ResponsePagination | undefined;
13
20
  export declare class CoolifyMcpServer extends McpServer {
14
21
  private readonly client;
15
22
  constructor(config: CoolifyConfig);
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Coolify MCP Server v2.4.0
2
+ * Coolify MCP Server
3
3
  * Consolidated tools for efficient token usage
4
4
  */
5
+ import { createRequire } from 'module';
5
6
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
7
  import { z } from 'zod';
7
8
  import { CoolifyClient, } from './coolify-client.js';
8
- const VERSION = '2.5.0';
9
+ const _require = createRequire(import.meta.url);
10
+ export const VERSION = _require('../../package.json').version;
9
11
  /** Wrap handler with error handling */
10
12
  function wrap(fn) {
11
13
  return fn()
@@ -39,6 +41,82 @@ export function truncateLogs(logs, lineLimit = 200, charLimit = 50000) {
39
41
  }
40
42
  return truncatedLogs;
41
43
  }
44
+ // =============================================================================
45
+ // Action Generators for HATEOAS-style responses
46
+ // =============================================================================
47
+ /** Generate contextual actions for an application based on its status */
48
+ export function getApplicationActions(uuid, status) {
49
+ const actions = [
50
+ { tool: 'application_logs', args: { uuid }, hint: 'View logs' },
51
+ ];
52
+ const s = (status || '').toLowerCase();
53
+ if (s.includes('running')) {
54
+ actions.push({
55
+ tool: 'control',
56
+ args: { resource: 'application', action: 'restart', uuid },
57
+ hint: 'Restart',
58
+ });
59
+ actions.push({
60
+ tool: 'control',
61
+ args: { resource: 'application', action: 'stop', uuid },
62
+ hint: 'Stop',
63
+ });
64
+ }
65
+ else {
66
+ actions.push({
67
+ tool: 'control',
68
+ args: { resource: 'application', action: 'start', uuid },
69
+ hint: 'Start',
70
+ });
71
+ }
72
+ return actions;
73
+ }
74
+ /** Generate contextual actions for a deployment */
75
+ export function getDeploymentActions(uuid, status, appUuid) {
76
+ const actions = [];
77
+ if (status === 'in_progress' || status === 'queued') {
78
+ actions.push({ tool: 'deployment', args: { action: 'cancel', uuid }, hint: 'Cancel' });
79
+ }
80
+ if (appUuid) {
81
+ actions.push({ tool: 'get_application', args: { uuid: appUuid }, hint: 'View app' });
82
+ actions.push({ tool: 'application_logs', args: { uuid: appUuid }, hint: 'App logs' });
83
+ }
84
+ return actions;
85
+ }
86
+ /** Generate pagination info for list endpoints */
87
+ export function getPagination(tool, page, perPage, count) {
88
+ const p = page ?? 1;
89
+ const pp = perPage ?? 50;
90
+ if (!count || count < pp) {
91
+ return p > 1 ? { prev: { tool, args: { page: p - 1, per_page: pp } } } : undefined;
92
+ }
93
+ return {
94
+ ...(p > 1 && { prev: { tool, args: { page: p - 1, per_page: pp } } }),
95
+ next: { tool, args: { page: p + 1, per_page: pp } },
96
+ };
97
+ }
98
+ /** Wrap handler with error handling and HATEOAS actions */
99
+ function wrapWithActions(fn, getActions, getPaginationFn) {
100
+ return fn()
101
+ .then((result) => {
102
+ const actions = getActions?.(result) ?? [];
103
+ const pagination = getPaginationFn?.(result);
104
+ const response = { data: result };
105
+ if (actions.length > 0)
106
+ response._actions = actions;
107
+ if (pagination)
108
+ response._pagination = pagination;
109
+ return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] };
110
+ })
111
+ .catch((error) => ({
112
+ content: [
113
+ {
114
+ type: 'text',
115
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
116
+ },
117
+ ],
118
+ }));
119
+ }
42
120
  export class CoolifyMcpServer extends McpServer {
43
121
  constructor(config) {
44
122
  super({ name: 'coolify', version: VERSION });
@@ -150,7 +228,7 @@ export class CoolifyMcpServer extends McpServer {
150
228
  // =========================================================================
151
229
  // Environments (1 tool - consolidated CRUD)
152
230
  // =========================================================================
153
- this.tool('environments', 'Manage environments: list/get/create/delete', {
231
+ this.tool('environments', 'Manage environments: list/get/create/delete (get includes dragonfly/keydb/clickhouse DBs missing from API)', {
154
232
  action: z.enum(['list', 'get', 'create', 'delete']),
155
233
  project_uuid: z.string(),
156
234
  name: z.string().optional(),
@@ -162,7 +240,8 @@ export class CoolifyMcpServer extends McpServer {
162
240
  case 'get':
163
241
  if (!name)
164
242
  return { content: [{ type: 'text', text: 'Error: name required' }] };
165
- return wrap(() => this.client.getProjectEnvironment(project_uuid, name));
243
+ // Use enhanced method that includes missing DB types (#88)
244
+ return wrap(() => this.client.getProjectEnvironmentWithDatabases(project_uuid, name));
166
245
  case 'create':
167
246
  if (!name)
168
247
  return { content: [{ type: 'text', text: 'Error: name required' }] };
@@ -176,8 +255,8 @@ export class CoolifyMcpServer extends McpServer {
176
255
  // =========================================================================
177
256
  // Applications (4 tools)
178
257
  // =========================================================================
179
- this.tool('list_applications', 'List apps (summary)', { page: z.number().optional(), per_page: z.number().optional() }, async ({ page, per_page }) => wrap(() => this.client.listApplications({ page, per_page, summary: true })));
180
- this.tool('get_application', 'App details', { uuid: z.string() }, async ({ uuid }) => wrap(() => this.client.getApplication(uuid)));
258
+ this.tool('list_applications', 'List apps (summary)', { page: z.number().optional(), per_page: z.number().optional() }, async ({ page, per_page }) => wrapWithActions(() => this.client.listApplications({ page, per_page, summary: true }), undefined, (result) => getPagination('list_applications', page, per_page, result.length)));
259
+ this.tool('get_application', 'App details', { uuid: z.string() }, async ({ uuid }) => wrapWithActions(() => this.client.getApplication(uuid), (app) => getApplicationActions(app.uuid, app.status)));
181
260
  this.tool('application', 'Manage app: create/update/delete', {
182
261
  action: z.enum([
183
262
  'create_public',
@@ -443,7 +522,10 @@ export class CoolifyMcpServer extends McpServer {
443
522
  name: z.string().optional(),
444
523
  description: z.string().optional(),
445
524
  instant_deploy: z.boolean().optional(),
446
- docker_compose_raw: z.string().optional(),
525
+ docker_compose_raw: z
526
+ .string()
527
+ .optional()
528
+ .describe('Raw docker-compose YAML for custom services (auto base64-encoded)'),
447
529
  delete_volumes: z.boolean().optional(),
448
530
  }, async (args) => {
449
531
  const { action, uuid, delete_volumes } = args;
@@ -504,7 +586,36 @@ export class CoolifyMcpServer extends McpServer {
504
586
  restart: (u) => this.client.restartService(u),
505
587
  },
506
588
  };
507
- return wrap(() => methods[resource][action](uuid));
589
+ // Generate contextual actions based on resource type and action taken
590
+ const getControlActions = () => {
591
+ const actions = [];
592
+ if (resource === 'application') {
593
+ actions.push({ tool: 'application_logs', args: { uuid }, hint: 'View logs' });
594
+ actions.push({ tool: 'get_application', args: { uuid }, hint: 'Check status' });
595
+ if (action === 'start' || action === 'restart') {
596
+ actions.push({
597
+ tool: 'control',
598
+ args: { resource: 'application', action: 'stop', uuid },
599
+ hint: 'Stop',
600
+ });
601
+ }
602
+ else {
603
+ actions.push({
604
+ tool: 'control',
605
+ args: { resource: 'application', action: 'start', uuid },
606
+ hint: 'Start',
607
+ });
608
+ }
609
+ }
610
+ else if (resource === 'database') {
611
+ actions.push({ tool: 'get_database', args: { uuid }, hint: 'Check status' });
612
+ }
613
+ else if (resource === 'service') {
614
+ actions.push({ tool: 'get_service', args: { uuid }, hint: 'Check status' });
615
+ }
616
+ return actions;
617
+ };
618
+ return wrapWithActions(() => methods[resource][action](uuid), getControlActions);
508
619
  });
509
620
  // =========================================================================
510
621
  // Environment Variables (1 tool - consolidated)
@@ -516,8 +627,7 @@ export class CoolifyMcpServer extends McpServer {
516
627
  key: z.string().optional(),
517
628
  value: z.string().optional(),
518
629
  env_uuid: z.string().optional(),
519
- is_build_time: z.boolean().optional(),
520
- }, async ({ resource, action, uuid, key, value, env_uuid, is_build_time }) => {
630
+ }, async ({ resource, action, uuid, key, value, env_uuid }) => {
521
631
  if (resource === 'application') {
522
632
  switch (action) {
523
633
  case 'list':
@@ -525,7 +635,8 @@ export class CoolifyMcpServer extends McpServer {
525
635
  case 'create':
526
636
  if (!key || !value)
527
637
  return { content: [{ type: 'text', text: 'Error: key, value required' }] };
528
- return wrap(() => this.client.createApplicationEnvVar(uuid, { key, value, is_build_time }));
638
+ // Note: is_build_time is not passed - Coolify API rejects it for create action
639
+ return wrap(() => this.client.createApplicationEnvVar(uuid, { key, value }));
529
640
  case 'update':
530
641
  if (!key || !value)
531
642
  return { content: [{ type: 'text', text: 'Error: key, value required' }] };
@@ -560,23 +671,30 @@ export class CoolifyMcpServer extends McpServer {
560
671
  // =========================================================================
561
672
  // Deployments (3 tools)
562
673
  // =========================================================================
563
- this.tool('list_deployments', 'List deployments (summary)', { page: z.number().optional(), per_page: z.number().optional() }, async ({ page, per_page }) => wrap(() => this.client.listDeployments({ page, per_page, summary: true })));
564
- this.tool('deploy', 'Deploy by tag/UUID', { tag_or_uuid: z.string(), force: z.boolean().optional() }, async ({ tag_or_uuid, force }) => wrap(() => this.client.deployByTagOrUuid(tag_or_uuid, force)));
565
- this.tool('deployment', 'Manage deployment: get/cancel/list_for_app (logs truncated to last 200 lines/50K chars by default)', {
674
+ this.tool('list_deployments', 'List deployments (summary)', { page: z.number().optional(), per_page: z.number().optional() }, async ({ page, per_page }) => wrapWithActions(() => this.client.listDeployments({ page, per_page, summary: true }), undefined, (result) => getPagination('list_deployments', page, per_page, result.length)));
675
+ this.tool('deploy', 'Deploy by tag/UUID', { tag_or_uuid: z.string(), force: z.boolean().optional() }, async ({ tag_or_uuid, force }) => wrapWithActions(() => this.client.deployByTagOrUuid(tag_or_uuid, force), () => [{ tool: 'list_deployments', args: {}, hint: 'Check deployment status' }]));
676
+ this.tool('deployment', 'Manage deployment: get/cancel/list_for_app (logs excluded by default, use lines param to include)', {
566
677
  action: z.enum(['get', 'cancel', 'list_for_app']),
567
678
  uuid: z.string(),
568
- lines: z.number().optional(), // Limit log output to last N lines (default: 200)
679
+ lines: z.number().optional(), // Include logs truncated to last N lines (omit for no logs)
569
680
  max_chars: z.number().optional(), // Limit log output to last N chars (default: 50000)
570
681
  }, async ({ action, uuid, lines, max_chars }) => {
571
682
  switch (action) {
572
683
  case 'get':
573
- return wrap(async () => {
574
- const deployment = await this.client.getDeployment(uuid);
575
- if (deployment.logs) {
576
- deployment.logs = truncateLogs(deployment.logs, lines ?? 200, max_chars ?? 50000);
577
- }
578
- return deployment;
579
- });
684
+ // If lines param specified, include logs and truncate
685
+ if (lines !== undefined) {
686
+ return wrapWithActions(async () => {
687
+ const deployment = (await this.client.getDeployment(uuid, {
688
+ includeLogs: true,
689
+ }));
690
+ if (deployment.logs) {
691
+ deployment.logs = truncateLogs(deployment.logs, lines, max_chars ?? 50000);
692
+ }
693
+ return deployment;
694
+ }, (dep) => getDeploymentActions(dep.uuid, dep.status, dep.application_uuid));
695
+ }
696
+ // Otherwise return essential info without logs
697
+ return wrapWithActions(() => this.client.getDeployment(uuid), (dep) => getDeploymentActions(dep.uuid, dep.status, dep.application_uuid));
580
698
  case 'cancel':
581
699
  return wrap(() => this.client.cancelDeployment(uuid));
582
700
  case 'list_for_app':
@@ -10,7 +10,7 @@ export interface ErrorResponse {
10
10
  error?: string;
11
11
  message: string;
12
12
  status?: number;
13
- errors?: Record<string, string[]>;
13
+ errors?: Record<string, string[] | string>;
14
14
  }
15
15
  export interface DeleteOptions {
16
16
  deleteConfigurations?: boolean;
@@ -634,14 +634,12 @@ export interface CreateServiceRequest {
634
634
  * - Wrong: "user:$apr1$hash$here"
635
635
  * - Docker Compose processes $$ → $ for Traefik
636
636
  *
637
- * 3. docker_compose_raw must be base64 encoded when sent to API
638
- * - Example: Buffer.from(yamlString).toString('base64')
637
+ * 3. docker_compose_raw is auto base64-encoded by the client — pass raw YAML
639
638
  *
640
639
  * Summary for htpasswd with basic auth:
641
640
  * - Generate hash: htpasswd -nb username password
642
641
  * - Replace $ with $$ in the hash
643
642
  * - Disable label escaping in Coolify UI (manual step!)
644
- * - Base64 encode the entire docker-compose YAML
645
643
  */
646
644
  export interface UpdateServiceRequest {
647
645
  name?: string;
@@ -901,3 +899,34 @@ export interface BatchOperationResult {
901
899
  error: string;
902
900
  }>;
903
901
  }
902
+ export interface ResponseAction {
903
+ tool: string;
904
+ args: Record<string, string | number | boolean>;
905
+ hint: string;
906
+ }
907
+ export interface ResponsePagination {
908
+ next?: {
909
+ tool: string;
910
+ args: Record<string, number>;
911
+ };
912
+ prev?: {
913
+ tool: string;
914
+ args: Record<string, number>;
915
+ };
916
+ }
917
+ export interface DeploymentEssential {
918
+ uuid: string;
919
+ deployment_uuid: string;
920
+ application_uuid?: string;
921
+ application_name?: string;
922
+ server_name?: string;
923
+ status: string;
924
+ commit?: string;
925
+ force_rebuild: boolean;
926
+ is_webhook: boolean;
927
+ is_api: boolean;
928
+ created_at: string;
929
+ updated_at: string;
930
+ logs_available?: boolean;
931
+ logs_info?: string;
932
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@masonator/coolify-mcp",
3
3
  "scope": "@masonator",
4
- "version": "2.5.0",
4
+ "version": "2.6.1",
5
5
  "description": "MCP server implementation for Coolify",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",