@masonator/coolify-mcp
Version:
MCP server implementation for Coolify
1,292 lines (1,291 loc) • 48.6 kB
JavaScript
/**
* Coolify API Client
* Complete HTTP client for the Coolify API v1
*/
/**
* Remove undefined values from an object.
* Keeps explicit false values so features like HTTP Basic Auth can be disabled.
*/
function cleanRequestData(data) {
const cleaned = {};
for (const [key, value] of Object.entries(data)) {
if (value !== undefined) {
cleaned[key] = value;
}
}
return cleaned;
}
/** Base64-encode a string, passing through values that are already base64. */
function toBase64(value) {
try {
const decoded = Buffer.from(value, 'base64').toString('utf-8');
if (Buffer.from(decoded, 'utf-8').toString('base64') === value) {
return value; // Already valid base64
}
}
catch {
// Not base64, encode it
}
return Buffer.from(value, 'utf-8').toString('base64');
}
// =============================================================================
// Summary Transformers - reduce full objects to essential fields
// =============================================================================
function toServerSummary(server) {
return {
uuid: server.uuid,
name: server.name,
ip: server.ip,
status: server.status,
is_reachable: server.is_reachable,
};
}
function toApplicationSummary(app) {
return {
uuid: app.uuid,
name: app.name,
status: app.status,
fqdn: app.fqdn,
git_repository: app.git_repository,
git_branch: app.git_branch,
};
}
function toDatabaseSummary(db) {
// API returns database_type not type, and environment_id not environment_uuid
const raw = db;
return {
uuid: db.uuid,
name: db.name,
type: db.type || raw.database_type,
status: db.status,
is_public: db.is_public,
environment_uuid: db.environment_uuid,
environment_name: db.environment_name,
environment_id: raw.environment_id,
};
}
function toServiceSummary(svc) {
return {
uuid: svc.uuid,
name: svc.name,
type: svc.type,
status: svc.status,
domains: svc.domains,
};
}
function toDeploymentSummary(dep) {
return {
uuid: dep.uuid,
deployment_uuid: dep.deployment_uuid,
application_name: dep.application_name,
status: dep.status,
created_at: dep.created_at,
};
}
function toDeploymentEssential(dep) {
return {
uuid: dep.uuid,
deployment_uuid: dep.deployment_uuid,
application_uuid: dep.application_uuid,
application_name: dep.application_name,
server_name: dep.server_name,
status: dep.status,
commit: dep.commit,
force_rebuild: dep.force_rebuild,
is_webhook: dep.is_webhook,
is_api: dep.is_api,
created_at: dep.created_at,
updated_at: dep.updated_at,
logs_available: !!dep.logs,
logs_info: dep.logs
? `Logs available (${dep.logs.length} chars). Use lines param to retrieve.`
: undefined,
};
}
function toProjectSummary(proj) {
return {
uuid: proj.uuid,
name: proj.name,
description: proj.description,
};
}
function toGitHubAppSummary(app) {
return {
id: app.id,
uuid: app.uuid,
name: app.name,
organization: app.organization,
is_public: app.is_public,
app_id: app.app_id,
};
}
function toEnvVarSummary(envVar) {
return {
uuid: envVar.uuid,
key: envVar.key,
value: envVar.value,
is_build_time: envVar.is_build_time,
};
}
/**
* HTTP client for the Coolify API
*/
export class CoolifyClient {
constructor(config) {
if (!config.baseUrl) {
throw new Error('Coolify base URL is required');
}
if (!config.accessToken) {
throw new Error('Coolify access token is required');
}
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.accessToken = config.accessToken;
}
// ===========================================================================
// Private HTTP methods
// ===========================================================================
async request(path, options = {}) {
const url = `${this.baseUrl}/api/v1${path}`;
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.accessToken}`,
...options.headers,
},
});
// Handle empty responses (204 No Content, etc.)
const text = await response.text();
const data = text ? JSON.parse(text) : {};
if (!response.ok) {
const error = data;
// Include validation errors if present
let errorMessage = error.message || `HTTP ${response.status}: ${response.statusText}`;
if (error.errors && Object.keys(error.errors).length > 0) {
const validationDetails = Object.entries(error.errors)
.map(([field, messages]) => `${field}: ${Array.isArray(messages) ? messages.join(', ') : String(messages)}`)
.join('; ');
errorMessage = `${errorMessage} - ${validationDetails}`;
}
throw new Error(errorMessage);
}
return data;
}
catch (error) {
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error(`Failed to connect to Coolify server at ${this.baseUrl}. Please check if the server is running and accessible.`);
}
throw error;
}
}
buildQueryString(params) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
searchParams.set(key, String(value));
}
}
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}
// ===========================================================================
// Health & Version
// ===========================================================================
async getVersion() {
// The /version endpoint returns plain text, not JSON
const url = `${this.baseUrl}/api/v1/version`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const version = await response.text();
return { version: version.trim() };
}
async validateConnection() {
try {
await this.getVersion();
}
catch (error) {
throw new Error(`Failed to connect to Coolify server: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// ===========================================================================
// Server endpoints
// ===========================================================================
async listServers(options) {
const query = this.buildQueryString({
page: options?.page,
per_page: options?.per_page,
});
const servers = await this.request(`/servers${query}`);
return options?.summary && Array.isArray(servers) ? servers.map(toServerSummary) : servers;
}
async getServer(uuid) {
return this.request(`/servers/${uuid}`);
}
async createServer(data) {
return this.request('/servers', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateServer(uuid, data) {
return this.request(`/servers/${uuid}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async deleteServer(uuid) {
return this.request(`/servers/${uuid}`, {
method: 'DELETE',
});
}
async getServerResources(uuid) {
return this.request(`/servers/${uuid}/resources`);
}
async getServerDomains(uuid) {
return this.request(`/servers/${uuid}/domains`);
}
async validateServer(uuid) {
return this.request(`/servers/${uuid}/validate`);
}
// ===========================================================================
// Project endpoints
// ===========================================================================
async listProjects(options) {
const query = this.buildQueryString({
page: options?.page,
per_page: options?.per_page,
});
const projects = await this.request(`/projects${query}`);
return options?.summary && Array.isArray(projects) ? projects.map(toProjectSummary) : projects;
}
async getProject(uuid) {
return this.request(`/projects/${uuid}`);
}
async createProject(data) {
return this.request('/projects', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateProject(uuid, data) {
return this.request(`/projects/${uuid}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async deleteProject(uuid) {
return this.request(`/projects/${uuid}`, {
method: 'DELETE',
});
}
// ===========================================================================
// Environment endpoints
// ===========================================================================
async listProjectEnvironments(projectUuid) {
return this.request(`/projects/${projectUuid}/environments`);
}
async getProjectEnvironment(projectUuid, environmentNameOrUuid) {
return this.request(`/projects/${projectUuid}/${environmentNameOrUuid}`);
}
/**
* Get environment with missing database types (dragonfly, keydb, clickhouse).
* Coolify API omits these from the environment endpoint - we cross-reference
* with listDatabases using lightweight summaries.
* @see https://github.com/StuMason/coolify-mcp/issues/88
*/
async getProjectEnvironmentWithDatabases(projectUuid, environmentNameOrUuid) {
const [environment, dbSummaries] = await Promise.all([
this.getProjectEnvironment(projectUuid, environmentNameOrUuid),
this.listDatabases({ summary: true }),
]);
// Filter for this environment's missing database types
// API uses environment_id, not environment_uuid
const envDbs = dbSummaries.filter((db) => db.environment_id === environment.id ||
db.environment_uuid === environment.uuid ||
db.environment_name === environment.name);
const dragonflys = envDbs.filter((db) => db.type?.includes('dragonfly'));
const keydbs = envDbs.filter((db) => db.type?.includes('keydb'));
const clickhouses = envDbs.filter((db) => db.type?.includes('clickhouse'));
return {
...environment,
...(dragonflys.length > 0 && { dragonflys }),
...(keydbs.length > 0 && { keydbs }),
...(clickhouses.length > 0 && { clickhouses }),
};
}
async createProjectEnvironment(projectUuid, data) {
return this.request(`/projects/${projectUuid}/environments`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async deleteProjectEnvironment(projectUuid, environmentNameOrUuid) {
return this.request(`/projects/${projectUuid}/environments/${environmentNameOrUuid}`, {
method: 'DELETE',
});
}
// ===========================================================================
// Application endpoints
// ===========================================================================
async listApplications(options) {
const query = this.buildQueryString({
page: options?.page,
per_page: options?.per_page,
});
const apps = await this.request(`/applications${query}`);
return options?.summary && Array.isArray(apps) ? apps.map(toApplicationSummary) : apps;
}
async getApplication(uuid) {
return this.request(`/applications/${uuid}`);
}
async createApplicationPublic(data) {
return this.request('/applications/public', {
method: 'POST',
body: JSON.stringify(data),
});
}
async createApplicationPrivateGH(data) {
return this.request('/applications/private-github-app', {
method: 'POST',
body: JSON.stringify(data),
});
}
async createApplicationPrivateKey(data) {
return this.request('/applications/private-deploy-key', {
method: 'POST',
body: JSON.stringify(data),
});
}
async createApplicationDockerfile(data) {
return this.request('/applications/dockerfile', {
method: 'POST',
body: JSON.stringify(data),
});
}
async createApplicationDockerImage(data) {
return this.request('/applications/dockerimage', {
method: 'POST',
body: JSON.stringify(data),
});
}
async createApplicationDockerCompose(data) {
const payload = { ...data };
if (payload.docker_compose_raw) {
payload.docker_compose_raw = toBase64(payload.docker_compose_raw);
}
return this.request('/applications/dockercompose', {
method: 'POST',
body: JSON.stringify(payload),
});
}
async updateApplication(uuid, data) {
const payload = { ...data };
if (payload.docker_compose_raw) {
payload.docker_compose_raw = toBase64(payload.docker_compose_raw);
}
return this.request(`/applications/${uuid}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
async deleteApplication(uuid, options) {
const query = this.buildQueryString({
delete_configurations: options?.deleteConfigurations,
delete_volumes: options?.deleteVolumes,
docker_cleanup: options?.dockerCleanup,
delete_connected_networks: options?.deleteConnectedNetworks,
});
return this.request(`/applications/${uuid}${query}`, {
method: 'DELETE',
});
}
async getApplicationLogs(uuid, lines = 100) {
return this.request(`/applications/${uuid}/logs?lines=${lines}`);
}
async startApplication(uuid, options) {
const query = this.buildQueryString({
force: options?.force,
instant_deploy: options?.instant_deploy,
});
return this.request(`/applications/${uuid}/start${query}`, {
method: 'POST',
});
}
async stopApplication(uuid) {
return this.request(`/applications/${uuid}/stop`, {
method: 'POST',
});
}
async restartApplication(uuid) {
return this.request(`/applications/${uuid}/restart`, {
method: 'POST',
});
}
// ===========================================================================
// Application Environment Variables
// ===========================================================================
async listApplicationEnvVars(uuid, options) {
const envVars = await this.request(`/applications/${uuid}/envs`);
return options?.summary ? envVars.map(toEnvVarSummary) : envVars;
}
async createApplicationEnvVar(uuid, data) {
return this.request(`/applications/${uuid}/envs`, {
method: 'POST',
body: JSON.stringify(cleanRequestData(data)),
});
}
async updateApplicationEnvVar(uuid, data) {
return this.request(`/applications/${uuid}/envs`, {
method: 'PATCH',
body: JSON.stringify(cleanRequestData(data)),
});
}
async bulkUpdateApplicationEnvVars(uuid, data) {
return this.request(`/applications/${uuid}/envs/bulk`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async deleteApplicationEnvVar(uuid, envUuid) {
return this.request(`/applications/${uuid}/envs/${envUuid}`, {
method: 'DELETE',
});
}
// ===========================================================================
// Database endpoints
// ===========================================================================
async listDatabases(options) {
const query = this.buildQueryString({
page: options?.page,
per_page: options?.per_page,
});
const dbs = await this.request(`/databases${query}`);
return options?.summary && Array.isArray(dbs) ? dbs.map(toDatabaseSummary) : dbs;
}
async getDatabase(uuid) {
return this.request(`/databases/${uuid}`);
}
async updateDatabase(uuid, data) {
return this.request(`/databases/${uuid}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async deleteDatabase(uuid, options) {
const query = this.buildQueryString({
delete_configurations: options?.deleteConfigurations,
delete_volumes: options?.deleteVolumes,
docker_cleanup: options?.dockerCleanup,
delete_connected_networks: options?.deleteConnectedNetworks,
});
return this.request(`/databases/${uuid}${query}`, {
method: 'DELETE',
});
}
async startDatabase(uuid) {
return this.request(`/databases/${uuid}/start`, {
method: 'POST',
});
}
async stopDatabase(uuid) {
return this.request(`/databases/${uuid}/stop`, {
method: 'POST',
});
}
async restartDatabase(uuid) {
return this.request(`/databases/${uuid}/restart`, {
method: 'POST',
});
}
// Database creation methods
async createPostgresql(data) {
return this.request('/databases/postgresql', {
method: 'POST',
body: JSON.stringify(data),
});
}
async createMysql(data) {
return this.request('/databases/mysql', {
method: 'POST',
body: JSON.stringify(data),
});
}
async createMariadb(data) {
return this.request('/databases/mariadb', {
method: 'POST',
body: JSON.stringify(data),
});
}
async createMongodb(data) {
return this.request('/databases/mongodb', {
method: 'POST',
body: JSON.stringify(data),
});
}
async createRedis(data) {
return this.request('/databases/redis', {
method: 'POST',
body: JSON.stringify(data),
});
}
async createKeydb(data) {
return this.request('/databases/keydb', {
method: 'POST',
body: JSON.stringify(data),
});
}
async createClickhouse(data) {
return this.request('/databases/clickhouse', {
method: 'POST',
body: JSON.stringify(data),
});
}
async createDragonfly(data) {
return this.request('/databases/dragonfly', {
method: 'POST',
body: JSON.stringify(data),
});
}
// ===========================================================================
// Service endpoints
// ===========================================================================
async listServices(options) {
const query = this.buildQueryString({
page: options?.page,
per_page: options?.per_page,
});
const services = await this.request(`/services${query}`);
return options?.summary && Array.isArray(services) ? services.map(toServiceSummary) : services;
}
async getService(uuid) {
return this.request(`/services/${uuid}`);
}
async createService(data) {
const payload = { ...data };
if (payload.docker_compose_raw) {
payload.docker_compose_raw = toBase64(payload.docker_compose_raw);
}
return this.request('/services', {
method: 'POST',
body: JSON.stringify(payload),
});
}
async updateService(uuid, data) {
const payload = { ...data };
if (payload.docker_compose_raw) {
payload.docker_compose_raw = toBase64(payload.docker_compose_raw);
}
return this.request(`/services/${uuid}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
async deleteService(uuid, options) {
const query = this.buildQueryString({
delete_configurations: options?.deleteConfigurations,
delete_volumes: options?.deleteVolumes,
docker_cleanup: options?.dockerCleanup,
delete_connected_networks: options?.deleteConnectedNetworks,
});
return this.request(`/services/${uuid}${query}`, {
method: 'DELETE',
});
}
async startService(uuid) {
return this.request(`/services/${uuid}/start`, {
method: 'GET',
});
}
async stopService(uuid) {
return this.request(`/services/${uuid}/stop`, {
method: 'GET',
});
}
async restartService(uuid) {
return this.request(`/services/${uuid}/restart`, {
method: 'GET',
});
}
// ===========================================================================
// Service Environment Variables
// ===========================================================================
async listServiceEnvVars(uuid) {
return this.request(`/services/${uuid}/envs`);
}
async createServiceEnvVar(uuid, data) {
return this.request(`/services/${uuid}/envs`, {
method: 'POST',
body: JSON.stringify(cleanRequestData(data)),
});
}
async updateServiceEnvVar(uuid, data) {
return this.request(`/services/${uuid}/envs`, {
method: 'PATCH',
body: JSON.stringify(cleanRequestData(data)),
});
}
async deleteServiceEnvVar(uuid, envUuid) {
return this.request(`/services/${uuid}/envs/${envUuid}`, {
method: 'DELETE',
});
}
// ===========================================================================
// Deployment endpoints
// ===========================================================================
async listDeployments(options) {
const query = this.buildQueryString({
page: options?.page,
per_page: options?.per_page,
});
const deployments = await this.request(`/deployments${query}`);
return options?.summary && Array.isArray(deployments)
? deployments.map(toDeploymentSummary)
: deployments;
}
async getDeployment(uuid, options) {
const deployment = await this.request(`/deployments/${uuid}`);
return options?.includeLogs ? deployment : toDeploymentEssential(deployment);
}
async deployByTagOrUuid(tagOrUuid, force = false) {
// Detect if the value looks like a UUID or a tag name
const param = this.isLikelyUuid(tagOrUuid) ? 'uuid' : 'tag';
return this.request(`/deploy?${param}=${encodeURIComponent(tagOrUuid)}&force=${force}`, { method: 'GET' });
}
async listApplicationDeployments(appUuid) {
return this.request(`/applications/${appUuid}/deployments`);
}
// ===========================================================================
// Team endpoints
// ===========================================================================
async listTeams() {
return this.request('/teams');
}
async getTeam(id) {
return this.request(`/teams/${id}`);
}
async getTeamMembers(id) {
return this.request(`/teams/${id}/members`);
}
async getCurrentTeam() {
return this.request('/teams/current');
}
async getCurrentTeamMembers() {
return this.request('/teams/current/members');
}
// ===========================================================================
// Private Key endpoints
// ===========================================================================
async listPrivateKeys() {
return this.request('/security/keys');
}
async getPrivateKey(uuid) {
return this.request(`/security/keys/${uuid}`);
}
async createPrivateKey(data) {
return this.request('/security/keys', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updatePrivateKey(uuid, data) {
return this.request(`/security/keys/${uuid}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async deletePrivateKey(uuid) {
return this.request(`/security/keys/${uuid}`, {
method: 'DELETE',
});
}
// ===========================================================================
// GitHub App endpoints
// ===========================================================================
async listGitHubApps(options) {
const apps = await this.request('/github-apps');
return options?.summary && Array.isArray(apps) ? apps.map(toGitHubAppSummary) : apps;
}
async createGitHubApp(data) {
return this.request('/github-apps', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateGitHubApp(id, data) {
return this.request(`/github-apps/${id}`, {
method: 'PATCH',
body: JSON.stringify(cleanRequestData(data)),
});
}
async deleteGitHubApp(id) {
return this.request(`/github-apps/${id}`, {
method: 'DELETE',
});
}
// ===========================================================================
// Cloud Token endpoints (Hetzner, DigitalOcean)
// ===========================================================================
async listCloudTokens() {
return this.request('/cloud-tokens');
}
async getCloudToken(uuid) {
return this.request(`/cloud-tokens/${uuid}`);
}
async createCloudToken(data) {
return this.request('/cloud-tokens', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateCloudToken(uuid, data) {
return this.request(`/cloud-tokens/${uuid}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async deleteCloudToken(uuid) {
return this.request(`/cloud-tokens/${uuid}`, {
method: 'DELETE',
});
}
async validateCloudToken(uuid) {
return this.request(`/cloud-tokens/${uuid}/validate`, { method: 'POST' });
}
// ===========================================================================
// Database Backup endpoints
// ===========================================================================
async listDatabaseBackups(databaseUuid) {
return this.request(`/databases/${databaseUuid}/backups`);
}
async getDatabaseBackup(databaseUuid, backupUuid) {
return this.request(`/databases/${databaseUuid}/backups/${backupUuid}`);
}
async listBackupExecutions(databaseUuid, backupUuid) {
return this.request(`/databases/${databaseUuid}/backups/${backupUuid}/executions`);
}
async getBackupExecution(databaseUuid, backupUuid, executionUuid) {
return this.request(`/databases/${databaseUuid}/backups/${backupUuid}/executions/${executionUuid}`);
}
async createDatabaseBackup(databaseUuid, data) {
return this.request(`/databases/${databaseUuid}/backups`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateDatabaseBackup(databaseUuid, backupUuid, data) {
return this.request(`/databases/${databaseUuid}/backups/${backupUuid}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async deleteDatabaseBackup(databaseUuid, backupUuid) {
return this.request(`/databases/${databaseUuid}/backups/${backupUuid}`, {
method: 'DELETE',
});
}
// ===========================================================================
// Deployment Control endpoints
// ===========================================================================
async cancelDeployment(uuid) {
return this.request(`/deployments/${uuid}/cancel`, {
method: 'POST',
});
}
// ===========================================================================
// Smart Lookup Helpers
// ===========================================================================
/**
* Check if a string looks like a UUID (Coolify format or standard format).
* Coolify UUIDs are alphanumeric strings, typically 24 chars like "xs0sgs4gog044s4k4c88kgsc"
* Also accepts standard UUID format with hyphens like "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
*/
isLikelyUuid(query) {
// Coolify UUID format: alphanumeric, 20+ chars
if (/^[a-z0-9]{20,}$/i.test(query)) {
return true;
}
// Standard UUID format with hyphens (8-4-4-4-12)
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(query)) {
return true;
}
return false;
}
/**
* Find an application by UUID, name, or domain (FQDN).
* Returns the UUID if found, throws if not found or multiple matches.
*/
async resolveApplicationUuid(query) {
// If it looks like a UUID, use it directly
if (this.isLikelyUuid(query)) {
return query;
}
// Otherwise, search by name or domain
const apps = (await this.listApplications());
const queryLower = query.toLowerCase();
const matches = apps.filter((app) => {
const nameMatch = app.name?.toLowerCase().includes(queryLower);
const fqdnMatch = app.fqdn?.toLowerCase().includes(queryLower);
return nameMatch || fqdnMatch;
});
if (matches.length === 0) {
throw new Error(`No application found matching "${query}"`);
}
if (matches.length > 1) {
const matchList = matches.map((a) => `${a.name} (${a.fqdn || 'no domain'})`).join(', ');
throw new Error(`Multiple applications match "${query}": ${matchList}. Please be more specific or use a UUID.`);
}
return matches[0].uuid;
}
/**
* Find a server by UUID, name, or IP address.
* Returns the UUID if found, throws if not found or multiple matches.
*/
async resolveServerUuid(query) {
// If it looks like a UUID, use it directly
if (this.isLikelyUuid(query)) {
return query;
}
// Otherwise, search by name or IP
const servers = (await this.listServers());
const queryLower = query.toLowerCase();
const matches = servers.filter((server) => {
const nameMatch = server.name?.toLowerCase().includes(queryLower);
const ipMatch = server.ip?.toLowerCase().includes(queryLower);
return nameMatch || ipMatch;
});
if (matches.length === 0) {
throw new Error(`No server found matching "${query}"`);
}
if (matches.length > 1) {
const matchList = matches.map((s) => `${s.name} (${s.ip})`).join(', ');
throw new Error(`Multiple servers match "${query}": ${matchList}. Please be more specific or use a UUID.`);
}
return matches[0].uuid;
}
// ===========================================================================
// Diagnostic endpoints (composite tools)
// ===========================================================================
/**
* Get comprehensive diagnostic info for an application.
* Aggregates: application details, logs, env vars, recent deployments.
* @param query - Application UUID, name, or domain (FQDN)
*/
async diagnoseApplication(query) {
// Resolve query to UUID
let uuid;
try {
uuid = await this.resolveApplicationUuid(query);
}
catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return {
application: null,
health: { status: 'unknown', issues: [] },
logs: null,
environment_variables: { count: 0, variables: [] },
recent_deployments: [],
errors: [msg],
};
}
const results = await Promise.allSettled([
this.getApplication(uuid),
this.getApplicationLogs(uuid, 50),
this.listApplicationEnvVars(uuid),
this.listApplicationDeployments(uuid),
]);
const errors = [];
const extract = (result, name) => {
if (result.status === 'fulfilled')
return result.value;
const msg = result.reason instanceof Error ? result.reason.message : String(result.reason);
errors.push(`${name}: ${msg}`);
return null;
};
const app = extract(results[0], 'application');
const logs = extract(results[1], 'logs');
const envVars = extract(results[2], 'environment_variables');
const deployments = extract(results[3], 'deployments');
// Determine health status and issues
const issues = [];
let healthStatus = 'unknown';
if (app) {
const status = app.status || '';
if (status.includes('running') && status.includes('healthy')) {
healthStatus = 'healthy';
}
else if (status.includes('exited') ||
status.includes('unhealthy') ||
status.includes('error')) {
healthStatus = 'unhealthy';
issues.push(`Status: ${status}`);
}
else if (status.includes('running')) {
healthStatus = 'healthy';
}
else {
issues.push(`Status: ${status}`);
}
}
// Check for failed deployments
if (deployments) {
const recentFailed = deployments.slice(0, 5).filter((d) => d.status === 'failed');
if (recentFailed.length > 0) {
issues.push(`${recentFailed.length} failed deployment(s) in last 5`);
if (healthStatus === 'healthy')
healthStatus = 'unhealthy';
}
}
return {
application: app
? {
uuid: app.uuid,
name: app.name,
status: app.status || 'unknown',
fqdn: app.fqdn || null,
git_repository: app.git_repository || null,
git_branch: app.git_branch || null,
}
: null,
health: {
status: healthStatus,
issues,
},
logs: typeof logs === 'string' ? logs : null,
environment_variables: {
count: envVars?.length || 0,
variables: (envVars || []).map((v) => ({
key: v.key,
is_build_time: v.is_build_time ?? false,
})),
},
recent_deployments: (deployments || []).slice(0, 5).map((d) => ({
uuid: d.uuid,
status: d.status,
created_at: d.created_at,
})),
...(errors.length > 0 && { errors }),
};
}
/**
* Get comprehensive diagnostic info for a server.
* Aggregates: server details, resources, domains, validation.
* @param query - Server UUID, name, or IP address
*/
async diagnoseServer(query) {
// Resolve query to UUID
let uuid;
try {
uuid = await this.resolveServerUuid(query);
}
catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return {
server: null,
health: { status: 'unknown', issues: [] },
resources: [],
domains: [],
validation: null,
errors: [msg],
};
}
const results = await Promise.allSettled([
this.getServer(uuid),
this.getServerResources(uuid),
this.getServerDomains(uuid),
this.validateServer(uuid),
]);
const errors = [];
const extract = (result, name) => {
if (result.status === 'fulfilled')
return result.value;
const msg = result.reason instanceof Error ? result.reason.message : String(result.reason);
errors.push(`${name}: ${msg}`);
return null;
};
const server = extract(results[0], 'server');
const resources = extract(results[1], 'resources');
const domains = extract(results[2], 'domains');
const validation = extract(results[3], 'validation');
// Determine health status and issues
const issues = [];
let healthStatus = 'unknown';
if (server) {
if (server.is_reachable === true) {
healthStatus = 'healthy';
}
else if (server.is_reachable === false) {
healthStatus = 'unhealthy';
issues.push('Server is not reachable');
}
if (server.is_usable === false) {
issues.push('Server is not usable');
healthStatus = 'unhealthy';
}
}
// Check for unhealthy resources
if (resources) {
const unhealthyResources = resources.filter((r) => r.status.includes('exited') ||
r.status.includes('unhealthy') ||
r.status.includes('error'));
if (unhealthyResources.length > 0) {
issues.push(`${unhealthyResources.length} unhealthy resource(s)`);
}
}
return {
server: server
? {
uuid: server.uuid,
name: server.name,
ip: server.ip,
status: server.status || null,
is_reachable: server.is_reachable ?? null,
}
: null,
health: {
status: healthStatus,
issues,
},
resources: (resources || []).map((r) => ({
uuid: r.uuid,
name: r.name,
type: r.type,
status: r.status,
})),
domains: (domains || []).map((d) => ({
ip: d.ip,
domains: d.domains,
})),
validation: validation
? {
message: validation.message,
...(validation.validation_logs && { validation_logs: validation.validation_logs }),
}
: null,
...(errors.length > 0 && { errors }),
};
}
/**
* Scan infrastructure for common issues.
* Finds: unreachable servers, unhealthy apps, exited databases, stopped services.
*/
async findInfrastructureIssues() {
const results = await Promise.allSettled([
this.listServers(),
this.listApplications(),
this.listDatabases(),
this.listServices(),
]);
const errors = [];
const issues = [];
const extract = (result, name) => {
if (result.status === 'fulfilled')
return result.value;
const msg = result.reason instanceof Error ? result.reason.message : String(result.reason);
errors.push(`${name}: ${msg}`);
return null;
};
const servers = extract(results[0], 'servers');
const applications = extract(results[1], 'applications');
const databases = extract(results[2], 'databases');
const services = extract(results[3], 'services');
// Check servers for unreachable
if (servers) {
for (const server of servers) {
if (server.is_reachable === false) {
issues.push({
type: 'server',
uuid: server.uuid,
name: server.name,
issue: 'Server is not reachable',
status: server.status || 'unreachable',
});
}
}
}
// Check applications for unhealthy status
if (applications) {
for (const app of applications) {
const status = app.status || '';
if (status.includes('exited') ||
status.includes('unhealthy') ||
status.includes('error') ||
status === 'stopped') {
issues.push({
type: 'application',
uuid: app.uuid,
name: app.name,
issue: `Application status: ${status}`,
status,
});
}
}
}
// Check databases for unhealthy status
if (databases) {
for (const db of databases) {
const status = db.status || '';
if (status.includes('exited') ||
status.includes('unhealthy') ||
status.includes('error') ||
status === 'stopped') {
issues.push({
type: 'database',
uuid: db.uuid,
name: db.name,
issue: `Database status: ${status}`,
status,
});
}
}
}
// Check services for unhealthy status
if (services) {
for (const svc of services) {
const status = svc.status || '';
if (status.includes('exited') ||
status.includes('unhealthy') ||
status.includes('error') ||
status === 'stopped') {
issues.push({
type: 'service',
uuid: svc.uuid,
name: svc.name,
issue: `Service status: ${status}`,
status,
});
}
}
}
return {
summary: {
total_issues: issues.length,
unhealthy_applications: issues.filter((i) => i.type === 'application').length,
unhealthy_databases: issues.filter((i) => i.type === 'database').length,
unhealthy_services: issues.filter((i) => i.type === 'service').length,
unreachable_servers: issues.filter((i) => i.type === 'server').length,
},
issues,
...(errors.length > 0 && { errors }),
};
}
// ===========================================================================
// Batch Operations
// ===========================================================================
/**
* Aggregate results from Promise.allSettled into a BatchOperationResult.
*/
aggregateBatchResults(resources, results) {
const succeeded = [];
const failed = [];
results.forEach((result, index) => {
const resource = resources[index];
const name = resource.name || resource.uuid;
if (result.status === 'fulfilled') {
succeeded.push({ uuid: resource.uuid, name });
}
else {
const error = result.reason instanceof Error ? result.reason.message : String(result.reason);
failed.push({ uuid: resource.uuid, name, error });
}
});
return {
summary: {
total: resources.length,
succeeded: succeeded.length,
failed: failed.length,
},
succeeded,
failed,
};
}
/**
* Restart all applications in a project.
* @param projectUuid - Project UUID
*/
async restartProjectApps(projectUuid) {
const allApps = (await this.listApplications());
const projectApps = allApps.filter((app) => app.project_uuid === projectUuid);
if (projectApps.length === 0) {
return {
summary: { total: 0, succeeded: 0, failed: 0 },
succeeded: [],
failed: [],
};
}
const results = await Promise.allSettled(projectApps.map((app) => this.restartApplication(app.uuid)));
return this.aggregateBatchResults(projectApps, results);
}
/**
* Update or create an environment variable across multiple applications.
* Uses upsert behavior: creates if not exists, updates if exists.
* @param appUuids - Array of application UUIDs
* @param key - Environment variable key
* @param value - Environment variable value
* @param isBuildTime - Whether this is a build-time variable (default: false)
*/
async bulkEnvUpdate(appUuids, key, value, isBuildTime = false) {
// Early return for empty array - avoid unnecessary API call
if (appUuids.length === 0) {
return {
summary: { total: 0, succeeded: 0, failed: 0 },
succeeded: [],
failed: [],
};
}
// Get app names first for better response
const allApps = (await this.listApplications());
const appMap = new Map(allApps.map((a) => [a.uuid, a.name || a.uuid]));
// Build the resource list with names
const resources = appUuids.map((uuid) => ({
uuid,
name: appMap.get(uuid) || uuid,
}));
const results = await Promise.allSettled(appUuids.map((uuid) => this.updateApplicationEnvVar(uuid, { key, value, is_build_time: isBuildTime })));
return this.aggregateBatchResults(resources, results);
}
/**
* Emergency stop all running applications across entire infrastructure.
*/
async stopAllApps() {
const allApps = (await this.listApplications());
// Only stop running apps
const runningApps = allApps.filter((app) => {
const status = app.status || '';
return status.includes('running') || status.includes('healthy');
});
if (runningApps.length === 0) {
return {
summary: { total: 0, succeeded: 0, failed: 0 },
succeeded: [],
failed: [],
};
}
const results = await Promise.allSettled(runningApps.map((app) => this.stopApplication(app.uuid)));
return this.aggregateBatchResults(runningApps, results);
}
/**
* Redeploy all applications in a project.
* @param projectUuid - Project UUID
* @param force - Force rebuild (default: true)
*/
async redeployProjectApps(projectUuid, force = true) {
const allApps = (await this.listApplications());
const projectApps = allApps.filter((app) => app.project_uuid === projectUuid);
if (projectApps.length === 0) {
return {
summary: { total: 0, succeeded: 0, failed: 0 },
succeeded: [],
failed: [],
};
}
const results = await Promise.allSettled(projectApps.map((app) => this.deployByTagOrUuid(app.uuid, force)));
return this.aggregateBatchResults(projectApps, results);
}
}