Architecture
Nội dung này hiện chưa có sẵn bằng ngôn ngữ của bạn.
This page is for people who want to understand the internals — either to self-host with confidence, debug an issue, or contribute. If you just want to connect Claude, the Quick start is enough.
One worker, two surfaces
Section titled “One worker, two surfaces”Everything ships as a single Cloudflare Worker. There are exactly two public HTTP entry points:
/mcp— JSON-RPC over Streamable HTTP. Where Claude (andmcp-remote, and any future MCP client) sendstools/listandtools/call./admin— server-rendered HTML + a JSON/admin/api/*surface. Where a human configures sites, mints tokens, and reviews the audit log.
A handful of supporting endpoints exist alongside them — /.well-known/oauth-*,
/oauth/authorize, /oauth/token, /oauth/revoke, /oauth/register — but
they only support the two main surfaces.
High-level diagram
Section titled “High-level diagram”Claude / MCP client Browser (admin) │ │ │ Authorization: Bearer │ admin_session cookie │ (oat_* OAuth or sn_prod_* │ (HMAC of MASTER_TOKEN) │ bearer) │ ▼ ▼┌──────────────────── Cloudflare Worker ───────────────────────┐│ src/index.ts (router) ││ ├─ /mcp → src/mcp/handler.ts ││ ├─ /oauth/* → src/auth/oauth/*.ts ││ ├─ /admin → src/admin/router.ts (HTML SSR) ││ └─ /admin/api/* → src/admin/api/*.ts (JSON) ││ ││ Auth ││ ├─ src/auth/mcp-auth.ts (OAuth + bearer + scope) ││ └─ src/auth/admin-auth.ts (master token + HMAC cookie) ││ ││ MCP dispatcher ││ 1. resolve token → user_sites whitelist ││ 2. tools/list = built-in 32 + proxy tools (per site) ││ 3. tools/call: scope check → adapter → audit_log row ││ ││ Adapters ││ ├─ src/adapters/wordpress.ts (REST, posts) ││ ├─ src/adapters/duda.ts (REST, posts) ││ ├─ src/adapters/page-wordpress.ts (REST, pages) ││ ├─ src/adapters/page-elementor.ts (template duplicate) ││ ├─ src/adapters/page-duda.ts (Duda Pages v2) ││ ├─ src/adapters/page-code.ts (code-first pages) ││ ├─ src/adapters/wp-mcp-proxy.ts (MCP-over-MCP) ││ ├─ src/adapters/github.ts (Git Data API) ││ └─ src/adapters/cf-pages.ts (Cloudflare Pages API) │└──────────────────────────────────────────────────────────────┘ │ │ │ ▼ ▼ ▼ D1 (metadata) Worker Secrets External APIs + encrypted - MASTER_TOKEN - WP REST blobs - DUDA_API_* - api.duda.co - GITHUB_APP_* - api.github.com - CLOUDFLARE_* - WP MCP plugin endpointsD1 schema
Section titled “D1 schema”The worker uses a single D1 database (blog_mcp by convention — kept for
backward-compat with the original project name). Migrations live in
migrations/ and apply in order.
Core tables
Section titled “Core tables”-- sites: one row per site Claude can touchCREATE TABLE sites ( id TEXT PRIMARY KEY, -- slug e.g. "blog-acme" name TEXT NOT NULL, platform TEXT NOT NULL, -- wordpress | duda | astro base_url TEXT NOT NULL, duda_site_name TEXT, -- Duda only wp_username TEXT, -- WP only wp_app_password_enc TEXT, -- AES-GCM blob (0002) page_builder TEXT DEFAULT 'gutenberg', available_builders TEXT, -- JSON array (0008) default_builder TEXT, -- (0008) mcp_backend TEXT DEFAULT 'default', mcp_endpoints_json TEXT, github_repo TEXT, -- "owner/repo" (0009) github_branch TEXT, github_path_prefix TEXT, cf_pages_project TEXT, -- (0012) cf_pages_url TEXT, astro_project_root TEXT, -- (0012) notes TEXT, created_at INTEGER NOT NULL, last_used_at INTEGER);
-- tokens: bearer auth + per-token scopeCREATE TABLE tokens ( id TEXT PRIMARY KEY, name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, -- sha256(plaintext) allowed_sites TEXT NOT NULL, -- JSON array, ["*"] = all allowed_tools TEXT NOT NULL, -- JSON array, ["*"] = all owner_user_id TEXT, -- NULL = super-admin issued (0005) created_at INTEGER NOT NULL, last_used_at INTEGER, revoked_at INTEGER);
-- audit_log: every tool callCREATE TABLE audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, actor_user_id TEXT, -- (0005) token_id TEXT, site_id TEXT, tool TEXT NOT NULL, args_json TEXT, -- truncated to ~1KB status TEXT NOT NULL, -- ok | denied | error error TEXT, duration_ms INTEGER, via TEXT -- "default" | "wp-mcp-adapter");RBAC tables (migration 0005)
Section titled “RBAC tables (migration 0005)”CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, -- PBKDF2(SHA-256, 100k rounds) created_at INTEGER NOT NULL, last_login_at INTEGER);
-- M:N — which sites can each user see / mint tokens forCREATE TABLE user_sites ( user_id TEXT NOT NULL, site_id TEXT NOT NULL, PRIMARY KEY (user_id, site_id));OAuth tables (migration 0006, 0007)
Section titled “OAuth tables (migration 0006, 0007)”CREATE TABLE oauth_clients ( client_id TEXT PRIMARY KEY, client_secret TEXT, -- nullable for public clients redirect_uris TEXT NOT NULL, -- JSON array metadata TEXT NOT NULL, -- JSON DCR record created_at INTEGER NOT NULL);CREATE TABLE oauth_codes ( code TEXT PRIMARY KEY, client_id TEXT NOT NULL, user_id TEXT NOT NULL, redirect_uri TEXT NOT NULL, code_challenge TEXT NOT NULL, -- PKCE S256 expires_at INTEGER NOT NULL);CREATE TABLE oauth_access_tokens ( token_hash TEXT PRIMARY KEY, -- sha256(oat_*) client_id TEXT NOT NULL, user_id TEXT NOT NULL, scope TEXT NOT NULL, -- "mcp:full" expires_at INTEGER NOT NULL, -- ~1h revoked_at INTEGER);CREATE TABLE oauth_refresh_tokens ( token_hash TEXT PRIMARY KEY, client_id TEXT NOT NULL, user_id TEXT NOT NULL, scope TEXT NOT NULL, expires_at INTEGER NOT NULL, -- ~30d, rotated on use revoked_at INTEGER);GitHub + Cloudflare Pages tables
Section titled “GitHub + Cloudflare Pages tables”-- migration 0011 — one row per GitHub App installationCREATE TABLE github_installations ( installation_id INTEGER PRIMARY KEY, account_login TEXT NOT NULL, -- e.g. "supportsn" account_type TEXT NOT NULL, -- User | Organization installed_at INTEGER NOT NULL, installed_by TEXT -- user_id who triggered install);See migrations/0001_init.sql through migrations/0013_*.sql for the full
history. Migrations are idempotent and tracked in D1’s internal
d1_migrations table — wrangler d1 migrations apply is safe to re-run.
Request flows
Section titled “Request flows”MCP request (/mcp)
Section titled “MCP request (/mcp)”1. Read Authorization: Bearer <token> - If "oat_..." → look up in oauth_access_tokens, check expiry/revoke - If "sn_prod_*"→ sha256 → look up in tokens table - Neither match → 401 + WWW-Authenticate pointing at the OAuth metadata2. Resolve actor: - OAuth: user_id from oauth_access_tokens.user_id - Bearer: tokens.owner_user_id (NULL for super-admin-issued)3. Parse JSON-RPC frame (initialize, tools/list, tools/call, ...)4. tools/list: a. Built-in 32 tools (some hidden when env secrets missing) b. For each in-scope site where mcp_backend='wp-mcp-adapter': - Fetch tools from each MCP endpoint (cached 60s) - Filter destructive proxy tools via proxy-guardrails.ts - Inject required `site_id` field into each tool's input schema c. Filter by token's allowed_tools5. tools/call: a. Look up tool definition (built-in or proxy) b. Resolve site_id from args; check user_sites + token's allowed_sites c. Resolve credentials (D1 encrypted blob + env secrets) d. Run adapter; catch AppError / Forbidden / Upstream e. Write audit_log row with status ok | denied | error6. waitUntil: bump tokens.last_used_at and sites.last_used_atAdmin request (/admin/*)
Section titled “Admin request (/admin/*)”1. GET /admin/login → SSR HTML form (master token OR username/password tabs)2. POST /admin/login: - Master tab: timingSafeEqual(body.token, env.MASTER_TOKEN) - User tab : verify username + PBKDF2 hash from users table - On success → set admin_session = HMAC(MASTER_TOKEN, "session:" + user_id + expiry)3. Any other route: verify cookie HMAC → redirect to /admin/login on fail4. Render context distinguishes "super-admin" (master) vs "user" - Super-admin: every page, every API - User: only own sites, own tokens, own audit rows5. /admin/api/* responds JSON; the rest is HTML+HTMX+Tailwind CDNOAuth (/oauth/*)
Section titled “OAuth (/oauth/*)”1. /oauth/register (DCR) → mint client_id (+ optional client_secret), insert row2. /oauth/authorize → SSR login form; on POST verify user, write oauth_codes3. /oauth/token → exchange code (PKCE verify) or refresh; emit oat_*4. /oauth/revoke → set revoked_at on both access + refresh row5. /.well-known/oauth-protected-resource and /oauth-authorization-server → static JSON metadata so MCP clients can autodiscoverSecrets
Section titled “Secrets”Worker secrets are set with wrangler secret put and never appear in source
or in D1.
| Key | Purpose | Required |
|---|---|---|
MASTER_TOKEN | Login to /admin. Also doubles as the HMAC key for admin_session cookies. | yes |
DUDA_API_USER / DUDA_API_PASS | Duda REST API credentials. One pair per worker (shared across all Duda sites). | only if you use Duda |
GITHUB_APP_ID | GitHub App numeric ID. | only for markdown sync + Astro tools |
GITHUB_APP_SLUG | App URL slug, e.g. seo-navigator-mcp. | only for markdown sync + Astro tools |
GITHUB_APP_CLIENT_ID / GITHUB_APP_CLIENT_SECRET | OAuth credentials for the App itself (used by /admin/github install dance). | only for markdown sync + Astro tools |
GITHUB_APP_PRIVATE_KEY | PEM string used to sign JWTs that mint short-lived installation tokens. | only for markdown sync + Astro tools |
CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN | Lets check_astro_deploy query the Pages deployments API. The tool is hidden from tools/list if these are not set. | only for Astro build verification |
WP_APP_PASS__<site_id> | Legacy fallback for the WordPress App Password. Normally not needed — the admin site form stores app passwords AES-GCM-encrypted in D1. | no |
Adapter contract
Section titled “Adapter contract”Every site platform implements a common interface — kept loose because Duda and WordPress disagree on what “schedule” means.
// src/adapters/types.ts (simplified)export interface PlatformAdapter { ping(creds): Promise<PingResult>; createDraft(creds, input: DraftInput): Promise<PostRef>; updateDraft(creds, postId, patch): Promise<PostRef>; scheduleDraft(creds, postId, when: Date): Promise<PostRef>; unschedule(creds, postId): Promise<PostRef>; getPost(creds, postId): Promise<Post>; listDrafts(creds, opts): Promise<Post[]>; uploadMedia(creds, input): Promise<MediaRef>; listTaxonomies?(creds, type: TaxType): Promise<Taxonomy[]>; createTaxonomy?(creds, type, name): Promise<Taxonomy>;}The proxy adapter (wp-mcp-proxy) doesn’t implement this interface — it
forwards JSON-RPC frames verbatim to the remote WP-MCP server and applies the
shared guardrail before returning.
The Astro / GitHub flow doesn’t use a PlatformAdapter either; those tools
call adapters/github.ts directly because there’s no concept of “draft” or
“schedule” — every commit is a publish to a preview branch, and Cloudflare
Pages decides whether to deploy it.
Hard rule: no publish-now, no delete
Section titled “Hard rule: no publish-now, no delete”Three layers of defense:
- Tools that don’t exist. There is no
delete_post, nopublish_post. The MCP tools surface refuses to call something it doesn’t expose. - Adapter-level checks.
wordpress.tsrejects any payload withstatus: "publish".duda.tsrejectsPOST /publishwithout a futureschedule_publish_date. Scheduled time must be >= 60 seconds in the future to block “schedule now” trickery. - Proxy guardrails.
src/mcp/proxy-guardrails.tsfilters proxy tool names matching(delete|remove|trash|destroy)on a page/post/template/content/media, plus anypublish-*(exceptunpublish-*). Filtering happens in BOTHtools/listandtools/call, so even a stale tool catalog can’t bypass.
The single documented exception is delete_astro_route — see
src/mcp/tools.ts around line 1716. Astro .astro files are code in a git
repo, not “content” in the CMS sense; git history preserves them, and the
tool is gated to super-admin tokens at runtime
(tokenOwnerUserId === null).
Key files when you’re poking around
Section titled “Key files when you’re poking around”| File | Why look at it |
|---|---|
src/index.ts | Top-level router; trace any request from here |
src/mcp/handler.ts | Streamable HTTP MCP server; auth, dispatch, audit |
src/mcp/tools.ts | Definitions for all 32 built-in tools — single source of truth |
src/mcp/proxy-guardrails.ts | Regex that decides which proxy tools are forbidden |
src/mcp/dynamic-tools.ts | Discovers + caches proxy tools from wp-mcp-adapter |
src/auth/mcp-auth.ts | OAuth + bearer parser; scope check helpers |
src/auth/oauth/* | DCR, authorize, token, revoke endpoints |
src/admin/router.ts | HTML SSR for the dashboard |
src/admin/api/* | JSON endpoints called by HTMX from the dashboard |
src/adapters/page-code.ts | The CSS auto-scope rewriter for code-first pages |
src/adapters/page-elementor.ts | Template duplicate + placeholder replacement |
src/utils/astro.ts | Route validation + HTML→Astro wrapper |
src/utils/errors.ts | AppError, ScopeDeniedError, UpstreamError |
wp-plugins/seo-navigator-code-page/ | WordPress plugin needed for code-first pages |
Cost & limits
Section titled “Cost & limits”The worker fits comfortably in the Cloudflare free tier for solo and small- agency usage. Bottlenecks worth knowing about:
- D1 row reads: every MCP call does 1–3 reads (token lookup + site + maybe user_sites). 100k reads/day = roughly 30k tool calls.
- Subrequest budget: each
tools/callmakes at least one outbound fetch to WP / Duda / GitHub. Free plan allows 50 subrequests per worker request, so deploys that fan out to many APIs (e.g.deploy_md_to_sitewith tag creation) can approach the limit. - D1 write throughput:
audit_logis the chattiest table. If you run a bulk scripted workload, prune it periodically from/admin/audit.
For a self-host walkthrough, see Deploy to Cloudflare.
For TDD conventions before contributing, the docs/testing.md file in the
code repo is the source of truth.