Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ hugo
node_modules/
resources/
src/.hugo_build.lock
bin/.hugo/
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,35 @@ This commit triggers a third GitHub workflow that will deploy the latest version

_Theme updates do not trigger a deployment, consequently following a theme update, the docs website must be deployed manually._

## AI assistant access (MCP)

The documentation is also exposed to AI assistants through a
[Model Context Protocol](https://modelcontextprotocol.io/) server, so that tools like
Claude, Cursor or ChatGPT answer PrestaShop questions from the actual documentation.

The Hugo build publishes three machine-readable artifacts alongside the site:

| Artifact | Contents |
| --- | --- |
| `/<path>/index.md` | raw markdown source of every page |
| [`/llms.txt`](https://devdocs.prestashop-project.org/llms.txt) | index of the supported documentation version, per the [llms.txt convention](https://llmstxt.org/) |
| [`/mcp-index.json`](https://devdocs.prestashop-project.org/mcp-index.json) | compact page catalogue |

The MCP server itself is a small stateless HTTP service in [`mcp-server/`](mcp-server/) —
GitHub Pages can only serve static files, and MCP requires an endpoint that answers `POST`
requests. It holds no data of its own: it reads the artifacts above and delegates search to
the site's existing Algolia DocSearch index, so **documentation changes go live without
redeploying it**.

To run and test the whole thing locally:

```bash
npm run mcp:local # builds the site, serves it, starts the Worker on :8787
npm run mcp:test # in another terminal
```

See [`mcp-server/README.md`](mcp-server/README.md) for setup, deployment and usage.

## License

Content from this documentation is licensed under the [Creative Commons Attribution-ShareAlike 4.0 International License](https://creativecommons.org/licenses/by-sa/4.0/).
143 changes: 143 additions & 0 deletions bin/mcp-local.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
#
# Runs the whole MCP stack locally:
#
# 1. builds the site with the same Hugo version CI uses (downloaded on first run)
# 2. serves src/public over HTTP, standing in for GitHub Pages
# 3. runs the Cloudflare Worker against it
#
# The MCP endpoint ends up at http://127.0.0.1:8787/mcp
#
# Pass --no-build to skip the Hugo build and reuse the existing src/public.
#
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Must match the version pinned in .github/workflows/build.yml. A newer Hugo will not
# build this site: the theme still uses getJSON, removed in Hugo 0.158.
HUGO_VERSION="0.121.1"
HUGO_DIR="${ROOT}/bin/.hugo/${HUGO_VERSION}"
HUGO_BIN="${HUGO_DIR}/hugo"
SITE_PORT="${SITE_PORT:-8788}"
MCP_PORT="${MCP_PORT:-8787}"

log() { printf '\033[36m▸\033[0m %s\n' "$*"; }
die() { printf '\033[31m✗ %s\033[0m\n' "$*" >&2; exit 1; }

install_hugo() {
[ -x "$HUGO_BIN" ] && return

local os arch asset
os="$(uname -s)"
arch="$(uname -m)"
case "$os" in
Darwin) asset="darwin-universal" ;;
Linux)
case "$arch" in
x86_64) asset="linux-amd64" ;;
aarch64|arm64) asset="linux-arm64" ;;
*) die "Unsupported Linux architecture: $arch" ;;
esac
;;
*) die "Unsupported OS: $os. Build the site yourself and rerun with --no-build." ;;
esac

log "Downloading Hugo ${HUGO_VERSION} (${asset})…"
mkdir -p "$HUGO_DIR"
curl -fsSL -o "${HUGO_DIR}/hugo.tar.gz" \
"https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_${asset}.tar.gz"
tar -xzf "${HUGO_DIR}/hugo.tar.gz" -C "$HUGO_DIR" hugo
rm -f "${HUGO_DIR}/hugo.tar.gz"
}

build_site() {
install_hugo
local configs="config.yml"
# config-local.yml is gitignored; it disables the git-info lookup that would
# otherwise need DEVDOCS_GITHUB_READ_TOKEN.
[ -f "${ROOT}/src/config-local.yml" ] && configs="config.yml,config-local.yml"

log "Building the site with Hugo ${HUGO_VERSION}…"
(cd "${ROOT}/src" && "$HUGO_BIN" --config "$configs" --quiet)

[ -f "${ROOT}/src/public/mcp-index.json" ] || die "Build produced no mcp-index.json."
log "Built $(find "${ROOT}/src/public" -name index.md | wc -l | tr -d ' ') markdown artifacts."
}

