sitedrop

sitedrop

MCP server for self-hosted static site publishing on Cloudflare Workers. Enables AI coding agents to deploy pages with a single 'publish' tool and get live URLs, with support for atomic updates, versioning, and per-site passwords.

Category
Visit Server

README

sitedrop

English · 简体中文

Self-hosted static site publishing for AI coding agents. One Cloudflare Worker, one HTTP call, a live URL:

POST /api/sites  →  https://sitedrop.you.workers.dev/amber-canyon/

Your agent (Claude Code, Cursor, Codex, …) publishes the page it just built, gets a URL back, and shows it to you. No build step, no git push, no wait.

Everything runs in your Cloudflare account: the Worker, the D1 database and the R2 bucket. There is no sitedrop service and no account to sign up for.

  • One MCP tool. publish, nothing else to learn.
  • File bytes never pass through the model. The tool returns a signed curl command; the agent runs it and the bytes go straight from disk to R2.
  • Instant and atomic. Publishing is a single pointer flip — visitors see the old version or the new one, never a half-uploaded site.
  • Free plan first. Designed inside 10 ms CPU and 50 subrequests per request.
  • ~2,500 lines, one runtime dependency (fflate, for zip).

Deploy

Deploy to Cloudflare

Or from a clone:

npm install
npx wrangler secret put ADMIN_TOKEN      # openssl rand -hex 32
npx wrangler secret put SESSION_SECRET   # openssl rand -hex 32
npx wrangler deploy

The D1 database and R2 bucket are declared without ids, so wrangler deploy provisions them on first run. Tables are created on the first request — there is no migration step.

Then open https://<your-worker>/admin, sign in with ADMIN_TOKEN, and create a token for each agent.

Note on R2: Cloudflare's free R2 tier generally requires a payment card on file before the bucket can be created, even though nothing is charged at these volumes. This is not documented by Cloudflare but is consistently reported. D1 and Workers have no such requirement.

Custom domain

Point a hostname you own at the Worker from the Cloudflare dashboard — Workers & Pages → sitedrop → Settings → Domains & Routes → Add → Custom domain — which also creates the DNS record and issues the certificate. This is deliberately not in wrangler.jsonc: a hostname belongs to one deployment, and hardcoding it would break every fork and the Deploy button. Custom domains live on the Worker rather than in the config, so later wrangler deploy runs leave them alone.

With a custom domain, also pin the canonical origin:

npx wrangler secret put BASE_URL          # https://sitedrop.example.com

A secret rather than a vars entry for the same reason — and because wrangler deploy replaces the whole vars block on every deploy, while secrets persist. See Security model for what it buys.

Continuous deployment

.github/workflows/deploy.yml deploys main once the tests pass. It stays inert until the repository has the secrets below, so forks are unaffected: they run the tests and skip the deploy step.

To enable it on your own copy, create a Cloudflare API token (My Profile → API Tokens → Create Token → Custom token) with:

Scope Permission Level
Account Workers Scripts Edit
Account D1 Edit
Account Workers R2 Storage Edit
Account Account Settings Read

Then add it under Settings → Secrets and variables → Actions:

  • CLOUDFLARE_API_TOKEN — required.
  • CLOUDFLARE_ACCOUNT_ID — only if the token can see more than one account.

No zone permissions are needed: the token only updates the Worker, and the custom domain above is already attached to it.

Connect an agent

The admin UI prints a ready-made snippet for each client when you create a token. For Claude Code:

claude mcp add --transport http sitedrop https://<your-worker>/mcp \
  --header "Authorization: Bearer sd_..."

For clients that cannot send custom headers, the token can go in the path instead: https://<your-worker>/mcp/sd_.... It is more leak-prone (URLs end up in logs and config files), so prefer the header.

What the agent sees

publish takes metadata only — slug, title, password, spa — and returns a short-lived upload URL plus the exact commands to run:

Site will be live at: https://sitedrop.you.workers.dev/amber-canyon/
Upload within 15 minutes by running ONE of these in the shell:

# a single HTML file:
curl -fsS -H 'Content-Type: text/html' --data-binary @index.html 'https://.../api/upload/sdt_…'

# a whole directory (needs the zip binary):
(cd DIR && zip -qr - .) | curl -fsS -H 'Content-Type: application/zip' --data-binary @- 'https://.../api/upload/sdt_…'

