Skip to main content
Glama
BZZ-Public

mcp-page-host

by BZZ-Public
README.md
# mcp-page-host

Host single-file HTML pages, deploy them over MCP.

An LLM generates an interactive page — an animation, a simulation, a visual
explanation, a small practice tool — and a teacher needs a stable URL to put in
a wiki, a PDF, Moodle or a presentation. This closes the gap between "the page
exists" and "here is the link":

```
deploy_page(slug: "physics-pendulum", html: "<!DOCTYPE html>…")
→ https://pages.example.org/physics-pendulum/
```

One page is one self-contained HTML file with its CSS and JavaScript inline.
No build step, no bundler, no multi-file deployments, no database. State lives
in the filesystem and is mirrored to a private git repository.

## Setup

Needs Node.js 22+ and Docker. Everything is configured through environment
variables; see [`.env.example`](.env.example) for the full list.

```bash
git clone <this repository>
cd mcp-page-host
cp .env.example .env

# One token per teacher. The name lands in meta.json and the audit log.
echo "PAGE_HOST_TOKENS=jane:$(openssl rand -hex 32)" >> .env

npm install && npm test
docker compose up -d --build
curl -s localhost:8080/healthz          # {"status":"ok"}
```

Deploy a page and read it back:

```bash
TOKEN=…   # the token from .env
curl -X PUT localhost:8080/api/pages/physics-pendulum \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"html":"<!DOCTYPE html><title>Pendulum</title><h1>Hello</h1>","title":"Pendulum"}'
curl -s localhost:8080/physics-pendulum/
```

For a public deployment, put a reverse proxy in front of the loopback port —
[`deploy/`](deploy/) has an Apache vhost pair as an example — and point
`./deploy.sh` at your server:

```bash
HOST=example.org DEPLOY_USER=pages ./deploy.sh
```

## HTTP API

All endpoints take `Authorization: Bearer <token>`.

| Method | Path | Purpose |
|---|---|---|
| `PUT` | `/api/pages/:slug` | Create or replace a page |
| `GET` | `/api/pages` | List all pages (`?search=` filters) |
| `GET` | `/api/pages/:slug` | Metadata (`?includeHtml=true` adds the HTML) |
| `DELETE` | `/api/pages/:slug` | Delete a page |

`PUT` refuses with `409` when the slug is taken, and names who created it — so
one teacher cannot quietly overwrite another's lesson. Pass `overwrite: true`
to replace it anyway.

## MCP

The MCP endpoint is `POST /mcp` (Streamable HTTP, stateless), authenticated
with the same tokens. It exposes four tools: `deploy_page`, `list_pages`,
`get_page` and `delete_page`.

```json
{
  "mcpServers": {
    "pages": {
      "type": "http",
      "url": "https://pages.example.org/mcp",
      "headers": { "Authorization": "Bearer <token>" }
    }
  }
}
```

## Handing out tokens

There is no user management, no login and no self-service. A token is a random
string an administrator generates and gives to one teacher:

```bash
openssl rand -hex 32
```

Add it to `PAGE_HOST_TOKENS` as `name:token`, comma separated, and restart the
container. The name appears in `meta.json` as `createdBy`, in the audit log,
and in the `409` message another teacher sees — so use something recognisable,
not `user1`.

To revoke access, delete the entry and restart. Pages created with that token
stay where they are; only the ability to deploy is withdrawn. Two names must
never share a token, or the audit log will name the wrong person; the service
refuses to start in that case.

Tokens belong in `.env` on the server, `chmod 600`, never in the repository.

## The CSP trade-off

Pages are served with a Content-Security-Policy that includes
`script-src 'unsafe-inline'`. This is a deliberate decision and the weakest
point of the design, so it is worth stating plainly.

The format of this service is one self-contained file: the JavaScript that
makes a page interactive sits in a `<script>` tag inside the document. There is
no build step that could hash or nonce those scripts, because introducing one
would mean introducing a bundler, a second file and a toolchain — everything
this service exists to avoid. So inline script has to be allowed.

What that means in practice: if a generated page contains hostile JavaScript,
the CSP will not stop it from running. The mitigations are elsewhere:

- **A separate origin.** Pages are served from their own hostname, which shares
  no cookie with any other system. A page cannot read a session from a wiki, a
  timetable or a learning platform, because none of those cookies are valid for
  this domain. This is the mitigation that actually matters — keep it.
- **A short CDN allowlist.** `PAGE_HOST_CDN_HOSTS` decides which other hosts may
  execute code. Every entry is a host trusted with a student's browser, so the
  default list is two well-known CDNs and it should stay short.
- **`object-src`, `base-uri` and `form-action` are `'none'`.** Each of these
  removes a way to turn a generated page into a redirector or a credential
  collector, and none of them costs anything for a self-contained page.
- **`frame-ancestors 'self'`** by default, so a page cannot be embedded
  elsewhere. Widen it via `PAGE_HOST_FRAME_ANCESTORS` if pages should appear
  inside a wiki or Moodle.
