@masonator/coolify-mcp 2.11.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,10 +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
 
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.
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
+
14
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.
15
25
 
16
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({}));
@@ -3198,7 +3244,7 @@ describe('CoolifyClient', () => {
3198
3244
  uuid: 'env-1',
3199
3245
  key: 'DB_HOST',
3200
3246
  value: 'localhost',
3201
- is_build_time: false,
3247
+ is_buildtime: false,
3202
3248
  is_literal: false,
3203
3249
  is_multiline: false,
3204
3250
  is_preview: false,
@@ -3532,13 +3578,119 @@ describe('CoolifyClient', () => {
3532
3578
  // Resources endpoint
3533
3579
  // ===========================================================================
3534
3580
  describe('listResources', () => {
3535
- it('should list all resources', async () => {
3536
- const mockData = [{ uuid: 'r1', type: 'application' }];
3537
- mockFetch.mockResolvedValueOnce(mockResponse(mockData));
3581
+ const fluffyResource = {
3582
+ uuid: 'r1',
3583
+ name: 'my-app',
3584
+ type: 'application',
3585
+ status: 'running:healthy',
3586
+ // ---- fluff that the essential projection should drop ----
3587
+ id: 6,
3588
+ config_hash: 'd1b84af30038c5902cced139b19e5f6f',
3589
+ build_pack: 'dockerfile',
3590
+ health_check_path: '/',
3591
+ ports_exposes: '3500',
3592
+ dockerfile_location: '/Dockerfile',
3593
+ custom_labels: 'dHJhZWZpa...',
3594
+ docker_compose: null,
3595
+ };
3596
+ it('returns essential projection by default (uuid/name/type/status only)', async () => {
3597
+ mockFetch.mockResolvedValueOnce(mockResponse([fluffyResource]));
3538
3598
  const result = await client.listResources();
3539
- expect(result).toEqual(mockData);
3599
+ expect(result).toEqual([
3600
+ { uuid: 'r1', name: 'my-app', type: 'application', status: 'running:healthy' },
3601
+ ]);
3602
+ const [first] = result;
3603
+ expect(Object.keys(first).sort()).toEqual(['name', 'status', 'type', 'uuid']);
3540
3604
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/resources', expect.any(Object));
3541
3605
  });
3606
+ it('omits status when the resource has none', async () => {
3607
+ const noStatus = { ...fluffyResource };
3608
+ delete noStatus.status;
3609
+ mockFetch.mockResolvedValueOnce(mockResponse([noStatus]));
3610
+ const result = await client.listResources();
3611
+ expect(result).toEqual([{ uuid: 'r1', name: 'my-app', type: 'application' }]);
3612
+ expect('status' in result[0]).toBe(false);
3613
+ });
3614
+ it('returns the raw Coolify payload when include_full is true', async () => {
3615
+ mockFetch.mockResolvedValueOnce(mockResponse([fluffyResource]));
3616
+ const result = await client.listResources({ include_full: true });
3617
+ expect(result).toEqual([fluffyResource]);
3618
+ const [first] = result;
3619
+ expect(first.config_hash).toBe(fluffyResource.config_hash);
3620
+ expect(first.custom_labels).toBe(fluffyResource.custom_labels);
3621
+ });
3622
+ it('treats include_full=false as the default essential projection', async () => {
3623
+ mockFetch.mockResolvedValueOnce(mockResponse([fluffyResource]));
3624
+ const result = await client.listResources({ include_full: false });
3625
+ expect(Object.keys(result[0]).sort()).toEqual(['name', 'status', 'type', 'uuid']);
3626
+ });
3627
+ it('returns [] on an empty payload (default projection)', async () => {
3628
+ mockFetch.mockResolvedValueOnce(mockResponse([]));
3629
+ const result = await client.listResources();
3630
+ expect(result).toEqual([]);
3631
+ });
3632
+ it('returns [] on an empty payload (include_full=true, mask path)', async () => {
3633
+ mockFetch.mockResolvedValueOnce(mockResponse([]));
3634
+ const result = await client.listResources({ include_full: true });
3635
+ expect(result).toEqual([]);
3636
+ });
3637
+ it('drops status when Coolify returns a non-string shape (defensive)', async () => {
3638
+ // Hypothetical future Coolify divergence: status emitted as an object/null.
3639
+ // The essential projection must not propagate a non-string into a `string`-typed field.
3640
+ const objStatus = { ...fluffyResource, status: { state: 'running', healthy: true } };
3641
+ mockFetch.mockResolvedValueOnce(mockResponse([objStatus]));
3642
+ const result = await client.listResources();
3643
+ expect(result).toEqual([{ uuid: 'r1', name: 'my-app', type: 'application' }]);
3644
+ expect('status' in result[0]).toBe(false);
3645
+ });
3646
+ describe('sensitive-field masking on include_full', () => {
3647
+ const sensitiveResource = {
3648
+ ...fluffyResource,
3649
+ manual_webhook_secret_github: 'real-github-secret',
3650
+ manual_webhook_secret_gitlab: 'real-gitlab-secret',
3651
+ manual_webhook_secret_gitea: 'real-gitea-secret',
3652
+ manual_webhook_secret_bitbucket: 'real-bitbucket-secret',
3653
+ http_basic_auth_password: 'real-basic-auth-pwd',
3654
+ };
3655
+ it('masks webhook secrets and basic-auth password by default on include_full=true', async () => {
3656
+ mockFetch.mockResolvedValueOnce(mockResponse([sensitiveResource]));
3657
+ const [first] = (await client.listResources({ include_full: true }));
3658
+ expect(first.manual_webhook_secret_github).toBe('***');
3659
+ expect(first.manual_webhook_secret_gitlab).toBe('***');
3660
+ expect(first.manual_webhook_secret_gitea).toBe('***');
3661
+ expect(first.manual_webhook_secret_bitbucket).toBe('***');
3662
+ expect(first.http_basic_auth_password).toBe('***');
3663
+ expect(first.config_hash).toBe(sensitiveResource.config_hash);
3664
+ });
3665
+ it('round-trips plaintext when reveal=true', async () => {
3666
+ mockFetch.mockResolvedValueOnce(mockResponse([sensitiveResource]));
3667
+ const [first] = (await client.listResources({
3668
+ include_full: true,
3669
+ reveal: true,
3670
+ }));
3671
+ expect(first.manual_webhook_secret_github).toBe('real-github-secret');
3672
+ expect(first.http_basic_auth_password).toBe('real-basic-auth-pwd');
3673
+ });
3674
+ it('leaves null secret values untouched (Coolify uses null when unset)', async () => {
3675
+ const noSecrets = {
3676
+ ...fluffyResource,
3677
+ manual_webhook_secret_github: null,
3678
+ manual_webhook_secret_gitlab: null,
3679
+ manual_webhook_secret_gitea: null,
3680
+ manual_webhook_secret_bitbucket: null,
3681
+ http_basic_auth_password: null,
3682
+ };
3683
+ mockFetch.mockResolvedValueOnce(mockResponse([noSecrets]));
3684
+ const [first] = (await client.listResources({ include_full: true }));
3685
+ expect(first.manual_webhook_secret_github).toBeNull();
3686
+ expect(first.http_basic_auth_password).toBeNull();
3687
+ });
3688
+ it('reveal=true on the default projection is a no-op (no fluff to reveal)', async () => {
3689
+ mockFetch.mockResolvedValueOnce(mockResponse([sensitiveResource]));
3690
+ const result = await client.listResources({ reveal: true });
3691
+ expect(Object.keys(result[0]).sort()).toEqual(['name', 'status', 'type', 'uuid']);
3692
+ });
3693
+ });
3542
3694
  });
3543
3695
  // ===========================================================================
3544
3696
  // Health endpoint
@@ -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;
@@ -300,6 +300,22 @@ describe('CoolifyMcpServer v2', () => {
300
300
  expect(result.content[0].text).toContain('key, value required');
301
301
  });
302
302
  });
303
+ describe('system tool handler', () => {
304
+ const callSystem = async (srv, args) => {
305
+ const tool = srv._registeredTools['system'];
306
+ return tool.handler(args, {});
307
+ };
308
+ it('forwards include_full and reveal to listResources', async () => {
309
+ const spy = jest.spyOn(server['client'], 'listResources').mockResolvedValue([]);
310
+ await callSystem(server, { action: 'list_resources', include_full: true, reveal: true });
311
+ expect(spy).toHaveBeenCalledWith({ include_full: true, reveal: true });
312
+ });
313
+ it('calls listResources with undefined flags when neither is passed', async () => {
314
+ const spy = jest.spyOn(server['client'], 'listResources').mockResolvedValue([]);
315
+ await callSystem(server, { action: 'list_resources' });
316
+ expect(spy).toHaveBeenCalledWith({ include_full: undefined, reveal: undefined });
317
+ });
318
+ });
303
319
  describe('bulk_env_update tool handler', () => {
304
320
  it('forwards is_buildtime/is_runtime to bulkEnvUpdate', async () => {
305
321
  const spy = jest.spyOn(server['client'], 'bulkEnvUpdate').mockResolvedValue({
@@ -478,6 +494,154 @@ describe('CoolifyMcpServer v2', () => {
478
494
  expect(updateData).not.toHaveProperty('uuid');
479
495
  });
480
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
+ });
481
645
  });
482
646
  describe('truncateLogs', () => {
483
647
  // Plain text log tests
@@ -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, DeploymentEssential, Team, TeamMember, PrivateKey, CreatePrivateKeyRequest, UpdatePrivateKeyRequest, GitHubApp, CreateGitHubAppRequest, UpdateGitHubAppRequest, GitHubAppUpdateResponse, CloudToken, CreateCloudTokenRequest, UpdateCloudTokenRequest, CloudTokenValidation, Version, StorageListResponse, CreateStorageRequest, UpdateStorageRequest, ScheduledTask, ScheduledTaskExecution, CreateScheduledTaskRequest, UpdateScheduledTaskRequest, HetznerLocation, HetznerServerType, HetznerImage, HetznerSSHKey, CreateHetznerServerRequest, CreateHetznerServerResponse, GitHubRepository, GitHubBranch, ApplicationDiagnostic, ServerDiagnostic, InfrastructureIssuesReport, BatchOperationResult, ResourceListItem } 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, StorageListResponse, CreateStorageRequest, UpdateStorageRequest, ScheduledTask, ScheduledTaskExecution, CreateScheduledTaskRequest, UpdateScheduledTaskRequest, HetznerLocation, HetznerServerType, HetznerImage, HetznerSSHKey, CreateHetznerServerRequest, CreateHetznerServerResponse, GitHubRepository, GitHubBranch, ApplicationDiagnostic, ServerDiagnostic, InfrastructureIssuesReport, BatchOperationResult, ResourceListItem, ResourceListItemFull } from '../types/coolify.js';
6
6
  export interface ListOptions {
7
7
  page?: number;
8
8
  per_page?: number;
@@ -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>;
@@ -262,7 +264,25 @@ export declare class CoolifyClient {
262
264
  createHetznerServer(data: CreateHetznerServerRequest): Promise<CreateHetznerServerResponse>;
263
265
  listGitHubAppRepositories(githubAppId: number): Promise<GitHubRepository[]>;
264
266
  listGitHubAppBranches(githubAppId: number, owner: string, repo: string): Promise<GitHubBranch[]>;
265
- listResources(): Promise<ResourceListItem[]>;
267
+ /**
268
+ * List every resource on the Coolify instance.
269
+ *
270
+ * Defaults to an essential projection ({@link ResourceListItem}: uuid, name,
271
+ * type, optional status) — Coolify's `/api/v1/resources` endpoint actually
272
+ * returns ~95 fields per row including the full build/healthcheck/limits
273
+ * config, which on a moderate instance can exceed 500 KB on a single call
274
+ * and blow MCP/LLM context budgets. Set `include_full: true` to opt back
275
+ * into the raw response shape ({@link ResourceListItemFull}).
276
+ *
277
+ * When `include_full: true`, sensitive fields ({@link SENSITIVE_RESOURCE_FIELDS}:
278
+ * webhook HMAC secrets + basic-auth password) are replaced with `'***'`
279
+ * unless the caller also passes `reveal: true`. Mirrors the v2.9.0 env_vars
280
+ * masking posture.
281
+ */
282
+ listResources(options?: {
283
+ include_full?: boolean;
284
+ reveal?: boolean;
285
+ }): Promise<ResourceListItem[] | ResourceListItemFull[]>;
266
286
  getHealth(): Promise<MessageResponse>;
267
287
  enableApi(): Promise<MessageResponse>;
268
288
  disableApi(): Promise<MessageResponse>;
@@ -180,6 +180,58 @@ function maskEnvVarSummary(envVar) {
180
180
  value: MASKED_VALUE,
181
181
  };
182
182
  }
183
+ /**
184
+ * Project a full Coolify resource row down to {@link ResourceListItem} — the
185
+ * four fields callers actually need for enumeration (uuid, name, type, status).
186
+ * Drops the ~90 extra fields Coolify returns by default to keep MCP token
187
+ * budgets sane.
188
+ */
189
+ function toResourceListItemEssential(item) {
190
+ const essential = {
191
+ uuid: item.uuid,
192
+ name: item.name,
193
+ type: item.type,
194
+ };
195
+ if (typeof item.status === 'string') {
196
+ essential.status = item.status;
197
+ }
198
+ return essential;
199
+ }
200
+ /**
201
+ * Per-resource sensitive fields returned by Coolify's `/api/v1/resources`
202
+ * endpoint that are masked by default in {@link CoolifyClient.listResources}
203
+ * when `include_full: true` is passed. Mirrors the v2.9.0 env-var masking
204
+ * posture: the underlying API exposes these via the same access token, but
205
+ * the MCP layer narrows the trust boundary so an LLM client that was granted
206
+ * "list resources" doesn't silently exfiltrate webhook HMAC secrets or
207
+ * basic-auth credentials.
208
+ *
209
+ * The `manual_webhook_secret_*` fields are HMAC signing keys for inbound
210
+ * deploy webhooks — anyone with one can forge deploys for that repo
211
+ * independently of the Coolify API token. `http_basic_auth_password` is the
212
+ * password gating front-of-app access.
213
+ */
214
+ const SENSITIVE_RESOURCE_FIELDS = [
215
+ 'manual_webhook_secret_github',
216
+ 'manual_webhook_secret_gitlab',
217
+ 'manual_webhook_secret_gitea',
218
+ 'manual_webhook_secret_bitbucket',
219
+ 'http_basic_auth_password',
220
+ ];
221
+ /**
222
+ * Replace each {@link SENSITIVE_RESOURCE_FIELDS} entry with `'***'` on a full
223
+ * resource row. Null/undefined values are preserved (since `null` conveys
224
+ * "no secret set" and matters to callers); only populated values get masked.
225
+ */
226
+ function maskResourceItemFull(item) {
227
+ const masked = { ...item };
228
+ for (const field of SENSITIVE_RESOURCE_FIELDS) {
229
+ if (masked[field] != null) {
230
+ masked[field] = MASKED_VALUE;
231
+ }
232
+ }
233
+ return masked;
234
+ }
183
235
  /**
184
236
  * HTTP client for the Coolify API
185
237
  */
@@ -715,8 +767,9 @@ export class CoolifyClient {
715
767
  method: 'GET',
716
768
  });
717
769
  }
718
- async restartService(uuid) {
719
- 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}`, {
720
773
  method: 'GET',
721
774
  });
722
775
  }
@@ -766,7 +819,13 @@ export class CoolifyClient {
766
819
  }
767
820
  async getDeployment(uuid, options) {
768
821
  const deployment = await this.request(`/deployments/${uuid}`);
769
- 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;
770
829
  }
771
830
  async deployByTagOrUuid(tagOrUuid, force = false) {
772
831
  // Detect if the value looks like a UUID or a tag name
@@ -782,14 +841,22 @@ export class CoolifyClient {
782
841
  * By default returns a DeploymentEssential summary (no `logs` field) because
783
842
  * each deployment's log blob can be 30–100KB, and a typical list has 20–35
784
843
  * deployments — exceeding MCP response token limits. Pass `includeLogs: true`
785
- * 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).
786
847
  */
787
848
  async listApplicationDeployments(appUuid, options) {
788
849
  const envelope = await this.request(`/deployments/applications/${appUuid}`);
789
850
  const deployments = Array.isArray(envelope?.deployments) ? envelope.deployments : [];
790
851
  return {
791
852
  count: typeof envelope?.count === 'number' ? envelope.count : deployments.length,
792
- 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
+ }),
793
860
  };
794
861
  }
795
862
  // ===========================================================================
@@ -1128,8 +1195,27 @@ export class CoolifyClient {
1128
1195
  // ===========================================================================
1129
1196
  // Resources endpoint
1130
1197
  // ===========================================================================
1131
- async listResources() {
1132
- return this.request('/resources');
1198
+ /**
1199
+ * List every resource on the Coolify instance.
1200
+ *
1201
+ * Defaults to an essential projection ({@link ResourceListItem}: uuid, name,
1202
+ * type, optional status) — Coolify's `/api/v1/resources` endpoint actually
1203
+ * returns ~95 fields per row including the full build/healthcheck/limits
1204
+ * config, which on a moderate instance can exceed 500 KB on a single call
1205
+ * and blow MCP/LLM context budgets. Set `include_full: true` to opt back
1206
+ * into the raw response shape ({@link ResourceListItemFull}).
1207
+ *
1208
+ * When `include_full: true`, sensitive fields ({@link SENSITIVE_RESOURCE_FIELDS}:
1209
+ * webhook HMAC secrets + basic-auth password) are replaced with `'***'`
1210
+ * unless the caller also passes `reveal: true`. Mirrors the v2.9.0 env_vars
1211
+ * masking posture.
1212
+ */
1213
+ async listResources(options) {
1214
+ const full = await this.request('/resources');
1215
+ if (options?.include_full !== true) {
1216
+ return full.map(toResourceListItemEssential);
1217
+ }
1218
+ return options.reveal === true ? full : full.map(maskResourceItemFull);
1133
1219
  }
1134
1220
  // ===========================================================================
1135
1221
  // Health endpoint
@@ -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;
@@ -1469,12 +1477,16 @@ export class CoolifyMcpServer extends McpServer {
1469
1477
  // =========================================================================
1470
1478
  // System (1 tool - health/list_resources/api_control consolidated)
1471
1479
  // =========================================================================
1472
- this.tool('system', 'System operations: health/list_resources/enable_api/disable_api', { action: z.enum(['health', 'list_resources', 'enable_api', 'disable_api']) }, async ({ action }) => {
1480
+ this.tool('system', 'System operations: health/list_resources/enable_api/disable_api. `list_resources` defaults to an essential projection (uuid/name/type/status) to keep token budgets sane on instances with many resources; pass `include_full: true` for the raw Coolify payload. When `include_full: true`, webhook HMAC secrets and basic-auth password are masked unless `reveal: true` is also set (matches the `env_vars` `reveal` ergonomics).', {
1481
+ action: z.enum(['health', 'list_resources', 'enable_api', 'disable_api']),
1482
+ include_full: z.boolean().optional(),
1483
+ reveal: z.boolean().optional(),
1484
+ }, async ({ action, include_full, reveal }) => {
1473
1485
  switch (action) {
1474
1486
  case 'health':
1475
1487
  return wrap(() => this.client.getHealth());
1476
1488
  case 'list_resources':
1477
- return wrap(() => this.client.listResources());
1489
+ return wrap(() => this.client.listResources({ include_full, reveal }));
1478
1490
  case 'enable_api':
1479
1491
  return wrap(() => this.client.enableApi());
1480
1492
  case 'disable_api':
@@ -1093,6 +1093,14 @@ export interface ResourceListItem {
1093
1093
  type: 'server' | 'application' | 'database' | 'service' | string;
1094
1094
  status?: string;
1095
1095
  }
1096
+ /**
1097
+ * Full Coolify `/api/v1/resources` row — the typed essentials plus every other
1098
+ * field Coolify returns (build/healthcheck/limits/git/docker-compose config,
1099
+ * etc.). Only surfaced when the caller passes `include_full: true`; the default
1100
+ * `listResources()` response uses {@link ResourceListItem} to keep MCP token
1101
+ * budgets sane on instances with many resources.
1102
+ */
1103
+ export type ResourceListItemFull = ResourceListItem & Record<string, unknown>;
1096
1104
  export interface ResponseAction {
1097
1105
  tool: string;
1098
1106
  args: Record<string, string | number | boolean>;
@@ -1123,4 +1131,5 @@ export interface DeploymentEssential {
1123
1131
  updated_at: string;
1124
1132
  logs_available?: boolean;
1125
1133
  logs_info?: string;
1134
+ logs?: string;
1126
1135
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@masonator/coolify-mcp",
3
3
  "scope": "@masonator",
4
- "version": "2.11.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",