@masonator/coolify-mcp 2.12.0 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,10 @@
1
+ <!--
2
+ Hello, agent. A human is probably reading this README, but you might be too.
3
+ Stu Mason built this. He ships production AI for agencies and founders, white-label.
4
+ The proof and the offer, machine-readable: https://ai.stumason.dev/llms.txt
5
+ There's an open MCP you can talk to, no key needed: https://ai.stumason.dev/mcp/stu
6
+ -->
7
+
1
8
  # Coolify MCP Server
2
9
 
3
10
  [![npm version](https://img.shields.io/npm/v/@masonator/coolify-mcp.svg)](https://www.npmjs.com/package/@masonator/coolify-mcp)
@@ -7,12 +14,13 @@
7
14
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.8-blue.svg)](https://www.typescriptlang.org/)
8
15
  [![CI](https://github.com/StuMason/coolify-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/StuMason/coolify-mcp/actions/workflows/ci.yml)
9
16
  [![codecov](https://codecov.io/gh/StuMason/coolify-mcp/branch/main/graph/badge.svg)](https://codecov.io/gh/StuMason/coolify-mcp)
10
- [![MseeP.ai Security Assessment Badge](https://mseep.net/pr/stumason-coolify-mcp-badge.png)](https://mseep.ai/app/stumason-coolify-mcp)
11
17
 
12
18
  > **The most comprehensive MCP server for Coolify** - 42 optimized tools, smart diagnostics, documentation search, and batch operations for managing your self-hosted PaaS through AI assistants.
13
19
 
14
20
  📖 **Docs**: [**coolify-mcp.stumason.dev**](https://coolify-mcp.stumason.dev) — install guide, quickstart, full tools reference, MCP primer, Coolify API gotchas, contributing guide, and the public v3 roadmap.
15
21
 
22
+ > 💡 **Building a Laravel app?** Check out [**laravel-coolify**](https://github.com/StuMason/laravel-coolify) — deploy Laravel to Coolify with a Horizon-style dashboard, Artisan commands, and auto-generated Dockerfiles.
23
+
16
24
  A Model Context Protocol (MCP) server for [Coolify](https://coolify.io/), enabling AI assistants to manage and debug your Coolify instances through natural language.
17
25
 
18
26
  ## Features
@@ -1735,6 +1735,18 @@ describe('CoolifyClient', () => {
1735
1735
  expect(result).toEqual({ message: 'Restarted' });
1736
1736
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/services/test-uuid/restart', expect.objectContaining({ method: 'GET' }));
1737
1737
  });
1738
+ it('should restart a service with pull_latest', async () => {
1739
+ mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Restarted' }));
1740
+ const result = await client.restartService('test-uuid', true);
1741
+ expect(result).toEqual({ message: 'Restarted' });
1742
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/services/test-uuid/restart?latest=true', expect.objectContaining({ method: 'GET' }));
1743
+ });
1744
+ it('should restart a service without pull_latest by default', async () => {
1745
+ mockFetch.mockResolvedValueOnce(mockResponse({ message: 'Restarted' }));
1746
+ const result = await client.restartService('test-uuid', false);
1747
+ expect(result).toEqual({ message: 'Restarted' });
1748
+ expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/services/test-uuid/restart', expect.objectContaining({ method: 'GET' }));
1749
+ });
1738
1750
  });
1739
1751
  // =========================================================================
1740
1752
  // Service Environment Variables
@@ -1860,12 +1872,35 @@ describe('CoolifyClient', () => {
1860
1872
  });
1861
1873
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deployments/dep-uuid', expect.any(Object));
1862
1874
  });
1863
- it('should get a deployment with logs when includeLogs is true', async () => {
1864
- const deploymentWithLogs = { ...mockDeployment, logs: 'Build started...' };
1875
+ it('should get a deployment with logs when includeLogs is true, still projected through essential', async () => {
1876
+ const deploymentWithLogs = {
1877
+ ...mockDeployment,
1878
+ logs: 'Build started...',
1879
+ // Simulate raw upstream fields that must never leak through.
1880
+ application: { docker_compose: 'raw compose', manual_webhook_secret_github: 'whsec' },
1881
+ destination: { server: { settings: { sentinel_token: 'sentinel-secret' } } },
1882
+ };
1865
1883
  mockFetch.mockResolvedValueOnce(mockResponse(deploymentWithLogs));
1866
1884
  const result = await client.getDeployment('dep-uuid', { includeLogs: true });
1867
- // With includeLogs: true, returns full Deployment with logs
1868
- expect(result).toEqual(deploymentWithLogs);
1885
+ // With includeLogs: true, logs are attached to the essential projection —
1886
+ // raw upstream fields (application/server graph, secrets, id) never leak.
1887
+ expect(result).toEqual({
1888
+ uuid: 'dep-uuid',
1889
+ deployment_uuid: 'dep-123',
1890
+ application_uuid: undefined,
1891
+ application_name: 'test-app',
1892
+ server_name: undefined,
1893
+ status: 'finished',
1894
+ commit: undefined,
1895
+ force_rebuild: false,
1896
+ is_webhook: false,
1897
+ is_api: true,
1898
+ created_at: '2024-01-01',
1899
+ updated_at: '2024-01-01',
1900
+ logs_available: true,
1901
+ logs_info: 'Logs available (16 chars). Use lines param to retrieve.',
1902
+ logs: 'Build started...',
1903
+ });
1869
1904
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deployments/dep-uuid', expect.any(Object));
1870
1905
  });
1871
1906
  it('should include logs_info when deployment has logs but includeLogs is false', async () => {
@@ -1894,11 +1929,22 @@ describe('CoolifyClient', () => {
1894
1929
  expect(first.id).toBeUndefined();
1895
1930
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/deployments/applications/app-uuid', expect.any(Object));
1896
1931
  });
1897
- it('should return full deployments when includeLogs is true', async () => {
1898
- const withLogs = { ...mockDeployment, logs: 'build log stream' };
1932
+ it('should attach logs to the essential projection when includeLogs is true (never the raw object)', async () => {
1933
+ const withLogs = {
1934
+ ...mockDeployment,
1935
+ logs: 'build log stream',
1936
+ application: { docker_compose: 'raw compose' },
1937
+ destination: { server: { settings: { sentinel_token: 'sentinel-secret' } } },
1938
+ };
1899
1939
  mockFetch.mockResolvedValueOnce(mockResponse({ count: 1, deployments: [withLogs] }));
1900
1940
  const result = await client.listApplicationDeployments('app-uuid', { includeLogs: true });
1901
- expect(result).toEqual({ count: 1, deployments: [withLogs] });
1941
+ expect(result.count).toBe(1);
1942
+ expect(result.deployments).toHaveLength(1);
1943
+ const [first] = result.deployments;
1944
+ expect(first.logs).toBe('build log stream');
1945
+ expect(first).not.toHaveProperty('application');
1946
+ expect(first).not.toHaveProperty('destination');
1947
+ expect(first).not.toHaveProperty('id');
1902
1948
  });
1903
1949
  it('should tolerate a malformed envelope (missing deployments array)', async () => {
1904
1950
  mockFetch.mockResolvedValueOnce(mockResponse({}));
@@ -6,7 +6,7 @@
6
6
  * These tests verify MCP server instantiation and structure.
7
7
  */
8
8
  import { createRequire } from 'module';
9
- import { describe, it, expect, beforeEach, jest } from '@jest/globals';
9
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
10
10
  import { CoolifyMcpServer, VERSION, truncateLogs, getApplicationActions, getDeploymentActions, getPagination, } from '../lib/mcp-server.js';
11
11
  describe('CoolifyMcpServer v2', () => {
12
12
  let server;
@@ -494,6 +494,154 @@ describe('CoolifyMcpServer v2', () => {
494
494
  expect(updateData).not.toHaveProperty('uuid');
495
495
  });
496
496
  });
497
+ describe('database tool handler', () => {
498
+ // Regression for #217 — the database tool's create action didn't expose
499
+ // destination_uuid, so Coolify rejected creates on servers with more than
500
+ // one destination ("Server has multiple destinations. Please provide a
501
+ // destination_uuid.").
502
+ const callDatabase = async (srv, args) => {
503
+ const tool = srv._registeredTools['database'];
504
+ return tool.handler(args, {});
505
+ };
506
+ it('forwards destination_uuid to createPostgresql when provided', async () => {
507
+ const spy = jest
508
+ .spyOn(server['client'], 'createPostgresql')
509
+ .mockResolvedValue({ uuid: 'db-1' });
510
+ await callDatabase(server, {
511
+ action: 'create',
512
+ type: 'postgresql',
513
+ project_uuid: 'proj-uuid',
514
+ server_uuid: 'server-uuid',
515
+ destination_uuid: 'dest-uuid',
516
+ });
517
+ expect(spy).toHaveBeenCalledWith(expect.objectContaining({
518
+ destination_uuid: 'dest-uuid',
519
+ }));
520
+ });
521
+ it('omits destination_uuid from createPostgresql when not provided', async () => {
522
+ const spy = jest
523
+ .spyOn(server['client'], 'createPostgresql')
524
+ .mockResolvedValue({ uuid: 'db-2' });
525
+ await callDatabase(server, {
526
+ action: 'create',
527
+ type: 'postgresql',
528
+ project_uuid: 'proj-uuid',
529
+ server_uuid: 'server-uuid',
530
+ });
531
+ const forwarded = spy.mock.calls[0]?.[0];
532
+ expect(forwarded.destination_uuid).toBeUndefined();
533
+ });
534
+ });
535
+ describe('deployment tool handler (#232 essential projection)', () => {
536
+ // Regression for #232: `deployment {action: get, lines: N}` used to call
537
+ // getDeployment(uuid, { includeLogs: true }) and spread the RAW upstream
538
+ // payload into the response — leaking the destination server's
539
+ // logdrain_custom_config bearer token, sentinel_token, webhook secrets,
540
+ // and the full docker_compose/application graph. It must now always go
541
+ // through toDeploymentEssential(), with only the (string) logs attached.
542
+ const callDeployment = async (srv, args) => {
543
+ const tool = srv._registeredTools['deployment'];
544
+ return tool.handler(args, {});
545
+ };
546
+ // Mock the raw HTTP layer (not the client) so the test exercises the real
547
+ // CoolifyClient projection logic, not just the mcp-server spread.
548
+ const mockFetch = jest.fn();
549
+ let originalFetch;
550
+ beforeEach(() => {
551
+ originalFetch = global.fetch;
552
+ global.fetch = mockFetch;
553
+ });
554
+ afterEach(() => {
555
+ global.fetch = originalFetch;
556
+ mockFetch.mockReset();
557
+ });
558
+ function mockJsonResponse(data) {
559
+ return {
560
+ ok: true,
561
+ status: 200,
562
+ statusText: 'OK',
563
+ headers: new Headers({ 'Content-Type': 'application/json' }),
564
+ text: async () => JSON.stringify(data),
565
+ };
566
+ }
567
+ // A raw upstream deployment payload shaped like real Coolify responses:
568
+ // the full application graph plus the destination server object,
569
+ // including the secrets called out in #232.
570
+ function rawDeploymentWithSecrets(logsEntryCount) {
571
+ const logs = JSON.stringify(Array.from({ length: logsEntryCount }, (_, i) => ({
572
+ output: `log line ${i}`,
573
+ timestamp: `2026-07-02T00:00:0${i}Z`,
574
+ hidden: false,
575
+ })));
576
+ return {
577
+ id: 1,
578
+ uuid: 'dep-uuid',
579
+ deployment_uuid: 'dep-123',
580
+ application_uuid: 'app-uuid',
581
+ application_name: 'test-app',
582
+ server_name: 'test-server',
583
+ status: 'finished',
584
+ commit: 'abc123',
585
+ force_rebuild: false,
586
+ is_webhook: false,
587
+ is_api: true,
588
+ restart_only: false,
589
+ created_at: '2024-01-01',
590
+ updated_at: '2024-01-01',
591
+ logs,
592
+ // Raw upstream fields that must never leak through the projection:
593
+ application: {
594
+ uuid: 'app-uuid',
595
+ docker_compose: 'x'.repeat(5000),
596
+ docker_compose_raw: 'x'.repeat(5000),
597
+ custom_labels: 'a'.repeat(2000),
598
+ manual_webhook_secret_github: 'ghsecret',
599
+ manual_webhook_secret_gitlab: 'glsecret',
600
+ },
601
+ destination: {
602
+ server: {
603
+ uuid: 'server-uuid',
604
+ ip: '1.2.3.4',
605
+ settings: {
606
+ logdrain_custom_config: 'Bearer live-logdrain-token-abc123',
607
+ sentinel_token: 'live-sentinel-token-xyz789',
608
+ },
609
+ proxy: { config: 'y'.repeat(3000) },
610
+ },
611
+ },
612
+ };
613
+ }
614
+ it('returns essential fields + logs only, no leaked secrets or nested graphs', async () => {
615
+ mockFetch.mockResolvedValueOnce(mockJsonResponse(rawDeploymentWithSecrets(5)));
616
+ const result = await callDeployment(server, { action: 'get', uuid: 'dep-uuid', lines: 5 });
617
+ const text = result.content[0].text;
618
+ expect(text).not.toContain('logdrain');
619
+ expect(text).not.toContain('sentinel_token');
620
+ expect(text).not.toContain('manual_webhook_secret');
621
+ expect(text).not.toContain('docker_compose');
622
+ expect(text).not.toMatch(/"application":\s*{/);
623
+ expect(text).not.toMatch(/"server":\s*{/);
624
+ expect(text).not.toMatch(/"destination":\s*{/);
625
+ const parsed = JSON.parse(text);
626
+ expect(parsed.data).toMatchObject({
627
+ uuid: 'dep-uuid',
628
+ application_uuid: 'app-uuid',
629
+ application_name: 'test-app',
630
+ server_name: 'test-server',
631
+ status: 'finished',
632
+ });
633
+ expect(typeof parsed.data.logs).toBe('string');
634
+ expect(parsed.data).not.toHaveProperty('application');
635
+ expect(parsed.data).not.toHaveProperty('destination');
636
+ expect(parsed.data).not.toHaveProperty('id');
637
+ });
638
+ it('keeps the response under 20KB even with a bloated upstream payload', async () => {
639
+ mockFetch.mockResolvedValueOnce(mockJsonResponse(rawDeploymentWithSecrets(5)));
640
+ const result = await callDeployment(server, { action: 'get', uuid: 'dep-uuid', lines: 5 });
641
+ const text = result.content[0].text;
642
+ expect(text.length).toBeLessThan(20_000);
643
+ });
644
+ });
497
645
  });
498
646
  describe('truncateLogs', () => {
499
647
  // Plain text log tests
@@ -162,7 +162,7 @@ export declare class CoolifyClient {
162
162
  deleteService(uuid: string, options?: DeleteOptions): Promise<MessageResponse>;
163
163
  startService(uuid: string): Promise<MessageResponse>;
164
164
  stopService(uuid: string): Promise<MessageResponse>;
165
- restartService(uuid: string): Promise<MessageResponse>;
165
+ restartService(uuid: string, pullLatest?: boolean): Promise<MessageResponse>;
166
166
  /**
167
167
  * List env vars for a service.
168
168
  *
@@ -179,7 +179,7 @@ export declare class CoolifyClient {
179
179
  listDeployments(options?: ListOptions): Promise<Deployment[] | DeploymentSummary[]>;
180
180
  getDeployment(uuid: string, options?: {
181
181
  includeLogs?: boolean;
182
- }): Promise<Deployment | DeploymentEssential>;
182
+ }): Promise<DeploymentEssential>;
183
183
  deployByTagOrUuid(tagOrUuid: string, force?: boolean): Promise<MessageResponse>;
184
184
  /**
185
185
  * List deployments for an application.
@@ -190,13 +190,15 @@ export declare class CoolifyClient {
190
190
  * By default returns a DeploymentEssential summary (no `logs` field) because
191
191
  * each deployment's log blob can be 30–100KB, and a typical list has 20–35
192
192
  * deployments — exceeding MCP response token limits. Pass `includeLogs: true`
193
- * to get raw Deployment objects with full build logs.
193
+ * to also attach the raw log string to each essential projection (never the
194
+ * raw upstream deployment object, which also embeds the full application/server
195
+ * graph and secrets).
194
196
  */
195
197
  listApplicationDeployments(appUuid: string, options?: {
196
198
  includeLogs?: boolean;
197
199
  }): Promise<{
198
200
  count: number;
199
- deployments: Deployment[] | DeploymentEssential[];
201
+ deployments: DeploymentEssential[];
200
202
  }>;
201
203
  listTeams(): Promise<Team[]>;
202
204
  getTeam(id: number): Promise<Team>;
@@ -767,8 +767,9 @@ export class CoolifyClient {
767
767
  method: 'GET',
768
768
  });
769
769
  }
770
- async restartService(uuid) {
771
- return this.request(`/services/${uuid}/restart`, {
770
+ async restartService(uuid, pullLatest = false) {
771
+ const qs = pullLatest ? '?latest=true' : '';
772
+ return this.request(`/services/${uuid}/restart${qs}`, {
772
773
  method: 'GET',
773
774
  });
774
775
  }
@@ -818,7 +819,13 @@ export class CoolifyClient {
818
819
  }
819
820
  async getDeployment(uuid, options) {
820
821
  const deployment = await this.request(`/deployments/${uuid}`);
821
- return options?.includeLogs ? deployment : toDeploymentEssential(deployment);
822
+ const essential = toDeploymentEssential(deployment);
823
+ // Attach the raw log string (never the raw upstream object, which also embeds
824
+ // the full application/server graph and secrets) onto the essential projection.
825
+ if (options?.includeLogs && deployment.logs) {
826
+ essential.logs = deployment.logs;
827
+ }
828
+ return essential;
822
829
  }
823
830
  async deployByTagOrUuid(tagOrUuid, force = false) {
824
831
  // Detect if the value looks like a UUID or a tag name
@@ -834,14 +841,22 @@ export class CoolifyClient {
834
841
  * By default returns a DeploymentEssential summary (no `logs` field) because
835
842
  * each deployment's log blob can be 30–100KB, and a typical list has 20–35
836
843
  * deployments — exceeding MCP response token limits. Pass `includeLogs: true`
837
- * to get raw Deployment objects with full build logs.
844
+ * to also attach the raw log string to each essential projection (never the
845
+ * raw upstream deployment object, which also embeds the full application/server
846
+ * graph and secrets).
838
847
  */
839
848
  async listApplicationDeployments(appUuid, options) {
840
849
  const envelope = await this.request(`/deployments/applications/${appUuid}`);
841
850
  const deployments = Array.isArray(envelope?.deployments) ? envelope.deployments : [];
842
851
  return {
843
852
  count: typeof envelope?.count === 'number' ? envelope.count : deployments.length,
844
- deployments: options?.includeLogs ? deployments : deployments.map(toDeploymentEssential),
853
+ deployments: deployments.map((dep) => {
854
+ const essential = toDeploymentEssential(dep);
855
+ if (options?.includeLogs && dep.logs) {
856
+ essential.logs = dep.logs;
857
+ }
858
+ return essential;
859
+ }),
845
860
  };
846
861
  }
847
862
  // ===========================================================================
@@ -608,6 +608,10 @@ export class CoolifyMcpServer extends McpServer {
608
608
  server_uuid: z.string().optional(),
609
609
  project_uuid: z.string().optional(),
610
610
  environment_name: z.string().optional(),
611
+ destination_uuid: z
612
+ .string()
613
+ .optional()
614
+ .describe('Destination UUID. Required if server has multiple destinations.'),
611
615
  name: z.string().optional(),
612
616
  description: z.string().optional(),
613
617
  image: z.string().optional(),
@@ -723,7 +727,11 @@ export class CoolifyMcpServer extends McpServer {
723
727
  resource: z.enum(['application', 'database', 'service']),
724
728
  action: z.enum(['start', 'stop', 'restart']),
725
729
  uuid: z.string(),
726
- }, async ({ resource, action, uuid }) => {
730
+ pull_latest: z
731
+ .boolean()
732
+ .optional()
733
+ .describe('Pull latest images before restarting (services only)'),
734
+ }, async ({ resource, action, uuid, pull_latest }) => {
727
735
  const methods = {
728
736
  application: {
729
737
  start: (u) => this.client.startApplication(u),
@@ -738,7 +746,7 @@ export class CoolifyMcpServer extends McpServer {
738
746
  service: {
739
747
  start: (u) => this.client.startService(u),
740
748
  stop: (u) => this.client.stopService(u),
741
- restart: (u) => this.client.restartService(u),
749
+ restart: (u) => this.client.restartService(u, pull_latest),
742
750
  },
743
751
  };
744
752
  // Generate contextual actions based on resource type and action taken
@@ -895,9 +903,9 @@ export class CoolifyMcpServer extends McpServer {
895
903
  const p = page ?? 1;
896
904
  const ll = lines;
897
905
  return wrapWithActions(async () => {
898
- const deployment = (await this.client.getDeployment(uuid, {
906
+ const deployment = await this.client.getDeployment(uuid, {
899
907
  includeLogs: true,
900
- }));
908
+ });
901
909
  if (deployment.logs) {
902
910
  const result = truncateLogs(deployment.logs, ll, max_chars ?? 50000, p);
903
911
  deployment.logs = result.logs;
@@ -1131,4 +1131,5 @@ export interface DeploymentEssential {
1131
1131
  updated_at: string;
1132
1132
  logs_available?: boolean;
1133
1133
  logs_info?: string;
1134
+ logs?: string;
1134
1135
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@masonator/coolify-mcp",
3
3
  "scope": "@masonator",
4
- "version": "2.12.0",
4
+ "version": "2.12.1",
5
5
  "mcpName": "io.github.StuMason/coolify",
6
6
  "description": "MCP server for Coolify — 42 optimized tools for infrastructure management, diagnostics, and documentation search",
7
7
  "type": "module",
@@ -66,7 +66,7 @@
66
66
  "devDependencies": {
67
67
  "@eslint/js": "^10.0.1",
68
68
  "@types/jest": "^30.0.0",
69
- "@types/node": "^25.0.3",
69
+ "@types/node": "^26.0.0",
70
70
  "@typescript-eslint/eslint-plugin": "^8.51.0",
71
71
  "@typescript-eslint/parser": "^8.51.0",
72
72
  "dotenv": "^17.2.3",
@@ -88,7 +88,22 @@
88
88
  "node": ">=20"
89
89
  },
90
90
  "overrides": {
91
- "handlebars": "^4.7.9"
91
+ "handlebars": "^4.7.9",
92
+ "qs": "^6.15.2",
93
+ "hono": "^4.12.25",
94
+ "@hono/node-server": "^2.0.5",
95
+ "express-rate-limit": "^8.5.2",
96
+ "path-to-regexp": "^8.4.2",
97
+ "@modelcontextprotocol/sdk": {
98
+ "ajv": "^8.20.0"
99
+ },
100
+ "fast-uri": "^4.0.0",
101
+ "ip-address": "^10.2.0",
102
+ "flatted": "^3.4.2",
103
+ "brace-expansion": "^5.0.6",
104
+ "picomatch": "^4.0.4",
105
+ "js-yaml": "^4.2.0",
106
+ "markdown-it": "^14.2.0"
92
107
  },
93
108
  "lint-staged": {
94
109
  "*.{ts,js,json,md,yaml,yml}": "prettier --write",