@jurislm/coolify-plugin 4.0.24 → 4.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coolify-plugin",
3
- "version": "4.0.24",
3
+ "version": "4.0.27",
4
4
  "description": "Fallback Codex metadata for local stdio Coolify tools.",
5
5
  "author": {
6
6
  "name": "JurisLM",
package/.mcp.json CHANGED
@@ -2,9 +2,9 @@
2
2
  "mcpServers": {
3
3
  "coolify": {
4
4
  "type": "stdio",
5
- "command": "bunx",
6
- "args": ["-y", "@jurislm/coolify-plugin@latest"],
7
- "env_vars": ["COOLIFY_BASE_URL", "COOLIFY_ACCESS_TOKEN"]
5
+ "command": "bun",
6
+ "args": ["x", "@jurislm/coolify-plugin@latest"],
7
+ "env_vars": ["COOLIFY_CLOUD_BASE_URL", "COOLIFY_CLOUD_ACCESS_TOKEN", "CURSOR_COOLIFY_BASE_URL", "CURSOR_COOLIFY_ACCESS_TOKEN", "COOLIFY_BASE_URL", "COOLIFY_ACCESS_TOKEN"]
8
8
  }
9
9
  }
10
10
  }
package/.mcp.json.example CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "mcpServers": {
3
3
  "coolify": {
4
- "command": "bunx",
5
- "args": ["-y", "@jurislm/coolify-plugin@latest"],
4
+ "command": "bun",
5
+ "args": ["x", "@jurislm/coolify-plugin@latest"],
6
6
  "env": {
7
7
  "COOLIFY_BASE_URL": "https://coolify.example",
8
8
  "COOLIFY_ACCESS_TOKEN": "<set-in-host-environment>"
package/README.md CHANGED
@@ -4,6 +4,8 @@ Portable Coolify MCP plugin. It exposes focused `coolify_*` tools generated from
4
4
 
5
5
  Official website: [JurisLM Coolify Plugin](https://jurislm.github.io/coolify-plugin/).
6
6
 
7
+ Follow the shared [JurisLM Plugin Architecture v1](https://github.com/jurislm/woodpecker-ci-plugin/blob/main/docs/plugin-architecture.md) for package, host configuration and acceptance boundaries.
8
+
7
9
  ## Scope
8
10
 
9
11
  This repository provides a local Codex and Cursor plugin. It runs a stdio MCP server against a user-configured Coolify instance. OpenAI public Plugin Directory submission is outside this scope; public HTTPS, OAuth, and listing requirements are not acceptance criteria for this local plugin.
@@ -25,16 +27,34 @@ bun run check
25
27
  bun dist/index.js
26
28
  ```
27
29
 
28
- `mcp.json` and `.mcp.json` use the same published-package `bunx` stdio registration as the Woodpecker CI plugin. `.mcp.json.example` contains placeholder environment values only. NPM package release is allowed.
30
+ `mcp.json`, `.mcp.json` and `plugins/cursor/mcp.json` launch the published package with `bun x` (not the separate `bunx` executable, which some package-manager shims break). When a connection variable is missing, empty, or an unsubstituted `${...}` placeholder, the server fills it from `~/.config/jurislm/plugin.env` (`KEY=value` or `export KEY=value` lines; override the path with `JURISLM_PLUGIN_ENV`); host values always take precedence and values are never logged. `.mcp.json.example` contains placeholder environment values only. NPM package release is allowed.
29
31
 
30
32
  For Codex repository marketplace installation, use the repository root and leave the sparse path empty. The supported marketplace manifest is `.agents/plugins/marketplace.json`; do not enter `plugins/codex`.
31
- The Codex MCP registration forwards `COOLIFY_BASE_URL` and `COOLIFY_ACCESS_TOKEN` from the Codex session environment. A desktop session may not inherit values exported in a terminal; start a new task after configuring the app environment, then verify a read-only Coolify tool before making changes.
33
+ The native Codex registration forwards `COOLIFY_CLOUD_BASE_URL`, `COOLIFY_CLOUD_ACCESS_TOKEN`, `CURSOR_COOLIFY_BASE_URL`, `CURSOR_COOLIFY_ACCESS_TOKEN`, `COOLIFY_BASE_URL` and `COOLIFY_ACCESS_TOKEN` from its owning environment. Portable `mcp.json` contains no host-specific fields or credential defaults.
32
34
 
33
35
  ```sh
34
36
  codex plugin marketplace add https://github.com/jurislm/coolify-plugin
35
37
  codex plugin add coolify-plugin@coolify-marketplace
36
38
  ```
37
39
 
40
+ ### Codex desktop on macOS
41
+
42
+ A Codex process launched from the Dock or Finder does not read zsh startup files. If your connection settings are exported by `.zshenv`, copy [`launchers/coolify-desktop.zsh`](launchers/coolify-desktop.zsh) to `~/.codex/bin/coolify-mcp.zsh`, then add this opt-in override to `~/.codex/config.toml`:
43
+
44
+ ```toml
45
+ [plugins."coolify-plugin@coolify-marketplace".mcp_servers.coolify]
46
+ enabled = false
47
+
48
+ [mcp_servers.coolify]
49
+ command = "/bin/zsh"
50
+ args = ["-f", "-c", 'source "$HOME/.codex/bin/coolify-mcp.zsh"']
51
+ startup_timeout_sec = 30
52
+ ```
53
+
54
+ The launcher checks startup-file syntax before explicitly sourcing `${ZDOTDIR:-$HOME}/.zshenv`, suppresses stdout and preserves stderr. Source status 0 or 1 is accepted because a final optional guard can return 1; higher statuses stop startup. It retains only the six supported connection variables, `HOME`, `PATH`, `TMPDIR` and `LANG`, fixes PATH to `$HOME/.bun/bin:/usr/bin:/bin` and executes the absolute `$HOME/.bun/bin/bun x` path. The `--require-config` option reuses `loadConfig` to require the selected complete URL/token pair before starting stdio; trim, placeholder handling and pair precedence remain the same. Ordinary portable initialization and tool discovery can still start without credentials.
55
+
56
+ Check `codex mcp get coolify`, restart Codex and open a fresh chat. Read `coolify_get_mcp_version` before connection checks and authenticated reads. Record the resolved package version, actual registration source, HTTP status and returned counts. Standalone stdio checks establish process behavior; fresh chat registration and provider reads require their own evidence.
57
+
38
58
  To check a connection, call `coolify_get_mcp_version`, then `coolify_check_connection`. The second tool reports the selected variable pair, whether a URL and credential are present, and the HTTP status of `/health` and `/version`. It never returns credential values. With a URL but no credential, it still checks public `/health` and leaves `/version` unattempted. A healthy `/health` with a 401 from `/version` means the instance is reachable but rejected the authenticated request; the response alone does not establish whether the credential expired, was revoked, or came from the wrong launcher.
39
59
 
40
60
  Check the active MCP registration separately. A standalone server named `coolify` can run the same package while the marketplace plugin's MCP is disabled. `coolify_get_mcp_version` identifies the package version, not which registration launched it. `coolify_get_infrastructure_overview` reports authentication failures and all-list failures as tool errors. When only some lists fail for other reasons, their counts are `null` and `complete` is `false`.
@@ -60,7 +80,9 @@ bun run api:check
60
80
  ## Checks
61
81
 
62
82
  ```sh
83
+ bun run check
63
84
  bun run api:check
85
+ bun run lint
64
86
  bun run typecheck
65
87
  bun test
66
88
  bun run build
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { CoolifyApiError, redactSensitive } from "./client.js";
3
+ import { errorMessage as safeErrorMessage } from "./errors.js";
3
4
  import { operations } from "./generated/operations.js";
4
5
  import { pluginVersion } from "./version.js";
5
6
  const outputSchema = z.object({ data: z.unknown(), status: z.number(), request: z.object({ method: z.string(), path: z.string() }) });
@@ -13,7 +14,6 @@ function rows(value) {
13
14
  function status(value) { return typeof value.status === "string" ? value.status : ""; }
14
15
  function running(value) { return /(^|:)running($|:)/iu.test(status(value)); }
15
16
  function unhealthy(value) { return /exited|unhealthy|error|stopped/iu.test(status(value)); }
16
- function errorMessage(value) { return value instanceof Error ? value.message : String(value); }
17
17
  function truncateLogs(logs, lineLimit = 200, charLimit = 50_000) {
18
18
  if (logs.split("\n").length <= lineLimit && logs.length <= charLimit)
19
19
  return logs;
@@ -28,6 +28,7 @@ function environmentSummary(value) {
28
28
  return rows(value).map((item) => ({ key: item.key, is_build_time: item.is_buildtime === true }));
29
29
  }
30
30
  export function registerCapabilities(server, client, config) {
31
+ const errorMessage = (error) => safeErrorMessage(error, [config.token]);
31
32
  const call = async (name, input = {}) => {
32
33
  const operation = operations.find((item) => item.name === name);
33
34
  if (!operation)
@@ -40,7 +41,14 @@ export function registerCapabilities(server, client, config) {
40
41
  return { structuredContent, content: [{ type: "text", text: JSON.stringify(structuredContent) }] };
41
42
  };
42
43
  const register = (name, description, inputSchema, annotations, handler) => {
43
- server.registerTool(name, { title: description, description, inputSchema, outputSchema, annotations }, async (input) => result(name, await handler(input)));
44
+ server.registerTool(name, { title: description, description, inputSchema, outputSchema, annotations }, async (input) => {
45
+ try {
46
+ return result(name, await handler(input));
47
+ }
48
+ catch (error) {
49
+ return { isError: true, content: [{ type: "text", text: errorMessage(error) }] };
50
+ }
51
+ });
44
52
  };
45
53
  const settledRows = async (names) => {
46
54
  const settled = await Promise.allSettled(names.map((name) => call(`coolify_list_${name}`)));
@@ -100,7 +108,7 @@ export function registerCapabilities(server, client, config) {
100
108
  const environmentIds = new Set(rows(environments).map((environment) => environment.id));
101
109
  return rows(apps).filter((app) => environmentIds.has(app.environment_id));
102
110
  };
103
- register("coolify_get_mcp_version", "Get the local Coolify plugin version.", z.object({}), { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async () => ({ name: "@jurislm/coolify-plugin", version: pluginVersion }));
111
+ register("coolify_get_mcp_version", "Get the local Coolify plugin version.", z.object({}), { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, () => Promise.resolve({ name: "@jurislm/coolify-plugin", version: pluginVersion }));
104
112
  register("coolify_check_connection", "Check selected configuration and Coolify API status without exposing credentials.", z.object({}), { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async () => {
105
113
  const probe = async (name) => {
106
114
  const operation = operations.find((item) => item.name === name);
@@ -126,7 +134,7 @@ export function registerCapabilities(server, client, config) {
126
134
  return { application: null, health: { status: "unknown", issues: [] }, logs: null, environment_variables: { count: 0, variables: [] }, recent_deployments: [], errors: [errorMessage(error)] };
127
135
  }
128
136
  if (!app?.uuid)
129
- return { application: null, health: { status: "unknown", issues: [] }, logs: null, environment_variables: { count: 0, variables: [] }, recent_deployments: [], errors: [`No application found matching ${query}`] };
137
+ return { application: null, health: { status: "unknown", issues: [] }, logs: null, environment_variables: { count: 0, variables: [] }, recent_deployments: [], errors: [`No application found matching ${String(query)}`] };
130
138
  const names = ["application", "logs", "environment_variables", "deployments"];
131
139
  const settled = await Promise.allSettled([
132
140
  call("coolify_get_application_by_uuid", { uuid: app.uuid }), running(app) ? call("coolify_get_application_logs_by_uuid", { uuid: app.uuid, lines: 50 }) : Promise.resolve(null), call("coolify_list_envs_by_application_uuid", { uuid: app.uuid }), call("coolify_list_deployments_by_app_uuid", { uuid: app.uuid }),
@@ -166,7 +174,7 @@ export function registerCapabilities(server, client, config) {
166
174
  return { server: null, health: { status: "unknown", issues: [] }, resources: [], domains: [], errors: [errorMessage(error)] };
167
175
  }
168
176
  if (!found?.uuid)
169
- return { server: null, health: { status: "unknown", issues: [] }, resources: [], domains: [], errors: [`No server found matching ${query}`] };
177
+ return { server: null, health: { status: "unknown", issues: [] }, resources: [], domains: [], errors: [`No server found matching ${String(query)}`] };
170
178
  const names = ["server", "resources", "domains"];
171
179
  const settled = await Promise.allSettled([
172
180
  call("coolify_get_server_by_uuid", { uuid: found.uuid }), call("coolify_get_resources_by_server_uuid", { uuid: found.uuid }), call("coolify_get_domains_by_server_uuid", { uuid: found.uuid }),
@@ -198,7 +206,7 @@ export function registerCapabilities(server, client, config) {
198
206
  register("coolify_restart_project_applications", "Restart every application in a project.", z.object({ project_uuid: z.string() }), { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ project_uuid }) => batch(await projectApplications(String(project_uuid)), (app) => call("coolify_restart_application_by_uuid", { uuid: app.uuid })));
199
207
  register("coolify_bulk_update_application_env", "Update an environment variable across applications.", z.object({ app_uuids: z.array(z.string()), key: z.string(), value: z.string() }).strict(), { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ app_uuids, key, value }) => {
200
208
  if (!app_uuids.length)
201
- return batch([], async () => undefined);
209
+ return batch([], () => Promise.resolve(undefined));
202
210
  const names = rows(await call("coolify_list_applications"));
203
211
  const labels = new Map(names.map((item) => [item.uuid, item.name ?? item.uuid]));
204
212
  return batch(app_uuids.map((uuid) => ({ uuid, name: labels.get(uuid) ?? uuid })), (app) => call("coolify_update_env_by_application_uuid", { uuid: app.uuid, body: { key, value } }));
@@ -214,7 +222,7 @@ export function registerCapabilities(server, client, config) {
214
222
  return { environment: null, missing_database_types: [...databaseTypes], ...(errors.length ? { errors } : {}) };
215
223
  const matching = databases.filter((database) => environment.id !== undefined && database.environment_id === environment.id);
216
224
  const byType = new Map();
217
- matching.forEach((database) => { const type = String(database.database_type ?? "").toLowerCase(); for (const expected of databaseTypes)
225
+ matching.forEach((database) => { const type = typeof database.database_type === "string" ? database.database_type.toLowerCase() : ""; for (const expected of databaseTypes)
218
226
  if (type.includes(expected))
219
227
  byType.set(expected, [...(byType.get(expected) ?? []), database]); });
220
228
  const missing = databaseTypes.filter((type) => !byType.has(type));
@@ -223,6 +231,6 @@ export function registerCapabilities(server, client, config) {
223
231
  register("coolify_docker_network_alias", "Generate Docker network alias remediation commands.", z.object({ server_uuid: z.string().regex(/^[a-zA-Z0-9-]{1,64}$/u), db_uuid: z.string().regex(/^[a-zA-Z0-9-]{1,64}$/u), name: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$/u), network: z.string().optional() }), { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ server_uuid, db_uuid, name, network = "coolify" }) => {
224
232
  const server = await call("coolify_get_server_by_uuid", { uuid: server_uuid }).catch(() => null);
225
233
  const quote = (value) => `'${value.replace(/'/gu, "'\\''")}'`;
226
- return { bug: "Coolify database containers may require a friendly Docker network alias.", warning: "The alias is lost after a rebuild or redeploy.", ...(server ? { server: { ip: server.ip, user: server.user, port: server.port } } : { server_lookup_failed: "Could not retrieve server details" }), commands: { ssh_connect: server ? `ssh -p ${server.port} ${server.user}@${server.ip}` : "ssh -p <port> <user>@<server-ip>", add_alias: [`docker network disconnect ${quote(String(network))} ${quote(String(db_uuid))}`, `docker network connect ${quote(String(network))} ${quote(String(db_uuid))} --alias ${quote(String(name))} --alias ${quote(String(db_uuid))}`], verify: `docker exec <any-app-container> getent hosts ${quote(String(name))}` }, next_actions: [{ tool: "coolify_get_database_by_uuid", args: { uuid: db_uuid }, hint: "Check database status" }] };
234
+ return { bug: "Coolify database containers may require a friendly Docker network alias.", warning: "The alias is lost after a rebuild or redeploy.", ...(server ? { server: { ip: server.ip, user: server.user, port: server.port } } : { server_lookup_failed: "Could not retrieve server details" }), commands: { ssh_connect: server ? `ssh -p ${String(server.port)} ${String(server.user)}@${String(server.ip)}` : "ssh -p <port> <user>@<server-ip>", add_alias: [`docker network disconnect ${quote(String(network))} ${quote(String(db_uuid))}`, `docker network connect ${quote(String(network))} ${quote(String(db_uuid))} --alias ${quote(String(name))} --alias ${quote(String(db_uuid))}`], verify: `docker exec <any-app-container> getent hosts ${quote(String(name))}` }, next_actions: [{ tool: "coolify_get_database_by_uuid", args: { uuid: db_uuid }, hint: "Check database status" }] };
227
235
  });
228
236
  }
package/dist/client.js CHANGED
@@ -1,28 +1,30 @@
1
- import { CoolifyApiError } from "./errors.js";
1
+ import { CoolifyApiError, redactErrorText } from "./errors.js";
2
2
  const sensitiveKey = /(^value$|real_?value|private_?key|api_?key|user_?key|webhook_?url|token|secret|password|authorization|cookie)/iu;
3
3
  export function redactSensitive(value) {
4
4
  if (Array.isArray(value))
5
5
  return value.map(redactSensitive);
6
6
  if (!value || typeof value !== "object")
7
7
  return value;
8
+ const binary = value.encoding === "base64" && typeof value.contentType === "string" && typeof value.value === "string";
8
9
  return Object.fromEntries(Object.entries(value).map(([key, child]) => [
9
10
  key,
10
- sensitiveKey.test(key) ? "[REDACTED]" : redactSensitive(child),
11
+ sensitiveKey.test(key) && !(binary && key === "value") ? "[REDACTED]" : redactSensitive(child),
11
12
  ]));
12
13
  }
13
14
  function pathValue(value, name) {
14
15
  if (value === undefined || value === null)
15
16
  throw new Error(`Missing required path parameter: ${name}`);
16
- return encodeURIComponent(String(value));
17
+ return encodeURIComponent(stringifyScalar(value));
17
18
  }
19
+ function stringifyScalar(value) { return String(value); }
18
20
  function appendQuery(url, name, value) {
19
21
  if (value === undefined || value === null)
20
22
  return;
21
23
  if (Array.isArray(value))
22
24
  for (const item of value)
23
- url.searchParams.append(name, String(item));
25
+ url.searchParams.append(name, stringifyScalar(item));
24
26
  else
25
- url.searchParams.set(name, typeof value === "object" ? JSON.stringify(value) : String(value));
27
+ url.searchParams.set(name, typeof value === "object" ? JSON.stringify(value) : stringifyScalar(value));
26
28
  }
27
29
  function base64(bytes) {
28
30
  let text = "";
@@ -72,7 +74,7 @@ export class CoolifyClient {
72
74
  }
73
75
  catch (error) {
74
76
  const cause = error instanceof Error ? error.message : "request error";
75
- throw new CoolifyApiError(0, operation.method, path, `Coolify request failed for ${operation.method} ${path}: ${token ? cause.replaceAll(token, "[REDACTED]") : cause}`);
77
+ throw new CoolifyApiError(0, operation.method, path, `Coolify request failed for ${operation.method} ${path}: ${redactErrorText(cause, [token])}`);
76
78
  }
77
79
  if (!response.ok) {
78
80
  let fields = "";
@@ -84,19 +86,26 @@ export class CoolifyClient {
84
86
  fields = Object.keys(errors).filter((key) => /^[a-z][a-z0-9_.-]{0,63}$/iu.test(key)).slice(0, 8).map((key) => token ? key.replaceAll(token, "[REDACTED]") : key).join(", ");
85
87
  }
86
88
  }
87
- catch { }
89
+ catch {
90
+ fields = "";
91
+ }
88
92
  }
89
93
  throw new CoolifyApiError(response.status, operation.method, path, `Coolify API returned ${response.status} for ${operation.method} ${path}${fields ? `; fields: ${fields}` : ""}`);
90
94
  }
91
95
  let data = null;
92
- if (response.status !== 204) {
93
- const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
94
- if (contentType.includes("json"))
95
- data = await response.json();
96
- else if (contentType.startsWith("text/") || contentType.includes("xml"))
97
- data = await response.text();
98
- else
99
- data = { encoding: "base64", contentType: contentType || "application/octet-stream", value: base64(await response.arrayBuffer()) };
96
+ try {
97
+ if (response.status !== 204) {
98
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
99
+ if (contentType.includes("json"))
100
+ data = await response.json();
101
+ else if (contentType.startsWith("text/") || contentType.includes("xml"))
102
+ data = await response.text();
103
+ else
104
+ data = { encoding: "base64", contentType: contentType || "application/octet-stream", value: base64(await response.arrayBuffer()) };
105
+ }
106
+ }
107
+ catch {
108
+ throw new Error("Coolify API returned an unreadable response body");
100
109
  }
101
110
  return redactSensitive({ data, status: response.status, request: { method: operation.method, path } });
102
111
  }
package/dist/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export declare const CREDENTIAL_GROUPS: readonly [readonly ["COOLIFY_CLOUD_BASE_URL", "COOLIFY_CLOUD_ACCESS_TOKEN"], readonly ["CURSOR_COOLIFY_BASE_URL", "CURSOR_COOLIFY_ACCESS_TOKEN"], readonly ["COOLIFY_BASE_URL", "COOLIFY_ACCESS_TOKEN"]];
1
2
  export interface CoolifyConfig {
2
3
  baseUrl?: string;
3
4
  token?: string;
package/dist/config.js CHANGED
@@ -1,3 +1,8 @@
1
+ export const CREDENTIAL_GROUPS = [
2
+ ["COOLIFY_CLOUD_BASE_URL", "COOLIFY_CLOUD_ACCESS_TOKEN"],
3
+ ["CURSOR_COOLIFY_BASE_URL", "CURSOR_COOLIFY_ACCESS_TOKEN"],
4
+ ["COOLIFY_BASE_URL", "COOLIFY_ACCESS_TOKEN"],
5
+ ];
1
6
  export class ConfigError extends Error {
2
7
  constructor(message) {
3
8
  super(message);
package/dist/errors.d.ts CHANGED
@@ -4,3 +4,18 @@ export declare class CoolifyApiError extends Error {
4
4
  readonly path: string;
5
5
  constructor(status: number, method: string, path: string, message: string);
6
6
  }
7
+ export declare function redactErrorText(message: string, tokens?: Array<string | undefined>): string;
8
+ export declare function errorMessage(error: unknown, tokens?: Array<string | undefined>): string;
9
+ export declare function formatToolError(error: unknown, tokens: Array<string | undefined>): {
10
+ code: string;
11
+ status: number;
12
+ method: string;
13
+ path: string;
14
+ message: string;
15
+ } | {
16
+ code: string;
17
+ message: string;
18
+ status?: undefined;
19
+ method?: undefined;
20
+ path?: undefined;
21
+ };
package/dist/errors.js CHANGED
@@ -10,3 +10,18 @@ export class CoolifyApiError extends Error {
10
10
  this.name = "CoolifyApiError";
11
11
  }
12
12
  }
13
+ export function redactErrorText(message, tokens = []) {
14
+ let redacted = message;
15
+ for (const token of tokens)
16
+ if (token)
17
+ redacted = redacted.replaceAll(token, "[REDACTED]");
18
+ return redacted.replace(/Bearer\s+[^\s,;]+/giu, "Bearer [REDACTED]");
19
+ }
20
+ export function errorMessage(error, tokens = []) {
21
+ return redactErrorText(error instanceof Error ? error.message : String(error), tokens);
22
+ }
23
+ export function formatToolError(error, tokens) {
24
+ return error instanceof CoolifyApiError
25
+ ? { code: "COOLIFY_API_ERROR", status: error.status, method: error.method, path: error.path, message: errorMessage(error, tokens) }
26
+ : { code: "COOLIFY_TOOL_ERROR", message: errorMessage(error, tokens) };
27
+ }
package/dist/index.js CHANGED
@@ -1,9 +1,13 @@
1
1
  #!/usr/bin/env bun
2
- import { loadConfig } from "./config.js";
2
+ import { CREDENTIAL_GROUPS, ConfigError, loadConfig } from "./config.js";
3
+ import { withPluginEnv } from "./plugin-env.js";
3
4
  import { createServer } from "./server.js";
4
5
  import { createStdioTransport } from "./transports/stdio.js";
5
6
  try {
6
- await createServer(loadConfig()).connect(createStdioTransport());
7
+ const config = loadConfig(withPluginEnv(process.env, CREDENTIAL_GROUPS));
8
+ if (process.argv.includes("--require-config") && (!config.baseUrl || !config.token))
9
+ throw new ConfigError("A complete URL/token pair is required for the selected Coolify configuration");
10
+ await createServer(config).connect(createStdioTransport());
7
11
  }
8
12
  catch (error) {
9
13
  console.error(`Coolify MCP server failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -0,0 +1,6 @@
1
+ export type Environment = Record<string, string | undefined>;
2
+ export type CredentialGroups = readonly (readonly string[])[];
3
+ export declare const PLUGIN_ENV_PATH_VARIABLE = "JURISLM_PLUGIN_ENV";
4
+ export declare function parsePluginEnv(text: string): Record<string, string>;
5
+ export declare function pluginEnvPath(env: Environment): string;
6
+ export declare function withPluginEnv(env: Environment, groups: CredentialGroups, readText?: (path: string) => string, warn?: (message: string) => void): Environment;
@@ -0,0 +1,66 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ export const PLUGIN_ENV_PATH_VARIABLE = "JURISLM_PLUGIN_ENV";
5
+ const PLACEHOLDER = /^\$\{[^}]*\}$/u;
6
+ const ASSIGNMENT = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/u;
7
+ function isUnset(value) {
8
+ const trimmed = value?.trim();
9
+ return !trimmed || PLACEHOLDER.test(trimmed);
10
+ }
11
+ export function parsePluginEnv(text) {
12
+ const values = {};
13
+ for (const rawLine of text.split(/\r?\n/u)) {
14
+ const match = ASSIGNMENT.exec(rawLine.trim());
15
+ if (!match)
16
+ continue;
17
+ const [, name, rawValue] = match;
18
+ const quote = rawValue[0];
19
+ if (quote === '"' || quote === "'") {
20
+ const end = rawValue.indexOf(quote, 1);
21
+ if (end > 0)
22
+ values[name] = rawValue.slice(1, end);
23
+ }
24
+ else {
25
+ values[name] = rawValue.replace(/\s+#.*$/u, "").trim();
26
+ }
27
+ }
28
+ return values;
29
+ }
30
+ export function pluginEnvPath(env) {
31
+ const home = env.HOME?.trim() || homedir();
32
+ const override = env[PLUGIN_ENV_PATH_VARIABLE];
33
+ if (!override || isUnset(override))
34
+ return join(home, ".config", "jurislm", "plugin.env");
35
+ const path = override.trim();
36
+ return resolve(path === "~" || path.startsWith("~/") ? join(home, path.slice(1)) : path);
37
+ }
38
+ function readPluginEnv(path, readText, warn) {
39
+ try {
40
+ return parsePluginEnv(readText(path));
41
+ }
42
+ catch (error) {
43
+ const code = error.code;
44
+ if (code !== "ENOENT")
45
+ warn(`Could not read plugin env file ${path} (${code ?? "unknown error"})`);
46
+ return {};
47
+ }
48
+ }
49
+ export function withPluginEnv(env, groups, readText = (path) => readFileSync(path, "utf8"), warn = (message) => console.error(message)) {
50
+ if (groups.every((group) => group.every((name) => !isUnset(env[name]))))
51
+ return env;
52
+ const fileValues = groups.some((group) => group.every((name) => isUnset(env[name]))) ? readPluginEnv(pluginEnvPath(env), readText, warn) : {};
53
+ const resolved = { ...env };
54
+ for (const group of groups) {
55
+ const fillFromFile = group.every((name) => isUnset(env[name]));
56
+ for (const name of group) {
57
+ if (!isUnset(env[name]))
58
+ continue;
59
+ if (fillFromFile && !isUnset(fileValues[name]))
60
+ resolved[name] = fileValues[name];
61
+ else
62
+ delete resolved[name];
63
+ }
64
+ }
65
+ return resolved;
66
+ }
package/dist/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { CoolifyApiError, CoolifyClient, redactSensitive } from "./client.js";
3
+ import { CoolifyClient, redactSensitive } from "./client.js";
4
+ import { formatToolError } from "./errors.js";
4
5
  import { registerCapabilities } from "./capabilities.js";
5
6
  import { operations } from "./generated/operations.js";
6
7
  import { pluginVersion } from "./version.js";
@@ -23,9 +24,7 @@ export function createServer(config, fetchImpl) {
23
24
  return { structuredContent, content: [{ type: "text", text: JSON.stringify(structuredContent) }] };
24
25
  }
25
26
  catch (error) {
26
- const details = error instanceof CoolifyApiError
27
- ? { code: "COOLIFY_API_ERROR", status: error.status, method: error.method, path: error.path, message: error.message }
28
- : { code: "COOLIFY_TOOL_ERROR", message: error instanceof Error ? error.message : String(error) };
27
+ const details = formatToolError(error, [config.token]);
29
28
  return { isError: true, content: [{ type: "text", text: JSON.stringify({ error: details }) }] };
30
29
  }
31
30
  });
@@ -0,0 +1,23 @@
1
+ #!/bin/zsh -f
2
+ startup_file="${ZDOTDIR:-$HOME}/.zshenv"
3
+ if [[ -r $startup_file ]]; then
4
+ /bin/zsh -f -n "$startup_file" >/dev/null || exit $?
5
+ startup_status=0
6
+ source "$startup_file" >/dev/null || startup_status=$?
7
+ if (( startup_status > 1 )); then
8
+ exit "$startup_status"
9
+ fi
10
+ fi
11
+ zmodload zsh/parameter
12
+ export PATH="$HOME/.bun/bin:/usr/bin:/bin"
13
+ for name in ${(k)parameters}; do
14
+ case "$name" in
15
+ HOME|PATH|TMPDIR|LANG|COOLIFY_CLOUD_BASE_URL|COOLIFY_CLOUD_ACCESS_TOKEN|CURSOR_COOLIFY_BASE_URL|CURSOR_COOLIFY_ACCESS_TOKEN|COOLIFY_BASE_URL|COOLIFY_ACCESS_TOKEN) ;;
16
+ *)
17
+ if [[ ${parameters[$name]} == *export* ]]; then
18
+ unset "$name"
19
+ fi
20
+ ;;
21
+ esac
22
+ done
23
+ exec "$HOME/.bun/bin/bun" x @jurislm/coolify-plugin@latest --require-config
package/mcp.json CHANGED
@@ -3,8 +3,8 @@
3
3
  "mcpServers": {
4
4
  "coolify": {
5
5
  "type": "stdio",
6
- "command": "bunx",
7
- "args": ["-y", "@jurislm/coolify-plugin@latest"]
6
+ "command": "bun",
7
+ "args": ["x", "@jurislm/coolify-plugin@latest"]
8
8
  }
9
9
  }
10
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jurislm/coolify-plugin",
3
- "version": "4.0.24",
3
+ "version": "4.0.27",
4
4
  "description": "Portable local stdio MCP plugin for Coolify",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "files": [
13
13
  "dist",
14
+ "launchers",
14
15
  "api",
15
16
  "openapi",
16
17
  "assets",
@@ -31,6 +32,7 @@
31
32
  "clean": "rm -rf dist",
32
33
  "build": "bun run clean && tsc",
33
34
  "start": "bun dist/index.js",
35
+ "lint": "bun ./node_modules/eslint/bin/eslint.js src scripts --max-warnings=0",
34
36
  "typecheck": "tsc --noEmit",
35
37
  "typecheck:tests": "tsc --noEmit -p tsconfig.test.json",
36
38
  "test": "bun test --path-ignore-patterns dist",
@@ -40,7 +42,7 @@
40
42
  "manifest:check": "bun scripts/validate-plugin-manifests.ts",
41
43
  "package:check": "bun pm pack --dry-run 2>&1 | bun scripts/package-contents-check.ts",
42
44
  "release:test": "bun scripts/ci/test-release-pr-auto-merge.ts && bun scripts/ci/test-release-workflows.ts",
43
- "check": "bun run manifest:check && bun run api:check && bun run typecheck && bun run typecheck:tests && bun run build && bun test && bun run release:test && bun pm pack --dry-run 2>&1 | bun scripts/package-contents-check.ts",
45
+ "check": "bun run manifest:check && bun run api:check && bun run lint && bun run typecheck && bun run typecheck:tests && bun run build && bun test && bun run release:test && bun pm pack --dry-run 2>&1 | bun scripts/package-contents-check.ts",
44
46
  "prepublishOnly": "bun run check"
45
47
  },
46
48
  "dependencies": {
@@ -48,10 +50,14 @@
48
50
  "zod": "4.6.5"
49
51
  },
50
52
  "devDependencies": {
53
+ "@eslint/js": "10.0.1",
51
54
  "@types/bun": "1.4.2",
55
+ "ajv": "8.20.0",
56
+ "eslint": "10.11.0",
52
57
  "json-schema-to-zod": "2.8.1",
53
58
  "openapi-typescript": "7.13.0",
54
59
  "typescript": "5.9.3",
60
+ "typescript-eslint": "8.70.1",
55
61
  "yaml": "2.9.1"
56
62
  },
57
63
  "packageManager": "bun@1.3.14"
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "coolify-plugin",
4
- "version": "4.0.24",
4
+ "version": "4.0.27",
5
5
  "description": "Portable Coolify MCP plugin with local stdio tools generated from the official OpenAPI contract.",
6
6
  "author": {
7
7
  "name": "JurisLM",