diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 572bb38..b94ca10 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -27,6 +27,10 @@ jobs: - name: Run integration tests run: bun run test:integration:run + - name: Dump container logs on failure + if: failure() + run: docker compose -f docker-compose.test.yml logs --tail=200 + - name: Teardown containers if: always() run: bun run test:integration:teardown diff --git a/docker-compose.test.yml b/docker-compose.test.yml index aa0ba3d..41f560f 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -27,8 +27,9 @@ services: - "${COOLIFY_TEST_PORT:-8099}:8080" environment: APP_NAME: "Coolify" - APP_ENV: local - APP_DEBUG: "true" + APP_ENV: production + APP_DEBUG: "false" + TELESCOPE_ENABLED: "false" APP_ID: "test-integration-id-1234567890abcdef" APP_KEY: "base64:MUScccH4Baj16/FXv2FjY0t6U5fse9zivTxus2AC7XI=" APP_URL: "http://localhost:8099" diff --git a/package.json b/package.json index 53b98ed..fdd78cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-coolify", - "version": "2.3.0", + "version": "3.0.0", "description": "MCP server for Coolify. Manage applications, databases, services, deployments, and servers from any MCP-compatible client.", "type": "module", "main": "dist/index.js", diff --git a/scripts/integration-run.ts b/scripts/integration-run.ts index f29aa4f..114f7e1 100644 --- a/scripts/integration-run.ts +++ b/scripts/integration-run.ts @@ -18,6 +18,10 @@ const files = [ "11-service-envs", "12-deployments", "13-logs", + "15-scheduled-tasks", + "16-storages", + "17-github-apps", + "18-resources", "14-cleanup", ]; diff --git a/scripts/integration-setup.ts b/scripts/integration-setup.ts index faa03a7..3a46ee0 100644 --- a/scripts/integration-setup.ts +++ b/scripts/integration-setup.ts @@ -6,7 +6,7 @@ import { writeFileSync } from "node:fs"; const COOLIFY_URL = process.env.COOLIFY_TEST_URL ?? "http://localhost:8099"; -const MAX_WAIT_MS = 120_000; +const MAX_WAIT_MS = 300_000; const POLL_INTERVAL_MS = 3_000; const STATE_FILE = "/tmp/coolify-integration-state.json"; diff --git a/src/__integration__/15-scheduled-tasks.integration.test.ts b/src/__integration__/15-scheduled-tasks.integration.test.ts new file mode 100644 index 0000000..69b246c --- /dev/null +++ b/src/__integration__/15-scheduled-tasks.integration.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { createTestClient, readState } from "./setup"; + +const client = createTestClient(); + +describe("15 - Scheduled Tasks", () => { + let taskUuid: string; + + test("createApplicationScheduledTask creates a task", async () => { + const { applicationUuid } = readState(); + if (!applicationUuid) return; + const result = await client.createApplicationScheduledTask(applicationUuid, { + name: "integration-test-task", + command: "echo hello", + frequency: "* * * * *", + enabled: false, + }); + expect(result).toHaveProperty("uuid"); + expect(result.uuid.length).toBeGreaterThan(0); + taskUuid = result.uuid; + }); + + test("listApplicationScheduledTasks includes the created task", async () => { + const { applicationUuid } = readState(); + if (!applicationUuid) return; + const tasks = await client.listApplicationScheduledTasks(applicationUuid); + expect(Array.isArray(tasks)).toBe(true); + const found = tasks.find((t) => t.uuid === taskUuid); + expect(found).toBeDefined(); + expect(found?.name).toBe("integration-test-task"); + }); + + test("updateApplicationScheduledTask updates the task", async () => { + const { applicationUuid } = readState(); + if (!applicationUuid || !taskUuid) return; + const result = await client.updateApplicationScheduledTask(applicationUuid, taskUuid, { + name: "integration-test-task-updated", + }); + expect(result).toBeDefined(); + }); + + test("listApplicationScheduledTaskExecutions returns an array", async () => { + const { applicationUuid } = readState(); + if (!applicationUuid || !taskUuid) return; + const executions = await client.listApplicationScheduledTaskExecutions( + applicationUuid, + taskUuid, + ); + expect(Array.isArray(executions)).toBe(true); + }); + + test("deleteApplicationScheduledTask deletes the task", async () => { + const { applicationUuid } = readState(); + if (!applicationUuid || !taskUuid) return; + const result = await client.deleteApplicationScheduledTask(applicationUuid, taskUuid); + expect(result).toBeDefined(); + }); +}); diff --git a/src/__integration__/16-storages.integration.test.ts b/src/__integration__/16-storages.integration.test.ts new file mode 100644 index 0000000..f03bbcb --- /dev/null +++ b/src/__integration__/16-storages.integration.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { CoolifyApiError } from "../lib/errors"; +import { createTestClient, readState } from "./setup"; + +const client = createTestClient(); + +describe("16 - Storages", () => { + let storageUuid: string; + + test("listApplicationStorages returns data", async () => { + const { applicationUuid } = readState(); + if (!applicationUuid) return; + try { + const storages = await client.listApplicationStorages(applicationUuid); + expect(storages).toBeDefined(); + } catch (error) { + if (error instanceof CoolifyApiError && error.statusCode === 404) { + console.log("Storages endpoint not available on this Coolify version, skipping"); + return; + } + throw error; + } + }); + + test("createApplicationStorage creates a storage mount", async () => { + const { applicationUuid } = readState(); + if (!applicationUuid) return; + try { + const result = await client.createApplicationStorage(applicationUuid, { + name: "integration-test-storage", + mount_path: "/test-data", + }); + expect(result).toHaveProperty("uuid"); + storageUuid = result.uuid; + } catch (error) { + if (error instanceof CoolifyApiError && [404, 422].includes(error.statusCode)) { + console.log(`Storages create skipped (${error.statusCode}): ${error.responseBody}`); + return; + } + throw error; + } + }); + + test("deleteApplicationStorage cleans up", async () => { + const { applicationUuid } = readState(); + if (!applicationUuid || !storageUuid) return; + try { + const result = await client.deleteApplicationStorage(applicationUuid, storageUuid); + expect(result).toBeDefined(); + } catch (error) { + if (error instanceof CoolifyApiError && [404, 422].includes(error.statusCode)) { + console.log(`Storages delete skipped (${error.statusCode})`); + return; + } + throw error; + } + }); + + test("listDatabaseStorages returns data", async () => { + const { databaseUuid } = readState(); + if (!databaseUuid) return; + try { + const storages = await client.listDatabaseStorages(databaseUuid); + expect(storages).toBeDefined(); + } catch (error) { + if (error instanceof CoolifyApiError && error.statusCode === 404) { + console.log("Database storages endpoint not available, skipping"); + return; + } + throw error; + } + }); + + test("listServiceStorages returns data", async () => { + const { serviceUuid } = readState(); + if (!serviceUuid) return; + try { + const storages = await client.listServiceStorages(serviceUuid); + expect(storages).toBeDefined(); + } catch (error) { + if (error instanceof CoolifyApiError && error.statusCode === 404) { + console.log("Service storages endpoint not available, skipping"); + return; + } + throw error; + } + }); +}); diff --git a/src/__integration__/17-github-apps.integration.test.ts b/src/__integration__/17-github-apps.integration.test.ts new file mode 100644 index 0000000..494eaff --- /dev/null +++ b/src/__integration__/17-github-apps.integration.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from "bun:test"; +import { createTestClient } from "./setup"; + +const client = createTestClient(); + +describe("17 - GitHub Apps", () => { + test("listGitHubApps returns an array", async () => { + const apps = await client.listGitHubApps(); + expect(Array.isArray(apps)).toBe(true); + }); +}); diff --git a/src/__integration__/18-resources.integration.test.ts b/src/__integration__/18-resources.integration.test.ts new file mode 100644 index 0000000..d4ca7c2 --- /dev/null +++ b/src/__integration__/18-resources.integration.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from "bun:test"; +import { createTestClient } from "./setup"; + +const client = createTestClient(); + +describe("18 - Resources", () => { + test("listResources returns an array", async () => { + const resources = await client.listResources(); + expect(Array.isArray(resources)).toBe(true); + }); +}); diff --git a/src/client.test.ts b/src/client.test.ts index baf751f..8f37ea3 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -299,6 +299,256 @@ describe("CoolifyClient", () => { ); }); + // Batch 1: docker_cleanup on stop + it("appends docker_cleanup query param for stopApplication", async () => { + mockFetch(new Response('{"message":"ok"}', { status: 200 })); + await client.stopApplication("app-1", { docker_cleanup: true }); + const [url, options] = fetchCalls[0]; + expect(url).toBe( + "https://coolify.example.com/api/v1/applications/app-1/stop?docker_cleanup=true", + ); + expect(options.method).toBe("POST"); + }); + + it("does not append docker_cleanup when undefined for stopApplication", async () => { + mockFetch(new Response('{"message":"ok"}', { status: 200 })); + await client.stopApplication("app-1"); + const [url] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/applications/app-1/stop"); + }); + + it("appends docker_cleanup query param for stopDatabase", async () => { + mockFetch(new Response('{"message":"ok"}', { status: 200 })); + await client.stopDatabase("db-1", { docker_cleanup: false }); + const [url] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/databases/db-1/stop?docker_cleanup=false"); + }); + + it("appends docker_cleanup query param for stopService", async () => { + mockFetch(new Response('{"message":"ok"}', { status: 200 })); + await client.stopService("svc-1", { docker_cleanup: true }); + const [url] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/services/svc-1/stop?docker_cleanup=true"); + }); + + // Batch 1: force on server delete + it("appends force query param for deleteServer", async () => { + mockFetch(new Response('{"message":"deleted"}', { status: 200 })); + await client.deleteServer("srv-1", { force: true }); + const [url] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/servers/srv-1?force=true"); + }); + + // Application Scheduled Tasks + it("constructs correct URL for listApplicationScheduledTasks", async () => { + mockFetch(new Response("[]", { status: 200 })); + await client.listApplicationScheduledTasks("app-1"); + const [url] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/applications/app-1/scheduled-tasks"); + }); + + it("uses POST for createApplicationScheduledTask", async () => { + mockFetch(new Response('{"uuid":"task-1"}', { status: 200 })); + await client.createApplicationScheduledTask("app-1", { + name: "cleanup", + command: "rm -rf /tmp/*", + frequency: "0 * * * *", + }); + const [url, options] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/applications/app-1/scheduled-tasks"); + expect(options.method).toBe("POST"); + const body = JSON.parse(options.body as string); + expect(body.name).toBe("cleanup"); + expect(body.command).toBe("rm -rf /tmp/*"); + expect(body.frequency).toBe("0 * * * *"); + }); + + it("uses PATCH for updateApplicationScheduledTask", async () => { + mockFetch(new Response('{"uuid":"task-1"}', { status: 200 })); + await client.updateApplicationScheduledTask("app-1", "task-1", { enabled: false }); + const [url, options] = fetchCalls[0]; + expect(url).toBe( + "https://coolify.example.com/api/v1/applications/app-1/scheduled-tasks/task-1", + ); + expect(options.method).toBe("PATCH"); + }); + + it("uses DELETE for deleteApplicationScheduledTask", async () => { + mockFetch(new Response('{"message":"deleted"}', { status: 200 })); + await client.deleteApplicationScheduledTask("app-1", "task-1"); + const [url, options] = fetchCalls[0]; + expect(url).toBe( + "https://coolify.example.com/api/v1/applications/app-1/scheduled-tasks/task-1", + ); + expect(options.method).toBe("DELETE"); + }); + + it("constructs correct URL for listApplicationScheduledTaskExecutions", async () => { + mockFetch(new Response("[]", { status: 200 })); + await client.listApplicationScheduledTaskExecutions("app-1", "task-1"); + const [url] = fetchCalls[0]; + expect(url).toBe( + "https://coolify.example.com/api/v1/applications/app-1/scheduled-tasks/task-1/executions", + ); + }); + + // Service Scheduled Tasks + it("constructs correct URL for listServiceScheduledTasks", async () => { + mockFetch(new Response("[]", { status: 200 })); + await client.listServiceScheduledTasks("svc-1"); + const [url] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/services/svc-1/scheduled-tasks"); + }); + + it("uses DELETE for deleteServiceScheduledTask", async () => { + mockFetch(new Response('{"message":"deleted"}', { status: 200 })); + await client.deleteServiceScheduledTask("svc-1", "task-1"); + const [url, options] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/services/svc-1/scheduled-tasks/task-1"); + expect(options.method).toBe("DELETE"); + }); + + // Application Storages + it("constructs correct URL for listApplicationStorages", async () => { + mockFetch(new Response("[]", { status: 200 })); + await client.listApplicationStorages("app-1"); + const [url] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/applications/app-1/storages"); + }); + + it("uses POST for createApplicationStorage", async () => { + mockFetch(new Response('{"uuid":"stor-1"}', { status: 200 })); + await client.createApplicationStorage("app-1", { name: "data", mount_path: "/data" }); + const [url, options] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/applications/app-1/storages"); + expect(options.method).toBe("POST"); + }); + + it("uses PATCH for updateApplicationStorage with storage UUID in path", async () => { + mockFetch(new Response('{"uuid":"stor-1"}', { status: 200 })); + await client.updateApplicationStorage("app-1", "stor-1", { name: "data-v2" }); + const [url, options] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/applications/app-1/storages/stor-1"); + expect(options.method).toBe("PATCH"); + }); + + it("uses DELETE for deleteApplicationStorage with storage UUID in path", async () => { + mockFetch(new Response('{"message":"deleted"}', { status: 200 })); + await client.deleteApplicationStorage("app-1", "stor-1"); + const [url, options] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/applications/app-1/storages/stor-1"); + expect(options.method).toBe("DELETE"); + }); + + // Database Storages + it("constructs correct URL for listDatabaseStorages", async () => { + mockFetch(new Response("[]", { status: 200 })); + await client.listDatabaseStorages("db-1"); + const [url] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/databases/db-1/storages"); + }); + + it("uses DELETE for deleteDatabaseStorage", async () => { + mockFetch(new Response('{"message":"deleted"}', { status: 200 })); + await client.deleteDatabaseStorage("db-1", "stor-1"); + const [url, options] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/databases/db-1/storages/stor-1"); + expect(options.method).toBe("DELETE"); + }); + + // Service Storages + it("constructs correct URL for listServiceStorages", async () => { + mockFetch(new Response("[]", { status: 200 })); + await client.listServiceStorages("svc-1"); + const [url] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/services/svc-1/storages"); + }); + + // GitHub Apps + it("constructs correct URL for listGitHubApps", async () => { + mockFetch(new Response("[]", { status: 200 })); + await client.listGitHubApps(); + const [url] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/github-apps"); + }); + + it("uses POST for createGitHubApp", async () => { + mockFetch(new Response('{"id":1}', { status: 200 })); + await client.createGitHubApp({ name: "my-app" }); + const [url, options] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/github-apps"); + expect(options.method).toBe("POST"); + }); + + it("uses PATCH for updateGitHubApp with numeric id", async () => { + mockFetch(new Response('{"id":1}', { status: 200 })); + await client.updateGitHubApp(42, { name: "updated" }); + const [url, options] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/github-apps/42"); + expect(options.method).toBe("PATCH"); + }); + + it("uses DELETE for deleteGitHubApp", async () => { + mockFetch(new Response('{"message":"deleted"}', { status: 200 })); + await client.deleteGitHubApp(42); + const [url, options] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/github-apps/42"); + expect(options.method).toBe("DELETE"); + }); + + it("constructs correct URL for listGitHubAppRepositories", async () => { + mockFetch(new Response("[]", { status: 200 })); + await client.listGitHubAppRepositories(5); + const [url] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/github-apps/5/repositories"); + }); + + it("constructs correct URL for listGitHubAppBranches", async () => { + mockFetch(new Response("[]", { status: 200 })); + await client.listGitHubAppBranches(5, "octocat", "hello-world"); + const [url] = fetchCalls[0]; + expect(url).toBe( + "https://coolify.example.com/api/v1/github-apps/5/repositories/octocat/hello-world/branches", + ); + }); + + // Backup Executions + it("constructs correct URL for listBackupExecutions", async () => { + mockFetch(new Response("[]", { status: 200 })); + await client.listBackupExecutions("db-1", "backup-1"); + const [url] = fetchCalls[0]; + expect(url).toBe( + "https://coolify.example.com/api/v1/databases/db-1/backups/backup-1/executions", + ); + }); + + it("uses DELETE for deleteBackupExecution", async () => { + mockFetch(new Response('{"message":"deleted"}', { status: 200 })); + await client.deleteBackupExecution("db-1", "backup-1", "exec-1"); + const [url, options] = fetchCalls[0]; + expect(url).toBe( + "https://coolify.example.com/api/v1/databases/db-1/backups/backup-1/executions/exec-1", + ); + expect(options.method).toBe("DELETE"); + }); + + // Backup Schedule Update + it("uses PATCH for updateDatabaseBackup", async () => { + mockFetch(new Response('{"uuid":"backup-1"}', { status: 200 })); + await client.updateDatabaseBackup("db-1", "backup-1", { enabled: false }); + const [url, options] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/databases/db-1/backups/backup-1"); + expect(options.method).toBe("PATCH"); + }); + + // Resources listing + it("constructs correct URL for listResources", async () => { + mockFetch(new Response("[]", { status: 200 })); + await client.listResources(); + const [url] = fetchCalls[0]; + expect(url).toBe("https://coolify.example.com/api/v1/resources"); + }); + it("parses JSON response correctly", async () => { const apps = [ { diff --git a/src/client.ts b/src/client.ts index 592ec68..0dba49b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,14 +2,21 @@ import type { Config } from "./config"; import { CoolifyApiError, NetworkError } from "./lib/errors"; import type { Application, + BackupExecution, Database, Deployment, Environment, EnvironmentVariable, + GitHubApp, + GitHubBranch, + GitHubRepository, PrivateKey, Project, + ScheduledTask, + ScheduledTaskExecution, ServerInfo, Service, + Storage, Team, TeamMember, } from "./types/api"; @@ -89,8 +96,15 @@ export class CoolifyClient { return this.request("POST", `/applications/${uuid}/start`); } - async stopApplication(uuid: string): Promise<{ message: string }> { - return this.request("POST", `/applications/${uuid}/stop`); + async stopApplication( + uuid: string, + opts?: { docker_cleanup?: boolean }, + ): Promise<{ message: string }> { + const params = new URLSearchParams(); + if (opts?.docker_cleanup !== undefined) + params.set("docker_cleanup", String(opts.docker_cleanup)); + const qs = params.toString(); + return this.request("POST", `/applications/${uuid}/stop${qs ? `?${qs}` : ""}`); } async restartApplication(uuid: string): Promise<{ message: string; deployment_uuid?: string }> { @@ -180,8 +194,11 @@ export class CoolifyClient { return this.request<{ uuid: string }>("PATCH", `/servers/${uuid}`, data); } - async deleteServer(uuid: string): Promise<{ message: string }> { - return this.request("DELETE", `/servers/${uuid}`); + async deleteServer(uuid: string, opts?: { force?: boolean }): Promise<{ message: string }> { + const params = new URLSearchParams(); + if (opts?.force !== undefined) params.set("force", String(opts.force)); + const qs = params.toString(); + return this.request("DELETE", `/servers/${uuid}${qs ? `?${qs}` : ""}`); } // Databases @@ -214,8 +231,15 @@ export class CoolifyClient { return this.request("GET", `/databases/${uuid}/start`); } - async stopDatabase(uuid: string): Promise<{ message: string }> { - return this.request("GET", `/databases/${uuid}/stop`); + async stopDatabase( + uuid: string, + opts?: { docker_cleanup?: boolean }, + ): Promise<{ message: string }> { + const params = new URLSearchParams(); + if (opts?.docker_cleanup !== undefined) + params.set("docker_cleanup", String(opts.docker_cleanup)); + const qs = params.toString(); + return this.request("GET", `/databases/${uuid}/stop${qs ? `?${qs}` : ""}`); } async restartDatabase(uuid: string): Promise<{ message: string }> { @@ -240,6 +264,7 @@ export class CoolifyClient { is_literal?: boolean; is_multiline?: boolean; is_shown_once?: boolean; + comment?: string; }, ): Promise<{ uuid: string }> { return this.request("POST", `/databases/${dbUuid}/envs`, data); @@ -254,6 +279,7 @@ export class CoolifyClient { is_literal?: boolean; is_multiline?: boolean; is_shown_once?: boolean; + comment?: string; }>, ): Promise { return this.request("PATCH", `/databases/${dbUuid}/envs/bulk`, { data: envs }); @@ -308,8 +334,15 @@ export class CoolifyClient { return this.request("POST", `/services/${uuid}/start`); } - async stopService(uuid: string): Promise<{ message: string }> { - return this.request("POST", `/services/${uuid}/stop`); + async stopService( + uuid: string, + opts?: { docker_cleanup?: boolean }, + ): Promise<{ message: string }> { + const params = new URLSearchParams(); + if (opts?.docker_cleanup !== undefined) + params.set("docker_cleanup", String(opts.docker_cleanup)); + const qs = params.toString(); + return this.request("POST", `/services/${uuid}/stop${qs ? `?${qs}` : ""}`); } async restartService(uuid: string): Promise<{ message: string }> { @@ -330,6 +363,7 @@ export class CoolifyClient { is_literal?: boolean; is_multiline?: boolean; is_shown_once?: boolean; + comment?: string; }, ): Promise<{ uuid: string }> { return this.request("POST", `/applications/${appUuid}/envs`, data); @@ -344,6 +378,7 @@ export class CoolifyClient { is_literal?: boolean; is_multiline?: boolean; is_shown_once?: boolean; + comment?: string; }>, ): Promise { return this.request("PATCH", `/applications/${appUuid}/envs/bulk`, { data: envs }); @@ -425,6 +460,7 @@ export class CoolifyClient { is_literal?: boolean; is_multiline?: boolean; is_shown_once?: boolean; + comment?: string; }, ): Promise<{ uuid: string }> { return this.request("POST", `/services/${serviceUuid}/envs`, data); @@ -439,6 +475,7 @@ export class CoolifyClient { is_literal?: boolean; is_multiline?: boolean; is_shown_once?: boolean; + comment?: string; }>, ): Promise { return this.request("PATCH", `/services/${serviceUuid}/envs/bulk`, { data: envs }); @@ -503,4 +540,246 @@ export class CoolifyClient { async getTeamMembers(teamId: number): Promise { return this.request("GET", `/teams/${teamId}/members`); } + + // Application Scheduled Tasks + async listApplicationScheduledTasks(uuid: string): Promise { + return this.request("GET", `/applications/${uuid}/scheduled-tasks`); + } + + async createApplicationScheduledTask( + uuid: string, + data: { + name: string; + command: string; + frequency: string; + container?: string; + timeout?: number; + enabled?: boolean; + }, + ): Promise<{ uuid: string }> { + return this.request<{ uuid: string }>("POST", `/applications/${uuid}/scheduled-tasks`, data); + } + + async updateApplicationScheduledTask( + uuid: string, + taskUuid: string, + data: Record, + ): Promise<{ uuid: string }> { + return this.request<{ uuid: string }>( + "PATCH", + `/applications/${uuid}/scheduled-tasks/${taskUuid}`, + data, + ); + } + + async deleteApplicationScheduledTask( + uuid: string, + taskUuid: string, + ): Promise<{ message: string }> { + return this.request("DELETE", `/applications/${uuid}/scheduled-tasks/${taskUuid}`); + } + + async listApplicationScheduledTaskExecutions( + uuid: string, + taskUuid: string, + ): Promise { + return this.request( + "GET", + `/applications/${uuid}/scheduled-tasks/${taskUuid}/executions`, + ); + } + + // Service Scheduled Tasks + async listServiceScheduledTasks(uuid: string): Promise { + return this.request("GET", `/services/${uuid}/scheduled-tasks`); + } + + async createServiceScheduledTask( + uuid: string, + data: { + name: string; + command: string; + frequency: string; + container?: string; + timeout?: number; + enabled?: boolean; + }, + ): Promise<{ uuid: string }> { + return this.request<{ uuid: string }>("POST", `/services/${uuid}/scheduled-tasks`, data); + } + + async updateServiceScheduledTask( + uuid: string, + taskUuid: string, + data: Record, + ): Promise<{ uuid: string }> { + return this.request<{ uuid: string }>( + "PATCH", + `/services/${uuid}/scheduled-tasks/${taskUuid}`, + data, + ); + } + + async deleteServiceScheduledTask(uuid: string, taskUuid: string): Promise<{ message: string }> { + return this.request("DELETE", `/services/${uuid}/scheduled-tasks/${taskUuid}`); + } + + async listServiceScheduledTaskExecutions( + uuid: string, + taskUuid: string, + ): Promise { + return this.request( + "GET", + `/services/${uuid}/scheduled-tasks/${taskUuid}/executions`, + ); + } + + // Application Storages + async listApplicationStorages(uuid: string): Promise { + return this.request("GET", `/applications/${uuid}/storages`); + } + + async createApplicationStorage( + uuid: string, + data: { name: string; mount_path: string; host_path?: string; content?: string }, + ): Promise<{ uuid: string }> { + return this.request<{ uuid: string }>("POST", `/applications/${uuid}/storages`, data); + } + + async updateApplicationStorage( + uuid: string, + storageUuid: string, + data: Record, + ): Promise<{ uuid: string }> { + return this.request<{ uuid: string }>( + "PATCH", + `/applications/${uuid}/storages/${storageUuid}`, + data, + ); + } + + async deleteApplicationStorage(uuid: string, storageUuid: string): Promise<{ message: string }> { + return this.request("DELETE", `/applications/${uuid}/storages/${storageUuid}`); + } + + // Database Storages + async listDatabaseStorages(uuid: string): Promise { + return this.request("GET", `/databases/${uuid}/storages`); + } + + async createDatabaseStorage( + uuid: string, + data: { name: string; mount_path: string; host_path?: string; content?: string }, + ): Promise<{ uuid: string }> { + return this.request<{ uuid: string }>("POST", `/databases/${uuid}/storages`, data); + } + + async updateDatabaseStorage( + uuid: string, + storageUuid: string, + data: Record, + ): Promise<{ uuid: string }> { + return this.request<{ uuid: string }>( + "PATCH", + `/databases/${uuid}/storages/${storageUuid}`, + data, + ); + } + + async deleteDatabaseStorage(uuid: string, storageUuid: string): Promise<{ message: string }> { + return this.request("DELETE", `/databases/${uuid}/storages/${storageUuid}`); + } + + // Service Storages + async listServiceStorages(uuid: string): Promise { + return this.request("GET", `/services/${uuid}/storages`); + } + + async createServiceStorage( + uuid: string, + data: { name: string; mount_path: string; host_path?: string; content?: string }, + ): Promise<{ uuid: string }> { + return this.request<{ uuid: string }>("POST", `/services/${uuid}/storages`, data); + } + + async updateServiceStorage( + uuid: string, + storageUuid: string, + data: Record, + ): Promise<{ uuid: string }> { + return this.request<{ uuid: string }>( + "PATCH", + `/services/${uuid}/storages/${storageUuid}`, + data, + ); + } + + async deleteServiceStorage(uuid: string, storageUuid: string): Promise<{ message: string }> { + return this.request("DELETE", `/services/${uuid}/storages/${storageUuid}`); + } + + // GitHub Apps + async listGitHubApps(): Promise { + return this.request("GET", "/github-apps"); + } + + async createGitHubApp(data: Record): Promise<{ id: number }> { + return this.request<{ id: number }>("POST", "/github-apps", data); + } + + async updateGitHubApp(id: number, data: Record): Promise<{ id: number }> { + return this.request<{ id: number }>("PATCH", `/github-apps/${id}`, data); + } + + async deleteGitHubApp(id: number): Promise<{ message: string }> { + return this.request("DELETE", `/github-apps/${id}`); + } + + async listGitHubAppRepositories(id: number): Promise { + return this.request("GET", `/github-apps/${id}/repositories`); + } + + async listGitHubAppBranches(id: number, owner: string, repo: string): Promise { + return this.request( + "GET", + `/github-apps/${id}/repositories/${owner}/${repo}/branches`, + ); + } + + // Backup Executions + async listBackupExecutions(dbUuid: string, backupUuid: string): Promise { + return this.request( + "GET", + `/databases/${dbUuid}/backups/${backupUuid}/executions`, + ); + } + + async deleteBackupExecution( + dbUuid: string, + backupUuid: string, + executionUuid: string, + ): Promise<{ message: string }> { + return this.request( + "DELETE", + `/databases/${dbUuid}/backups/${backupUuid}/executions/${executionUuid}`, + ); + } + + // Backup Schedule Update + async updateDatabaseBackup( + dbUuid: string, + backupUuid: string, + data: Record, + ): Promise<{ uuid: string }> { + return this.request<{ uuid: string }>( + "PATCH", + `/databases/${dbUuid}/backups/${backupUuid}`, + data, + ); + } + + // Resources (aggregate) + async listResources(): Promise { + return this.request("GET", "/resources"); + } } diff --git a/src/index.ts b/src/index.ts index 1c60eea..68f51b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,15 +7,21 @@ import { registerPrompts } from "./prompts"; import { registerResources } from "./resources"; import { registerApplicationTools } from "./tools/applications"; import { registerDatabaseEnvTools } from "./tools/database-envs"; +import { registerDatabaseStorageTools } from "./tools/database-storages"; import { registerDatabaseTools } from "./tools/databases"; import { registerDeploymentTools } from "./tools/deployments"; import { registerEnvTools } from "./tools/envs"; +import { registerGitHubAppTools } from "./tools/github-apps"; import { registerLogTools } from "./tools/logs"; import { registerPrivateKeyTools } from "./tools/private-keys"; import { registerProjectTools } from "./tools/projects"; +import { registerScheduledTaskTools } from "./tools/scheduled-tasks"; import { registerServerTools } from "./tools/servers"; import { registerServiceEnvTools } from "./tools/service-envs"; +import { registerServiceScheduledTaskTools } from "./tools/service-scheduled-tasks"; +import { registerServiceStorageTools } from "./tools/service-storages"; import { registerServiceTools } from "./tools/services"; +import { registerStorageTools } from "./tools/storages"; import { registerSystemTools } from "./tools/system"; import { registerTeamTools } from "./tools/teams"; @@ -26,7 +32,7 @@ async function main() { const server = new McpServer( { name: "coolify-mcp", - version: "2.3.0", + version: "3.0.0", }, { instructions: `Coolify MCP server for managing self-hosted PaaS instances. @@ -64,7 +70,13 @@ async function main() { 20. **Database env vars**: list_database_envs → create_database_env / update_database_envs_bulk / delete_database_env 21. **Database/service logs**: get_database_logs / get_service_logs (same filters as get_logs) 22. **Backup management**: list_database_backups → create_database_backup / delete_database_backup -23. **Deployment history**: list_application_deployments (by app UUID, with pagination)`, +23. **Deployment history**: list_application_deployments (by app UUID, with pagination) +24. **Scheduled tasks**: list_application_scheduled_tasks → create/update/delete_application_scheduled_task → list executions +25. **Storage/Volumes**: list_application_storages → create/update/delete_application_storage (same for database/service) +26. **GitHub Apps**: list_github_apps → list_github_app_repositories → list_github_app_branches +27. **Backup executions**: list_database_backups → list_backup_executions → delete_backup_execution +28. **Backup schedule**: update_database_backup (modify frequency, enable/disable) +29. **All resources**: list_resources (aggregate view across all projects)`, }, ); @@ -72,14 +84,20 @@ async function main() { registerApplicationTools(server, client, config); registerDatabaseTools(server, client, config); registerDatabaseEnvTools(server, client, config); + registerDatabaseStorageTools(server, client, config); registerDeploymentTools(server, client, config); registerEnvTools(server, client, config); + registerGitHubAppTools(server, client, config); registerLogTools(server, client, config); registerProjectTools(server, client, config); registerPrivateKeyTools(server, client, config); + registerScheduledTaskTools(server, client, config); registerServerTools(server, client, config); registerServiceTools(server, client, config); registerServiceEnvTools(server, client, config); + registerServiceScheduledTaskTools(server, client, config); + registerServiceStorageTools(server, client, config); + registerStorageTools(server, client, config); registerSystemTools(server, client, config); registerTeamTools(server, client, config); diff --git a/src/lib/safety.test.ts b/src/lib/safety.test.ts index fbe04c2..4d1e544 100644 --- a/src/lib/safety.test.ts +++ b/src/lib/safety.test.ts @@ -97,6 +97,71 @@ describe("isToolAllowed", () => { expect(isToolAllowed("coolify_delete_database_backup", config)).toBe(false); }); + // Scheduled Tasks + it("blocks scheduled task write/destructive in readonly mode", () => { + const config = { ...baseConfig, readonly: true }; + expect(isToolAllowed("coolify_create_application_scheduled_task", config)).toBe(false); + expect(isToolAllowed("coolify_update_application_scheduled_task", config)).toBe(false); + expect(isToolAllowed("coolify_delete_application_scheduled_task", config)).toBe(false); + expect(isToolAllowed("coolify_create_service_scheduled_task", config)).toBe(false); + expect(isToolAllowed("coolify_delete_service_scheduled_task", config)).toBe(false); + }); + + it("allows scheduled task read tools in readonly mode", () => { + const config = { ...baseConfig, readonly: true }; + expect(isToolAllowed("coolify_list_application_scheduled_tasks", config)).toBe(true); + expect(isToolAllowed("coolify_list_application_scheduled_task_executions", config)).toBe(true); + expect(isToolAllowed("coolify_list_service_scheduled_tasks", config)).toBe(true); + expect(isToolAllowed("coolify_list_service_scheduled_task_executions", config)).toBe(true); + }); + + // Storages + it("blocks storage write/destructive in readonly mode", () => { + const config = { ...baseConfig, readonly: true }; + expect(isToolAllowed("coolify_create_application_storage", config)).toBe(false); + expect(isToolAllowed("coolify_update_application_storage", config)).toBe(false); + expect(isToolAllowed("coolify_delete_application_storage", config)).toBe(false); + expect(isToolAllowed("coolify_create_database_storage", config)).toBe(false); + expect(isToolAllowed("coolify_delete_database_storage", config)).toBe(false); + expect(isToolAllowed("coolify_create_service_storage", config)).toBe(false); + expect(isToolAllowed("coolify_delete_service_storage", config)).toBe(false); + }); + + it("allows storage read tools in readonly mode", () => { + const config = { ...baseConfig, readonly: true }; + expect(isToolAllowed("coolify_list_application_storages", config)).toBe(true); + expect(isToolAllowed("coolify_list_database_storages", config)).toBe(true); + expect(isToolAllowed("coolify_list_service_storages", config)).toBe(true); + }); + + // GitHub Apps + it("blocks github app write/destructive in readonly mode", () => { + const config = { ...baseConfig, readonly: true }; + expect(isToolAllowed("coolify_create_github_app", config)).toBe(false); + expect(isToolAllowed("coolify_update_github_app", config)).toBe(false); + expect(isToolAllowed("coolify_delete_github_app", config)).toBe(false); + }); + + it("allows github app read tools in readonly mode", () => { + const config = { ...baseConfig, readonly: true }; + expect(isToolAllowed("coolify_list_github_apps", config)).toBe(true); + expect(isToolAllowed("coolify_list_github_app_repositories", config)).toBe(true); + expect(isToolAllowed("coolify_list_github_app_branches", config)).toBe(true); + }); + + // Backup Executions & Update + it("blocks backup execution destructive and backup update in readonly mode", () => { + const config = { ...baseConfig, readonly: true }; + expect(isToolAllowed("coolify_delete_backup_execution", config)).toBe(false); + expect(isToolAllowed("coolify_update_database_backup", config)).toBe(false); + }); + + it("allows backup execution read and resources list in readonly mode", () => { + const config = { ...baseConfig, readonly: true }; + expect(isToolAllowed("coolify_list_backup_executions", config)).toBe(true); + expect(isToolAllowed("coolify_list_resources", config)).toBe(true); + }); + it("allows unknown tools by default", () => { expect(isToolAllowed("unknown_tool", baseConfig)).toBe(true); }); @@ -159,6 +224,60 @@ describe("checkConfirmation", () => { expect(result.proceed).toBe(true); }); + it("requires confirmation for delete_application_scheduled_task", () => { + const config = { ...baseConfig, requireConfirm: true }; + const result = checkConfirmation( + "coolify_delete_application_scheduled_task", + { uuid: "app-1", task_uuid: "task-1" }, + config, + ); + expect(result.proceed).toBe(false); + expect(result.response).toBeDefined(); + }); + + it("requires confirmation for delete_application_storage", () => { + const config = { ...baseConfig, requireConfirm: true }; + const result = checkConfirmation( + "coolify_delete_application_storage", + { uuid: "app-1", storage_uuid: "stor-1" }, + config, + ); + expect(result.proceed).toBe(false); + expect(result.response).toBeDefined(); + }); + + it("requires confirmation for delete_github_app", () => { + const config = { ...baseConfig, requireConfirm: true }; + const result = checkConfirmation("coolify_delete_github_app", { id: 42 }, config); + expect(result.proceed).toBe(false); + expect(result.response).toBeDefined(); + }); + + it("requires confirmation for delete_backup_execution", () => { + const config = { ...baseConfig, requireConfirm: true }; + const result = checkConfirmation( + "coolify_delete_backup_execution", + { uuid: "db-1", backup_uuid: "b-1", execution_uuid: "e-1" }, + config, + ); + expect(result.proceed).toBe(false); + expect(result.response).toBeDefined(); + }); + + it("does not require confirmation for write-level scheduled task/storage tools", () => { + const config = { ...baseConfig, requireConfirm: true }; + expect( + checkConfirmation("coolify_create_application_scheduled_task", { uuid: "app-1" }, config) + .proceed, + ).toBe(true); + expect( + checkConfirmation("coolify_create_application_storage", { uuid: "app-1" }, config).proceed, + ).toBe(true); + expect( + checkConfirmation("coolify_update_database_backup", { uuid: "db-1" }, config).proceed, + ).toBe(true); + }); + it("proceeds for destructive tools with confirm: true", () => { const config = { ...baseConfig, requireConfirm: true }; const result = checkConfirmation( diff --git a/src/lib/safety.ts b/src/lib/safety.ts index a72e103..caff9e8 100644 --- a/src/lib/safety.ts +++ b/src/lib/safety.ts @@ -167,10 +167,71 @@ const TOOL_METADATA: Record = { // Phase 3: System - read coolify_get_version: { level: "read" }, coolify_healthcheck: { level: "read" }, + coolify_list_resources: { level: "read" }, // Phase 3: Teams - read coolify_list_teams: { level: "read" }, coolify_get_current_team: { level: "read" }, coolify_get_team_members: { level: "read" }, + // Application Scheduled Tasks + coolify_list_application_scheduled_tasks: { level: "read" }, + coolify_create_application_scheduled_task: { level: "write" }, + coolify_update_application_scheduled_task: { level: "write" }, + coolify_delete_application_scheduled_task: { + level: "destructive", + dangerWarning: "Permanently deletes the scheduled task.", + }, + coolify_list_application_scheduled_task_executions: { level: "read" }, + // Service Scheduled Tasks + coolify_list_service_scheduled_tasks: { level: "read" }, + coolify_create_service_scheduled_task: { level: "write" }, + coolify_update_service_scheduled_task: { level: "write" }, + coolify_delete_service_scheduled_task: { + level: "destructive", + dangerWarning: "Permanently deletes the scheduled task.", + }, + coolify_list_service_scheduled_task_executions: { level: "read" }, + // Application Storages + coolify_list_application_storages: { level: "read" }, + coolify_create_application_storage: { level: "write" }, + coolify_update_application_storage: { level: "write" }, + coolify_delete_application_storage: { + level: "destructive", + dangerWarning: "Permanently deletes the storage mount.", + }, + // Database Storages + coolify_list_database_storages: { level: "read" }, + coolify_create_database_storage: { level: "write" }, + coolify_update_database_storage: { level: "write" }, + coolify_delete_database_storage: { + level: "destructive", + dangerWarning: "Permanently deletes the storage mount.", + }, + // Service Storages + coolify_list_service_storages: { level: "read" }, + coolify_create_service_storage: { level: "write" }, + coolify_update_service_storage: { level: "write" }, + coolify_delete_service_storage: { + level: "destructive", + dangerWarning: "Permanently deletes the storage mount.", + }, + // GitHub Apps + coolify_list_github_apps: { level: "read" }, + coolify_create_github_app: { level: "write" }, + coolify_update_github_app: { level: "write" }, + coolify_delete_github_app: { + level: "destructive", + dangerWarning: "Permanently deletes the GitHub App integration.", + }, + coolify_list_github_app_repositories: { level: "read" }, + coolify_list_github_app_branches: { level: "read" }, + // Backup Executions + coolify_list_backup_executions: { level: "read" }, + coolify_delete_backup_execution: { + level: "destructive", + dangerWarning: "Permanently deletes the backup execution record.", + }, + // Backup Schedule Update + coolify_update_database_backup: { level: "write" }, }; export function getToolMeta(name: string): ToolMeta | undefined { diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 303eae3..d2d2119 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -31,3 +31,5 @@ export const customFields = z .record(z.string(), z.unknown()) .optional() .describe("Additional fields not listed above (advanced)"); + +export const numericId = z.number().int().min(1).describe("ID of the resource"); diff --git a/src/tools/applications.ts b/src/tools/applications.ts index e8f6932..b6beed9 100644 --- a/src/tools/applications.ts +++ b/src/tools/applications.ts @@ -75,14 +75,18 @@ export function registerApplicationTools(server: McpServer, client: CoolifyClien server.tool( "coolify_stop_application", "[DESTRUCTIVE] Stop a running Coolify application. Causes downtime.", - { uuid: schemas.uuid, confirm: schemas.confirm }, - async ({ uuid, confirm }) => { + { + uuid: schemas.uuid, + confirm: schemas.confirm, + docker_cleanup: z.boolean().optional().describe("Clean up Docker resources on stop"), + }, + async ({ uuid, confirm, docker_cleanup }) => { if (!isToolAllowed("coolify_stop_application", config)) return readonlyError("coolify_stop_application"); const check = checkConfirmation("coolify_stop_application", { uuid, confirm }, config); if (!check.proceed) return check.response!; return wrap(async () => { - const result = await client.stopApplication(uuid); + const result = await client.stopApplication(uuid, { docker_cleanup }); return result.message || `Application ${uuid} stop command sent`; }); }, diff --git a/src/tools/database-envs.ts b/src/tools/database-envs.ts index e13ca44..461f2f2 100644 --- a/src/tools/database-envs.ts +++ b/src/tools/database-envs.ts @@ -28,8 +28,18 @@ export function registerDatabaseEnvTools(server: McpServer, client: CoolifyClien is_literal: z.boolean().default(false).describe("Do not interpolate/expand the value"), is_multiline: z.boolean().default(false).describe("Value contains newlines"), is_shown_once: z.boolean().default(false).describe("Value is only shown once then hidden"), + comment: z.string().optional().describe("Comment or note for this variable"), }, - async ({ uuid, key, value, is_preview, is_literal, is_multiline, is_shown_once }) => { + async ({ + uuid, + key, + value, + is_preview, + is_literal, + is_multiline, + is_shown_once, + comment, + }) => { if (!isToolAllowed("coolify_create_database_env", config)) return readonlyError("coolify_create_database_env"); return wrap(async () => { @@ -40,6 +50,7 @@ export function registerDatabaseEnvTools(server: McpServer, client: CoolifyClien is_literal, is_multiline, is_shown_once, + comment, }); return { message: `Database env var '${key}' created`, env_uuid: result.uuid }; }); @@ -62,6 +73,7 @@ export function registerDatabaseEnvTools(server: McpServer, client: CoolifyClien is_literal: z.boolean().optional(), is_multiline: z.boolean().optional(), is_shown_once: z.boolean().optional(), + comment: z.string().optional(), }), ) .min(1) diff --git a/src/tools/database-storages.ts b/src/tools/database-storages.ts new file mode 100644 index 0000000..623b74b --- /dev/null +++ b/src/tools/database-storages.ts @@ -0,0 +1,108 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { CoolifyClient } from "../client"; +import type { Config } from "../config"; +import { checkConfirmation, isToolAllowed, readonlyError } from "../lib/safety"; +import * as schemas from "../lib/schemas"; +import { wrap } from "../lib/wrap"; +import { toStorageSummary } from "../types/api"; + +export function registerDatabaseStorageTools( + server: McpServer, + client: CoolifyClient, + config: Config, +) { + server.tool( + "coolify_list_database_storages", + "List all persistent storage mounts for a Coolify database", + { uuid: schemas.uuid.describe("UUID of the database") }, + async ({ uuid }) => { + return wrap(async () => { + const storages = await client.listDatabaseStorages(uuid); + return storages.map(toStorageSummary); + }); + }, + ); + + if (isToolAllowed("coolify_create_database_storage", config)) { + server.tool( + "coolify_create_database_storage", + "[WRITE] Add a persistent storage mount to a Coolify database", + { + uuid: schemas.uuid.describe("UUID of the database"), + name: z.string().min(1).describe("Storage name"), + mount_path: z.string().min(1).describe("Container mount path (e.g. /data)"), + host_path: z.string().optional().describe("Host path (leave empty for Docker volume)"), + content: z.string().optional().describe("File content (for config file mounts)"), + }, + async ({ uuid, name, mount_path, host_path, content }) => { + if (!isToolAllowed("coolify_create_database_storage", config)) + return readonlyError("coolify_create_database_storage"); + return wrap(async () => { + const result = await client.createDatabaseStorage(uuid, { + name, + mount_path, + host_path, + content, + }); + return { message: `Storage '${name}' created`, uuid: result.uuid }; + }); + }, + ); + } + + if (isToolAllowed("coolify_update_database_storage", config)) { + server.tool( + "coolify_update_database_storage", + "[WRITE] Update a persistent storage mount for a Coolify database", + { + uuid: schemas.uuid.describe("UUID of the database"), + storage_uuid: schemas.uuid.describe("UUID of the storage to update"), + name: z.string().optional().describe("Storage name"), + mount_path: z.string().optional().describe("Container mount path"), + host_path: z.string().optional().describe("Host path"), + content: z.string().optional().describe("File content"), + custom_fields: schemas.customFields, + }, + async ({ uuid, storage_uuid, custom_fields, ...fields }) => { + if (!isToolAllowed("coolify_update_database_storage", config)) + return readonlyError("coolify_update_database_storage"); + return wrap(async () => { + const data: Record = {}; + for (const [k, v] of Object.entries(fields)) { + if (v !== undefined) data[k] = v; + } + if (custom_fields) Object.assign(data, custom_fields); + await client.updateDatabaseStorage(uuid, storage_uuid, data); + return `Database storage ${storage_uuid} updated`; + }); + }, + ); + } + + if (isToolAllowed("coolify_delete_database_storage", config)) { + server.tool( + "coolify_delete_database_storage", + "[DESTRUCTIVE] Remove a persistent storage mount from a Coolify database", + { + uuid: schemas.uuid.describe("UUID of the database"), + storage_uuid: schemas.uuid.describe("UUID of the storage to delete"), + confirm: schemas.confirm, + }, + async ({ uuid, storage_uuid, confirm }) => { + if (!isToolAllowed("coolify_delete_database_storage", config)) + return readonlyError("coolify_delete_database_storage"); + const check = checkConfirmation( + "coolify_delete_database_storage", + { uuid, storage_uuid, confirm }, + config, + ); + if (!check.proceed) return check.response!; + return wrap(async () => { + const result = await client.deleteDatabaseStorage(uuid, storage_uuid); + return result.message || `Storage ${storage_uuid} deleted`; + }); + }, + ); + } +} diff --git a/src/tools/databases.ts b/src/tools/databases.ts index 5935fa4..94c1ea0 100644 --- a/src/tools/databases.ts +++ b/src/tools/databases.ts @@ -106,14 +106,18 @@ export function registerDatabaseTools(server: McpServer, client: CoolifyClient, server.tool( "coolify_stop_database", "[DESTRUCTIVE] Stop a running Coolify database. Causes downtime.", - { uuid: schemas.uuid, confirm: schemas.confirm }, - async ({ uuid, confirm }) => { + { + uuid: schemas.uuid, + confirm: schemas.confirm, + docker_cleanup: z.boolean().optional().describe("Clean up Docker resources on stop"), + }, + async ({ uuid, confirm, docker_cleanup }) => { if (!isToolAllowed("coolify_stop_database", config)) return readonlyError("coolify_stop_database"); const check = checkConfirmation("coolify_stop_database", { uuid, confirm }, config); if (!check.proceed) return check.response!; return wrap(async () => { - const result = await client.stopDatabase(uuid); + const result = await client.stopDatabase(uuid, { docker_cleanup }); return result.message || `Database ${uuid} stop command sent`; }); }, @@ -250,6 +254,77 @@ export function registerDatabaseTools(server: McpServer, client: CoolifyClient, ); } + // Read: list backup executions + server.tool( + "coolify_list_backup_executions", + "List executions of a database backup schedule", + { + uuid: schemas.uuid.describe("UUID of the database"), + backup_uuid: schemas.uuid.describe("UUID of the scheduled backup"), + }, + async ({ uuid, backup_uuid }) => { + return wrap(() => client.listBackupExecutions(uuid, backup_uuid)); + }, + ); + + // Destructive: delete backup execution + if (isToolAllowed("coolify_delete_backup_execution", config)) { + server.tool( + "coolify_delete_backup_execution", + "[DESTRUCTIVE] Delete a specific backup execution record", + { + uuid: schemas.uuid.describe("UUID of the database"), + backup_uuid: schemas.uuid.describe("UUID of the scheduled backup"), + execution_uuid: schemas.uuid.describe("UUID of the backup execution to delete"), + confirm: schemas.confirm, + }, + async ({ uuid, backup_uuid, execution_uuid, confirm }) => { + if (!isToolAllowed("coolify_delete_backup_execution", config)) + return readonlyError("coolify_delete_backup_execution"); + const check = checkConfirmation( + "coolify_delete_backup_execution", + { uuid, backup_uuid, execution_uuid, confirm }, + config, + ); + if (!check.proceed) return check.response!; + return wrap(async () => { + const result = await client.deleteBackupExecution(uuid, backup_uuid, execution_uuid); + return result.message || `Backup execution ${execution_uuid} deleted`; + }); + }, + ); + } + + // Write: update backup schedule + if (isToolAllowed("coolify_update_database_backup", config)) { + server.tool( + "coolify_update_database_backup", + "[WRITE] Update a database backup schedule configuration", + { + uuid: schemas.uuid.describe("UUID of the database"), + backup_uuid: schemas.uuid.describe("UUID of the scheduled backup"), + enabled: z.boolean().optional().describe("Enable or disable the backup schedule"), + frequency: z.string().optional().describe("Cron frequency for backups"), + s3_storage_id: z.number().int().optional().describe("S3 storage ID for remote backups"), + database_name_prefix: z.string().optional().describe("Prefix for backup database name"), + custom_fields: schemas.customFields, + }, + async ({ uuid, backup_uuid, custom_fields, ...fields }) => { + if (!isToolAllowed("coolify_update_database_backup", config)) + return readonlyError("coolify_update_database_backup"); + return wrap(async () => { + const data: Record = {}; + for (const [k, v] of Object.entries(fields)) { + if (v !== undefined) data[k] = v; + } + if (custom_fields) Object.assign(data, custom_fields); + await client.updateDatabaseBackup(uuid, backup_uuid, data); + return `Backup schedule ${backup_uuid} updated`; + }); + }, + ); + } + // Write: update if (isToolAllowed("coolify_update_database", config)) { server.tool( diff --git a/src/tools/envs.ts b/src/tools/envs.ts index e0e7866..4ef48a8 100644 --- a/src/tools/envs.ts +++ b/src/tools/envs.ts @@ -31,8 +31,18 @@ export function registerEnvTools(server: McpServer, client: CoolifyClient, confi is_literal: z.boolean().default(false).describe("Do not interpolate/expand the value"), is_multiline: z.boolean().default(false).describe("Value contains newlines"), is_shown_once: z.boolean().default(false).describe("Value is only shown once then hidden"), + comment: z.string().optional().describe("Comment or note for this variable"), }, - async ({ uuid, key, value, is_preview, is_literal, is_multiline, is_shown_once }) => { + async ({ + uuid, + key, + value, + is_preview, + is_literal, + is_multiline, + is_shown_once, + comment, + }) => { if (!isToolAllowed("coolify_create_env", config)) return readonlyError("coolify_create_env"); return wrap(async () => { @@ -43,6 +53,7 @@ export function registerEnvTools(server: McpServer, client: CoolifyClient, confi is_literal, is_multiline, is_shown_once, + comment, }); return { message: `Environment variable '${key}' created`, env_uuid: result.uuid }; }); @@ -65,6 +76,7 @@ export function registerEnvTools(server: McpServer, client: CoolifyClient, confi is_literal: z.boolean().optional(), is_multiline: z.boolean().optional(), is_shown_once: z.boolean().optional(), + comment: z.string().optional(), }), ) .min(1) diff --git a/src/tools/github-apps.ts b/src/tools/github-apps.ts new file mode 100644 index 0000000..0501e03 --- /dev/null +++ b/src/tools/github-apps.ts @@ -0,0 +1,109 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { CoolifyClient } from "../client"; +import type { Config } from "../config"; +import { checkConfirmation, isToolAllowed, readonlyError } from "../lib/safety"; +import * as schemas from "../lib/schemas"; +import { wrap } from "../lib/wrap"; +import { toGitHubAppSummary } from "../types/api"; + +export function registerGitHubAppTools(server: McpServer, client: CoolifyClient, config: Config) { + server.tool( + "coolify_list_github_apps", + "List all GitHub App integrations configured in Coolify", + {}, + async () => { + return wrap(async () => { + const apps = await client.listGitHubApps(); + return apps.map(toGitHubAppSummary); + }); + }, + ); + + if (isToolAllowed("coolify_create_github_app", config)) { + server.tool( + "coolify_create_github_app", + "[WRITE] Create a new GitHub App integration in Coolify", + { + name: z.string().min(1).describe("GitHub App name"), + custom_fields: schemas.customFields, + }, + async ({ name, custom_fields }) => { + if (!isToolAllowed("coolify_create_github_app", config)) + return readonlyError("coolify_create_github_app"); + return wrap(async () => { + const data: Record = { name }; + if (custom_fields) Object.assign(data, custom_fields); + const result = await client.createGitHubApp(data); + return { message: `GitHub App '${name}' created`, id: result.id }; + }); + }, + ); + } + + if (isToolAllowed("coolify_update_github_app", config)) { + server.tool( + "coolify_update_github_app", + "[WRITE] Update a GitHub App integration in Coolify", + { + id: schemas.numericId.describe("ID of the GitHub App"), + name: z.string().optional().describe("GitHub App name"), + custom_fields: schemas.customFields, + }, + async ({ id, name, custom_fields }) => { + if (!isToolAllowed("coolify_update_github_app", config)) + return readonlyError("coolify_update_github_app"); + return wrap(async () => { + const data: Record = {}; + if (name !== undefined) data.name = name; + if (custom_fields) Object.assign(data, custom_fields); + await client.updateGitHubApp(id, data); + return `GitHub App ${id} updated`; + }); + }, + ); + } + + if (isToolAllowed("coolify_delete_github_app", config)) { + server.tool( + "coolify_delete_github_app", + "[DESTRUCTIVE] Delete a GitHub App integration from Coolify", + { + id: schemas.numericId.describe("ID of the GitHub App"), + confirm: schemas.confirm, + }, + async ({ id, confirm }) => { + if (!isToolAllowed("coolify_delete_github_app", config)) + return readonlyError("coolify_delete_github_app"); + const check = checkConfirmation("coolify_delete_github_app", { id, confirm }, config); + if (!check.proceed) return check.response!; + return wrap(async () => { + const result = await client.deleteGitHubApp(id); + return result.message || `GitHub App ${id} deleted`; + }); + }, + ); + } + + server.tool( + "coolify_list_github_app_repositories", + "List repositories accessible by a GitHub App", + { id: schemas.numericId.describe("ID of the GitHub App") }, + async ({ id }) => { + return wrap(() => client.listGitHubAppRepositories(id)); + }, + ); + + server.tool( + "coolify_list_github_app_branches", + "List branches for a repository accessible by a GitHub App", + { + id: schemas.numericId.describe("ID of the GitHub App"), + owner: z.string().min(1).describe("Repository owner (user or organization)"), + repo: z.string().min(1).describe("Repository name"), + }, + async ({ id, owner, repo }) => { + return wrap(() => client.listGitHubAppBranches(id, owner, repo)); + }, + ); +} diff --git a/src/tools/scheduled-tasks.ts b/src/tools/scheduled-tasks.ts new file mode 100644 index 0000000..06719b0 --- /dev/null +++ b/src/tools/scheduled-tasks.ts @@ -0,0 +1,124 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { CoolifyClient } from "../client"; +import type { Config } from "../config"; +import { checkConfirmation, isToolAllowed, readonlyError } from "../lib/safety"; +import * as schemas from "../lib/schemas"; +import { wrap } from "../lib/wrap"; +import { toScheduledTaskSummary } from "../types/api"; + +export function registerScheduledTaskTools( + server: McpServer, + client: CoolifyClient, + config: Config, +) { + server.tool( + "coolify_list_application_scheduled_tasks", + "List all scheduled tasks for a Coolify application", + { uuid: schemas.uuid.describe("UUID of the application") }, + async ({ uuid }) => { + return wrap(async () => { + const tasks = await client.listApplicationScheduledTasks(uuid); + return tasks.map(toScheduledTaskSummary); + }); + }, + ); + + if (isToolAllowed("coolify_create_application_scheduled_task", config)) { + server.tool( + "coolify_create_application_scheduled_task", + "[WRITE] Create a scheduled task for a Coolify application", + { + uuid: schemas.uuid.describe("UUID of the application"), + name: z.string().min(1).describe("Task name"), + command: z.string().min(1).describe("Command to execute"), + frequency: z.string().min(1).describe("Cron frequency (e.g. '* * * * *')"), + container: z.string().optional().describe("Container name to run in"), + timeout: z.number().int().optional().describe("Timeout in seconds"), + enabled: z.boolean().optional().describe("Whether the task is enabled"), + }, + async ({ uuid, name, command, frequency, container, timeout, enabled }) => { + if (!isToolAllowed("coolify_create_application_scheduled_task", config)) + return readonlyError("coolify_create_application_scheduled_task"); + return wrap(async () => { + const result = await client.createApplicationScheduledTask(uuid, { + name, + command, + frequency, + container, + timeout, + enabled, + }); + return { message: `Scheduled task '${name}' created`, uuid: result.uuid }; + }); + }, + ); + } + + if (isToolAllowed("coolify_update_application_scheduled_task", config)) { + server.tool( + "coolify_update_application_scheduled_task", + "[WRITE] Update a scheduled task for a Coolify application", + { + uuid: schemas.uuid.describe("UUID of the application"), + task_uuid: schemas.uuid.describe("UUID of the scheduled task"), + name: z.string().optional().describe("Task name"), + command: z.string().optional().describe("Command to execute"), + frequency: z.string().optional().describe("Cron frequency"), + container: z.string().optional().describe("Container name"), + timeout: z.number().int().optional().describe("Timeout in seconds"), + enabled: z.boolean().optional().describe("Whether the task is enabled"), + }, + async ({ uuid, task_uuid, ...fields }) => { + if (!isToolAllowed("coolify_update_application_scheduled_task", config)) + return readonlyError("coolify_update_application_scheduled_task"); + return wrap(async () => { + const data: Record = {}; + for (const [k, v] of Object.entries(fields)) { + if (v !== undefined) data[k] = v; + } + await client.updateApplicationScheduledTask(uuid, task_uuid, data); + return `Scheduled task ${task_uuid} updated`; + }); + }, + ); + } + + if (isToolAllowed("coolify_delete_application_scheduled_task", config)) { + server.tool( + "coolify_delete_application_scheduled_task", + "[DESTRUCTIVE] Delete a scheduled task from a Coolify application", + { + uuid: schemas.uuid.describe("UUID of the application"), + task_uuid: schemas.uuid.describe("UUID of the scheduled task"), + confirm: schemas.confirm, + }, + async ({ uuid, task_uuid, confirm }) => { + if (!isToolAllowed("coolify_delete_application_scheduled_task", config)) + return readonlyError("coolify_delete_application_scheduled_task"); + const check = checkConfirmation( + "coolify_delete_application_scheduled_task", + { uuid, task_uuid, confirm }, + config, + ); + if (!check.proceed) return check.response!; + return wrap(async () => { + const result = await client.deleteApplicationScheduledTask(uuid, task_uuid); + return result.message || `Scheduled task ${task_uuid} deleted`; + }); + }, + ); + } + + server.tool( + "coolify_list_application_scheduled_task_executions", + "List executions of a scheduled task for a Coolify application", + { + uuid: schemas.uuid.describe("UUID of the application"), + task_uuid: schemas.uuid.describe("UUID of the scheduled task"), + }, + async ({ uuid, task_uuid }) => { + return wrap(() => client.listApplicationScheduledTaskExecutions(uuid, task_uuid)); + }, + ); +} diff --git a/src/tools/servers.ts b/src/tools/servers.ts index 2cf1235..38402c1 100644 --- a/src/tools/servers.ts +++ b/src/tools/servers.ts @@ -138,14 +138,15 @@ export function registerServerTools(server: McpServer, client: CoolifyClient, co { uuid: schemas.uuid, confirm: schemas.confirm, + force: z.boolean().optional().describe("Force delete even if server has running resources"), }, - async ({ uuid, confirm }) => { + async ({ uuid, confirm, force }) => { if (!isToolAllowed("coolify_delete_server", config)) return readonlyError("coolify_delete_server"); const check = checkConfirmation("coolify_delete_server", { uuid, confirm }, config); if (!check.proceed) return check.response!; return wrap(async () => { - const result = await client.deleteServer(uuid); + const result = await client.deleteServer(uuid, { force }); return result.message || `Server ${uuid} deleted`; }); }, diff --git a/src/tools/service-envs.ts b/src/tools/service-envs.ts index e4e9068..8856d81 100644 --- a/src/tools/service-envs.ts +++ b/src/tools/service-envs.ts @@ -28,8 +28,18 @@ export function registerServiceEnvTools(server: McpServer, client: CoolifyClient is_literal: z.boolean().default(false).describe("Do not interpolate/expand the value"), is_multiline: z.boolean().default(false).describe("Value contains newlines"), is_shown_once: z.boolean().default(false).describe("Value is only shown once then hidden"), + comment: z.string().optional().describe("Comment or note for this variable"), }, - async ({ uuid, key, value, is_preview, is_literal, is_multiline, is_shown_once }) => { + async ({ + uuid, + key, + value, + is_preview, + is_literal, + is_multiline, + is_shown_once, + comment, + }) => { if (!isToolAllowed("coolify_create_service_env", config)) return readonlyError("coolify_create_service_env"); return wrap(async () => { @@ -40,6 +50,7 @@ export function registerServiceEnvTools(server: McpServer, client: CoolifyClient is_literal, is_multiline, is_shown_once, + comment, }); return { message: `Service env var '${key}' created`, env_uuid: result.uuid }; }); @@ -62,6 +73,7 @@ export function registerServiceEnvTools(server: McpServer, client: CoolifyClient is_literal: z.boolean().optional(), is_multiline: z.boolean().optional(), is_shown_once: z.boolean().optional(), + comment: z.string().optional(), }), ) .min(1) diff --git a/src/tools/service-scheduled-tasks.ts b/src/tools/service-scheduled-tasks.ts new file mode 100644 index 0000000..54d21f6 --- /dev/null +++ b/src/tools/service-scheduled-tasks.ts @@ -0,0 +1,124 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { CoolifyClient } from "../client"; +import type { Config } from "../config"; +import { checkConfirmation, isToolAllowed, readonlyError } from "../lib/safety"; +import * as schemas from "../lib/schemas"; +import { wrap } from "../lib/wrap"; +import { toScheduledTaskSummary } from "../types/api"; + +export function registerServiceScheduledTaskTools( + server: McpServer, + client: CoolifyClient, + config: Config, +) { + server.tool( + "coolify_list_service_scheduled_tasks", + "List all scheduled tasks for a Coolify service", + { uuid: schemas.uuid.describe("UUID of the service") }, + async ({ uuid }) => { + return wrap(async () => { + const tasks = await client.listServiceScheduledTasks(uuid); + return tasks.map(toScheduledTaskSummary); + }); + }, + ); + + if (isToolAllowed("coolify_create_service_scheduled_task", config)) { + server.tool( + "coolify_create_service_scheduled_task", + "[WRITE] Create a scheduled task for a Coolify service", + { + uuid: schemas.uuid.describe("UUID of the service"), + name: z.string().min(1).describe("Task name"), + command: z.string().min(1).describe("Command to execute"), + frequency: z.string().min(1).describe("Cron frequency (e.g. '* * * * *')"), + container: z.string().optional().describe("Container name to run in"), + timeout: z.number().int().optional().describe("Timeout in seconds"), + enabled: z.boolean().optional().describe("Whether the task is enabled"), + }, + async ({ uuid, name, command, frequency, container, timeout, enabled }) => { + if (!isToolAllowed("coolify_create_service_scheduled_task", config)) + return readonlyError("coolify_create_service_scheduled_task"); + return wrap(async () => { + const result = await client.createServiceScheduledTask(uuid, { + name, + command, + frequency, + container, + timeout, + enabled, + }); + return { message: `Scheduled task '${name}' created`, uuid: result.uuid }; + }); + }, + ); + } + + if (isToolAllowed("coolify_update_service_scheduled_task", config)) { + server.tool( + "coolify_update_service_scheduled_task", + "[WRITE] Update a scheduled task for a Coolify service", + { + uuid: schemas.uuid.describe("UUID of the service"), + task_uuid: schemas.uuid.describe("UUID of the scheduled task"), + name: z.string().optional().describe("Task name"), + command: z.string().optional().describe("Command to execute"), + frequency: z.string().optional().describe("Cron frequency"), + container: z.string().optional().describe("Container name"), + timeout: z.number().int().optional().describe("Timeout in seconds"), + enabled: z.boolean().optional().describe("Whether the task is enabled"), + }, + async ({ uuid, task_uuid, ...fields }) => { + if (!isToolAllowed("coolify_update_service_scheduled_task", config)) + return readonlyError("coolify_update_service_scheduled_task"); + return wrap(async () => { + const data: Record = {}; + for (const [k, v] of Object.entries(fields)) { + if (v !== undefined) data[k] = v; + } + await client.updateServiceScheduledTask(uuid, task_uuid, data); + return `Scheduled task ${task_uuid} updated`; + }); + }, + ); + } + + if (isToolAllowed("coolify_delete_service_scheduled_task", config)) { + server.tool( + "coolify_delete_service_scheduled_task", + "[DESTRUCTIVE] Delete a scheduled task from a Coolify service", + { + uuid: schemas.uuid.describe("UUID of the service"), + task_uuid: schemas.uuid.describe("UUID of the scheduled task"), + confirm: schemas.confirm, + }, + async ({ uuid, task_uuid, confirm }) => { + if (!isToolAllowed("coolify_delete_service_scheduled_task", config)) + return readonlyError("coolify_delete_service_scheduled_task"); + const check = checkConfirmation( + "coolify_delete_service_scheduled_task", + { uuid, task_uuid, confirm }, + config, + ); + if (!check.proceed) return check.response!; + return wrap(async () => { + const result = await client.deleteServiceScheduledTask(uuid, task_uuid); + return result.message || `Scheduled task ${task_uuid} deleted`; + }); + }, + ); + } + + server.tool( + "coolify_list_service_scheduled_task_executions", + "List executions of a scheduled task for a Coolify service", + { + uuid: schemas.uuid.describe("UUID of the service"), + task_uuid: schemas.uuid.describe("UUID of the scheduled task"), + }, + async ({ uuid, task_uuid }) => { + return wrap(() => client.listServiceScheduledTaskExecutions(uuid, task_uuid)); + }, + ); +} diff --git a/src/tools/service-storages.ts b/src/tools/service-storages.ts new file mode 100644 index 0000000..f7754fa --- /dev/null +++ b/src/tools/service-storages.ts @@ -0,0 +1,108 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { CoolifyClient } from "../client"; +import type { Config } from "../config"; +import { checkConfirmation, isToolAllowed, readonlyError } from "../lib/safety"; +import * as schemas from "../lib/schemas"; +import { wrap } from "../lib/wrap"; +import { toStorageSummary } from "../types/api"; + +export function registerServiceStorageTools( + server: McpServer, + client: CoolifyClient, + config: Config, +) { + server.tool( + "coolify_list_service_storages", + "List all persistent storage mounts for a Coolify service", + { uuid: schemas.uuid.describe("UUID of the service") }, + async ({ uuid }) => { + return wrap(async () => { + const storages = await client.listServiceStorages(uuid); + return storages.map(toStorageSummary); + }); + }, + ); + + if (isToolAllowed("coolify_create_service_storage", config)) { + server.tool( + "coolify_create_service_storage", + "[WRITE] Add a persistent storage mount to a Coolify service", + { + uuid: schemas.uuid.describe("UUID of the service"), + name: z.string().min(1).describe("Storage name"), + mount_path: z.string().min(1).describe("Container mount path (e.g. /data)"), + host_path: z.string().optional().describe("Host path (leave empty for Docker volume)"), + content: z.string().optional().describe("File content (for config file mounts)"), + }, + async ({ uuid, name, mount_path, host_path, content }) => { + if (!isToolAllowed("coolify_create_service_storage", config)) + return readonlyError("coolify_create_service_storage"); + return wrap(async () => { + const result = await client.createServiceStorage(uuid, { + name, + mount_path, + host_path, + content, + }); + return { message: `Storage '${name}' created`, uuid: result.uuid }; + }); + }, + ); + } + + if (isToolAllowed("coolify_update_service_storage", config)) { + server.tool( + "coolify_update_service_storage", + "[WRITE] Update a persistent storage mount for a Coolify service", + { + uuid: schemas.uuid.describe("UUID of the service"), + storage_uuid: schemas.uuid.describe("UUID of the storage to update"), + name: z.string().optional().describe("Storage name"), + mount_path: z.string().optional().describe("Container mount path"), + host_path: z.string().optional().describe("Host path"), + content: z.string().optional().describe("File content"), + custom_fields: schemas.customFields, + }, + async ({ uuid, storage_uuid, custom_fields, ...fields }) => { + if (!isToolAllowed("coolify_update_service_storage", config)) + return readonlyError("coolify_update_service_storage"); + return wrap(async () => { + const data: Record = {}; + for (const [k, v] of Object.entries(fields)) { + if (v !== undefined) data[k] = v; + } + if (custom_fields) Object.assign(data, custom_fields); + await client.updateServiceStorage(uuid, storage_uuid, data); + return `Service storage ${storage_uuid} updated`; + }); + }, + ); + } + + if (isToolAllowed("coolify_delete_service_storage", config)) { + server.tool( + "coolify_delete_service_storage", + "[DESTRUCTIVE] Remove a persistent storage mount from a Coolify service", + { + uuid: schemas.uuid.describe("UUID of the service"), + storage_uuid: schemas.uuid.describe("UUID of the storage to delete"), + confirm: schemas.confirm, + }, + async ({ uuid, storage_uuid, confirm }) => { + if (!isToolAllowed("coolify_delete_service_storage", config)) + return readonlyError("coolify_delete_service_storage"); + const check = checkConfirmation( + "coolify_delete_service_storage", + { uuid, storage_uuid, confirm }, + config, + ); + if (!check.proceed) return check.response!; + return wrap(async () => { + const result = await client.deleteServiceStorage(uuid, storage_uuid); + return result.message || `Storage ${storage_uuid} deleted`; + }); + }, + ); + } +} diff --git a/src/tools/services.ts b/src/tools/services.ts index 871c196..f36789f 100644 --- a/src/tools/services.ts +++ b/src/tools/services.ts @@ -75,14 +75,18 @@ export function registerServiceTools(server: McpServer, client: CoolifyClient, c server.tool( "coolify_stop_service", "[DESTRUCTIVE] Stop a running Coolify service. Causes downtime.", - { uuid: schemas.uuid, confirm: schemas.confirm }, - async ({ uuid, confirm }) => { + { + uuid: schemas.uuid, + confirm: schemas.confirm, + docker_cleanup: z.boolean().optional().describe("Clean up Docker resources on stop"), + }, + async ({ uuid, confirm, docker_cleanup }) => { if (!isToolAllowed("coolify_stop_service", config)) return readonlyError("coolify_stop_service"); const check = checkConfirmation("coolify_stop_service", { uuid, confirm }, config); if (!check.proceed) return check.response!; return wrap(async () => { - const result = await client.stopService(uuid); + const result = await client.stopService(uuid, { docker_cleanup }); return result.message || `Service ${uuid} stop command sent`; }); }, diff --git a/src/tools/storages.ts b/src/tools/storages.ts new file mode 100644 index 0000000..062bb4d --- /dev/null +++ b/src/tools/storages.ts @@ -0,0 +1,104 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { CoolifyClient } from "../client"; +import type { Config } from "../config"; +import { checkConfirmation, isToolAllowed, readonlyError } from "../lib/safety"; +import * as schemas from "../lib/schemas"; +import { wrap } from "../lib/wrap"; +import { toStorageSummary } from "../types/api"; + +export function registerStorageTools(server: McpServer, client: CoolifyClient, config: Config) { + server.tool( + "coolify_list_application_storages", + "List all persistent storage mounts for a Coolify application", + { uuid: schemas.uuid.describe("UUID of the application") }, + async ({ uuid }) => { + return wrap(async () => { + const storages = await client.listApplicationStorages(uuid); + return storages.map(toStorageSummary); + }); + }, + ); + + if (isToolAllowed("coolify_create_application_storage", config)) { + server.tool( + "coolify_create_application_storage", + "[WRITE] Add a persistent storage mount to a Coolify application", + { + uuid: schemas.uuid.describe("UUID of the application"), + name: z.string().min(1).describe("Storage name"), + mount_path: z.string().min(1).describe("Container mount path (e.g. /data)"), + host_path: z.string().optional().describe("Host path (leave empty for Docker volume)"), + content: z.string().optional().describe("File content (for config file mounts)"), + }, + async ({ uuid, name, mount_path, host_path, content }) => { + if (!isToolAllowed("coolify_create_application_storage", config)) + return readonlyError("coolify_create_application_storage"); + return wrap(async () => { + const result = await client.createApplicationStorage(uuid, { + name, + mount_path, + host_path, + content, + }); + return { message: `Storage '${name}' created`, uuid: result.uuid }; + }); + }, + ); + } + + if (isToolAllowed("coolify_update_application_storage", config)) { + server.tool( + "coolify_update_application_storage", + "[WRITE] Update a persistent storage mount for a Coolify application", + { + uuid: schemas.uuid.describe("UUID of the application"), + storage_uuid: schemas.uuid.describe("UUID of the storage to update"), + name: z.string().optional().describe("Storage name"), + mount_path: z.string().optional().describe("Container mount path"), + host_path: z.string().optional().describe("Host path"), + content: z.string().optional().describe("File content"), + custom_fields: schemas.customFields, + }, + async ({ uuid, storage_uuid, custom_fields, ...fields }) => { + if (!isToolAllowed("coolify_update_application_storage", config)) + return readonlyError("coolify_update_application_storage"); + return wrap(async () => { + const data: Record = {}; + for (const [k, v] of Object.entries(fields)) { + if (v !== undefined) data[k] = v; + } + if (custom_fields) Object.assign(data, custom_fields); + await client.updateApplicationStorage(uuid, storage_uuid, data); + return `Application storage ${storage_uuid} updated`; + }); + }, + ); + } + + if (isToolAllowed("coolify_delete_application_storage", config)) { + server.tool( + "coolify_delete_application_storage", + "[DESTRUCTIVE] Remove a persistent storage mount from a Coolify application", + { + uuid: schemas.uuid.describe("UUID of the application"), + storage_uuid: schemas.uuid.describe("UUID of the storage to delete"), + confirm: schemas.confirm, + }, + async ({ uuid, storage_uuid, confirm }) => { + if (!isToolAllowed("coolify_delete_application_storage", config)) + return readonlyError("coolify_delete_application_storage"); + const check = checkConfirmation( + "coolify_delete_application_storage", + { uuid, storage_uuid, confirm }, + config, + ); + if (!check.proceed) return check.response!; + return wrap(async () => { + const result = await client.deleteApplicationStorage(uuid, storage_uuid); + return result.message || `Storage ${storage_uuid} deleted`; + }); + }, + ); + } +} diff --git a/src/tools/system.ts b/src/tools/system.ts index 903eac3..c7a163c 100644 --- a/src/tools/system.ts +++ b/src/tools/system.ts @@ -16,4 +16,13 @@ export function registerSystemTools(server: McpServer, client: CoolifyClient, _c return wrap(() => client.healthcheck()); }, ); + + server.tool( + "coolify_list_resources", + "List all resources (applications, databases, services) across all projects", + {}, + async () => { + return wrap(() => client.listResources()); + }, + ); } diff --git a/src/types/api.test.ts b/src/types/api.test.ts index 669eb59..0cd5e01 100644 --- a/src/types/api.test.ts +++ b/src/types/api.test.ts @@ -4,19 +4,25 @@ import { type Database, type Deployment, type Environment, + type GitHubApp, type PrivateKey, type Project, + type ScheduledTask, type ServerInfo, type Service, + type Storage, type Team, toApplicationSummary, toDatabaseSummary, toDeploymentSummary, toEnvironmentSummary, + toGitHubAppSummary, toPrivateKeySummary, toProjectSummary, + toScheduledTaskSummary, toServerSummary, toServiceSummary, + toStorageSummary, toTeamSummary, } from "./api"; @@ -263,3 +269,92 @@ describe("toPrivateKeySummary", () => { expect("updated_at" in summary).toBe(false); }); }); + +describe("toScheduledTaskSummary", () => { + const task: ScheduledTask = { + uuid: "task-001", + name: "cleanup-logs", + command: "rm -rf /tmp/logs/*", + frequency: "0 2 * * *", + container: "app", + timeout: 300, + enabled: true, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-06-15T00:00:00Z", + }; + + it("extracts summary fields", () => { + const summary = toScheduledTaskSummary(task); + expect(summary.uuid).toBe("task-001"); + expect(summary.name).toBe("cleanup-logs"); + expect(summary.frequency).toBe("0 2 * * *"); + expect(summary.enabled).toBe(true); + }); + + it("excludes detail fields", () => { + const summary = toScheduledTaskSummary(task); + expect("command" in summary).toBe(false); + expect("container" in summary).toBe(false); + expect("timeout" in summary).toBe(false); + expect("created_at" in summary).toBe(false); + }); +}); + +describe("toStorageSummary", () => { + const storage: Storage = { + uuid: "stor-001", + name: "app-data", + mount_path: "/data", + host_path: "/mnt/data", + content: "some config", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-06-15T00:00:00Z", + }; + + it("extracts summary fields", () => { + const summary = toStorageSummary(storage); + expect(summary.uuid).toBe("stor-001"); + expect(summary.name).toBe("app-data"); + expect(summary.mount_path).toBe("/data"); + expect(summary.host_path).toBe("/mnt/data"); + }); + + it("excludes detail fields", () => { + const summary = toStorageSummary(storage); + expect("content" in summary).toBe(false); + expect("created_at" in summary).toBe(false); + expect("updated_at" in summary).toBe(false); + }); +}); + +describe("toGitHubAppSummary", () => { + const app: GitHubApp = { + id: 42, + uuid: "gh-001", + name: "my-github-app", + organization_id: 1, + app_id: 12345, + installation_id: 67890, + html_url: "https://github.com/apps/my-app", + is_system_wide: false, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-06-15T00:00:00Z", + }; + + it("extracts summary fields", () => { + const summary = toGitHubAppSummary(app); + expect(summary.id).toBe(42); + expect(summary.uuid).toBe("gh-001"); + expect(summary.name).toBe("my-github-app"); + expect(summary.is_system_wide).toBe(false); + }); + + it("excludes detail fields", () => { + const summary = toGitHubAppSummary(app); + expect("organization_id" in summary).toBe(false); + expect("app_id" in summary).toBe(false); + expect("installation_id" in summary).toBe(false); + expect("html_url" in summary).toBe(false); + expect("created_at" in summary).toBe(false); + }); +}); diff --git a/src/types/api.ts b/src/types/api.ts index 65740ad..55eaf4a 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -119,6 +119,7 @@ export interface EnvironmentVariable { is_literal: boolean; is_multiline: boolean; is_shown_once: boolean; + comment?: string; } // --- Phase 2 types --- @@ -195,6 +196,97 @@ export interface TeamMember { role?: string; } +// --- Scheduled Tasks types --- + +export interface ScheduledTask { + uuid: string; + name: string; + command: string; + frequency: string; + container?: string; + timeout?: number; + enabled: boolean; + created_at: string; + updated_at: string; +} + +export interface ScheduledTaskSummary { + uuid: string; + name: string; + frequency: string; + enabled: boolean; +} + +export interface ScheduledTaskExecution { + id: number; + status: string; + message?: string; + created_at: string; +} + +// --- Storage/Volumes types --- + +export interface Storage { + uuid: string; + name: string; + mount_path: string; + host_path?: string; + content?: string; + created_at: string; + updated_at: string; +} + +export interface StorageSummary { + uuid: string; + name: string; + mount_path: string; + host_path?: string; +} + +// --- GitHub Apps types --- + +export interface GitHubApp { + id: number; + uuid: string; + name: string; + organization_id?: number; + app_id?: number; + installation_id?: number; + html_url?: string; + is_system_wide?: boolean; + created_at: string; + updated_at: string; +} + +export interface GitHubAppSummary { + id: number; + uuid: string; + name: string; + is_system_wide?: boolean; +} + +export interface GitHubRepository { + id: number; + full_name: string; + private: boolean; + html_url: string; +} + +export interface GitHubBranch { + name: string; +} + +// --- Backup Execution types --- + +export interface BackupExecution { + id: number; + status: string; + message?: string; + filename?: string; + size?: number; + created_at: string; +} + // Transformer functions export function toApplicationSummary(app: Application): ApplicationSummary { @@ -275,3 +367,30 @@ export function toTeamSummary(team: Team): TeamSummary { description: team.description, }; } + +export function toScheduledTaskSummary(task: ScheduledTask): ScheduledTaskSummary { + return { + uuid: task.uuid, + name: task.name, + frequency: task.frequency, + enabled: task.enabled, + }; +} + +export function toStorageSummary(storage: Storage): StorageSummary { + return { + uuid: storage.uuid, + name: storage.name, + mount_path: storage.mount_path, + host_path: storage.host_path, + }; +} + +export function toGitHubAppSummary(app: GitHubApp): GitHubAppSummary { + return { + id: app.id, + uuid: app.uuid, + name: app.name, + is_system_wide: app.is_system_wide, + }; +}