Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
31 changes: 30 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions .claude/skills/rundeck-mcp-docker-build/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<you>/.rundeck-mcp/.env"
in place of the two "-e" pairs.
```
2 changes: 2 additions & 0 deletions .claude/skills/rundeck-mcp-docker-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ test -f .mcp.json && echo "found: $(pwd)/.mcp.json" || (test -f ~/.mcp.json && e

Replace `<RUNDECK_URL>` and `<RUNDECK_TOKEN>` 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", "<HOME>/.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.
Comment thread
Copilot marked this conversation as resolved.
Outdated

```
TaskUpdate taskId=<mcp_json_id> status="completed"
```
Expand Down
22 changes: 21 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
# 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 (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):
# - 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
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"}}}
Comment thread
Copilot marked this conversation as resolved.
Outdated
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
node_modules/
dist/
.env
.env.*
!.env.example
.mcp.json
docs/
.idea/
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment thread
Copilot marked this conversation as resolved.
Outdated

### 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.
Expand Down
48 changes: 48 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,54 @@ 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)

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.

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:

1. Create it from the template:

```bash
mkdir -p ~/.rundeck-mcp
cp .env.example ~/.rundeck-mcp/.env
chmod 600 ~/.rundeck-mcp/.env
```

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).
Comment thread
smartinellibenedetti marked this conversation as resolved.

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)

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.
Expand Down
2 changes: 2 additions & 0 deletions TECHNICAL-CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment thread
Copilot marked this conversation as resolved.
Outdated

### Security

- **Token Storage**: API tokens stored in memory only (not persisted to disk)
Expand Down
24 changes: 22 additions & 2 deletions ci/docker-smoke-test.sh
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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."
Expand Down
54 changes: 54 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading