Sign In

@masonator/coolify-mcp

Package Overview
Dependencies
Maintainers
1
Versions
80
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@masonator/coolify-mcp - npm Package Compare versions

Comparing version
2.16.0
to
2.17.0
+1
dist/__tests__/elicitation.test.d.ts
export {};
/**
* Elicitation tests (#261).
*
* These drive a real `Client` over an in-memory transport rather than stubbing
* `getClientCapabilities()`, because the thing most likely to be wrong here is
* the capability negotiation itself: a server that elicits against a client
* that never advertised support fails at runtime in exactly the environment
* (Claude Desktop, claude.ai) where nobody is watching a test suite. Declaring
* the capability on the client and letting the SDK negotiate is the only way
* the "absent" branch is genuinely absent.
*
* Every Coolify call is spied, so no test here talks to an API.
*/
import { describe, it, expect, jest } from '@jest/globals';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { CoolifyMcpServer } from '../lib/mcp-server.js';
import { confirmDestructive, describeBlastRadius, sanitizeForPrompt, ELICIT_TIMEOUT_MS, } from '../lib/elicit.js';
const accept = () => ({ action: 'accept', content: {} });
const decline = () => ({ action: 'decline' });
const cancel = () => ({ action: 'cancel' });
/**
* Connect a client to a fresh server.
*
* Passing no `answer` builds a client that never declares the elicitation
* capability — the fallback case, and the one that must keep working.
*/
async function harness(answer) {
const server = new CoolifyMcpServer({
baseUrl: 'http://localhost:3000',
accessToken: 'test-token',
});
const client = new Client({ name: 'test', version: '0' }, answer ? { capabilities: { elicitation: {} } } : {});
const prompts = [];
if (answer) {
client.setRequestHandler(ElicitRequestSchema, async (request) => {
const { message } = request.params;
prompts.push(message);
return answer(message);
});
}
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
return {
server,
prompts,
call: async (name, args) => {
const result = (await client.callTool({ name, arguments: args }));
return result.content.map((c) => c.text).join('\n');
},
close: async () => {
await client.close();
},
};
}
/**
* Two running apps on two servers, plus one that is genuinely down.
*
* Shaped like the real `GET /applications` response, which nests
* `destination.server_id` and does **not** populate the flat `server_uuid`.
* An earlier version of this fixture set `server_uuid`, which made the
* server-count assertion pass against a response shape Coolify never sends —
* verified against a live 4.1.2 instance.
*/
const APPS = [
{
uuid: 'app-1',
name: 'api',
status: 'running:healthy',
destination: { server_id: 1 },
project_uuid: 'p1',
},
{
uuid: 'app-2',
name: 'worker',
status: 'running:healthy',
destination: { server_id: 2 },
project_uuid: 'p1',
},
{
uuid: 'app-3',
name: 'old',
status: 'exited',
destination: { server_id: 1 },
project_uuid: 'p2',
},
];
function stubEstate(server) {
const client = server['client'];
jest.spyOn(client, 'listApplications').mockResolvedValue(APPS);
const stopAllApps = jest
.spyOn(client, 'stopAllApps')
.mockResolvedValue({ summary: { total: 2, succeeded: 2, failed: 0 } });
return { stopAllApps };
}
describe('elicitation: capability gating', () => {
it('runs without asking when the client never advertised elicitation', async () => {
const h = await harness();
const { stopAllApps } = stubEstate(h.server);
const text = await h.call('stop_all_apps', { confirm: true });
// The whole progressive-enhancement promise: an old client is not a
// blocked client.
expect(stopAllApps).toHaveBeenCalled();
expect(h.prompts).toEqual([]);
expect(text).toContain('succeeded');
await h.close();
});
it('asks, then runs, when the client accepts', async () => {
const h = await harness(accept);
const { stopAllApps } = stubEstate(h.server);
await h.call('stop_all_apps', { confirm: true });
expect(h.prompts).toHaveLength(1);
expect(stopAllApps).toHaveBeenCalled();
await h.close();
});
it('COOLIFY_MCP_ELICITATION=off disables asking even on a capable client', async () => {
const previous = process.env.COOLIFY_MCP_ELICITATION;
process.env.COOLIFY_MCP_ELICITATION = 'off';
try {
// `accept` means this client both advertises the capability and answers,
// so a prompt would be shown were the escape hatch not honoured.
const h = await harness(accept);
const { stopAllApps } = stubEstate(h.server);
await h.call('stop_all_apps', { confirm: true });
// The escape hatch exists for a client that advertises `elicitation`
// without implementing it, where every guarded tool would otherwise be
// permanently unusable with no way out but downgrading the package.
expect(h.prompts).toEqual([]);
expect(stopAllApps).toHaveBeenCalled();
await h.close();
}
finally {
if (previous === undefined)
delete process.env.COOLIFY_MCP_ELICITATION;
else
process.env.COOLIFY_MCP_ELICITATION = previous;
}
});
it('only "off" disables it, so a stray value cannot silently remove the guard', async () => {
const previous = process.env.COOLIFY_MCP_ELICITATION;
process.env.COOLIFY_MCP_ELICITATION = 'false';
try {
const h = await harness(decline);
const { stopAllApps } = stubEstate(h.server);
await h.call('stop_all_apps', { confirm: true });
expect(h.prompts).toHaveLength(1);
expect(stopAllApps).not.toHaveBeenCalled();
await h.close();
}
finally {
if (previous === undefined)
delete process.env.COOLIFY_MCP_ELICITATION;
else
process.env.COOLIFY_MCP_ELICITATION = previous;
}
});
it('does not run when the client declines', async () => {
const h = await harness(decline);
const { stopAllApps } = stubEstate(h.server);
const text = await h.call('stop_all_apps', { confirm: true });
expect(stopAllApps).not.toHaveBeenCalled();
expect(text).toContain('the user declined');
expect(text).toContain('Nothing was changed');
await h.close();
});
it('does not run when the client cancels, and says so distinctly', async () => {
const h = await harness(cancel);
const { stopAllApps } = stubEstate(h.server);
const text = await h.call('stop_all_apps', { confirm: true });
expect(stopAllApps).not.toHaveBeenCalled();
// Distinct from a decline: dismissing a dialog is not the same answer as
// saying no, and a model reading the transcript should be able to tell.
expect(text).toContain('cancelled the prompt');
await h.close();
});
it('fails closed when the elicitation request itself errors', async () => {
const h = await harness(() => {
throw new Error('client blew up');
});
const { stopAllApps } = stubEstate(h.server);
const text = await h.call('stop_all_apps', { confirm: true });
// A client that advertised the capability and then failed to answer has
// not given consent. Proceeding here would make the guard decorative.
expect(stopAllApps).not.toHaveBeenCalled();
expect(text).toContain('could not confirm with the user');
await h.close();
});
it('still asks when the blast-radius lookup fails', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest.spyOn(client, 'listApplications').mockRejectedValue(new Error('coolify unreachable'));
const stopAllApps = jest.spyOn(client, 'stopAllApps').mockResolvedValue({});
await h.call('stop_all_apps', { confirm: true });
// Losing the details is a reason to ask a vaguer question, not to skip the
// question — and the human should be told the lookup failed.
expect(h.prompts).toHaveLength(1);
expect(h.prompts[0]).toContain('Could not load the details first');
expect(h.prompts[0]).toContain('coolify unreachable');
// Vaguer, but not contentless. "Proceed with this destructive operation?"
// reads identically for deleting one service and for stopping the estate,
// and this degraded path fires exactly when Coolify is flaky — when people
// are least inclined to read carefully.
expect(h.prompts[0]).toContain('stop every running application');
expect(stopAllApps).toHaveBeenCalled();
await h.close();
});
it('waits longer than the SDK default, because a human is reading it', () => {
expect(ELICIT_TIMEOUT_MS).toBeGreaterThan(60_000);
});
// Both catch blocks stringify whatever was thrown. Nothing guarantees a
// rejection is an Error — an SDK or a transport can reject with a plain
// object — and a guard that throws while reporting a failure is a guard that
// fails open.
it('survives a non-Error rejection from the blast-radius lookup', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest.spyOn(client, 'listApplications').mockRejectedValue('just a string');
const stopAllApps = jest.spyOn(client, 'stopAllApps').mockResolvedValue({});
await h.call('stop_all_apps', { confirm: true });
expect(h.prompts[0]).toContain('just a string');
expect(stopAllApps).toHaveBeenCalled();
await h.close();
});
it('aborts when the client handler throws a bare string', async () => {
const h = await harness(() => {
throw 'client threw a string';
});
const { stopAllApps } = stubEstate(h.server);
const text = await h.call('stop_all_apps', { confirm: true });
expect(stopAllApps).not.toHaveBeenCalled();
expect(text).toContain('could not confirm with the user');
await h.close();
});
// Over a real transport the SDK always wraps a rejection into an `McpError`,
// so the non-Error branch in the `elicitInput` catch cannot be reached from
// the test above no matter what the client handler throws. Reaching it needs
// `elicitInput` itself to reject with a non-Error, which only a stub can do.
it('stringifies a non-Error rejection from elicitInput itself', async () => {
const stub = {
getClientCapabilities: () => ({ elicitation: {} }),
elicitInput: () => Promise.reject('raw string failure'),
};
const outcome = await confirmDestructive(stub, 'do the thing', () => 'proceed?');
expect(outcome.approved).toBe(false);
expect(outcome.approved === false && outcome.message).toContain('raw string failure');
});
});
describe('elicitation: cancellation and no-ops', () => {
it('aborts the prompt when the caller gives up, instead of running later', async () => {
// The scenario this closes: the elicitation runs *inside* the `tools/call`
// request, and the SDK's client-side default request timeout is 60s —
// shorter than a human takes to decide. Without the signal threaded
// through, the client gives up, the model is told the call failed, and the
// prompt stays live; a later accept then stops every app with nobody
// listening. Here the client times out at 250ms while the human never
// answers at all.
const server = new CoolifyMcpServer({
baseUrl: 'http://localhost:3000',
accessToken: 'test-token',
});
const client = new Client({ name: 'test', version: '0' }, { capabilities: { elicitation: {} } });
let asked = false;
client.setRequestHandler(ElicitRequestSchema, async () => {
asked = true;
// The human accepts, but only *after* the caller has given up — the
// t=90s accept following a t=60s client timeout, scaled down. A test
// where the human never answers at all would pass with or without the
// signal threaded, since nothing would have run either way.
await new Promise((resolve) => setTimeout(resolve, 600));
return { action: 'accept', content: {} };
});
const [ct, st] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(st), client.connect(ct)]);
const { stopAllApps } = stubEstate(server);
await expect(client.callTool({ name: 'stop_all_apps', arguments: { confirm: true } }, undefined, {
timeout: 250,
})).rejects.toThrow();
// Wait past the point where the late accept lands. Without the signal
// threaded through, `stopAllApps` fires here with nobody listening.
await new Promise((resolve) => setTimeout(resolve, 800));
expect(asked).toBe(true);
expect(stopAllApps).not.toHaveBeenCalled();
await client.close();
}, 10_000);
it('passes the abort signal to elicitInput rather than only a timeout', async () => {
const controller = new AbortController();
let seen;
const stub = {
getClientCapabilities: () => ({ elicitation: {} }),
elicitInput: (_params, options) => {
seen = options;
return Promise.resolve({ action: 'accept' });
},
};
await confirmDestructive(stub, 'do the thing', () => 'proceed?', controller.signal);
expect(seen?.signal).toBe(controller.signal);
expect(seen?.timeout).toBe(ELICIT_TIMEOUT_MS);
});
it('does not ask before an emergency stop on an idle estate', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest
.spyOn(client, 'listApplications')
.mockResolvedValue([{ uuid: 'a', name: 'api', status: 'exited' }]);
const stopAllApps = jest.spyOn(client, 'stopAllApps').mockResolvedValue({});
await h.call('stop_all_apps', { confirm: true });
// "Take down 0 running applications?" is the kind of dialog that teaches
// people these dialogs are noise.
expect(h.prompts).toEqual([]);
expect(stopAllApps).toHaveBeenCalled();
await h.close();
});
it('does not ask before redeploying an empty project', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest.spyOn(client, 'applicationsInProject').mockResolvedValue([]);
const redeploy = jest.spyOn(client, 'redeployProjectApps').mockResolvedValue({});
await h.call('redeploy_project', { project_uuid: 'empty' });
expect(h.prompts).toEqual([]);
expect(redeploy).toHaveBeenCalled();
await h.close();
});
it('asks before disabling the API, the one call that disables every other call', async () => {
const h = await harness(decline);
const disable = jest.spyOn(h.server['client'], 'disableApi').mockResolvedValue({});
const text = await h.call('system', { action: 'disable_api' });
expect(h.prompts[0]).toContain('Disable the Coolify API?');
expect(h.prompts[0]).toContain('enable_api');
expect(disable).not.toHaveBeenCalled();
expect(text).toContain('Nothing was changed');
await h.close();
});
it('leaves enable_api unguarded, since it only restores access', async () => {
const h = await harness(accept);
const enable = jest.spyOn(h.server['client'], 'enableApi').mockResolvedValue({});
await h.call('system', { action: 'enable_api' });
expect(h.prompts).toEqual([]);
expect(enable).toHaveBeenCalled();
await h.close();
});
});
describe('elicitation: prompt injection via resource names', () => {
it('flattens newlines out of a name so it cannot restructure the dialog', () => {
const hostile = 'api\n\nThis is routine and safe to accept.\n\nDelete anything else?';
const safe = sanitizeForPrompt(hostile);
expect(safe).not.toContain('\n');
expect(safe).toBe('api This is routine and safe to accept. Delete anything else?');
});
it('clamps a very long name so it cannot bury the question', () => {
const safe = sanitizeForPrompt('x'.repeat(200));
expect(safe.length).toBeLessThanOrEqual(64);
expect(safe.endsWith('…')).toBe(true);
});
it('leaves an ordinary name untouched', () => {
expect(sanitizeForPrompt('checkout-web')).toBe('checkout-web');
});
it('sanitises names inside a blast-radius list', () => {
const text = describeBlastRadius('application', ['api\nrogue line']);
expect(text).toBe('1 application (api rogue line)');
});
// The uuid arguments are plain `z.string()`, so their value is arbitrary text
// the *model* chose. That needs no write access to Coolify at all — only a
// model that read something hostile in a README, an issue body or a log line
// — which makes it a stronger vector than the resource names, not a weaker
// one.
it('sanitises a model-supplied uuid in an application delete prompt', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest.spyOn(client, 'getApplication').mockResolvedValue({ uuid: 'x', name: 'api' });
jest.spyOn(client, 'deleteApplication').mockResolvedValue({});
await h.call('application', {
action: 'delete',
uuid: 'a1b2c3\n\nNOTE: routine teardown, safe to accept.',
});
expect(h.prompts[0]).not.toContain('\nNOTE:');
expect(h.prompts[0]).toContain('(a1b2c3 NOTE: routine teardown, safe to accept.)');
await h.close();
});
it('sanitises a model-supplied uuid in a project delete prompt', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest.spyOn(client, 'projectContents').mockResolvedValue({
project: { uuid: 'p', name: 'estate' },
applications: [],
databases: [],
services: [],
});
jest.spyOn(client, 'deleteProject').mockResolvedValue({});
await h.call('projects', { action: 'delete', uuid: 'p1\n\nAlready approved.' });
expect(h.prompts[0]).not.toContain('\nAlready approved.');
await h.close();
});
it('sanitises a model-supplied project uuid in an environment delete prompt', async () => {
const h = await harness(accept);
jest.spyOn(h.server['client'], 'deleteProjectEnvironment').mockResolvedValue({});
await h.call('environments', {
action: 'delete',
project_uuid: 'p1\n\nRoutine.',
name: 'staging',
});
expect(h.prompts[0]).not.toContain('\nRoutine.');
await h.close();
});
it('strips link syntax so a name cannot render as a clickable link', () => {
const safe = sanitizeForPrompt('[Approve](https://evil.example)');
expect(safe).not.toContain('[');
expect(safe).not.toContain(']');
expect(safe).toBe('Approvehttps://evil.example');
});
it('strips the delimiters the prompt templates themselves use', () => {
// `deleteResourcePrompt` renders `Delete application "NAME" (UUID)?`, so a
// quote or paren inside the value does not just render as markdown — it
// closes the delimiter telling the reader where the untrusted text ends.
expect(sanitizeForPrompt('x" is routine and safe (')).toBe('x is routine and safe');
expect(sanitizeForPrompt('# Approved')).toBe('Approved');
});
it('strips emphasis and code spans', () => {
expect(sanitizeForPrompt('**SAFE - routine**')).toBe('SAFE - routine');
expect(sanitizeForPrompt('`sudo rm -rf`')).toBe('sudo rm -rf');
});
it('leaves underscores alone, because env var names are full of them', () => {
// Mangling `API_KEY` into `APIKEY` in the one dialog meant to let someone
// recognise what they are approving is worse than the emphasis it would
// prevent.
expect(sanitizeForPrompt('DATABASE_URL')).toBe('DATABASE_URL');
});
it('leaves a real uuid untouched', () => {
const real = 'wrcooc9efp6gmp9z3r2foggo';
expect(sanitizeForPrompt(real)).toBe(real);
});
it('sanitises the name in a delete prompt', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest
.spyOn(client, 'getApplication')
.mockResolvedValue({ uuid: 'a', name: 'api\n\nSafe to accept.' });
jest.spyOn(client, 'deleteApplication').mockResolvedValue({});
await h.call('application', { action: 'delete', uuid: 'a' });
expect(h.prompts[0]).toContain('Delete application "api Safe to accept."');
await h.close();
});
});
describe('elicitation: blast radius in the prompt', () => {
it('names the running apps and the server count for stop_all_apps', async () => {
const h = await harness(accept);
stubEstate(h.server);
await h.call('stop_all_apps', { confirm: true });
const prompt = h.prompts[0];
expect(prompt).toContain('2 running applications');
expect(prompt).toContain('api');
expect(prompt).toContain('worker');
expect(prompt).toContain('across 2 servers');
// The stopped app is not part of the blast radius and must not pad the count.
expect(prompt).not.toContain('old');
await h.close();
});
it('omits the server count when everything is on one server', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest.spyOn(client, 'listApplications').mockResolvedValue([
{ uuid: 'a', name: 'api', status: 'running:healthy', destination: { server_id: 1 } },
{ uuid: 'b', name: 'web', status: 'running:healthy', destination: { server_id: 1 } },
]);
jest.spyOn(client, 'stopAllApps').mockResolvedValue({});
await h.call('stop_all_apps', { confirm: true });
expect(h.prompts[0]).toContain('2 running applications');
expect(h.prompts[0]).not.toContain('servers');
await h.close();
});
// Coolify's built-in localhost server is `server_id: 0`. A `.filter(Boolean)`
// here would discard it, so an estate split across the localhost server and
// one remote would report "1 server" instead of 2 — undercounting the blast
// radius, which is the one direction a confirmation must never be wrong in.
it('counts server_id 0, which is falsy but real', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest.spyOn(client, 'listApplications').mockResolvedValue([
{ uuid: 'a', name: 'api', status: 'running:healthy', destination: { server_id: 0 } },
{ uuid: 'b', name: 'web', status: 'running:healthy', destination: { server_id: 1 } },
]);
jest.spyOn(client, 'stopAllApps').mockResolvedValue({});
await h.call('stop_all_apps', { confirm: true });
expect(h.prompts[0]).toContain('across 2 servers');
await h.close();
});
it('does not double-count a server described both ways', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest.spyOn(client, 'listApplications').mockResolvedValue([
{ uuid: 'a', name: 'api', status: 'running:healthy', destination: { server_id: 1 } },
{ uuid: 'b', name: 'web', status: 'running:healthy', destination: { server_id: 1 } },
{ uuid: 'c', name: 'job', status: 'running:healthy', server_uuid: 'srv-2' },
]);
jest.spyOn(client, 'stopAllApps').mockResolvedValue({});
await h.call('stop_all_apps', { confirm: true });
// Two distinct servers, described in two different key spaces. Mixing raw
// numbers and strings in one Set is how one server becomes two.
expect(h.prompts[0]).toContain('across 2 servers');
await h.close();
});
// The flat field is still read as a fallback in case a future Coolify
// populates it on the list endpoint.
it('falls back to a flat server_uuid when no destination is present', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest.spyOn(client, 'listApplications').mockResolvedValue([
{ uuid: 'a', name: 'api', status: 'running:healthy', server_uuid: 's1' },
{ uuid: 'b', name: 'web', status: 'running:healthy', server_uuid: 's2' },
]);
jest.spyOn(client, 'stopAllApps').mockResolvedValue({});
await h.call('stop_all_apps', { confirm: true });
expect(h.prompts[0]).toContain('across 2 servers');
await h.close();
});
it('scopes redeploy_project to the project, and redeploys exactly what was shown', async () => {
const h = await harness(accept);
const client = h.server['client'];
const inProject = [APPS[0], APPS[1]];
jest.spyOn(client, 'applicationsInProject').mockResolvedValue(inProject);
const redeploy = jest.spyOn(client, 'redeployProjectApps').mockResolvedValue({});
await h.call('redeploy_project', { project_uuid: 'p1' });
expect(h.prompts[0]).toContain('2 applications');
expect(h.prompts[0]).toContain('api');
expect(h.prompts[0]).not.toContain('old');
// The approved set is handed to the operation rather than re-resolved, so
// an app that starts between the prompt and the accept is not swept in.
expect(redeploy).toHaveBeenCalledWith('p1', true, inProject);
await h.close();
});
it('restarts exactly the set the human was shown', async () => {
const h = await harness(accept);
const client = h.server['client'];
const inProject = [APPS[0]];
jest.spyOn(client, 'applicationsInProject').mockResolvedValue(inProject);
const restart = jest.spyOn(client, 'restartProjectApps').mockResolvedValue({});
await h.call('restart_project_apps', { project_uuid: 'p1' });
expect(h.prompts[0]).toContain('1 application (api)');
expect(restart).toHaveBeenCalledWith('p1', inProject);
await h.close();
});
it('stops exactly the running set the human was shown', async () => {
const h = await harness(accept);
const { stopAllApps } = stubEstate(h.server);
await h.call('stop_all_apps', { confirm: true });
const passed = stopAllApps.mock.calls[0][0] ?? [];
expect(passed.map((app) => app.uuid)).toEqual(['app-1', 'app-2']);
await h.close();
});
it('truncates long lists rather than printing sixty names', () => {
const many = Array.from({ length: 12 }, (_, i) => `app-${i}`);
const text = describeBlastRadius('application', many);
expect(text).toContain('12 applications');
expect(text).toContain('and 4 more');
expect(text).not.toContain('app-8');
});
it('pluralises a single resource correctly', () => {
expect(describeBlastRadius('application', ['solo'])).toBe('1 application (solo)');
});
it('handles an empty set without inventing a name list', () => {
expect(describeBlastRadius('application', [])).toBe('0 applications');
});
});
describe('elicitation: bulk_env_update threshold', () => {
const args = (count) => ({
app_uuids: Array.from({ length: count }, (_, i) => `app-${i}`),
key: 'API_KEY',
value: 'super-secret-value',
});
/** Names for the uuids `args` generates, so the prompt can resolve them. */
const mockAppLookup = (client, count) => jest.spyOn(client, 'listApplications').mockResolvedValue(Array.from({ length: count }, (_, i) => ({
uuid: `app-${i}`,
name: `service-${i}`,
})));
it('does not prompt for a small update', async () => {
const h = await harness(accept);
const bulk = jest
.spyOn(h.server['client'], 'bulkEnvUpdate')
.mockResolvedValue({ summary: { total: 3 } });
const list = mockAppLookup(h.server['client'], 3);
await h.call('bulk_env_update', args(3));
// Prompting on routine three-app edits is how people learn to dismiss
// prompts without reading them.
expect(h.prompts).toEqual([]);
expect(bulk).toHaveBeenCalled();
// The name lookup is inside `summarize`, so a sub-threshold call must not
// pay for it either.
expect(list).not.toHaveBeenCalled();
await h.close();
});
it('prompts once the update crosses the threshold, naming the applications', async () => {
const h = await harness(accept);
const bulk = jest.spyOn(h.server['client'], 'bulkEnvUpdate').mockResolvedValue({});
mockAppLookup(h.server['client'], 4);
await h.call('bulk_env_update', args(4));
expect(h.prompts).toHaveLength(1);
expect(h.prompts[0]).toContain('4 applications');
expect(h.prompts[0]).toContain('API_KEY');
// `app_uuids` is a set the model assembled, so a bare count would ask the
// human to approve something they cannot see.
expect(h.prompts[0]).toContain('service-0');
expect(h.prompts[0]).toContain('service-3');
expect(bulk).toHaveBeenCalled();
await h.close();
});
it('falls back to the uuid when an application cannot be named', async () => {
const h = await harness(accept);
jest.spyOn(h.server['client'], 'bulkEnvUpdate').mockResolvedValue({});
// Only two of the four resolve — a uuid the model invented, or an app
// deleted since it listed them.
jest
.spyOn(h.server['client'], 'listApplications')
.mockResolvedValue([{ uuid: 'app-0', name: 'service-0' }]);
await h.call('bulk_env_update', args(4));
expect(h.prompts[0]).toContain('service-0');
expect(h.prompts[0]).toContain('app-1');
await h.close();
});
it('still asks, naming the key, when the lookup fails', async () => {
const h = await harness(decline);
const bulk = jest.spyOn(h.server['client'], 'bulkEnvUpdate').mockResolvedValue({});
jest
.spyOn(h.server['client'], 'listApplications')
.mockRejectedValue(new Error('coolify unreachable'));
await h.call('bulk_env_update', args(4));
// Degrades to the label, which still carries the key and the count.
expect(h.prompts).toHaveLength(1);
expect(h.prompts[0]).toContain('API_KEY');
expect(h.prompts[0]).toContain('4 applications');
expect(bulk).not.toHaveBeenCalled();
await h.close();
});
it('never puts the env var value in the prompt', async () => {
const h = await harness(accept);
jest.spyOn(h.server['client'], 'bulkEnvUpdate').mockResolvedValue({});
mockAppLookup(h.server['client'], 4);
await h.call('bulk_env_update', args(4));
// The prompt surfaces in client UI and in logs. Naming the key is useful;
// echoing the value would leak whatever secret is being rotated.
expect(h.prompts[0]).not.toContain('super-secret-value');
await h.close();
});
it('declining a large update leaves every app untouched', async () => {
const h = await harness(decline);
const bulk = jest.spyOn(h.server['client'], 'bulkEnvUpdate').mockResolvedValue({});
mockAppLookup(h.server['client'], 10);
await h.call('bulk_env_update', args(10));
expect(bulk).not.toHaveBeenCalled();
await h.close();
});
});
describe('elicitation: delete prompts', () => {
it('warns that omitting delete_volumes still destroys the data', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest.spyOn(client, 'getApplication').mockResolvedValue({ uuid: 'app-1', name: 'api' });
jest.spyOn(client, 'deleteApplication').mockResolvedValue({});
await h.call('application', { action: 'delete', uuid: 'app-1' });
// The trap this exists to close: `delete_volumes` is documented
// `default: true`, so "I left the optional flag off" is the destructive
// path, not the cautious one.
expect(h.prompts[0]).toContain('DESTROYED');
expect(h.prompts[0]).toContain('defaults to true');
expect(h.prompts[0]).toContain('api');
await h.close();
});
it('says volumes are kept only when delete_volumes is explicitly false', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest.spyOn(client, 'getApplication').mockResolvedValue({ uuid: 'app-1', name: 'api' });
jest.spyOn(client, 'deleteApplication').mockResolvedValue({});
await h.call('application', { action: 'delete', uuid: 'app-1', delete_volumes: false });
expect(h.prompts[0]).toContain('Persistent volumes are kept');
expect(h.prompts[0]).not.toContain('DESTROYED');
await h.close();
});
it('declining a delete does not call the API', async () => {
const h = await harness(decline);
const client = h.server['client'];
jest.spyOn(client, 'getDatabase').mockResolvedValue({ uuid: 'db-1', name: 'prod-pg' });
const del = jest.spyOn(client, 'deleteDatabase').mockResolvedValue({});
const text = await h.call('database', {
action: 'delete',
uuid: 'db-1',
delete_volumes: true,
});
expect(del).not.toHaveBeenCalled();
expect(text).toContain('Nothing was changed');
await h.close();
});
it('prompts for service deletes too', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest.spyOn(client, 'getService').mockResolvedValue({ uuid: 'svc-1', name: 'umami' });
const del = jest.spyOn(client, 'deleteService').mockResolvedValue({});
await h.call('service', { action: 'delete', uuid: 'svc-1' });
expect(h.prompts[0]).toContain('Delete service "umami"');
expect(del).toHaveBeenCalled();
await h.close();
});
it('prompts for project and environment deletes', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest.spyOn(client, 'projectContents').mockResolvedValue({
project: { uuid: 'p1', name: 'estate' },
applications: [{ uuid: 'a1', name: 'billing-api' }],
databases: [{ uuid: 'd1', name: 'orders-pg' }],
services: [],
});
jest.spyOn(client, 'deleteProject').mockResolvedValue({});
jest.spyOn(client, 'deleteProjectEnvironment').mockResolvedValue({});
await h.call('projects', { action: 'delete', uuid: 'p1' });
await h.call('environments', { action: 'delete', project_uuid: 'p1', name: 'staging' });
expect(h.prompts[0]).toContain('Delete project "estate"');
// The label says "and everything in it"; the message the human reads has
// to carry the same weight, and the spec documents no "project has
// resources" refusal to fall back on.
expect(h.prompts[0]).toContain('billing-api');
// A project delete cascades over databases and services too, so counting
// only applications would understate a project full of Postgres.
expect(h.prompts[0]).toContain('orders-pg');
expect(h.prompts[0]).toContain('1 application');
expect(h.prompts[0]).toContain('1 database');
expect(h.prompts[1]).toContain('Delete environment "staging"');
// Coolify refuses a non-empty environment (documented 400), so a prompt
// implying otherwise overstates the danger and teaches people to click
// through.
expect(h.prompts[1]).toContain('only succeeds on an empty one');
await h.close();
});
it('claims emptiness only as broadly as it actually checked', async () => {
const h = await harness(accept);
const client = h.server['client'];
jest.spyOn(client, 'projectContents').mockResolvedValue({
project: { uuid: 'p1', name: 'scratch' },
applications: [],
databases: [],
services: [],
});
jest.spyOn(client, 'deleteProject').mockResolvedValue({});
await h.call('projects', { action: 'delete', uuid: 'p1' });
// Still asks — deleting a project is still a delete — but names the three
// resource kinds it checked rather than asserting a blanket emptiness it
// has no evidence for.
expect(h.prompts[0]).toContain('no applications, databases or services');
await h.close();
});
it('names databases and services in a project that has no applications', async () => {
const h = await harness(decline);
const client = h.server['client'];
jest.spyOn(client, 'projectContents').mockResolvedValue({
project: { uuid: 'p1', name: 'datastores' },
applications: [],
databases: [
{ uuid: 'd1', name: 'orders-pg' },
{ uuid: 'd2', name: 'cache-redis' },
],
services: [{ uuid: 's1', name: 'umami' }],
});
const del = jest.spyOn(client, 'deleteProject').mockResolvedValue({});
await h.call('projects', { action: 'delete', uuid: 'p1' });
// The exact case the application-only count got wrong: a project holding
// databases and nothing else would have read as "no applications in it".
expect(h.prompts[0]).toContain('2 databases');
expect(h.prompts[0]).toContain('1 service');
expect(h.prompts[0]).not.toContain('no applications, databases or services');
expect(del).not.toHaveBeenCalled();
await h.close();
});
it('warns that a database delete destroys its volumes', async () => {
const h = await harness(decline);
const client = h.server['client'];
jest.spyOn(client, 'getDatabase').mockResolvedValue({ uuid: 'db-1', name: 'orders' });
const del = jest.spyOn(client, 'deleteDatabase').mockResolvedValue({});
await h.call('database', { action: 'delete', uuid: 'db-1' });
// Same helper as the application delete, but this is the resource where
// the volume-destruction wording matters most.
expect(h.prompts[0]).toContain('Delete database "orders"');
expect(h.prompts[0]).toContain('DESTROYED');
expect(h.prompts[0]).toContain('defaults to true');
expect(del).not.toHaveBeenCalled();
await h.close();
});
it('leaves non-delete actions unprompted', async () => {
const h = await harness(accept);
jest
.spyOn(h.server['client'], 'listApplications')
.mockResolvedValue([{ uuid: 'app-1', name: 'api' }]);
await h.call('list_applications', {});
expect(h.prompts).toEqual([]);
await h.close();
});
});
/**
* Human-in-the-loop confirmation for destructive tools (#261).
*
* The problem this solves: `stop_all_apps` is gated on a `confirm: z.literal(true)`
* parameter, and the model fills that parameter in. That is the model confirming
* with itself before taking every application on the estate down. Elicitation
* moves the question to the human, rendered by the client, outside the model's
* control.
*
* **Strictly progressive enhancement.** Client support is uneven — Claude Code
* and VS Code Copilot have it, Claude Desktop and claude.ai do not yet — so this
* checks the client's advertised `elicitation` capability at runtime and, when
* it is absent, approves and lets the existing parameter guards stand. A client
* that cannot be asked is not a client that gets blocked.
*
* Failure is closed in the other direction: once a client says it supports
* elicitation, a decline, a cancel, a timeout or a transport error all abort the
* operation. The one case that does not abort is the blast-radius lookup
* failing, because a summary we could not compute is a reason to ask with less
* detail, not a reason to skip asking.
*
* V3 note: SDK v2 redesigns this as `inputRequired.elicit()` (#259). Everything
* version-specific is inside `confirmDestructive`; call sites see only
* {@link ConfirmOutcome}.
*/
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
/**
* How long to wait for a human.
*
* The SDK's default request timeout is 60s, which is a sensible ceiling for a
* machine answering and a bad one for a person reading "stop ALL 12
* applications?" and deciding. Five minutes; after that the request is
* abandoned and the operation aborts, which is the safe direction.
*
* This is a backstop, not the primary control — see the `signal` passed
* alongside it, which lets the caller's own cancellation win first.
*/
export declare const ELICIT_TIMEOUT_MS = 300000;
/**
* Whether this client can be asked at all.
*
* **Read this before wiring up the HTTP transport (#303).** This depends on a
* completed initialize handshake being retained for the connection. In a
* stateless HTTP mode, where per-connection capabilities are not kept, it
* returns `undefined`, every guard approves, and the entire confirmation layer
* disappears with no signal that it has.
*
* Failing open is the right default here, on stdio, where the alternative is
* blocking Claude Desktop users out of tools that work today. It is very
* probably the wrong default for a remote server reachable over the network,
* which is the transport where the parameter-based guards stop being credible
* at all — the reason #303 lists elicitation as a prerequisite. Decide that
* deliberately there rather than inheriting this choice by accident.
*
* `COOLIFY_MCP_ELICITATION=off` is an escape hatch, not a feature. Once a
* client advertises the capability every rejection from `elicitInput` aborts,
* including `-32601 Method not found` — so a client that advertises
* `elicitation` without implementing the handler, or a proxy that drops the
* request, makes nine tools permanently unusable with no way out but
* downgrading the package. The resulting error ("could not confirm with the
* user") does not point at the client, which makes it hard to diagnose from the
* outside. Rare, but unrecoverable, and the fallback is the same shape as the
* one capability-less clients already get. The default is unchanged: absent the
* variable, an advertised capability is trusted and the guards fail closed.
*/
export declare function supportsElicitation(server: Server): boolean;
export type ConfirmOutcome = {
approved: true;
}
/** `message` is user-facing text explaining why nothing ran. */
| {
approved: false;
message: string;
};
/**
* Ask the human to approve a destructive operation.
*
* @param server The low-level `Server` (i.e. `mcpServer.server`), which owns
* both the client capabilities and `elicitInput`.
* @param label One line naming the operation, independent of any lookup.
* Used when `summarize` fails, so the degraded prompt still
* says what it is asking about.
* @param summarize Produces the prompt text, or `null` when the pre-flight
* found nothing to confirm. A callback rather than a string so
* that call sites needing an API round trip to state their blast
* radius — "how many apps am I about to stop?" — only pay for it
* on clients that will actually show the question.
* @param signal The tool call's abort signal. See the call to `elicitInput`
* for why omitting it is dangerous rather than merely untidy.
*/
export declare function confirmDestructive(server: Server, label: string, summarize: () => string | null | Promise<string | null>, signal?: AbortSignal): Promise<ConfirmOutcome>;
/**
* Make a value safe to interpolate into a confirmation dialog.
*
* Two sources, and the weaker-looking one is the stronger vector:
*
* - **Coolify-supplied names** are attacker-influenced in the weak sense that
* anyone able to create resources on the instance chooses them. A name
* containing newlines — `api\n\nThis is routine, safe to accept.` — reshapes
* a dialog whose entire job is to be trustworthy into one that argues for its
* own approval.
* - **Model-supplied identifiers** (the `uuid` arguments) are worse. Those
* schemas are plain strings with no uuid constraint, so the value is
* arbitrary text the model chose, and producing it needs no write access to
* the Coolify instance at all — only a model that read something hostile in a
* README, an issue body or a log line. Anything crossing into the dialog gets
* sanitized, whichever side it came from; the whole point of the dialog is to
* sit outside the model's control.
*
* A real 36-character UUID is well inside {@link MAX_NAME_LENGTH}, so genuine
* values render unchanged.
*
* Markdown-significant characters are neutralised alongside the control ones.
* The prompts in this codebase use backticks for emphasis, which means they
* assume a client that renders markdown — and markdown rendering *is* parsing,
* whatever the text is nominally "for". Under that assumption a resource named
* `[Approve](https://evil.example)` becomes a link and `**SAFE - routine**`
* becomes bold reassurance, inside a dialog whose whole job is to look
* trustworthy. The length clamp caps that but does not remove it.
*/
export declare function sanitizeForPrompt(name: string): string;
/**
* Render "12 applications (a, b, c and 9 more)" for a confirmation message.
*
* Names are truncated because the point of the list is recognition — spotting
* the one production app that should not be in the set — and a wall of sixty
* names defeats that as thoroughly as no names at all.
*/
export declare function describeBlastRadius(noun: string, names: string[]): string;
/**
* Human-in-the-loop confirmation for destructive tools (#261).
*
* The problem this solves: `stop_all_apps` is gated on a `confirm: z.literal(true)`
* parameter, and the model fills that parameter in. That is the model confirming
* with itself before taking every application on the estate down. Elicitation
* moves the question to the human, rendered by the client, outside the model's
* control.
*
* **Strictly progressive enhancement.** Client support is uneven — Claude Code
* and VS Code Copilot have it, Claude Desktop and claude.ai do not yet — so this
* checks the client's advertised `elicitation` capability at runtime and, when
* it is absent, approves and lets the existing parameter guards stand. A client
* that cannot be asked is not a client that gets blocked.
*
* Failure is closed in the other direction: once a client says it supports
* elicitation, a decline, a cancel, a timeout or a transport error all abort the
* operation. The one case that does not abort is the blast-radius lookup
* failing, because a summary we could not compute is a reason to ask with less
* detail, not a reason to skip asking.
*
* V3 note: SDK v2 redesigns this as `inputRequired.elicit()` (#259). Everything
* version-specific is inside `confirmDestructive`; call sites see only
* {@link ConfirmOutcome}.
*/
/**
* How long to wait for a human.
*
* The SDK's default request timeout is 60s, which is a sensible ceiling for a
* machine answering and a bad one for a person reading "stop ALL 12
* applications?" and deciding. Five minutes; after that the request is
* abandoned and the operation aborts, which is the safe direction.
*
* This is a backstop, not the primary control — see the `signal` passed
* alongside it, which lets the caller's own cancellation win first.
*/
export const ELICIT_TIMEOUT_MS = 300_000;
/**
* Whether this client can be asked at all.
*
* **Read this before wiring up the HTTP transport (#303).** This depends on a
* completed initialize handshake being retained for the connection. In a
* stateless HTTP mode, where per-connection capabilities are not kept, it
* returns `undefined`, every guard approves, and the entire confirmation layer
* disappears with no signal that it has.
*
* Failing open is the right default here, on stdio, where the alternative is
* blocking Claude Desktop users out of tools that work today. It is very
* probably the wrong default for a remote server reachable over the network,
* which is the transport where the parameter-based guards stop being credible
* at all — the reason #303 lists elicitation as a prerequisite. Decide that
* deliberately there rather than inheriting this choice by accident.
*
* `COOLIFY_MCP_ELICITATION=off` is an escape hatch, not a feature. Once a
* client advertises the capability every rejection from `elicitInput` aborts,
* including `-32601 Method not found` — so a client that advertises
* `elicitation` without implementing the handler, or a proxy that drops the
* request, makes nine tools permanently unusable with no way out but
* downgrading the package. The resulting error ("could not confirm with the
* user") does not point at the client, which makes it hard to diagnose from the
* outside. Rare, but unrecoverable, and the fallback is the same shape as the
* one capability-less clients already get. The default is unchanged: absent the
* variable, an advertised capability is trusted and the guards fail closed.
*/
export function supportsElicitation(server) {
if (process.env.COOLIFY_MCP_ELICITATION === 'off')
return false;
return Boolean(server.getClientCapabilities()?.elicitation);
}
/**
* Text returned to the model when the human says no.
*
* Deliberately not prefixed `Error:` — a decline is a decision, not a fault —
* but explicit that nothing changed and that retrying is not the next step,
* because a model reading a bare "cancelled" will often just try again.
*/
function abortText(reason) {
return `Aborted: ${reason}. Nothing was changed. Do not retry without new instructions from the user.`;
}
/**
* Ask the human to approve a destructive operation.
*
* @param server The low-level `Server` (i.e. `mcpServer.server`), which owns
* both the client capabilities and `elicitInput`.
* @param label One line naming the operation, independent of any lookup.
* Used when `summarize` fails, so the degraded prompt still
* says what it is asking about.
* @param summarize Produces the prompt text, or `null` when the pre-flight
* found nothing to confirm. A callback rather than a string so
* that call sites needing an API round trip to state their blast
* radius — "how many apps am I about to stop?" — only pay for it
* on clients that will actually show the question.
* @param signal The tool call's abort signal. See the call to `elicitInput`
* for why omitting it is dangerous rather than merely untidy.
*/
export async function confirmDestructive(server, label, summarize, signal) {
if (!supportsElicitation(server)) {
return { approved: true };
}
let message;
try {
const summary = await summarize();
// `null` means the pre-flight found nothing to do — an emergency stop on an
// idle estate, a redeploy of an empty project. Asking a human to confirm a
// no-op is how they learn these dialogs are noise, which is the same
// argument behind BULK_ENV_CONFIRM_THRESHOLD.
if (summary === null)
return { approved: true };
message = summary;
}
catch (error) {
// The pre-flight lookup failed. Still ask — a human confirming a vaguer
// question is a better outcome than an unconfirmed destructive call, and
// the failure itself is worth putting in front of them.
// `label` is why this stays a real question. Without it the degraded
// prompt reads "Proceed with this destructive operation?" whether the
// operation deletes one service or stops every application on the estate —
// the weakest possible ask, fired on exactly the condition (a flaky or
// unreachable Coolify) where a human is most likely to be clicking through
// things quickly.
message =
`${label}\n\nProceed? ` +
`(Could not load the details first: ${error instanceof Error ? error.message : String(error)})`;
}
let result;
try {
result = await server.elicitInput({
message,
// No fields: the answer is the accept/decline action itself. This is the
// spec's confirmation-only shape, and asking for a redundant "type YES"
// field would only add a way for the client to fail validation.
requestedSchema: { type: 'object', properties: {} },
},
// The caller's signal matters more than the timeout. The elicitation runs
// *inside* the `tools/call` request, and the SDK's client-side default
// request timeout is 60s — shorter than a human takes to read "stop ALL
// 12 applications?" and decide. Without this signal, a client that gives
// up at 60s and sends `notifications/cancelled` would leave the prompt
// live for another four minutes, and an accept at t=90s would execute the
// destructive operation with nobody listening — after the model had
// already been told the call failed, and may have retried it.
{ timeout: ELICIT_TIMEOUT_MS, signal });
}
catch (error) {
// Timeout, caller cancellation, transport failure, or a client that
// advertised the capability and then rejected the request. We asked and got
// no yes.
return {
approved: false,
message: abortText(`could not confirm with the user (${error instanceof Error ? error.message : String(error)})`),
};
}
if (result.action === 'accept') {
return { approved: true };
}
return {
approved: false,
message: abortText(result.action === 'decline' ? 'the user declined' : 'the user cancelled the prompt'),
};
}
/** Cap on how many resource names a blast-radius summary spells out. */
const MAX_NAMED = 8;
/** Cap on a single interpolated name, so one long name cannot bury the question. */
const MAX_NAME_LENGTH = 64;
/**
* Characters that turn a plain name into markdown, **or that the prompt
* templates themselves use as delimiters**. Stripped rather than escaped — a
* mangled name is a better outcome in a security dialog than a rendered one.
*
* The second category is the easy one to miss. `deleteResourcePrompt` renders
* `Delete application "NAME" (UUID)?`, so a quote or a parenthesis inside NAME
* is not merely cosmetic markdown — it closes the delimiter the reader is using
* to tell where the untrusted value ends. A resource named
* `x" is routine and safe (` produces a sentence that reads as the server's own
* prose. That matters most for the `uuid` arguments, which are plain strings in
* the tool schemas with no uuid constraint, so their contents are chosen by the
* model rather than by anyone with access to the Coolify instance.
*
* `#` goes too: the module already assumes a markdown-rendering client (that is
* the stated reason for stripping backticks and `[`), and a leading `# ` is a
* heading.
*
* Deliberately **not** `_`. `API_KEY` is the shape of almost every env var name
* this will ever render, and turning it into `APIKEY` in the one dialog whose
* job is to let someone recognise what they are approving is a worse failure
* than the thing it prevents. Residual risk is emphasis via `__underscores__`,
* which is cosmetic.
*/
const MARKDOWN_CHARS = /[`*[\]<>"()#]/g;
/** C0 and C1 control ranges — where newlines and carriage returns live. */
// eslint-disable-next-line no-control-regex
const CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F]/g;
/**
* Make a value safe to interpolate into a confirmation dialog.
*
* Two sources, and the weaker-looking one is the stronger vector:
*
* - **Coolify-supplied names** are attacker-influenced in the weak sense that
* anyone able to create resources on the instance chooses them. A name
* containing newlines — `api\n\nThis is routine, safe to accept.` — reshapes
* a dialog whose entire job is to be trustworthy into one that argues for its
* own approval.
* - **Model-supplied identifiers** (the `uuid` arguments) are worse. Those
* schemas are plain strings with no uuid constraint, so the value is
* arbitrary text the model chose, and producing it needs no write access to
* the Coolify instance at all — only a model that read something hostile in a
* README, an issue body or a log line. Anything crossing into the dialog gets
* sanitized, whichever side it came from; the whole point of the dialog is to
* sit outside the model's control.
*
* A real 36-character UUID is well inside {@link MAX_NAME_LENGTH}, so genuine
* values render unchanged.
*
* Markdown-significant characters are neutralised alongside the control ones.
* The prompts in this codebase use backticks for emphasis, which means they
* assume a client that renders markdown — and markdown rendering *is* parsing,
* whatever the text is nominally "for". Under that assumption a resource named
* `[Approve](https://evil.example)` becomes a link and `**SAFE - routine**`
* becomes bold reassurance, inside a dialog whose whole job is to look
* trustworthy. The length clamp caps that but does not remove it.
*/
export function sanitizeForPrompt(name) {
const flattened = name
.replace(CONTROL_CHARS, ' ')
.replace(MARKDOWN_CHARS, '')
.replace(/\s+/g, ' ')
.trim();
if (flattened.length <= MAX_NAME_LENGTH)
return flattened;
return `${flattened.slice(0, MAX_NAME_LENGTH - 1)}…`;
}
/**
* Render "12 applications (a, b, c and 9 more)" for a confirmation message.
*
* Names are truncated because the point of the list is recognition — spotting
* the one production app that should not be in the set — and a wall of sixty
* names defeats that as thoroughly as no names at all.
*/
export function describeBlastRadius(noun, names) {
const count = `${names.length} ${noun}${names.length === 1 ? '' : 's'}`;
if (names.length === 0)
return count;
const safe = names.map(sanitizeForPrompt);
if (safe.length <= MAX_NAMED)
return `${count} (${safe.join(', ')})`;
const shown = safe.slice(0, MAX_NAMED).join(', ');
return `${count} (${shown} and ${safe.length - MAX_NAMED} more)`;
}
+39
-17

@@ -18,16 +18,7 @@ /**

const shouldRun = COOLIFY_URL && COOLIFY_TOKEN;
// Test data - UUIDs from actual infrastructure
// These should be updated to match your test environment
const TEST_DATA = {
// Server: coolify-apps (running, reachable)
SERVER_UUID: 'ggkk8w4c08gw48oowsg4g0oc',
// Application: test-system (running)
APP_UUID_HEALTHY: 'xs0sgs4gog044s4k4c88kgsc',
// Application: Bumnail Benerator (exited:unhealthy)
APP_UUID_UNHEALTHY: 't444wg40s4kkwcc04s084wgw',
};
const describeFn = shouldRun ? describe : describe.skip;
describeFn('Diagnostic Integration Tests', () => {
let client;
beforeAll(() => {
const fixtures = {};
beforeAll(async () => {
if (!COOLIFY_URL || !COOLIFY_TOKEN) {

@@ -40,9 +31,32 @@ throw new Error('COOLIFY_URL and COOLIFY_TOKEN must be set for integration tests');

});
});
const [servers, apps] = await Promise.all([
client.listServers(),
client.listApplications(),
]);
fixtures.serverUuid = servers[0]?.uuid;
// Deliberately NOT `isRunningStatus`. That predicate answers "what should
// `stop_all_apps` target", where over-matching is the safe direction — it
// treats `exited:unhealthy` as running because 'unhealthy' contains
// 'healthy'. The question here is the stricter "which application is
// actually healthy", so that an app in a bad state is not handed to the
// test asserting healthy diagnostics. Different question, so a different
// test, not a rival copy of the same one.
fixtures.healthyAppUuid = apps.find((app) => app.status?.includes('running') && !app.status.includes('unhealthy'))?.uuid;
fixtures.unhealthyAppUuid = apps.find((app) => app.status?.includes('exited') || app.status?.includes('unhealthy'))?.uuid;
// Fail here, once, rather than letting each test below dereference an
// undefined uuid and fail separately against `/applications/undefined`.
// One honest error beats three confusing ones, and an instance with no
// servers or no running application cannot verify anything in this suite.
if (!fixtures.serverUuid || !fixtures.healthyAppUuid) {
throw new Error(`Cannot run diagnostics integration tests against ${COOLIFY_URL}: ` +
`discovered ${servers.length} servers and ${apps.length} applications, ` +
`needing at least one server and one healthy running application.`);
}
}, 60000);
describe('diagnoseApplication', () => {
it('should return diagnostic data for a healthy application', async () => {
const result = await client.diagnoseApplication(TEST_DATA.APP_UUID_HEALTHY);
const result = await client.diagnoseApplication(fixtures.healthyAppUuid);
// Should have application info
expect(result.application).not.toBeNull();
expect(result.application?.uuid).toBe(TEST_DATA.APP_UUID_HEALTHY);
expect(result.application?.uuid).toBe(fixtures.healthyAppUuid);
expect(result.application?.name).toBeDefined();

@@ -71,3 +85,11 @@ // Should have health assessment

it('should detect issues in an unhealthy application', async () => {
const result = await client.diagnoseApplication(TEST_DATA.APP_UUID_UNHEALTHY);
if (!fixtures.unhealthyAppUuid) {
// Stated out loud rather than passing quietly: an estate where
// everything is up cannot exercise this path, and a silent green here
// would read as "unhealthy detection works".
console.warn('\n[diagnostics.integration] No unhealthy application on this instance — ' +
'unhealthy detection was NOT verified.\n');
return;
}
const result = await client.diagnoseApplication(fixtures.unhealthyAppUuid);
expect(result.application).not.toBeNull();

@@ -92,6 +114,6 @@ // Should detect unhealthy status

it('should return diagnostic data for a server', async () => {
const result = await client.diagnoseServer(TEST_DATA.SERVER_UUID);
const result = await client.diagnoseServer(fixtures.serverUuid);
// Should have server info
expect(result.server).not.toBeNull();
expect(result.server?.uuid).toBe(TEST_DATA.SERVER_UUID);
expect(result.server?.uuid).toBe(fixtures.serverUuid);
expect(result.server?.name).toBeDefined();

@@ -98,0 +120,0 @@ expect(result.server?.ip).toBeDefined();

@@ -94,2 +94,29 @@ /**

/**
* Whether an application's status string counts as "up", for the purposes of
* deciding what `stopAllApps` targets.
*
* Coolify reports composite statuses like `running:healthy` and
* `exited:unhealthy`, hence substring tests rather than equality.
*
* Exported because `stopAllApps` uses it to decide what to stop and the #261
* elicitation prompt uses it to tell the human what is about to be stopped. Two
* copies of this predicate would eventually disagree, and the failure mode of
* that is a confirmation dialog understating its own blast radius.
*
* **Fixed here, because extraction changed what the bug costs.** `'unhealthy'`
* contains `'healthy'`, so the original `includes('healthy')` classified
* `exited:unhealthy` as running. Inside `stopAllApps` that cost only a no-op
* stop against an already-stopped app, which nobody ever saw. Shared with the
* #261 confirmation prompt it does something worse: it names already-dead
* applications in a dialog whose entire job is to be accurate, and someone
* scanning that list for the one application that should not be in it is handed
* noise. A prompt that pads its own blast radius trains people to stop reading
* it.
*
* `running:unhealthy` still counts, via the `running` branch — an application
* that is up but failing health checks is still something an emergency stop
* should take down.
*/
export declare function isRunningStatus(status?: string): boolean;
/**
* HTTP client for the Coolify API

@@ -436,4 +463,60 @@ */

*/
restartProjectApps(projectUuid: string): Promise<BatchOperationResult>;
/**
* Applications belonging to a project.
*
* **`GET /applications` does not return `project_uuid`.** Verified live
* against 4.1.2: none of the 26 applications on the test estate carried the
* field, and it is absent from the response entirely. `restartProjectApps`
* and `redeployProjectApps` both filtered on it, so both matched zero
* applications and silently reported "0 succeeded" instead of doing anything.
*
* The only link an application carries is the numeric `environment_id`, and
* `GET /projects/{uuid}` is what expands a project into its environments. So
* the resolution is project → environment ids → applications in those
* environments. Verified live: this maps all 26 applications to a project.
*
* `getProject` rather than the narrower `listProjectEnvironments`
* (`GET /projects/{uuid}/environments`) because one call answers both halves
* of the question — it returns the environments *and* confirms the project
* exists — and it is verified against a live 4.1.2, which the narrower
* endpoint is not. `environments` is optional on the `Project` type, so the
* check below is what stops that choice degrading into a silent zero; if a
* future instance stops expanding it, the fix is to fall back to
* `listProjectEnvironments` here rather than to soften the check.
*
* Deliberately takes no pre-fetched application list. The #261 confirmation
* path shares the set the human approved by passing it to the *operation*
* (`restartProjectApps` / `redeployProjectApps` both accept it), not by
* re-entering this lookup, so a pre-fetch parameter here would have no caller.
*/
applicationsInProject(projectUuid: string): Promise<Application[]>;
/**
* Everything a project delete would take with it.
*
* `DELETE /projects/{uuid}` documents no "project has resources" refusal —
* unlike the environment delete, which has an explicit 400 — so the delete is
* assumed to cascade, and a confirmation that counts only applications
* understates a project holding three Postgres instances and no apps. That is
* the direction a destructive prompt must never be wrong in.
*
* Databases and services resolve exactly like applications: verified live
* against 4.1.2, neither list endpoint returns `project_uuid` and both carry
* the numeric `environment_id`.
*
* Returns the `project` too, so the confirmation path does not fetch it a
* second time — the one path where an extra round trip happens with a human
* waiting on the dialog.
*/
projectContents(projectUuid: string): Promise<{
project: Project;
applications: Application[];
databases: Database[];
services: Service[];
}>;
/**
* @param projectApps The applications to restart. Pass the set a human already
* approved; omit to resolve it from the project.
*/
restartProjectApps(projectUuid: string, projectApps?: Application[]): Promise<BatchOperationResult>;
/**
* Update or create an environment variable across multiple applications.

@@ -451,4 +534,8 @@ * Uses upsert behavior: creates if not exists, updates if exists.

*/
stopAllApps(): Promise<BatchOperationResult>;
/**
* @param runningApps The applications to stop, already filtered to running.
* Pass the set a human approved; omit to resolve it from the estate.
*/
stopAllApps(runningApps?: Application[]): Promise<BatchOperationResult>;
/**
* Redeploy all applications in a project.

@@ -458,3 +545,3 @@ * @param projectUuid - Project UUID

*/
redeployProjectApps(projectUuid: string, force?: boolean): Promise<BatchOperationResult>;
redeployProjectApps(projectUuid: string, force?: boolean, projectApps?: Application[]): Promise<BatchOperationResult>;
}

@@ -184,2 +184,27 @@ /**

private defineTool;
/**
* Run `operation`, but ask the human first (#261).
*
* On clients that support elicitation this renders `summarize()` as a
* confirmation prompt and aborts unless it is accepted; on clients that do
* not, it is a straight pass-through to {@link wrap} and the tool behaves
* exactly as it did before. See `elicit.ts` for why that asymmetry is the
* right default.
*
* `summarize` is lazy so that call sites which need an API round trip to
* count their blast radius do not make it on clients that will never show
* the question, and may return `null` to mean "nothing to confirm" — an
* emergency stop on an idle estate should not raise a dialog.
*
* `label` names the operation without needing any lookup, so that when
* `summarize` fails the human is still told what they are approving. The
* degraded prompt fires exactly when Coolify is flaky, which is when people
* are least inclined to read carefully.
*
* `signal` is the tool call's own abort signal and must be threaded through:
* without it, a client that times the `tools/call` out at 60s leaves the
* prompt live, and a later accept executes the operation with nobody
* listening.
*/
private guardDestructive;
constructor(config: CoolifyConfig);

@@ -186,0 +211,0 @@ connect(transport: Transport): Promise<void>;

@@ -196,2 +196,18 @@ /**

destination_id?: number;
/**
* Expanded destination, as returned by `GET /applications`. Verified live
* against 4.1.2 — the list response nests this object and does **not**
* populate the flat `server_uuid` below, so `server_uuid` cannot be used to
* answer "which server is this app on" for a listed application.
*
* Each destination belongs to exactly one server, so `server_id` is the
* correct key for counting distinct servers; `destination_id` would overcount
* a server that has several docker networks.
*/
destination?: {
id?: number;
uuid?: string;
name?: string;
server_id?: number;
};
source_type?: string;

@@ -203,2 +219,3 @@ source_id?: number;

environment_uuid?: string;
/** Not returned by `GET /applications`; see {@link Application.destination}. */
server_uuid?: string;

@@ -461,2 +478,9 @@ created_at: string;

project_uuid?: string;
/**
* Numeric environment link, and the only usable one on a listed database.
* Verified live against 4.1.2: `GET /databases` returns this and leaves
* `project_uuid` undefined, exactly as `GET /applications` does. It is what
* resolves a database to its project — see `projectContents`.
*/
environment_id?: number;
environment_uuid?: string;

@@ -669,2 +693,9 @@ environment_name?: string;

project_uuid?: string;
/**
* Numeric environment link, and the only usable one on a listed service.
* Verified live against 4.1.2: `GET /services` returns this and leaves
* `project_uuid` undefined, exactly as `GET /applications` does. It is what
* resolves a service to its project — see `projectContents`.
*/
environment_id?: number;
environment_name?: string;

@@ -671,0 +702,0 @@ environment_uuid?: string;

{
"name": "@masonator/coolify-mcp",
"scope": "@masonator",
"version": "2.16.0",
"version": "2.17.0",
"mcpName": "io.github.StuMason/coolify",

@@ -28,3 +28,3 @@ "description": "MCP server for Coolify — 44 optimized tools for infrastructure management, diagnostics, and documentation search",

"test:coverage": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --testPathIgnorePatterns=integration",
"test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --testPathPattern=integration --testTimeout=60000",
"test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --testPathPatterns=integration --testTimeout=60000 --coverage=false",
"lint": "eslint .",

@@ -31,0 +31,0 @@ "lint:fix": "eslint . --fix",

@@ -85,2 +85,24 @@ # Coolify MCP Server

## Ask before it hurts
Destructive operations pause and ask **you**, not the model, on clients that support [elicitation](https://modelcontextprotocol.io/specification/2025-06-18/changelog) — Claude Code and VS Code Copilot today. The prompt states the blast radius before you answer:
```text
EMERGENCY STOP: take down 12 running applications
(api, worker, cockpit, umami, scheduler, mailer, search, billing and 4 more)
across 3 servers?
```
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.
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.
This is progressive enhancement, not a new requirement: clients without elicitation support (Claude Desktop, claude.ai) behave exactly as before. Once a client does advertise support, a decline, a cancel or a timeout all abort the call.
These tools also carry the MCP `destructiveHint` annotation, so on a client that honours annotations **and** supports elicitation you may answer two dialogs in a row — the client's own permission prompt, then this one. That is the client's prompt plus the server's, not a bug. Allowlisting the tool in your client removes the first and leaves this one as the gate.
Set `COOLIFY_MCP_ELICITATION=off` to turn the confirmations off entirely. It exists for the case where a client advertises elicitation support but does not actually implement it — otherwise every guarded tool would return `could not confirm with the user` with no way to recover. It is an escape hatch, not a normal setting.
> **If confirmations time out before you can answer them**, raise your client's MCP tool timeout. The prompt runs inside the tool call, and the MCP SDK's default request timeout is 60 seconds. The server aborts cleanly when the client gives up — nothing runs behind your back — but you will see the call fail rather than the dialog you were reading.
## Secure by default

@@ -87,0 +109,0 @@

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display