@masonator/coolify-mcp 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
  };
@@ -342,6 +346,18 @@ describe('CoolifyClient', () => {
342
346
  expect(result).toEqual({ message: 'Deployed' });
343
347
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deploy?tag=my-tag&force=true', expect.any(Object));
344
348
  });
349
+ it('should deploy by Coolify UUID (24 char alphanumeric)', async () => {
350
+ mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Deployed' }));
351
+ // Coolify-style UUID: 24 lowercase alphanumeric chars
352
+ await client.deployByTagOrUuid('xs0sgs4gog044s4k4c88kgsc', false);
353
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deploy?uuid=xs0sgs4gog044s4k4c88kgsc&force=false', expect.any(Object));
354
+ });
355
+ it('should deploy by standard UUID format', async () => {
356
+ mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Deployed' }));
357
+ // Standard UUID format with hyphens
358
+ await client.deployByTagOrUuid('a1b2c3d4-e5f6-7890-abcd-ef1234567890', true);
359
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deploy?uuid=a1b2c3d4-e5f6-7890-abcd-ef1234567890&force=true', expect.any(Object));
360
+ });
345
361
  });
346
362
  describe('private keys', () => {
347
363
  it('should list private keys', async () => {
@@ -574,6 +590,98 @@ describe('CoolifyClient', () => {
574
590
  expect(result).toEqual(mockEnvironment);
575
591
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/projects/proj-uuid/production', expect.any(Object));
576
592
  });
593
+ it('should get project environment with missing database types', async () => {
594
+ // Use environment_id to match (what real API uses)
595
+ const mockDbSummaries = [
596
+ {
597
+ uuid: 'pg-uuid',
598
+ name: 'pg-db',
599
+ type: 'postgresql',
600
+ status: 'running',
601
+ is_public: false,
602
+ environment_id: 1,
603
+ },
604
+ {
605
+ uuid: 'dragonfly-uuid',
606
+ name: 'dragonfly-cache',
607
+ type: 'standalone-dragonfly',
608
+ status: 'running',
609
+ is_public: false,
610
+ environment_id: 1,
611
+ },
612
+ {
613
+ uuid: 'other-env-db',
614
+ name: 'other-db',
615
+ type: 'standalone-keydb',
616
+ status: 'running',
617
+ is_public: false,
618
+ environment_id: 999, // different env
619
+ },
620
+ ];
621
+ mockFetch
622
+ .mockResolvedValueOnce(mockResponse(mockEnvironment))
623
+ .mockResolvedValueOnce(mockResponse(mockDbSummaries));
624
+ const result = await client.getProjectEnvironmentWithDatabases('proj-uuid', 'production');
625
+ expect(result.uuid).toBe('env-uuid');
626
+ expect(result.dragonflys).toHaveLength(1);
627
+ expect(result.dragonflys[0].uuid).toBe('dragonfly-uuid');
628
+ expect(result.keydbs).toBeUndefined(); // other-env-db is in different env
629
+ });
630
+ it('should match databases by environment_uuid fallback', async () => {
631
+ const mockDbSummaries = [
632
+ {
633
+ uuid: 'keydb-uuid',
634
+ name: 'keydb-cache',
635
+ type: 'standalone-keydb',
636
+ status: 'running',
637
+ is_public: false,
638
+ environment_uuid: 'env-uuid', // matching by uuid
639
+ },
640
+ ];
641
+ mockFetch
642
+ .mockResolvedValueOnce(mockResponse(mockEnvironment))
643
+ .mockResolvedValueOnce(mockResponse(mockDbSummaries));
644
+ const result = await client.getProjectEnvironmentWithDatabases('proj-uuid', 'production');
645
+ expect(result.keydbs).toHaveLength(1);
646
+ expect(result.keydbs[0].uuid).toBe('keydb-uuid');
647
+ });
648
+ it('should match databases by environment_name fallback', async () => {
649
+ const mockDbSummaries = [
650
+ {
651
+ uuid: 'clickhouse-uuid',
652
+ name: 'clickhouse-analytics',
653
+ type: 'standalone-clickhouse',
654
+ status: 'running',
655
+ is_public: false,
656
+ environment_name: 'production', // matching by name
657
+ },
658
+ ];
659
+ mockFetch
660
+ .mockResolvedValueOnce(mockResponse(mockEnvironment))
661
+ .mockResolvedValueOnce(mockResponse(mockDbSummaries));
662
+ const result = await client.getProjectEnvironmentWithDatabases('proj-uuid', 'production');
663
+ expect(result.clickhouses).toHaveLength(1);
664
+ expect(result.clickhouses[0].uuid).toBe('clickhouse-uuid');
665
+ });
666
+ it('should not add empty arrays when no missing DB types exist', async () => {
667
+ const mockDbSummaries = [
668
+ {
669
+ uuid: 'pg-uuid',
670
+ name: 'pg-db',
671
+ type: 'postgresql', // not a "missing" type
672
+ status: 'running',
673
+ is_public: false,
674
+ environment_id: 1,
675
+ },
676
+ ];
677
+ mockFetch
678
+ .mockResolvedValueOnce(mockResponse(mockEnvironment))
679
+ .mockResolvedValueOnce(mockResponse(mockDbSummaries));
680
+ const result = await client.getProjectEnvironmentWithDatabases('proj-uuid', 'production');
681
+ expect(result.dragonflys).toBeUndefined();
682
+ expect(result.keydbs).toBeUndefined();
683
+ expect(result.clickhouses).toBeUndefined();
684
+ });
577
685
  it('should create a project environment', async () => {
578
686
  mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-env-uuid' }));
579
687
  const result = await client.createProjectEnvironment('proj-uuid', { name: 'staging' });
@@ -1165,12 +1273,47 @@ describe('CoolifyClient', () => {
1165
1273
  },
1166
1274
  ]);
1167
1275
  });
1168
- it('should get a deployment', async () => {
1276
+ it('should get a deployment (essential by default, no logs)', async () => {
1169
1277
  mockFetch.mockResolvedValueOnce(mockResponse(mockDeployment));
1170
1278
  const result = await client.getDeployment('dep-uuid');
1171
- expect(result).toEqual(mockDeployment);
1279
+ // By default, returns DeploymentEssential without logs
1280
+ expect(result).toEqual({
1281
+ uuid: 'dep-uuid',
1282
+ deployment_uuid: 'dep-123',
1283
+ application_uuid: undefined,
1284
+ application_name: 'test-app',
1285
+ server_name: undefined,
1286
+ status: 'finished',
1287
+ commit: undefined,
1288
+ force_rebuild: false,
1289
+ is_webhook: false,
1290
+ is_api: true,
1291
+ created_at: '2024-01-01',
1292
+ updated_at: '2024-01-01',
1293
+ logs_available: false,
1294
+ logs_info: undefined,
1295
+ });
1172
1296
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deployments/dep-uuid', expect.any(Object));
1173
1297
  });
1298
+ it('should get a deployment with logs when includeLogs is true', async () => {
1299
+ const deploymentWithLogs = { ...mockDeployment, logs: 'Build started...' };
1300
+ mockFetch.mockResolvedValueOnce(mockResponse(deploymentWithLogs));
1301
+ const result = await client.getDeployment('dep-uuid', { includeLogs: true });
1302
+ // With includeLogs: true, returns full Deployment with logs
1303
+ expect(result).toEqual(deploymentWithLogs);
1304
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deployments/dep-uuid', expect.any(Object));
1305
+ });
1306
+ it('should include logs_info when deployment has logs but includeLogs is false', async () => {
1307
+ const deploymentWithLogs = { ...mockDeployment, logs: 'Build started...' };
1308
+ mockFetch.mockResolvedValueOnce(mockResponse(deploymentWithLogs));
1309
+ const result = await client.getDeployment('dep-uuid');
1310
+ // Should have logs_info indicating logs are available
1311
+ expect(result).toMatchObject({
1312
+ uuid: 'dep-uuid',
1313
+ logs_available: true,
1314
+ logs_info: 'Logs available (16 chars). Use lines param to retrieve.',
1315
+ });
1316
+ });
1174
1317
  it('should list application deployments', async () => {
1175
1318
  mockFetch.mockResolvedValueOnce(mockResponse([mockDeployment]));
1176
1319
  const result = await client.listApplicationDeployments('app-uuid');
@@ -6,7 +6,7 @@
6
6
  * These tests verify MCP server instantiation and structure.
7
7
  */
8
8
  import { describe, it, expect, beforeEach } from '@jest/globals';
9
- import { CoolifyMcpServer, truncateLogs } from '../lib/mcp-server.js';
9
+ import { CoolifyMcpServer, truncateLogs, getApplicationActions, getDeploymentActions, getPagination, } from '../lib/mcp-server.js';
10
10
  describe('CoolifyMcpServer v2', () => {
11
11
  let server;
12
12
  beforeEach(() => {
@@ -47,6 +47,7 @@ describe('CoolifyMcpServer v2', () => {
47
47
  // Environment operations
48
48
  expect(typeof client.listProjectEnvironments).toBe('function');
49
49
  expect(typeof client.getProjectEnvironment).toBe('function');
50
+ expect(typeof client.getProjectEnvironmentWithDatabases).toBe('function');
50
51
  expect(typeof client.createProjectEnvironment).toBe('function');
51
52
  expect(typeof client.deleteProjectEnvironment).toBe('function');
52
53
  // Application operations
@@ -186,3 +187,124 @@ describe('truncateLogs', () => {
186
187
  expect(result.length).toBe(100);
187
188
  });
188
189
  });
190
+ // =============================================================================
191
+ // Action Generators Tests
192
+ // =============================================================================
193
+ describe('getApplicationActions', () => {
194
+ it('should return view logs action for all apps', () => {
195
+ const actions = getApplicationActions('app-uuid', 'stopped');
196
+ expect(actions).toContainEqual({
197
+ tool: 'application_logs',
198
+ args: { uuid: 'app-uuid' },
199
+ hint: 'View logs',
200
+ });
201
+ });
202
+ it('should return restart/stop actions for running apps', () => {
203
+ const actions = getApplicationActions('app-uuid', 'running');
204
+ expect(actions).toContainEqual({
205
+ tool: 'control',
206
+ args: { resource: 'application', action: 'restart', uuid: 'app-uuid' },
207
+ hint: 'Restart',
208
+ });
209
+ expect(actions).toContainEqual({
210
+ tool: 'control',
211
+ args: { resource: 'application', action: 'stop', uuid: 'app-uuid' },
212
+ hint: 'Stop',
213
+ });
214
+ });
215
+ it('should return start action for stopped apps', () => {
216
+ const actions = getApplicationActions('app-uuid', 'stopped');
217
+ expect(actions).toContainEqual({
218
+ tool: 'control',
219
+ args: { resource: 'application', action: 'start', uuid: 'app-uuid' },
220
+ hint: 'Start',
221
+ });
222
+ });
223
+ it('should handle running:healthy status', () => {
224
+ const actions = getApplicationActions('app-uuid', 'running:healthy');
225
+ expect(actions.some((a) => a.hint === 'Restart')).toBe(true);
226
+ expect(actions.some((a) => a.hint === 'Stop')).toBe(true);
227
+ });
228
+ it('should handle undefined status', () => {
229
+ const actions = getApplicationActions('app-uuid', undefined);
230
+ expect(actions).toContainEqual({
231
+ tool: 'control',
232
+ args: { resource: 'application', action: 'start', uuid: 'app-uuid' },
233
+ hint: 'Start',
234
+ });
235
+ });
236
+ });
237
+ describe('getDeploymentActions', () => {
238
+ it('should return cancel action for in_progress deployments', () => {
239
+ const actions = getDeploymentActions('dep-uuid', 'in_progress', 'app-uuid');
240
+ expect(actions).toContainEqual({
241
+ tool: 'deployment',
242
+ args: { action: 'cancel', uuid: 'dep-uuid' },
243
+ hint: 'Cancel',
244
+ });
245
+ });
246
+ it('should return cancel action for queued deployments', () => {
247
+ const actions = getDeploymentActions('dep-uuid', 'queued', 'app-uuid');
248
+ expect(actions).toContainEqual({
249
+ tool: 'deployment',
250
+ args: { action: 'cancel', uuid: 'dep-uuid' },
251
+ hint: 'Cancel',
252
+ });
253
+ });
254
+ it('should return app actions when appUuid provided', () => {
255
+ const actions = getDeploymentActions('dep-uuid', 'finished', 'app-uuid');
256
+ expect(actions).toContainEqual({
257
+ tool: 'get_application',
258
+ args: { uuid: 'app-uuid' },
259
+ hint: 'View app',
260
+ });
261
+ expect(actions).toContainEqual({
262
+ tool: 'application_logs',
263
+ args: { uuid: 'app-uuid' },
264
+ hint: 'App logs',
265
+ });
266
+ });
267
+ it('should not return cancel for finished deployments', () => {
268
+ const actions = getDeploymentActions('dep-uuid', 'finished', 'app-uuid');
269
+ expect(actions.some((a) => a.hint === 'Cancel')).toBe(false);
270
+ });
271
+ it('should return empty actions when no appUuid and not in_progress', () => {
272
+ const actions = getDeploymentActions('dep-uuid', 'finished', undefined);
273
+ expect(actions).toEqual([]);
274
+ });
275
+ });
276
+ describe('getPagination', () => {
277
+ it('should return undefined when count is less than perPage and page is 1', () => {
278
+ const result = getPagination('list_apps', 1, 50, 30);
279
+ expect(result).toBeUndefined();
280
+ });
281
+ it('should return next when count equals or exceeds perPage', () => {
282
+ const result = getPagination('list_apps', 1, 50, 50);
283
+ expect(result).toEqual({
284
+ next: { tool: 'list_apps', args: { page: 2, per_page: 50 } },
285
+ });
286
+ });
287
+ it('should return both prev and next for middle pages', () => {
288
+ const result = getPagination('list_apps', 2, 50, 50);
289
+ expect(result).toEqual({
290
+ prev: { tool: 'list_apps', args: { page: 1, per_page: 50 } },
291
+ next: { tool: 'list_apps', args: { page: 3, per_page: 50 } },
292
+ });
293
+ });
294
+ it('should return prev when page > 1 and count < perPage', () => {
295
+ const result = getPagination('list_apps', 3, 50, 20);
296
+ expect(result).toEqual({
297
+ prev: { tool: 'list_apps', args: { page: 2, per_page: 50 } },
298
+ });
299
+ });
300
+ it('should use default page and perPage when undefined', () => {
301
+ const result = getPagination('list_apps', undefined, undefined, 100);
302
+ expect(result).toEqual({
303
+ next: { tool: 'list_apps', args: { page: 2, per_page: 50 } },
304
+ });
305
+ });
306
+ it('should return undefined when count is undefined', () => {
307
+ const result = getPagination('list_apps', 1, 50, undefined);
308
+ expect(result).toBeUndefined();
309
+ });
310
+ });
@@ -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[]>;
@@ -38,12 +38,17 @@ function toApplicationSummary(app) {
38
38
  };
39
39
  }
40
40
  function toDatabaseSummary(db) {
41
+ // API returns database_type not type, and environment_id not environment_uuid
42
+ const raw = db;
41
43
  return {
42
44
  uuid: db.uuid,
43
45
  name: db.name,
44
- type: db.type,
46
+ type: db.type || raw.database_type,
45
47
  status: db.status,
46
48
  is_public: db.is_public,
49
+ environment_uuid: db.environment_uuid,
50
+ environment_name: db.environment_name,
51
+ environment_id: raw.environment_id,
47
52
  };
48
53
  }
49
54
  function toServiceSummary(svc) {
@@ -64,6 +69,26 @@ function toDeploymentSummary(dep) {
64
69
  created_at: dep.created_at,
65
70
  };
66
71
  }
72
+ function toDeploymentEssential(dep) {
73
+ return {
74
+ uuid: dep.uuid,
75
+ deployment_uuid: dep.deployment_uuid,
76
+ application_uuid: dep.application_uuid,
77
+ application_name: dep.application_name,
78
+ server_name: dep.server_name,
79
+ status: dep.status,
80
+ commit: dep.commit,
81
+ force_rebuild: dep.force_rebuild,
82
+ is_webhook: dep.is_webhook,
83
+ is_api: dep.is_api,
84
+ created_at: dep.created_at,
85
+ updated_at: dep.updated_at,
86
+ logs_available: !!dep.logs,
87
+ logs_info: dep.logs
88
+ ? `Logs available (${dep.logs.length} chars). Use lines param to retrieve.`
89
+ : undefined,
90
+ };
91
+ }
67
92
  function toProjectSummary(proj) {
68
93
  return {
69
94
  uuid: proj.uuid,
@@ -256,6 +281,32 @@ export class CoolifyClient {
256
281
  async getProjectEnvironment(projectUuid, environmentNameOrUuid) {
257
282
  return this.request(`/projects/${projectUuid}/${environmentNameOrUuid}`);
258
283
  }
284
+ /**
285
+ * Get environment with missing database types (dragonfly, keydb, clickhouse).
286
+ * Coolify API omits these from the environment endpoint - we cross-reference
287
+ * with listDatabases using lightweight summaries.
288
+ * @see https://github.com/StuMason/coolify-mcp/issues/88
289
+ */
290
+ async getProjectEnvironmentWithDatabases(projectUuid, environmentNameOrUuid) {
291
+ const [environment, dbSummaries] = await Promise.all([
292
+ this.getProjectEnvironment(projectUuid, environmentNameOrUuid),
293
+ this.listDatabases({ summary: true }),
294
+ ]);
295
+ // Filter for this environment's missing database types
296
+ // API uses environment_id, not environment_uuid
297
+ const envDbs = dbSummaries.filter((db) => db.environment_id === environment.id ||
298
+ db.environment_uuid === environment.uuid ||
299
+ db.environment_name === environment.name);
300
+ const dragonflys = envDbs.filter((db) => db.type?.includes('dragonfly'));
301
+ const keydbs = envDbs.filter((db) => db.type?.includes('keydb'));
302
+ const clickhouses = envDbs.filter((db) => db.type?.includes('clickhouse'));
303
+ return {
304
+ ...environment,
305
+ ...(dragonflys.length > 0 && { dragonflys }),
306
+ ...(keydbs.length > 0 && { keydbs }),
307
+ ...(clickhouses.length > 0 && { clickhouses }),
308
+ };
309
+ }
259
310
  async createProjectEnvironment(projectUuid, data) {
260
311
  return this.request(`/projects/${projectUuid}/environments`, {
261
312
  method: 'POST',
@@ -569,11 +620,14 @@ export class CoolifyClient {
569
620
  ? deployments.map(toDeploymentSummary)
570
621
  : deployments;
571
622
  }
572
- async getDeployment(uuid) {
573
- return this.request(`/deployments/${uuid}`);
623
+ async getDeployment(uuid, options) {
624
+ const deployment = await this.request(`/deployments/${uuid}`);
625
+ return options?.includeLogs ? deployment : toDeploymentEssential(deployment);
574
626
  }
575
627
  async deployByTagOrUuid(tagOrUuid, force = false) {
576
- return this.request(`/deploy?tag=${encodeURIComponent(tagOrUuid)}&force=${force}`, { method: 'GET' });
628
+ // Detect if the value looks like a UUID or a tag name
629
+ const param = this.isLikelyUuid(tagOrUuid) ? 'uuid' : 'tag';
630
+ return this.request(`/deploy?${param}=${encodeURIComponent(tagOrUuid)}&force=${force}`, { method: 'GET' });
577
631
  }
578
632
  async listApplicationDeployments(appUuid) {
579
633
  return this.request(`/applications/${appUuid}/deployments`);
@@ -4,12 +4,18 @@
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
8
  /**
9
9
  * Truncate logs by line count and character count.
10
10
  * Exported for testing.
11
11
  */
12
12
  export declare function truncateLogs(logs: string, lineLimit?: number, charLimit?: number): string;
13
+ /** Generate contextual actions for an application based on its status */
14
+ export declare function getApplicationActions(uuid: string, status?: string): ResponseAction[];
15
+ /** Generate contextual actions for a deployment */
16
+ export declare function getDeploymentActions(uuid: string, status: string, appUuid?: string): ResponseAction[];
17
+ /** Generate pagination info for list endpoints */
18
+ export declare function getPagination(tool: string, page?: number, perPage?: number, count?: number): ResponsePagination | undefined;
13
19
  export declare class CoolifyMcpServer extends McpServer {
14
20
  private readonly client;
15
21
  constructor(config: CoolifyConfig);
@@ -39,6 +39,82 @@ export function truncateLogs(logs, lineLimit = 200, charLimit = 50000) {
39
39
  }
40
40
  return truncatedLogs;
41
41
  }
42
+ // =============================================================================
43
+ // Action Generators for HATEOAS-style responses
44
+ // =============================================================================
45
+ /** Generate contextual actions for an application based on its status */
46
+ export function getApplicationActions(uuid, status) {
47
+ const actions = [
48
+ { tool: 'application_logs', args: { uuid }, hint: 'View logs' },
49
+ ];
50
+ const s = (status || '').toLowerCase();
51
+ if (s.includes('running')) {
52
+ actions.push({
53
+ tool: 'control',
54
+ args: { resource: 'application', action: 'restart', uuid },
55
+ hint: 'Restart',
56
+ });
57
+ actions.push({
58
+ tool: 'control',
59
+ args: { resource: 'application', action: 'stop', uuid },
60
+ hint: 'Stop',
61
+ });
62
+ }
63
+ else {
64
+ actions.push({
65
+ tool: 'control',
66
+ args: { resource: 'application', action: 'start', uuid },
67
+ hint: 'Start',
68
+ });
69
+ }
70
+ return actions;
71
+ }
72
+ /** Generate contextual actions for a deployment */
73
+ export function getDeploymentActions(uuid, status, appUuid) {
74
+ const actions = [];
75
+ if (status === 'in_progress' || status === 'queued') {
76
+ actions.push({ tool: 'deployment', args: { action: 'cancel', uuid }, hint: 'Cancel' });
77
+ }
78
+ if (appUuid) {
79
+ actions.push({ tool: 'get_application', args: { uuid: appUuid }, hint: 'View app' });
80
+ actions.push({ tool: 'application_logs', args: { uuid: appUuid }, hint: 'App logs' });
81
+ }
82
+ return actions;
83
+ }
84
+ /** Generate pagination info for list endpoints */
85
+ export function getPagination(tool, page, perPage, count) {
86
+ const p = page ?? 1;
87
+ const pp = perPage ?? 50;
88
+ if (!count || count < pp) {
89
+ return p > 1 ? { prev: { tool, args: { page: p - 1, per_page: pp } } } : undefined;
90
+ }
91
+ return {
92
+ ...(p > 1 && { prev: { tool, args: { page: p - 1, per_page: pp } } }),
93
+ next: { tool, args: { page: p + 1, per_page: pp } },
94
+ };
95
+ }
96
+ /** Wrap handler with error handling and HATEOAS actions */
97
+ function wrapWithActions(fn, getActions, getPaginationFn) {
98
+ return fn()
99
+ .then((result) => {
100
+ const actions = getActions?.(result) ?? [];
101
+ const pagination = getPaginationFn?.(result);
102
+ const response = { data: result };
103
+ if (actions.length > 0)
104
+ response._actions = actions;
105
+ if (pagination)
106
+ response._pagination = pagination;
107
+ return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] };
108
+ })
109
+ .catch((error) => ({
110
+ content: [
111
+ {
112
+ type: 'text',
113
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
114
+ },
115
+ ],
116
+ }));
117
+ }
42
118
  export class CoolifyMcpServer extends McpServer {
43
119
  constructor(config) {
44
120
  super({ name: 'coolify', version: VERSION });
@@ -150,7 +226,7 @@ export class CoolifyMcpServer extends McpServer {
150
226
  // =========================================================================
151
227
  // Environments (1 tool - consolidated CRUD)
152
228
  // =========================================================================
153
- this.tool('environments', 'Manage environments: list/get/create/delete', {
229
+ this.tool('environments', 'Manage environments: list/get/create/delete (get includes dragonfly/keydb/clickhouse DBs missing from API)', {
154
230
  action: z.enum(['list', 'get', 'create', 'delete']),
155
231
  project_uuid: z.string(),
156
232
  name: z.string().optional(),
@@ -162,7 +238,8 @@ export class CoolifyMcpServer extends McpServer {
162
238
  case 'get':
163
239
  if (!name)
164
240
  return { content: [{ type: 'text', text: 'Error: name required' }] };
165
- return wrap(() => this.client.getProjectEnvironment(project_uuid, name));
241
+ // Use enhanced method that includes missing DB types (#88)
242
+ return wrap(() => this.client.getProjectEnvironmentWithDatabases(project_uuid, name));
166
243
  case 'create':
167
244
  if (!name)
168
245
  return { content: [{ type: 'text', text: 'Error: name required' }] };
@@ -176,8 +253,8 @@ export class CoolifyMcpServer extends McpServer {
176
253
  // =========================================================================
177
254
  // Applications (4 tools)
178
255
  // =========================================================================
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)));
256
+ 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)));
257
+ this.tool('get_application', 'App details', { uuid: z.string() }, async ({ uuid }) => wrapWithActions(() => this.client.getApplication(uuid), (app) => getApplicationActions(app.uuid, app.status)));
181
258
  this.tool('application', 'Manage app: create/update/delete', {
182
259
  action: z.enum([
183
260
  'create_public',
@@ -504,7 +581,36 @@ export class CoolifyMcpServer extends McpServer {
504
581
  restart: (u) => this.client.restartService(u),
505
582
  },
506
583
  };
507
- return wrap(() => methods[resource][action](uuid));
584
+ // Generate contextual actions based on resource type and action taken
585
+ const getControlActions = () => {
586
+ const actions = [];
587
+ if (resource === 'application') {
588
+ actions.push({ tool: 'application_logs', args: { uuid }, hint: 'View logs' });
589
+ actions.push({ tool: 'get_application', args: { uuid }, hint: 'Check status' });
590
+ if (action === 'start' || action === 'restart') {
591
+ actions.push({
592
+ tool: 'control',
593
+ args: { resource: 'application', action: 'stop', uuid },
594
+ hint: 'Stop',
595
+ });
596
+ }
597
+ else {
598
+ actions.push({
599
+ tool: 'control',
600
+ args: { resource: 'application', action: 'start', uuid },
601
+ hint: 'Start',
602
+ });
603
+ }
604
+ }
605
+ else if (resource === 'database') {
606
+ actions.push({ tool: 'get_database', args: { uuid }, hint: 'Check status' });
607
+ }
608
+ else if (resource === 'service') {
609
+ actions.push({ tool: 'get_service', args: { uuid }, hint: 'Check status' });
610
+ }
611
+ return actions;
612
+ };
613
+ return wrapWithActions(() => methods[resource][action](uuid), getControlActions);
508
614
  });
509
615
  // =========================================================================
510
616
  // Environment Variables (1 tool - consolidated)
@@ -516,8 +622,7 @@ export class CoolifyMcpServer extends McpServer {
516
622
  key: z.string().optional(),
517
623
  value: z.string().optional(),
518
624
  env_uuid: z.string().optional(),
519
- is_build_time: z.boolean().optional(),
520
- }, async ({ resource, action, uuid, key, value, env_uuid, is_build_time }) => {
625
+ }, async ({ resource, action, uuid, key, value, env_uuid }) => {
521
626
  if (resource === 'application') {
522
627
  switch (action) {
523
628
  case 'list':
@@ -525,7 +630,8 @@ export class CoolifyMcpServer extends McpServer {
525
630
  case 'create':
526
631
  if (!key || !value)
527
632
  return { content: [{ type: 'text', text: 'Error: key, value required' }] };
528
- return wrap(() => this.client.createApplicationEnvVar(uuid, { key, value, is_build_time }));
633
+ // Note: is_build_time is not passed - Coolify API rejects it for create action
634
+ return wrap(() => this.client.createApplicationEnvVar(uuid, { key, value }));
529
635
  case 'update':
530
636
  if (!key || !value)
531
637
  return { content: [{ type: 'text', text: 'Error: key, value required' }] };
@@ -560,23 +666,30 @@ export class CoolifyMcpServer extends McpServer {
560
666
  // =========================================================================
561
667
  // Deployments (3 tools)
562
668
  // =========================================================================
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)', {
669
+ 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)));
670
+ 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' }]));
671
+ this.tool('deployment', 'Manage deployment: get/cancel/list_for_app (logs excluded by default, use lines param to include)', {
566
672
  action: z.enum(['get', 'cancel', 'list_for_app']),
567
673
  uuid: z.string(),
568
- lines: z.number().optional(), // Limit log output to last N lines (default: 200)
674
+ lines: z.number().optional(), // Include logs truncated to last N lines (omit for no logs)
569
675
  max_chars: z.number().optional(), // Limit log output to last N chars (default: 50000)
570
676
  }, async ({ action, uuid, lines, max_chars }) => {
571
677
  switch (action) {
572
678
  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
- });
679
+ // If lines param specified, include logs and truncate
680
+ if (lines !== undefined) {
681
+ return wrapWithActions(async () => {
682
+ const deployment = (await this.client.getDeployment(uuid, {
683
+ includeLogs: true,
684
+ }));
685
+ if (deployment.logs) {
686
+ deployment.logs = truncateLogs(deployment.logs, lines, max_chars ?? 50000);
687
+ }
688
+ return deployment;
689
+ }, (dep) => getDeploymentActions(dep.uuid, dep.status, dep.application_uuid));
690
+ }
691
+ // Otherwise return essential info without logs
692
+ return wrapWithActions(() => this.client.getDeployment(uuid), (dep) => getDeploymentActions(dep.uuid, dep.status, dep.application_uuid));
580
693
  case 'cancel':
581
694
  return wrap(() => this.client.cancelDeployment(uuid));
582
695
  case 'list_for_app':
@@ -901,3 +901,34 @@ export interface BatchOperationResult {
901
901
  error: string;
902
902
  }>;
903
903
  }
904
+ export interface ResponseAction {
905
+ tool: string;
906
+ args: Record<string, string | number | boolean>;
907
+ hint: string;
908
+ }
909
+ export interface ResponsePagination {
910
+ next?: {
911
+ tool: string;
912
+ args: Record<string, number>;
913
+ };
914
+ prev?: {
915
+ tool: string;
916
+ args: Record<string, number>;
917
+ };
918
+ }
919
+ export interface DeploymentEssential {
920
+ uuid: string;
921
+ deployment_uuid: string;
922
+ application_uuid?: string;
923
+ application_name?: string;
924
+ server_name?: string;
925
+ status: string;
926
+ commit?: string;
927
+ force_rebuild: boolean;
928
+ is_webhook: boolean;
929
+ is_api: boolean;
930
+ created_at: string;
931
+ updated_at: string;
932
+ logs_available?: boolean;
933
+ logs_info?: string;
934
+ }
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.0",
5
5
  "description": "MCP server implementation for Coolify",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",