@dokploy/mcp 0.29.0 → 0.29.3

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.
package/README.md CHANGED
@@ -287,10 +287,19 @@ The configuration on Windows is slightly different compared to Linux or macOS. U
287
287
  |----------|----------|-------------|
288
288
  | `DOKPLOY_URL` | Yes | Your Dokploy server URL (e.g., `https://your-dokploy-server.com`) |
289
289
  | `DOKPLOY_API_KEY` | Yes | Your Dokploy API authentication token |
290
+ | `DOKPLOY_CUSTOM_HEADERS` | No | JSON object of additional upstream request headers. Header names and values must be strings. Reserved headers cannot be set here: `x-api-key`, `content-type`, `accept`. |
290
291
  | `DOKPLOY_ENABLED_TAGS` | No | Comma-separated list of tags to filter which tools are loaded (e.g., `project,application,postgres`) |
291
292
  | `DOKPLOY_TIMEOUT` | No | Request timeout in milliseconds (default: `30000`) |
292
293
  | `DOKPLOY_RETRY_ATTEMPTS` | No | Number of retry attempts (default: `3`) |
293
294
  | `DOKPLOY_RETRY_DELAY` | No | Delay between retries in milliseconds (default: `1000`) |
295
+ | `DOKPLOY_REDACT_ENV` | No | When `true`, redacts secret-bearing fields from API responses before they reach the MCP client (default: `false`). Useful when an LLM consumes responses and you don't want env vars or compose files in its context. |
296
+ | `DOKPLOY_REDACT_FIELDS` | No | Comma-separated list of response field names to redact when `DOKPLOY_REDACT_ENV=true`. Matched case-insensitively at any nesting depth. Defaults to: `env`, `buildArgs`, `composeFile`, `dockerCompose`, `environment`, `buildSecrets`, `previewBuildSecrets`, `password`, `currentPassword`, `appPassword`, `databasePassword`, `databaseRootPassword`, `redisPassword`, `mariadbPassword`, `mongoPassword`, `mysqlPassword`, `postgresPassword`, `registryPassword`, `token`, `accessToken`, `appToken`, `apiToken`, `botToken`, `refreshToken`, `secret`, `clientSecret`, `apiKey`, `secretAccessKey`, `accessKey`, `licenseKey`, `userKey`, `privateKey`, `privateKeyPass`, `encPrivateKey`, `encPrivateKeyPass`, `sshKey`, `sshPrivateKey`, `customGitSSHKey`, `dockerAuth`. |
297
+
298
+ For Dokploy instances behind Cloudflare Access or a similar reverse proxy, pass service-token headers with placeholder values like this:
299
+
300
+ ```bash
301
+ DOKPLOY_CUSTOM_HEADERS='{"CF-Access-Client-Id":"your-client-id.access","CF-Access-Client-Secret":"your-client-secret"}'
302
+ ```
294
303
 
295
304
  ## Transport Modes
296
305
 
@@ -1,5 +1,5 @@
1
1
  // AUTO-GENERATED FILE — DO NOT EDIT MANUALLY
2
- // Generated from openapi.json on 2026-04-17
2
+ // Generated from openapi.json on 2026-04-25
3
3
  // Run `pnpm generate` to regenerate
4
4
  import { z } from "zod";
