Sign In

@masonator/coolify-mcp

Package Overview
Dependencies
Maintainers
1
Versions
81
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.15.0
to
2.16.0
+28
dist/__tests__/integration/helpers.d.ts
/**
* Shared setup for the integration suites.
*
* Not a `*.test.ts` file, so `--testPathPattern`/`--testPathPatterns=integration` does not try to
* collect it as a suite.
*/
import { CoolifyClient } from '../../lib/coolify-client.js';
/** Trailing slashes produce `//api/v1/...`, which Coolify's catch-all 404s — making every "method rejected" assertion pass for the wrong reason. */
export declare const COOLIFY_URL: string | undefined;
export declare const COOLIFY_TOKEN: string | undefined;
export declare const hasCredentials: boolean;
export declare function warnIfSkipped(suite: string): void;
export declare const describeIf: (condition: boolean) => jest.Describe;
export declare function makeClient(): CoolifyClient;
/**
* Parsed `major.minor`. Returned as a tuple rather than a float because
* `Number('4.10')` is `4.1`, which compares as *older* than 4.2 — and the
* pre-4.2 branch of the compat suite issues real state-changing probes, so a
* mis-parse there is not a cosmetic bug.
*/
export declare function parseMajorMinor(version: string): [number, number];
export declare function atLeast(actual: [number, number], target: [number, number]): boolean;
export declare const V4_2: [number, number];
/** Resolve the instance version, or `[0, 0]` if it cannot be read. */
export declare function resolveVersion(client: CoolifyClient): Promise<{
version: [number, number];
raw: string;
}>;
/**
* Shared setup for the integration suites.
*
* Not a `*.test.ts` file, so `--testPathPattern`/`--testPathPatterns=integration` does not try to
* collect it as a suite.
*/
import { config } from 'dotenv';
import { CoolifyClient } from '../../lib/coolify-client.js';
// override: true because an empty COOLIFY_URL in the ambient environment
// otherwise wins over .env and silently skips every test — and a skipped suite
// reads exactly like a passing one, which is the failure these tests exist to
// catch.
config({ override: true });
/** Trailing slashes produce `//api/v1/...`, which Coolify's catch-all 404s — making every "method rejected" assertion pass for the wrong reason. */
export const COOLIFY_URL = process.env.COOLIFY_URL?.replace(/\/+$/, '');
export const COOLIFY_TOKEN = process.env.COOLIFY_TOKEN;
export const hasCredentials = Boolean(COOLIFY_URL && COOLIFY_TOKEN);
export function warnIfSkipped(suite) {
if (!hasCredentials) {
console.warn(`\n[${suite}] SKIPPED — COOLIFY_URL and COOLIFY_TOKEN are not set. ` +
'Nothing was verified against a real Coolify.\n');
}
}
export const describeIf = (condition) => condition ? global.describe : global.describe.skip;
export function makeClient() {
return hasCredentials
? new CoolifyClient({
baseUrl: COOLIFY_URL,
accessToken: COOLIFY_TOKEN,
})
: null;
}
/**
* Parsed `major.minor`. Returned as a tuple rather than a float because
* `Number('4.10')` is `4.1`, which compares as *older* than 4.2 — and the
* pre-4.2 branch of the compat suite issues real state-changing probes, so a
* mis-parse there is not a cosmetic bug.
*/
export function parseMajorMinor(version) {
const match = /^v?(\d+)\.(\d+)/.exec(version);
return match ? [Number(match[1]), Number(match[2])] : [0, 0];
}
export function atLeast(actual, target) {
return actual[0] > target[0] || (actual[0] === target[0] && actual[1] >= target[1]);
}
export const V4_2 = [4, 2];
/** Resolve the instance version, or `[0, 0]` if it cannot be read. */
export async function resolveVersion(client) {
try {
const { version } = await client.getVersion();
return { version: parseMajorMinor(version), raw: version };
}
catch {
return { version: [0, 0], raw: 'unknown' };
}
}
/**
* Integration tests for the log endpoints added in #300.
*
* These exist because parts of this feature could not be verified from a
* development sandbox. `GET /applications/{uuid}/logs` was confirmed against a
* live instance to return `{ logs: "..." }` rather than a bare string, but
* upstream's OpenAPI types the service sub-resource listings as an untyped
* `array of object`, so the real shape of `/services/{uuid}/applications` and
* `/services/{uuid}/databases` is unconfirmed — as is whether the
* `sub_service_name` values they yield are accepted by `/services/{uuid}/logs`.
*
* Run these against a real Coolify before trusting the service paths:
* npm run test:integration
*
* Prerequisites:
* - COOLIFY_URL and COOLIFY_TOKEN set (from .env)
* - TEST_APPLICATION_UUID / TEST_DATABASE_UUID / TEST_SERVICE_UUID set to
* resources on that instance. Each block skips if its uuid is absent.
*/
export {};
/**
* Integration tests for the log endpoints added in #300.
*
* These exist because parts of this feature could not be verified from a
* development sandbox. `GET /applications/{uuid}/logs` was confirmed against a
* live instance to return `{ logs: "..." }` rather than a bare string, but
* upstream's OpenAPI types the service sub-resource listings as an untyped
* `array of object`, so the real shape of `/services/{uuid}/applications` and
* `/services/{uuid}/databases` is unconfirmed — as is whether the
* `sub_service_name` values they yield are accepted by `/services/{uuid}/logs`.
*
* Run these against a real Coolify before trusting the service paths:
* npm run test:integration
*
* Prerequisites:
* - COOLIFY_URL and COOLIFY_TOKEN set (from .env)
* - TEST_APPLICATION_UUID / TEST_DATABASE_UUID / TEST_SERVICE_UUID set to
* resources on that instance. Each block skips if its uuid is absent.
*/
import { it, expect } from '@jest/globals';
import { hasCredentials, warnIfSkipped, describeIf, makeClient, resolveVersion, atLeast, V4_2, } from './helpers.js';
warnIfSkipped('logs.integration');
const APPLICATION_UUID = process.env.TEST_APPLICATION_UUID;
const DATABASE_UUID = process.env.TEST_DATABASE_UUID;
const SERVICE_UUID = process.env.TEST_SERVICE_UUID;
const client = makeClient();
// Resolved at module scope, before jest collects the suites, so version-gated
// blocks are genuinely SKIPPED rather than passing via an early return. A test
// that returns early still reports a green tick, which is precisely the false
// confidence these tests exist to prevent.
let version = [0, 0];
if (hasCredentials) {
const resolvedVersion = await resolveVersion(client);
version = resolvedVersion.version;
const rawVersion = resolvedVersion.raw;
if (!atLeast(version, V4_2)) {
console.warn(`\n[logs.integration] Coolify ${rawVersion} predates v4.2 — the database and ` +
'service log endpoints do not exist on it yet, so those suites are SKIPPED, ' +
'not verified. Re-run after upgrading.\n');
}
}
const resolved = version[0] > 0;
const supportsV42 = resolved && atLeast(version, V4_2);
describeIf(hasCredentials)('logs integration', () => {
it('resolved the instance version, so a skip below is a real skip', () => {
expect(resolved).toBe(true);
});
describeIf(Boolean(APPLICATION_UUID))('application logs', () => {
it('returns a plain string, not the raw { logs } envelope', async () => {
const logs = await client.getApplicationLogs(APPLICATION_UUID, 5);
// The whole point of the unwrapping in #300 — a caller must be able to do
// string work on this without it silently being an object.
expect(typeof logs).toBe('string');
expect(logs).not.toContain('{"logs"');
}, 30_000);
it('accepts show_timestamps without erroring', async () => {
const logs = await client.getApplicationLogs(APPLICATION_UUID, 5, true);
expect(typeof logs).toBe('string');
}, 30_000);
});
describeIf(Boolean(DATABASE_UUID) && supportsV42)('database logs (Coolify v4.2+)', () => {
it('returns a plain string', async () => {
const logs = await client.getDatabaseLogs(DATABASE_UUID, 5);
expect(typeof logs).toBe('string');
}, 30_000);
});
describeIf(Boolean(SERVICE_UUID) && supportsV42)('service containers (Coolify v4.2+)', () => {
it('lists the containers inside a service with usable names', async () => {
const [applications, databases] = await Promise.all([
client.listServiceApplications(SERVICE_UUID),
client.listServiceDatabases(SERVICE_UUID),
]);
expect(Array.isArray(applications)).toBe(true);
expect(Array.isArray(databases)).toBe(true);
// The shape assumption this whole feature rests on: every container has a
// `name`, because that is what `logs` passes as `sub_service_name`.
for (const container of [...applications, ...databases]) {
expect(typeof container.name).toBe('string');
expect(container.name.length).toBeGreaterThan(0);
}
}, 30_000);
it('fetches logs for a container discovered from that listing', async () => {
const applications = await client.listServiceApplications(SERVICE_UUID);
const databases = await client.listServiceDatabases(SERVICE_UUID);
const container = [...applications, ...databases][0];
if (!container) {
console.warn('Service has no containers — skipping the round-trip assertion');
return;
}
// Closes the loop: a name from list_containers must be accepted by the
// logs endpoint. If this fails, discovery and retrieval disagree.
const logs = await client.getServiceLogs(SERVICE_UUID, container.name, 5);
expect(typeof logs).toBe('string');
}, 30_000);
});
});
/**
* Integration tests for the tags tool (#298).
*
* Tag endpoints landed in Coolify v4.2 (`coollabsio/coolify#9275`), so on an
* older instance these are genuinely SKIPPED rather than failed — the endpoints
* do not exist yet, which is not a defect.
*
* Three behavioural claims the tool description makes are confirmed here rather
* than taken from the spec, because CLAUDE.md is explicit that Coolify's OpenAPI
* is unreliable. Reading `HandlesTagsApi.php` upstream says:
*
* - `attachTagsToResource` uses `syncWithoutDetaching`, so **attach is additive**
* and existing tags survive. If it were a `sync`, attaching one tag would
* silently strip the rest, and the description would be wrong in a way that
* destroys user configuration.
* - `createTag` returns `$resource->refresh()->tags`, i.e. the **full** tag set,
* not just the newly added ones.
* - `deleteTag` returns `{'message': 'Tag removed.'}`, so `MessageResponse` with
* a required `message` is the right type.
*
* The additive test below is the one that matters — it is the only assertion
* that would catch upstream switching to a replace.
*
* **Side effects:** this suite creates and removes tags on a resource you
* nominate via TEST_TAG_APPLICATION_UUID, using names prefixed `coolify-mcp-test-`.
* It cleans up after itself and touches nothing else. It is skipped entirely
* unless that variable is set, so it never runs against an arbitrary resource.
*
* Only the **application** tag routes are exercised. The client ships database
* and service tag surface too, and those remain unverified against a live
* instance.
*
* Run with: npm run test:integration
*/
export {};
/**
* Integration tests for the tags tool (#298).
*
* Tag endpoints landed in Coolify v4.2 (`coollabsio/coolify#9275`), so on an
* older instance these are genuinely SKIPPED rather than failed — the endpoints
* do not exist yet, which is not a defect.
*
* Three behavioural claims the tool description makes are confirmed here rather
* than taken from the spec, because CLAUDE.md is explicit that Coolify's OpenAPI
* is unreliable. Reading `HandlesTagsApi.php` upstream says:
*
* - `attachTagsToResource` uses `syncWithoutDetaching`, so **attach is additive**
* and existing tags survive. If it were a `sync`, attaching one tag would
* silently strip the rest, and the description would be wrong in a way that
* destroys user configuration.
* - `createTag` returns `$resource->refresh()->tags`, i.e. the **full** tag set,
* not just the newly added ones.
* - `deleteTag` returns `{'message': 'Tag removed.'}`, so `MessageResponse` with
* a required `message` is the right type.
*
* The additive test below is the one that matters — it is the only assertion
* that would catch upstream switching to a replace.
*
* **Side effects:** this suite creates and removes tags on a resource you
* nominate via TEST_TAG_APPLICATION_UUID, using names prefixed `coolify-mcp-test-`.
* It cleans up after itself and touches nothing else. It is skipped entirely
* unless that variable is set, so it never runs against an arbitrary resource.
*
* Only the **application** tag routes are exercised. The client ships database
* and service tag surface too, and those remain unverified against a live
* instance.
*
* Run with: npm run test:integration
*/
import { it, expect, afterAll } from '@jest/globals';
import { hasCredentials, warnIfSkipped, describeIf, makeClient, resolveVersion, atLeast, V4_2, } from './helpers.js';
warnIfSkipped('tags.integration');
/**
* Deliberately opt-in: this is the only integration suite that writes.
*
* An **application** uuid specifically — every call below uses the application
* tag routes. Pointing it at a service would produce a run of 404s reported as
* "upgrade to v4.2", which is exactly the misdirection finding 1 fixed.
*/
const APPLICATION_UUID = process.env.TEST_TAG_APPLICATION_UUID;
const client = makeClient();
let version = [0, 0];
if (hasCredentials) {
const resolvedVersion = await resolveVersion(client);
version = resolvedVersion.version;
if (!atLeast(version, V4_2)) {
console.warn(`\n[tags.integration] Coolify ${resolvedVersion.raw} predates v4.2 — tag ` +
'endpoints do not exist on it yet, so this suite is SKIPPED, not verified.\n');
}
}
const supportsV42 = version[0] > 0 && atLeast(version, V4_2);
const PREFIX = 'coolify-mcp-test-';
const TAG_A = `${PREFIX}alpha`;
const TAG_B = `${PREFIX}beta`;
if (hasCredentials && supportsV42 && !APPLICATION_UUID) {
console.warn('\n[tags.integration] SKIPPED — TEST_TAG_APPLICATION_UUID is not set, so the ' +
'ADDITIVE assertion (the only one that would catch upstream switching attach ' +
'to a replace) did not run. Set it to an application uuid to verify.\n');
}
describeIf(hasCredentials)('tags integration', () => {
// Without this, a transient getVersion() failure resolves to [0, 0],
// supportsV42 goes false, and the whole suite vanishes as though the instance
// were simply old — the false confidence helpers.ts exists to prevent.
it('resolved the instance version, so a skip below is a real skip', () => {
expect(version[0]).toBeGreaterThan(0);
});
});
describeIf(hasCredentials && supportsV42 && Boolean(APPLICATION_UUID))('tags integration (writes)', () => {
afterAll(async () => {
// Remove anything this suite created, whatever the outcome above.
const tags = await client.listApplicationTags(APPLICATION_UUID).catch(() => []);
for (const tag of tags) {
if (tag.name.startsWith(PREFIX)) {
await client
.detachApplicationTag(APPLICATION_UUID, tag.uuid)
.catch(() => undefined);
}
}
}, 60_000);
it("lists the current team's tags", async () => {
const tags = await client.listTags();
expect(Array.isArray(tags)).toBe(true);
for (const tag of tags) {
expect(typeof tag.uuid).toBe('string');
expect(typeof tag.name).toBe('string');
}
}, 30_000);
it('attach is ADDITIVE — a second attach keeps the first tag', async () => {
await client.attachApplicationTags(APPLICATION_UUID, { tag_names: [TAG_A] });
const afterFirst = await client.listApplicationTags(APPLICATION_UUID);
expect(afterFirst.map((t) => t.name)).toContain(TAG_A);
await client.attachApplicationTags(APPLICATION_UUID, { tag_names: [TAG_B] });
const afterSecond = await client.listApplicationTags(APPLICATION_UUID);
// The claim the tool description rests on. A `sync` upstream would have
// dropped TAG_A here, silently destroying tags the user set.
expect(afterSecond.map((t) => t.name)).toContain(TAG_A);
expect(afterSecond.map((t) => t.name)).toContain(TAG_B);
}, 60_000);
it('attach returns the full tag set, not only the new ones', async () => {
const returned = await client.attachApplicationTags(APPLICATION_UUID, {
tag_names: [TAG_A],
});
expect(Array.isArray(returned)).toBe(true);
expect(returned.map((t) => t.name)).toContain(TAG_A);
}, 30_000);
it('detach removes one tag and returns a message', async () => {
await client.attachApplicationTags(APPLICATION_UUID, { tag_names: [TAG_A, TAG_B] });
const before = await client.listApplicationTags(APPLICATION_UUID);
const target = before.find((t) => t.name === TAG_A);
expect(target).toBeDefined();
const result = await client.detachApplicationTag(APPLICATION_UUID, target.uuid);
// MessageResponse.message is required — if upstream returned 204/empty this
// would be a silent undefined, the same class of problem as withheld v4.2
// secrets.
expect(typeof result.message).toBe('string');
const after = await client.listApplicationTags(APPLICATION_UUID);
expect(after.map((t) => t.name)).not.toContain(TAG_A);
// Detaching one must not remove the others.
expect(after.map((t) => t.name)).toContain(TAG_B);
}, 60_000);
});
/**
* Integration tests for the Coolify v4.2 GET-to-POST compatibility work (#292 / #296).
*
* That work was built by reading upstream's `routes/api.php` at v4.1.2, v4.0.0
* and older betas, and shipped without ever being run against a pre-4.2
* instance. These tests close that gap: on Coolify < 4.2 they prove the legacy
* path is genuinely needed, and on >= 4.2 they prove POST is genuinely accepted.
* Either way the suite asserts something real.
*
* **Every assertion here is side-effect free**, and deliberately so:
*
* - A method the router rejects executes no controller, so a real POST probe
* starts, stops and deploys nothing. That is the same property the fallback
* itself relies on.
* - `/deploy` with a tag matching no resource routes successfully and then
* deploys nothing, isolating "was the method accepted" from "did anything
* happen".
* - `/enable` is only ever called on an instance whose API is already on, where
* it is a no-op. **`/disable` is never called at all** — it would turn off the
* API this client depends on, and it proves nothing that `/enable` and
* `/servers/{uuid}/validate` do not already prove.
*
* That guarantee is downstream of the version compare being correct, which is
* why `parseMajorMinor` returns a tuple — `Number('4.10')` is `4.1`, which would
* classify a 4.10 instance as pre-4.2 and point state-changing probes at a box
* that accepts them.
*
* Run with: npm run test:integration
*/
export {};
/**
* Integration tests for the Coolify v4.2 GET-to-POST compatibility work (#292 / #296).
*
* That work was built by reading upstream's `routes/api.php` at v4.1.2, v4.0.0
* and older betas, and shipped without ever being run against a pre-4.2
* instance. These tests close that gap: on Coolify < 4.2 they prove the legacy
* path is genuinely needed, and on >= 4.2 they prove POST is genuinely accepted.
* Either way the suite asserts something real.
*
* **Every assertion here is side-effect free**, and deliberately so:
*
* - A method the router rejects executes no controller, so a real POST probe
* starts, stops and deploys nothing. That is the same property the fallback
* itself relies on.
* - `/deploy` with a tag matching no resource routes successfully and then
* deploys nothing, isolating "was the method accepted" from "did anything
* happen".
* - `/enable` is only ever called on an instance whose API is already on, where
* it is a no-op. **`/disable` is never called at all** — it would turn off the
* API this client depends on, and it proves nothing that `/enable` and
* `/servers/{uuid}/validate` do not already prove.
*
* That guarantee is downstream of the version compare being correct, which is
* why `parseMajorMinor` returns a tuple — `Number('4.10')` is `4.1`, which would
* classify a 4.10 instance as pre-4.2 and point state-changing probes at a box
* that accepts them.
*
* Run with: npm run test:integration
*/
import { it, expect } from '@jest/globals';
import { COOLIFY_URL, COOLIFY_TOKEN, hasCredentials, warnIfSkipped, describeIf, makeClient, resolveVersion, atLeast, V4_2, } from './helpers.js';
warnIfSkipped('v42-compat.integration');
const client = makeClient();
let version = [0, 0];
if (hasCredentials) {
const resolvedVersion = await resolveVersion(client);
version = resolvedVersion.version;
const rawVersion = resolvedVersion.raw;
console.warn(`\n[v42-compat.integration] Coolify ${rawVersion} — asserting the ` +
`${atLeast(version, V4_2) ? 'v4.2 POST-only' : 'pre-4.2 GET-only'} behaviour.\n`);
}
const resolved = version[0] > 0;
const isPre42 = resolved && !atLeast(version, V4_2);
const is42Plus = resolved && atLeast(version, V4_2);
/** Issue a raw request, bypassing the client's fallback entirely. */
async function rawProbe(path, method) {
const response = await fetch(`${COOLIFY_URL}/api/v1${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${COOLIFY_TOKEN}`,
},
});
const body = await response.json().catch(() => ({}));
// Status alone cannot answer "did the router accept this method": Coolify's
// catch-all and a controller's own "not found" are both 404. The catch-all
// adds a `docs` key, and no controller response carries it — the same
// signature the client's fallback keys off.
const isCatchAll = response.status === 404 && typeof body === 'object' && body !== null && 'docs' in body;
return { status: response.status, routed: !isCatchAll && response.status !== 405 };
}
async function rawStatus(path, method) {
return (await rawProbe(path, method)).status;
}
describeIf(hasCredentials)('v4.2 method compatibility', () => {
it('resolved the instance version, so a skip below is a real skip', () => {
expect(resolved).toBe(true);
});
describeIf(isPre42)('on a pre-4.2 instance', () => {
// The premise of the whole fallback: these are GET-only before v4.2, so a
// blanket switch to POST would have broken every instance like this one.
it('rejects POST on /enable without executing anything', async () => {
// `routed` rather than a bare status check: any 404 satisfies the latter,
// including one from a wrong base URL or a proxy, so the suite could pass
// without proving anything. This also exercises the catch-all's `docs`
// signature live — the discriminator the client's fallback keys off.
const { routed } = await rawProbe('/enable', 'POST');
expect(routed).toBe(false);
}, 30_000);
it('rejects POST on /servers/{uuid}/validate without running a validation', async () => {
const servers = await client.listServers();
expect(servers.length).toBeGreaterThan(0);
// Rejected at routing, so no validation job ran against a real server.
const { routed } = await rawProbe(`/servers/${servers[0].uuid}/validate`, 'POST');
expect(routed).toBe(false);
}, 30_000);
// The other half of #296: these were `Route::match(['get','post'])` well
// before v4.2, so they send POST unconditionally with no fallback. If that
// were wrong, this instance would reject them.
it('accepts POST on /deploy, so the unconditional POST is correct', async () => {
// The tag matches nothing, so the controller answers 404 "no resource
// found" — which is the point: reaching the controller at all proves the
// method was routed, and nothing was deployed. Asserting on status alone
// would confuse that with the router rejecting POST.
const { routed } = await rawProbe('/deploy?tag=coolify-mcp-compat-probe-no-such-tag&force=false', 'POST');
expect(routed).toBe(true);
}, 30_000);
it('accepts GET on /enable, which is why the fallback lands', async () => {
const status = await rawStatus('/enable', 'GET');
expect(status).toBeLessThan(400);
}, 30_000);
it('drives the real client through POST-then-GET and succeeds', async () => {
// The end-to-end proof. On this instance the POST comes back 404 from the
// catch-all, not 405 — handling only 405 is exactly what broke this path
// in 2.15.0. Idempotent: the API is already enabled.
await expect(client.enableApi()).resolves.toBeDefined();
}, 30_000);
});
describeIf(is42Plus)('on a v4.2+ instance', () => {
it('accepts POST on /enable without needing the fallback', async () => {
const status = await rawStatus('/enable', 'POST');
// Not merely "not 405" — a 404 would satisfy that while proving the
// opposite of acceptance.
expect(status).toBeLessThan(400);
}, 30_000);
it('rejects the legacy GET on /enable', async () => {
// v4.2 registers a `post_required` handler returning 405, but the
// catch-all is still the last route in the file, so 404 is possible too.
// The point is that GET no longer works, not which rejection it is.
const { routed } = await rawProbe('/enable', 'GET');
expect(routed).toBe(false);
}, 30_000);
it('drives the real client, which should succeed on the first POST', async () => {
await expect(client.enableApi()).resolves.toBeDefined();
}, 30_000);
});
});
+2
-5

