Skip to main content
Glama
BZZ-Public

mcp-page-host

by BZZ-Public

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 for the full list.

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:

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/ has an Apache vhost pair as an example — and point ./deploy.sh at your server:

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.

{
  "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:

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.

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 — 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:

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:

# 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 2 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

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.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/BZZ-Public/mcp-page-host'

If you have feedback or need assistance with the MCP directory API, please join our Discord server