@masonator/coolify-mcp
Advanced tools
@@ -794,1 +794,162 @@ /** | ||
| }); | ||
| describe('elicitation: credential deletes (#315)', () => { | ||
| it('asks before deleting a private key, and says it is unrecoverable', async () => { | ||
| const h = await harness(decline); | ||
| const client = h.server['client']; | ||
| jest | ||
| .spyOn(client, 'getPrivateKey') | ||
| .mockResolvedValue({ uuid: 'key-1', name: 'deploy-key' }); | ||
| const del = jest.spyOn(client, 'deletePrivateKey').mockResolvedValue({}); | ||
| const text = await h.call('private_keys', { action: 'delete', uuid: 'key-1' }); | ||
| // The reason this delete is guarded and the routine ones are not: the key | ||
| // material is write-only in Coolify, so there is no undo. | ||
| expect(h.prompts[0]).toContain('Delete SSH private key "deploy-key"'); | ||
| expect(h.prompts[0]).toContain('not recoverable'); | ||
| expect(del).not.toHaveBeenCalled(); | ||
| expect(text).toContain('Nothing was changed'); | ||
| await h.close(); | ||
| }); | ||
| it('asks before deleting a cloud token', async () => { | ||
| const h = await harness(accept); | ||
| const client = h.server['client']; | ||
| jest | ||
| .spyOn(client, 'getCloudToken') | ||
| .mockResolvedValue({ uuid: 'ct-1', name: 'hetzner-prod' }); | ||
| const del = jest.spyOn(client, 'deleteCloudToken').mockResolvedValue({}); | ||
| await h.call('cloud_tokens', { action: 'delete', uuid: 'ct-1' }); | ||
| expect(h.prompts[0]).toContain('Delete cloud-provider token "hetzner-prod"'); | ||
| expect(del).toHaveBeenCalled(); | ||
| await h.close(); | ||
| }); | ||
| it('counts the applications sourced from a GitHub app before deleting it', async () => { | ||
| const h = await harness(decline); | ||
| const client = h.server['client']; | ||
| jest.spyOn(client, 'listApplications').mockResolvedValue([ | ||
| { uuid: 'a', name: 'api', source_id: 7, source_type: 'App\\Models\\GithubApp' }, | ||
| { uuid: 'b', name: 'web', source_id: 7, source_type: 'App\\Models\\GithubApp' }, | ||
| // Same numeric id, different source type — must not be counted. | ||
| { uuid: 'c', name: 'gl', source_id: 7, source_type: 'App\\Models\\GitlabApp' }, | ||
| { uuid: 'd', name: 'pub', source_id: null, source_type: null }, | ||
| ]); | ||
| jest | ||
| .spyOn(client, 'listGitHubApps') | ||
| .mockResolvedValue([{ id: 7, name: 'my-installation' }]); | ||
| const del = jest.spyOn(client, 'deleteGitHubApp').mockResolvedValue({}); | ||
| await h.call('github_apps', { action: 'delete', id: 7 }); | ||
| expect(h.prompts[0]).toContain('Delete GitHub app "my-installation"'); | ||
| expect(h.prompts[0]).toContain('2 applications'); | ||
| expect(h.prompts[0]).toContain('lose their deploy source'); | ||
| expect(h.prompts[0]).not.toContain('gl'); | ||
| expect(del).not.toHaveBeenCalled(); | ||
| await h.close(); | ||
| }); | ||
| it('says so plainly when no applications are sourced from the GitHub app', async () => { | ||
| const h = await harness(accept); | ||
| const client = h.server['client']; | ||
| jest.spyOn(client, 'listApplications').mockResolvedValue([]); | ||
| jest.spyOn(client, 'listGitHubApps').mockResolvedValue([{ id: 7, name: 'idle' }]); | ||
| const del = jest.spyOn(client, 'deleteGitHubApp').mockResolvedValue({}); | ||
| await h.call('github_apps', { action: 'delete', id: 7 }); | ||
| // Still asks — deleting an installation is not undoable from Coolify's | ||
| // side either — but the prompt is honest that nothing currently breaks. | ||
| expect(h.prompts[0]).toContain('No applications are currently sourced from it'); | ||
| expect(del).toHaveBeenCalled(); | ||
| await h.close(); | ||
| }); | ||
| it('leaves the routine deletes unguarded, by decision not omission', async () => { | ||
| const h = await harness(accept); | ||
| const client = h.server['client']; | ||
| const delStorage = jest | ||
| .spyOn(client, 'deleteApplicationStorage') | ||
| .mockResolvedValue({}); | ||
| await h.call('storages', { | ||
| action: 'delete', | ||
| resource: 'application', | ||
| uuid: 'app-1', | ||
| storage_uuid: 'st-1', | ||
| }); | ||
| // #315's boundary: prompting on every delete is how prompts stop being | ||
| // read. If this test starts failing because storages grew a guard, that | ||
| // should be a deliberate revisit of the boundary, not a side effect. | ||
| expect(h.prompts).toEqual([]); | ||
| expect(delStorage).toHaveBeenCalled(); | ||
| await h.close(); | ||
| }); | ||
| }); | ||
| describe('elicitation: #315 review round', () => { | ||
| it('counts an app whose source_type is absent rather than reassuring falsely', async () => { | ||
| const h = await harness(decline); | ||
| const client = h.server['client']; | ||
| jest.spyOn(client, 'listApplications').mockResolvedValue([ | ||
| // source_id matches but the instance did not serialise source_type — | ||
| // the field is absent from the vendored spec and only live-verified on | ||
| // 4.1.2, so this response shape is plausible on other versions. | ||
| { uuid: 'a', name: 'api', source_id: 7 }, | ||
| ]); | ||
| jest.spyOn(client, 'listGitHubApps').mockResolvedValue([{ id: 7, name: 'inst' }]); | ||
| jest.spyOn(client, 'deleteGitHubApp').mockResolvedValue({}); | ||
| await h.call('github_apps', { action: 'delete', id: 7 }); | ||
| // Excluding on a missing type would print "No applications are currently | ||
| // sourced from it" — the most reassuring sentence this dialog can emit — | ||
| // for a delete that breaks the app. Over-counting asks harder instead. | ||
| expect(h.prompts[0]).toContain('1 application (api)'); | ||
| await h.close(); | ||
| }); | ||
| it('still asks, carrying the label, when the github_apps pre-flight fails', async () => { | ||
| const h = await harness(decline); | ||
| const client = h.server['client']; | ||
| // The only summarize in the codebase making two calls in Promise.all — | ||
| // the most ways to reject, on exactly the flaky-Coolify days when the | ||
| // guard matters most. | ||
| jest.spyOn(client, 'listApplications').mockRejectedValue(new Error('coolify unreachable')); | ||
| jest.spyOn(client, 'listGitHubApps').mockResolvedValue([]); | ||
| const del = jest.spyOn(client, 'deleteGitHubApp').mockResolvedValue({}); | ||
| await h.call('github_apps', { action: 'delete', id: 7 }); | ||
| expect(h.prompts[0]).toContain('Delete a GitHub app installation'); | ||
| expect(h.prompts[0]).toContain('Could not load the details first'); | ||
| expect(del).not.toHaveBeenCalled(); | ||
| await h.close(); | ||
| }); | ||
| it('guards a key-material replacement exactly like a delete', async () => { | ||
| const h = await harness(decline); | ||
| const client = h.server['client']; | ||
| jest | ||
| .spyOn(client, 'getPrivateKey') | ||
| .mockResolvedValue({ uuid: 'key-1', name: 'deploy-key' }); | ||
| const update = jest.spyOn(client, 'updatePrivateKey').mockResolvedValue({}); | ||
| await h.call('private_keys', { | ||
| action: 'update', | ||
| uuid: 'key-1', | ||
| private_key: '-----BEGIN OPENSSH PRIVATE KEY-----\nnew material', | ||
| }); | ||
| // Overwriting key material IS deleting the old key: Coolify never returns | ||
| // key material, so the previous value is exactly as gone either way. | ||
| expect(h.prompts[0]).toContain('Replace the key material'); | ||
| expect(h.prompts[0]).toContain('not recoverable'); | ||
| expect(update).not.toHaveBeenCalled(); | ||
| await h.close(); | ||
| }); | ||
| it('lets a rename through without a prompt', async () => { | ||
| const h = await harness(accept); | ||
| const update = jest | ||
| .spyOn(h.server['client'], 'updatePrivateKey') | ||
| .mockResolvedValue({}); | ||
| await h.call('private_keys', { action: 'update', uuid: 'key-1', name: 'renamed' }); | ||
| // A rename destroys nothing; prompting on it would be pure fatigue. | ||
| expect(h.prompts).toEqual([]); | ||
| expect(update).toHaveBeenCalledWith('key-1', { name: 'renamed', description: undefined }); | ||
| await h.close(); | ||
| }); | ||
| it('names the consequence even when no applications are sourced', async () => { | ||
| const h = await harness(accept); | ||
| const client = h.server['client']; | ||
| jest.spyOn(client, 'listApplications').mockResolvedValue([]); | ||
| jest.spyOn(client, 'listGitHubApps').mockResolvedValue([{ id: 7, name: 'idle' }]); | ||
| jest.spyOn(client, 'deleteGitHubApp').mockResolvedValue({}); | ||
| await h.call('github_apps', { action: 'delete', id: 7 }); | ||
| // Every other prompt names what the reader loses; this one must too — | ||
| // re-creating and re-installing the app on GitHub is real work. | ||
| expect(h.prompts[0]).toContain('not undoable from Coolify'); | ||
| await h.close(); | ||
| }); | ||
| }); |
@@ -59,3 +59,3 @@ /** | ||
| * `elicitation` without implementing the handler, or a proxy that drops the | ||
| * request, makes nine tools permanently unusable with no way out but | ||
| * request, makes every guarded tool permanently unusable with no way out but | ||
| * downgrading the package. The resulting error ("could not confirm with the | ||
@@ -62,0 +62,0 @@ * user") does not point at the client, which makes it hard to diagnose from the |
@@ -58,3 +58,3 @@ /** | ||
| * `elicitation` without implementing the handler, or a proxy that drops the | ||
| * request, makes nine tools permanently unusable with no way out but | ||
| * request, makes every guarded tool permanently unusable with no way out but | ||
| * downgrading the package. The resulting error ("could not confirm with the | ||
@@ -61,0 +61,0 @@ * user") does not point at the client, which makes it hard to diagnose from the |
+1
-1
| { | ||
| "name": "@masonator/coolify-mcp", | ||
| "scope": "@masonator", | ||
| "version": "2.17.0", | ||
| "version": "2.18.0", | ||
| "mcpName": "io.github.StuMason/coolify", | ||
@@ -6,0 +6,0 @@ "description": "MCP server for Coolify — 44 optimized tools for infrastructure management, diagnostics, and documentation search", |
+20
-6
@@ -12,4 +12,6 @@ # Coolify MCP Server | ||
| 📖 **Full docs: [coolify-mcp.stumason.dev](https://coolify-mcp.stumason.dev)** — install guide, tools reference, architecture, security model, v3 roadmap. | ||
| 📖 **[coolify-mcp.stumason.dev](https://coolify-mcp.stumason.dev)** — what it does, how to install it, and why it is safe to point at production. | ||
| This README is the full reference: every tool, every gotcha, every parameter. | ||
| ## Install | ||
@@ -47,3 +49,3 @@ | ||
| Behind Cloudflare Access or an auth proxy? Add `--header "Key: Value"` args (repeatable). Cursor, multiple Coolify instances, and proxy setups are covered in the [install guide](https://coolify-mcp.stumason.dev/guide/installation). | ||
| Behind Cloudflare Access or an auth proxy? Add `--header "Key: Value"` args (repeatable). The same config works in Cursor, Claude Code and any other MCP client, and can be repeated for multiple Coolify instances. | ||
@@ -77,3 +79,3 @@ ## Tools | ||
| Full reference with parameters and examples: [tools docs](https://coolify-mcp.stumason.dev/tools/). | ||
| Every tool takes an `action` parameter — run one with no arguments and it lists what it accepts. | ||
@@ -98,3 +100,3 @@ ## Design | ||
| Confirmation is asked for on `stop_all_apps`, `redeploy_project`, `restart_project_apps`, `system disable_api`, application / database / service / project / environment deletes, and `bulk_env_update` across more than three apps. Deleting a resource spells out whether its **persistent volumes** go with it — `delete_volumes` defaults to `true` upstream, so leaving the flag unset is the destructive choice, not the cautious one. | ||
| Confirmation is asked for on `stop_all_apps`, `redeploy_project`, `restart_project_apps`, `system disable_api`, application / database / service / project / environment deletes, the credential deletes (`private_keys`, `cloud_tokens`, `github_apps` — none recoverable from Coolify once gone), and `bulk_env_update` across more than three apps. Routine deletes (storages, scheduled tasks, individual env vars, backup schedules) deliberately stay unprompted — a dialog on every delete is how dialogs stop being read. Deleting a resource spells out whether its **persistent volumes** go with it — `delete_volumes` defaults to `true` upstream, so leaving the flag unset is the destructive choice, not the cautious one. | ||
@@ -119,3 +121,3 @@ Prompts are skipped where there is nothing to confirm: an emergency stop on an idle estate, or a redeploy of an empty project, just runs. | ||
| Details: [security model](https://coolify-mcp.stumason.dev/concepts/security). | ||
| Destructive operations also ask a human first — see [Ask before it hurts](#ask-before-it-hurts) above. | ||
@@ -154,4 +156,16 @@ ## Coolify version compatibility | ||
| Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) and the [contributor docs](https://coolify-mcp.stumason.dev/contributing/adding-tools). | ||
| Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) and the architecture notes in [CLAUDE.md](CLAUDE.md). | ||
| ## Work with me | ||
| I'm Stu Mason. I build MCP servers, AI integrations and agentic systems for agencies, SMEs and enterprise — this repo is what that work looks like in the open. | ||
| - **An MCP server for your product** — give Claude, Cursor and every other AI client a proper way into your API, like this one. | ||
| - **Answers from your own stuff** — AI that answers from your documents and data, with the receipts, instead of guessing. Can stay on your own servers. | ||
| - **Work that runs itself** — jobs on a schedule that sort, check and report, with a person signing off before anything goes out. | ||
| White-label under your own name if you're an agency. And if a job doesn't need AI, I'll say so before anyone's paid for anything. | ||
| 📮 [hey@stumason.dev](mailto:hey@stumason.dev) · [stumason.dev](https://stumason.dev) · [coolify-mcp.stumason.dev](https://coolify-mcp.stumason.dev/#hire) | ||
| ## Links | ||
@@ -158,0 +172,0 @@ |
Sorry, the diff of this file is too big to display
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
772283
2.13%15433
1.51%173
8.81%