@masonator/coolify-mcp 2.11.0 → 2.12.0

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
@@ -11,6 +11,8 @@
11
11
 
12
12
  > **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
13
 
14
+ 📖 **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
+
14
16
  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
17
 
16
18
  ## Features
@@ -3198,7 +3198,7 @@ describe('CoolifyClient', () => {
3198
3198
  uuid: 'env-1',
3199
3199
  key: 'DB_HOST',
3200
3200
  value: 'localhost',
3201
- is_build_time: false,
3201
+ is_buildtime: false,
3202
3202
  is_literal: false,
3203
3203
  is_multiline: false,
3204
3204
  is_preview: false,
@@ -3532,13 +3532,119 @@ describe('CoolifyClient', () => {
3532
3532
  // Resources endpoint
3533
3533
  // ===========================================================================
3534
3534
  describe('listResources', () => {
3535
- it('should list all resources', async () => {
3536
- const mockData = [{ uuid: 'r1', type: 'application' }];
3537
- mockFetch.mockResolvedValueOnce(mockResponse(mockData));
3535
+ const fluffyResource = {
3536
+ uuid: 'r1',
3537
+ name: 'my-app',
3538
+ type: 'application',
3539
+ status: 'running:healthy',
3540
+ // ---- fluff that the essential projection should drop ----
3541
+ id: 6,
3542
+ config_hash: 'd1b84af30038c5902cced139b19e5f6f',
3543
+ build_pack: 'dockerfile',
3544
+ health_check_path: '/',
3545
+ ports_exposes: '3500',
3546
+ dockerfile_location: '/Dockerfile',
3547
+ custom_labels: 'dHJhZWZpa...',
3548
+ docker_compose: null,
3549
+ };
3550
+ it('returns essential projection by default (uuid/name/type/status only)', async () => {
3551
+ mockFetch.mockResolvedValueOnce(mockResponse([fluffyResource]));
3538
3552
  const result = await client.listResources();
3539
- expect(result).toEqual(mockData);
3553
+ expect(result).toEqual([
3554
+ { uuid: 'r1', name: 'my-app', type: 'application', status: 'running:healthy' },
3555
+ ]);
3556
+ const [first] = result;
3557
+ expect(Object.keys(first).sort()).toEqual(['name', 'status', 'type', 'uuid']);
3540
3558
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/resources', expect.any(Object));
3541
3559
  });
3560
+ it('omits status when the resource has none', async () => {
3561
+ const noStatus = { ...fluffyResource };
3562
+ delete noStatus.status;
3563
+ mockFetch.mockResolvedValueOnce(mockResponse([noStatus]));
3564
+ const result = await client.listResources();
3565
+ expect(result).toEqual([{ uuid: 'r1', name: 'my-app', type: 'application' }]);
3566
+ expect('status' in result[0]).toBe(false);
3567
+ });
3568
+ it('returns the raw Coolify payload when include_full is true', async () => {
3569
+ mockFetch.mockResolvedValueOnce(mockResponse([fluffyResource]));
3570
+ const result = await client.listResources({ include_full: true });
3571
+ expect(result).toEqual([fluffyResource]);
3572
+ const [first] = result;
3573
+ expect(first.config_hash).toBe(fluffyResource.config_hash);
3574
+ expect(first.custom_labels).toBe(fluffyResource.custom_labels);
3575
+ });
3576
+ it('treats include_full=false as the default essential projection', async () => {
3577
+ mockFetch.mockResolvedValueOnce(mockResponse([fluffyResource]));
3578
+ const result = await client.listResources({ include_full: false });
3579
+ expect(Object.keys(result[0]).sort()).toEqual(['name', 'status', 'type', 'uuid']);
3580
+ });
3581
+ it('returns [] on an empty payload (default projection)', async () => {
3582
+ mockFetch.mockResolvedValueOnce(mockResponse([]));
3583
+ const result = await client.listResources();
3584
+ expect(result).toEqual([]);
3585
+ });
3586
+ it('returns [] on an empty payload (include_full=true, mask path)', async () => {
3587
+ mockFetch.mockResolvedValueOnce(mockResponse([]));
3588
+ const result = await client.listResources({ include_full: true });
3589
+ expect(result).toEqual([]);
3590
+ });
3591
+ it('drops status when Coolify returns a non-string shape (defensive)', async () => {
3592
+ // Hypothetical future Coolify divergence: status emitted as an object/null.
3593
+ // The essential projection must not propagate a non-string into a `string`-typed field.
3594
+ const objStatus = { ...fluffyResource, status: { state: 'running', healthy: true } };
3595
+ mockFetch.mockResolvedValueOnce(mockResponse([objStatus]));
3596
+ const result = await client.listResources();
3597
+ expect(result).toEqual([{ uuid: 'r1', name: 'my-app', type: 'application' }]);
3598
+ expect('status' in result[0]).toBe(false);
3599
+ });
3600
+ describe('sensitive-field masking on include_full', () => {
3601
+ const sensitiveResource = {
3602
+ ...fluffyResource,
3603
+ manual_webhook_secret_github: 'real-github-secret',
3604
+ manual_webhook_secret_gitlab: 'real-gitlab-secret',
3605
+ manual_webhook_secret_gitea: 'real-gitea-secret',
3606
+ manual_webhook_secret_bitbucket: 'real-bitbucket-secret',
3607
+ http_basic_auth_password: 'real-basic-auth-pwd',
3608
+ };
3609
+ it('masks webhook secrets and basic-auth password by default on include_full=true', async () => {
3610
+ mockFetch.mockResolvedValueOnce(mockResponse([sensitiveResource]));
3611
+ const [first] = (await client.listResources({ include_full: true }));
3612
+ expect(first.manual_webhook_secret_github).toBe('***');
3613
+ expect(first.manual_webhook_secret_gitlab).toBe('***');
3614
+ expect(first.manual_webhook_secret_gitea).toBe('***');
3615
+ expect(first.manual_webhook_secret_bitbucket).toBe('***');
3616
+ expect(first.http_basic_auth_password).toBe('***');
3617
+ expect(first.config_hash).toBe(sensitiveResource.config_hash);
3618
+ });
3619
+ it('round-trips plaintext when reveal=true', async () => {
3620
+ mockFetch.mockResolvedValueOnce(mockResponse([sensitiveResource]));
3621
+ const [first] = (await client.listResources({
3622
+ include_full: true,
3623
+ reveal: true,
3624
+ }));
3625
+ expect(first.manual_webhook_secret_github).toBe('real-github-secret');
3626
+ expect(first.http_basic_auth_password).toBe('real-basic-auth-pwd');
3627
+ });
3628
+ it('leaves null secret values untouched (Coolify uses null when unset)', async () => {
3629
+ const noSecrets = {
3630
+ ...fluffyResource,
3631
+ manual_webhook_secret_github: null,
3632
+ manual_webhook_secret_gitlab: null,
3633
+ manual_webhook_secret_gitea: null,
3634
+ manual_webhook_secret_bitbucket: null,
3635
+ http_basic_auth_password: null,
3636
+ };
3637
+ mockFetch.mockResolvedValueOnce(mockResponse([noSecrets]));
3638
+ const [first] = (await client.listResources({ include_full: true }));
3639
+ expect(first.manual_webhook_secret_github).toBeNull();
3640
+ expect(first.http_basic_auth_password).toBeNull();
3641
+ });
3642
+ it('reveal=true on the default projection is a no-op (no fluff to reveal)', async () => {
3643
+ mockFetch.mockResolvedValueOnce(mockResponse([sensitiveResource]));
3644
+ const result = await client.listResources({ reveal: true });
3645
+ expect(Object.keys(result[0]).sort()).toEqual(['name', 'status', 'type', 'uuid']);
3646
+ });
3647
+ });
3542
3648
  });
3543
3649
  // ===========================================================================
3544
3650
  // Health endpoint
@@ -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({
@@ -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;
@@ -262,7 +262,25 @@ export declare class CoolifyClient {
262
262
  createHetznerServer(data: CreateHetznerServerRequest): Promise<CreateHetznerServerResponse>;
263
263
  listGitHubAppRepositories(githubAppId: number): Promise<GitHubRepository[]>;
264
264
  listGitHubAppBranches(githubAppId: number, owner: string, repo: string): Promise<GitHubBranch[]>;
265
- listResources(): Promise<ResourceListItem[]>;
265
+ /**
266
+ * List every resource on the Coolify instance.
267
+ *
268
+ * Defaults to an essential projection ({@link ResourceListItem}: uuid, name,
269
+ * type, optional status) — Coolify's `/api/v1/resources` endpoint actually
270
+ * returns ~95 fields per row including the full build/healthcheck/limits
271
+ * config, which on a moderate instance can exceed 500 KB on a single call
272
+ * and blow MCP/LLM context budgets. Set `include_full: true` to opt back
273
+ * into the raw response shape ({@link ResourceListItemFull}).
274
+ *
275
+ * When `include_full: true`, sensitive fields ({@link SENSITIVE_RESOURCE_FIELDS}:
276
+ * webhook HMAC secrets + basic-auth password) are replaced with `'***'`
277
+ * unless the caller also passes `reveal: true`. Mirrors the v2.9.0 env_vars
278
+ * masking posture.
279
+ */
280
+ listResources(options?: {
281
+ include_full?: boolean;
282
+ reveal?: boolean;
283
+ }): Promise<ResourceListItem[] | ResourceListItemFull[]>;
266
284
  getHealth(): Promise<MessageResponse>;
267
285
  enableApi(): Promise<MessageResponse>;
268
286
  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
  */
@@ -1128,8 +1180,27 @@ export class CoolifyClient {
1128
1180
  // ===========================================================================
1129
1181
  // Resources endpoint
1130
1182
  // ===========================================================================
1131
- async listResources() {
1132
- return this.request('/resources');
1183
+ /**
1184
+ * List every resource on the Coolify instance.
1185
+ *
1186
+ * Defaults to an essential projection ({@link ResourceListItem}: uuid, name,
1187
+ * type, optional status) — Coolify's `/api/v1/resources` endpoint actually
1188
+ * returns ~95 fields per row including the full build/healthcheck/limits
1189
+ * config, which on a moderate instance can exceed 500 KB on a single call
1190
+ * and blow MCP/LLM context budgets. Set `include_full: true` to opt back
1191
+ * into the raw response shape ({@link ResourceListItemFull}).
1192
+ *
1193
+ * When `include_full: true`, sensitive fields ({@link SENSITIVE_RESOURCE_FIELDS}:
1194
+ * webhook HMAC secrets + basic-auth password) are replaced with `'***'`
1195
+ * unless the caller also passes `reveal: true`. Mirrors the v2.9.0 env_vars
1196
+ * masking posture.
1197
+ */
1198
+ async listResources(options) {
1199
+ const full = await this.request('/resources');
1200
+ if (options?.include_full !== true) {
1201
+ return full.map(toResourceListItemEssential);
1202
+ }
1203
+ return options.reveal === true ? full : full.map(maskResourceItemFull);
1133
1204
  }
1134
1205
  // ===========================================================================
1135
1206
  // Health endpoint
@@ -1469,12 +1469,16 @@ export class CoolifyMcpServer extends McpServer {
1469
1469
  // =========================================================================
1470
1470
  // System (1 tool - health/list_resources/api_control consolidated)
1471
1471
  // =========================================================================
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 }) => {
1472
+ 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).', {
1473
+ action: z.enum(['health', 'list_resources', 'enable_api', 'disable_api']),
1474
+ include_full: z.boolean().optional(),
1475
+ reveal: z.boolean().optional(),
1476
+ }, async ({ action, include_full, reveal }) => {
1473
1477
  switch (action) {
1474
1478
  case 'health':
1475
1479
  return wrap(() => this.client.getHealth());
1476
1480
  case 'list_resources':
1477
- return wrap(() => this.client.listResources());
1481
+ return wrap(() => this.client.listResources({ include_full, reveal }));
1478
1482
  case 'enable_api':
1479
1483
  return wrap(() => this.client.enableApi());
1480
1484
  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>;
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.0",
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",