Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,3 @@ Makefile
# Package locks (can be regenerated)
package-lock.json
yarn.lock
pnpm-lock.yaml
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,11 @@ MCP_TRANSPORT=http

# External port mapping (container always uses 3000 internally)
EXTERNAL_PORT=3000

# Optional shared-secret gate on /mcp, /sse, /messages. When set, callers
# must send `Authorization: Bearer <this-value>` OR append `?token=<value>`
# to the URL (the query-string form exists for MCP client UIs like Cowork's
# "Add custom connector" dialog, which only accept a URL). When unset, the
# endpoints are open — fine for local dev, NOT fine for a public URL.
# Generate one with: openssl rand -hex 32
# MCP_AUTH_TOKEN=
8 changes: 4 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ FROM node:lts-alpine AS builder
WORKDIR /app

# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate

# Copy package and configuration
COPY package.json pnpm-lock.yaml tsconfig.json ./
COPY package.json pnpm-lock.yaml tsconfig.json .npmrc ./

# Copy source code
COPY src ./src
Expand All @@ -19,13 +19,13 @@ FROM node:lts-alpine
WORKDIR /app

# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate

# Copy built artifacts
COPY --from=builder /app/build ./build

# Copy package.json and lockfile for production install
COPY package.json pnpm-lock.yaml ./
COPY package.json pnpm-lock.yaml .npmrc ./

# Install only production dependencies
RUN pnpm install --prod --frozen-lockfile --ignore-scripts
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,51 @@
# Dokploy MCP Server

> **Fork note.** This is a fork of [Dokploy/mcp](https://github.com/Dokploy/mcp) that adds an opt-in shared-secret gate on the HTTP transport so the server can be safely exposed to Claude / Cowork over the public internet. See [HTTP endpoint authentication](#http-endpoint-authentication-fork-addition) for details. Everything else is unchanged from upstream.

[![npm version](https://img.shields.io/npm/v/@dokploy/mcp.svg)](https://www.npmjs.com/package/@dokploy/mcp) [<img alt="Install in VS Code (npx)" src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Dokploy%20MCP&color=0098FF">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%7B%22name%22%3A%22dokploy-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40dokploy%2Fmcp%40latest%22%5D%7D)

Dokploy MCP Server exposes **all Dokploy API endpoints** as tools consumable via the Model Context Protocol (MCP). It allows MCP-compatible clients (e.g., AI models, other applications) to interact with your Dokploy server programmatically.

## HTTP endpoint authentication (fork addition)

The upstream project ships the HTTP/SSE transports with **no authentication** on `/mcp`, `/sse`, or `/messages`. That's fine when they're bound to `localhost`, but exposing them publicly (Cowork, Claude Desktop's URL-based custom connectors, remote CLI) means anyone who can reach the URL can call every tool against your Dokploy instance.

This fork adds an optional shared-secret gate. Set the env var:

```bash
MCP_AUTH_TOKEN=$(openssl rand -hex 32)
```

Callers then present the token in one of two ways:

**Header (preferred, RFC 6750-clean)** — works with the Claude CLI and anything else that lets you attach headers:

```bash
claude mcp add --transport http dokploy https://mcp.yourdomain.tld/mcp \
--header "Authorization: Bearer $MCP_AUTH_TOKEN"
```

**Query string** — works with UIs that only take a URL (Cowork's "Add custom connector" dialog, etc.):

```
https://mcp.yourdomain.tld/mcp?token=<your-MCP_AUTH_TOKEN>
```

Both paths use the same timing-safe comparison. Requests presenting neither (or a wrong value) get `401 Unauthorized` with a `WWW-Authenticate: Bearer` header. The `/health` endpoint is intentionally left open for container orchestrators.

If `MCP_AUTH_TOKEN` is unset, behavior matches upstream (endpoints open) and the process logs a warning at startup.

**Why offer the query-string form at all?** RFC 6750 discourages tokens in URLs because they can leak via server access logs, referer headers, or browser history. None of those apply here: this URL is only ever POST'd by an MCP client (never navigated in a browser), and this process doesn't log request URLs. Prefer the header when your client supports it.

For Cowork's "Add custom connector" dialog:

- **Name:** anything (e.g. `Dokploy`)
- **URL:** `https://mcp.yourdomain.tld/mcp?token=<your-MCP_AUTH_TOKEN>` — note the `/mcp` path *and* the `?token=` query param
- Leave OAuth Client ID / Client Secret blank — this server uses a static shared secret, not OAuth 2.0.

---


With **508 tools** across **49 categories**, this server provides complete coverage of the Dokploy API — from project and application management to databases, notifications, SSO, Docker, backups, and more.

## Getting Started
Expand Down
16 changes: 14 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,24 @@ services:
dokploy-mcp-http:
build: .
container_name: dokploy-mcp-http
ports:
- "${EXTERNAL_PORT:-3000}:3000"
# No host port mapping: when this compose is deployed behind Dokploy /
# Traefik / any other reverse proxy, the proxy reaches the container on
# its internal port over the shared Docker network — publishing to the
# host causes port collisions (Dokploy itself binds :3000) without
# adding anything. If you want to run this compose standalone without
# a proxy, uncomment the block below.
# ports:
# - "${EXTERNAL_PORT:-3000}:3000"
expose:
- "3000"
environment:
- MCP_TRANSPORT=${MCP_TRANSPORT:-http}
- DOKPLOY_URL=${DOKPLOY_URL:-https://your-dokploy-server.com}
- DOKPLOY_API_KEY=${DOKPLOY_API_KEY:-your_token_here}
# Optional shared-secret gate on /mcp, /sse, /messages. Leave unset
# for open access, or set to require Authorization: Bearer <token>
# (or ?token=<value> for URL-only clients like Cowork).
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN:-}
restart: unless-stopped
networks:
- mcp-network
Expand Down
21 changes: 20 additions & 1 deletion src/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { Hono } from "hono";
import { createServer } from "./server.js";
import { makeAuthMiddleware } from "./utils/auth.js";
import { createLogger } from "./utils/logger.js";

const PORT = 3000;
const logger = createLogger("MCP-HTTP-Server");

const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN;
const requireAuth = makeAuthMiddleware(MCP_AUTH_TOKEN);

const jsonrpcError = (code: number, message: string) => ({
jsonrpc: "2.0" as const,
error: { code, message },
Expand All @@ -35,9 +39,17 @@ export async function main() {
sse: {} as Record<string, SSEServerTransport>,
};

// Health check
// Health check — intentionally left open so orchestrators (Docker,
// Traefik, k8s) can probe without a token.
app.get("/health", (c) => c.json({ status: "ok", timestamp: new Date().toISOString() }));

// Gate every MCP transport endpoint behind the optional shared-secret
// check. `/health` above is deliberately registered before this line so
// probes stay open.
app.use("/mcp", requireAuth);
app.use("/sse", requireAuth);
app.use("/messages", requireAuth);

// Modern Streamable HTTP - POST
app.post("/mcp", async (c) => {
const { incoming, outgoing } = c.env;
Expand Down Expand Up @@ -193,7 +205,14 @@ export async function main() {
legacy: `http://localhost:${PORT}/sse`,
health: `http://localhost:${PORT}/health`,
},
auth: MCP_AUTH_TOKEN ? "bearer token required" : "open (MCP_AUTH_TOKEN unset)",
});
if (!MCP_AUTH_TOKEN) {
logger.warn(
"MCP_AUTH_TOKEN is not set — MCP endpoints are open to anyone who can reach this port. " +
"Set MCP_AUTH_TOKEN or restrict access at the proxy layer before exposing publicly.",
);
}
});
}

Expand Down
126 changes: 126 additions & 0 deletions src/utils/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { Hono } from "hono";
import { describe, expect, it } from "vitest";
import { makeAuthMiddleware } from "./auth.js";

/**
* Builds a tiny Hono app that runs the middleware in front of a fixed
* `200 ok` handler, so tests can drive it with real `fetch()` calls
* instead of hand-mocking a Context.
*/
function appWithAuth(token: string | undefined) {
const app = new Hono();
app.use("/mcp", makeAuthMiddleware(token));
app.post("/mcp", (c) => c.text("ok"));
return app;
}

const post = async (app: Hono, path: string, init: RequestInit = {}): Promise<Response> =>
app.request(path, { method: "POST", ...init });

describe("makeAuthMiddleware", () => {
describe("when token is undefined (upstream default)", () => {
it("passes through without any header or query param", async () => {
const app = appWithAuth(undefined);
const res = await post(app, "/mcp");
expect(res.status).toBe(200);
});

it("passes through with an arbitrary Authorization header (ignored)", async () => {
const app = appWithAuth(undefined);
const res = await post(app, "/mcp", {
headers: { authorization: "Bearer anything" },
});
expect(res.status).toBe(200);
});
});

describe("when token is empty string", () => {
it("treats it as unset (does not become an accept-anyone-with-empty-token trap)", async () => {
const app = appWithAuth("");
const res = await post(app, "/mcp");
expect(res.status).toBe(200);
});
});

describe("when token is set", () => {
const token = "s3cret-value";

it("rejects a request with no credentials", async () => {
const app = appWithAuth(token);
const res = await post(app, "/mcp");
expect(res.status).toBe(401);
expect(res.headers.get("www-authenticate")).toBe('Bearer realm="dokploy-mcp"');
const body = await res.json();
expect(body).toEqual({
jsonrpc: "2.0",
error: { code: -32001, message: "Unauthorized" },
id: null,
});
});

it("accepts the correct Authorization: Bearer header", async () => {
const app = appWithAuth(token);
const res = await post(app, "/mcp", {
headers: { authorization: `Bearer ${token}` },
});
expect(res.status).toBe(200);
expect(await res.text()).toBe("ok");
});

it("rejects a wrong-value Authorization: Bearer header", async () => {
const app = appWithAuth(token);
const res = await post(app, "/mcp", {
headers: { authorization: "Bearer wrong-value-same-length!!" },
});
expect(res.status).toBe(401);
});

it("rejects an Authorization header that isn't a Bearer scheme", async () => {
const app = appWithAuth(token);
const res = await post(app, "/mcp", {
headers: { authorization: `Basic ${Buffer.from(`user:${token}`).toString("base64")}` },
});
expect(res.status).toBe(401);
});

it("accepts the correct ?token= query param", async () => {
const app = appWithAuth(token);
const res = await post(app, `/mcp?token=${encodeURIComponent(token)}`);
expect(res.status).toBe(200);
expect(await res.text()).toBe("ok");
});

it("rejects a wrong-value ?token= query param", async () => {
const app = appWithAuth(token);
const res = await post(app, "/mcp?token=nope");
expect(res.status).toBe(401);
});

it("rejects a token of the wrong length (guards the timing-safe compare)", async () => {
const app = appWithAuth(token);
const res = await post(app, "/mcp?token=short");
expect(res.status).toBe(401);
});

it("prefers the header when both header and query are present and both valid", async () => {
// Not a behavioral guarantee we need to document — just checks that
// presenting both doesn't somehow break by double-consuming or
// short-circuiting to a rejection.
const app = appWithAuth(token);
const res = await post(app, `/mcp?token=${encodeURIComponent(token)}`, {
headers: { authorization: `Bearer ${token}` },
});
expect(res.status).toBe(200);
});

it("still rejects if the header is valid but the query is wrong (no fall-through)", async () => {
// Regression guard: valid header must accept the request even if
// the query has garbage.
const app = appWithAuth(token);
const res = await post(app, "/mcp?token=garbage", {
headers: { authorization: `Bearer ${token}` },
});
expect(res.status).toBe(200);
});
});
});
60 changes: 60 additions & 0 deletions src/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { timingSafeEqual } from "node:crypto";
import type { Context, MiddlewareHandler, Next } from "hono";

/**
* Optional shared-secret gate for the HTTP/SSE transports. When `token`
* is a non-empty string, the returned middleware requires callers to
* present the token as *either*:
* - `Authorization: Bearer <token>` (preferred, RFC 6750-clean), or
* - `?token=<token>` query param
*
* The query-param path exists so this server works with MCP client UIs
* that only accept a URL (Cowork's "Add custom connector" dialog, and
* similar). The token is only visible to whoever already holds the URL,
* and this process doesn't log request URLs — so the leakage vectors
* RFC 6750 warns about (access logs, referer headers, browser history)
* don't apply to this deployment shape. Prefer the header form when the
* client supports it.
*
* When `token` is undefined or empty, the middleware is a no-op — this
* preserves upstream Dokploy/mcp behavior so existing deployments aren't
* locked out on upgrade.
*
* Comparisons use `timingSafeEqual` so a byte-by-byte early return
* can't leak the token via response-time differences.
*/
export function makeAuthMiddleware(token: string | undefined): MiddlewareHandler {
if (!token) {
return async (_c: Context, next: Next) => next();
}

const expected = Buffer.from(token, "utf8");

const matches = (presented: string): boolean => {
const buf = Buffer.from(presented, "utf8");
if (buf.length !== expected.length) return false;
return timingSafeEqual(buf, expected);
};

return async (c: Context, next: Next) => {
const header = c.req.header("authorization");
if (typeof header === "string" && header.startsWith("Bearer ")) {
if (matches(header.slice("Bearer ".length))) return next();
}

const queryToken = c.req.query("token");
if (typeof queryToken === "string" && matches(queryToken)) {
return next();
}

c.header("WWW-Authenticate", 'Bearer realm="dokploy-mcp"');
return c.json(
{
jsonrpc: "2.0" as const,
error: { code: -32001, message: "Unauthorized" },
id: null,
},
401,
);
};
}