From 3c109b3923ca7ed2336156fc12ea0a1a92f9b84a Mon Sep 17 00:00:00 2001 From: smartinellibenedetti <139791797+smartinellibenedetti@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:29:02 -0600 Subject: [PATCH 1/5] Support docker --env-file; bake release version into image User-Agent - config: normalize env values (trim, CR, one pair of surrounding quotes, empty -> unset, trailing slash on URLs) since `docker run --env-file` passes quotes/CRLF through verbatim; add tests - smoke test: add --env-file case (quoted URL + CRLF) - docs: document --env-file usage, recommended ~/.rundeck-mcp/.env location, format rules, and Runlayer setup; update .env.example, skills, CLAUDE.md - gitignore: ignore .env.* except .env.example - Dockerfile/CircleCI: pass RUNDECK_MCP_VERSION build-arg so the image's User-Agent carries the release version Co-Authored-By: Claude Sonnet 5.5 --- .circleci/config.yml | 31 +++++++++- .../skills/rundeck-mcp-docker-build/SKILL.md | 4 ++ .../skills/rundeck-mcp-docker-setup/SKILL.md | 2 + .env.example | 22 ++++++- .gitignore | 2 + CLAUDE.md | 2 +- Dockerfile | 5 ++ README.md | 2 + SETUP.md | 40 ++++++++++++ TECHNICAL-CAPABILITIES.md | 2 + ci/docker-smoke-test.sh | 24 ++++++- src/__tests__/config.test.ts | 54 ++++++++++++++++ src/config.ts | 62 +++++++++++++++---- 13 files changed, 234 insertions(+), 18 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f55abf7..98e6e9c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -74,6 +74,12 @@ jobs: # contain shell metacharacters like $() or backticks, and CIRCLE_TAG is # interpolated into a shell command below, so an unvalidated tag would be # a command-injection vector. + # + # This only reaches the npm package (npm-publish reuses this job's + # dist/ via workspace attach). The docker-build job does its own + # fresh checkout and compiles src/ itself, so it bakes the same + # version independently via the Dockerfile's RUNDECK_MCP_VERSION + # build-arg — see that job's "Compute release version" step. name: Bake release version into User-Agent and server.json command: | if [ -n "${CIRCLE_TAG:-}" ]; then @@ -122,6 +128,24 @@ jobs: - run: name: Docker login command: echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin + - run: + # Same version this repo's User-Agent header gets baked with (see + # the Dockerfile's RUNDECK_MCP_VERSION build-arg) — this job does a + # fresh checkout and builds the image straight from source, so it + # can't rely on the "build" job's in-container sed of + # src/tools/api.ts (that patch never leaves that job's workspace). + # Persisted via BASH_ENV so later steps in this job see it. + name: Compute release version for User-Agent bake + command: | + if [ -n "${CIRCLE_TAG:-}" ]; then + if ! echo "${CIRCLE_TAG}" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "CIRCLE_TAG '${CIRCLE_TAG}' does not match expected vX.Y.Z pattern — failing." >&2 + exit 1 + fi + echo "export RUNDECK_MCP_VERSION=${CIRCLE_TAG#v}" >> "$BASH_ENV" + else + echo "export RUNDECK_MCP_VERSION=SNAPSHOT" >> "$BASH_ENV" + fi - run: name: Set up QEMU + Buildx command: | @@ -139,6 +163,7 @@ jobs: name: Build local image for smoke test (with Cloudsmith) command: | docker build --no-cache --secret id=cloudsmith_token,env=CLOUDSMITH_NPM_TOKEN \ + --build-arg RUNDECK_MCP_VERSION="${RUNDECK_MCP_VERSION}" \ -t rundeck-mcp:smoke-test . - run: name: Run Docker smoke tests (with Cloudsmith) @@ -147,7 +172,9 @@ jobs: # Validates the Dockerfile's OSS/external-contributor fallback path # (no CLOUDSMITH_NPM_TOKEN) actually works, not just that it exists. name: Build local image for smoke test (OSS fallback, no Cloudsmith) - command: docker build --no-cache -t rundeck-mcp:smoke-test-oss . + command: | + docker build --no-cache --build-arg RUNDECK_MCP_VERSION="${RUNDECK_MCP_VERSION}" \ + -t rundeck-mcp:smoke-test-oss . - run: name: Run Docker smoke tests (OSS fallback) command: sh ci/docker-smoke-test.sh rundeck-mcp:smoke-test-oss @@ -160,6 +187,7 @@ jobs: command: | docker buildx build --no-cache --platform linux/arm64 --load \ --secret id=cloudsmith_token,env=CLOUDSMITH_NPM_TOKEN \ + --build-arg RUNDECK_MCP_VERSION="${RUNDECK_MCP_VERSION}" \ -t rundeck-mcp:smoke-test-arm64 . - run: name: Run Docker smoke tests (arm64) @@ -193,6 +221,7 @@ jobs: # actually gets pushed) could silently reuse that wrong layer. docker buildx build --no-cache --platform linux/amd64,linux/arm64 --push \ --secret id=cloudsmith_token,env=CLOUDSMITH_NPM_TOKEN \ + --build-arg RUNDECK_MCP_VERSION="${RUNDECK_MCP_VERSION}" \ "${TAG_ARGS[@]}" . # Stage 3b: promote the CI image built above to the public release image. diff --git a/.claude/skills/rundeck-mcp-docker-build/SKILL.md b/.claude/skills/rundeck-mcp-docker-build/SKILL.md index 60293c0..106f732 100644 --- a/.claude/skills/rundeck-mcp-docker-build/SKILL.md +++ b/.claude/skills/rundeck-mcp-docker-build/SKILL.md @@ -143,4 +143,8 @@ Add to .mcp.json (stdio transport — docs downloaded on first start): "-e", "RUNDECK_TOKEN=your-token", "rundeck/mcp-ci:latest"] } + +Or keep credentials in an env file (see .env.example) and use + "--env-file", "/Users//.rundeck-mcp/.env" +in place of the two "-e" pairs. ``` \ No newline at end of file diff --git a/.claude/skills/rundeck-mcp-docker-setup/SKILL.md b/.claude/skills/rundeck-mcp-docker-setup/SKILL.md index 700c12a..c84b0b6 100644 --- a/.claude/skills/rundeck-mcp-docker-setup/SKILL.md +++ b/.claude/skills/rundeck-mcp-docker-setup/SKILL.md @@ -180,6 +180,8 @@ test -f .mcp.json && echo "found: $(pwd)/.mcp.json" || (test -f ~/.mcp.json && e Replace `` and `` with the values from Step 4. +Alternative: if the user prefers an env file over inline credentials, write `RUNDECK_URL`/`RUNDECK_TOKEN` to `~/.rundeck-mcp/.env` (`mkdir -p ~/.rundeck-mcp`; outside any project so the agent and git never see it; see `.env.example` for the format — unquoted `KEY=VALUE`, one per line, `chmod 600`) and replace the two `-e` pairs with `"--env-file", "/.rundeck-mcp/.env"` (expanded absolute path — no `~` in JSON args) (same change in the `claude mcp add` command in Step 6). Never use a bind mount. + ``` TaskUpdate taskId= status="completed" ``` diff --git a/.env.example b/.env.example index 170c84d..129a362 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,20 @@ -# Rundeck instance base URL (no trailing slash) +# Save your copy as ~/.rundeck-mcp/.env (chmod 600) — outside any project, next to instances.json. +# +# Example env file for the Rundeck MCP server. +# +# Works for all of these: +# docker run -i --rm --env-file ~/.rundeck-mcp/.env rundeck/mcp:latest +# Runlayer (paste the KEY=VALUE pairs into the server's environment config) +# `docker compose` / shell `set -a; . ./.env; set +a` +# +# Format rules (`docker run --env-file` is stricter than dotenv): +# - One KEY=VALUE per line, comments on their own line (a trailing "# ..." becomes part of the value). +# - No `export ` prefix. +# - Quotes are not needed. The server strips one surrounding pair, but Docker itself keeps them. +# - Keep every value on a single line (RUNDECK_INSTANCES included). +# - Never commit the real file: .env and .env.* are gitignored and excluded from the Docker build context. + +# Rundeck instance base URL (a trailing slash is stripped) RUNDECK_URL=https://your-rundeck-instance.example.com # API token — Rundeck → User Profile → API Tokens @@ -6,3 +22,7 @@ RUNDECK_TOKEN=your-api-token-here # API version (default is fine for most installations) RUNDECK_API_VERSION=59 + +# Optional: switch between several instances mid-session (replaces RUNDECK_URL/RUNDECK_TOKEN). +# Single-line JSON, no wrapping quotes. Uncomment to use. +# RUNDECK_INSTANCES={"default":"prod","instances":{"prod":{"url":"https://rundeck-prod.example.com","token":"prod-token"},"staging":{"url":"https://rundeck-staging.example.com","token":"staging-token"}}} diff --git a/.gitignore b/.gitignore index cbbacb2..941c10b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ dist/ .env +.env.* +!.env.example .mcp.json docs/ .idea/ diff --git a/CLAUDE.md b/CLAUDE.md index 8877cd3..c2cbeaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,7 @@ Resources that read from the filesystem use `configManager.getConfig().docsPath` Each tool exports its handler function and a Zod schema. Schemas are converted to JSON Schema via `zod-to-json-schema` in `index.ts` when responding to `ListTools`. -`api_call` reads `RUNDECK_URL` and `RUNDECK_TOKEN` from `configManager` (which lazily refreshes from environment). The base URL is constructed as `{RUNDECK_URL}/api/{RUNDECK_API_VERSION}`. +`api_call` reads `RUNDECK_URL` and `RUNDECK_TOKEN` from `configManager` (which lazily refreshes from environment). The base URL is constructed as `{RUNDECK_URL}/api/{RUNDECK_API_VERSION}`. Env values are cleaned by `cleanEnvValue()`/`normalizeUrl()` in `src/config.ts` (trim, strip one surrounding quote pair, empty → unset, trailing `/` dropped from URLs) because `docker run --env-file` passes quotes and CRLF through verbatim; the Docker image works identically with `-e` or `--env-file` (see `.env.example`, SETUP.md, and smoke-test section 6 in `ci/docker-smoke-test.sh`). ### Configuration (`src/config.ts`) diff --git a/Dockerfile b/Dockerfile index 316f2c0..184ec86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,11 @@ RUN --mount=type=secret,id=cloudsmith_token sh -c '\ # Compile TypeScript COPY tsconfig.json ./ COPY src/ ./src/ +# Bakes the release version into the User-Agent header (src/tools/api.ts's +# USER_AGENT constant). Passed as --build-arg by CI on tagged builds; stays +# "SNAPSHOT" otherwise (default below, and on any build that doesn't pass it). +ARG RUNDECK_MCP_VERSION=SNAPSHOT +RUN sed -i "s/rundeck-mcp\/SNAPSHOT/rundeck-mcp\/${RUNDECK_MCP_VERSION}/" src/tools/api.ts RUN npm run build # Prune to production deps only diff --git a/README.md b/README.md index da7d87b..9cef66f 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ For Claude Code, add via the CLI: claude mcp add rundeck-mcp -- docker run -i --rm -e RUNDECK_URL=https://your-rundeck-instance.example.com -e RUNDECK_TOKEN=your-rundeck-api-token-here rundeck/mcp:latest ``` +Prefer keeping credentials out of your client config? Put them in an env file (start from [`.env.example`](./.env.example)) and save it as `~/.rundeck-mcp/.env` (`chmod 600`) and swap the two `-e` pairs for `"--env-file", "/Users/you/.rundeck-mcp/.env"` (absolute path — JSON args aren't shell-expanded, so no `~`). Details, format rules, and the Runlayer setup are in [SETUP.md](./SETUP.md#passing-configuration-to-the-docker-image). + ### Using npx Instead (No Docker) Once published, the server is also available as the [`@rundeck/mcp`](https://www.npmjs.com/package/@rundeck/mcp) npm package, exposing the `rundeck-mcp` binary over stdio — use this if you'd rather not run Docker. diff --git a/SETUP.md b/SETUP.md index 55a611e..888fedd 100644 --- a/SETUP.md +++ b/SETUP.md @@ -107,6 +107,46 @@ export RUNDECK_API_VERSION=59 Note: When running via MCP client, shell environment variables may not be available. Use MCP settings instead. +#### Option 3: Env file (Docker) + +For the Docker image, `docker run --env-file ~/.rundeck-mcp/.env` is equivalent to `-e` flags — see [Passing Configuration to the Docker Image](#passing-configuration-to-the-docker-image) and [`.env.example`](./.env.example). + +### Passing Configuration to the Docker Image + +The image reads everything from its process environment, so these are interchangeable: + +```bash +# individual variables +docker run -i --rm -e RUNDECK_URL=https://your-rundeck.example.com -e RUNDECK_TOKEN=your-token rundeck/mcp:latest + +# an env file (mkdir -p ~/.rundeck-mcp && cp .env.example ~/.rundeck-mcp/.env && chmod 600 ~/.rundeck-mcp/.env, then fill it in) +docker run -i --rm --env-file ~/.rundeck-mcp/.env rundeck/mcp:latest +``` + +In an `mcpServers` block: `"args": ["run", "-i", "--rm", "--env-file", "/Users/you/.rundeck-mcp/.env", "rundeck/mcp:latest"]`. Use an absolute path with no `~` (JSON args aren't shell-expanded, and a relative path resolves against the MCP client's working directory, not your project). The Docker CLI reads the file on the host, so this is not a bind mount. + +- If the same key is set by both, `-e` wins over `--env-file`. +- `-e RUNDECK_TOKEN` with no value forwards your shell's current value. +- `--env-file` is stricter than dotenv: no `export` prefix, no trailing `# comments` on a value line, one line per variable (so `RUNDECK_INSTANCES` must be single-line JSON). Docker keeps quotes and CRLF `\r` verbatim; the server strips one surrounding pair of quotes, whitespace, and a trailing `/` on URLs, but don't rely on that — see [`.env.example`](./.env.example) for a known-good file. +- **Where to keep it:** `~/.rundeck-mcp/.env`, next to the multi-instance `instances.json` below. Not in a project directory: it holds a live token, can be committed by accident, and anything in the directory where you run `claude` is readable by the agent. +- `chmod 600` the file. + +### Runlayer (PagerDuty internal) + +Runlayer runs the CI-built `rundeck/mcp-ci:latest` image (see [Building the Internal Docker Image](#building-the-internal-docker-image-rundeckmcp-ci)) with the server's environment variables supplied by its connector configuration, the equivalent of `--env-file` above. The variables are the ones documented in the [Rundeck MCP configuration docs](https://docs.rundeck.com/docs/mcp/configuration.html). + +You must provide one of: + +- `RUNDECK_URL` **and** `RUNDECK_TOKEN` (a single instance), or +- `RUNDECK_INSTANCES` (several instances; see [Multiple Rundeck Instances](#multiple-rundeck-instances-optional)). + +Everything else (`RUNDECK_API_VERSION`, `RUNDECK_DOCS_BRANCH`, …) is optional. + +1. Copy [`.env.example`](./.env.example) to `~/.rundeck-mcp/.env` and keep only the variables you need: `RUNDECK_URL` + `RUNDECK_TOKEN`, or a single-line `RUNDECK_INSTANCES` (remove `RUNDECK_URL`/`RUNDECK_TOKEN` in that case). +2. Enter those `KEY=VALUE` pairs, unquoted and one per variable, in the Rundeck server's environment settings in Runlayer. +3. Add the Rundeck server to your client from Runlayer. +4. To check the wiring before involving Runlayer, run the same file locally against the same image: `docker run -i --rm --env-file ~/.rundeck-mcp/.env rundeck/mcp-ci:latest`. + ## Multiple Rundeck Instances (optional) Everything above assumes the common case: one Rundeck instance, configured via `RUNDECK_URL`/`RUNDECK_TOKEN`. If that's you, there's nothing else to do. diff --git a/TECHNICAL-CAPABILITIES.md b/TECHNICAL-CAPABILITIES.md index 2fcbb77..7a49f5b 100644 --- a/TECHNICAL-CAPABILITIES.md +++ b/TECHNICAL-CAPABILITIES.md @@ -318,6 +318,8 @@ The server is configured via environment variables: - `SKIP_RUNDECK_DOCS_DOWNLOAD`: Set to `1` to skip the npm-install-time docs download (no effect on Docker) - `MCP_DEBUG`: Enable verbose logging ("1" or "true") +Values may come from `-e` flags, `docker run --env-file`, or the client's `env` block — the server only sees `process.env`. It trims whitespace/CR, strips one pair of surrounding quotes, treats empty values as unset, and drops trailing slashes from instance URLs. See [SETUP.md](./SETUP.md#passing-configuration-to-the-docker-image). + ### Security - **Token Storage**: API tokens stored in memory only (not persisted to disk) diff --git a/ci/docker-smoke-test.sh b/ci/docker-smoke-test.sh index fe704f5..714c759 100755 --- a/ci/docker-smoke-test.sh +++ b/ci/docker-smoke-test.sh @@ -1,8 +1,8 @@ #!/bin/sh # Smoke-tests a built rundeck-mcp Docker image: verifies the entrypoint's docs # fetch (sparse git clone), the resulting /app/docs layout, the -# RUNDECK_DOCS_PATH bypass, the restart/skip-fetch path, and that the server -# actually answers an MCP `initialize` request over stdio. +# RUNDECK_DOCS_PATH bypass, `--env-file` handling, the restart/skip-fetch path, +# and that the server actually answers an MCP `initialize` request over stdio. # # Usage: ci/docker-smoke-test.sh [image] (default: rundeck/mcp-ci:latest) # Deliberately no `set -e` — failing assertions must be recorded via fail() @@ -107,6 +107,26 @@ case "$RESPONSE" in *) fail "initialize did not return expected result: $RESPONSE" ;; esac +echo "== 6. --env-file reaches the container exactly like -e ==" +ENV_FILE="$(mktemp)" +# Quoted URL + CRLF on purpose: `docker run --env-file` keeps both verbatim, +# and the server is expected to cope (src/config.ts's cleanEnvValue). +printf 'RUNDECK_URL="https://rundeck.example.com/"\r\nRUNDECK_TOKEN=smoke-token\r\nRUNDECK_DOCS_PATH=/tmp/external-docs\r\n' > "$ENV_FILE" +docker rm -f smoke-run >/dev/null 2>&1 +docker run --name smoke-run --env-file "$ENV_FILE" "$IMAGE" >/tmp/smoke-envfile.log 2>&1 || true +if grep -q "skipping docs fetch" /tmp/smoke-envfile.log && ! grep -q "fetching docs" /tmp/smoke-envfile.log; then + pass "variable supplied via --env-file was seen by the entrypoint" +else + fail "--env-file variable was not seen by the entrypoint" +fi +RESPONSE="$(echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke-test","version":"0.0.1"}}}' \ + | docker run -i --rm --env-file "$ENV_FILE" "$IMAGE" 2>/dev/null | head -1)" +case "$RESPONSE" in + *'"result"'*'"rundeck-docs"'*) pass "initialize returned a valid result with --env-file" ;; + *) fail "initialize with --env-file did not return expected result: $RESPONSE" ;; +esac +rm -f "$ENV_FILE" + echo if [ "$FAILED" -eq 0 ]; then echo "All smoke tests passed." diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 4b08744..7fdc6be 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -98,6 +98,60 @@ describe("Config Manager", () => { }); }); + describe("env value normalization (docker --env-file quirks)", () => { + afterEach(() => { + delete process.env.RUNDECK_INSTANCES; + }); + + it("strips surrounding quotes, whitespace, CR, and trailing slashes", () => { + process.env.RUNDECK_URL = '"https://test.rundeck.com/"\r'; + process.env.RUNDECK_TOKEN = "'test-token'"; + process.env.RUNDECK_API_VERSION = " 45 "; + + configManager.initialize(); + + const config = configManager.getConfig(); + expect(config.rundeckUrl).toBe("https://test.rundeck.com"); + expect(config.apiToken).toBe("test-token"); + expect(config.apiVersion).toBe("45"); + expect(configManager.getApiBaseUrl()).toBe("https://test.rundeck.com/api/45"); + }); + + it("treats empty or blank values as unset", () => { + process.env.RUNDECK_URL = ""; + process.env.RUNDECK_TOKEN = '""'; + process.env.RUNDECK_API_VERSION = ""; + + configManager.initialize(); + + const config = configManager.getConfig(); + expect(config.rundeckUrl).toBeUndefined(); + expect(config.apiToken).toBeUndefined(); + expect(config.apiVersion).toBe("59"); + }); + + it("leaves interior quotes alone", () => { + process.env.RUNDECK_TOKEN = 'ab"cd'; + configManager.initialize(); + expect(configManager.getConfig().apiToken).toBe('ab"cd'); + }); + + it("accepts a RUNDECK_INSTANCES registry wrapped in quotes and normalizes instance urls", () => { + process.env.RUNDECK_INSTANCES = + `'${JSON.stringify({ + default: "prod", + instances: { prod: { url: "https://prod.example.com/", token: " prod-token " } }, + })}'\r`; + + configManager.initialize(); + + expect(configManager.hasInstanceRegistry()).toBe(true); + const config = configManager.getConfig(); + expect(config.rundeckUrl).toBe("https://prod.example.com"); + expect(config.apiToken).toBe("prod-token"); + }); + }); + describe("RUNDECK_INSTANCES registry", () => { afterEach(() => { delete process.env.RUNDECK_INSTANCES; diff --git a/src/config.ts b/src/config.ts index 8b19c0a..0a1c8e5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,41 @@ export interface RundeckConfig { const DEFAULT_API_TIMEOUT_MS = 30_000; +/** + * Cleans up a raw env var value. `docker run --env-file` (unlike dotenv) keeps + * surrounding quotes and any CR from CRLF line endings verbatim, so a `.env` + * written with dotenv habits would otherwise reach us as `"https://x"`. + * Trims whitespace, strips one pair of matching surrounding quotes, and maps + * an empty result to undefined. + */ +function cleanEnvValue(raw: string | undefined): string | undefined { + if (raw === undefined) { + return undefined; + } + let value = raw.trim(); + if (value.length >= 2) { + const first = value[0]; + if ((first === '"' || first === "'") && value[value.length - 1] === first) { + value = value.slice(1, -1).trim(); + } + } + return value === "" ? undefined : value; +} + +/** Drops trailing slashes so `${url}/api/...` never ends up with `//`. */ +function normalizeUrl(url: string): string { + return url.replace(/\/+$/, ""); +} + +function readEnv(name: string): string | undefined { + return cleanEnvValue(process.env[name]); +} + +function readUrlEnv(name: string): string | undefined { + const value = readEnv(name); + return value === undefined ? undefined : normalizeUrl(value); +} + interface RundeckInstanceEntry { url: string; token: string; @@ -37,7 +72,7 @@ class ConfigManager { /** Parses RUNDECK_API_TIMEOUT_MS, falling back to the default on anything non-positive or non-numeric. */ private parseApiTimeoutMs(): number { - const raw = process.env.RUNDECK_API_TIMEOUT_MS; + const raw = readEnv("RUNDECK_API_TIMEOUT_MS"); if (!raw) { return DEFAULT_API_TIMEOUT_MS; } @@ -83,14 +118,15 @@ class ConfigManager { * Initialize configuration from environment variables */ initialize(): void { - this.config.rundeckUrl = process.env.RUNDECK_URL; - this.config.apiToken = process.env.RUNDECK_TOKEN; - this.config.apiVersion = process.env.RUNDECK_API_VERSION || "59"; + this.config.rundeckUrl = readUrlEnv("RUNDECK_URL"); + this.config.apiToken = readEnv("RUNDECK_TOKEN"); + this.config.apiVersion = readEnv("RUNDECK_API_VERSION") || "59"; this.config.apiTimeoutMs = this.parseApiTimeoutMs(); // Only override docs path if explicitly set - if (process.env.RUNDECK_DOCS_PATH) { - this.config.docsPath = resolve(process.env.RUNDECK_DOCS_PATH); + const docsPathEnv = readEnv("RUNDECK_DOCS_PATH"); + if (docsPathEnv) { + this.config.docsPath = resolve(docsPathEnv); } else { // Re-find docs path on initialization this.config.docsPath = this.findDocsPath(); @@ -111,7 +147,7 @@ class ConfigManager { private loadInstanceRegistry(): void { this.instanceRegistry = null; - const raw = process.env.RUNDECK_INSTANCES; + const raw = readEnv("RUNDECK_INSTANCES"); if (!raw) { return; } @@ -172,8 +208,8 @@ class ConfigManager { return; } validated[name] = { - url: (entry as RundeckInstanceEntry).url, - token: (entry as RundeckInstanceEntry).token, + url: normalizeUrl((entry as RundeckInstanceEntry).url.trim()), + token: (entry as RundeckInstanceEntry).token.trim(), }; } @@ -256,9 +292,9 @@ class ConfigManager { const hadUrl = !!this.config.rundeckUrl; const hadToken = !!this.config.apiToken; - this.config.rundeckUrl = process.env.RUNDECK_URL || this.config.rundeckUrl; - this.config.apiToken = process.env.RUNDECK_TOKEN || this.config.apiToken; - this.config.apiVersion = process.env.RUNDECK_API_VERSION || this.config.apiVersion; + this.config.rundeckUrl = readUrlEnv("RUNDECK_URL") || this.config.rundeckUrl; + this.config.apiToken = readEnv("RUNDECK_TOKEN") || this.config.apiToken; + this.config.apiVersion = readEnv("RUNDECK_API_VERSION") || this.config.apiVersion; this.config.apiTimeoutMs = this.parseApiTimeoutMs(); if (!hadToken && this.config.apiToken) { @@ -277,7 +313,7 @@ class ConfigManager { token: string, apiVersion?: string ): void { - this.config.rundeckUrl = url; + this.config.rundeckUrl = normalizeUrl(url); this.config.apiToken = token; if (apiVersion) { this.config.apiVersion = apiVersion; From 3785f05222dd614ea5fb97bc96c434b163c8e4af Mon Sep 17 00:00:00 2001 From: smartinellibenedetti <139791797+smartinellibenedetti@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:31:25 -0600 Subject: [PATCH 2/5] Docs: Runlayer uses --env-file from ~/.rundeck-mcp/.env Runlayer (internal) only supports --env-file, configured to read the standard ~/.rundeck-mcp/.env, so users just create the file there. Customers can still use -e variables or --env-file. Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 2 +- SETUP.md | 26 +++++++++++++++++--------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 129a362..0f6de6e 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ # # Works for all of these: # docker run -i --rm --env-file ~/.rundeck-mcp/.env rundeck/mcp:latest -# Runlayer (paste the KEY=VALUE pairs into the server's environment config) +# Runlayer (PagerDuty internal): reads this file from ~/.rundeck-mcp/.env, no flag needed # `docker compose` / shell `set -a; . ./.env; set +a` # # Format rules (`docker run --env-file` is stricter than dotenv): diff --git a/SETUP.md b/SETUP.md index 888fedd..4438fce 100644 --- a/SETUP.md +++ b/SETUP.md @@ -133,19 +133,27 @@ In an `mcpServers` block: `"args": ["run", "-i", "--rm", "--env-file", "/Users/y ### Runlayer (PagerDuty internal) -Runlayer runs the CI-built `rundeck/mcp-ci:latest` image (see [Building the Internal Docker Image](#building-the-internal-docker-image-rundeckmcp-ci)) with the server's environment variables supplied by its connector configuration, the equivalent of `--env-file` above. The variables are the ones documented in the [Rundeck MCP configuration docs](https://docs.rundeck.com/docs/mcp/configuration.html). +This section is for PagerDuty people using or developing the server through Runlayer. Runlayer runs the CI-built `rundeck/mcp-ci:latest` image (see [Building the Internal Docker Image](#building-the-internal-docker-image-rundeckmcp-ci)) and only supports `--env-file` for configuration, not individual environment variables. Everyone else can use either `-e` variables or `--env-file` as described above. -You must provide one of: +The Runlayer connector is configured to read the env file from the standard location, `~/.rundeck-mcp/.env`, so you never pass `--env-file` yourself. You only create the file there: -- `RUNDECK_URL` **and** `RUNDECK_TOKEN` (a single instance), or -- `RUNDECK_INSTANCES` (several instances; see [Multiple Rundeck Instances](#multiple-rundeck-instances-optional)). +1. Create it from the template: -Everything else (`RUNDECK_API_VERSION`, `RUNDECK_DOCS_BRANCH`, …) is optional. + ```bash + mkdir -p ~/.rundeck-mcp + cp .env.example ~/.rundeck-mcp/.env + chmod 600 ~/.rundeck-mcp/.env + ``` -1. Copy [`.env.example`](./.env.example) to `~/.rundeck-mcp/.env` and keep only the variables you need: `RUNDECK_URL` + `RUNDECK_TOKEN`, or a single-line `RUNDECK_INSTANCES` (remove `RUNDECK_URL`/`RUNDECK_TOKEN` in that case). -2. Enter those `KEY=VALUE` pairs, unquoted and one per variable, in the Rundeck server's environment settings in Runlayer. -3. Add the Rundeck server to your client from Runlayer. -4. To check the wiring before involving Runlayer, run the same file locally against the same image: `docker run -i --rm --env-file ~/.rundeck-mcp/.env rundeck/mcp-ci:latest`. +2. Edit it, keeping only the variables you need. You must provide one of: + - `RUNDECK_URL` **and** `RUNDECK_TOKEN` (a single instance), or + - `RUNDECK_INSTANCES` (several instances, as single-line JSON; see [Multiple Rundeck Instances](#multiple-rundeck-instances-optional)). Remove `RUNDECK_URL` and `RUNDECK_TOKEN` in that case. + + Everything else (`RUNDECK_API_VERSION`, `RUNDECK_DOCS_BRANCH`, …) is optional. The variables are the ones documented in the [Rundeck MCP configuration docs](https://docs.rundeck.com/docs/mcp/configuration.html), and the format rules in [`.env.example`](./.env.example) apply (unquoted `KEY=VALUE`, one per line). + +3. Add the Rundeck server to your client from Runlayer. Changes to the file take effect the next time the server starts, so restart the connector after editing it. + +To check the file before involving Runlayer, run the same image against it directly: `docker run -i --rm --env-file ~/.rundeck-mcp/.env rundeck/mcp-ci:latest`. ## Multiple Rundeck Instances (optional) From 8a15ca43d450cdc85af5b759994ddee99385bc46 Mon Sep 17 00:00:00 2001 From: smartinellibenedetti <139791797+smartinellibenedetti@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:48:54 -0600 Subject: [PATCH 3/5] Address PR review: normalize entrypoint branch, validate trimmed registry values, fix docs Co-Authored-By: Claude Sonnet 5.5 --- .../skills/rundeck-mcp-docker-setup/SKILL.md | 2 +- .env.example | 4 ++-- README.md | 2 +- TECHNICAL-CAPABILITIES.md | 2 +- ci/docker-smoke-test.sh | 11 +++++++++++ docker-entrypoint.sh | 4 +++- src/__tests__/config.test.ts | 17 +++++++++++++++++ src/config.ts | 13 +++++++++---- 8 files changed, 45 insertions(+), 10 deletions(-) diff --git a/.claude/skills/rundeck-mcp-docker-setup/SKILL.md b/.claude/skills/rundeck-mcp-docker-setup/SKILL.md index c84b0b6..d30cd1a 100644 --- a/.claude/skills/rundeck-mcp-docker-setup/SKILL.md +++ b/.claude/skills/rundeck-mcp-docker-setup/SKILL.md @@ -180,7 +180,7 @@ test -f .mcp.json && echo "found: $(pwd)/.mcp.json" || (test -f ~/.mcp.json && e Replace `` and `` with the values from Step 4. -Alternative: if the user prefers an env file over inline credentials, write `RUNDECK_URL`/`RUNDECK_TOKEN` to `~/.rundeck-mcp/.env` (`mkdir -p ~/.rundeck-mcp`; outside any project so the agent and git never see it; see `.env.example` for the format — unquoted `KEY=VALUE`, one per line, `chmod 600`) and replace the two `-e` pairs with `"--env-file", "/.rundeck-mcp/.env"` (expanded absolute path — no `~` in JSON args) (same change in the `claude mcp add` command in Step 6). Never use a bind mount. +Alternative: if the user prefers an env file over inline credentials, write `RUNDECK_URL`/`RUNDECK_TOKEN` to `~/.rundeck-mcp/.env` (`mkdir -p ~/.rundeck-mcp`; outside any project so the agent and git never see it; see `.env.example` for the format — unquoted `KEY=VALUE`, one per line, `chmod 600`) and replace the two `-e` pairs with `"--env-file", "/.rundeck-mcp/.env"` (expanded absolute path — no `~` in JSON args). In the Step 6 `claude mcp add` shell command, the equivalent is `--env-file /.rundeck-mcp/.env` (no quotes or commas). Never use a bind mount. ``` TaskUpdate taskId= status="completed" diff --git a/.env.example b/.env.example index 0f6de6e..7579d76 100644 --- a/.env.example +++ b/.env.example @@ -24,5 +24,5 @@ RUNDECK_TOKEN=your-api-token-here RUNDECK_API_VERSION=59 # Optional: switch between several instances mid-session (replaces RUNDECK_URL/RUNDECK_TOKEN). -# Single-line JSON, no wrapping quotes. Uncomment to use. -# RUNDECK_INSTANCES={"default":"prod","instances":{"prod":{"url":"https://rundeck-prod.example.com","token":"prod-token"},"staging":{"url":"https://rundeck-staging.example.com","token":"staging-token"}}} +# Single-line JSON wrapped in single quotes (keeps it valid when the file is sourced by a shell; the server strips them). Uncomment to use. +# RUNDECK_INSTANCES='{"default":"prod","instances":{"prod":{"url":"https://rundeck-prod.example.com","token":"prod-token"},"staging":{"url":"https://rundeck-staging.example.com","token":"staging-token"}}}' diff --git a/README.md b/README.md index 9cef66f..9b74f33 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ For Claude Code, add via the CLI: claude mcp add rundeck-mcp -- docker run -i --rm -e RUNDECK_URL=https://your-rundeck-instance.example.com -e RUNDECK_TOKEN=your-rundeck-api-token-here rundeck/mcp:latest ``` -Prefer keeping credentials out of your client config? Put them in an env file (start from [`.env.example`](./.env.example)) and save it as `~/.rundeck-mcp/.env` (`chmod 600`) and swap the two `-e` pairs for `"--env-file", "/Users/you/.rundeck-mcp/.env"` (absolute path — JSON args aren't shell-expanded, so no `~`). Details, format rules, and the Runlayer setup are in [SETUP.md](./SETUP.md#passing-configuration-to-the-docker-image). +Prefer keeping credentials out of your client config? Put them in an env file (start from [`.env.example`](./.env.example)) and save it as `~/.rundeck-mcp/.env` (`chmod 600`) and swap the two `-e` pairs for `--env-file /Users/you/.rundeck-mcp/.env` in the command above (or `"--env-file", "/Users/you/.rundeck-mcp/.env"` in a JSON `args` array). Use an absolute path: JSON args aren't shell-expanded, so no `~`. Details, format rules, and the Runlayer setup are in [SETUP.md](./SETUP.md#passing-configuration-to-the-docker-image). ### Using npx Instead (No Docker) diff --git a/TECHNICAL-CAPABILITIES.md b/TECHNICAL-CAPABILITIES.md index 7a49f5b..e12f0eb 100644 --- a/TECHNICAL-CAPABILITIES.md +++ b/TECHNICAL-CAPABILITIES.md @@ -318,7 +318,7 @@ The server is configured via environment variables: - `SKIP_RUNDECK_DOCS_DOWNLOAD`: Set to `1` to skip the npm-install-time docs download (no effect on Docker) - `MCP_DEBUG`: Enable verbose logging ("1" or "true") -Values may come from `-e` flags, `docker run --env-file`, or the client's `env` block — the server only sees `process.env`. It trims whitespace/CR, strips one pair of surrounding quotes, treats empty values as unset, and drops trailing slashes from instance URLs. See [SETUP.md](./SETUP.md#passing-configuration-to-the-docker-image). +Values read through the config layer (`RUNDECK_URL`, `RUNDECK_TOKEN`, `RUNDECK_API_VERSION`, `RUNDECK_INSTANCES`, etc.) may come from `-e` flags, `docker run --env-file`, or the client's `env` block — the server only sees `process.env`. It trims whitespace/CR, strips one pair of surrounding quotes, treats empty values as unset, and drops trailing slashes from instance URLs. Flags read directly (`MCP_DEBUG`, `SKIP_ELICITATION`, `RUNDECK_SKIP_OPENAPI_VALIDATE`) are not normalized, so write them unquoted and with LF line endings; `RUNDECK_DOCS_BRANCH` is cleaned by the Docker entrypoint. See [SETUP.md](./SETUP.md#passing-configuration-to-the-docker-image). ### Security diff --git a/ci/docker-smoke-test.sh b/ci/docker-smoke-test.sh index 714c759..0951ef2 100755 --- a/ci/docker-smoke-test.sh +++ b/ci/docker-smoke-test.sh @@ -125,6 +125,17 @@ case "$RESPONSE" in *'"result"'*'"rundeck-docs"'*) pass "initialize returned a valid result with --env-file" ;; *) fail "initialize with --env-file did not return expected result: $RESPONSE" ;; esac + +# RUNDECK_DOCS_BRANCH is read by the entrypoint, not by cleanEnvValue: a CR from +# a CRLF env file must not reach `git clone --branch`. +printf 'RUNDECK_DOCS_BRANCH=4.0.x\r\n' > "$ENV_FILE" +docker rm -f smoke-run >/dev/null 2>&1 +docker run --name smoke-run -i --env-file "$ENV_FILE" -e RUNDECK_DOCS_PATH= "$IMAGE" /tmp/smoke-envfile-branch.log 2>&1 || true +if grep -q "fetching docs (branch 4.0.x)" /tmp/smoke-envfile-branch.log; then + pass "CRLF in RUNDECK_DOCS_BRANCH is stripped by the entrypoint" +else + fail "CRLF in RUNDECK_DOCS_BRANCH reached the entrypoint's git clone: $(cat /tmp/smoke-envfile-branch.log)" +fi rm -f "$ENV_FILE" echo diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 887e89d..ba59d92 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -2,7 +2,9 @@ set -e DOCS_DIR="/app/docs" -DOCS_BRANCH="${RUNDECK_DOCS_BRANCH:-4.0.x}" +# `tr -d '\r\n '`: `docker run --env-file` keeps CR from CRLF files verbatim, which would break `git clone --branch`. +DOCS_BRANCH="$(printf '%s' "${RUNDECK_DOCS_BRANCH:-}" | tr -d '\r\n ')" +DOCS_BRANCH="${DOCS_BRANCH:-4.0.x}" DOCS_REPO="https://github.com/rundeck/docs.git" log() { diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 7fdc6be..089b52d 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -290,6 +290,23 @@ describe("Config Manager", () => { expect(configManager.hasInstanceRegistry()).toBe(false); }); + it("falls back to no registry when an instance url/token is whitespace-only", () => { + for (const entry of [ + { url: " ", token: "tok" }, + { url: "https://prod.example.com", token: " \r" }, + { url: "/", token: "tok" }, + ]) { + process.env.RUNDECK_INSTANCES = JSON.stringify({ + default: "prod", + instances: { prod: entry }, + }); + + configManager.initialize(); + + expect(configManager.hasInstanceRegistry()).toBe(false); + } + }); + it("falls back to no registry when an instance entry has an empty url/token", () => { // scripts/rundeck-connect.sh's own validation already rejects an empty // string the same way it rejects a missing field — this keeps diff --git a/src/config.ts b/src/config.ts index 0a1c8e5..3af91d1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -207,10 +207,15 @@ class ConfigManager { ); return; } - validated[name] = { - url: normalizeUrl((entry as RundeckInstanceEntry).url.trim()), - token: (entry as RundeckInstanceEntry).token.trim(), - }; + const url = normalizeUrl((entry as RundeckInstanceEntry).url.trim()); + const token = (entry as RundeckInstanceEntry).token.trim(); + if (!url || !token) { + logger.error( + `RUNDECK_INSTANCES entry "${name}" is missing "url"/"token" — ignoring RUNDECK_INSTANCES` + ); + return; + } + validated[name] = { url, token }; } if (Object.keys(validated).length === 0) { From a2211f2ce06a6c4a23051e830f425143fc939c65 Mon Sep 17 00:00:00 2001 From: smartinellibenedetti <139791797+smartinellibenedetti@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:29:27 -0600 Subject: [PATCH 4/5] Entrypoint: apply cleanEnvValue semantics to RUNDECK_DOCS_BRANCH and RUNDECK_DOCS_PATH Strip surrounding quotes and treat empty/whitespace values as unset, matching src/config.ts. Smoke test covers quoted branch, quoted-empty path, and quoted non-empty path. Co-Authored-By: Claude Sonnet 5.5 --- ci/docker-smoke-test.sh | 20 ++++++++++++++++++++ docker-entrypoint.sh | 16 +++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/ci/docker-smoke-test.sh b/ci/docker-smoke-test.sh index 0951ef2..336df98 100755 --- a/ci/docker-smoke-test.sh +++ b/ci/docker-smoke-test.sh @@ -136,6 +136,26 @@ if grep -q "fetching docs (branch 4.0.x)" /tmp/smoke-envfile-branch.log; then else fail "CRLF in RUNDECK_DOCS_BRANCH reached the entrypoint's git clone: $(cat /tmp/smoke-envfile-branch.log)" fi +# Quoted values: the entrypoint must apply the same quote-stripping/empty-is-unset +# semantics as cleanEnvValue(), so a quoted branch is usable and a quoted-empty +# RUNDECK_DOCS_PATH does not skip the fetch. +printf 'RUNDECK_DOCS_BRANCH="4.0.x"\r\nRUNDECK_DOCS_PATH=""\r\n' > "$ENV_FILE" +docker rm -f smoke-run >/dev/null 2>&1 +docker run --name smoke-run -i --env-file "$ENV_FILE" "$IMAGE" /tmp/smoke-envfile-quoted.log 2>&1 || true +if grep -q "fetching docs (branch 4.0.x)" /tmp/smoke-envfile-quoted.log; then + pass "quoted RUNDECK_DOCS_BRANCH and quoted-empty RUNDECK_DOCS_PATH are normalized by the entrypoint" +else + fail "entrypoint did not normalize quoted env-file values: $(cat /tmp/smoke-envfile-quoted.log)" +fi +# A quoted, non-empty RUNDECK_DOCS_PATH must still count as set and bypass the fetch. +printf 'RUNDECK_DOCS_PATH="/tmp/external-docs"\r\n' > "$ENV_FILE" +docker rm -f smoke-run >/dev/null 2>&1 +docker run --name smoke-run -i --env-file "$ENV_FILE" "$IMAGE" /tmp/smoke-envfile-quoted-path.log 2>&1 || true +if grep -q "RUNDECK_DOCS_PATH set" /tmp/smoke-envfile-quoted-path.log && ! grep -q "fetching docs" /tmp/smoke-envfile-quoted-path.log; then + pass "quoted RUNDECK_DOCS_PATH bypasses the fetch" +else + fail "quoted RUNDECK_DOCS_PATH did not bypass the fetch: $(cat /tmp/smoke-envfile-quoted-path.log)" +fi rm -f "$ENV_FILE" echo diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index ba59d92..c951183 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -2,9 +2,19 @@ set -e DOCS_DIR="/app/docs" -# `tr -d '\r\n '`: `docker run --env-file` keeps CR from CRLF files verbatim, which would break `git clone --branch`. -DOCS_BRANCH="$(printf '%s' "${RUNDECK_DOCS_BRANCH:-}" | tr -d '\r\n ')" +# Mirrors cleanEnvValue() in src/config.ts for the values this script reads itself: +# `docker run --env-file` passes quotes and CR (from CRLF files) through verbatim. +# Trims whitespace, strips one surrounding quote pair, trims again; empty → unset. +clean_env() { + printf '%s' "$1" | tr -d '\n' \ + | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \ + -e "s/^\"\(.*\)\"\$/\1/" -e "s/^'\(.*\)'\$/\1/" \ + -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' +} + +DOCS_BRANCH="$(clean_env "${RUNDECK_DOCS_BRANCH:-}")" DOCS_BRANCH="${DOCS_BRANCH:-4.0.x}" +DOCS_PATH_OVERRIDE="$(clean_env "${RUNDECK_DOCS_PATH:-}")" DOCS_REPO="https://github.com/rundeck/docs.git" log() { @@ -66,7 +76,7 @@ fetch_docs() { rm -rf "$tmp_dir" || true } -if [ -n "$RUNDECK_DOCS_PATH" ]; then +if [ -n "$DOCS_PATH_OVERRIDE" ]; then log "RUNDECK_DOCS_PATH set — skipping docs fetch" elif [ -d "$DOCS_DIR" ] && [ "$(ls -A "$DOCS_DIR" 2>/dev/null)" ]; then log "docs already present at $DOCS_DIR — skipping fetch" From 942cca6258eed9fec5414c3477942c0d58dfeefb Mon Sep 17 00:00:00 2001 From: smartinellibenedetti <139791797+smartinellibenedetti@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:57:48 -0600 Subject: [PATCH 5/5] Docs: entrypoint also normalizes RUNDECK_DOCS_PATH Co-Authored-By: Claude Sonnet 5.5 --- TECHNICAL-CAPABILITIES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TECHNICAL-CAPABILITIES.md b/TECHNICAL-CAPABILITIES.md index e12f0eb..4a71613 100644 --- a/TECHNICAL-CAPABILITIES.md +++ b/TECHNICAL-CAPABILITIES.md @@ -318,7 +318,7 @@ The server is configured via environment variables: - `SKIP_RUNDECK_DOCS_DOWNLOAD`: Set to `1` to skip the npm-install-time docs download (no effect on Docker) - `MCP_DEBUG`: Enable verbose logging ("1" or "true") -Values read through the config layer (`RUNDECK_URL`, `RUNDECK_TOKEN`, `RUNDECK_API_VERSION`, `RUNDECK_INSTANCES`, etc.) may come from `-e` flags, `docker run --env-file`, or the client's `env` block — the server only sees `process.env`. It trims whitespace/CR, strips one pair of surrounding quotes, treats empty values as unset, and drops trailing slashes from instance URLs. Flags read directly (`MCP_DEBUG`, `SKIP_ELICITATION`, `RUNDECK_SKIP_OPENAPI_VALIDATE`) are not normalized, so write them unquoted and with LF line endings; `RUNDECK_DOCS_BRANCH` is cleaned by the Docker entrypoint. See [SETUP.md](./SETUP.md#passing-configuration-to-the-docker-image). +Values read through the config layer (`RUNDECK_URL`, `RUNDECK_TOKEN`, `RUNDECK_API_VERSION`, `RUNDECK_INSTANCES`, etc.) may come from `-e` flags, `docker run --env-file`, or the client's `env` block — the server only sees `process.env`. It trims whitespace/CR, strips one pair of surrounding quotes, treats empty values as unset, and drops trailing slashes from instance URLs. Flags read directly (`MCP_DEBUG`, `SKIP_ELICITATION`, `RUNDECK_SKIP_OPENAPI_VALIDATE`) are not normalized, so write them unquoted and with LF line endings; `RUNDECK_DOCS_BRANCH` and `RUNDECK_DOCS_PATH` are cleaned the same way by the Docker entrypoint. See [SETUP.md](./SETUP.md#passing-configuration-to-the-docker-image). ### Security