@masonator/coolify-mcp 2.6.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/dist/__tests__/coolify-client.test.js +65 -2
- 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 +7 -1
- package/dist/lib/coolify-client.js +34 -5
- package/dist/lib/mcp-server.d.ts +2 -1
- package/dist/lib/mcp-server.js +8 -3
- package/dist/types/coolify.d.ts +2 -4
- package/package.json +1 -1
|
@@ -223,18 +223,19 @@ describe('CoolifyClient', () => {
|
|
|
223
223
|
body: JSON.stringify(createData),
|
|
224
224
|
}));
|
|
225
225
|
});
|
|
226
|
-
it('should
|
|
226
|
+
it('should pass through already base64-encoded docker_compose_raw', async () => {
|
|
227
227
|
const responseData = {
|
|
228
228
|
uuid: 'compose-uuid',
|
|
229
229
|
domains: ['custom.example.com'],
|
|
230
230
|
};
|
|
231
231
|
mockFetch.mockResolvedValueOnce(mockResponse(responseData));
|
|
232
|
+
const base64Value = 'dmVyc2lvbjogIjMiCnNlcnZpY2VzOgogIGFwcDoKICAgIGltYWdlOiBuZ2lueA==';
|
|
232
233
|
const createData = {
|
|
233
234
|
name: 'custom-compose-service',
|
|
234
235
|
project_uuid: 'project-uuid',
|
|
235
236
|
environment_uuid: 'env-uuid',
|
|
236
237
|
server_uuid: 'server-uuid',
|
|
237
|
-
docker_compose_raw:
|
|
238
|
+
docker_compose_raw: base64Value,
|
|
238
239
|
};
|
|
239
240
|
const result = await client.createService(createData);
|
|
240
241
|
expect(result).toEqual(responseData);
|
|
@@ -243,6 +244,24 @@ describe('CoolifyClient', () => {
|
|
|
243
244
|
body: JSON.stringify(createData),
|
|
244
245
|
}));
|
|
245
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
|
+
});
|
|
246
265
|
});
|
|
247
266
|
describe('deleteService', () => {
|
|
248
267
|
it('should delete a service', async () => {
|
|
@@ -480,6 +499,25 @@ describe('CoolifyClient', () => {
|
|
|
480
499
|
}, false, 422));
|
|
481
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.');
|
|
482
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
|
+
});
|
|
483
521
|
});
|
|
484
522
|
// =========================================================================
|
|
485
523
|
// Server endpoints - additional coverage
|
|
@@ -795,6 +833,17 @@ describe('CoolifyClient', () => {
|
|
|
795
833
|
expect(result).toEqual({ uuid: 'new-app-uuid' });
|
|
796
834
|
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/dockercompose', expect.objectContaining({ method: 'POST' }));
|
|
797
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
|
+
});
|
|
798
847
|
/**
|
|
799
848
|
* Issue #76 - Client Layer Behavior Test
|
|
800
849
|
*
|
|
@@ -837,6 +886,13 @@ describe('CoolifyClient', () => {
|
|
|
837
886
|
body: JSON.stringify({ name: 'updated-app', description: 'new desc' }),
|
|
838
887
|
}));
|
|
839
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
|
+
});
|
|
840
896
|
/**
|
|
841
897
|
* Issue #76 - Client Layer Behavior Test
|
|
842
898
|
*
|
|
@@ -1194,6 +1250,13 @@ describe('CoolifyClient', () => {
|
|
|
1194
1250
|
const result = await client.updateService('test-uuid', { name: 'updated-service' });
|
|
1195
1251
|
expect(result.name).toBe('updated-service');
|
|
1196
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
|
+
});
|
|
1197
1260
|
it('should start a service', async () => {
|
|
1198
1261
|
mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Started' }));
|
|
1199
1262
|
const result = await client.startService('test-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, getApplicationActions, getDeploymentActions, getPagination, } 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', () => {
|
|
@@ -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
|
// =============================================================================
|
|
@@ -151,7 +164,7 @@ export class CoolifyClient {
|
|
|
151
164
|
let errorMessage = error.message || `HTTP ${response.status}: ${response.statusText}`;
|
|
152
165
|
if (error.errors && Object.keys(error.errors).length > 0) {
|
|
153
166
|
const validationDetails = Object.entries(error.errors)
|
|
154
|
-
.map(([field, messages]) => `${field}: ${messages.join(', ')}`)
|
|
167
|
+
.map(([field, messages]) => `${field}: ${Array.isArray(messages) ? messages.join(', ') : String(messages)}`)
|
|
155
168
|
.join('; ');
|
|
156
169
|
errorMessage = `${errorMessage} - ${validationDetails}`;
|
|
157
170
|
}
|
|
@@ -363,15 +376,23 @@ export class CoolifyClient {
|
|
|
363
376
|
});
|
|
364
377
|
}
|
|
365
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
|
+
}
|
|
366
383
|
return this.request('/applications/dockercompose', {
|
|
367
384
|
method: 'POST',
|
|
368
|
-
body: JSON.stringify(
|
|
385
|
+
body: JSON.stringify(payload),
|
|
369
386
|
});
|
|
370
387
|
}
|
|
371
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
|
+
}
|
|
372
393
|
return this.request(`/applications/${uuid}`, {
|
|
373
394
|
method: 'PATCH',
|
|
374
|
-
body: JSON.stringify(
|
|
395
|
+
body: JSON.stringify(payload),
|
|
375
396
|
});
|
|
376
397
|
}
|
|
377
398
|
async deleteApplication(uuid, options) {
|
|
@@ -547,15 +568,23 @@ export class CoolifyClient {
|
|
|
547
568
|
return this.request(`/services/${uuid}`);
|
|
548
569
|
}
|
|
549
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
|
+
}
|
|
550
575
|
return this.request('/services', {
|
|
551
576
|
method: 'POST',
|
|
552
|
-
body: JSON.stringify(
|
|
577
|
+
body: JSON.stringify(payload),
|
|
553
578
|
});
|
|
554
579
|
}
|
|
555
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
|
+
}
|
|
556
585
|
return this.request(`/services/${uuid}`, {
|
|
557
586
|
method: 'PATCH',
|
|
558
|
-
body: JSON.stringify(
|
|
587
|
+
body: JSON.stringify(payload),
|
|
559
588
|
});
|
|
560
589
|
}
|
|
561
590
|
async deleteService(uuid, options) {
|
package/dist/lib/mcp-server.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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
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.
|
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()
|
|
@@ -520,7 +522,10 @@ export class CoolifyMcpServer extends McpServer {
|
|
|
520
522
|
name: z.string().optional(),
|
|
521
523
|
description: z.string().optional(),
|
|
522
524
|
instant_deploy: z.boolean().optional(),
|
|
523
|
-
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)'),
|
|
524
529
|
delete_volumes: z.boolean().optional(),
|
|
525
530
|
}, async (args) => {
|
|
526
531
|
const { action, uuid, delete_volumes } = args;
|
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;
|