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/container.yml b/.github/workflows/container.yml new file mode 100644 index 00000000..08570364 --- /dev/null +++ b/.github/workflows/container.yml @@ -0,0 +1,32 @@ +name: Container + +on: + pull_request: + paths: + - '.containerignore' + - 'Containerfile' + - 'scripts/container.*' + - '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' }} 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..7032e5f5 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,127 @@ 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", + "env": { + "PODMAN_USERNS": "keep-id" + }, + "args": [ + "run", + "--rm", + "-i", + "--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 one adjustment: +> - Replace `"command": "podman"` with `"command": "docker"`. +> - 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 + +```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..70ff2eff --- /dev/null +++ b/mcp-config-container-example.json @@ -0,0 +1,20 @@ +{ + "mcpServers": { + "patternfly-mcp": { + "command": "podman", + "env": { + "PODMAN_USERNS": "keep-id" + }, + "args": [ + "run", + "--rm", + "-i", + "--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..ec5d94f3 --- /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..d1619013 --- /dev/null +++ b/scripts/container.run.sh @@ -0,0 +1,67 @@ +#!/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 (`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 +# 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 +# +# 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`. +# +# 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" + export PODMAN_USERNS="keep-id" + 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 \ + --security-opt=no-new-privileges \ + --cap-drop=ALL \ + --read-only \ + --tmpfs /tmp:rw,size=64m \ + "${IMAGE}" "$@" +} 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..d200e506 --- /dev/null +++ b/tests/container/jest.setupTests.ts @@ -0,0 +1,19 @@ +// Shared helpers for container 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..a727c30c --- /dev/null +++ b/tests/container/utils/containerClient.ts @@ -0,0 +1,97 @@ +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. + * + * Probes `podman` first, then `docker` on the system's PATH. + * + * @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. + * + * @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. + * @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', + '--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, + ...((engine === 'podman' && { env: { PODMAN_USERNS: 'keep-id' } }) || {}) + }); +}; + +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 });