@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 +22 -0
- package/dist/__tests__/coolify-client.test.js +210 -4
- package/dist/__tests__/integration/smoke.integration.test.d.ts +14 -0
- package/dist/__tests__/integration/smoke.integration.test.js +71 -0
- package/dist/__tests__/mcp-server.test.js +129 -1
- package/dist/lib/coolify-client.d.ts +18 -2
- package/dist/lib/coolify-client.js +92 -9
- package/dist/lib/mcp-server.d.ts +9 -2
- package/dist/lib/mcp-server.js +140 -22
- package/dist/types/coolify.d.ts +33 -4
- package/package.json +1 -1
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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`);
|
package/dist/lib/mcp-server.d.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Coolify MCP Server
|
|
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);
|
package/dist/lib/mcp-server.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Coolify MCP Server
|
|
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
|
|
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
|
-
|
|
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 }) =>
|
|
180
|
-
this.tool('get_application', 'App details', { uuid: z.string() }, async ({ 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }) =>
|
|
564
|
-
this.tool('deploy', 'Deploy by tag/UUID', { tag_or_uuid: z.string(), force: z.boolean().optional() }, async ({ tag_or_uuid, force }) =>
|
|
565
|
-
this.tool('deployment', 'Manage deployment: get/cancel/list_for_app (logs
|
|
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(), //
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
deployment
|
|
577
|
-
|
|
578
|
-
|
|
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':
|
package/dist/types/coolify.d.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|