@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 +11 -1
- package/dist/__tests__/coolify-client.test.js +164 -12
- package/dist/__tests__/mcp-server.test.js +165 -1
- package/dist/lib/coolify-client.d.ts +26 -6
- package/dist/lib/coolify-client.js +93 -7
- package/dist/lib/mcp-server.js +18 -6
- package/dist/types/coolify.d.ts +9 -0
- package/package.json +18 -3
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
|
[](https://www.npmjs.com/package/@masonator/coolify-mcp)
|
|
@@ -7,10 +14,13 @@
|
|
|
7
14
|
[](https://www.typescriptlang.org/)
|
|
8
15
|
[](https://github.com/StuMason/coolify-mcp/actions/workflows/ci.yml)
|
|
9
16
|
[](https://codecov.io/gh/StuMason/coolify-mcp)
|
|
10
|
-
[](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 = {
|
|
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,
|
|
1868
|
-
|
|
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
|
|
1898
|
-
const withLogs = {
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
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(
|
|
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<
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
1132
|
-
|
|
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
|
package/dist/lib/mcp-server.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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':
|
package/dist/types/coolify.d.ts
CHANGED
|
@@ -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.
|
|
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": "^
|
|
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",
|