- **`X-Robots-Tag: noindex, nofollow`**, because generated lesson pages do not
  belong in search results.

The application is the single source of truth for these headers. The example
vhost adds `Header setifempty` for the three simple ones as a fallback, but
deliberately not for the CSP: Apache's config parser treats single quotes as
quoting characters, so `'self'` arrives as bare `self` — invalid CSP syntax —
and two CSP headers are enforced as the intersection of both policies.

Note the missing `always` on those `setifempty` lines. It looks like the safer
spelling, but `always` writes to a different header table than the one a proxied
response lands in, so `setifempty` never sees what the backend sent and every
response goes out with the header twice.

## The GitHub mirror

With `PAGE_HOST_MIRROR_ENABLED=true`, every deploy and delete is committed and
pushed to a private git repository. The data volume is a cache; the mirror is
the source of truth. Losing the volume is not an incident — the next start
clones it back.

Access uses an **SSH deploy key**, not a personal access token: a deploy key is
scoped to one repository and cannot be used against the account.

```bash
ssh-keygen -t ed25519 -f ./mirror_deploy_key -N "" -C "page host mirror"
# Add mirror_deploy_key.pub to the repo under
# Settings → Deploy keys → Add deploy key, with "Allow write access" ticked.
```

Put the private key on the server and mount it read-only into the container —
the mount is commented out in [`docker-compose.yml`](docker-compose.yml) — then
point `PAGE_HOST_GIT_SSH_KEY` at it.

A bind mount keeps the host's ownership, and the container runs as uid 10001,
so the key file has to belong to **10001**, not to the account you deploy with.
`ssh` refuses a key it cannot read, and the mirror then disables itself with
`Load key …: Permission denied` in the log:

```bash
sudo install -d -o 10001 -g 10001 -m 700 /opt/<app>/secrets
sudo install -o 10001 -g 10001 -m 400 mirror_deploy_key /opt/<app>/secrets/
```

Keep server-specific mounts in an untracked `docker-compose.override.yml` next
to the compose file; `deploy.sh` excludes it from the rsync so it survives a
redeploy.

Make the mirror repository **private**. A public one would undo the `noindex`
header, because GitHub lets its contents be indexed. And note that pages leave
your infrastructure when they are mirrored: generated pages must not contain
personal data.

### What happens at start-up

| Volume | Mirror | Result |
|---|---|---|
| has `.git` | — | `git pull --ff-only` |
| empty | has commits | `git clone` |
| has pages | empty | `git init`, adopt what is on the volume |
| has pages | has commits | **refuses**, mirroring off, pages still served |

The last row is the ambiguous one, and nothing is guessed: no `reset --hard`,
no `--force`, in either direction. Reconcile the two by hand once and restart.

The commit is synchronous and local — that is what protects the previous
revision. The push waits up to `PAGE_HOST_PUSH_WAIT_MS` and then retries in the
background, so GitHub being slow never turns into a failed deploy of a page
that is already live. A response with `mirrored: false` means "live and
committed locally, not yet at GitHub", never "lost".

### Restoring

The mirror is an ordinary git repository, so recovery needs no tooling:

```bash
# The whole service: delete the volume and restart. Start-up clones it back.
docker compose down && docker volume rm <project>_pages-data && docker compose up -d

# One page, from before it was changed or deleted:
git clone git@github.com:your-org/your-pages-mirror.git
git -C your-pages-mirror log --oneline -- pages/physics-pendulum
git -C your-pages-mirror show <commit>:pages/physics-pendulum/index.html
```

Then redeploy that HTML through `deploy_page` or `PUT`, rather than writing into
the volume by hand.

## Security notes

- The container runs as an unprivileged user with a read-only root filesystem,
  all capabilities dropped, `no-new-privileges`, and memory, PID and CPU limits.
- It publishes only on the host's loopback interface. TLS belongs to the
  reverse proxy.
- Slugs match `^[a-z0-9][a-z0-9-]{2,59}$`, and the resolved path is checked
  against the pages root a second time. Only `index.html` is ever served, always
  as `text/html; charset=utf-8`; `meta.json` returns 404, because it carries the
  teacher's name. Symlinks are refused, so a symlink committed to the mirror
  cannot be used to read the volume.
- Pages are limited to 8 MiB and writes to 30 per token and hour.
- Every deploy and delete is logged with timestamp, token name and slug. Page
  content is never logged.

## Development

```bash
npm install
npm test          # unit tests, HTTP API, static serving, MCP, mirror
npm run dev       # tsx watch, needs PAGE_HOST_TOKENS and PAGE_HOST_DATA_DIR
npm run typecheck
```

The mirror tests use a local bare repository as a stand-in for GitHub, so the
suite needs neither network access nor a deploy key.

## Not in scope

No build step, no multi-file deployments, no web UI, no database, no user
management, no analytics, no server-side rendering. If a change starts to need
one of these, it probably belongs in a different project.

## License

MIT — see [LICENSE](LICENSE).