# large sites, more than 40 files, or no zip binary:
curl -fsSL https://.../cli -o /tmp/sitedrop && sh /tmp/sitedrop publish DIR --ticket 'sdt_…'

The ticket is an HMAC-signed, single-slug, 15-minute credential. Nothing is stored server-side for it, and the long-lived agent token never appears in the transcript.

For a small page the agent already holds in memory, publish(html: "…") skips the round trip and returns the live URL immediately.

CLI

The Worker serves its own CLI with the deployment URL baked in — POSIX sh and curl, nothing else:

curl -fsSL https://<your-worker>/cli -o sitedrop
export SITEDROP_TOKEN=sd_...

sh sitedrop publish ./dist            # directory: uploaded file by file
sh sitedrop publish page.html         # single page
sh sitedrop list
sh sitedrop password my-site hunter2  # or --remove
sh sitedrop rollback my-site
sh sitedrop delete my-site

It prints the URL on stdout and everything else on stderr, so URL=$(sh sitedrop publish ./dist) just works. It is never interactive: a missing argument is an error, not a prompt. Publishing a directory writes .sitedrop.json next to it so the next publish updates the same URL.

REST

Full schema at /openapi.json. The essentials:

# single page
curl -H "Authorization: Bearer $SITEDROP_TOKEN" -H 'Content-Type: text/html' \
     --data-binary @index.html "$BASE/api/sites?slug=my-site"

# a zip
(cd dist && zip -qr - .) | curl -H "Authorization: Bearer $SITEDROP_TOKEN" \
     -H 'Content-Type: application/zip' --data-binary @- "$BASE/api/sites?slug=my-site"

# JSON envelope
curl -H "Authorization: Bearer $SITEDROP_TOKEN" -H 'Content-Type: application/json' \
     -d '{"slug":"my-site","files":[{"path":"index.html","content":"<h1>hi</h1>"}]}' \
     "$BASE/api/sites"

Options travel as query parameters (slug, title, spa, indexable, sandbox), as JSON body fields, or — for passwords — in the X-Sitedrop-Password header. An empty password removes it; omitting it leaves it unchanged.

Sites larger than one request go through the batch endpoints (POST /api/sites/:slug/versions → PUT …/files/<path> → POST …/finalize), which is what the CLI uses for directories. There is no size limit on that path.

How it works

Worker ── D1 ── sites / versions / tokens        (metadata, pointer flip)
   └──── R2 ── sites/<site>/<version>/<path>     (one object per file)
  • Publishing writes every R2 object first, then runs one db.batch() that demotes the old version, promotes the new one and repoints the site. Readers therefore never observe a partial publish. Concurrent publishes to the same slug both succeed; one of them wins the pointer.
  • Versions are immutable prefixes in R2. The last three are kept, so rollback is another pointer flip and never re-uploads anything. Older versions are deleted in waitUntil using the manifest stored on the version row.
  • Serving costs two indexed row reads (slug lookup + version join) and one R2 get with the request's conditional headers, so If-None-Match becomes a 304 without transferring the body.
  • No KV, no Durable Objects. KV's negative caching would make a publish-then-fetch 404 for up to a minute; D1 and R2 are strongly consistent.

Caching, and what it actually saves

Cloudflare's cache sits in front of the Worker (cache: { enabled: true }), so a hit is answered at the edge without invoking it at all — no CPU, no D1 row read, no R2 GET. Public site responses are written to make use of that:

Response Cache-Control
HTML public, max-age=0, must-revalidate, s-maxage=3600, stale-while-revalidate=3600
assets public, max-age=300, s-maxage=3600, stale-while-revalidate=3600
password-protected private, … — never enters a shared cache
404s, redirects, the gate, all of /api no-store

Browsers keep revalidating HTML on every navigation, so a publish is visible on the next reload, while every other visitor is served from the edge. What makes the long shared TTL safe is that publish, rollback, option changes and delete all purge the site's cache tag. Tune it with EDGE_CACHE_SECONDS; 0 sends every request to the Worker.

It does not reduce your request count. Cloudflare is explicit: "all requests to your Worker are billed at the standard Workers request rate — the same per-rate as any other request to your Worker — whether the response comes from cache or from your Worker." Caching cuts CPU time, D1 rows read, R2 Class B operations and latency. It does not lower the free plan's 100,000 requests/day — if you are hitting that ceiling, the $5/month paid plan (10M requests included) is the lever, not the cache.