serve_site() {
log "Serving src/public on http://127.0.0.1:${SITE_PORT} (standing in for GitHub Pages)…"
(cd "${ROOT}/src/public" && python3 -m http.server "$SITE_PORT" --bind 127.0.0.1 >/dev/null 2>&1) &
SITE_PID=$!

for _ in $(seq 1 40); do
curl -sf -o /dev/null "http://127.0.0.1:${SITE_PORT}/mcp-index.json" && return
sleep 0.25
done
die "Static server did not come up on port ${SITE_PORT}."
}

port_pids() { lsof -nP -tiTCP:"$1" -sTCP:LISTEN 2>/dev/null || true; }

# Signalling the PIDs we launched is not enough on its own: `npx` sits in front of a
# chain of node processes, so terminating it can leave the real wrangler process alive
# and still holding the port. The contract we actually care about is "both ports are
# free afterwards", so verify that and force it if the polite shutdown stalls.
cleanup() {
trap - EXIT INT TERM
log "Shutting down…"

for pid in "${WRANGLER_PID:-}" "${SITE_PID:-}"; do
[ -n "$pid" ] && kill -TERM "$pid" 2>/dev/null || true
done

for _ in $(seq 1 20); do
[ -z "$(port_pids "$MCP_PORT")$(port_pids "$SITE_PORT")" ] && return
sleep 0.25
done

for port in "$MCP_PORT" "$SITE_PORT"; do
pids="$(port_pids "$port")"
[ -n "$pids" ] && kill -9 $pids 2>/dev/null || true
done
}
trap cleanup EXIT INT TERM

# Fail early and clearly rather than half-starting on top of a previous run.
for port_pair in "$SITE_PORT:static server" "$MCP_PORT:MCP endpoint"; do
port="${port_pair%%:*}"
if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
die "Port ${port} (${port_pair#*:}) is already in use — a previous run may still be alive.
Inspect it with: lsof -nP -iTCP:${port} -sTCP:LISTEN
Then stop it, or rerun with SITE_PORT/MCP_PORT set to free ports."
fi
done

[ "${1:-}" = "--no-build" ] || build_site
[ -d "${ROOT}/src/public" ] || die "No src/public. Run without --no-build first."

serve_site

[ -d "${ROOT}/mcp-server/node_modules" ] || (log "Installing Worker dependencies…" && cd "${ROOT}/mcp-server" && npm install --silent)

cat <<EOF

MCP endpoint http://127.0.0.1:${MCP_PORT}/mcp
Smoke test ./mcp-server/test/smoke.sh
Inspector npx @modelcontextprotocol/inspector
Claude Code claude mcp add --transport http devdocs-local http://127.0.0.1:${MCP_PORT}/mcp

Ctrl-C to stop.

EOF

cd "${ROOT}/mcp-server"
# Deliberately NOT `exec`: that would replace this shell and discard the trap above,
# leaving the static server on $SITE_PORT orphaned when wrangler stops.
#
# Backgrounded, then waited on, rather than run in the foreground: bash defers trap
# handlers until the current foreground command returns, so a foreground wrangler
# would swallow the signal until it felt like exiting — which is exactly the hang
# this is meant to survive. `wait` is interruptible, so the trap fires immediately.
npx wrangler dev --port "$MCP_PORT" --var "DOCS_BASE_URL:http://127.0.0.1:${SITE_PORT}/" &
WRANGLER_PID=$!
wait "$WRANGLER_PID"
3 changes: 3 additions & 0 deletions mcp-server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
.wrangler/
.dev.vars
160 changes: 160 additions & 0 deletions mcp-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# PrestaShop DevDocs MCP server

A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes the
PrestaShop developer documentation to AI assistants (Claude, Cursor, ChatGPT, VS Code,
Zed, …), so they answer PrestaShop 9 questions from the actual documentation instead of
from training-data recall.

## How it fits together

GitHub Pages can only serve static files, and an MCP server has to answer `POST` requests
with JSON-RPC — so the two halves live in different places:

```
┌─ this repository ──────────────────────────────────────────────┐
│ Hugo build (build.yml) ──► GitHub Pages │
│ ├─ /9/**/index.md raw markdown │
│ ├─ /llms.txt page index │
│ └─ /mcp-index.json page catalogue│
└─────────────────────────────────────────────────────────────────┘
▲ ▲
│ fetch (cached) │
┌─ mcp-server/ (Cloudflare Worker) ──────────────────────────────┐
│ POST /mcp Streamable HTTP, stateless │
│ search_docs ──► Algolia DocSearch (the site's own index) │
│ get_doc ──► /<path>/index.md on GitHub Pages │
│ list_sections ► /mcp-index.json │
└─────────────────────────────────────────────────────────────────┘
│ https
Claude / Cursor / ChatGPT / …
```

