@masonator/coolify-mcp 2.12.0 → 2.13.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 +46 -4
- package/dist/__tests__/coolify-client.test.js +137 -8
- package/dist/__tests__/mcp-server.test.js +481 -1
- package/dist/lib/coolify-client.d.ts +21 -6
- package/dist/lib/coolify-client.js +60 -5
- package/dist/lib/mcp-server.d.ts +33 -0
- package/dist/lib/mcp-server.js +316 -8
- package/dist/types/coolify.d.ts +15 -0
- package/package.json +19 -3
|
@@ -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;
|
|
@@ -63,6 +63,7 @@ describe('CoolifyMcpServer v2', () => {
|
|
|
63
63
|
expect(typeof client.createApplicationPrivateGH).toBe('function');
|
|
64
64
|
expect(typeof client.createApplicationPrivateKey).toBe('function');
|
|
65
65
|
expect(typeof client.createApplicationDockerImage).toBe('function');
|
|
66
|
+
expect(typeof client.createApplicationDockerfile).toBe('function');
|
|
66
67
|
expect(typeof client.updateApplication).toBe('function');
|
|
67
68
|
expect(typeof client.deleteApplication).toBe('function');
|
|
68
69
|
expect(typeof client.getApplicationLogs).toBe('function');
|
|
@@ -474,6 +475,36 @@ describe('CoolifyMcpServer v2', () => {
|
|
|
474
475
|
expect(forwarded).not.toHaveProperty('install_command');
|
|
475
476
|
expect(forwarded).not.toHaveProperty('dockerfile_location');
|
|
476
477
|
});
|
|
478
|
+
it('forwards fields in create_dockerfile', async () => {
|
|
479
|
+
const spy = jest
|
|
480
|
+
.spyOn(server['client'], 'createApplicationDockerfile')
|
|
481
|
+
.mockResolvedValue({ uuid: 'app-5' });
|
|
482
|
+
await callApplication(server, {
|
|
483
|
+
action: 'create_dockerfile',
|
|
484
|
+
project_uuid: 'proj-uuid',
|
|
485
|
+
server_uuid: 'server-uuid',
|
|
486
|
+
dockerfile: 'FROM node:20\nCMD ["node", "index.js"]',
|
|
487
|
+
dockerfile_location: '/Dockerfile',
|
|
488
|
+
ports_exposes: '3000',
|
|
489
|
+
base_directory: '/apps/api',
|
|
490
|
+
});
|
|
491
|
+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
|
|
492
|
+
project_uuid: 'proj-uuid',
|
|
493
|
+
server_uuid: 'server-uuid',
|
|
494
|
+
dockerfile: 'FROM node:20\nCMD ["node", "index.js"]',
|
|
495
|
+
dockerfile_location: '/Dockerfile',
|
|
496
|
+
ports_exposes: '3000',
|
|
497
|
+
base_directory: '/apps/api',
|
|
498
|
+
}));
|
|
499
|
+
});
|
|
500
|
+
it('returns required-param error when create_dockerfile is missing dockerfile', async () => {
|
|
501
|
+
const result = (await callApplication(server, {
|
|
502
|
+
action: 'create_dockerfile',
|
|
503
|
+
project_uuid: 'proj-uuid',
|
|
504
|
+
server_uuid: 'server-uuid',
|
|
505
|
+
}));
|
|
506
|
+
expect(result.content[0].text).toContain('project_uuid, server_uuid, dockerfile required');
|
|
507
|
+
});
|
|
477
508
|
it('forwards dockerfile_target_build through update (PATCH-only)', async () => {
|
|
478
509
|
const spy = jest.spyOn(server['client'], 'updateApplication').mockResolvedValue({});
|
|
479
510
|
await callApplication(server, {
|
|
@@ -494,6 +525,455 @@ describe('CoolifyMcpServer v2', () => {
|
|
|
494
525
|
expect(updateData).not.toHaveProperty('uuid');
|
|
495
526
|
});
|
|
496
527
|
});
|
|
528
|
+
describe('database tool handler', () => {
|
|
529
|
+
// Regression for #217 — the database tool's create action didn't expose
|
|
530
|
+
// destination_uuid, so Coolify rejected creates on servers with more than
|
|
531
|
+
// one destination ("Server has multiple destinations. Please provide a
|
|
532
|
+
// destination_uuid.").
|
|
533
|
+
const callDatabase = async (srv, args) => {
|
|
534
|
+
const tool = srv._registeredTools['database'];
|
|
535
|
+
return tool.handler(args, {});
|
|
536
|
+
};
|
|
537
|
+
it('forwards destination_uuid to createPostgresql when provided', async () => {
|
|
538
|
+
const spy = jest
|
|
539
|
+
.spyOn(server['client'], 'createPostgresql')
|
|
540
|
+
.mockResolvedValue({ uuid: 'db-1' });
|
|
541
|
+
await callDatabase(server, {
|
|
542
|
+
action: 'create',
|
|
543
|
+
type: 'postgresql',
|
|
544
|
+
project_uuid: 'proj-uuid',
|
|
545
|
+
server_uuid: 'server-uuid',
|
|
546
|
+
destination_uuid: 'dest-uuid',
|
|
547
|
+
});
|
|
548
|
+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
|
|
549
|
+
destination_uuid: 'dest-uuid',
|
|
550
|
+
}));
|
|
551
|
+
});
|
|
552
|
+
it('omits destination_uuid from createPostgresql when not provided', async () => {
|
|
553
|
+
const spy = jest
|
|
554
|
+
.spyOn(server['client'], 'createPostgresql')
|
|
555
|
+
.mockResolvedValue({ uuid: 'db-2' });
|
|
556
|
+
await callDatabase(server, {
|
|
557
|
+
action: 'create',
|
|
558
|
+
type: 'postgresql',
|
|
559
|
+
project_uuid: 'proj-uuid',
|
|
560
|
+
server_uuid: 'server-uuid',
|
|
561
|
+
});
|
|
562
|
+
const forwarded = spy.mock.calls[0]?.[0];
|
|
563
|
+
expect(forwarded.destination_uuid).toBeUndefined();
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
describe('deployment tool handler (#232 essential projection)', () => {
|
|
567
|
+
// Regression for #232: `deployment {action: get, lines: N}` used to call
|
|
568
|
+
// getDeployment(uuid, { includeLogs: true }) and spread the RAW upstream
|
|
569
|
+
// payload into the response — leaking the destination server's
|
|
570
|
+
// logdrain_custom_config bearer token, sentinel_token, webhook secrets,
|
|
571
|
+
// and the full docker_compose/application graph. It must now always go
|
|
572
|
+
// through toDeploymentEssential(), with only the (string) logs attached.
|
|
573
|
+
const callDeployment = async (srv, args) => {
|
|
574
|
+
const tool = srv._registeredTools['deployment'];
|
|
575
|
+
return tool.handler(args, {});
|
|
576
|
+
};
|
|
577
|
+
// Mock the raw HTTP layer (not the client) so the test exercises the real
|
|
578
|
+
// CoolifyClient projection logic, not just the mcp-server spread.
|
|
579
|
+
const mockFetch = jest.fn();
|
|
580
|
+
let originalFetch;
|
|
581
|
+
beforeEach(() => {
|
|
582
|
+
originalFetch = global.fetch;
|
|
583
|
+
global.fetch = mockFetch;
|
|
584
|
+
});
|
|
585
|
+
afterEach(() => {
|
|
586
|
+
global.fetch = originalFetch;
|
|
587
|
+
mockFetch.mockReset();
|
|
588
|
+
});
|
|
589
|
+
function mockJsonResponse(data) {
|
|
590
|
+
return {
|
|
591
|
+
ok: true,
|
|
592
|
+
status: 200,
|
|
593
|
+
statusText: 'OK',
|
|
594
|
+
headers: new Headers({ 'Content-Type': 'application/json' }),
|
|
595
|
+
text: async () => JSON.stringify(data),
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
// A raw upstream deployment payload shaped like real Coolify responses:
|
|
599
|
+
// the full application graph plus the destination server object,
|
|
600
|
+
// including the secrets called out in #232.
|
|
601
|
+
function rawDeploymentWithSecrets(logsEntryCount) {
|
|
602
|
+
const logs = JSON.stringify(Array.from({ length: logsEntryCount }, (_, i) => ({
|
|
603
|
+
output: `log line ${i}`,
|
|
604
|
+
timestamp: `2026-07-02T00:00:0${i}Z`,
|
|
605
|
+
hidden: false,
|
|
606
|
+
})));
|
|
607
|
+
return {
|
|
608
|
+
id: 1,
|
|
609
|
+
uuid: 'dep-uuid',
|
|
610
|
+
deployment_uuid: 'dep-123',
|
|
611
|
+
application_uuid: 'app-uuid',
|
|
612
|
+
application_name: 'test-app',
|
|
613
|
+
server_name: 'test-server',
|
|
614
|
+
status: 'finished',
|
|
615
|
+
commit: 'abc123',
|
|
616
|
+
force_rebuild: false,
|
|
617
|
+
is_webhook: false,
|
|
618
|
+
is_api: true,
|
|
619
|
+
restart_only: false,
|
|
620
|
+
created_at: '2024-01-01',
|
|
621
|
+
updated_at: '2024-01-01',
|
|
622
|
+
logs,
|
|
623
|
+
// Raw upstream fields that must never leak through the projection:
|
|
624
|
+
application: {
|
|
625
|
+
uuid: 'app-uuid',
|
|
626
|
+
docker_compose: 'x'.repeat(5000),
|
|
627
|
+
docker_compose_raw: 'x'.repeat(5000),
|
|
628
|
+
custom_labels: 'a'.repeat(2000),
|
|
629
|
+
manual_webhook_secret_github: 'ghsecret',
|
|
630
|
+
manual_webhook_secret_gitlab: 'glsecret',
|
|
631
|
+
},
|
|
632
|
+
destination: {
|
|
633
|
+
server: {
|
|
634
|
+
uuid: 'server-uuid',
|
|
635
|
+
ip: '1.2.3.4',
|
|
636
|
+
settings: {
|
|
637
|
+
logdrain_custom_config: 'Bearer live-logdrain-token-abc123',
|
|
638
|
+
sentinel_token: 'live-sentinel-token-xyz789',
|
|
639
|
+
},
|
|
640
|
+
proxy: { config: 'y'.repeat(3000) },
|
|
641
|
+
},
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
it('returns essential fields + logs only, no leaked secrets or nested graphs', async () => {
|
|
646
|
+
mockFetch.mockResolvedValueOnce(mockJsonResponse(rawDeploymentWithSecrets(5)));
|
|
647
|
+
const result = await callDeployment(server, { action: 'get', uuid: 'dep-uuid', lines: 5 });
|
|
648
|
+
const text = result.content[0].text;
|
|
649
|
+
expect(text).not.toContain('logdrain');
|
|
650
|
+
expect(text).not.toContain('sentinel_token');
|
|
651
|
+
expect(text).not.toContain('manual_webhook_secret');
|
|
652
|
+
expect(text).not.toContain('docker_compose');
|
|
653
|
+
expect(text).not.toMatch(/"application":\s*{/);
|
|
654
|
+
expect(text).not.toMatch(/"server":\s*{/);
|
|
655
|
+
expect(text).not.toMatch(/"destination":\s*{/);
|
|
656
|
+
const parsed = JSON.parse(text);
|
|
657
|
+
expect(parsed.data).toMatchObject({
|
|
658
|
+
uuid: 'dep-uuid',
|
|
659
|
+
application_uuid: 'app-uuid',
|
|
660
|
+
application_name: 'test-app',
|
|
661
|
+
server_name: 'test-server',
|
|
662
|
+
status: 'finished',
|
|
663
|
+
});
|
|
664
|
+
expect(typeof parsed.data.logs).toBe('string');
|
|
665
|
+
expect(parsed.data).not.toHaveProperty('application');
|
|
666
|
+
expect(parsed.data).not.toHaveProperty('destination');
|
|
667
|
+
expect(parsed.data).not.toHaveProperty('id');
|
|
668
|
+
});
|
|
669
|
+
it('keeps the response under 20KB even with a bloated upstream payload', async () => {
|
|
670
|
+
mockFetch.mockResolvedValueOnce(mockJsonResponse(rawDeploymentWithSecrets(5)));
|
|
671
|
+
const result = await callDeployment(server, { action: 'get', uuid: 'dep-uuid', lines: 5 });
|
|
672
|
+
const text = result.content[0].text;
|
|
673
|
+
expect(text.length).toBeLessThan(20_000);
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
describe('scheduled_tasks tool handler', () => {
|
|
677
|
+
// Regression for #234 — Coolify's `command` column is a 255-char varchar and
|
|
678
|
+
// rejects longer commands with a bodyless HTTP 500. The zod schema must reject
|
|
679
|
+
// an over-long command locally, before any HTTP call is attempted.
|
|
680
|
+
const getScheduledTasksTool = (srv) => srv._registeredTools['scheduled_tasks'];
|
|
681
|
+
const baseArgs = {
|
|
682
|
+
resource: 'application',
|
|
683
|
+
action: 'create',
|
|
684
|
+
uuid: 'app-uuid',
|
|
685
|
+
name: 'my-task',
|
|
686
|
+
frequency: '* * * * *',
|
|
687
|
+
};
|
|
688
|
+
it('rejects a command over 255 chars locally, with an actionable message', () => {
|
|
689
|
+
const createSpy = jest.spyOn(server['client'], 'createApplicationScheduledTask');
|
|
690
|
+
const updateSpy = jest.spyOn(server['client'], 'updateApplicationScheduledTask');
|
|
691
|
+
const tool = getScheduledTasksTool(server);
|
|
692
|
+
const result = tool.inputSchema.safeParse({ ...baseArgs, command: 'a'.repeat(256) });
|
|
693
|
+
expect(result.success).toBe(false);
|
|
694
|
+
const error = result.error;
|
|
695
|
+
expect(error.issues[0]?.message).toContain('Coolify rejects scheduled-task commands longer than 255 chars');
|
|
696
|
+
// No HTTP call should have been attempted.
|
|
697
|
+
expect(createSpy).not.toHaveBeenCalled();
|
|
698
|
+
expect(updateSpy).not.toHaveBeenCalled();
|
|
699
|
+
});
|
|
700
|
+
it('accepts a command at exactly 255 chars', () => {
|
|
701
|
+
const tool = getScheduledTasksTool(server);
|
|
702
|
+
const result = tool.inputSchema.safeParse({ ...baseArgs, command: 'a'.repeat(255) });
|
|
703
|
+
expect(result.success).toBe(true);
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
describe('scheduled_tasks tool handler - run_once', () => {
|
|
707
|
+
const callScheduledTasks = async (srv, args) => {
|
|
708
|
+
const tool = srv._registeredTools['scheduled_tasks'];
|
|
709
|
+
return tool.handler(args, {});
|
|
710
|
+
};
|
|
711
|
+
const baseArgs = {
|
|
712
|
+
resource: 'application',
|
|
713
|
+
action: 'run_once',
|
|
714
|
+
uuid: 'app-uuid',
|
|
715
|
+
command: 'php artisan migrate',
|
|
716
|
+
container: 'app',
|
|
717
|
+
wait_seconds: 10, // small budget -> few poll attempts in tests
|
|
718
|
+
};
|
|
719
|
+
const mockTask = {
|
|
720
|
+
id: 1,
|
|
721
|
+
uuid: 'task-uuid',
|
|
722
|
+
enabled: true,
|
|
723
|
+
name: 'oneoff-abc123',
|
|
724
|
+
command: 'php artisan migrate',
|
|
725
|
+
frequency: '* * * * *',
|
|
726
|
+
timeout: 0,
|
|
727
|
+
created_at: '',
|
|
728
|
+
updated_at: '',
|
|
729
|
+
};
|
|
730
|
+
beforeEach(() => {
|
|
731
|
+
// Poll loop uses a real setTimeout by default; override the instance method
|
|
732
|
+
// directly so tests are instant (jest.spyOn's generic inference struggles
|
|
733
|
+
// with private methods here, so a plain shadowing assignment is simpler).
|
|
734
|
+
server.sleep = () => Promise.resolve();
|
|
735
|
+
});
|
|
736
|
+
it('validates command and container are required', async () => {
|
|
737
|
+
const result = await callScheduledTasks(server, {
|
|
738
|
+
resource: 'application',
|
|
739
|
+
action: 'run_once',
|
|
740
|
+
uuid: 'app-uuid',
|
|
741
|
+
});
|
|
742
|
+
expect(result.content[0].text).toBe('Error: command, container required');
|
|
743
|
+
});
|
|
744
|
+
it('creates a task, polls until a terminal execution, returns its output, and deletes the task', async () => {
|
|
745
|
+
const createSpy = jest
|
|
746
|
+
.spyOn(server['client'], 'createApplicationScheduledTask')
|
|
747
|
+
.mockResolvedValue(mockTask);
|
|
748
|
+
const listSpy = jest
|
|
749
|
+
.spyOn(server['client'], 'listApplicationScheduledTaskExecutions')
|
|
750
|
+
.mockResolvedValueOnce([]) // first poll: nothing yet
|
|
751
|
+
.mockResolvedValueOnce([
|
|
752
|
+
{
|
|
753
|
+
uuid: 'exec-uuid',
|
|
754
|
+
status: 'success',
|
|
755
|
+
message: 'Migrated: 2026_01_01_000000_add_col',
|
|
756
|
+
retry_count: 0,
|
|
757
|
+
created_at: '',
|
|
758
|
+
updated_at: '',
|
|
759
|
+
},
|
|
760
|
+
]);
|
|
761
|
+
const deleteSpy = jest
|
|
762
|
+
.spyOn(server['client'], 'deleteApplicationScheduledTask')
|
|
763
|
+
.mockResolvedValue({ message: 'deleted' });
|
|
764
|
+
const result = await callScheduledTasks(server, baseArgs);
|
|
765
|
+
expect(createSpy).toHaveBeenCalledWith('app-uuid', expect.objectContaining({
|
|
766
|
+
command: 'php artisan migrate',
|
|
767
|
+
frequency: '* * * * *',
|
|
768
|
+
container: 'app',
|
|
769
|
+
enabled: true,
|
|
770
|
+
}));
|
|
771
|
+
expect(listSpy).toHaveBeenCalledTimes(2);
|
|
772
|
+
expect(listSpy).toHaveBeenCalledWith('app-uuid', 'task-uuid');
|
|
773
|
+
expect(deleteSpy).toHaveBeenCalledWith('app-uuid', 'task-uuid');
|
|
774
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
775
|
+
expect(parsed.status).toBe('success');
|
|
776
|
+
expect(parsed.message).toBe('Migrated: 2026_01_01_000000_add_col');
|
|
777
|
+
expect(parsed.task_uuid).toBe('task-uuid');
|
|
778
|
+
expect(parsed.cleanup).toContain('deleted');
|
|
779
|
+
});
|
|
780
|
+
it('times out when no execution ever appears, and still deletes the task', async () => {
|
|
781
|
+
jest.spyOn(server['client'], 'createApplicationScheduledTask').mockResolvedValue(mockTask);
|
|
782
|
+
jest.spyOn(server['client'], 'listApplicationScheduledTaskExecutions').mockResolvedValue([]);
|
|
783
|
+
const deleteSpy = jest
|
|
784
|
+
.spyOn(server['client'], 'deleteApplicationScheduledTask')
|
|
785
|
+
.mockResolvedValue({ message: 'deleted' });
|
|
786
|
+
const result = await callScheduledTasks(server, baseArgs);
|
|
787
|
+
expect(deleteSpy).toHaveBeenCalledWith('app-uuid', 'task-uuid');
|
|
788
|
+
expect(result.content[0].text).toContain('Timed out');
|
|
789
|
+
expect(result.content[0].text).toContain('task-uuid');
|
|
790
|
+
expect(result.content[0].text).toContain('deleted');
|
|
791
|
+
});
|
|
792
|
+
it('still deletes the task when polling throws, and surfaces the poll error', async () => {
|
|
793
|
+
jest.spyOn(server['client'], 'createApplicationScheduledTask').mockResolvedValue(mockTask);
|
|
794
|
+
jest
|
|
795
|
+
.spyOn(server['client'], 'listApplicationScheduledTaskExecutions')
|
|
796
|
+
.mockRejectedValue(new Error('network blip'));
|
|
797
|
+
const deleteSpy = jest
|
|
798
|
+
.spyOn(server['client'], 'deleteApplicationScheduledTask')
|
|
799
|
+
.mockResolvedValue({ message: 'deleted' });
|
|
800
|
+
const result = await callScheduledTasks(server, baseArgs);
|
|
801
|
+
expect(deleteSpy).toHaveBeenCalledWith('app-uuid', 'task-uuid');
|
|
802
|
+
expect(result.content[0].text).toContain('network blip');
|
|
803
|
+
expect(result.content[0].text).toContain('task-uuid');
|
|
804
|
+
});
|
|
805
|
+
it('warns loudly with the task UUID when the cleanup delete itself fails', async () => {
|
|
806
|
+
jest.spyOn(server['client'], 'createApplicationScheduledTask').mockResolvedValue(mockTask);
|
|
807
|
+
jest.spyOn(server['client'], 'listApplicationScheduledTaskExecutions').mockResolvedValue([
|
|
808
|
+
{
|
|
809
|
+
uuid: 'exec-uuid',
|
|
810
|
+
status: 'success',
|
|
811
|
+
message: 'ok',
|
|
812
|
+
retry_count: 0,
|
|
813
|
+
created_at: '',
|
|
814
|
+
updated_at: '',
|
|
815
|
+
},
|
|
816
|
+
]);
|
|
817
|
+
jest
|
|
818
|
+
.spyOn(server['client'], 'deleteApplicationScheduledTask')
|
|
819
|
+
.mockRejectedValue(new Error('403 forbidden'));
|
|
820
|
+
const result = await callScheduledTasks(server, baseArgs);
|
|
821
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
822
|
+
expect(parsed.cleanup).toContain('WARNING');
|
|
823
|
+
expect(parsed.cleanup).toContain('task-uuid');
|
|
824
|
+
expect(parsed.cleanup).toContain('403 forbidden');
|
|
825
|
+
});
|
|
826
|
+
it('supports the service resource', async () => {
|
|
827
|
+
const createSpy = jest
|
|
828
|
+
.spyOn(server['client'], 'createServiceScheduledTask')
|
|
829
|
+
.mockResolvedValue(mockTask);
|
|
830
|
+
jest.spyOn(server['client'], 'listServiceScheduledTaskExecutions').mockResolvedValue([
|
|
831
|
+
{
|
|
832
|
+
uuid: 'e',
|
|
833
|
+
status: 'success',
|
|
834
|
+
message: 'ok',
|
|
835
|
+
retry_count: 0,
|
|
836
|
+
created_at: '',
|
|
837
|
+
updated_at: '',
|
|
838
|
+
},
|
|
839
|
+
]);
|
|
840
|
+
const deleteSpy = jest
|
|
841
|
+
.spyOn(server['client'], 'deleteServiceScheduledTask')
|
|
842
|
+
.mockResolvedValue({ message: 'deleted' });
|
|
843
|
+
await callScheduledTasks(server, { ...baseArgs, resource: 'service', uuid: 'svc-uuid' });
|
|
844
|
+
expect(createSpy).toHaveBeenCalledWith('svc-uuid', expect.any(Object));
|
|
845
|
+
expect(deleteSpy).toHaveBeenCalledWith('svc-uuid', 'task-uuid');
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
describe('deploy tool handler', () => {
|
|
849
|
+
// #238 — opt-in `wait` polls the deployment to a terminal status instead
|
|
850
|
+
// of firing-and-forgetting. The no-wait path must stay byte-for-byte
|
|
851
|
+
// identical to the pre-#238 behaviour.
|
|
852
|
+
const callDeploy = async (srv, args) => {
|
|
853
|
+
const tool = srv._registeredTools['deploy'];
|
|
854
|
+
return tool.handler(args, {});
|
|
855
|
+
};
|
|
856
|
+
const essentialDeployment = (overrides = {}) => ({
|
|
857
|
+
uuid: 'dep-uuid',
|
|
858
|
+
deployment_uuid: 'dep-uuid',
|
|
859
|
+
application_uuid: 'app-uuid',
|
|
860
|
+
application_name: 'my-app',
|
|
861
|
+
status: 'in_progress',
|
|
862
|
+
commit: 'abc123',
|
|
863
|
+
force_rebuild: false,
|
|
864
|
+
is_webhook: false,
|
|
865
|
+
is_api: true,
|
|
866
|
+
created_at: '2026-01-01T10:00:00Z',
|
|
867
|
+
updated_at: '2026-01-01T10:00:00Z',
|
|
868
|
+
...overrides,
|
|
869
|
+
});
|
|
870
|
+
afterEach(() => {
|
|
871
|
+
jest.useRealTimers();
|
|
872
|
+
});
|
|
873
|
+
it('no-wait path is unchanged: triggers deploy and returns immediately', async () => {
|
|
874
|
+
const spy = jest
|
|
875
|
+
.spyOn(server['client'], 'deployByTagOrUuid')
|
|
876
|
+
.mockResolvedValue({ deployments: [{ deployment_uuid: 'dep-uuid' }] });
|
|
877
|
+
const pollSpy = jest.spyOn(server['client'], 'getDeployment');
|
|
878
|
+
const result = (await callDeploy(server, { tag_or_uuid: 'my-tag', force: true }));
|
|
879
|
+
expect(spy).toHaveBeenCalledWith('my-tag', true);
|
|
880
|
+
expect(pollSpy).not.toHaveBeenCalled();
|
|
881
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
882
|
+
expect(parsed.data).toEqual({ deployments: [{ deployment_uuid: 'dep-uuid' }] });
|
|
883
|
+
expect(parsed._actions).toEqual([
|
|
884
|
+
{ tool: 'list_deployments', args: {}, hint: 'Check deployment status' },
|
|
885
|
+
]);
|
|
886
|
+
});
|
|
887
|
+
it('wait: true polls until finished', async () => {
|
|
888
|
+
jest.useFakeTimers();
|
|
889
|
+
jest
|
|
890
|
+
.spyOn(server['client'], 'deployByTagOrUuid')
|
|
891
|
+
.mockResolvedValue({ deployments: [{ deployment_uuid: 'dep-uuid' }] });
|
|
892
|
+
const getDeploymentSpy = jest
|
|
893
|
+
.spyOn(server['client'], 'getDeployment')
|
|
894
|
+
.mockResolvedValueOnce(essentialDeployment({ status: 'in_progress' }))
|
|
895
|
+
.mockResolvedValueOnce(essentialDeployment({ status: 'finished' }));
|
|
896
|
+
const resultPromise = callDeploy(server, { tag_or_uuid: 'my-tag', wait: true });
|
|
897
|
+
await jest.advanceTimersByTimeAsync(5000);
|
|
898
|
+
const result = await resultPromise;
|
|
899
|
+
expect(getDeploymentSpy).toHaveBeenCalledTimes(2);
|
|
900
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
901
|
+
expect(parsed.data.status).toBe('finished');
|
|
902
|
+
expect(parsed.data.deployment_uuid).toBe('dep-uuid');
|
|
903
|
+
expect(parsed.data.logs_tail).toBeUndefined();
|
|
904
|
+
});
|
|
905
|
+
it('wait: true returns a bounded log tail on failure, never the raw payload', async () => {
|
|
906
|
+
jest.useFakeTimers();
|
|
907
|
+
jest
|
|
908
|
+
.spyOn(server['client'], 'deployByTagOrUuid')
|
|
909
|
+
.mockResolvedValue({ deployments: [{ deployment_uuid: 'dep-uuid' }] });
|
|
910
|
+
jest
|
|
911
|
+
.spyOn(server['client'], 'getDeployment')
|
|
912
|
+
.mockImplementation(async (uuid, options) => {
|
|
913
|
+
if (options?.includeLogs) {
|
|
914
|
+
return {
|
|
915
|
+
...essentialDeployment({ status: 'failed' }),
|
|
916
|
+
logs: JSON.stringify([{ output: 'build failed: OOM', timestamp: 't1' }]),
|
|
917
|
+
// Fields that would only appear on the raw upstream object —
|
|
918
|
+
// must never leak into the tool response.
|
|
919
|
+
server: { ip: '10.0.0.1', private_key: 'super-secret' },
|
|
920
|
+
application: { env_secret: 'shh' },
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
return essentialDeployment({ status: 'failed' });
|
|
924
|
+
});
|
|
925
|
+
const resultPromise = callDeploy(server, { tag_or_uuid: 'my-tag', wait: true });
|
|
926
|
+
const result = await resultPromise;
|
|
927
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
928
|
+
expect(parsed.data.status).toBe('failed');
|
|
929
|
+
expect(parsed.data.deployment_uuid).toBe('dep-uuid');
|
|
930
|
+
expect(parsed.data.logs_tail).toContain('build failed: OOM');
|
|
931
|
+
expect(result.content[0].text).not.toContain('private_key');
|
|
932
|
+
expect(result.content[0].text).not.toContain('env_secret');
|
|
933
|
+
expect(parsed.data).not.toHaveProperty('server');
|
|
934
|
+
expect(parsed.data).not.toHaveProperty('application');
|
|
935
|
+
});
|
|
936
|
+
it('wait: true returns an explicit timeout with a next-action hint', async () => {
|
|
937
|
+
jest.useFakeTimers();
|
|
938
|
+
jest
|
|
939
|
+
.spyOn(server['client'], 'deployByTagOrUuid')
|
|
940
|
+
.mockResolvedValue({ deployments: [{ deployment_uuid: 'dep-uuid' }] });
|
|
941
|
+
jest
|
|
942
|
+
.spyOn(server['client'], 'getDeployment')
|
|
943
|
+
.mockResolvedValue(essentialDeployment({ status: 'in_progress' }));
|
|
944
|
+
const resultPromise = callDeploy(server, {
|
|
945
|
+
tag_or_uuid: 'my-tag',
|
|
946
|
+
wait: true,
|
|
947
|
+
timeout_seconds: 10,
|
|
948
|
+
});
|
|
949
|
+
// Let the poll loop exceed the 10s timeout.
|
|
950
|
+
await jest.advanceTimersByTimeAsync(15_000);
|
|
951
|
+
const result = await resultPromise;
|
|
952
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
953
|
+
expect(parsed.data.status).toBe('in_progress');
|
|
954
|
+
expect(parsed.data.timed_out).toBe(true);
|
|
955
|
+
expect(parsed.data.deployment_uuid).toBe('dep-uuid');
|
|
956
|
+
expect(parsed.data.next_action).toEqual(expect.stringContaining('deployment'));
|
|
957
|
+
});
|
|
958
|
+
it('wait: true watches only the first deployment when a tag triggers several', async () => {
|
|
959
|
+
jest.useFakeTimers();
|
|
960
|
+
jest.spyOn(server['client'], 'deployByTagOrUuid').mockResolvedValue({
|
|
961
|
+
deployments: [{ deployment_uuid: 'dep-1' }, { deployment_uuid: 'dep-2' }],
|
|
962
|
+
});
|
|
963
|
+
const getDeploymentSpy = jest.spyOn(server['client'], 'getDeployment').mockResolvedValue(essentialDeployment({
|
|
964
|
+
status: 'finished',
|
|
965
|
+
deployment_uuid: 'dep-1',
|
|
966
|
+
uuid: 'dep-1',
|
|
967
|
+
}));
|
|
968
|
+
const resultPromise = callDeploy(server, { tag_or_uuid: 'my-tag', wait: true });
|
|
969
|
+
const result = await resultPromise;
|
|
970
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
971
|
+
expect(getDeploymentSpy).toHaveBeenCalledWith('dep-1');
|
|
972
|
+
expect(getDeploymentSpy).not.toHaveBeenCalledWith('dep-2');
|
|
973
|
+
expect(parsed.data.deployment_uuid).toBe('dep-1');
|
|
974
|
+
expect(parsed.data.additional_deployment_uuids).toEqual(['dep-2']);
|
|
975
|
+
});
|
|
976
|
+
});
|
|
497
977
|
});
|
|
498
978
|
describe('truncateLogs', () => {
|
|
499
979
|
// 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, ResourceListItemFull } 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, DeployTriggerResponse, 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;
|
|
@@ -66,6 +66,13 @@ export interface GitHubAppSummary {
|
|
|
66
66
|
is_public: boolean;
|
|
67
67
|
app_id: number | null;
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Map a failed response's status/path to an actionable hint for known Coolify quirks.
|
|
71
|
+
* Coolify sometimes returns bodyless errors (e.g. bare `HTTP 500: Internal Server Error`)
|
|
72
|
+
* that leave the caller guessing at the cause — this appends a short, testable hint for
|
|
73
|
+
* the cases we've hit in practice. Returns undefined when no known case matches.
|
|
74
|
+
*/
|
|
75
|
+
export declare function errorHint(status: number, path: string): string | undefined;
|
|
69
76
|
/**
|
|
70
77
|
* HTTP client for the Coolify API
|
|
71
78
|
*/
|
|
@@ -115,6 +122,12 @@ export declare class CoolifyClient {
|
|
|
115
122
|
createApplicationPrivateKey(data: CreateApplicationPrivateKeyRequest): Promise<UuidResponse>;
|
|
116
123
|
createApplicationDockerfile(data: CreateApplicationDockerfileRequest): Promise<UuidResponse>;
|
|
117
124
|
createApplicationDockerImage(data: CreateApplicationDockerImageRequest): Promise<UuidResponse>;
|
|
125
|
+
/**
|
|
126
|
+
* @deprecated Coolify removed POST /applications/dockercompose upstream in
|
|
127
|
+
* v4.1.0 (coollabsio/coolify commit 6ee75cfa) in favour of POST /services.
|
|
128
|
+
* This 404s against current Coolify releases; use createService instead.
|
|
129
|
+
* Not exposed via any MCP tool — see #235.
|
|
130
|
+
*/
|
|
118
131
|
createApplicationDockerCompose(data: CreateApplicationDockerComposeRequest): Promise<UuidResponse>;
|
|
119
132
|
updateApplication(uuid: string, data: UpdateApplicationRequest): Promise<Application>;
|
|
120
133
|
deleteApplication(uuid: string, options?: DeleteOptions): Promise<MessageResponse>;
|
|
@@ -162,7 +175,7 @@ export declare class CoolifyClient {
|
|
|
162
175
|
deleteService(uuid: string, options?: DeleteOptions): Promise<MessageResponse>;
|
|
163
176
|
startService(uuid: string): Promise<MessageResponse>;
|
|
164
177
|
stopService(uuid: string): Promise<MessageResponse>;
|
|
165
|
-
restartService(uuid: string): Promise<MessageResponse>;
|
|
178
|
+
restartService(uuid: string, pullLatest?: boolean): Promise<MessageResponse>;
|
|
166
179
|
/**
|
|
167
180
|
* List env vars for a service.
|
|
168
181
|
*
|
|
@@ -179,8 +192,8 @@ export declare class CoolifyClient {
|
|
|
179
192
|
listDeployments(options?: ListOptions): Promise<Deployment[] | DeploymentSummary[]>;
|
|
180
193
|
getDeployment(uuid: string, options?: {
|
|
181
194
|
includeLogs?: boolean;
|
|
182
|
-
}): Promise<
|
|
183
|
-
deployByTagOrUuid(tagOrUuid: string, force?: boolean): Promise<
|
|
195
|
+
}): Promise<DeploymentEssential>;
|
|
196
|
+
deployByTagOrUuid(tagOrUuid: string, force?: boolean): Promise<DeployTriggerResponse>;
|
|
184
197
|
/**
|
|
185
198
|
* List deployments for an application.
|
|
186
199
|
*
|
|
@@ -190,13 +203,15 @@ export declare class CoolifyClient {
|
|
|
190
203
|
* By default returns a DeploymentEssential summary (no `logs` field) because
|
|
191
204
|
* each deployment's log blob can be 30–100KB, and a typical list has 20–35
|
|
192
205
|
* deployments — exceeding MCP response token limits. Pass `includeLogs: true`
|
|
193
|
-
* to
|
|
206
|
+
* to also attach the raw log string to each essential projection (never the
|
|
207
|
+
* raw upstream deployment object, which also embeds the full application/server
|
|
208
|
+
* graph and secrets).
|
|
194
209
|
*/
|
|
195
210
|
listApplicationDeployments(appUuid: string, options?: {
|
|
196
211
|
includeLogs?: boolean;
|
|
197
212
|
}): Promise<{
|
|
198
213
|
count: number;
|
|
199
|
-
deployments:
|
|
214
|
+
deployments: DeploymentEssential[];
|
|
200
215
|
}>;
|
|
201
216
|
listTeams(): Promise<Team[]>;
|
|
202
217
|
getTeam(id: number): Promise<Team>;
|