5
5
  export const generatedTools = [
@@ -1269,12 +1269,48 @@ export const generatedTools = [
1269
1269
  tag: "docker",
1270
1270
  method: "POST",
1271
1271
  path: "/docker.restartContainer",
1272
- schema: z.object({ "containerId": z.string().regex(new RegExp("^[a-zA-Z0-9.\\-_]+$")).min(1) }),
1272
+ schema: z.object({ "containerId": z.string().regex(new RegExp("^[a-zA-Z0-9.\\-_]+$")).min(1), "serverId": z.string().optional() }),
1273
1273
  annotations: {
1274
1274
  title: "Docker RestartContainer",
1275
1275
  ...{ "idempotentHint": true, "openWorldHint": true },
1276
1276
  },
1277
1277
  },
1278
+ {
1279
+ name: "docker-startContainer",
1280
+ description: "POST /docker.startContainer",
1281
+ tag: "docker",
1282
+ method: "POST",
1283
+ path: "/docker.startContainer",
1284
+ schema: z.object({ "containerId": z.string().regex(new RegExp("^[a-zA-Z0-9.\\-_]+$")).min(1), "serverId": z.string().optional() }),
1285
+ annotations: {
1286
+ title: "Docker StartContainer",
1287
+ ...{ "idempotentHint": true, "openWorldHint": true },
1288
+ },
1289
+ },
1290
+ {
1291
+ name: "docker-stopContainer",
1292
+ description: "POST /docker.stopContainer",
1293
+ tag: "docker",
1294
+ method: "POST",
1295
+ path: "/docker.stopContainer",
1296
+ schema: z.object({ "containerId": z.string().regex(new RegExp("^[a-zA-Z0-9.\\-_]+$")).min(1), "serverId": z.string().optional() }),
1297
+ annotations: {
1298
+ title: "Docker StopContainer",
1299
+ ...{ "idempotentHint": true, "openWorldHint": true },
1300
+ },
1301
+ },
1302
+ {
1303
+ name: "docker-killContainer",
1304
+ description: "POST /docker.killContainer",
1305
+ tag: "docker",
1306
+ method: "POST",
1307
+ path: "/docker.killContainer",
1308
+ schema: z.object({ "containerId": z.string().regex(new RegExp("^[a-zA-Z0-9.\\-_]+$")).min(1), "serverId": z.string().optional() }),
1309
+ annotations: {
1310
+ title: "Docker KillContainer",
1311
+ ...{ "idempotentHint": true, "openWorldHint": true },
1312
+ },
1313
+ },
1278
1314
  {
1279
1315
  name: "docker-removeContainer",
1280
1316
  description: "POST /docker.removeContainer",
@@ -3411,6 +3447,18 @@ export const generatedTools = [
3411
3447
  ...{ "readOnlyHint": true, "idempotentHint": true, "openWorldHint": true },
3412
3448
  },
3413
3449
  },
3450
+ {
3451
+ name: "project-homeStats",
3452
+ description: "GET /project.homeStats",
3453
+ tag: "project",
3454
+ method: "GET",
3455
+ path: "/project.homeStats",
3456
+ schema: z.object({}),
3457
+ annotations: {
3458
+ title: "Project HomeStats",
3459
+ ...{ "readOnlyHint": true, "idempotentHint": true, "openWorldHint": true },
3460
+ },
3461
+ },
3414
3462
  {
3415
3463
  name: "project-search",
3416
3464
  description: "GET /project.search",
@@ -4803,6 +4851,18 @@ export const generatedTools = [
4803
4851
  ...{ "readOnlyHint": true, "idempotentHint": true, "openWorldHint": true },
4804
4852
  },
4805
4853
  },
4854
+ {
4855
+ name: "stripe-updateInvoiceNotifications",
4856
+ description: "POST /stripe.updateInvoiceNotifications",
4857
+ tag: "stripe",
4858
+ method: "POST",
4859
+ path: "/stripe.updateInvoiceNotifications",
4860
+ schema: z.object({ "enabled": z.boolean() }),
4861
+ annotations: {
4862
+ title: "Stripe UpdateInvoiceNotifications",
4863
+ ...{ "idempotentHint": true, "openWorldHint": true },
4864
+ },
4865
+ },
4806
4866
  {
4807
4867
  name: "stripe-getInvoices",
4808
4868
  description: "GET /stripe.getInvoices",
@@ -4965,7 +5025,7 @@ export const generatedTools = [
4965
5025
  tag: "user",
4966
5026
  method: "POST",
4967
5027
  path: "/user.update",
4968
- schema: z.object({ "id": z.string().min(1).optional(), "firstName": z.string().optional(), "lastName": z.string().optional(), "isRegistered": z.boolean().optional(), "expirationDate": z.string().optional(), "createdAt2": z.string().optional(), "createdAt": z.union([z.string(), z.null()]).optional(), "twoFactorEnabled": z.union([z.boolean(), z.null()]).optional(), "email": z.string().email().regex(new RegExp("^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$")).min(1).optional(), "emailVerified": z.boolean().optional(), "image": z.union([z.string(), z.null()]).optional(), "banned": z.union([z.boolean(), z.null()]).optional(), "banReason": z.union([z.string(), z.null()]).optional(), "banExpires": z.union([z.string(), z.null()]).optional(), "updatedAt": z.string().optional(), "enablePaidFeatures": z.boolean().optional(), "allowImpersonation": z.boolean().optional(), "enableEnterpriseFeatures": z.boolean().optional(), "licenseKey": z.union([z.string(), z.null()]).optional(), "stripeCustomerId": z.union([z.string(), z.null()]).optional(), "stripeSubscriptionId": z.union([z.string(), z.null()]).optional(), "serversQuantity": z.number().optional(), "password": z.string().optional(), "currentPassword": z.string().optional() }),
5028
+ schema: z.object({ "id": z.string().min(1).optional(), "firstName": z.string().optional(), "lastName": z.string().optional(), "isRegistered": z.boolean().optional(), "expirationDate": z.string().optional(), "createdAt2": z.string().optional(), "createdAt": z.union([z.string(), z.null()]).optional(), "twoFactorEnabled": z.union([z.boolean(), z.null()]).optional(), "email": z.string().email().regex(new RegExp("^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$")).min(1).optional(), "emailVerified": z.boolean().optional(), "image": z.union([z.string(), z.null()]).optional(), "banned": z.union([z.boolean(), z.null()]).optional(), "banReason": z.union([z.string(), z.null()]).optional(), "banExpires": z.union([z.string(), z.null()]).optional(), "updatedAt": z.string().optional(), "enablePaidFeatures": z.boolean().optional(), "allowImpersonation": z.boolean().optional(), "enableEnterpriseFeatures": z.boolean().optional(), "licenseKey": z.union([z.string(), z.null()]).optional(), "stripeCustomerId": z.union([z.string(), z.null()]).optional(), "stripeSubscriptionId": z.union([z.string(), z.null()]).optional(), "serversQuantity": z.number().optional(), "sendInvoiceNotifications": z.boolean().optional(), "password": z.string().optional(), "currentPassword": z.string().optional() }),
4969
5029
  annotations: {
4970
5030
  title: "User Update",
4971
5031
  ...{ "idempotentHint": true, "openWorldHint": true },
package/build/handler.js CHANGED
@@ -1,15 +1,19 @@
1
1
  import apiClient from "./utils/apiClient.js";
2
+ import { getClientConfig } from "./utils/clientConfig.js";
2
3
  import { createLogger } from "./utils/logger.js";
4
+ import { redactSensitive } from "./utils/redactSensitive.js";
3
5
  import { ResponseFormatter } from "./utils/responseFormatter.js";
4
6
  const logger = createLogger("ToolHandler");
5
7
  export function createHandler(tool) {
6
8
  return async (input) => {
9
+ const { redactEnv, redactFields } = getClientConfig();
10
+ const redact = (value) => (redactEnv ? redactSensitive(value, redactFields) : value);
7
11
  try {
8
- logger.info(`Executing tool: ${tool.name}`, { input });
12
+ logger.info(`Executing tool: ${tool.name}`, { input: redact(input) });
9
13
  const response = tool.method === "GET"
10
14
  ? await apiClient.get(tool.path, { params: input })
11
15
  : await apiClient.post(tool.path, input);
12
- return ResponseFormatter.success(`${tool.name} completed successfully`, response.data);
16
+ return ResponseFormatter.success(`${tool.name} completed successfully`, redact(response.data));
13
17
  }
14
18
  catch (error) {
15
19
  logger.error(`Tool execution failed: ${tool.name}`, {
package/build/server.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
3
+ import { zodToJsonSchema } from "zod-to-json-schema";
2
4
  import { generatedTools } from "./generated/tools.js";
3
5
  import { createHandler } from "./handler.js";
4
6
  import { createLogger } from "./utils/logger.js";
5
7
  const logger = createLogger("MCP-Server");
8
+ const JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema";
6
9
  function getEnabledTools() {
7
10
  const enabledTags = process.env.DOKPLOY_ENABLED_TAGS;
8
11
  if (!enabledTags) {
@@ -20,6 +23,38 @@ function getEnabledTools() {
20
23
  });
21
24
  return filtered;
22
25
  }
26
+ function stripNestedSchemaKeys(value) {
27
+ if (value === null || typeof value !== "object")
28
+ return;
29
+ if (Array.isArray(value)) {
30
+ for (const item of value)
31
+ stripNestedSchemaKeys(item);
32
+ return;
33
+ }
34
+ const record = value;
35
+ for (const key of Object.keys(record)) {
36
+ if (key === "$schema") {
37
+ delete record[key];
38
+ }
39
+ else {
40
+ stripNestedSchemaKeys(record[key]);
41
+ }
42
+ }
43
+ }
44
+ // Claude's API requires JSON Schema draft 2020-12. The MCP SDK's built-in
45
+ // Zod→JSON Schema converter emits draft-07 by default, which causes a 400
46
+ // error on tools/list. We bypass the SDK's auto-generated handler by
47
+ // registering our own with pre-converted draft-2020-12 schemas.
48
+ // See https://github.com/Dokploy/mcp/issues/32
49
+ function toDraft2020_12JsonSchema(schema) {
50
+ const result = zodToJsonSchema(schema, {
51
+ target: "jsonSchema2019-09",
52
+ strictUnions: true,
53
+ });
54
+ stripNestedSchemaKeys(result);
55
+ result.$schema = JSON_SCHEMA_2020_12;
56
+ return result;
57
+ }
23
58
  export function createServer() {
24
59
  const server = new McpServer({
25
60
  name: "dokploy",
@@ -29,5 +64,14 @@ export function createServer() {
29
64
  for (const tool of tools) {
30
65
  server.tool(tool.name, tool.description, tool.schema.shape, tool.annotations ?? {}, createHandler(tool));
31
66
  }
67
+ const toolList = tools.map((tool) => ({
68
+ name: tool.name,
69
+ description: tool.description,
70
+ inputSchema: toDraft2020_12JsonSchema(tool.schema),
71
+ annotations: tool.annotations,
72
+ }));
73
+ server.server.setRequestHandler(ListToolsRequestSchema, async () => ({
74
+ tools: toolList,
75
+ }));
32
76
  return server;
33
77
  }
@@ -9,7 +9,8 @@ const config = getClientConfig();
9
9
  const DEFAULT_HEADERS = {
10
10
  "Content-Type": "application/json",
11
11
  Accept: "application/json",
12
- "x-api-key": config.authToken, // Use the same auth mechanism as httpClient
12
+ ...config.customHeaders,
13
+ "x-api-key": config.authToken,
13
14
  };
14
15
  // Create axios instance with configuration from clientConfig
15
16
  // Ensure baseURL includes /api prefix for Dokploy API routes
@@ -1,3 +1,34 @@
1
+ import { DEFAULT_REDACTED_FIELDS } from "./redactSensitive.js";
2
+ const RESERVED_CUSTOM_HEADER_NAMES = new Set(["x-api-key", "content-type", "accept"]);
3
+ export function parseCustomHeaders(rawHeaders) {
4
+ if (rawHeaders === undefined) {
5
+ return {};
6
+ }
7
+ let parsed;
8
+ try {
9
+ parsed = JSON.parse(rawHeaders);
10
+ }
11
+ catch (error) {
12
+ throw new Error("Environment variable DOKPLOY_CUSTOM_HEADERS must be valid JSON containing an object of string header names to string values", { cause: error });
13
+ }
14
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
15
+ throw new Error("Environment variable DOKPLOY_CUSTOM_HEADERS must be a JSON object of string header names to string values");
16
+ }
17
+ const customHeaders = {};
18
+ for (const [name, value] of Object.entries(parsed)) {
19
+ if (name.trim() === "") {
20
+ throw new Error("Environment variable DOKPLOY_CUSTOM_HEADERS contains an empty header name");
21
+ }
22
+ if (RESERVED_CUSTOM_HEADER_NAMES.has(name.toLowerCase())) {
23
+ throw new Error("Environment variable DOKPLOY_CUSTOM_HEADERS cannot override reserved headers x-api-key, content-type, or accept; configure Dokploy authentication with DOKPLOY_API_KEY");
24
+ }
25
+ if (typeof value !== "string") {
26
+ throw new Error("Environment variable DOKPLOY_CUSTOM_HEADERS must contain only string header values");
27
+ }
28
+ customHeaders[name] = value;
29
+ }
30
+ return customHeaders;
31
+ }
1
32
  class ConfigManager {
2
33
  static instance;
3
34
  config = null;
@@ -23,15 +54,33 @@ class ConfigManager {
23
54
  if (!authToken) {
24
55
  throw new Error("Environment variable DOKPLOY_API_KEY is not defined");
25
56
  }
57
+ const redactEnv = parseBoolean(process.env.DOKPLOY_REDACT_ENV, false);
58
+ const parsedFields = process.env.DOKPLOY_REDACT_FIELDS?.split(",")
59
+ .map((f) => f.trim())
60
+ .filter((f) => f.length > 0) ?? [];
61
+ const redactFields = parsedFields.length > 0 ? parsedFields : DEFAULT_REDACTED_FIELDS;
26
62
  return {
27
63
  dokployUrl,
28
64
  authToken,
65
+ customHeaders: parseCustomHeaders(process.env.DOKPLOY_CUSTOM_HEADERS),
29
66
  timeout: parseInt(process.env.DOKPLOY_TIMEOUT || "30000", 10),
30
67
  retryAttempts: parseInt(process.env.DOKPLOY_RETRY_ATTEMPTS || "3", 10),
31
68
  retryDelay: parseInt(process.env.DOKPLOY_RETRY_DELAY || "1000", 10),
69
+ redactEnv,
70
+ redactFields,
32
71
  };
33
72
  }
34
73
  }
35
74
  export function getClientConfig() {
36
75
  return ConfigManager.getInstance().getConfig();
37
76
  }
77
+ function parseBoolean(value, fallback) {
78
+ if (value === undefined)
79
+ return fallback;
80
+ const normalized = value.trim().toLowerCase();
81
+ if (["true", "1", "yes", "on"].includes(normalized))
82
+ return true;
83
+ if (["false", "0", "no", "off", ""].includes(normalized))
84
+ return false;
85
+ return fallback;
86
+ }
@@ -0,0 +1,80 @@
1
+ export const DEFAULT_REDACTED_FIELDS = [
2
+ "env",
3
+ "buildArgs",
4
+ "composeFile",
5
+ "dockerCompose",
6
+ "environment",
7
+ "buildSecrets",
8
+ "previewBuildSecrets",
9
+ "password",
10
+ "currentPassword",
11
+ "appPassword",
12
+ "databasePassword",
13
+ "databaseRootPassword",
14
+ "redisPassword",
15
+ "mariadbPassword",
16
+ "mongoPassword",
17
+ "mysqlPassword",
18
+ "postgresPassword",
19
+ "registryPassword",
20
+ "token",
21
+ "accessToken",
22
+ "appToken",
23
+ "apiToken",
24
+ "botToken",
25
+ "refreshToken",
26
+ "secret",
27
+ "clientSecret",
28
+ "apiKey",
29
+ "secretAccessKey",
30
+ "accessKey",
31
+ "licenseKey",
32
+ "userKey",
33
+ "privateKey",
34
+ "privateKeyPass",
35
+ "encPrivateKey",
36
+ "encPrivateKeyPass",
37
+ "sshKey",
38
+ "sshPrivateKey",
39
+ "customGitSSHKey",
40
+ "dockerAuth",
41
+ ];
42
+ const REDACTED_PLACEHOLDER = "[REDACTED]";
43
+ export function redactSensitive(data, fields) {
44
+ if (fields.length === 0)
45
+ return data;
46
+ const lowered = new Set(fields.map((f) => f.toLowerCase()));
47
+ return walk(data, lowered, new WeakSet());
48
+ }
49
+ function isPlainObject(value) {
50
+ if (value === null || typeof value !== "object")
51
+ return false;
52
+ const proto = Object.getPrototypeOf(value);
53
+ return proto === Object.prototype || proto === null;
54
+ }
55
+ function walk(value, fields, seen) {
56
+ if (Array.isArray(value)) {
57
+ if (seen.has(value))
58
+ return value;
59
+ seen.add(value);
60
+ return value.map((item) => walk(item, fields, seen));
61
+ }
62
+ if (isPlainObject(value)) {
63
+ if (seen.has(value))
64
+ return value;
65
+ seen.add(value);
66
+ const out = Object.create(null);
67
+ for (const [key, val] of Object.entries(value)) {
68
+ if (key === "__proto__" || key === "constructor" || key === "prototype")
69
+ continue;
70
+ if (fields.has(key.toLowerCase())) {
71
+ out[key] = val === null || val === undefined ? val : REDACTED_PLACEHOLDER;
72
+ }
73
+ else {
74
+ out[key] = walk(val, fields, seen);
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+ return value;
80
+ }
@@ -3,7 +3,7 @@ export class ResponseFormatter {
3
3
  const responseData = {
4
4
  success: true,
5
5
  message,
6
- ...(data && typeof data === "object" && data !== null ? { data } : {}),
6
+ ...(data !== undefined && data !== null ? { data } : {}),
7
7
  };
8
8
  return {
9
9
  content: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dokploy/mcp",
3
- "version": "v0.29.0",
3
+ "version": "0.29.3",
4
4
  "description": "MCP Server for Dokploy API",
5
5
  "main": "build/index.js",
6
6
  "bin": {
@@ -30,14 +30,16 @@
30
30
  "@modelcontextprotocol/sdk": "^1.12.0",
31
31
  "axios": "^1.9.0",
32
32
  "hono": "^4.12.12",
33
- "zod": "^3.25.28"
33
+ "zod": "^3.25.28",
34
+ "zod-to-json-schema": "^3.25.2"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@biomejs/biome": "^2.4.10",
37
38
  "@types/node": "^22.15.21",
38
39
  "json-schema-to-zod": "^2.8.1",
39
40
  "tsx": "^4.21.0",
40
- "typescript": "^5.8.3"
41
+ "typescript": "^5.8.3",
42
+ "vitest": "^4.1.6"
41
43
  },
42
44
  "scripts": {
43
45
  "build": "pnpm run clean && tsc && chmod 755 build/index.js",
@@ -57,6 +59,6 @@
57
59
  "type-check": "tsc --noEmit",
58
60
  "clean": "rm -rf build",
59
61
  "precommit": "biome check && pnpm run type-check",
60
- "test": "echo \"Error: no test spcified\" && exit 1"
62
+ "test": "vitest run"
61
63
  }
62
64
  }