@@ -13,8 +13,5 @@ /**

*/
import { config } from 'dotenv';
import { CoolifyClient } from '../../lib/coolify-client.js';
// Load environment variables from .env file
config();
const COOLIFY_URL = process.env.COOLIFY_URL;
const COOLIFY_TOKEN = process.env.COOLIFY_TOKEN;
import { COOLIFY_URL, COOLIFY_TOKEN, warnIfSkipped } from './helpers.js';
warnIfSkipped('diagnostics.integration');
// Skip all tests if environment variables are not set

@@ -21,0 +18,0 @@ const shouldRun = COOLIFY_URL && COOLIFY_TOKEN;

@@ -14,7 +14,5 @@ /**

*/
import { config } from 'dotenv';
import { CoolifyClient } from '../../lib/coolify-client.js';
config();
const COOLIFY_URL = process.env.COOLIFY_URL;
const COOLIFY_TOKEN = process.env.COOLIFY_TOKEN;
import { COOLIFY_URL, COOLIFY_TOKEN, warnIfSkipped } from './helpers.js';
warnIfSkipped('smoke.integration');
const shouldRun = COOLIFY_URL && COOLIFY_TOKEN;

@@ -21,0 +19,0 @@ const describeFn = shouldRun ? describe : describe.skip;

@@ -5,3 +5,3 @@ /**

*/
import type { CoolifyConfig, DeleteOptions, MessageResponse, UuidResponse, Server, ServerResource, ServerDomain, ServerValidation, CreateServerRequest, UpdateServerRequest, Project, CreateProjectRequest, UpdateProjectRequest, Environment, CreateEnvironmentRequest, Application, CreateApplicationPublicRequest, CreateApplicationPrivateGHRequest, CreateApplicationPrivateKeyRequest, CreateApplicationDockerfileRequest, CreateApplicationDockerImageRequest, CreateApplicationDockerComposeRequest, UpdateApplicationRequest, ApplicationActionResponse, EnvironmentVariable, EnvVarSummary, CreateEnvVarRequest, UpdateEnvVarRequest, BulkUpdateEnvVarsRequest, Database, UpdateDatabaseRequest, CreatePostgresqlRequest, CreateMysqlRequest, CreateMariadbRequest, CreateMongodbRequest, CreateRedisRequest, CreateKeydbRequest, CreateClickhouseRequest, CreateDragonflyRequest, CreateDatabaseResponse, DatabaseBackup, BackupExecution, CreateDatabaseBackupRequest, UpdateDatabaseBackupRequest, Service, CreateServiceRequest, UpdateServiceRequest, ServiceCreateResponse, Deployment, DeploymentEssential, DeployTriggerResponse, Team, TeamMember, PrivateKey, CreatePrivateKeyRequest, UpdatePrivateKeyRequest, GitHubApp, CreateGitHubAppRequest, UpdateGitHubAppRequest, GitHubAppUpdateResponse, CloudToken, CreateCloudTokenRequest, UpdateCloudTokenRequest, CloudTokenValidation, Version, StorageListResponse, CreateStorageRequest, UpdateStorageRequest, ScheduledTask, ScheduledTaskExecution, CreateScheduledTaskRequest, UpdateScheduledTaskRequest, HetznerLocation, HetznerServerType, HetznerImage, HetznerSSHKey, CreateHetznerServerRequest, CreateHetznerServerResponse, GitHubRepository, GitHubBranch, ApplicationDiagnostic, ServerDiagnostic, InfrastructureIssuesReport, BatchOperationResult, ResourceListItem, ResourceListItemFull } from '../types/coolify.js';
import type { CoolifyConfig, DeleteOptions, MessageResponse, UuidResponse, Server, ServerResource, ServerDomain, ServerValidation, CreateServerRequest, UpdateServerRequest, Project, CreateProjectRequest, UpdateProjectRequest, Environment, CreateEnvironmentRequest, Application, CreateApplicationPublicRequest, CreateApplicationPrivateGHRequest, CreateApplicationPrivateKeyRequest, CreateApplicationDockerfileRequest, CreateApplicationDockerImageRequest, CreateApplicationDockerComposeRequest, UpdateApplicationRequest, ApplicationActionResponse, EnvironmentVariable, EnvVarSummary, CreateEnvVarRequest, UpdateEnvVarRequest, BulkUpdateEnvVarsRequest, Database, UpdateDatabaseRequest, CreatePostgresqlRequest, CreateMysqlRequest, CreateMariadbRequest, CreateMongodbRequest, CreateRedisRequest, CreateKeydbRequest, CreateClickhouseRequest, CreateDragonflyRequest, CreateDatabaseResponse, DatabaseBackup, BackupExecution, CreateDatabaseBackupRequest, UpdateDatabaseBackupRequest, Service, CreateServiceRequest, UpdateServiceRequest, ServiceCreateResponse, Deployment, DeploymentEssential, DeployTriggerResponse, Team, TeamMember, PrivateKey, CreatePrivateKeyRequest, UpdatePrivateKeyRequest, GitHubApp, CreateGitHubAppRequest, UpdateGitHubAppRequest, GitHubAppUpdateResponse, CloudToken, CreateCloudTokenRequest, UpdateCloudTokenRequest, CloudTokenValidation, Version, StorageListResponse, CreateStorageRequest, UpdateStorageRequest, ScheduledTask, ScheduledTaskExecution, CreateScheduledTaskRequest, UpdateScheduledTaskRequest, HetznerLocation, HetznerServerType, HetznerImage, HetznerSSHKey, CreateHetznerServerRequest, CreateHetznerServerResponse, GitHubRepository, GitHubBranch, ApplicationDiagnostic, ServerDiagnostic, InfrastructureIssuesReport, BatchOperationResult, ResourceListItem, ResourceListItemFull, ServiceSubResource, Tag, AttachTagsRequest } from '../types/coolify.js';
export interface ListOptions {

@@ -81,3 +81,7 @@ page?: number;

readonly status: number;
constructor(message: string, status: number);
/** Parsed response body, when there was one. Lets callers tell Coolify's routing catch-all apart from a controller's own 404. */
readonly body?: unknown | undefined;
constructor(message: string, status: number,
/** Parsed response body, when there was one. Lets callers tell Coolify's routing catch-all apart from a controller's own 404. */
body?: unknown | undefined);
}

