@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.
@@ -223,18 +223,19 @@ describe('CoolifyClient', () => {
223
223
  body: JSON.stringify(createData),
224
224
  }));
225
225
  });
226
- it('should create a service with docker_compose_raw instead of type', async () => {
226
+ it('should pass through already base64-encoded docker_compose_raw', async () => {
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: 'dmVyc2lvbjogIjMiCnNlcnZpY2VzOgogIGFwcDoKICAgIGltYWdlOiBuZ2lueA==',
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(data),
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(data),
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(data),
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(data),
587
+ body: JSON.stringify(payload),
559
588
  });
560
589
  }
561
590
  async deleteService(uuid, options) {
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Coolify MCP Server v2.4.0
2
+ * Coolify MCP Server
3
3
  * Consolidated tools for efficient token usage
4
4
  */
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
7
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.
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Coolify MCP Server v2.4.0
2
+ * Coolify MCP Server
3
3
  * Consolidated tools for efficient token usage
4
4
  */
5
+ import { createRequire } from 'module';
5
6
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
7
  import { z } from 'zod';
7
8
  import { CoolifyClient, } from './coolify-client.js';
8
- const VERSION = '2.5.0';
9
+ const _require = createRequire(import.meta.url);
10
+ export const VERSION = _require('../../package.json').version;
9
11
  /** Wrap handler with error handling */
10
12
  function wrap(fn) {
11
13
  return fn()
@@ -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.string().optional(),
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;
@@ -10,7 +10,7 @@ export interface ErrorResponse {
10
10
  error?: string;
11
11
  message: string;
12
12
  status?: number;
13
- errors?: Record<string, string[]>;
13
+ errors?: Record<string, string[] | string>;
14
14
  }
15
15
  export interface DeleteOptions {
16
16
  deleteConfigurations?: boolean;
@@ -634,14 +634,12 @@ export interface CreateServiceRequest {
634
634
  * - Wrong: "user:$apr1$hash$here"
635
635
  * - Docker Compose processes $$ → $ for Traefik
636
636
  *
637
- * 3. docker_compose_raw must be base64 encoded when sent to API
638
- * - Example: Buffer.from(yamlString).toString('base64')
637
+ * 3. docker_compose_raw is auto base64-encoded by the client — pass raw YAML
639
638
  *
640
639
  * Summary for htpasswd with basic auth:
641
640
  * - Generate hash: htpasswd -nb username password
642
641
  * - Replace $ with $$ in the hash
643
642
  * - Disable label escaping in Coolify UI (manual step!)
644
- * - Base64 encode the entire docker-compose YAML
645
643
  */
646
644
  export interface UpdateServiceRequest {
647
645
  name?: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@masonator/coolify-mcp",
3
3
  "scope": "@masonator",
4
- "version": "2.6.0",
4
+ "version": "2.6.1",
5
5
  "description": "MCP server implementation for Coolify",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",