@dokploy/mcp
Advanced tools
| export const DEFAULT_REDACTED_FIELDS = [ | ||
| "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", | ||
| ]; | ||
| const REDACTED_PLACEHOLDER = "[REDACTED]"; | ||
| export function redactSensitive(data, fields) { | ||
| if (fields.length === 0) | ||
| return data; | ||
| const lowered = new Set(fields.map((f) => f.toLowerCase())); | ||
| return walk(data, lowered, new WeakSet()); | ||
| } | ||
| function isPlainObject(value) { | ||
| if (value === null || typeof value !== "object") | ||
| return false; | ||
| const proto = Object.getPrototypeOf(value); | ||
| return proto === Object.prototype || proto === null; | ||
| } | ||
| function walk(value, fields, seen) { | ||
| if (Array.isArray(value)) { | ||
| if (seen.has(value)) | ||
| return value; | ||
| seen.add(value); | ||
| return value.map((item) => walk(item, fields, seen)); | ||
| } | ||
| if (isPlainObject(value)) { | ||
| if (seen.has(value)) | ||
| return value; | ||
| seen.add(value); | ||
| const out = Object.create(null); | ||
| for (const [key, val] of Object.entries(value)) { | ||
| if (key === "__proto__" || key === "constructor" || key === "prototype") | ||
| continue; | ||
| if (fields.has(key.toLowerCase())) { | ||
| out[key] = val === null || val === undefined ? val : REDACTED_PLACEHOLDER; | ||
| } | ||
| else { | ||
| out[key] = walk(val, fields, seen); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
| return value; | ||
| } |
+6
-2
| import apiClient from "./utils/apiClient.js"; | ||
| import { getClientConfig } from "./utils/clientConfig.js"; | ||
| import { createLogger } from "./utils/logger.js"; | ||
| import { redactSensitive } from "./utils/redactSensitive.js"; | ||
| import { ResponseFormatter } from "./utils/responseFormatter.js"; | ||
@@ -7,8 +9,10 @@ const logger = createLogger("ToolHandler"); | ||
| return async (input) => { | ||
| const { redactEnv, redactFields } = getClientConfig(); | ||
| const redact = (value) => (redactEnv ? redactSensitive(value, redactFields) : value); | ||
| try { | ||
| logger.info(`Executing tool: ${tool.name}`, { input }); | ||
| logger.info(`Executing tool: ${tool.name}`, { input: redact(input) }); | ||
| const response = tool.method === "GET" | ||
| ? await apiClient.get(tool.path, { params: input }) | ||
| : await apiClient.post(tool.path, input); | ||
| return ResponseFormatter.success(`${tool.name} completed successfully`, response.data); | ||
| return ResponseFormatter.success(`${tool.name} completed successfully`, redact(response.data)); | ||
| } | ||
@@ -15,0 +19,0 @@ catch (error) { |
+44
-0
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | ||
| import { zodToJsonSchema } from "zod-to-json-schema"; | ||
| import { generatedTools } from "./generated/tools.js"; | ||
@@ -6,2 +8,3 @@ import { createHandler } from "./handler.js"; | ||
| const logger = createLogger("MCP-Server"); | ||
| const JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema"; | ||
| function getEnabledTools() { | ||
@@ -24,2 +27,34 @@ const enabledTags = process.env.DOKPLOY_ENABLED_TAGS; | ||
| } | ||
| function stripNestedSchemaKeys(value) { | ||
| if (value === null || typeof value !== "object") | ||
| return; | ||
| if (Array.isArray(value)) { | ||
| for (const item of value) | ||
| stripNestedSchemaKeys(item); | ||
| return; | ||
| } | ||
| const record = value; | ||
| for (const key of Object.keys(record)) { | ||
| if (key === "$schema") { | ||
| delete record[key]; | ||
| } | ||
| else { | ||
| stripNestedSchemaKeys(record[key]); | ||
| } | ||
| } | ||
| } | ||
| // Claude's API requires JSON Schema draft 2020-12. The MCP SDK's built-in | ||
| // Zod→JSON Schema converter emits draft-07 by default, which causes a 400 | ||
| // error on tools/list. We bypass the SDK's auto-generated handler by | ||
| // registering our own with pre-converted draft-2020-12 schemas. | ||
| // See https://github.com/Dokploy/mcp/issues/32 | ||
| function toDraft2020_12JsonSchema(schema) { | ||
| const result = zodToJsonSchema(schema, { | ||
| target: "jsonSchema2019-09", | ||
| strictUnions: true, | ||
| }); | ||
| stripNestedSchemaKeys(result); | ||
| result.$schema = JSON_SCHEMA_2020_12; | ||
| return result; | ||
| } | ||
| export function createServer() { | ||
@@ -34,3 +69,12 @@ const server = new McpServer({ | ||
| } | ||
| const toolList = tools.map((tool) => ({ | ||
| name: tool.name, | ||
| description: tool.description, | ||
| inputSchema: toDraft2020_12JsonSchema(tool.schema), | ||
| annotations: tool.annotations, | ||
| })); | ||
| server.server.setRequestHandler(ListToolsRequestSchema, async () => ({ | ||
| tools: toolList, | ||
| })); | ||
| return server; | ||
| } |
@@ -12,3 +12,4 @@ import axios from "axios"; | ||
| Accept: "application/json", | ||
| "x-api-key": config.authToken, // Use the same auth mechanism as httpClient | ||
| ...config.customHeaders, | ||
| "x-api-key": config.authToken, | ||
| }; | ||
@@ -15,0 +16,0 @@ // Create axios instance with configuration from clientConfig |
@@ -0,1 +1,32 @@ | ||
| import { DEFAULT_REDACTED_FIELDS } from "./redactSensitive.js"; | ||
| const RESERVED_CUSTOM_HEADER_NAMES = new Set(["x-api-key", "content-type", "accept"]); | ||
| export function parseCustomHeaders(rawHeaders) { | ||
| if (rawHeaders === undefined) { | ||
| return {}; | ||
| } | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(rawHeaders); | ||
| } | ||
| catch (error) { | ||
| throw new Error("Environment variable DOKPLOY_CUSTOM_HEADERS must be valid JSON containing an object of string header names to string values", { cause: error }); | ||
| } | ||
| if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { | ||
| throw new Error("Environment variable DOKPLOY_CUSTOM_HEADERS must be a JSON object of string header names to string values"); | ||
| } | ||
| const customHeaders = {}; | ||
| for (const [name, value] of Object.entries(parsed)) { | ||
| if (name.trim() === "") { | ||
| throw new Error("Environment variable DOKPLOY_CUSTOM_HEADERS contains an empty header name"); | ||
| } | ||
| if (RESERVED_CUSTOM_HEADER_NAMES.has(name.toLowerCase())) { | ||
| 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"); | ||
| } | ||
| if (typeof value !== "string") { | ||
| throw new Error("Environment variable DOKPLOY_CUSTOM_HEADERS must contain only string header values"); | ||
| } | ||
| customHeaders[name] = value; | ||
| } | ||
| return customHeaders; | ||
| } | ||
| class ConfigManager { | ||
@@ -26,8 +57,16 @@ static instance; | ||
| } | ||
| const redactEnv = parseBoolean(process.env.DOKPLOY_REDACT_ENV, false); | ||
| const parsedFields = process.env.DOKPLOY_REDACT_FIELDS?.split(",") | ||
| .map((f) => f.trim()) | ||
| .filter((f) => f.length > 0) ?? []; | ||
| const redactFields = parsedFields.length > 0 ? parsedFields : DEFAULT_REDACTED_FIELDS; | ||
| return { | ||
| dokployUrl, | ||
| authToken, | ||
| customHeaders: parseCustomHeaders(process.env.DOKPLOY_CUSTOM_HEADERS), | ||
| timeout: parseInt(process.env.DOKPLOY_TIMEOUT || "30000", 10), | ||
| retryAttempts: parseInt(process.env.DOKPLOY_RETRY_ATTEMPTS || "3", 10), | ||
| retryDelay: parseInt(process.env.DOKPLOY_RETRY_DELAY || "1000", 10), | ||
| redactEnv, | ||
| redactFields, | ||
| }; | ||
@@ -39,1 +78,11 @@ } | ||
| } | ||
| function parseBoolean(value, fallback) { | ||
| if (value === undefined) | ||
| return fallback; | ||
| const normalized = value.trim().toLowerCase(); | ||
| if (["true", "1", "yes", "on"].includes(normalized)) | ||
| return true; | ||
| if (["false", "0", "no", "off", ""].includes(normalized)) | ||
| return false; | ||
| return fallback; | ||
| } |
@@ -6,3 +6,3 @@ export class ResponseFormatter { | ||
| message, | ||
| ...(data && typeof data === "object" && data !== null ? { data } : {}), | ||
| ...(data !== undefined && data !== null ? { data } : {}), | ||
| }; | ||
@@ -9,0 +9,0 @@ return { |
+6
-4
| { | ||
| "name": "@dokploy/mcp", | ||
| "version": "0.29.2", | ||
| "version": "0.29.3", | ||
| "description": "MCP Server for Dokploy API", | ||
@@ -33,3 +33,4 @@ "main": "build/index.js", | ||
| "hono": "^4.12.12", | ||
| "zod": "^3.25.28" | ||
| "zod": "^3.25.28", | ||
| "zod-to-json-schema": "^3.25.2" | ||
| }, | ||
@@ -41,3 +42,4 @@ "devDependencies": { | ||
| "tsx": "^4.21.0", | ||
| "typescript": "^5.8.3" | ||
| "typescript": "^5.8.3", | ||
| "vitest": "^4.1.6" | ||
| }, | ||
@@ -62,4 +64,4 @@ "scripts": { | ||
| "precommit": "biome check && pnpm run type-check", | ||
| "test": "echo \"Error: no test spcified\" && exit 1" | ||
| "test": "vitest run" | ||
| } | ||
| } |
+9
-0
@@ -290,2 +290,3 @@ # Dokploy MCP Server | ||
| | `DOKPLOY_API_KEY` | Yes | Your Dokploy API authentication token | | ||
| | `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`. | | ||
| | `DOKPLOY_ENABLED_TAGS` | No | Comma-separated list of tags to filter which tools are loaded (e.g., `project,application,postgres`) | | ||
@@ -295,3 +296,11 @@ | `DOKPLOY_TIMEOUT` | No | Request timeout in milliseconds (default: `30000`) | | ||
| | `DOKPLOY_RETRY_DELAY` | No | Delay between retries in milliseconds (default: `1000`) | | ||
| | `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. | | ||
| | `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`. | | ||
| For Dokploy instances behind Cloudflare Access or a similar reverse proxy, pass service-token headers with placeholder values like this: | ||
| ```bash | ||
| DOKPLOY_CUSTOM_HEADERS='{"CF-Access-Client-Id":"your-client-id.access","CF-Access-Client-Secret":"your-client-secret"}' | ||
| ``` | ||
| ## Transport Modes | ||
@@ -298,0 +307,0 @@ |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
No tests
QualityPackage does not have any tests. This is a strong signal of a poorly maintained or low quality package.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
340519
2.35%14
7.69%7246
2.52%1
-50%515
1.78%6
20%6
20%12
33.33%+ Added