diff --git a/.dockerignore b/.dockerignore
index c986db4..b65605c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -26,7 +26,3 @@ vite.config.js
tsconfig.json
package-lock.json
apps.json
-
-# The MCP search index is runtime data for the MCP server, not public static
-# content: keep it out of the nginx web root.
-dist/_mcp
diff --git a/AGENTS.md b/AGENTS.md
index 137a43d..ac6dde5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -58,6 +58,7 @@ One paragraph on what changed and why.
## Verification
- `npm run build:headless` — green
+- `npm run lint` — green
- `npm test` — green
- `npx playwright test --config=playwright.config.ci.js` — green
@@ -76,14 +77,20 @@ characters and explain *why* in the body when it is not obvious from the diff.
## Before opening a PR
-All three must pass locally — CI runs the same three:
+All four must pass locally — CI runs the same four (`make help` has them as targets):
```bash
npm run build:headless # hermetic build
+npm run lint # MCP server: tsc, ESLint, Prettier
npm test # embedded web-fragment E2E
npx playwright test --config=playwright.config.ci.js # standalone fragment layer
```
+While working on `mcp/`, `npm run test:mcp` runs only the MCP suites, over the
+`dist/` the build left behind, in seconds. A deliberate change to what the
+server answers regenerates the goldens: `UPDATE_GOLDEN=1 npm run test:mcp`;
+review the diff in `tests/fixtures/mcp/golden/` like code.
+
The build and both suites are hermetic: they use the committed
`tests/fixtures/` artifacts via `apps.json`, so no `GITHUB_TOKEN`, no network and
no sibling repository is required. If a change makes any of them need network,
diff --git a/CLAUDE.md b/CLAUDE.md
index c003fd9..5acf644 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,6 +8,9 @@ Build-time aggregator that downloads pre-built static doc sites from GitHub Rele
## Commands
+`make help` lists the same tasks as make targets (`make lint`, `make test-mcp`, `make prod`, …); each
+delegates to an npm script, so everything below also works without make.
+
```bash
# Install
npm install # Node >= 24 required
@@ -25,10 +28,19 @@ npm run dev:fetch # fetch artifacts then start dev
# Preview
npm run preview # serve the built dist/ (astro preview, :4321)
+# MCP server (from source — Node strips the types; needs a build's dist/_mcp/search-index.json)
+npm run mcp:dev # HTTP on :8081, restarts on change
+npm run mcp:stdio # stdio
+
+# Lint (mcp/ only: tsc --noEmit, ESLint strictTypeChecked, Prettier)
+npm run lint
+npm run format
+
# Test (Playwright E2E — self-contained; auto-starts its own servers)
npm test # embedded web-fragment harness
npx playwright test --config=playwright.config.ci.js # standalone fragment server
npm run test:container # real nginx image (needs Docker)
+npm run test:mcp # MCP suites only, over an existing dist/ (fast loop)
npm run test:ui # Playwright UI
npm run test:headed # visible browser
npm run test:debug # debug mode
@@ -49,6 +61,7 @@ apps.json (registry)
→ [3] astro build: [...path].astro enumerates all HTML via getStaticPaths
→ transformSubAppHtml() rewrites URLs + splits the document,
which Base.astro then re-hosts (masthead + head/body)
+ → [3b] buildMcpArtifacts() extracts plain-text search index + bundles MCP server → dist/_mcp/
→ dist/
```
@@ -80,7 +93,12 @@ Orchestrator: `scripts/build-vite.js`. Flags: `--local`, `--headless`.
- `src/scripts/embedded-transitions.js` — Loaded by the layout; inert standalone. Inside a web fragment it runs Astro's view transition on the host document (the iframe's is never painted) and replaces Astro's swap with one that targets reframed's `wf-html`/`wf-head`/`wf-body`, because the default swap nests a new `wf-html` per navigation and leaks every stylesheet. Its head diff never moves a reused node: on a pierced page a `` already moved once by reframed's portal falls out of the applied stylesheets when moved again. It also imports the fetched head and body into the host document before swapping them in: reframed routes a script into its iframe only through host-realm DOM methods, and the router's page is parsed in the iframe, so Astro's `script.replaceWith()` re-run would otherwise execute every swapped-in sub-app script in the host window
- `src/utils/config.js` — `PATH_PREFIX`/`BASE_PATH`, `isHeadlessBuild()` and `REGISTRY_FILE` — the build-wide constants
- `src/utils/registry.js` — Registry validation, manifest reading/validation, expansion map. Shared by the build and by Astro so both resolve the same registry
-- `scripts/build-vite.js` — Build orchestrator (4 steps: prepare, hoist, copy assets, astro build)
+- `scripts/build-vite.js` — Build orchestrator; step 3b generates MCP search index and self-contained runtime bundle
+- `mcp/` — the MCP server, in TypeScript that Node runs as-is (`mcp/tsconfig.json` allows only erasable syntax; relative imports spell `.ts`). Linted on its own (`eslint.config.js`). Three parts, kept apart by an ESLint import boundary:
+ - `mcp/search-index/schema.ts` — the search index file format and `validateSearchIndex()`, the one thing build and runtime share
+ - `mcp/build/` — build time only: `extract.ts` (untrusted HTML → bounded markdown-like text, parse5), `index-builder.ts`, `bundle.ts` (the esbuild config: minified, English-only zod locales, the SDK's `_shims` aliased to `src/sdk-shims.ts` to drop Ajv)
+ - `mcp/src/` — the runtime. `ports.ts` declares `DocumentRepository` and `SearchBackend` (async, adapter-owned opaque cursors); `knowledge-base.ts` is the facade the protocol layer talks to (public shapes, links, bounded reads); `adapters/memory/` implements both ports over the search index (lexical search: stemmed term frequency, title boost, compound split, fuzzy match); `compose.ts` wires adapters to the facade; `protocol/` registers one tool per file plus `kb://` resources on the official SDK v2 `McpServer`; `transport/` is the stateless HTTP handler (Origin, body cap, per-client and global rate limits, in-flight cap, timeout) and listener; `config.ts` is the only reader of argv/env. A new backend (OpenSearch) is a new `adapters//` pair plus a branch in `compose.ts`, and must pass `tests/support/backend-contract.js`
+- `scripts/build-mcp.js` — Emits `dist/_mcp/search-index.json` and bundled `server.mjs`
- `scripts/fetch-apps.js` — GitHub Release artifact downloader. Only *obtains* an artifact; installing it is one shared path in `build-vite.js`
- `scripts/artifacts.js` — Safe tarball extraction + tree copy, shared by both fetch paths. Validates archive members (no traversal, no absolute paths, no symlinks) before anything is written, and replaces the old `cp -r`/`tar` shell-outs so the build runs on Windows
- `scripts/check-artifact.js` — Runs the publish action's contract checker (`actions/lib/check.js`) on every installed artifact, before hoisting; logs findings grouped by rule, and fails a strict build on an error. `check.js` resolves its parsers from `actions/node_modules` or the root, which pins the same versions
@@ -163,7 +181,9 @@ Self-contained Playwright E2E — `npm test` auto-starts everything (no external
second time drops out of the applied stylesheets; see `embedded-transitions.js`).
Every embedded test runs twice, as Playwright projects `chromium` (:4201) and
-`chromium-pierced` (:4202). Suites (`tests/`), all four commands listed in `AGENTS.md`:
+`chromium-pierced` (:4202). The `mcp-*.spec.js` suites need no browser; `playwright.config.mcp.js`
+runs them alone (`npm run test:mcp`) against an existing `dist/`, starting only the `:3000`
+fragment server. Suites (`tests/`), all commands listed in `AGENTS.md`:
- `build-integrity.spec.js` — `dist/` output: both apps enumerated, absolute URL rewriting,
headless markup and the per-app `"headless"` override, the knowledge base stylesheet
inlined into every body (no page links one from its head) plus its stable `dist/style.css`
@@ -206,6 +226,20 @@ Every embedded test runs twice, as Playwright projects `chromium` (:4201) and
(name ↔ directory, portable fields only), no scripts shipped, every referenced file exists,
`examples/` are byte-identical to the contract's code blocks, and the docs carry the install
command.
+- `mcp-golden.spec.js` — byte-exact pins: every protocol case over the synthetic search index
+ (`tests/fixtures/mcp/synthetic-index.js`; cases in `support/mcp-cases.js`), the same with a
+ public origin, the extraction of `fixtures/mcp/extract-rich.html`, and the search index the fixture
+ build generates. Files in `fixtures/mcp/golden/`; long strings are pinned by length and hash.
+ A deliberate change regenerates them with `UPDATE_GOLDEN=1`.
+- `mcp-backend-contract.spec.js` — the memory adapters against `support/backend-contract.js`:
+ canonical order, complete paging, filters, cursor binding, typed errors, deterministic
+ best-first ranking (ranking itself is not pinned, so a vector backend can pass it).
+- `mcp-bundle.spec.js` — the built `dist/_mcp/server.mjs` answers every protocol case exactly
+ like the source, and stays within its size budget (`make size`).
+- `mcp-server.spec.js`, `mcp-search.spec.js`, `mcp-extract.spec.js`, `mcp-index-builder.spec.js`,
+ `mcp-error-page.spec.js` — HTTP guards and rate limits, ranking examples, extraction,
+ search index edge cases, browser error pages. `support/mcp.js` builds the runtime for tests (in
+ process, or the bundle as a child process).
- `standalone.spec.js` — the `:3000` fragment server directly (`playwright.config.ci.js`).
- `container.spec.js` — the real nginx image (`playwright.config.docker.js`, needs Docker).
- `support/fragment.js` — shadow-DOM traversal + reframed-body wait/query helpers.
@@ -229,6 +263,7 @@ in the committed `apps.json` without breaking CI, which only has this repo.
- `KB_STRICT` — `true` rejects `prebuilt`/`localPath`/`optional` entries, an empty registry, any entry that yields no apps, and any artifact with an `error` finding from the contract checker (`scripts/check-artifact.js`; every build logs the findings, grouped by rule, strict or not). Production builds only; this repo's own registry is a fixture and fails it by design.
- `KB_HEADLESS` — `true` produces web-fragment output; **anything else, including unset, means standalone**. `scripts/build-vite.js` always exports an explicit value, so the default only applies when `astro build`/`astro dev` runs directly. Read it through `isHeadlessBuild()`, never inline. A per-app `"headless"` in `apps.json` overrides it in either direction.
- `AWS_REGION`, `ECR_REPOSITORY`, `ECS_CLUSTER`, `ECS_SERVICE` — deployment config
+- `KB_MCP_*` — the MCP server's runtime settings (`SEARCH_INDEX`, `HOST`, `PORT`, `PUBLIC_ORIGIN`, `ALLOWED_ORIGINS`, `TRUSTED_PROXIES`, `BACKEND` — `memory`, the only backend today). Parsed and validated in `mcp/src/config.ts` only; documented in `docs/mcp.md` (developer guide: `docs/mcp-development.md`)
- `KB_EXAMPLE_ARTIFACT` — overrides the packaged artifact `scripts/setup-test-apps.mjs` registers
- `KB_CONTAINER_PORT`, `KB_SKIP_BUILD` — container-suite harness (`tests/container/serve.mjs`); CI sets `KB_SKIP_BUILD` because its image job already built the image
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e31a515..e026a7b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -7,9 +7,17 @@ Thanks for your interest in improving **knowledge-base**.
```bash
npm install # Node >= 24
npm run build:headless # hermetic build from the vendored fixture (tests/fixtures/)
+npm run lint # MCP server: tsc, ESLint, Prettier
npm test # embedded web-fragment E2E (Playwright)
```
+With GNU make installed (on Windows: `winget install ezwinports.make`, run from
+Git Bash), `make help` lists the same tasks as targets.
+
+```bash
+make fixture-build && make test-mcp # MCP suites only, the fast loop for mcp/
+```
+
The build and tests are fully hermetic — they use the committed
`tests/fixtures/docs-example.dist.tar.gz` fixture (registered via `apps.json`),
so no `GITHUB_TOKEN`, network, or sibling repository is required.
@@ -33,6 +41,7 @@ opening your first PR, and required reading for automated contributors.
| `npm test` | Embedded web-fragment harness (`playwright.config.js`) — host gateway proxies/embeds the fragment on `:4201`. |
| `npx playwright test --config=playwright.config.ci.js` | Standalone fragment-server layer (`:3000`) — headers, headless contract, asset routing, #297. |
| `npm run test:container` | Integration tests against the real nginx image. **Needs Docker**, so it is not part of `npm test`. Run it when changing `nginx.conf`, `nginx.headers.conf` or the `Dockerfile`. |
+| `npm run test:mcp` | The MCP suites alone (`playwright.config.mcp.js`) over an existing `dist/`. Part of `npm test` too. |
Both run in CI (`.github/workflows/ci.yml`). Please make sure both pass before
opening a PR.
@@ -41,8 +50,8 @@ opening a PR.
1. Branch off `master`.
2. Keep changes focused; update docs/tests alongside code.
-3. Ensure `npm run build:headless`, `npm test`, and the standalone suite pass.
-4. CI (type-check, build, E2E, audit) must be green.
+3. Ensure `npm run build:headless`, `npm run lint`, `npm test`, and the standalone suite pass.
+4. CI (type-check, lint, build, E2E, audit) must be green.
## Reporting security issues
diff --git a/Dockerfile b/Dockerfile
index 245d090..03c96ae 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,37 +1,30 @@
-# Runtime image: nginx serving the prebuilt static site.
-#
-# nginx-unprivileged rather than the stock nginx image: this container serves
-# static files on port 8080 and needs no privileged port, so there is no reason
-# for the master process to run as root. This variant already listens on 8080
-# and runs as UID 101.
-#
-# Pinned by digest, matching how every GitHub Action in .github/workflows is
-# pinned. Dependabot bumps the tag; the digest keeps the deployment from moving
-# underneath it in the meantime.
-FROM nginxinc/nginx-unprivileged:1.29-alpine@sha256:0c79d56aee561a1d81c63f00eee5fb5fe29279560cdc55e91425133104c7fbe6 AS runtime
+# Node runtime donor. Only binary and Alpine C++ runtime libraries are copied; no apk/npm run here.
+FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS node-runtime
-# Overwrite the stock config rather than `RUN rm`-ing it: this image drops to a
-# non-root user, which cannot delete files under /etc/nginx.
+FROM nginxinc/nginx-unprivileged:1.29-alpine@sha256:0c79d56aee561a1d81c63f00eee5fb5fe29279560cdc55e91425133104c7fbe6 AS runtime
+USER root
+COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node
+COPY --from=node-runtime /usr/lib/libstdc++.so.6* /usr/lib/
+COPY --from=node-runtime /usr/lib/libgcc_s.so.1 /usr/lib/
+RUN node --version
COPY nginx.conf /etc/nginx/conf.d/default.conf
-
-# The shared CORS + security header set, included by nginx.conf. Lives outside
-# conf.d/ because nginx loads conf.d/*.conf as top-level server configuration
-# and this is a fragment, not a server block.
COPY nginx.headers.conf /etc/nginx/kb-headers.conf
-
-# `dist/` is built outside the image (npm run build / build:headless) and is not
-# reproducible from this Dockerfile alone — see README. Fail loudly here rather
-# than shipping an image that 404s, which is what a missing or half-built dist
-# would otherwise produce at runtime.
COPY dist /usr/share/nginx/html
RUN test -f /usr/share/nginx/html/index.html \
|| (echo "dist/ has no index.html — run 'npm run build:headless' before docker build" >&2; exit 1)
RUN test -f /usr/share/nginx/html/style.css \
|| (echo "dist/ has no style.css — sub-app pages reference it via /__wf/knowledge-base/style.css" >&2; exit 1)
-
+RUN test -f /usr/share/nginx/html/_error/not-found.html \
+ || (echo "dist/ has no _error/not-found.html — nginx would answer every 404 with a 500" >&2; exit 1)
+# Runtime search index is not static public content.
+RUN test -f /usr/share/nginx/html/_mcp/server.mjs && test -f /usr/share/nginx/html/_mcp/search-index.json \
+ || (echo "dist/_mcp missing — run the knowledge-base build (step 3b) before docker build" >&2; exit 1) \
+ && mkdir -p /opt/kb-mcp && mv /usr/share/nginx/html/_mcp/* /opt/kb-mcp/ && rmdir /usr/share/nginx/html/_mcp
+COPY docker/kb-entrypoint.sh /usr/local/bin/kb-entrypoint.sh
+RUN chmod 0755 /usr/local/bin/kb-entrypoint.sh
+USER 101
+ENV KB_MCP_HOST=127.0.0.1 KB_MCP_PORT=8081 KB_MCP_SEARCH_INDEX=/opt/kb-mcp/search-index.json NODE_ENV=production
EXPOSE 8080
-
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
- CMD wget -qO- http://localhost:8080/healthz || exit 1
-
-CMD ["nginx", "-g", "daemon off;"]
+ CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:8081/healthz >/dev/null || exit 1
+CMD ["/usr/local/bin/kb-entrypoint.sh"]
diff --git a/README.md b/README.md
index ec3764e..3c4795b 100644
--- a/README.md
+++ b/README.md
@@ -210,6 +210,12 @@ own apps for a real deployment.
---
+## AI agents (MCP)
+
+Public read-only MCP endpoint: `https:///knowledge-base/mcp`. It exposes searchable `kb://` documentation resources plus `search_documents`, `list_documents`, and `read_document`. See [docs/mcp.md](docs/mcp.md) for client setup, limits, security posture, and operations, and [docs/mcp-development.md](docs/mcp-development.md) to work on the server.
+
+---
+
## Testing
E2E tests use Playwright. Everything is hermetic — built from
@@ -219,7 +225,10 @@ E2E tests use Playwright. Everything is hermetic — built from
|---|---|
| `npm test` | **Embedded** harness (`playwright.config.js`). Starts the fragment server (`:3000`) and a minimal web-fragments **host gateway** (`tests/host/server.mjs`, `:4201`) that embeds the fragment. Covers shadow-DOM isolation, smooth no-reload SPA routing, cross-app navigation, asset 404s, and the fragment-history limitation. |
| `npx playwright test --config=playwright.config.ci.js` | **Standalone** layer. Hits the fragment server (`tests/fragment-server.mjs`, `:3000`) directly. Covers HTTP header safety (`X-Frame-Options`), the headless contract, CSS-link stability (web-fragments [#297](https://github.com/web-fragments/web-fragments/issues/297)), and asset routing. |
-| `npm run test:container` | **Container** layer (needs Docker). Runs the real production image — nginx serving `dist/` — instead of the Express mirror the other two use. Covers the shipped `nginx.conf`: rewrites, response headers, the CSP in a browser, and that the image does not run as root. |
+| `npm run test:container` | **Container** layer (needs Docker). Runs real production image — nginx plus loopback MCP sidecar — instead of Express mirror. Covers shipped rewrites, headers, CSP, MCP proxy, and unprivileged runtime. |
+| `npm run test:mcp` | **MCP** suites only (`playwright.config.mcp.js`) over an existing `dist/`: golden protocol transcripts, the backend contract, the built bundle against the source and its size budget. |
+| `npm run mcp:stdio` / `npm run mcp:http` / `npm run mcp:dev` | Runs the MCP server from source (TypeScript, no build step) over the search index from `npm run build:headless`; `mcp:dev` restarts on change. |
+| `make help` | The same tasks as make targets, plus `make lint` and `make prod` (clean, install, strict build, bundle size check, image). |
> `tests/fragment-server.mjs` serves `dist/` and mirrors the production
> `nginx.conf` rewrites (including `/__wf/knowledge-base/* → /knowledge-base/*`).
diff --git a/contract/DEPLOYMENT.md b/contract/DEPLOYMENT.md
index e004092..4b9b532 100644
--- a/contract/DEPLOYMENT.md
+++ b/contract/DEPLOYMENT.md
@@ -283,3 +283,33 @@ alone cannot give once `latest` has moved.
- [ ] On runners without a route to `registry.npmjs.org`: `runs-on`,
`npm-registry` and the `npm-token` secret set; `node-mirror` if Node is
neither preinstalled nor downloadable; the Docker daemon's mirror configured
+
+---
+
+## MCP runtime
+
+Build step 3b emits `dist/_mcp/search-index.json` and self-contained `server.mjs`.
+Image runs nginx plus loopback Node MCP process. Docker moves `_mcp` to
+`/opt/kb-mcp`, outside public web root; image build uses no npm install or
+`apk add`.
+
+Gateway must forward unauthenticated `POST /knowledge-base/mcp` with JSON body
+and MCP protocol headers. Do not serve `/knowledge-base/_mcp/*`. Node binds
+`127.0.0.1:8081`; nginx proxies exact endpoint; supervisor exits container when
+either process dies. ECS task replacement remains required because it ignores
+Docker health checks.
+
+Runtime variables: `KB_MCP_SEARCH_INDEX`, `KB_MCP_HOST`, `KB_MCP_PORT`,
+`KB_MCP_ALLOWED_ORIGINS` (comma-separated exact origins), and
+`KB_MCP_PUBLIC_ORIGIN` (optional pathless HTTP(S) origin), and
+`KB_MCP_TRUSTED_PROXIES` (proxies in front of Node whose `X-Forwarded-For`
+entries are trusted for per-client rate limits; default 1, nginx itself — set 2
+when a load balancer forwards to the container; 0–16), and `KB_MCP_BACKEND`
+(`memory`, the only backend today). An invalid value stops the server at
+startup. MCP is public,
+read-only documentation; edge controls may add abuse limits but must not require
+authentication for endpoint.
+
+Private Docker runners need pinned nginx and Node layers available from their
+Docker mirror. Node binary and Alpine libraries are copied from digest-pinned
+`node:24-alpine`; Docker performs no network package installation.
diff --git a/docker/kb-entrypoint.sh b/docker/kb-entrypoint.sh
new file mode 100644
index 0000000..b90edbd
--- /dev/null
+++ b/docker/kb-entrypoint.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+# Runs MCP loopback process and nginx; either exit stops container for ECS.
+set -u
+node /opt/kb-mcp/server.mjs --http --host "${KB_MCP_HOST}" --port "${KB_MCP_PORT}" --index "${KB_MCP_SEARCH_INDEX}" &
+NODE_PID=$!
+nginx -g 'daemon off;' &
+NGINX_PID=$!
+term() { kill -TERM "$NODE_PID" 2>/dev/null; kill -QUIT "$NGINX_PID" 2>/dev/null; wait; exit 0; }
+trap term TERM INT
+while kill -0 "$NODE_PID" 2>/dev/null && kill -0 "$NGINX_PID" 2>/dev/null; do sleep 2; done
+echo "kb-entrypoint: a process exited (node=$NODE_PID nginx=$NGINX_PID); stopping container" >&2
+kill -TERM "$NODE_PID" 2>/dev/null; kill -QUIT "$NGINX_PID" 2>/dev/null
+wait
+exit 1
diff --git a/docs/mcp-development.md b/docs/mcp-development.md
new file mode 100644
index 0000000..d94a78c
--- /dev/null
+++ b/docs/mcp-development.md
@@ -0,0 +1,151 @@
+# Developing the MCP server
+
+How the knowledge base MCP server is built, tested and extended. For what it offers and how to
+run it in production, see [mcp.md](mcp.md).
+
+## Quick start
+
+```sh
+npm install
+make fixture-build # hermetic headless build; writes dist/_mcp/search-index.json
+make mcp-dev # serve from source on :8081, restart on change
+make lint # tsc --noEmit, ESLint, Prettier check
+make test-mcp # MCP suites only, in seconds
+```
+
+Every make target delegates to an npm script (`npm run mcp:dev`, `npm run lint`,
+`npm run test:mcp`, …), so make is optional. On Windows, install it with
+`winget install ezwinports.make` and run it from Git Bash.
+
+## How it fits together
+
+The server is TypeScript. Node runs the sources as-is by stripping the types, so there is no
+compile step in development or tests. `mcp/tsconfig.json` therefore allows only syntax Node can
+erase (no enums, namespaces or parameter properties), and relative imports spell their `.ts`
+extension.
+
+```
+build time run time
+────────── ────────
+dist/ HTML ──▶ mcp/build/index-builder.ts ──▶ search-index.json ──▶ mcp/src/ (server.mjs)
+ └ extract.ts (parse5) ▲
+ mcp/search-index/schema.ts (the shared format)
+```
+
+- **`mcp/search-index/schema.ts`** — the search index file format and `validateSearchIndex()`.
+ The only code build and runtime share.
+- **`mcp/build/`** — build time only:
+ - `extract.ts` turns untrusted page HTML into bounded markdown-like text;
+ - `index-builder.ts` writes the search index;
+ - `bundle.ts` holds the esbuild configuration.
+
+ `scripts/build-mcp.js` calls them in build step 3b.
+- **`mcp/src/`** — the runtime, bundled into `dist/_mcp/server.mjs`.
+
+An ESLint rule keeps `mcp/src/` from importing `mcp/build/`, parse5 or the Astro utilities, so
+build-time code cannot reach the bundle.
+
+### Runtime layers
+
+```
+transport/ (HTTP guards, limits) ─▶ protocol/ (tools, resources) ─▶ KnowledgeBase (facade)
+ │ │
+ DocumentRepository SearchBackend ← ports.ts
+ │ │
+ adapters/memory/: SearchIndexRepository, LexicalSearch
+```
+
+- **`transport/`** — the stateless HTTP handler: Origin check, body cap, per-client and global
+ rate limits, in-flight cap, timeout. Also the listener, with `/healthz` and browser error pages.
+- **`protocol/`** — one file per tool (`tools/`), the `kb://` resources, the input and output
+ schemas, and `server.ts`. It builds an MCP server per request on the official SDK v2
+ `McpServer`.
+- **`knowledge-base.ts`** — the facade, and the only thing the protocol layer calls. It turns
+ backend results into what clients see: public document shapes, links, app names, bounded
+ reads that never split a surrogate pair. It throws typed errors (`domain.ts`), which the tool
+ layer maps by class, never by message.
+- **`ports.ts`** — what a backend implements:
+ - `DocumentRepository`: get, list, apps;
+ - `SearchBackend`: search.
+
+ Both are async. Cursors are opaque and owned by the adapter that issued them.
+- **`adapters/memory/`** — today's backend, everything served from the search index file:
+ - `SearchIndexRepository` keeps documents in canonical order, with a URI map;
+ - `LexicalSearch` ranks by stemmed term frequency with a title boost, splits compound words
+ ("eventbus" → "event bus") and matches misspellings.
+- **`compose.ts`** — wires adapters to the facade. **`config.ts`** — the only code that reads
+ argv or the environment. **`main.ts`** — the entry point.
+
+## Adding a search backend
+
+A new backend, such as a vector index in AWS OpenSearch, needs four things:
+
+1. **Adapters.** Implement `DocumentRepository` and `SearchBackend` in `mcp/src/adapters//`.
+ Return whatever cursor format suits the backend (`search_after`, a point-in-time id…); the
+ facade passes it through untouched.
+2. **Wiring.** Add the name to `BACKENDS` in `config.ts` and a branch in `compose.ts` that builds
+ the adapters from config.
+3. **Contract test.** Add a spec that calls `describeBackendContract()` from
+ `tests/support/backend-contract.js` with a factory for your adapters. The suite checks:
+ - canonical order and complete paging;
+ - filters;
+ - cursors rejected for another query or filter;
+ - typed errors;
+ - deterministic, best-first results.
+
+ It does not pin a ranking, so a vector backend can pass it.
+4. **Ingestion.** If the backend needs its documents loaded, add that as a build-side step under
+ `mcp/build/`, next to `index-builder.ts`. The runtime never writes to a backend.
+
+Nothing in `protocol/` or `transport/` changes.
+
+## Tests
+
+`npm run test:mcp` (`playwright.config.mcp.js`) runs the MCP suites alone, without a browser, over
+the `dist/` a build left behind. They also run as part of `npm test`.
+
+| Suite | Pins |
+|---|---|
+| `mcp-golden.spec.js` | Byte-exact responses for every protocol case over a synthetic search index, the same with a public origin, the extraction of a hostile page, and the search index the fixture build generates |
+| `mcp-backend-contract.spec.js` | The memory adapters against the backend contract |
+| `mcp-bundle.spec.js` | The built `server.mjs` answers every case exactly like the source, and stays within its size budget |
+| `mcp-server.spec.js` | HTTP guards, CORS, rate limits, log hygiene |
+| `mcp-search.spec.js`, `mcp-extract.spec.js`, `mcp-index-builder.spec.js`, `mcp-error-page.spec.js` | Ranking examples, extraction, index edge cases, browser error pages |
+
+The golden files live in `tests/fixtures/mcp/golden/`, and the cases in
+`tests/support/mcp-cases.js`. Any diff there is a regression, unless you meant to change what the
+server answers. In that case, regenerate them and review the diff like code:
+
+```sh
+UPDATE_GOLDEN=1 npm run test:mcp
+```
+
+`tests/support/mcp.js` starts the runtime for tests, either in process or as the bundle in a
+child process.
+
+## The bundle
+
+`mcp/build/bundle.ts` builds one minified, dependency-free `server.mjs` (about 410 KB, against a
+450 KiB budget enforced by `make size`). Two substitutions keep it small. Both apply only to the
+bundle; dev and tests run the unmodified packages, and `mcp-bundle.spec.js` proves the two
+behave the same.
+
+- **zod locales.** zod's classic API re-exports every locale as `z.locales`, and a namespace
+ import keeps all of them. A resolver plugin keeps English only, which saves about 258 KB.
+- **The SDK's JSON Schema validator.** The SDK's Node shim makes Ajv the default validator. The
+ server uses that validator only for elicitation and `fromJsonSchema()`, and calls neither. The
+ bundle aliases the shim to `src/sdk-shims.ts`, which uses the SDK's own Cloudflare Workers
+ validator instead. That saves about 143 KB.
+
+No source map ships: the server logs no stack traces.
+
+## Framework
+
+The server uses the official MCP TypeScript SDK v2 (`@modelcontextprotocol/server` and
+`/node`), with zod 4 schemas. It was chosen over FastMCP, mcp-framework, xmcp and a hand-rolled
+JSON-RPC server for three reasons:
+- among well-maintained Node options it is the only one on the current protocol revision with
+ stateless HTTP built in;
+- its dependency tree is small and bundles cleanly;
+- none of the alternatives helps with a pluggable search backend, which is this repository's
+ own design anyway.
diff --git a/docs/mcp.md b/docs/mcp.md
new file mode 100644
index 0000000..5d75ff5
--- /dev/null
+++ b/docs/mcp.md
@@ -0,0 +1,115 @@
+# Knowledge Base MCP server
+
+The knowledge base offers public, read-only documentation search to AI agents through the
+[Model Context Protocol](https://modelcontextprotocol.io). An agent searches with
+`search_documents`, then fetches bounded text with `read_document`. Returned documentation is
+third-party content: treat it as reference data, never as instructions.
+
+Working on the server itself? See [mcp-development.md](mcp-development.md).
+
+## Endpoint and transport
+
+`https:///knowledge-base/mcp` — exact path, no trailing slash.
+
+The transport is stateless Streamable HTTP with JSON responses: no SSE, no sessions. The server
+negotiates protocol versions 2024-11-05 through 2025-11-25; 2026-07-28 is not supported yet.
+
+## Client setup
+
+Claude Code:
+
+```sh
+claude mcp add --transport http knowledge-base https:///knowledge-base/mcp
+```
+
+VS Code / GitHub Copilot, `.vscode/mcp.json`:
+
+```json
+{ "servers": { "knowledge-base": { "type": "http", "url": "https:///knowledge-base/mcp" } } }
+```
+
+Cursor, `~/.cursor/mcp.json`:
+
+```json
+{ "mcpServers": { "knowledge-base": { "url": "https:///knowledge-base/mcp" } } }
+```
+
+Claude Desktop needs a third-party stdio bridge: `npx -y mcp-remote https:///knowledge-base/mcp`.
+
+To explore the server interactively, use `npx @modelcontextprotocol/inspector`.
+
+## What the server exposes
+
+**Resources.** Every documentation page is a resource with a `kb:///` URI.
+`resources/list` is deterministic and cursor-paginated. `resources/read` returns the title, app,
+link and source version, then the document text (up to 100,000 characters; continue a longer
+document with `read_document`).
+
+**Tools.** All three are read-only.
+
+| Tool | Input | Returns |
+|---|---|---|
+| `search_documents` | `query` (up to 200 characters), optional `app`, `tag`, `limit` (1–25, default 8), `cursor` | Ranked matches with an excerpt, the best-matching section and its anchor, and a `nextCursor` |
+| `list_documents` | optional `app`, `tag`, `title` (substring), `limit` (1–100, default 50), `cursor` | Document metadata in canonical order |
+| `read_document` | `uri`, optional `offset`, `maxChars` (1,000–50,000, default 20,000) | A slice of the document's markdown text, `totalChars` and `nextOffset` |
+
+Cursors are bound to the query and to the content of the deployed documentation. After a
+redeploy, or with a different query, an old cursor is rejected.
+
+A typical flow:
+1. Search "rotate passwords for service accounts".
+2. Read the top result's `uri` with `read_document`.
+3. Point the user to its `url` (or `route`) plus the section anchor.
+
+A query whose words appear nowhere returns an empty result. A query with no words at all, such as
+`!!!`, is an error.
+
+## Operations
+
+The image runs nginx and the MCP server, a Node process listening on loopback only. nginx
+forwards `POST` and `OPTIONS /knowledge-base/mcp` to it. The search index and the server bundle
+live outside the web root and are never served as files. The gateway in front of the image must
+pass unauthenticated `POST /knowledge-base/mcp` through.
+
+The server reads its settings from environment variables. An invalid value stops it at startup.
+
+| Variable | Default | Meaning |
+|---|---|---|
+| `KB_MCP_SEARCH_INDEX` | `search-index.json` next to the server | The search index the build generated |
+| `KB_MCP_HOST` / `KB_MCP_PORT` | `127.0.0.1` / `8081` | Listen address |
+| `KB_MCP_PUBLIC_ORIGIN` | unset | `https://host` the site is served from; when set, results carry absolute `url`s |
+| `KB_MCP_ALLOWED_ORIGINS` | none | Comma-separated browser origins allowed to call the endpoint |
+| `KB_MCP_TRUSTED_PROXIES` | `1` | Proxies (0–16) whose `X-Forwarded-For` entries are trusted; set `2` behind a load balancer |
+| `KB_MCP_BACKEND` | `memory` | Search backend; `memory` is the only one today |
+
+## Security
+
+There is no authentication, by product decision: the data is public and every tool is read-only.
+
+**Origins.** A request without an `Origin` header (an agent) is allowed. A request with one must
+match `KB_MCP_ALLOWED_ORIGINS` exactly. Browser preflights from listed origins are answered with
+the allowed method and MCP headers.
+
+**Limits:**
+- Request body: 64 KiB.
+- Query: 200 characters.
+- Per client: 20 requests/s, burst 40. The client is the address `KB_MCP_TRUSTED_PROXIES` hops
+ from the right of `X-Forwarded-For`.
+- Overall: 200 requests/s, burst 400.
+- 32 requests in flight; 10 s timeout per request.
+
+**Logs.** Logs record status, method and duration, never the query or document content.
+
+**Untrusted content.** Page HTML is reduced to plain text at build time. The text still comes
+from third-party documentation, so clients must treat it as untrusted and capable of prompt
+injection.
+
+## Troubleshooting
+
+| Symptom | Meaning |
+|---|---|
+| Browser `GET` shows a "MCP client required" page (405) | By design: MCP clients use `POST` |
+| 403 | The request's `Origin` is not in `KB_MCP_ALLOWED_ORIGINS` |
+| 413 / 415 | Body over 64 KiB / `Content-Type` is not `application/json` |
+| 429 | Rate limited; retry after the `Retry-After` delay |
+| 502 | The MCP server process is not running |
diff --git a/nginx.conf b/nginx.conf
index 1e281b2..e806c20 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -22,6 +22,13 @@ map $request_uri $kb_cache_control {
"~^/(__wf/)?knowledge-base/[^?]*/_kb-inline/[0-9a-f]{16}\.js(\?|$)" "public, max-age=31536000, immutable, no-transform";
}
+# A server-level `if` runs before any location is chosen, so the MCP exclusion
+# has to be part of the condition itself.
+map "$request_method:$uri" $kb_static_preflight {
+ default 0;
+ "~^OPTIONS:(?!/knowledge-base/mcp$)" 1;
+}
+
server {
listen 8080;
server_name _;
@@ -29,6 +36,16 @@ server {
root /usr/share/nginx/html;
index index.html;
+ # Serve one designed 404 page for every nginx location. The internal route
+ # avoids exposing implementation file paths and keeps the original 404 code.
+ error_page 404 /__kb-not-found;
+ location = /__kb-not-found {
+ internal;
+ include /etc/nginx/kb-headers.conf;
+ add_header Cache-Control "no-store" always;
+ try_files /_error/not-found.html =500;
+ }
+
# ── Compression ───────────────────────────────────────────────────────────
gzip on;
gzip_vary on;
@@ -53,8 +70,10 @@ server {
# See nginx.headers.conf. Enforced by tests/nginx-config.spec.js.
include /etc/nginx/kb-headers.conf;
- # Handle OPTIONS preflight without hitting try_files
- if ($request_method = OPTIONS) {
+ # Handle OPTIONS preflight without hitting try_files. The MCP endpoint is
+ # excluded: its preflight must allow POST and the MCP headers for origins in
+ # KB_MCP_ALLOWED_ORIGINS, which only Node knows.
+ if ($kb_static_preflight) {
return 204;
}
@@ -80,6 +99,41 @@ server {
try_files $uri =404;
}
+ # ── MCP endpoint: /knowledge-base/mcp → local MCP server (see docs/mcp.md) ──
+ # Exact match wins over prefix location. Node owns rate, size and Origin limits
+ # so production and tests/fragment-server.mjs share one implementation.
+ location = /knowledge-base/mcp {
+ include /etc/nginx/kb-headers.conf;
+ add_header Cache-Control "no-store" always;
+ gzip off;
+ client_max_body_size 64k;
+ proxy_pass http://127.0.0.1:8081/mcp;
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_connect_timeout 2s;
+ proxy_send_timeout 15s;
+ proxy_read_timeout 15s;
+ }
+
+ # MCP subpaths reach Node so browsers receive its designed 404 response.
+ location ^~ /knowledge-base/mcp/ {
+ include /etc/nginx/kb-headers.conf;
+ add_header Cache-Control "no-store" always;
+ gzip off;
+ client_max_body_size 64k;
+ rewrite ^/knowledge-base/(.*)$ /$1 break;
+ proxy_pass http://127.0.0.1:8081;
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_connect_timeout 2s;
+ proxy_send_timeout 15s;
+ proxy_read_timeout 15s;
+ }
+
# ── All knowledge base traffic: /knowledge-base/* ────────────────────────
# Handles both the data-gateway fragment path and the dedicated hostname.
# Strip the /knowledge-base/ prefix so files resolve from the dist root.
diff --git a/tests/container.spec.js b/tests/container.spec.js
index 1761346..cb11b81 100644
--- a/tests/container.spec.js
+++ b/tests/container.spec.js
@@ -84,14 +84,18 @@ test.describe('routing', () => {
expect(res.headers()['content-type']).toContain('text/css');
});
- test('an unknown sub-app path is a 404, not the landing page', async ({ request }) => {
+ test('an unknown sub-app path is a designed 404, not the landing page', async ({ request }) => {
const res = await request.get('/knowledge-base/no-such-app/');
expect(res.status()).toBe(404);
+ expect(res.headers()['content-type']).toContain('text/html');
+ expect(await res.text()).toContain('This page is not available.');
});
- test('an unknown top-level path is a 404', async ({ request }) => {
+ test('an unknown top-level path is a designed 404', async ({ request }) => {
const res = await request.get('/no-such-thing');
expect(res.status()).toBe(404);
+ expect(res.headers()['content-type']).toContain('text/html');
+ expect(await res.text()).toContain('This page is not available.');
});
test('OPTIONS preflight is answered without hitting try_files', async ({ request }) => {
diff --git a/tests/nginx-config.spec.js b/tests/nginx-config.spec.js
index 3580cc0..433f803 100644
--- a/tests/nginx-config.spec.js
+++ b/tests/nginx-config.spec.js
@@ -161,6 +161,28 @@ test.describe('nginx.conf header inheritance', () => {
}
});
+ test('all nginx 404s use internal no-store not-found page', () => {
+ expect(uncomment(CONF)).toContain('error_page 404 /__kb-not-found;');
+ const page = locationBlocks(uncomment(CONF)).find((b) => b.selector === '= /__kb-not-found');
+ expect(page, 'internal 404 page location block').toBeTruthy();
+ expect(page.body).toContain('internal;');
+ expect(page.body).toContain('try_files /_error/not-found.html =500;');
+ expect(page.body).toMatch(/add_header\s+Cache-Control\s+"no-store"\s+always/);
+ // Without the guard a build that forgot the page would turn every 404 into a 500.
+ expect(readFileSync(join(ROOT, 'Dockerfile'), 'utf8')).toContain('test -f /usr/share/nginx/html/_error/not-found.html');
+ });
+
+ test('the static OPTIONS shortcut leaves the MCP preflight to Node', () => {
+ const conf = uncomment(CONF);
+ expect(conf).not.toMatch(/if\s*\(\$request_method\s*=\s*OPTIONS\)/);
+ expect(conf).toMatch(/if\s*\(\$kb_static_preflight\)\s*\{\s*return 204;/);
+ const pattern = conf.match(/"~(\^OPTIONS:[^"]+)"\s+1;/)[1];
+ const preflight = new RegExp(pattern);
+ expect(preflight.test('OPTIONS:/knowledge-base/')).toBe(true);
+ expect(preflight.test('OPTIONS:/knowledge-base/mcp/other')).toBe(true);
+ expect(preflight.test('OPTIONS:/knowledge-base/mcp')).toBe(false);
+ });
+
test('healthz sets its content type with default_type, not a post-return add_header', () => {
const healthz = locationBlocks(uncomment(CONF)).find((b) => b.selector === '= /healthz');
expect(healthz, '/healthz location block').toBeTruthy();
@@ -172,11 +194,15 @@ test.describe('nginx.conf header inheritance', () => {
test.describe('Dockerfile', () => {
const DOCKERFILE = readFileSync(join(ROOT, 'Dockerfile'), 'utf8');
- test('runs an unprivileged nginx pinned by digest', () => {
- const from = DOCKERFILE.split('\n').find((l) => l.startsWith('FROM '));
- expect(from).toContain('nginx-unprivileged');
- expect(from, 'base image must be pinned by digest, like the GitHub Actions are')
- .toMatch(/@sha256:[a-f0-9]{64}/);
+ test('runs a digest-pinned Node donor and unprivileged nginx runtime', () => {
+ const from = DOCKERFILE.split('\n').filter((l) => l.startsWith('FROM '));
+ expect(from).toHaveLength(2);
+ expect(from[0]).toContain('node:24-alpine');
+ expect(from[1]).toContain('nginx-unprivileged');
+ for (const image of from) {
+ expect(image, 'every base image must be pinned by digest, like the GitHub Actions are')
+ .toMatch(/@sha256:[a-f0-9]{64}/);
+ }
});
test('ships the shared header snippet the config includes', () => {