@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.
@@ -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<Deployment | DeploymentEssential>;
183
- deployByTagOrUuid(tagOrUuid: string, force?: boolean): Promise<MessageResponse>;
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 get raw Deployment objects with full build logs.
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: Deployment[] | DeploymentEssential[];
214
+ deployments: DeploymentEssential[];
200
215
  }>;
201
216
  listTeams(): Promise<Team[]>;
202
217
  getTeam(id: number): Promise<Team>;