From 642dbf993eca7d2a84fb617926b12eb3c62aca88 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Thu, 25 Jun 2026 16:00:55 -0400 Subject: [PATCH 1/6] feat: pf-4298 build, run container * containerfile, multi-stage node 24 build * docs, new container run section, examples * config, container example * package.json, container:build and container:start scripts * scripts, build image, run container * tests, container audit, smoke test --- .containerignore | 8 ++ .github/workflows/audit.yml | 20 ++++ Containerfile | 84 ++++++++++++++++ cspell.config.json | 7 +- docs/usage.md | 120 +++++++++++++++++++++++ jest.config.ts | 7 ++ mcp-config-container-example.json | 16 +++ package.json | 3 + scripts/container.build.sh | 63 ++++++++++++ scripts/container.run.sh | 62 ++++++++++++ tests/audit/utils/containerClient.ts | 83 ++++++++++++++++ tests/container/container.audit.test.ts | 35 +++++++ tests/container/jest.setupTests.ts | 19 ++++ tests/container/utils/containerClient.ts | 98 ++++++++++++++++++ tests/e2e/utils/stdioTransportClient.ts | 4 +- 15 files changed, 626 insertions(+), 3 deletions(-) create mode 100644 .containerignore create mode 100644 Containerfile create mode 100644 mcp-config-container-example.json create mode 100755 scripts/container.build.sh create mode 100755 scripts/container.run.sh create mode 100644 tests/audit/utils/containerClient.ts create mode 100644 tests/container/container.audit.test.ts create mode 100644 tests/container/jest.setupTests.ts create mode 100644 tests/container/utils/containerClient.ts diff --git a/.containerignore b/.containerignore new file mode 100644 index 00000000..4b5ae244 --- /dev/null +++ b/.containerignore @@ -0,0 +1,8 @@ +# Ignore everything by default +* + +# Allowlist only the files and directories required for the build context +!package.json +!package-lock.json +!tsconfig.json +!src/ diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index f8383f8b..ce24102b 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -5,6 +5,7 @@ on: paths: - 'src/docs.json' - 'tests/audit/**' + - 'tests/container/**' - 'package.json' - 'package-lock.json' schedule: @@ -12,6 +13,25 @@ on: workflow_dispatch: jobs: + container-audit: + name: Container Audit + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - name: Setup Node.js + uses: actions/setup-node@v6.4.0 + with: + node-version: '24' + cache: npm + - name: Install dependencies + run: npm ci + - name: Run container audit + run: npm run test:audit-container + # Advisories are non-blocking for PRs + continue-on-error: ${{ github.event_name == 'pull_request' }} + documentation-audit: name: Documentation Audit runs-on: ubuntu-latest diff --git a/Containerfile b/Containerfile new file mode 100644 index 00000000..80b46e65 --- /dev/null +++ b/Containerfile @@ -0,0 +1,84 @@ +# +# PatternFly MCP server — container image definition. +# +# Multi-stage build. Produces a minimal Node.js 24 runtime image that +# launches the MCP server as a stdio process; HTTP transport is +# available by passing `--http --port ` at `podman run` time (the +# `ENTRYPOINT` forwards all arguments verbatim to the CLI). +# +# Base image: UBI 9 Node.js 24 minimal. +# - Anonymous pull from `registry.access.redhat.com` (no login required). +# - glibc-based, rootless-friendly, sets `APP_ROOT=/opt/app-root` and a +# default non-root user at UID 1001. +# +# +# ---- Stage 1: builder --------------------------------------------------- +# Installs deps, compiles the bundle with pkgroll, and prunes dev deps so +# only the runtime payload moves into stage 2. +FROM registry.access.redhat.com/ubi9/nodejs-24-minimal:latest AS builder + +# Non-root build/runtime user. Matches the UBI base default; exposed as an +# ARG so downstream tooling can override without editing the file. +ARG CONTAINER_UID=1001 +ENV CONTAINER_UID=${CONTAINER_UID} + +# UBI defines `APP_ROOT=/opt/app-root`; reuse it so paths line up with the +# base image's conventions. +WORKDIR ${APP_ROOT} + +# Copy only the files needed to resolve deps first. +COPY --chown=${CONTAINER_UID}:0 package.json package-lock.json tsconfig.json ./ + +USER ${CONTAINER_UID} + +# Lockfile-exact install. +RUN npm ci --ignore-scripts --no-audit --no-fund + +# Copy the rest of the source and produce the bundle. +COPY --chown=${CONTAINER_UID}:0 src ./src +RUN npx --no-install pkgroll --minify \ + && npm prune --omit=dev --ignore-scripts + +# ---- Stage 2: runtime --------------------------------------------------- +# Minimal runtime image. Only the compiled bundle, pruned `node_modules`, +# and `package.json` are carried over from the builder stage. +FROM registry.access.redhat.com/ubi9/nodejs-24-minimal:latest AS runtime + +# Standard OCI annotations +LABEL org.opencontainers.image.title="patternfly-mcp" \ + org.opencontainers.image.description="PatternFly documentation MCP server (Node.js 24)" \ + org.opencontainers.image.source="https://github.com/patternfly/patternfly-mcp" \ + org.opencontainers.image.url="https://github.com/patternfly/patternfly-mcp" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.vendor="Red Hat" \ + io.modelcontextprotocol.transport="stdio" + +# Re-declare the build arg in this stage. ARGs do not cross stages. +ARG CONTAINER_UID=1001 +ENV CONTAINER_UID=${CONTAINER_UID} + +# Runtime environment defaults: +# - `NODE_ENV=production` enables production code paths in deps +# - `NPM_CONFIG_UPDATE_NOTIFIER` suppress npm self-update nags +# - `NO_COLOR` keep stderr/stdout machine-readable +# (MCP clients consume these streams) +ENV NODE_ENV=production \ + NPM_CONFIG_UPDATE_NOTIFIER=false \ + NO_COLOR=1 + +WORKDIR ${APP_ROOT} + +# Runtime payload +COPY --from=builder --chown=${CONTAINER_UID}:0 ${APP_ROOT}/package.json ${APP_ROOT}/package.json +COPY --from=builder --chown=${CONTAINER_UID}:0 ${APP_ROOT}/node_modules ${APP_ROOT}/node_modules +COPY --from=builder --chown=${CONTAINER_UID}:0 ${APP_ROOT}/dist ${APP_ROOT}/dist + +USER ${CONTAINER_UID} + +# stdio MCP server by default. Clients attach over stdin/stdout. `CMD` +# provides only a default flag set; anything passed after the image name +# on `podman run ... ` replaces `CMD` entirely, so every +# CLI option (including `--http --port ` for HTTP transport) works +# without rebuilding the image. +ENTRYPOINT ["node", "dist/cli.js"] +CMD ["--log-stderr"] diff --git a/cspell.config.json b/cspell.config.json index 91c5876c..742dcdec 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -3,6 +3,7 @@ "words": [ "amet", "codemods", + "containerfile", "deprioritized", "ized", "llms", @@ -16,13 +17,17 @@ "prefault", "rereview", "rescan", + "rootfs", "rsort", + "sandboxed", "sandboxing", "sparkline", "streamable", + "tmpfs", "treeify", "unrepresentable", "unsub", - "untampered" + "untampered", + "userns" ] } diff --git a/docs/usage.md b/docs/usage.md index 0f3c3178..dc595b3d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -6,6 +6,7 @@ A comprehensive guide to PatternFly MCP Server tools, resources, and configurati - [Built-in tools](#built-in-tools) - [Built-in resources](#built-in-resources) - [MCP client configuration](#mcp-client-configuration) +- [Running via container](#running-via-container-podman) - [Custom MCP tool plugins](#custom-mcp-tool-plugins) - [Experimental settings](./experimental.md) - [Troubleshooting](#troubleshooting) @@ -200,6 +201,125 @@ Depending on your environment, you may have to delay updating to the minimum Nod } ``` +### Running via container (podman) + +The server can also be launched from a container image instead of `npx`. This is useful when you want a pinned, sandboxed runtime that doesn't depend on the host's Node.js installation. Running with an MCP client spawns `podman` (or `docker`) instead of `npx`. + +> **Prerequisites:** +> Podman is the supported container runtime for PatternFly MCP. We recommend [Podman Desktop](https://podman-desktop.io/downloads) or [Podman](https://podman.io/). +> +> We make a minimal effort to support Docker, and additional configuration may be required. + +#### Build the image locally + +From the repository root: + +```bash +bash ./scripts/container.build.sh +``` + +or using Node.js and NPM: + +```sh +npm run container:build +``` + +This produces the following images: +- `localhost/patternfly-mcp:`, +- `localhost/patternfly-mcp:-node24`, +- `localhost/patternfly-mcp:sha-` +- `localhost/patternfly-mcp:latest`. + +You can confirm by running `$ podman images` from the terminal. View the [Containerfile](../Containerfile) for the image definition. + +#### Running your local image, MCP client configuration + +```json +{ + "mcpServers": { + "patternfly-mcp": { + "command": "podman", + "args": [ + "run", + "--rm", + "-i", + "--userns=keep-id", + "--security-opt=no-new-privileges", + "--cap-drop=ALL", + "localhost/patternfly-mcp:latest", + "--log-stderr" + ], + "description": "PatternFly rules and documentation (local container)" + } + } +} +``` + +> **Important**: +> - `-i` (interactive stdin) is **required** for stdio MCP. Do **not** pass `-t`. Anything appended after the image name is forwarded verbatim to the CLI, so every flag (`--verbose`, `--http`, `--port`, `--tool`, ...) works without rebuilding. +> - If you're attempting to run the same configuration with Docker, you'll need to make at least two adjustments: +> - replace `"command": "podman"` with `"command": "docker"` +> - and remove the `--userns=keep-id` flag + +#### Smoke test + +```bash +podman run --rm --entrypoint node localhost/patternfly-mcp:latest -e "console.log(process.versions.node)" # -> 24.x +``` + +#### Running in HTTP transport mode + +The image's `ENTRYPOINT` forwards every argument straight to the CLI, so HTTP mode works against the **same image** with no rebuild — just publish a port and pass `--http --port`. + +```bash +podman run --rm \ + --userns=keep-id \ + --security-opt=no-new-privileges \ + --cap-drop=ALL \ + --read-only \ + --tmpfs /tmp:rw,size=64m \ + -p 3030:8080 \ + localhost/patternfly-mcp:latest \ + --http \ + --port 8080 \ + --allowed-origins "http://127.0.0.1:3030" \ + --allowed-hosts "127.0.0.1" \ + --log-stderr +``` + +Notes: +- `-p :` publishes the port. The container-side port (`8080` above) must match `--port`. The host-side port is whatever you want clients to connect to. +- `8080` inside the container matches the port the UBI Node.js base already advertises via `EXPOSE`, and a non-root user can bind it without extra capabilities. +- `-i` (interactive stdin) is **not** required in HTTP mode and is omitted here. +- `--allowed-origins` and `--allowed-hosts` are required guardrails when the server is reachable beyond stdio; set them explicitly. +- `--read-only` is safe today because the server writes nothing to the rootfs (only `/tmp`, which is a tmpfs). When the persistent SQLite cache lands, you will need to either drop `--read-only` or mount a writable volume for the cache directory. +- TLS is out of scope for the image itself. For anything beyond `localhost`, terminate TLS at a reverse proxy (Caddy, nginx, an OpenShift route, etc.). + +##### MCP client configuration (HTTP) + +HTTP MCP clients connect via URL rather than spawning a process. Start the container separately (e.g. with the command above, `podman-compose`, or a systemd unit) and point the client at the published host port: + +```json +{ + "mcpServers": { + "patternfly-mcp": { + "url": "http://localhost:3030", + "description": "PatternFly rules and documentation (HTTP transport, containerized)" + } + } +} +``` + +> Stdio-only MCP clients should keep using the [stdio container configuration above](#running-your-local-image-mcp-client-configuration); the HTTP form requires a client that supports HTTP/SSE MCP transports. + +##### Verify it's listening + +```bash +curl -sS -i http://localhost:3030/ | head -20 +``` + +`--log-stderr` will print the registered routes at startup, which is the easiest way to confirm the server bound the expected port. + ## Custom MCP tool plugins You can extend the server's capabilities by loading custom **Tool Plugins** at startup. diff --git a/jest.config.ts b/jest.config.ts index b97ead1d..b0e63857 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -77,6 +77,13 @@ export default { roots: ['/tests/audit'], testMatch: ['/tests/audit/**/*.test.ts'], ...baseConfig + }, + { + displayName: 'audit:container', + roots: ['/tests/container'], + testMatch: ['/tests/container/container.audit.test.ts'], + setupFilesAfterEnv: ['/tests/container/jest.setupTests.ts'], + ...baseConfig } ] }; diff --git a/mcp-config-container-example.json b/mcp-config-container-example.json new file mode 100644 index 00000000..156cc441 --- /dev/null +++ b/mcp-config-container-example.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "patternfly-mcp": { + "command": "podman", + "args": [ + "run", "--rm", "-i", + "--userns=keep-id", + "--security-opt=no-new-privileges", + "--cap-drop=ALL", + "localhost/patternfly-mcp:latest", + "--log-stderr" + ], + "description": "PatternFly rules and documentation (local container)" + } + } +} diff --git a/package.json b/package.json index 97def254..3f6c3135 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,14 @@ "build": "npm run build:clean; npm run test:types; pkgroll", "build:clean": "rm -rf dist", "build:watch": "npm run build -- --watch", + "container:build": "bash ./scripts/container.build.sh", + "container:start": "bash ./scripts/container.run.sh", "release": "changelog --non-cc --link-url https://github.com/patternfly/patternfly-mcp.git", "start": "node dist/cli.js --log-stderr", "start:dev": "tsx watch src/cli.ts --verbose --log-stderr", "test": "npm run test:spell && npm run test:spell-docs && npm run test:lint && npm run test:types && jest --selectProjects unit", "test:audit": "jest --selectProjects audit", + "test:audit-container": "npm run container:build && jest --selectProjects audit:container", "test:ci": "npm test -- --coverage", "test:dev": "npm test -- --watchAll", "test:integration": "npm run build && NODE_OPTIONS='--experimental-vm-modules' jest --selectProjects e2e", diff --git a/scripts/container.build.sh b/scripts/container.build.sh new file mode 100755 index 00000000..17e0e1a2 --- /dev/null +++ b/scripts/container.build.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Build the PatternFly MCP container image locally from the repository's +# Containerfile. Podman is the primary, supported runtime; Docker is +# detected as a fallback only if Podman is not installed. +# +# Produces four tags under the `${IMAGE}` repository: +# - ${IMAGE}: (from package.json#version) +# - ${IMAGE}:-node24 (Node.js 24 base image identifier) +# - ${IMAGE}:sha- (current HEAD short SHA, or `dev` outside git) +# - ${IMAGE}:latest +# +# Usage: +# ./scripts/container.build.sh +# IMAGE=localhost/patternfly-mcp ./scripts/container.build.sh +# +# Environment: +# IMAGE Image repository (without tag). Defaults to `localhost/patternfly-mcp`. +# +# main() +# +{ + # Fail fast on errors, unset variables, and broken pipes. + set -euo pipefail + + # Derive tag metadata from package.json and git. `SHA` falls back to `dev` + # when invoked outside a git checkout (e.g. extracted tarball). + VERSION="$(node -p "require('./package.json').version")" + SHA="$(git rev-parse --short HEAD 2>/dev/null || echo dev)" + IMAGE="${IMAGE:-localhost/patternfly-mcp}" + ENGINE="" + + # Prefer podman. Docker is supported but intentionally not advertised; if + # neither runtime is available, fail with a clear error. + if [ "$(command -v podman)" ]; then + ENGINE="podman" + elif [ "$(command -v docker)" ]; then + ENGINE="docker" + else + echo 'Error: Podman and Docker not found.' >&2 + exit 1 + fi + + echo "Using $ENGINE..."; + + # Build with all four tags applied in a single pass so the layer cache is + # shared and the tags are guaranteed to reference the same image digest. + "$ENGINE" build \ + --file Containerfile \ + --tag "${IMAGE}:${VERSION}" \ + --tag "${IMAGE}:${VERSION}-node24" \ + --tag "${IMAGE}:sha-${SHA}" \ + --tag "${IMAGE}:latest" \ + . + + # Echo the resulting tag set so callers (and CI logs) can see exactly what + # was produced without re-running `podman images`. + echo + echo "Built tags:" + echo " ${IMAGE}:${VERSION}" + echo " ${IMAGE}:${VERSION}-node24" + echo " ${IMAGE}:sha-${SHA}" + echo " ${IMAGE}:latest" +} diff --git a/scripts/container.run.sh b/scripts/container.run.sh new file mode 100755 index 00000000..90507094 --- /dev/null +++ b/scripts/container.run.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Run the PatternFly MCP container image locally as a stdio MCP server. +# Podman is the primary, supported runtime; Docker is detected as a +# fallback only if Podman is not installed. +# +# All arguments passed to this script are forwarded verbatim to the CLI +# inside the container, so every flag (`--verbose`, `--http`, `--port`, +# `--tool`, ...) works without rebuilding the image. +# +# Runtime posture (hardened by default): +# - `--rm` remove the container on exit +# - `-i` (no `-t`) REQUIRED for stdio MCP; do NOT add `-t` +# - `--userns=keep-id` map the container user to the host user +# - `--security-opt=no-new-privileges` block privilege escalation +# - `--cap-drop=ALL` drop all Linux capabilities +# - `--read-only` read-only rootfs (server writes nothing +# outside `/tmp`) +# - `--tmpfs /tmp:rw,size=64m` writable, size-capped tmpfs for `/tmp` +# +# Usage: +# ./scripts/container.run.sh [] +# IMAGE=localhost/patternfly-mcp:latest ./scripts/container.run.sh --verbose --log-stderr +# +# Environment: +# IMAGE Fully qualified image reference (with tag). Defaults to +# `localhost/patternfly-mcp:latest`. +# +# main() +# +{ + # Fail fast on errors, unset variables, and broken pipes. + set -euo pipefail + + # Resolve the image reference. Caller can override via the IMAGE env var + # to run a specific tag (e.g. `:sha-` or `:`). + IMAGE="${IMAGE:-localhost/patternfly-mcp:latest}" + ENGINE="" + + # Prefer podman. Docker is supported but intentionally not advertised; if + # neither runtime is available, fail with a clear error. + if [ "$(command -v podman)" ]; then + ENGINE="podman" + elif [ "$(command -v docker)" ]; then + ENGINE="docker" + else + echo 'Error: Podman and Docker not found.' >&2 + exit 1 + fi + + echo "Using $ENGINE..."; + + # `exec` replaces the shell so signals (SIGINT/SIGTERM from the MCP client) + # are delivered directly to the container runtime, which forwards them to + # the server process for a clean shutdown. + exec "$ENGINE" run --rm -i \ + --userns=keep-id \ + --security-opt=no-new-privileges \ + --cap-drop=ALL \ + --read-only \ + --tmpfs /tmp:rw,size=64m \ + "${IMAGE}" "$@" +} diff --git a/tests/audit/utils/containerClient.ts b/tests/audit/utils/containerClient.ts new file mode 100644 index 00000000..109b73b3 --- /dev/null +++ b/tests/audit/utils/containerClient.ts @@ -0,0 +1,83 @@ +import { execSync } from 'node:child_process'; +import { startServer, type StdioTransportClient } from '../../e2e/utils/stdioTransportClient'; + +interface StartOptions { + engine?: 'podman' | 'docker'; + args?: string[]; + image?: string; +} + +/** + * Resolves which container engine to use. + * + * 1. If the `CONTAINER_ENGINE` environment variable is set, its value is returned. + * 2. If the `CONTAINER_ENGINE` environment variable is not set, it searches for common + * container engines like `podman` and `docker`. If one of these engines is available + * (found in the system's PATH), its name is returned. + * 3. If no container engine is detected or available, the function returns `undefined`. + * + * @returns The resolved container engine name or `undefined` if none is found. + */ +const resolveContainerEngine = (): string | undefined => { + for (const engine of ['podman', 'docker']) { + try { + execSync(`command -v ${engine}`, { stdio: 'ignore' }); + + return engine; + } catch {} + } + + return undefined; +}; + +const buildImage = async (engine: string, image: string) => { + try { + execSync(`${engine} image inspect ${image}`, { stdio: 'ignore' }); + } catch { + console.warn(`Image ${image} not found. Building...`); + execSync('npm run container:build', { stdio: 'inherit' }); + } +}; + +const startContainer = async ({ + engine, + args = [], + image = 'localhost/patternfly-mcp:latest' +}: StartOptions = {}): Promise => { + if (!engine) { + throw new Error('Container engine not found'); + } + + const updatedArgs = [ + 'run', + '--rm', + '-i', + '--userns=keep-id', + '--security-opt=no-new-privileges', + '--cap-drop=ALL', + image, + '--mode', + 'test', + '--log-stderr', + ...args + ]; + + try { + await buildImage(engine, image); + } catch (error) { + throw new Error(`Failed to build image: ${error}`); + } + + return startServer({ + command: engine, + serverPath: '', + args: updatedArgs + }); +}; + +export { + buildImage, + resolveContainerEngine, + startContainer, + type StdioTransportClient +}; diff --git a/tests/container/container.audit.test.ts b/tests/container/container.audit.test.ts new file mode 100644 index 00000000..7485f519 --- /dev/null +++ b/tests/container/container.audit.test.ts @@ -0,0 +1,35 @@ +import { resolveContainerEngine, startContainer, type StdioTransportClient } from './utils/containerClient'; + +const engine: any = resolveContainerEngine(); + +describeSkip(engine !== undefined)('Container Audit', () => { + let CLIENT: StdioTransportClient; + + beforeAll(async () => { + CLIENT = await startContainer({ + engine + }); + }); + + afterAll(async () => { + if (CLIENT) { + await CLIENT.stop(); + } + }); + + it('should start and have basic tools and resources', async () => { + const tools = await CLIENT.send({ method: 'tools/list' }); + const hasTool = tools?.result?.tools?.some( + (tool: any) => tool.name === 'searchPatternFlyDocs' || tool.name === 'searchPatternFly' + ); + + expect(hasTool).toBe(true); + + const resources = await CLIENT.send({ method: 'resources/list' }); + const hasResource = resources?.result?.resources?.some( + (resource: any) => resource.uri === 'patternfly://context' + ); + + expect(hasResource).toBe(true); + }); +}); diff --git a/tests/container/jest.setupTests.ts b/tests/container/jest.setupTests.ts new file mode 100644 index 00000000..01ae8e32 --- /dev/null +++ b/tests/container/jest.setupTests.ts @@ -0,0 +1,19 @@ +// Shared helpers for audit Jest tests + +declare global { + var describeSkip: (check: unknown) => typeof describe | typeof describe.skip; +} + +/** + * Conditionally skip "describe" blocks. + * + * @example + * describeSkip(true)('should do a thing...', () => { ... }); + * + * @param {*|boolean} check - Any `truthy`/`falsy` value + * @returns On `truthy` returns `describe`, on `falsy` returns `describe.skip`. + */ +export const describeSkip = (check: unknown): typeof describe | typeof describe.skip => (check ? describe : describe.skip); + +global.describeSkip = describeSkip; + diff --git a/tests/container/utils/containerClient.ts b/tests/container/utils/containerClient.ts new file mode 100644 index 00000000..c96b5d2d --- /dev/null +++ b/tests/container/utils/containerClient.ts @@ -0,0 +1,98 @@ +import { execSync } from 'node:child_process'; +import { startServer, type StdioTransportClient } from '../../e2e/utils/stdioTransportClient'; + +interface StartOptions { + engine?: 'podman' | 'docker'; + args?: string[]; + image?: string; +} + +/** + * Resolves which container engine to use. + * + * 1. If the `CONTAINER_ENGINE` environment variable is set, its value is returned. + * 2. If the `CONTAINER_ENGINE` environment variable is not set, it searches for common + * container engines like `podman` and `docker`. If one of these engines is available + * (found in the system's PATH), its name is returned. + * 3. If no container engine is detected or available, the function returns `undefined`. + * + * @returns The resolved container engine name or `undefined` if none is found. + */ +const resolveContainerEngine = (): string | undefined => { + for (const engine of ['podman', 'docker']) { + try { + execSync(`command -v ${engine}`, { stdio: 'ignore' }); + + return engine; + } catch {} + } + + return undefined; +}; + +/** + * Build the container image if it doesn't exist. + * + * @param engine - Container engine to use (e.g., 'podman', 'docker'). + * @param image - Image name to build. + */ +const buildImage = async (engine: 'podman' | 'docker', image: string) => { + try { + execSync(`${engine} image inspect ${image}`, { stdio: 'ignore' }); + } catch { + console.warn(`Image ${image} not found. Building...`); + execSync('npm run container:build', { stdio: 'inherit' }); + } +}; + +/** + * Start the container and return a client for interacting with it. + * + * @param options - Server configuration options + * @param options.engine - Container engine to use (e.g., 'podman', 'docker'). + * @param options.args - Additional args to pass to the container. + * @param options.image - Image to use for the container. Defaults to 'localhost/patternfly-mcp:latest'. + * @returns Client for interacting with a container. + */ +const startContainer = async ({ + engine, + args = [], + image = 'localhost/patternfly-mcp:latest' +}: StartOptions = {}): Promise => { + if (!engine) { + throw new Error('Container engine not found'); + } + + const updatedArgs = [ + 'run', + '--rm', + '-i', + '--userns=keep-id', + '--security-opt=no-new-privileges', + '--cap-drop=ALL', + image, + '--mode', + 'test', + '--log-stderr', + ...args + ]; + + try { + await buildImage(engine, image); + } catch (error) { + throw new Error(`Failed to build image: ${error}`); + } + + return startServer({ + command: engine, + serverPath: '', + args: updatedArgs + }); +}; + +export { + buildImage, + resolveContainerEngine, + startContainer, + type StdioTransportClient +}; diff --git a/tests/e2e/utils/stdioTransportClient.ts b/tests/e2e/utils/stdioTransportClient.ts index 93d79306..9b2965be 100644 --- a/tests/e2e/utils/stdioTransportClient.ts +++ b/tests/e2e/utils/stdioTransportClient.ts @@ -11,7 +11,7 @@ export type { Request as RpcRequest } from '@modelcontextprotocol/sdk/types.js'; export interface StartOptions { command?: string; - serverPath?: string; + serverPath?: string | undefined; args?: string[]; env?: Record; } @@ -57,7 +57,7 @@ export const startServer = async ({ // Set stderr to 'pipe' so we can handle server logs separately from JSON-RPC messages const transport = new StdioClientTransport({ command, - args: [serverPath, '--mode', 'test', ...args], + args: !serverPath ? [...args] : [serverPath, '--mode', 'test', ...args], env: { ...process.env, ...env } as any, stderr: 'pipe' // Pipe stderr so server logs don't interfere with JSON-RPC on stdout }); From 67d03c40cac25aa61bf1567a86d63c9ffc70aad0 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Fri, 26 Jun 2026 09:24:29 -0400 Subject: [PATCH 2/6] fix: review update --- .github/workflows/audit.yml | 20 -------------------- .github/workflows/container.yml | 29 +++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/container.yml diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index ce24102b..f8383f8b 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -5,7 +5,6 @@ on: paths: - 'src/docs.json' - 'tests/audit/**' - - 'tests/container/**' - 'package.json' - 'package-lock.json' schedule: @@ -13,25 +12,6 @@ on: workflow_dispatch: jobs: - container-audit: - name: Container Audit - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v6 - - name: Setup Node.js - uses: actions/setup-node@v6.4.0 - with: - node-version: '24' - cache: npm - - name: Install dependencies - run: npm ci - - name: Run container audit - run: npm run test:audit-container - # Advisories are non-blocking for PRs - continue-on-error: ${{ github.event_name == 'pull_request' }} - documentation-audit: name: Documentation Audit runs-on: ubuntu-latest diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 00000000..44a06d64 --- /dev/null +++ b/.github/workflows/container.yml @@ -0,0 +1,29 @@ +name: Container + +on: + pull_request: + paths: + - 'tests/container/**' + - 'package.json' + - 'package-lock.json' + workflow_dispatch: + +jobs: + container-audit: + name: Container Audit + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - name: Setup Node.js + uses: actions/setup-node@v6.4.0 + with: + node-version: '24' + cache: npm + - name: Install dependencies + run: npm ci + - name: Run container audit + run: npm run test:audit-container + # Advisories are non-blocking for PRs + continue-on-error: ${{ github.event_name == 'pull_request' }} From 3116c1c5d2863f768b9239ca362a46a07fa6de2e Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Fri, 26 Jun 2026 09:34:48 -0400 Subject: [PATCH 3/6] fix: review update --- tests/audit/utils/containerClient.ts | 83 ------------------------ tests/container/utils/containerClient.ts | 6 +- 2 files changed, 1 insertion(+), 88 deletions(-) delete mode 100644 tests/audit/utils/containerClient.ts diff --git a/tests/audit/utils/containerClient.ts b/tests/audit/utils/containerClient.ts deleted file mode 100644 index 109b73b3..00000000 --- a/tests/audit/utils/containerClient.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { execSync } from 'node:child_process'; -import { startServer, type StdioTransportClient } from '../../e2e/utils/stdioTransportClient'; - -interface StartOptions { - engine?: 'podman' | 'docker'; - args?: string[]; - image?: string; -} - -/** - * Resolves which container engine to use. - * - * 1. If the `CONTAINER_ENGINE` environment variable is set, its value is returned. - * 2. If the `CONTAINER_ENGINE` environment variable is not set, it searches for common - * container engines like `podman` and `docker`. If one of these engines is available - * (found in the system's PATH), its name is returned. - * 3. If no container engine is detected or available, the function returns `undefined`. - * - * @returns The resolved container engine name or `undefined` if none is found. - */ -const resolveContainerEngine = (): string | undefined => { - for (const engine of ['podman', 'docker']) { - try { - execSync(`command -v ${engine}`, { stdio: 'ignore' }); - - return engine; - } catch {} - } - - return undefined; -}; - -const buildImage = async (engine: string, image: string) => { - try { - execSync(`${engine} image inspect ${image}`, { stdio: 'ignore' }); - } catch { - console.warn(`Image ${image} not found. Building...`); - execSync('npm run container:build', { stdio: 'inherit' }); - } -}; - -const startContainer = async ({ - engine, - args = [], - image = 'localhost/patternfly-mcp:latest' -}: StartOptions = {}): Promise => { - if (!engine) { - throw new Error('Container engine not found'); - } - - const updatedArgs = [ - 'run', - '--rm', - '-i', - '--userns=keep-id', - '--security-opt=no-new-privileges', - '--cap-drop=ALL', - image, - '--mode', - 'test', - '--log-stderr', - ...args - ]; - - try { - await buildImage(engine, image); - } catch (error) { - throw new Error(`Failed to build image: ${error}`); - } - - return startServer({ - command: engine, - serverPath: '', - args: updatedArgs - }); -}; - -export { - buildImage, - resolveContainerEngine, - startContainer, - type StdioTransportClient -}; diff --git a/tests/container/utils/containerClient.ts b/tests/container/utils/containerClient.ts index c96b5d2d..8658e475 100644 --- a/tests/container/utils/containerClient.ts +++ b/tests/container/utils/containerClient.ts @@ -10,11 +10,7 @@ interface StartOptions { /** * Resolves which container engine to use. * - * 1. If the `CONTAINER_ENGINE` environment variable is set, its value is returned. - * 2. If the `CONTAINER_ENGINE` environment variable is not set, it searches for common - * container engines like `podman` and `docker`. If one of these engines is available - * (found in the system's PATH), its name is returned. - * 3. If no container engine is detected or available, the function returns `undefined`. + * Probes `podman` first, then `docker` on the system's PATH. * * @returns The resolved container engine name or `undefined` if none is found. */ From 84cc8398b966af3be493397f25539fc197c9d455 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Fri, 26 Jun 2026 10:11:15 -0400 Subject: [PATCH 4/6] fix: review update --- .github/workflows/container.yml | 3 +++ docs/usage.md | 12 +++++++----- mcp-config-container-example.json | 4 +++- scripts/container.build.sh | 6 +++--- scripts/container.run.sh | 15 ++++++++++----- tests/container/utils/containerClient.ts | 7 +++++-- 6 files changed, 31 insertions(+), 16 deletions(-) diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 44a06d64..08438852 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -3,6 +3,9 @@ name: Container on: pull_request: paths: + - '.containerignore', + - 'Containerfile', + - 'scripts/container.*' - 'tests/container/**' - 'package.json' - 'package-lock.json' diff --git a/docs/usage.md b/docs/usage.md index dc595b3d..adc72344 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -206,7 +206,7 @@ Depending on your environment, you may have to delay updating to the minimum Nod The server can also be launched from a container image instead of `npx`. This is useful when you want a pinned, sandboxed runtime that doesn't depend on the host's Node.js installation. Running with an MCP client spawns `podman` (or `docker`) instead of `npx`. > **Prerequisites:** -> Podman is the supported container runtime for PatternFly MCP. We recommend [Podman Desktop](https://podman-desktop.io/downloads) or [Podman](https://podman.io/). +> `podman` is the supported container runtime for PatternFly MCP. We recommend [podman desktop](https://podman-desktop.io/downloads) or [podman](https://podman.io/). > > We make a minimal effort to support Docker, and additional configuration may be required. @@ -239,11 +239,13 @@ You can confirm by running `$ podman images` from the terminal. View the [Contai "mcpServers": { "patternfly-mcp": { "command": "podman", + "env": { + "PODMAN_USERNS": "keep-id" + }, "args": [ "run", "--rm", "-i", - "--userns=keep-id", "--security-opt=no-new-privileges", "--cap-drop=ALL", "localhost/patternfly-mcp:latest", @@ -257,9 +259,9 @@ You can confirm by running `$ podman images` from the terminal. View the [Contai > **Important**: > - `-i` (interactive stdin) is **required** for stdio MCP. Do **not** pass `-t`. Anything appended after the image name is forwarded verbatim to the CLI, so every flag (`--verbose`, `--http`, `--port`, `--tool`, ...) works without rebuilding. -> - If you're attempting to run the same configuration with Docker, you'll need to make at least two adjustments: -> - replace `"command": "podman"` with `"command": "docker"` -> - and remove the `--userns=keep-id` flag +> - If you're attempting to run the same configuration with Docker, you'll need to make at least one adjustment: +> - Replace `"command": "podman"` with `"command": "docker"`. +> - For Docker compatibility, we leverage the `PODMAN_USERNS` environment variable to map the container user to your host user. If you come across the `--userns=keep-id` flag, and you are using Docker, remove it. #### Smoke test diff --git a/mcp-config-container-example.json b/mcp-config-container-example.json index 156cc441..f40c46aa 100644 --- a/mcp-config-container-example.json +++ b/mcp-config-container-example.json @@ -2,9 +2,11 @@ "mcpServers": { "patternfly-mcp": { "command": "podman", + "env": { + "PODMAN_USERNS": "keep-id" + }, "args": [ "run", "--rm", "-i", - "--userns=keep-id", "--security-opt=no-new-privileges", "--cap-drop=ALL", "localhost/patternfly-mcp:latest", diff --git a/scripts/container.build.sh b/scripts/container.build.sh index 17e0e1a2..99a44940 100755 --- a/scripts/container.build.sh +++ b/scripts/container.build.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Build the PatternFly MCP container image locally from the repository's -# Containerfile. Podman is the primary, supported runtime; Docker is -# detected as a fallback only if Podman is not installed. +# Containerfile. podman is the primary, supported runtime; Docker is +# detected as a fallback only if podman is not installed. # # Produces four tags under the `${IMAGE}` repository: # - ${IMAGE}: (from package.json#version) @@ -36,7 +36,7 @@ elif [ "$(command -v docker)" ]; then ENGINE="docker" else - echo 'Error: Podman and Docker not found.' >&2 + echo 'Error: podman and Docker not found.' >&2 exit 1 fi diff --git a/scripts/container.run.sh b/scripts/container.run.sh index 90507094..c86e4979 100755 --- a/scripts/container.run.sh +++ b/scripts/container.run.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Run the PatternFly MCP container image locally as a stdio MCP server. -# Podman is the primary, supported runtime; Docker is detected as a -# fallback only if Podman is not installed. +# podman is the primary, supported runtime; Docker is detected as a +# fallback only if podman is not installed. # # All arguments passed to this script are forwarded verbatim to the CLI # inside the container, so every flag (`--verbose`, `--http`, `--port`, @@ -10,7 +10,8 @@ # Runtime posture (hardened by default): # - `--rm` remove the container on exit # - `-i` (no `-t`) REQUIRED for stdio MCP; do NOT add `-t` -# - `--userns=keep-id` map the container user to the host user +# - `--userns=keep-id` map the container user to the host +# user (`podman` specific) # - `--security-opt=no-new-privileges` block privilege escalation # - `--cap-drop=ALL` drop all Linux capabilities # - `--read-only` read-only rootfs (server writes nothing @@ -21,6 +22,10 @@ # ./scripts/container.run.sh [] # IMAGE=localhost/patternfly-mcp:latest ./scripts/container.run.sh --verbose --log-stderr # +# Note: +# Since `--userns=keep-id` is `podman` specific, for compatibility with +# Docker, we leverage an env value `export PODMAN_USERNS=keep-id`. +# # Environment: # IMAGE Fully qualified image reference (with tag). Defaults to # `localhost/patternfly-mcp:latest`. @@ -40,10 +45,11 @@ # neither runtime is available, fail with a clear error. if [ "$(command -v podman)" ]; then ENGINE="podman" + export PODMAN_USERNS="keep-id" elif [ "$(command -v docker)" ]; then ENGINE="docker" else - echo 'Error: Podman and Docker not found.' >&2 + echo 'Error: podman and Docker not found.' >&2 exit 1 fi @@ -53,7 +59,6 @@ # are delivered directly to the container runtime, which forwards them to # the server process for a clean shutdown. exec "$ENGINE" run --rm -i \ - --userns=keep-id \ --security-opt=no-new-privileges \ --cap-drop=ALL \ --read-only \ diff --git a/tests/container/utils/containerClient.ts b/tests/container/utils/containerClient.ts index 8658e475..a727c30c 100644 --- a/tests/container/utils/containerClient.ts +++ b/tests/container/utils/containerClient.ts @@ -44,6 +44,9 @@ const buildImage = async (engine: 'podman' | 'docker', image: string) => { /** * Start the container and return a client for interacting with it. * + * @note We use the `PODMAN_USERNS` environment variable instead of + * the direct flag `--userns=keep-id` for Docker compatibility. + * * @param options - Server configuration options * @param options.engine - Container engine to use (e.g., 'podman', 'docker'). * @param options.args - Additional args to pass to the container. @@ -63,7 +66,6 @@ const startContainer = async ({ 'run', '--rm', '-i', - '--userns=keep-id', '--security-opt=no-new-privileges', '--cap-drop=ALL', image, @@ -82,7 +84,8 @@ const startContainer = async ({ return startServer({ command: engine, serverPath: '', - args: updatedArgs + args: updatedArgs, + ...((engine === 'podman' && { env: { PODMAN_USERNS: 'keep-id' } }) || {}) }); }; From aec2c1b78e9736a48d0b43ea8ae31ae5572f01c2 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Fri, 26 Jun 2026 10:22:36 -0400 Subject: [PATCH 5/6] fix: review update --- .github/workflows/container.yml | 4 ++-- docs/usage.md | 2 +- scripts/container.build.sh | 2 +- scripts/container.run.sh | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 08438852..08570364 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -3,8 +3,8 @@ name: Container on: pull_request: paths: - - '.containerignore', - - 'Containerfile', + - '.containerignore' + - 'Containerfile' - 'scripts/container.*' - 'tests/container/**' - 'package.json' diff --git a/docs/usage.md b/docs/usage.md index adc72344..7032e5f5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -261,7 +261,7 @@ You can confirm by running `$ podman images` from the terminal. View the [Contai > - `-i` (interactive stdin) is **required** for stdio MCP. Do **not** pass `-t`. Anything appended after the image name is forwarded verbatim to the CLI, so every flag (`--verbose`, `--http`, `--port`, `--tool`, ...) works without rebuilding. > - If you're attempting to run the same configuration with Docker, you'll need to make at least one adjustment: > - Replace `"command": "podman"` with `"command": "docker"`. -> - For Docker compatibility, we leverage the `PODMAN_USERNS` environment variable to map the container user to your host user. If you come across the `--userns=keep-id` flag, and you are using Docker, remove it. +> - On podman, `PODMAN_USERNS` replaces `--userns=keep-id`. If you come across the `--userns=keep-id` flag, and you are using Docker, remove it. #### Smoke test diff --git a/scripts/container.build.sh b/scripts/container.build.sh index 99a44940..ec5d94f3 100755 --- a/scripts/container.build.sh +++ b/scripts/container.build.sh @@ -36,7 +36,7 @@ elif [ "$(command -v docker)" ]; then ENGINE="docker" else - echo 'Error: podman and Docker not found.' >&2 + echo 'Error: "podman" and "docker" not found.' >&2 exit 1 fi diff --git a/scripts/container.run.sh b/scripts/container.run.sh index c86e4979..d1619013 100755 --- a/scripts/container.run.sh +++ b/scripts/container.run.sh @@ -49,7 +49,7 @@ elif [ "$(command -v docker)" ]; then ENGINE="docker" else - echo 'Error: podman and Docker not found.' >&2 + echo 'Error: "podman" and "docker" not found.' >&2 exit 1 fi From 226f4465636cad96d5a345f5b2549251b0dda327 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Fri, 26 Jun 2026 10:25:43 -0400 Subject: [PATCH 6/6] fix: review update --- mcp-config-container-example.json | 4 +++- tests/container/jest.setupTests.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/mcp-config-container-example.json b/mcp-config-container-example.json index f40c46aa..70ff2eff 100644 --- a/mcp-config-container-example.json +++ b/mcp-config-container-example.json @@ -6,7 +6,9 @@ "PODMAN_USERNS": "keep-id" }, "args": [ - "run", "--rm", "-i", + "run", + "--rm", + "-i", "--security-opt=no-new-privileges", "--cap-drop=ALL", "localhost/patternfly-mcp:latest", diff --git a/tests/container/jest.setupTests.ts b/tests/container/jest.setupTests.ts index 01ae8e32..d200e506 100644 --- a/tests/container/jest.setupTests.ts +++ b/tests/container/jest.setupTests.ts @@ -1,4 +1,4 @@ -// Shared helpers for audit Jest tests +// Shared helpers for container Jest tests declare global { var describeSkip: (check: unknown) => typeof describe | typeof describe.skip;