diff --git a/.dockerignore b/.dockerignore index 322ddd0..0bdd2ac 100644 --- a/.dockerignore +++ b/.dockerignore @@ -96,4 +96,3 @@ Makefile # Package locks (can be regenerated) package-lock.json yarn.lock -pnpm-lock.yaml diff --git a/.env.example b/.env.example index f24bb43..269b4f8 100644 --- a/.env.example +++ b/.env.example @@ -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 ` OR append `?token=` +# 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= diff --git a/Dockerfile b/Dockerfile index bb29163..5581648 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -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 diff --git a/README.md b/README.md index adf1224..f79621a 100644 --- a/README.md +++ b/README.md @@ -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) [Install in VS Code (npx)](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= +``` + +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=` — 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 diff --git a/docker-compose.yml b/docker-compose.yml index ae8cdd8..5a31ed5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 + # (or ?token= for URL-only clients like Cowork). + - MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN:-} restart: unless-stopped networks: - mcp-network diff --git a/src/http-server.ts b/src/http-server.ts index 0ab5e9b..ca5be0c 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -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 }, @@ -35,9 +39,17 @@ export async function main() { sse: {} as Record, }; - // 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; @@ -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.", + ); + } }); } diff --git a/src/utils/auth.test.ts b/src/utils/auth.test.ts new file mode 100644 index 0000000..ebcbdc0 --- /dev/null +++ b/src/utils/auth.test.ts @@ -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 => + 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); + }); + }); +}); diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..a9a7155 --- /dev/null +++ b/src/utils/auth.ts @@ -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 ` (preferred, RFC 6750-clean), or + * - `?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, + ); + }; +}