From 17a91faeef36fd3d1f60842ea1ff5845757c0e0d Mon Sep 17 00:00:00 2001 From: Detlev Detleffsen Date: Tue, 16 Jun 2026 17:24:13 +0200 Subject: [PATCH 1/6] feat: add Docker deployment for MCP server Multi-stage Dockerfile (pnpm/turbo build -> node:22-alpine runtime) plus an entrypoint that lazily initializes docsets from config.yaml and starts the stdio MCP server. Docsets are mounted via volumes; symlinks are created inside the Linux container, avoiding the Windows symlink-permission problem on the host. Adds Dockerfile, docker-entrypoint.sh, .dockerignore and docs/docker.md; ignores .idea/ and npm lockfiles. --- .dockerignore | 15 ++++++ .gitignore | 8 ++- Dockerfile | 45 ++++++++++++++++ docker-entrypoint.sh | 18 +++++++ docs/docker.md | 121 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-entrypoint.sh create mode 100644 docs/docker.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7e7e0e8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +**/node_modules +**/dist +**/.turbo +**/*.tsbuildinfo +.git +.github +.idea +.vibe +.beads +.crowd +.knowledge/docsets +dist-local +*.tgz +test diff --git a/.gitignore b/.gitignore index 4db4d34..055d4fa 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,10 @@ node_modules # local build dist-local -*.tgz \ No newline at end of file +*.tgz + +# IDE +.idea/ + +# npm lockfiles (this is a pnpm workspace — only pnpm-lock.yaml is tracked) +**/package-lock.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0b942d4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# Stage 1 – build +FROM node:22-alpine AS build + +RUN corepack enable && corepack prepare pnpm@10.32.1 --activate + +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages/core/package.json packages/core/ +COPY packages/cli/package.json packages/cli/ +COPY packages/mcp-server/package.json packages/mcp-server/ +COPY packages/content-loader/package.json packages/content-loader/ + +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm run build + +# Stage 2 – runtime +FROM node:22-alpine AS runtime + +RUN corepack enable && corepack prepare pnpm@10.32.1 --activate + +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages/core/package.json packages/core/ +COPY packages/cli/package.json packages/cli/ +COPY packages/mcp-server/package.json packages/mcp-server/ +COPY packages/content-loader/package.json packages/content-loader/ + +RUN pnpm install --frozen-lockfile --prod --ignore-scripts + +COPY --from=build /app/packages/core/dist packages/core/dist +COPY --from=build /app/packages/cli/dist packages/cli/dist +COPY --from=build /app/packages/mcp-server/dist packages/mcp-server/dist +COPY --from=build /app/packages/content-loader/dist packages/content-loader/dist + +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Mount point for host data +WORKDIR /knowledge + +USER node + +ENTRYPOINT ["docker-entrypoint.sh"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..b3cc524 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +CONFIG="/knowledge/.knowledge/config.yaml" + +if [ ! -f "$CONFIG" ]; then + echo "ERROR: $CONFIG not found. Mount your .knowledge directory." >&2 + exit 1 +fi + +# Extract docset IDs from config.yaml and init each (init is safe to re-run: skips if already initialized) +grep -E '^\s+- id:' "$CONFIG" | sed 's/.*id: *//' | tr -d '\r' | while read -r id; do + echo "Init docset: $id" >&2 + # Send all init output to stderr; stdout must stay clean for the MCP JSON-RPC stream + node /app/packages/cli/dist/index.js init "$id" 1>&2 2>&1 || true +done + +exec node /app/packages/mcp-server/dist/bin.js diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..f0fd3c8 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,121 @@ +# Docker Deployment + +## Overview + +The Docker image for `agentic-knowledge-mcp` is based on a volume-mount approach: +the MCP server runs inside the container, while the source data lives on the host and is bound in +via mounts. Docset initialization (symlinks + metadata) is handled by the entrypoint on first start. + +## Why not initialize `local_folder` sources directly on a Windows host? + +`local_folder` sources create symlinks under `.knowledge/docsets/{id}/`. On Windows, creating +symlinks requires elevated privileges (e.g. Developer Mode or an elevated shell), which makes +initialization on a Windows host impractical for reusable, versioned docsets. + +Inside the container, Node.js runs on Linux/Alpine — there, regular `fs.symlink()` calls work +without elevated privileges. `init` is therefore executed **inside the container**, not on the +Windows host. + +## Volume layout + +``` +docker run -i \ + -v \.knowledge:/knowledge/.knowledge \ + -v \my-docs:/knowledge/my-docs:ro \ + agentic-knowledge-mcp +``` + +| Mount | Purpose | +|---|---| +| `\.knowledge` → `/knowledge/.knowledge` | `config.yaml` + persisted docsets (writable) | +| `\my-docs` → `/knowledge/my-docs:ro` | Source files (docs, knowledge, tests) — read-only | + +- Container WORKDIR: `/knowledge` +- Config discovery finds `/knowledge/.knowledge/config.yaml` (walk-up from CWD) +- Relative paths in `config.yaml` (e.g. `./my-docs`) resolve against `/knowledge` + +> `` is a placeholder for the root directory of your project; replace it with your own +> absolute path. `my-docs` is an example name for your source-data directory. + +## Entrypoint logic + +``` +1. Check whether /knowledge/.knowledge/config.yaml exists + → no: error message on stderr, exit 1 + +2. Extract docset IDs from config.yaml + (grep/sed on lines of the form "- id:", CRLF stripped via `tr -d '\r'`) + For each ID: + node /app/packages/cli/dist/index.js init + - Idempotency is handled by the init command itself (skips already-initialized docsets) + - All init output goes to stderr (1>&2 2>&1) so that stdout stays clean for the + JSON-RPC stream + - A failure of a single docset does not abort startup (|| true) + +3. Start the MCP server (stdio): + exec node /app/packages/mcp-server/dist/bin.js +``` + +The `init` command is idempotent: docsets that are already initialized (detected via +`.agentic-metadata.json`) are skipped. The entrypoint therefore calls `init` for all docsets on +every start — on the second container start this is effectively a no-op, because the metadata is +persisted in the writable `.knowledge` volume. + +> Note: ID extraction is a simple grep/sed over lines of the form `- id:` and assumes that format +> in `config.yaml` (it is not a full YAML parser). + +## Symlinks inside the container + +- The container runs on Linux/Alpine → `fs.symlink()` works without elevated privileges ✅ +- The Windows symlink-permission problem does not apply on Linux +- Symlinks point to mounted host paths, e.g. `/knowledge/my-docs/confluence/...` +- As long as the source volume is mounted, all symlinks are resolvable + +## Files + +| File | Purpose | +|---|---| +| `Dockerfile` ✅ | Multi-stage build: pnpm/turbo (build) → node:22-alpine (runtime) | +| `docker-entrypoint.sh` ✅ | Lazy init (see above) + MCP server start | +| `.dockerignore` ✅ | Excludes `node_modules`, `dist`, `.git`, `.turbo`, etc. | + +### Dockerfile (structure) + +**Stage 1 – build** (`node:22-alpine`): +- `corepack enable && corepack prepare pnpm@10.32.1 --activate` +- `pnpm install --frozen-lockfile` (incl. devDeps) +- `pnpm run build` (turbo builds all packages in parallel) + +**Stage 2 – runtime** (`node:22-alpine`): +- `corepack enable && corepack prepare pnpm@10.32.1 --activate` +- `pnpm install --frozen-lockfile --prod --ignore-scripts` +- Copy the `dist/` directories of all packages from stage 1 +- Unprivileged user `node` +- WORKDIR `/knowledge` (mount point) +- ENTRYPOINT `docker-entrypoint.sh` + +## MCP configuration (example) + +```json +{ + "mcpServers": { + "knowledge": { + "command": "docker", + "args": [ + "run", "--rm", "-i", + "-v", "\\.knowledge:/knowledge/.knowledge", + "-v", "\\my-docs:/knowledge/my-docs:ro", + "agentic-knowledge-mcp" + ] + } + } +} +``` + +## Open points + +- CI/CD pipeline (GitHub Actions) for automated image builds: TBD +- Image registry and versioning strategy (`:latest` vs. `:2.2.0`): TBD +- Behavior on `init` failure in the entrypoint: **resolved** — single docset failures are + tolerated (`|| true`) and the server still starts. It remains open whether a failed docset + should be signaled more strongly (e.g. exit code / healthcheck): TBD From ad129b4ea8fb4e2c27a1509af51a5ec0ce419612 Mon Sep 17 00:00:00 2001 From: Detlev Detleffsen Date: Thu, 18 Jun 2026 10:43:22 +0200 Subject: [PATCH 2/6] fix(docker): set USER root for build steps to support rootless Podman corepack enable creates symlinks in the root-owned /usr/local/bin. Under rootless Podman the build container defaults to a non-root uid, so this failed with EACCES. Docker builds as root by default and was unaffected. Add explicit USER root to both build and runtime stages before the corepack/pnpm install steps; the runtime stage still drops to USER node before the ENTRYPOINT. Mirrors the pattern already used in bruno-cli-mcp. --- Dockerfile | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Dockerfile b/Dockerfile index 0b942d4..67885cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,11 @@ # Stage 1 – build FROM node:22-alpine AS build +# Run install/build steps as root. Required for rootless Podman, where the +# container defaults to a non-root uid and corepack cannot create symlinks in +# the root-owned /usr/local/bin (EACCES). Docker builds as root by default. +USER root + RUN corepack enable && corepack prepare pnpm@10.32.1 --activate WORKDIR /app @@ -18,6 +23,11 @@ RUN pnpm run build # Stage 2 – runtime FROM node:22-alpine AS runtime +# Same as build stage: corepack needs root to write its symlinks under +# /usr/local/bin (rootless Podman defaults to a non-root uid). We switch to the +# unprivileged `node` user further down, before the ENTRYPOINT. +USER root + RUN corepack enable && corepack prepare pnpm@10.32.1 --activate WORKDIR /app From 4cad49869ecaaaa252dd6ff316442fd78e2a5482 Mon Sep 17 00:00:00 2001 From: Detlev Detleffsen Date: Sat, 27 Jun 2026 17:56:03 +0200 Subject: [PATCH 3/6] fix(docker): replace fragile YAML parsing with init-all CLI subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entrypoint grep/sed approach broke on tabs, inline comments, and other valid YAML formatting. Moving docset discovery to Node.js via a dedicated `init-all` CLI command uses js-yaml (already a dependency) for correct config parsing. Failures now cause a hard abort (fast-fail) instead of a silent || true — MCP server logs are rarely read so fail- fast makes broken starts visible. Also fixes deprecated --prod flag (pnpm v9+) and .gitignore missing trailing newline. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 +- Dockerfile | 2 +- docker-entrypoint.sh | 14 +----- .../cli/src/__tests__/cli-commands.test.ts | 8 +++- packages/cli/src/cli.ts | 2 + packages/cli/src/commands/init-all.ts | 43 +++++++++++++++++++ 6 files changed, 55 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/commands/init-all.ts diff --git a/.gitignore b/.gitignore index 055d4fa..2382d86 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,4 @@ dist-local .idea/ # npm lockfiles (this is a pnpm workspace — only pnpm-lock.yaml is tracked) -**/package-lock.json \ No newline at end of file +**/package-lock.json diff --git a/Dockerfile b/Dockerfile index 67885cd..3025294 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ COPY packages/cli/package.json packages/cli/ COPY packages/mcp-server/package.json packages/mcp-server/ COPY packages/content-loader/package.json packages/content-loader/ -RUN pnpm install --frozen-lockfile --prod --ignore-scripts +RUN pnpm install --frozen-lockfile --production --ignore-scripts COPY --from=build /app/packages/core/dist packages/core/dist COPY --from=build /app/packages/cli/dist packages/cli/dist diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index b3cc524..c368e85 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,18 +1,6 @@ #!/bin/sh set -e -CONFIG="/knowledge/.knowledge/config.yaml" - -if [ ! -f "$CONFIG" ]; then - echo "ERROR: $CONFIG not found. Mount your .knowledge directory." >&2 - exit 1 -fi - -# Extract docset IDs from config.yaml and init each (init is safe to re-run: skips if already initialized) -grep -E '^\s+- id:' "$CONFIG" | sed 's/.*id: *//' | tr -d '\r' | while read -r id; do - echo "Init docset: $id" >&2 - # Send all init output to stderr; stdout must stay clean for the MCP JSON-RPC stream - node /app/packages/cli/dist/index.js init "$id" 1>&2 2>&1 || true -done +node /app/packages/cli/dist/index.js init-all exec node /app/packages/mcp-server/dist/bin.js diff --git a/packages/cli/src/__tests__/cli-commands.test.ts b/packages/cli/src/__tests__/cli-commands.test.ts index 84fca31..62dfe67 100644 --- a/packages/cli/src/__tests__/cli-commands.test.ts +++ b/packages/cli/src/__tests__/cli-commands.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from "vitest"; import { initCommand } from "../commands/init.js"; +import { initAllCommand } from "../commands/init-all.js"; import { refreshCommand } from "../commands/refresh.js"; import { statusCommand } from "../commands/status.js"; @@ -13,6 +14,11 @@ describe("CLI Commands Validation", () => { expect(initCommand.description()).toContain("Initialize sources"); }); + it("should export init-all command with correct name", () => { + expect(initAllCommand.name()).toBe("init-all"); + expect(initAllCommand.description()).toContain("Initialize all docsets"); + }); + it("should export refresh command with correct name", () => { expect(refreshCommand.name()).toBe("refresh"); expect(refreshCommand.description()).toContain("Refresh sources"); @@ -24,8 +30,8 @@ describe("CLI Commands Validation", () => { }); it("should have proper command structure", () => { - // Check that commands are properly structured Commander objects expect(typeof initCommand.parse).toBe("function"); + expect(typeof initAllCommand.parse).toBe("function"); expect(typeof refreshCommand.parse).toBe("function"); expect(typeof statusCommand.parse).toBe("function"); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d9391d2..131d5e1 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -4,6 +4,7 @@ import { Command } from "commander"; import { initCommand } from "./commands/init.js"; +import { initAllCommand } from "./commands/init-all.js"; import { refreshCommand } from "./commands/refresh.js"; import { statusCommand } from "./commands/status.js"; import { createCommand } from "./commands/create.js"; @@ -19,6 +20,7 @@ export function runCli() { // Add commands program.addCommand(createCommand); program.addCommand(initCommand); + program.addCommand(initAllCommand); program.addCommand(refreshCommand); program.addCommand(statusCommand); diff --git a/packages/cli/src/commands/init-all.ts b/packages/cli/src/commands/init-all.ts new file mode 100644 index 0000000..0ce5244 --- /dev/null +++ b/packages/cli/src/commands/init-all.ts @@ -0,0 +1,43 @@ +import { Command } from "commander"; +import { ConfigManager } from "@codemcp/knowledge-core"; +import { initDocset } from "../api/init.js"; + +export const initAllCommand = new Command("init-all") + .description("Initialize all docsets from configuration (Docker entrypoint)") + .action(async () => { + const configManager = new ConfigManager(); + let config; + try { + ({ config } = await configManager.loadConfig(process.cwd())); + } catch (error) { + process.stderr.write( + `ERROR: Failed to load config: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exit(1); + } + + if (config.docsets.length === 0) { + process.stderr.write("No docsets configured.\n"); + return; + } + + for (const docset of config.docsets) { + process.stderr.write(`Initializing docset: ${docset.id}\n`); + try { + const result = await initDocset({ + docsetId: docset.id, + cwd: process.cwd(), + }); + if (result.alreadyInitialized) { + process.stderr.write(` -> already initialized, skipping.\n`); + } else { + process.stderr.write(` -> done (${result.totalFiles} files).\n`); + } + } catch (error) { + process.stderr.write( + `ERROR: Failed to initialize docset '${docset.id}': ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exit(1); + } + } + }); From 9663dd0bf1f7146453f4ded31c5091489bcbc5a6 Mon Sep 17 00:00:00 2001 From: Detlev Detleffsen Date: Sat, 27 Jun 2026 18:04:39 +0200 Subject: [PATCH 4/6] chore: bump version to 2.2.1 [skip ci] Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- packages/cli/package.json | 2 +- packages/content-loader/package.json | 2 +- packages/core/package.json | 2 +- packages/mcp-server/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 1764f04..3405148 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codemcp/knowledge", - "version": "2.2.0", + "version": "2.2.1", "description": "A Model Context Protocol server for agentic knowledge guidance with web-based documentation loading and intelligent search instructions", "type": "module", "main": "packages/cli/dist/index.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index c191f3b..c055788 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@codemcp/knowledge-cli", - "version": "2.2.0", + "version": "2.2.1", "description": "Command-line interface for agentic knowledge web content management", "type": "module", "main": "dist/exports.js", diff --git a/packages/content-loader/package.json b/packages/content-loader/package.json index 1f19b40..17f7263 100644 --- a/packages/content-loader/package.json +++ b/packages/content-loader/package.json @@ -1,6 +1,6 @@ { "name": "@codemcp/knowledge-content-loader", - "version": "2.2.0", + "version": "2.2.1", "description": "Web content loading and metadata management for agentic knowledge system", "type": "module", "main": "dist/index.js", diff --git a/packages/core/package.json b/packages/core/package.json index 943a91a..2ba94aa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@codemcp/knowledge-core", - "version": "2.2.0", + "version": "2.2.1", "description": "Core functionality for agentic knowledge guidance system", "type": "module", "main": "dist/index.js", diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 0ac9b29..68801f7 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@codemcp/knowledge-server", - "version": "2.2.0", + "version": "2.2.1", "description": "Lightweight MCP server for agentic knowledge guidance", "type": "module", "main": "dist/index.js", From 9d38f3585e8600f06a1213bb50c749eccf3aca01 Mon Sep 17 00:00:00 2001 From: Detlev Detleffsen Date: Sun, 28 Jun 2026 13:10:16 +0200 Subject: [PATCH 5/6] docs(adr): add ADR-002/ADR-003 and Docker deployment design doc - ADR-002: Windows runtime is container-only (Docker/Podman), no native execution - ADR-003: container image serves only pre-materialized local_folder docsets; git_repo/archive are rejected by the entrypoint and materialized on the host (loaders stay available in the npm package/CLI) - docker-deployment-design.md: design rationale, scope narrowed per ADR-003 Co-Authored-By: Claude Opus 4.8 --- .../adr/002-windows-runtime-container-only.md | 140 +++++++++++++ docs/adr/003-container-local-folder-only.md | 168 ++++++++++++++++ docs/docker-deployment-design.md | 184 ++++++++++++++++++ 3 files changed, 492 insertions(+) create mode 100644 docs/adr/002-windows-runtime-container-only.md create mode 100644 docs/adr/003-container-local-folder-only.md create mode 100644 docs/docker-deployment-design.md diff --git a/docs/adr/002-windows-runtime-container-only.md b/docs/adr/002-windows-runtime-container-only.md new file mode 100644 index 0000000..b909309 --- /dev/null +++ b/docs/adr/002-windows-runtime-container-only.md @@ -0,0 +1,140 @@ +# ADR-002: Windows Runtime — Container-Only (Docker/Podman), no native execution + +**Date:** 2026-06-08 +**Status:** Accepted +**Deciders:** Detlev Detleffsen +**Technical Story:** Decide how the MCP server runs on Windows, and whether the native-Windows symlink fallback (`fix/windows-symlink-support`, commit `4ca5566`) is needed + +--- + +## Context and Problem Statement + +`local_folder` docsets are not copied into `.knowledge/`; they are exposed via **symlinks**. `createSymlinks` (`packages/core/src/paths/symlinks.ts`) links each top-level entry of the source folder into `.knowledge/docsets/{id}/`, and the searcher walks those links (see commit `147e18d`, which made `walkFiles` follow symlinks). The rest of the system depends on these being _live_ links: + +- `refresh` skips `local_folder` sources entirely ("they are symlinked", `refresh.ts:174`). +- `removeSymlinks`/`cleanup` only remove entries where `entry.isSymbolicLink()` is true. + +On **native Windows** this breaks down. `fs.symlink()` for a _file_ requires the `SeCreateSymbolicLinkPrivilege`, which by default only Administrators hold. Commit `4ca5566` worked around this with a platform branch: junctions for directories, **`copyFile` for files**. That copy fallback is a poor fit for the codebase: + +1. **Stale snapshots** — a copied file is a point-in-time copy; edits to the source never appear (and `refresh` skips `local_folder`, so they never get refreshed). +2. **Leaks on cleanup** — `removeSymlinks` skips non-symlink entries, so copied files are never removed. +3. **Inconsistent semantics** — half the entries (dirs) are live junctions, the other half (files) are dead copies. + +The upstream author wants the copy fallback removed. The alternative for native Windows — "Developer Mode + plain symlink" — restores live semantics but requires the user to **enable Windows Developer Mode** (a one-time action that itself needs admin rights and is frequently disabled by corporate group policy). + +This raises the underlying question: **must the server run natively on Windows at all, or is a container the supported Windows runtime?** + +--- + +## Decision Drivers + +| # | Criterion | Weight | Rationale | +| --- | ------------------------------------------------------ | :----: | ---------------------------------------------------------------------------------- | +| 1 | **Symlink correctness & consistency** (live, no stale) | 5 | The system assumes live links; stale copies + cleanup leaks are latent data bugs | +| 2 | **Works on locked-down / corporate Windows** | 5 | No admin, no group-policy assumptions; Developer Mode is often GPO-blocked | +| 3 | **Implementation & maintenance burden** | 4 | Platform-specific code paths are a recurring cost and a source of the bugs above | +| 4 | **`local_folder` data-access correctness at runtime** | 4 | The source folder must be reachable, with the right path, wherever the server runs | +| 5 | **Operational / setup complexity for the user** | 3 | One-time enablement steps, volume layout, proxy config | +| 6 | **File-walk performance** | 2 | Search walks many small files; mount/filesystem choice affects latency | + +Total weight: **23** + +--- + +## Considered Options + +### Option A — Native Windows with junction/copy fallback (commit `4ca5566`) + +Run the server natively (`node`/`npx`) on Windows. Directories are linked as junctions (no elevation needed); files are `copyFile`-d into the docset directory. + +### Option B — Native Windows with Developer Mode + plain symlink + +Run the server natively on Windows, but require the user to enable **Developer Mode**. Then `fs.symlink` works for both files and directories without elevation, so `createSymlinks` collapses back to a single plain `fs.symlink` for all platforms (current `main` behaviour). + +### Option C — Container-only runtime (Docker/Podman), `.knowledge/` on a Linux-native volume + +Do not support native-Windows execution. On Windows the server runs **exclusively inside the Docker/Podman image** (`feat(docker)`, commit `f06dc4a`). Inside the container `process.platform === "linux"`, so plain `fs.symlink` always applies — no junction, no copy, no Developer Mode. The writable cache (`.knowledge/`, where symlinks are created) lives on a Linux-native filesystem (a named volume or the WSL2 distro filesystem), never on a `C:\` bind mount. + +--- + +## Decision Outcome + +**Chosen option: Option C — Container-only runtime (Docker/Podman).** + +Native-Windows execution is explicitly **out of scope**. Consequences for the code base: + +- **Do not merge** `fix/windows-symlink-support` / commit `4ca5566`. `createSymlinks` stays as the plain `fs.symlink` on `main` (no `win32` branch, no junction, no copy). +- The Windows runtime is the container; the symlink mechanics are therefore always the Linux path. + +### Rationale + +Inside the container the entire Windows symlink-privilege problem **disappears** — there is no `SeCreateSymbolicLinkPrivilege`, no Developer Mode, no junction/copy special-casing. That gives the same _live_-symlink semantics the rest of the system already assumes (driver 1) and keeps a **single code path** (driver 3), eliminating the stale-copy and cleanup-leak bugs in one move. + +Option B also restores live semantics and removes the platform branch, but it hard-depends on Developer Mode, which needs admin to enable and is commonly **disabled by group policy on managed corporate machines** (driver 2) — exactly the environment this is used in. Container-only avoids that dependency entirely. Podman (rootless) is an acceptable, corporate-friendly runtime alongside Docker Desktop. + +The honest cost of Option C is **driver 4**: `local_folder` sources must now be made reachable _inside_ the container with a matching path, and the writable cache must sit on a Linux-native filesystem. These are operational requirements, captured below — not blockers. + +### Positive Consequences + +- Single, platform-agnostic symlink code path; the `win32` branch and its latent bugs are dropped. +- No admin rights, no Developer Mode, no group-policy dependency on Windows. +- Live symlink semantics everywhere → `refresh` and `cleanup` behave as designed. +- Works identically under Docker Desktop and Podman. + +### Negative Consequences + +- `local_folder` sources must be bind-mounted into the container and referenced by their **in-container** path (relative to the workspace mount), not a `C:\…` host path. +- The writable cache `.knowledge/` must live on a Linux-native filesystem (named volume or WSL2 fs). Putting it on a `C:\` bind mount makes symlink _creation_ fail or be unreliable (Docker Desktop / WSL2 mount drivers do not reliably support creating symlinks on Windows-backed mounts), and is slow for file walking. +- A container runtime must be installed and, on corporate networks, `init_docset`'s outbound git/https may need `HTTP_PROXY`/`HTTPS_PROXY`. +- No native-Windows fallback: if no container runtime is available, there is no supported Windows path (Linux/macOS native execution is unaffected). + +--- + +## Pugh Matrix + +**Scoring:** −1 = worse than baseline, 0 = same as baseline, +1 = better than baseline +**Baseline:** Option A (native Windows, junction/copy fallback) + +> Each score is multiplied by its criterion weight; column totals are weighted sums. + +| Criterion | Weight | A (Baseline) | B (DevMode + symlink) | C (Container-only) | +| ---------------------------------------- | :----: | :----------: | --------------------- | ------------------ | +| Symlink correctness & consistency | 5 | 0 | +1 × 5 = **+5** | +1 × 5 = **+5** | +| Works on locked-down / corporate Windows | 5 | 0 | −1 × 5 = **−5** ¹ | 0 × 5 = **0** ² | +| Implementation & maintenance burden | 4 | 0 | +1 × 4 = **+4** | +1 × 4 = **+4** ³ | +| `local_folder` data-access correctness | 4 | 0 | 0 × 4 = **0** | −1 × 4 = **−4** ⁴ | +| Operational / setup complexity | 3 | 0 | −1 × 3 = **−3** ⁵ | −1 × 3 = **−3** ⁶ | +| File-walk performance | 2 | 0 | 0 × 2 = **0** | 0 × 2 = **0** ⁷ | +| **Weighted total** | **23** | **0** | **+1** | **+2** | + +**Notes:** + +¹ Developer Mode needs admin to enable and is frequently disabled by corporate group policy → may be impossible for the target user. Junction/copy (baseline) needs no admin, hence baseline scores higher here. +² The container needs no symlink privilege at all; a runtime must be installed, but Podman runs rootless. Roughly parity with the no-admin baseline. +³ No `win32` branch (plain `fs.symlink`); the only added artifact is documentation of the volume layout, not code. +⁴ Sources must be mounted into the container with a matching path, and `.knowledge/` must be on a Linux-native fs — genuine extra moving parts vs. direct native file access. +⁵ One-time Developer Mode enablement (admin, possibly policy-blocked). +⁶ Install a container runtime; configure named volume + bind mount; pass proxy env on corporate networks. +⁷ Placement-dependent: on a Linux-native volume, parity with native; on a `C:\` bind mount, worse — avoided by the volume-layout requirement. + +Option C wins on the high-weight drivers (correctness, corporate-Windows viability, maintenance) at the cost of runtime data-access setup. The qualitative tiebreaker over B is **driver 2**: on a locked-down corporate machine, Developer Mode may simply not be available, whereas a container does not depend on it. + +--- + +## Implementation Notes (volume layout on Windows) + +1. **`.knowledge/` on a Linux-native filesystem** — use a Docker/Podman **named volume** for the workspace/cache, or keep the project inside the **WSL2 distro filesystem** (`\\wsl$\…`). Do _not_ place the writable `.knowledge/` on a `C:\` bind mount: symlink creation is unreliable and file walking is slow. +2. **`local_folder` sources** — bind-mount the source directory into the container and reference it by its in-container path (e.g. relative to the `/knowledge` workspace mount, the container `WORKDIR`) in `config.yaml`. A symlink _target_ may point into a read-only host bind mount (reading through it works); only symlink _creation_ must happen on the Linux-native fs. +3. **Podman specifics** — append `:Z`/`:z` to bind mounts on SELinux hosts; the mounted workspace must be writable by uid 1000 (`USER node`). +4. **Outbound network** — `init_docset` clones via git/https; on corporate networks pass `HTTP_PROXY`/`HTTPS_PROXY` (and any TLS settings) via `-e`. + +--- + +## Links + +- Commit `4ca5566` — `fix(core): support Windows symlinks via junction/copy fallback` (the rejected native-Windows fallback) +- Commit `147e18d` — `fix(search): follow symlinks when walking docset directories` +- Commit `f06dc4a` — `feat(docker): ship the MCP server as a Docker image` +- `docs/docker-deployment-design.md` — Docker deployment design +- [Windows: Enable your device for development (Developer Mode)](https://learn.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development) +- [Nygard ADR template](https://github.com/joelparkerhenderson/architecture-decision-record/blob/main/locales/en/templates/decision-record-template-by-michael-nygard/index.md) diff --git a/docs/adr/003-container-local-folder-only.md b/docs/adr/003-container-local-folder-only.md new file mode 100644 index 0000000..5e43c9b --- /dev/null +++ b/docs/adr/003-container-local-folder-only.md @@ -0,0 +1,168 @@ +# ADR-003: Container image serves only pre-materialized `local_folder` docsets + +**Date:** 2026-06-28 +**Status:** Accepted +**Deciders:** Detlev Detleffsen +**Technical Story:** Decide which docset source types the **Docker/Podman image** initializes at +container start, after observing that `git_repo`/`archive` auto-init pulls a chain of runtime +dependencies (git binary, boot-time network/proxy, writable `/knowledge/.tmp`) into the image. + +--- + +## Context and Problem Statement + +A docset source (`DocsetConfig.sources[]`, `packages/core/src/types.ts`) is one of three types: + +| Type | What init does | Lands in `.knowledge` as | Runtime needs | +| -------------- | --------------------------------------------------------------- | ------------------------ | ------------------------------------------------------ | +| `local_folder` | `createSymlinks` links a mounted folder | **live symlinks** | nothing | +| `archive` | download/read + extract into a scratch dir, copy filtered files | **materialized copy** | network (remote) + writable `cwd/.tmp` | +| `git_repo` | `git ls-remote` + `git clone` (`execSync`) | **materialized copy** | **`git` binary** + network/proxy + writable `cwd/.tmp` | + +The container's `docker-entrypoint.sh` runs `init-all` at startup, which initializes **every** +configured docset. For `git_repo`/`archive` that means the container performs, **at boot**, the same +materialization a user could do once on the host: + +- **`git` binary** must be baked into the image (it is not — see the original B1 finding; `node:22-alpine` has no git). +- **Network egress / `HTTP(S)_PROXY`** must be reachable _at container start_, not just on demand. +- A **writable `cwd/.tmp`** (`/knowledge/.tmp`, created by `ArchiveLoader`/`GitRepoLoader`) is + required, but `/knowledge` is the image-owned `WORKDIR` (root, mode 755); under `USER node` + (uid 1000) the writable mount only sits one level down at `/knowledge/.knowledge` → `EACCES` risk. +- `init-all` is **fail-fast** under `set -e`: one unreachable remote blocks startup for all docsets. + +Crucially, this contradicts the container's _raison d'être_: **ADR-002** chose container-only Windows +runtime **solely** to get Linux `fs.symlink` semantics — i.e. the `local_folder` path. `git_repo` +and `archive` produce a plain copy that needs none of the container's symlink machinery; they can be +materialized on any host, before the container ever runs. + +The question: **should the image materialize `git_repo`/`archive` at boot, or should it be a pure +"mount pre-materialized docsets and serve" runtime?** + +--- + +## Decision Drivers + +| # | Criterion | Weight | Rationale | +| --- | ----------------------------------------------------------- | :----: | ------------------------------------------------------------------------- | +| 1 | **Startup robustness on locked-down / corporate hosts** | 5 | No git, no boot-time network/proxy, no `/knowledge/.tmp` write dependency | +| 2 | **Startup predictability & honesty** | 5 | No silent incompleteness, no cryptic mid-boot failure; deterministic | +| 3 | **Image simplicity & attack surface** | 3 | No `git` binary, smaller image, fewer CVEs | +| 4 | **Consistency with the container's purpose (ADR-002)** | 4 | The container exists for Linux symlink semantics = `local_folder` | +| 5 | **Self-describing reproducibility** (config alone → docset) | 4 | What we give up: `git_repo` config + URL reproduces a docset anywhere | + +Total weight: **21** + +--- + +## Considered Options + +### Option A — Image supports all three source types (status-quo intent) + +`init-all` materializes `git_repo`/`archive` at boot. Requires `apk add git`, boot-time network/proxy, +and a writable scratch dir. The baseline. + +### Option B — Image = `local_folder` only; entrypoint **rejects** `git_repo`/`archive` (variant a) + +Docsets must be **pre-materialized** (cloned/extracted on the host, by hand or a host-side script) +and bind-mounted, then configured as `local_folder`. If a `git_repo`/`archive` source is present in +`config.yaml`, `init-all` exits non-zero with a clear, actionable message and the server does not +start. The `git_repo`/`archive` loaders remain fully available in the **npm package / CLI** for +host-side use — no product/API change. + +### Option C — Image = `local_folder` default; **tolerate** `git_repo`/`archive` by skipping with a warning (variant b) + +Same as B, but instead of failing, `init-all` skips non-`local_folder` sources with a stderr warning +and still starts the server with whatever `local_folder` docsets initialized. + +--- + +## Decision Outcome + +**Chosen option: Option B — `local_folder`-only image, entrypoint rejects `git_repo`/`archive`.** + +The container becomes a pure **"mount pre-materialized docsets and serve"** runtime. Consequences: + +- The runtime image needs **no `git`** and does **no network fetch at startup** — the original B1 + gap (missing `apk add git`) is resolved _by scope_, not by adding git. +- `init-all` gains a guard: it inspects every docset's `sources[]`; if any is `git_repo` or + `archive`, it writes a clear error to **stderr** (R1) and `process.exit(1)` **before** initializing + anything, so the failure is deterministic and explained. +- `git_repo`/`archive` stay in the npm package/CLI (`init_docset`, `ade-knowledge init`), so users + who want self-describing fetch keep it on the host — no capability is removed from the product. + +### Rationale + +Option B aligns the image with exactly the reason the container exists (ADR-002, driver 4) and strips +the boot-time fragility — git binary, network/proxy, `/knowledge/.tmp` writability, fail-fast on a +remote (drivers 1–3). The honest cost is **driver 5**: a `local_folder` docset is not self-describing, +so the materialized data must be provided/mounted alongside `config.yaml`. For the corporate, +mount-and-serve deployment this ADR targets, that is an accepted trade. + +B beats C on **driver 2**: C would start the server with silently incomplete docsets (a `search_docs` +that quietly misses content), whereas B fails loudly and tells the operator what to fix — consistent +with the project's fail-fast startup stance and the R2 "infra/input error ≠ silent result" spirit. + +### Positive Consequences + +- No `git` in the image; no boot-time network/proxy dependency; smaller attack surface. +- Deterministic startup: the only init work is creating Linux symlinks for mounted folders. +- The `/knowledge/.tmp` uid-1000 writability question disappears (no extraction/clone at runtime). +- Single, clear image contract that matches ADR-002. + +### Negative Consequences + +- `local_folder` docsets are **not self-describing**: the materialized source must be produced on + the host and bind-mounted; `config.yaml` alone is not enough to reproduce a docset. +- Users who relied on the container cloning a `git_repo` must move that step to the host (one-time + `git clone`/unzip, or a small host-side prep script), then mount the result. +- A misconfigured docset (`git_repo`/`archive`) fails the whole container start (by design). + +--- + +## Pugh Matrix + +**Scoring:** −1 = worse than baseline, 0 = same, +1 = better. +**Baseline:** Option A (image supports all three types). + +| Criterion | Weight | A (Baseline) | B (reject, variant a) | C (skip, variant b) | +| -------------------------------------------- | :----: | :----------: | --------------------- | ------------------- | +| Startup robustness (corporate/locked-down) | 5 | 0 | +1 × 5 = **+5** | +1 × 5 = **+5** | +| Startup predictability & honesty | 5 | 0 | +1 × 5 = **+5** | 0 × 5 = **0** ¹ | +| Image simplicity & attack surface | 3 | 0 | +1 × 3 = **+3** | +1 × 3 = **+3** | +| Consistency with container purpose (ADR-002) | 4 | 0 | +1 × 4 = **+4** | +1 × 4 = **+4** | +| Self-describing reproducibility | 4 | 0 | −1 × 4 = **−4** ² | −1 × 4 = **−4** ² | +| **Weighted total** | **21** | **0** | **+13** | **+8** | + +**Notes:** + +¹ Skipping silently starts a server whose docsets are incomplete; `search_docs` returns partial +results with no signal → not an improvement in predictability over the baseline. +² Both B and C drop the config-only-reproduces-a-docset property that `git_repo` gives; the +materialized data must be mounted. + +Option B wins; the qualitative tiebreaker over C is **driver 2** — fail loud and actionable beats +serve-silently-incomplete. + +--- + +## Implementation Notes + +1. **Entrypoint guard** — `packages/cli/src/commands/init-all.ts` checks each docset's `sources[]` + before initializing. On any `git_repo`/`archive` source: one stderr line naming the docset and the + offending type, a pointer to mount it as `local_folder`, then `process.exit(1)`. +2. **No `git` in the image** — the runtime `Dockerfile` stage deliberately omits `apk add git`. +3. **Docs** — `docs/docker.md` states the contract (materialize on host → mount RO → configure as + `local_folder`); `docs/docker-deployment-design.md` scope narrowed accordingly; CLAUDE.md drops + the "runtime + git" note. +4. **Package unchanged** — `git_repo`/`archive` loaders remain in `content-loader` and the CLI for + host-side materialization; this ADR constrains only the _container image_ contract. + +--- + +## Links + +- **ADR-002** — Windows runtime container-only (the symlink-semantics rationale this ADR builds on) +- `docs/docker.md` — container delivery (operational reference) +- `docs/docker-deployment-design.md` — Docker deployment design +- `packages/content-loader/src/content/git-repo-loader.ts`, `archive-loader.ts` — the loaders kept in the package +- [Nygard ADR template](https://github.com/joelparkerhenderson/architecture-decision-record/blob/main/locales/en/templates/decision-record-template-by-michael-nygard/index.md) diff --git a/docs/docker-deployment-design.md b/docs/docker-deployment-design.md new file mode 100644 index 0000000..f6091bd --- /dev/null +++ b/docs/docker-deployment-design.md @@ -0,0 +1,184 @@ +# Design: Docker-Auslieferung für agentic-knowledge-mcp + +> Status: **Draft** · 2026-06-05 · committed for review — finale Form noch mit dem Tool-Autor +> abzustimmen (s. ADR-003) +> Referenz-Setup: `bruno-mcp` (funktionierendes Multi-Stage-Alpine-Image) +> +> **Scope-Update (ADR-003):** Das Image bedient ausschließlich **vor-materialisierte +> `local_folder`-Docsets** ("mount & serve"). `git_repo`/`archive` werden vom Entrypoint +> abgelehnt und auf dem **Host** materialisiert (bleiben im npm-Paket/CLI verfügbar). Folge: +> **kein `git` im Runtime-Image**, kein Netz beim Start. Die git/archive-bezogenen Passagen unten +> beschreiben das Paket-/Host-Verhalten, **nicht** das Image. + +## 1. Ziel & Scope + +Den MCP-Server `@codemcp/knowledge` (Paket `@codemcp/knowledge-server`) als +eigenständiges Docker-Image ausliefern, das über **stdio** (JSON-RPC) von einem +MCP-Client (`docker run -i`) angesprochen wird. + +**In Scope (dieser Schritt):** + +- Multi-Stage-Dockerfile (Build mit pnpm/turbo → schlankes Runtime-Image). +- `.dockerignore`. +- Lokaler Build + Smoke-Test über stdio (`initialize` / `tools/list`). +- `mcp.json`-Beispiel + README-Abschnitt. + +**Out of Scope (später):** + +- Registry-Push (GHCR/Docker Hub), CI-Workflow, Versions-Tags/OCI-Labels in CI. +- Voll vorinitialisierte Docset-Images ("baked-in"). + +## 2. Datenmodell-Entscheidung: Laufzeit-Init + beschreibbares Volume + +Gewählt: **generisches Image, Docsets werden zur Laufzeit geladen.** Begründung +ergibt sich aus dem Laufzeitverhalten des Servers: + +| Verhalten | Quelle | Konsequenz fürs Image | +| --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Config-Discovery läuft per **cwd-Aufwärtssuche** nach `.knowledge/config.yaml` | `core/config/discovery.ts` | Container-`WORKDIR` = Mount-Punkt; Nutzer mountet sein Projekt dorthin | +| `init_docset` nutzt `process.cwd()`; Docsets landen relativ zur Config (`projectRoot = dirname(dirname(configPath))`) | `mcp-server/server.ts`, `git-repo-loader.ts` | Volume muss **read-write** sein und persistieren | +| Git-Clone/`ls-remote` via `execSync("git …")`; Temp unter `cwd/.tmp` | `content-loader/git-repo-loader.ts` | nur **Host/Paket** — im Image **nicht** unterstützt (ADR-003); kein `git` im Runtime-Image | +| Archive: `tar` (gebündelt) + `adm-zip` (external) | `archive-loader.ts` | s. Dependency-Analyse | + +→ Das Image bleibt **domänen-agnostisch**: keine Collection-/Docset-Namen, keine +Config eingebacken. Genau wie heute via `npx`, nur mit gebündeltem Node (ohne `git` — +Image bedient nur `local_folder`, ADR-003). + +## 3. Dependency-Analyse (entscheidet die Runtime-Schlankheit) + +`packages/mcp-server/tsup.config.ts`: + +```ts +bundle: true, +external: ["@modelcontextprotocol/sdk", "adm-zip"], +noExternal: ["@codemcp/knowledge-core", "@codemcp/knowledge-content-loader"], +``` + +`bundle: true` + `noExternal` → `core` und `content-loader` (und transitiv ihre +nicht-externalisierten Deps: `js-yaml`, `minisearch`, `tar`, `simple-git`) werden +**in `dist/bin.js` gebündelt**. Die **einzigen** echten Laufzeit-`node_modules` +sind die beiden Externals: + +- `@modelcontextprotocol/sdk` +- `adm-zip` + +Bestätigung über das npm-Distributionsmodell: das publizierte Paket liefert nur +`packages/*/dist` + die Sub-Paket-Deps (sdk, adm-zip, commander) — alles andere +steckt im Bundle. Damit ist die Runtime sehr klein. + +**`git`-CLI-Binary:** `content-loader` clont per `execSync("git …")` — aber nur für +`git_repo`-Quellen. Per **ADR-003** unterstützt das Image diese nicht (sie werden auf dem Host +materialisiert), daher **kein `apk add git`** im Runtime-Image. Das `git`-Binary ist nur für die +Host-/Paket-Nutzung der CLI relevant. + +## 4. Build-Architektur (Multi-Stage) + +``` +┌── Stage build (node:22-alpine + corepack pnpm@10.32.1) ──┐ +│ COPY lockfile + workspace + alle packages/*/package.json │ +│ pnpm install --frozen-lockfile (HUSKY=0) │ +│ COPY sources │ +│ pnpm run build (turbo → tsup) │ +│ pnpm --filter @codemcp/knowledge-server deploy --prod \ │ +│ /deploy → flacht pnpm-Symlinks zu echtem │ +│ node_modules (sdk, adm-zip) + dist │ +└──────────────────────────────────────────────────────────┘ +┌── Stage runtime (node:22-alpine) ───────────────────────┐ +│ (kein git — ADR-003: nur local_folder im Image) │ +│ pnpm install --frozen-lockfile --prod --ignore-scripts │ +│ COPY --from=build packages/*/dist (gebaute Bundles) │ +│ COPY docker-entrypoint.sh → /usr/local/bin │ +│ WORKDIR /knowledge (Mount-Punkt für .knowledge/) │ +│ USER node │ +│ ENTRYPOINT ["docker-entrypoint.sh"] (init-all + server) │ +└──────────────────────────────────────────────────────────┘ +``` + +Designentscheidungen: + +- **Basis `node:22-alpine`** — gespiegelt vom funktionierenden bruno-mcp-Setup + (Projekt verlangt `node >= 20`). +- **`corepack`** aktiviert exakt `pnpm@10.32.1` (aus `packageManager`-Feld). +- **`HUSKY=0`** beim Install — `prepare: husky` würde sonst ohne `.git` brechen. + **Kein** `--ignore-scripts` (esbuild/tsup brauchen ihren Postinstall). +- **Runtime-`node_modules`**: ursprünglich war `pnpm deploy --prod` vorgesehen + (flacht symlink-basierte node_modules zu einem prod-only Verzeichnis). + **Umgesetzt wurde der dokumentierte Fallback**: im Runtime-Stage erneut + `pnpm install --frozen-lockfile --prod --ignore-scripts` über die kopierten + `package.json` + Lockfile, dann die gebauten `packages/*/dist` aus dem + Build-Stage kopieren. Die CLI/Server-Bundles ziehen `core`/`content-loader` + ohnehin per tsup `noExternal` ein; echte Runtime-Externals sind nur + sdk/adm-zip/commander. +- **Entrypoint statt direktem `node bin.js`**: `docker-entrypoint.sh` ruft erst + `init-all` (Laufzeit-Init aller Docsets, idempotent, fail-fast unter `set -e`), + dann `exec node …/bin.js`. Details in `docs/docker.md`. +- **`WORKDIR /knowledge`** statt `/etc/bruno` — read-**write** Mount, weil + Docsets geschrieben/gecacht werden (anders als bruno: read-only). +- **`USER node` (uid 1000)** — gemountetes Volume muss für uid 1000 schreibbar + sein (in Doku vermerken). + +## 5. Netzwerk + +Anders als bruno-mcp (brauchte `--network host`, um eine lokale API zu erreichen) +benötigt dieser Server nur **Standard-Egress** zum Klonen öffentlicher Git-Repos. +Default-Bridge genügt; kein Host-Networking nötig. + +## 6. Nutzung (Soll) + +`mcp.json` / Client-Config: + +```json +{ + "mcpServers": { + "agentic-knowledge": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-v", + "${workspaceFolder}/.knowledge:/knowledge/.knowledge", + "-v", + "${workspaceFolder}/my-docs:/knowledge/my-docs:ro", + "agentic-knowledge-mcp:latest" + ] + } + } +} +``` + +Voraussetzung: unter dem (beschreibbaren) `.knowledge`-Mount liegt `config.yaml`. +Config-Discovery läuft aufwärts ab `WORKDIR /knowledge`. `init_docset` lädt Docsets +in dasselbe persistente Volume. Konkrete Volume-/Schreibrechte-Details (uid 1000): +`docs/docker.md`. + +## 7. R1 — stdout-Hygiene (übernommen) + +Der MCP-Kanal _ist_ stdout. Server loggt bereits ausschließlich nach stderr +(`console.error`, s. `server.ts` / `bin.ts`). Git-`execSync` läuft mit +`stdio: "pipe"` → kein Fremd-Output auf stdout. Keine Code-Änderung nötig. + +## 8. Akzeptanzkriterien (lokaler Schritt) + +1. `docker build` erzeugt das Image ohne Fehler. +2. `initialize` + `tools/list` über `docker run -i` liefern valide JSON-RPC- + Antworten; `tools/list` zeigt `search_docs`, `list_docsets`, `init_docset`. +3. Bei gemountetem Volume mit `.knowledge/config.yaml` erscheinen die + konfigurierten Docsets in der Tool-Beschreibung. +4. (Optional) `init_docset` eines Git-Docsets klont erfolgreich (git + Netz). + +## 9. Risiken / offene Punkte + +- `pnpm deploy`-Variabilität → **erledigt**: stattdessen Fallback (Runtime-`pnpm +install --prod` + `dist`-Copy) umgesetzt. +- Volume-Schreibrechte für uid 1000 (Host-Ownership) — typische Docker-Stolperfalle; + dokumentiert in `docs/docker.md`. +- `tar`/`adm-zip` im Bundle vs. external: adm-zip bewusst external (CommonJS) — + muss in Runtime-node_modules vorhanden sein (durch den prod-`pnpm install` abgedeckt). +- **`git`-Binary im Runtime-Image** — **erledigt durch ADR-003**: das Image bedient nur + `local_folder`, `git_repo`/`archive` werden vom Entrypoint abgelehnt → bewusst **kein** `git` + im Runtime-Image. Materialisierung von git/archive passiert auf dem Host (CLI/Paket). + +``` + +``` From 4ed0a9e568745582fe929bf54a07b38ef6d91539 Mon Sep 17 00:00:00 2001 From: Detlev Detleffsen Date: Mon, 29 Jun 2026 10:36:02 +0200 Subject: [PATCH 6/6] docs(docker): align deployment design with ADR-003 and cut redundancy The docker-deployment-design.md draft still carried pre-ADR-003 git/network assumptions and duplicated operative content from docker.md. Both surfaced as drift risk: a stale "needs egress to clone git repos" network section and an acceptance criterion testing in-image git cloning, neither of which the local_folder-only image does. - Section 5 (Netzwerk): image needs no network at startup; git-clone egress is marked as host/CLI behaviour, not the image. - Acceptance criterion 4: replaced the in-image git-clone scenario with a local_folder/init-all criterion; git/archive materialization is host-side. - Removed the mcp.json block and the Dockerfile stage diagram (both duplicate docs/docker.md), replacing them with pointers; the design doc keeps only the rationale (the "why"). docker.md remains the operative reference; the design doc holds the rationale. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/docker-deployment-design.md | 71 +++++---------- docs/docker.md | 143 ++++++++++++++++++++++--------- 2 files changed, 125 insertions(+), 89 deletions(-) diff --git a/docs/docker-deployment-design.md b/docs/docker-deployment-design.md index f6091bd..6bcd78d 100644 --- a/docs/docker-deployment-design.md +++ b/docs/docker-deployment-design.md @@ -73,26 +73,9 @@ Host-/Paket-Nutzung der CLI relevant. ## 4. Build-Architektur (Multi-Stage) -``` -┌── Stage build (node:22-alpine + corepack pnpm@10.32.1) ──┐ -│ COPY lockfile + workspace + alle packages/*/package.json │ -│ pnpm install --frozen-lockfile (HUSKY=0) │ -│ COPY sources │ -│ pnpm run build (turbo → tsup) │ -│ pnpm --filter @codemcp/knowledge-server deploy --prod \ │ -│ /deploy → flacht pnpm-Symlinks zu echtem │ -│ node_modules (sdk, adm-zip) + dist │ -└──────────────────────────────────────────────────────────┘ -┌── Stage runtime (node:22-alpine) ───────────────────────┐ -│ (kein git — ADR-003: nur local_folder im Image) │ -│ pnpm install --frozen-lockfile --prod --ignore-scripts │ -│ COPY --from=build packages/*/dist (gebaute Bundles) │ -│ COPY docker-entrypoint.sh → /usr/local/bin │ -│ WORKDIR /knowledge (Mount-Punkt für .knowledge/) │ -│ USER node │ -│ ENTRYPOINT ["docker-entrypoint.sh"] (init-all + server) │ -└──────────────────────────────────────────────────────────┘ -``` +Die konkrete Stage-Struktur des `Dockerfile` (Build → Runtime, kopierte `dist/`, +ENTRYPOINT) steht in **`docs/docker.md`** → _Dockerfile (structure)_ und wird hier +nicht dupliziert. Dieser Abschnitt hält nur die **Begründungen** fest. Designentscheidungen: @@ -119,38 +102,23 @@ Designentscheidungen: ## 5. Netzwerk -Anders als bruno-mcp (brauchte `--network host`, um eine lokale API zu erreichen) -benötigt dieser Server nur **Standard-Egress** zum Klonen öffentlicher Git-Repos. -Default-Bridge genügt; kein Host-Networking nötig. +Per **ADR-003** lädt das Image beim Start nichts nach (`local_folder`-only, +mount & serve) → es benötigt **kein Netzwerk beim Start**. Anders als bruno-mcp +(brauchte `--network host`, um eine lokale API zu erreichen) ist hier weder +Host-Networking noch Egress nötig. + +Das Klonen öffentlicher Git-Repos (`execSync("git …")`) braucht Standard-Egress — +das passiert aber **auf dem Host** über die CLI/das npm-Paket, nicht im Image. ## 6. Nutzung (Soll) -`mcp.json` / Client-Config: - -```json -{ - "mcpServers": { - "agentic-knowledge": { - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-v", - "${workspaceFolder}/.knowledge:/knowledge/.knowledge", - "-v", - "${workspaceFolder}/my-docs:/knowledge/my-docs:ro", - "agentic-knowledge-mcp:latest" - ] - } - } -} -``` +Design-relevante Eigenschaft: unter dem (beschreibbaren) `.knowledge`-Mount liegt +`config.yaml`; Config-Discovery läuft aufwärts ab `WORKDIR /knowledge`, und +`init_docset` lädt Docsets in dasselbe persistente Volume. -Voraussetzung: unter dem (beschreibbaren) `.knowledge`-Mount liegt `config.yaml`. -Config-Discovery läuft aufwärts ab `WORKDIR /knowledge`. `init_docset` lädt Docsets -in dasselbe persistente Volume. Konkrete Volume-/Schreibrechte-Details (uid 1000): -`docs/docker.md`. +Das konkrete `mcp.json`-Beispiel, Volume-Layout und die Schreibrechte-Details +(uid 1000) stehen in **`docs/docker.md`** (operative Referenz) — hier nicht +dupliziert. ## 7. R1 — stdout-Hygiene (übernommen) @@ -165,7 +133,12 @@ Der MCP-Kanal _ist_ stdout. Server loggt bereits ausschließlich nach stderr Antworten; `tools/list` zeigt `search_docs`, `list_docsets`, `init_docset`. 3. Bei gemountetem Volume mit `.knowledge/config.yaml` erscheinen die konfigurierten Docsets in der Tool-Beschreibung. -4. (Optional) `init_docset` eines Git-Docsets klont erfolgreich (git + Netz). +4. Der Entrypoint (`init-all`) initialisiert die konfigurierten + **`local_folder`**-Docsets (Symlinks + Metadata unter `.knowledge/`) und lehnt + `git_repo`/`archive`-Quellen mit klarer Fehlermeldung ab (ADR-003). + +> Git-/Archive-Materialisierung wird **auf dem Host** über die CLI getestet, nicht +> über das Image. ## 9. Risiken / offene Punkte diff --git a/docs/docker.md b/docs/docker.md index f0fd3c8..3c503e8 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -1,10 +1,20 @@ # Docker Deployment +> ⚠️ **Scope (ADR-003): the image serves only pre-materialized `local_folder` docsets.** +> The container is a **"mount & serve"** runtime — it creates Linux symlinks for bind-mounted +> folders and searches them. It does **not** fetch or extract docsets at startup: `git_repo` and +> `archive` sources are **rejected** by the entrypoint (clear error, exit 1). Materialize those on +> the **host** (clone/unzip) and mount the result as `local_folder`. The `git_repo`/`archive` +> loaders stay available in the npm package / CLI. Consequence: the runtime image ships **without +> `git`** and needs **no network at startup**. See **ADR-003** and **ADR-002**. + ## Overview -The Docker image for `agentic-knowledge-mcp` is based on a volume-mount approach: -the MCP server runs inside the container, while the source data lives on the host and is bound in -via mounts. Docset initialization (symlinks + metadata) is handled by the entrypoint on first start. +The Docker image for `agentic-knowledge-mcp` uses a volume-mount approach: the MCP server runs +inside the container, while the (already materialized) source data lives on the host and is bound in +via mounts. On first start the entrypoint initializes each configured **`local_folder`** docset — +i.e. it creates the symlinks + metadata under `.knowledge/`. Per **ADR-003** that is the only +supported source type inside the image (see the scope note above). ## Why not initialize `local_folder` sources directly on a Windows host? @@ -25,10 +35,10 @@ docker run -i \ agentic-knowledge-mcp ``` -| Mount | Purpose | -|---|---| -| `\.knowledge` → `/knowledge/.knowledge` | `config.yaml` + persisted docsets (writable) | -| `\my-docs` → `/knowledge/my-docs:ro` | Source files (docs, knowledge, tests) — read-only | +| Mount | Purpose | +| -------------------------------------------------- | ------------------------------------------------- | +| `\.knowledge` → `/knowledge/.knowledge` | `config.yaml` + persisted docsets (writable) | +| `\my-docs` → `/knowledge/my-docs:ro` | Source files (docs, knowledge, tests) — read-only | - Container WORKDIR: `/knowledge` - Config discovery finds `/knowledge/.knowledge/config.yaml` (walk-up from CWD) @@ -37,32 +47,75 @@ docker run -i \ > `` is a placeholder for the root directory of your project; replace it with your own > absolute path. `my-docs` is an example name for your source-data directory. +### Write permissions on the `.knowledge` volume (uid 1000) + +The container runs as the unprivileged `node` user (**uid 1000**, see `Dockerfile`). The writable +`.knowledge` mount — where the config lives and where `init_docset` creates docset directories and +symlinks — must therefore be **writable by uid 1000**. Common pitfalls: + +- **Host bind mount owned by another uid** → `init-all` fails with `EACCES` and (being fail-fast) + the container does not start. Fix: `chown -R 1000:1000 /.knowledge` on the host, or + use a **named volume** (Docker/Podman initializes its ownership from the image, so uid 1000 can + write). +- **Podman (rootless)** maps container uid 1000 to a subordinate host uid; a host-owned bind mount + is typically not writable. Prefer a named volume, or run with `--userns=keep-id`. +- **SELinux hosts** → append `:Z` (private) or `:z` (shared) to the bind mount so the volume is + relabeled, e.g. `-v /.knowledge:/knowledge/.knowledge:Z`. + +> On Windows the writable `.knowledge` cache must additionally sit on a **Linux-native filesystem** +> (named volume or WSL2 distro fs), never a `C:\` bind mount — symlink _creation_ is unreliable +> there. See **ADR-002**. + ## Entrypoint logic -``` -1. Check whether /knowledge/.knowledge/config.yaml exists - → no: error message on stderr, exit 1 - -2. Extract docset IDs from config.yaml - (grep/sed on lines of the form "- id:", CRLF stripped via `tr -d '\r'`) - For each ID: - node /app/packages/cli/dist/index.js init - - Idempotency is handled by the init command itself (skips already-initialized docsets) - - All init output goes to stderr (1>&2 2>&1) so that stdout stays clean for the - JSON-RPC stream - - A failure of a single docset does not abort startup (|| true) - -3. Start the MCP server (stdio): - exec node /app/packages/mcp-server/dist/bin.js -``` +`docker-entrypoint.sh` is a two-liner under `set -e`: -The `init` command is idempotent: docsets that are already initialized (detected via -`.agentic-metadata.json`) are skipped. The entrypoint therefore calls `init` for all docsets on -every start — on the second container start this is effectively a no-op, because the metadata is -persisted in the writable `.knowledge` volume. +```sh +#!/bin/sh +set -e +node /app/packages/cli/dist/index.js init-all # 1. initialize all configured docsets +exec node /app/packages/mcp-server/dist/bin.js # 2. hand over to the MCP server (stdio) +``` -> Note: ID extraction is a simple grep/sed over lines of the form `- id:` and assumes that format -> in `config.yaml` (it is not a full YAML parser). +1. **`init-all`** (CLI subcommand, `packages/cli/src/commands/init-all.ts`) loads the config via + the `ConfigManager` (a real YAML parser; config discovery walks up from the working directory + `/knowledge` to find `/knowledge/.knowledge/config.yaml`), then calls `initDocset` for every + configured docset. + - **Idempotent**: docsets already initialized (detected via `.agentic-metadata.json`) are + skipped. The metadata persists in the writable `.knowledge` volume, so on the second container + start `init-all` is effectively a no-op. + - **R1-clean**: all progress and error output goes to **stderr** (`process.stderr.write`), so + stdout stays free for the JSON-RPC stream. + - **`local_folder` only (ADR-003)**: before initializing anything, `init-all` scans every + docset's `sources[]`. If any source is `git_repo` or `archive`, it writes a clear error to + stderr (naming the docset and the offending type) and exits 1 — the server does **not** start. + Materialize such sources on the host and mount them as `local_folder` (see the scope note at the + top). + - **No docsets configured** → a message on stderr and exit 0; the server still starts. + +2. **`exec`** replaces the shell with the MCP server process, so signals (SIGTERM/SIGINT) reach the + server directly. + +> Replaced the earlier `grep`/`sed` ID extraction (with manual CRLF stripping) with the `init-all` +> subcommand — config parsing now goes through the same YAML parser as the rest of the system, so +> the CRLF/format assumptions no longer apply. + +### Startup failure behaviour (fail-fast) + +`init-all` is **fail-fast**: if loading the config fails, or if **any** docset fails to initialize, +it exits non-zero (`process.exit(1)`). Because the entrypoint runs under `set -e`, a non-zero +`init-all` aborts the script **before** the server starts — the container does not come up. + +This is a deliberate choice: a failed docset at startup is an _infrastructure/input_ problem +(unreachable git remote, bad URL, missing source mount, proxy required), and surfacing it loudly at +container start is preferable to silently serving a half-initialized server whose `search_docs` +returns incomplete results. The cost is that one bad docset blocks startup for all of them. + +> Trade-off note: an earlier draft tolerated single-docset failures (`|| true`) and started the +> server anyway. The current behaviour is the opposite. If partial startup becomes desirable (e.g. +> many independent docsets where one bad source should not take the server down), change `init-all` +> to log per-docset failures and continue, and decide separately whether the process should still +> exit non-zero. See **Open points**. ## Symlinks inside the container @@ -73,20 +126,22 @@ persisted in the writable `.knowledge` volume. ## Files -| File | Purpose | -|---|---| -| `Dockerfile` ✅ | Multi-stage build: pnpm/turbo (build) → node:22-alpine (runtime) | -| `docker-entrypoint.sh` ✅ | Lazy init (see above) + MCP server start | -| `.dockerignore` ✅ | Excludes `node_modules`, `dist`, `.git`, `.turbo`, etc. | +| File | Purpose | +| ------------------------- | ---------------------------------------------------------------- | +| `Dockerfile` ✅ | Multi-stage build: pnpm/turbo (build) → node:22-alpine (runtime) | +| `docker-entrypoint.sh` ✅ | Lazy init (see above) + MCP server start | +| `.dockerignore` ✅ | Excludes `node_modules`, `dist`, `.git`, `.turbo`, etc. | ### Dockerfile (structure) **Stage 1 – build** (`node:22-alpine`): + - `corepack enable && corepack prepare pnpm@10.32.1 --activate` - `pnpm install --frozen-lockfile` (incl. devDeps) - `pnpm run build` (turbo builds all packages in parallel) **Stage 2 – runtime** (`node:22-alpine`): + - `corepack enable && corepack prepare pnpm@10.32.1 --activate` - `pnpm install --frozen-lockfile --prod --ignore-scripts` - Copy the `dist/` directories of all packages from stage 1 @@ -102,9 +157,13 @@ persisted in the writable `.knowledge` volume. "knowledge": { "command": "docker", "args": [ - "run", "--rm", "-i", - "-v", "\\.knowledge:/knowledge/.knowledge", - "-v", "\\my-docs:/knowledge/my-docs:ro", + "run", + "--rm", + "-i", + "-v", + "\\.knowledge:/knowledge/.knowledge", + "-v", + "\\my-docs:/knowledge/my-docs:ro", "agentic-knowledge-mcp" ] } @@ -114,8 +173,12 @@ persisted in the writable `.knowledge` volume. ## Open points +- Supported source types in the image: **decided — `local_folder` only** (ADR-003). `git_repo`/ + `archive` are rejected by the entrypoint; the runtime image ships without `git`. Host-side use of + those loaders via the CLI is unaffected. - CI/CD pipeline (GitHub Actions) for automated image builds: TBD - Image registry and versioning strategy (`:latest` vs. `:2.2.0`): TBD -- Behavior on `init` failure in the entrypoint: **resolved** — single docset failures are - tolerated (`|| true`) and the server still starts. It remains open whether a failed docset - should be signaled more strongly (e.g. exit code / healthcheck): TBD +- Startup behaviour on docset-init failure: **decided — fail-fast** (see _Startup failure + behaviour_ above). The container does not start if any docset fails to initialize. Whether a + partial-startup mode (tolerate single failures, still serve the rest) should be offered as an + opt-in remains open: TBD.