The Worker stores nothing. Search relevance comes from the same Algolia DocSearch index
that powers the search box on the website, and page content is read from the markdown
files the Hugo build already publishes. **New documentation is live as soon as GitHub
Pages redeploys — the Worker never needs redeploying for content changes.**

## Tools

| Tool | Purpose |
| --- | --- |
| `search_docs(query, limit?, section?)` | Full-text search, returns pages with the heading path of each match |
| `get_doc(path, offset?, max_chars?)` | Full markdown source of one page, with paging for long pages |
| `list_sections(section?)` | Browse the documentation tree |

Only the currently supported documentation version (PrestaShop 9, per
`params.versions.current` in `src/config.yml`) is served. `get_doc` rejects paths outside
that version.

## Using it

Once deployed, point any MCP client at the `/mcp` endpoint.

```bash
claude mcp add --transport http prestashop-devdocs https://<worker-host>/mcp
```

Or, in a `mcp.json` / `claude_desktop_config.json`:

```json
{
"mcpServers": {
"prestashop-devdocs": {
"type": "http",
"url": "https://<worker-host>/mcp"
}
}
}
```

No authentication — the server is public and read-only, exactly like the website it reads.

## Running it locally

One command brings up the whole stack — it builds the site with the Hugo version CI uses
(downloaded to `bin/.hugo/` on first run), serves `src/public` in place of GitHub Pages,
and starts the Worker against it:

```bash
npm run mcp:local # from the repository root
```

The MCP endpoint lands on `http://127.0.0.1:8787/mcp`. Add `-- --no-build` to skip the
Hugo build and reuse the existing `src/public`. `SITE_PORT` and `MCP_PORT` override the
ports.

> Do not use a system-wide `hugo` for this. The theme still calls `getJSON`, removed in
> Hugo 0.158, so anything newer fails to build the site at all. The script pins 0.121.1 to
> match `.github/workflows/build.yml`.

### Testing it

```bash
npm run mcp:test # 38 checks: transport rules, lifecycle, every tool
```

With the official [MCP Inspector](https://github.com/modelcontextprotocol/inspector) —
`--cli` for one-shot calls, or drop it for the browser UI:

```bash
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8787/mcp \
--transport http --method tools/list

npx @modelcontextprotocol/inspector --cli http://127.0.0.1:8787/mcp \
--transport http --method tools/call \
--tool-name search_docs --tool-arg 'query=actionValidateOrder'
```

Or wire the local server into Claude Code and use it for real:

```bash
claude mcp add --transport http devdocs-local http://127.0.0.1:8787/mcp
claude mcp remove devdocs-local # when you are done
```

Plain `curl` works too — it is only JSON-RPC over `POST`:

```bash
curl -s -X POST http://127.0.0.1:8787/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"search_docs","arguments":{"query":"actionValidateOrder"}}}'
```

### Working on the Worker alone

`npm run dev` in this directory starts just the Worker, reading the **live**
`https://devdocs.prestashop-project.org/`. Useful for iterating on search, but `get_doc`
and `list_sections` will 404 until the artifacts from this branch are actually deployed.

## Search behaviour

`search_docs` queries Algolia twice. The first pass requires every word of the query to
match; only if that finds nothing does it retry with Algolia's default behaviour of
dropping words until something matches, and then labels the results as approximate.

This matters: on the lenient default, a question the documentation does not cover comes
back with a page of confident-looking irrelevant hits — `"zzzqqqxyzzy no such thing"`
returns 358 of them — which is precisely the material a model will hallucinate from. The
strict-first pass returns zero for that, and long natural-language questions still get
answers through the labelled fallback.

## Deployment

**Deployment is owned by the platform/devops team — this repository does not deploy the
server.** There is deliberately no CI workflow and there are no deployment secrets here.

Whichever host: Algolia DocSearch is free for open source and already in use by the
website, and the compute involved is negligible on any platform's free tier.

## Protocol notes

- Transport: Streamable HTTP, stateless (no `Mcp-Session-Id`, no SSE stream).
- `POST /mcp` returns `application/json`; `GET`/`DELETE /mcp` return `405`, which the spec
permits for servers that do not offer server-initiated streams.
- Negotiates protocol versions `2025-11-25` down to `2024-11-05`.
- CORS is fully open. The server is unauthenticated, read-only and holds no user data, so
the DNS-rebinding threat the `Origin` check defends against does not apply; allowing all
origins is what makes it usable from browser-based clients.
Loading
Loading