@@ -119,6 +123,9 @@ /**

*
* Strategy: try POST, and on a 405 retry once with GET. The retry is safe
* Strategy: try POST, and on a 405 or 404 retry once with GET. The retry is safe
* because a 405 comes from the router before the controller runs, so nothing
* has executed and there is no risk of double-firing a state change. Only 405
* triggers the fallback — any other failure propagates untouched.
* has executed and there is no risk of double-firing a state change. The same
* holds for the catch-all 404, which is identified by its body shape rather
* than by status alone so a controller's genuine "not found" stays out of the
* retry path. Nothing else triggers the fallback — a 500 in particular
* propagates untouched, since it may mean the action partially ran.
*

@@ -186,3 +193,27 @@ * The resolved method is cached per `key`, so the extra round trip is paid at

deleteApplication(uuid: string, options?: DeleteOptions): Promise<MessageResponse>;
getApplicationLogs(uuid: string, lines?: number): Promise<string>;
getApplicationLogs(uuid: string, lines?: number, showTimestamps?: boolean): Promise<string>;
getDatabaseLogs(uuid: string, lines?: number, showTimestamps?: boolean): Promise<string>;
/**
* Logs for one container inside a service. `subServiceName` is required by
* Coolify — a service is a multi-container stack, so "the service logs" is
* ambiguous without it. Discover valid names via {@link listServiceApplications}
* and {@link listServiceDatabases}.
*/
getServiceLogs(uuid: string, subServiceName: string, lines?: number, showTimestamps?: boolean): Promise<string>;
/**
* Every tag on the **current team** — tokens are team-scoped, so this is not
* the whole instance. Useful for discovering a name to attach or deploy by.
*/
listTags(): Promise<Tag[]>;
listApplicationTags(uuid: string): Promise<Tag[]>;
listDatabaseTags(uuid: string): Promise<Tag[]>;
listServiceTags(uuid: string): Promise<Tag[]>;
attachApplicationTags(uuid: string, data: AttachTagsRequest): Promise<Tag[]>;
attachDatabaseTags(uuid: string, data: AttachTagsRequest): Promise<Tag[]>;
attachServiceTags(uuid: string, data: AttachTagsRequest): Promise<Tag[]>;
detachApplicationTag(uuid: string, tagUuid: string): Promise<MessageResponse>;
detachDatabaseTag(uuid: string, tagUuid: string): Promise<MessageResponse>;
detachServiceTag(uuid: string, tagUuid: string): Promise<MessageResponse>;
listServiceApplications(uuid: string): Promise<ServiceSubResource[]>;
listServiceDatabases(uuid: string): Promise<ServiceSubResource[]>;
startApplication(uuid: string, options?: {

@@ -189,0 +220,0 @@ force?: boolean;

@@ -28,5 +28,158 @@ /**

export declare function getPagination(tool: string, page?: number, perPage?: number, count?: number): ResponsePagination | undefined;
export declare const TOOL_ANNOTATIONS: {
get_version: Readonly<{
readOnlyHint: true;
}>;
get_mcp_version: {
readOnlyHint: true;
openWorldHint: false;
};
get_infrastructure_overview: Readonly<{
readOnlyHint: true;
}>;
list_servers: Readonly<{
readOnlyHint: true;
}>;
list_applications: Readonly<{
readOnlyHint: true;
}>;
list_databases: Readonly<{
readOnlyHint: true;
}>;
list_services: Readonly<{
readOnlyHint: true;
}>;
list_deployments: Readonly<{
readOnlyHint: true;
}>;
get_server: Readonly<{
readOnlyHint: true;
}>;
get_application: Readonly<{
readOnlyHint: true;
}>;
get_database: Readonly<{
readOnlyHint: true;
}>;
get_service: Readonly<{
readOnlyHint: true;
}>;
server_resources: Readonly<{
readOnlyHint: true;
}>;
server_domains: Readonly<{
readOnlyHint: true;
}>;
diagnose_app: Readonly<{
readOnlyHint: true;
}>;
diagnose_server: Readonly<{
readOnlyHint: true;
}>;
find_issues: Readonly<{
readOnlyHint: true;
}>;
search_docs: Readonly<{
readOnlyHint: true;
}>;
application_logs: Readonly<{
readOnlyHint: true;
}>;
logs: Readonly<{
readOnlyHint: true;
}>;
teams: Readonly<{
readOnlyHint: true;
}>;
application: Readonly<{
destructiveHint: true;
}>;
database: Readonly<{
destructiveHint: true;
}>;
service: Readonly<{
destructiveHint: true;
}>;
projects: Readonly<{
destructiveHint: true;
}>;
environments: Readonly<{
destructiveHint: true;
}>;
env_vars: Readonly<{
destructiveHint: true;
}>;
private_keys: Readonly<{
destructiveHint: true;
}>;
github_apps: Readonly<{
destructiveHint: true;
}>;
cloud_tokens: Readonly<{
destructiveHint: true;
}>;
storages: Readonly<{
destructiveHint: true;
}>;
tags: Readonly<{
destructiveHint: true;
}>;
scheduled_tasks: Readonly<{
destructiveHint: true;
}>;
database_backups: Readonly<{
destructiveHint: true;
}>;
control: Readonly<{
destructiveHint: true;
}>;
deploy: Readonly<{
destructiveHint: true;
}>;
deployment: Readonly<{
destructiveHint: true;
}>;
stop_all_apps: Readonly<{
destructiveHint: true;
}>;
bulk_env_update: Readonly<{
destructiveHint: true;
}>;
redeploy_project: Readonly<{
destructiveHint: true;
}>;
restart_project_apps: Readonly<{
destructiveHint: true;
}>;
system: Readonly<{
destructiveHint: true;
}>;
hetzner: {
destructiveHint: false;
};
validate_server: {
destructiveHint: false;
idempotentHint: true;
};
};
/**
* Every tool name known to the annotations table. `defineTool` takes this
* rather than `string`, so registering a tool that has no annotations is a
* compile error instead of a runtime throw. The throw stays as a backstop for
* anything reaching the method dynamically.
*/
export type ToolName = keyof typeof TOOL_ANNOTATIONS;
export declare class CoolifyMcpServer extends McpServer {
private readonly client;
private readonly docsSearch;
/**
* Register a tool, attaching its annotations from {@link TOOL_ANNOTATIONS}.
*
* Wraps SDK `registerTool` (the legacy `tool()` overloads are deprecated) so
* annotations cannot be forgotten at a call site. `name` is typed to the
* table's own keys, so a tool with no annotations fails `tsc` rather than
* throwing when someone runs the server; the runtime throw remains as a
* backstop for dynamic callers.
*/
private defineTool;
constructor(config: CoolifyConfig);

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

@@ -717,2 +717,43 @@ /**

}
/**
* A container inside a Coolify service. Services are multi-container stacks, and
* `name` is the `sub_service_name` that `GET /services/{uuid}/logs` requires.
*
* Deliberately permissive: upstream's OpenAPI types the list responses as a bare
* `array of object`, so only the fields we actually rely on are declared and the
* rest pass through untyped rather than being asserted from an unverified spec.
*/
export interface ServiceSubResource {
uuid: string;
name: string;
status?: string;
[key: string]: unknown;
}
/**
* A Coolify tag. Tags group resources across projects, and `deploy` already
* accepts a tag name — {@link CoolifyClient.deployByTagOrUuid} resolves it and
* triggers a deployment for everything carrying it.
*/
export interface Tag {
uuid: string;
name: string;
created_at?: string;
updated_at?: string;
}
/**
* Attach request. Upstream accepts either `tag_name` (single) or `tag_names`
* (array); this client always sends `tag_names` so there is one shape to reason
* about, with a single-element array covering the singular case.
*/
export interface AttachTagsRequest {
tag_names: string[];
}
/**
* Log endpoints return `{ logs: "..." }` — verified against a live Coolify
* instance, and matching upstream's OpenAPI. Older instances have been observed
* returning a bare string, so {@link CoolifyClient} normalises both.
*/
export interface LogsResponse {
logs: string;
}
export interface Deployment {

@@ -719,0 +760,0 @@ id: number;

{
"name": "@masonator/coolify-mcp",
"scope": "@masonator",
"version": "2.15.0",
"version": "2.16.0",
"mcpName": "io.github.StuMason/coolify",
"description": "MCP server for Coolify — 42 optimized tools for infrastructure management, diagnostics, and documentation search",
"description": "MCP server for Coolify — 44 optimized tools for infrastructure management, diagnostics, and documentation search",
"type": "module",

@@ -32,2 +32,4 @@ "main": "./dist/index.js",

"check:spec-drift": "node scripts/check-client-spec-drift.mjs",
"build:chunks": "node scripts/split-openapi-chunks.mjs",
"check:chunk-drift": "node scripts/split-openapi-chunks.mjs --check",
"format": "prettier --write .",

@@ -34,0 +36,0 @@ "format:check": "prettier --check .",

@@ -10,3 +10,3 @@ # Coolify MCP Server

Manage [Coolify](https://coolify.io/) through natural language — 42 token-optimized MCP tools for deploying, debugging, and operating your self-hosted PaaS from Claude, Cursor, or any MCP client.
Manage [Coolify](https://coolify.io/) through natural language — 44 token-optimized MCP tools for deploying, debugging, and operating your self-hosted PaaS from Claude, Cursor, or any MCP client.

@@ -59,6 +59,8 @@ 📖 **Full docs: [coolify-mcp.stumason.dev](https://coolify-mcp.stumason.dev)** — install guide, tools reference, architecture, security model, v3 roadmap.

| **Environments** | `environments` (list, get, create, delete via action param) |
| **Applications** | `list_applications`, `get_application`, `application` (CRUD + delete_preview), `application_logs` |
| **Applications** | `list_applications`, `get_application`, `application` (CRUD + delete_preview) |
| **Databases** | `list_databases`, `get_database`, `database` (create 8 types, delete), `database_backups` (CRUD schedules, executions incl. delete) |
| **Services** | `list_services`, `get_service`, `service` (create, update, delete) |
| **Services** | `list_services`, `get_service`, `service` (create, update, delete, list_containers) |
| **Control** | `control` (start/stop/restart for apps, databases, services) |
| **Logs** | `logs` (container logs for app, database, service — services need `container`), `application_logs` (superseded by `logs`) |
| **Tags** | `tags` (list, attach, detach for apps, databases, services; tag resources then `deploy` them together — Coolify v4.2+) |
| **Env Vars** | `env_vars` (CRUD + bulk_update for application, service, and database env vars) |

@@ -65,0 +67,0 @@ | **Storages** | `storages` (list, create, update, delete persistent/file storages for apps, databases, services) |

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