Skip to main content
Glama
t0saki
by t0saki

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.

Related MCP server: G4 Data Model MCP Server

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/versionsPUT …/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

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

  • Hosted MCP for creating, checking, deploying, and hosting static sites for AI agents.

  • MCP server for AI agents to plan, verify, and deploy Cloudflare-native apps.

  • AI agent website builder. Create and publish link-in-bio sites via MCP or REST API.

View all MCP Connectors

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/t0saki/sitedrop'

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