The free plan allows 5 purges/minute (bucket of 25), which is ample for one purge per publish but not for a runaway republish loop. A rejected purge is logged rather than raised — a publish must not fail over the cache — so the worst case is a site serving its previous version for up to EDGE_CACHE_SECONDS. Deploying the Worker also clears everything, because the Worker version is part of the cache key unless you turn on cross_version_cache.

Security model

sitedrop is single-tenant: every valid agent token may publish, update or delete any site. Tokens are for handing to your own agents, not for sharing.

  • Tokens are stored as SHA-256 hashes; the plaintext is shown once at creation. last_used_at is recorded (throttled to one write per hour).
  • The admin UI never sets a cookie — it holds the admin token in sessionStorage and sends it as a header — so nothing on this origin can ride an ambient admin session.
  • Site passwords gate previews, they are not accounts. They are hashed with HMAC-SHA256 under a server-side key rather than a real KDF, because the free plan's 10 ms CPU budget cannot run PBKDF2/scrypt at a useful cost factor. A database leak alone does not allow offline cracking (the key never leaves the Worker), but do not reuse a password that matters.
  • The unlock cookie is bound to the current password hash: changing the password signs out everyone immediately.
  • A protected site's subresources are refused when the Referer says the request came from a different path on this deployment, which stops another page on the same origin from riding the unlock cookie. Requests with no Referer are allowed — that is a deliberate, documented gap.
  • All sites share one origin. localStorage, sessionStorage and cookie jars are per-origin, so a page published at /a/ can read what a page at /b/ stored. Do not host untrusted code next to something sensitive; turn on the per-site sandbox CSP (admin UI → Options) for pages you did not write.
  • Uploaded paths are rejected for traversal (including percent-encoded), backslashes, control characters and padded segments. Executables (.exe, .sh, .dll, …) are skipped with a warning.
  • Zip archives are checked against their central directory before anything is inflated, so a zip bomb is rejected without running the decompressor.
  • Every site response carries X-Robots-Tag: noindex unless the site opts in, and /robots.txt disallows everything.

Limits

Defaults, all configurable in wrangler.jsonc:

Variable Default Meaning
EDGE_CACHE_SECONDS 3600 edge TTL for public sites; 0 disables edge caching
LIMIT_MAX_FILES 40 files in one request (zip or JSON)
LIMIT_MAX_ZIP_BYTES 2 MB archive size
LIMIT_MAX_FILE_BYTES 5 MB one file
LIMIT_MAX_SITE_BYTES 25 MB whole site
KEEP_VERSIONS 3 versions kept for rollback

These keep a single request inside the free plan's 10 ms CPU and 50 subrequests. Bigger sites are not blocked — they go through the batch API, which the CLI uses automatically. On a paid plan you can raise all of them.

Cache purges are budgeted (5/minute on the free plan), so sitedrop issues exactly one per publish.

Development

npm install
cp .dev.vars.example .dev.vars   # fill in ADMIN_TOKEN and SESSION_SECRET
npm run dev

npm run typecheck
npm test                          # vitest, runs in workerd via miniflare
./scripts/conformance.sh          # MCP conformance suite
ADMIN_TOKEN=… ./scripts/smoke.sh https://<your-worker>   # against a real deploy

MCP implementation notes

The server is a hand-written stateless Streamable HTTP endpoint (~200 lines, no SDK). Deliberate deviations:

  • never responds with SSE — every reply is one JSON body;
  • ignores Mcp-Session-Id and Last-Event-ID, because there is no session;
  • GET/DELETE /mcp return 405 (there is no stream to open);
  • 401 carries no WWW-Authenticate, so clients do not start an OAuth discovery flow this server does not implement;
  • JSON-RPC batching is refused with -32600, per the 2025-06-18 spec;
  • business failures (bad slug, too many files, …) come back as isError: true tool results that say whether retrying helps — not as protocol errors.

Set BASE_URL to the deployment's canonical origin to enable strict DNS-rebinding protection; without it the origin check falls back to the request's own Host header.

License

MIT

Recommended Servers

playwright-mcp

playwright-mcp

A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.

Official
Featured
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

graphlit-mcp-server

The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.

Official
Featured
TypeScript
Kagi MCP Server

Kagi MCP Server

An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.

Official
Featured
Python
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Exa Search

Exa Search

A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured