MCP connector internals
apps/mcp is the remote MCP connector behind mcp.drawn.guide — a thin protocol + auth adapter
over the same multi-tenant cores the admin app and client sites use. This page covers how it’s
built and configured; for what it does from a user’s chair, see
Using the MCP connector.
Service shape
Section titled “Service shape”A plain Node service on Netlify functions — deliberately not an Astro app:
- It renders no HTML (aside from the OAuth consent page), so Astro/Vite buys nothing.
- Importing
@drawnagency/*from builtdist/only (no@/source aliases) sidesteps the registry-duplication hazard that the client-site Astro integration needsresolve.aliasmachinery for. One module copy, one registry;ensureSchemasRegistered()+ theSymbol.fornet cover the rest. create_siteneeds a background function, which is first-class in a plain functions app and awkward inside Astro’s single SSR function.
Two functions are emitted, both self-contained single files:
| Function | Entry | Serves |
|---|---|---|
mcp | src/entry.ts → src/router.ts | /mcp (the MCP resource), the OAuth endpoints (/authorize, /token, /register, /revoke, /oauth/callback), the /.well-known/* discovery documents, /healthz |
provision-background | src/provision-background.ts | The async provisioning worker, invoked at /.netlify/functions/provision-background |
The MCP transport is stateless: every POST /mcp builds a fresh server + transport (no
sessionId map, enableJsonResponse, DNS-rebinding protection pinned to the deploy host) and
closes it in finally. All persistent state lives in Supabase.
Build pipeline
Section titled “Build pipeline”pnpm --filter portal-mcp build runs apps/mcp/build.mjs (esbuild), which emits the two bundles
under .netlify/v1/functions/ with inline Frameworks-API config — there is no netlify.toml.
Constraints worth knowing before touching it:
- Functions deploy with
nodeBundler: "none"— Netlify ships exactly the one emitted file per function. Anything read at runtime must be inlined at build time; a runtimefs.readFileof a package asset willENOENTin production. - That’s why the narrative authoring guidance
(
packages/authoring/skills/populate-site/guidance/authoring-core.md— shared with the/populate-siteskill) is injected as the esbuilddefineconstant__AUTHORING_GUIDANCE__, consumed by BOTH thepopulate_siteprompt and theget_authoring_guidetool. Every connector deploy re-syncs the current guidance; edit the shared asset, not either consumer. - The server also declares MCP
instructions(apps/mcp/src/mcp/instructions.ts) — a compact editing-contract summary returned in the initialize handshake and injected into every session’s context by clients like claude.ai. Contract mechanics live there; the authoring narrative stays in the shared guidance asset. - A
js-to-tsesbuild plugin rewrites internal.jsspecifiers to.ts, and a banner shims CJSrequirefor deps that need it.
CI (.github/workflows/ci.yml) builds the bundle and smoke-loads it on every push.
OAuth authorization server
Section titled “OAuth authorization server”The service is its own OAuth 2.1 authorization server (src/oauth/), delegating identity
to the existing Supabase/Google login and authorization to the platform tables. The flow:
- DCR (
POST /register): client registration is open but the redirect URI must match a hard-coded allow-list (src/oauth/redirect-allowlist.ts):https://claude.ai/api/mcp/auth_callbackor loopbackhttp://localhost|127.0.0.1:<port>/callback(Claude Code). This is the main defense against the classic remote-MCP attack (attacker-registered redirect receiving a legitimate user’s code). GET /authorize: requires PKCE S256 and an exact-match registeredredirect_uri. The request’s parameters travel in a short-lived signed txn JWT — no server session — and the user is bounced to Supabase’s Google sign-in, returning to/oauth/callback.GET /oauth/callback: exchanges the Supabase code, then runscheckPlatformAccess(@drawnagency/platform— the identical gate the admin middleware uses:platform_usersrow, orallowed_signupsmatch that auto-provisions amember). Denied → no code is ever minted. Allowed → a consent interstitial (CSRF-bound via an HttpOnly SameSite=Strict cookie against a nonce in a signed consent JWT) before the authorization code exists at all.POST /token: authorization codes are single-use, sub-60-second, and bound to client + exact redirect + PKCE challenge + user + resource. Refresh tokens rotate one-time-use with family revocation on reuse; access tokens live 1 hour (ACCESS_TOKEN_TTL_SECONDS, single-sourced with theexpires_inthe token endpoint advertises). The TTL is not the authorization boundary — role and account existence are re-resolved fromplatform_userson every request, so a revoked user is locked out on their next call regardless of it.POST /revoke(RFC 7009) revokes the whole refresh family.
Access tokens are ES256 JWTs carrying identity only (sub = platform_users.id, the auth
userId, email, clientId) — never a role or authorization decision. iss is the deploy
origin, aud is {origin}/mcp and must byte-match the protected-resource metadata. Verification
pins alg: ["ES256"]. Keys come from env (see Configuration); the public JWK is served at
/.well-known/jwks.json.
All OAuth state is in Supabase (oauth_clients, oauth_auth_codes, oauth_refresh_tokens,
oauth_rate_limits — RLS enabled with no policies, so service-role only), because Netlify
function instances share no memory. Expired rows are purged by the oauth_cleanup /
rate-limit-cleanup RPCs scheduled via pg_cron.
Per-request authorization
Section titled “Per-request authorization”The JWT is deliberately weak evidence. On every request:
verifyBearer(src/mcp/bearer.ts) re-resolves theplatform_usersrow by the token’ssub. Deleting the row revokes every outstanding token effectively immediately; role comes from this read, never the JWT.- Site-scoped tools call
assertSiteAccess(src/mcp/auth-context.ts): admin reaches any site, a member needs aninstallation_membersrow for the site’s installation. The site’sowner/repo/installation_idare read from thesitesrow — never from caller input. assertCanProvisiongatescreate_site(admin, or member withplatform_users.can_provision, re-read fail-closed);assertSiteAdmingatesdelete_site(admin only).- Missing site and no-access produce the identical error, so site-scoped tools can’t be used as a cross-tenant existence oracle.
Rejections are classified, and infrastructure failures are not rejections. verifyBearer maps
each jose failure to a reason (expired, signature, unknown_key, claims, alg, malformed)
plus revoked for a missing row, logs one greppable [mcp] auth reject {…} line carrying sub and
clientId (never token material, never email), and returns a message naming the specific case — only
expired is recoverable by the client on its own. A failed platform_users read, by contrast, says
nothing about the token: it raises AuthUnavailableError and the transport answers 503 +
Retry-After, never a 401 (which would make the client discard a valid token) and no longer a bare
500 (which reads as a broken server). A burst of those opaque 500s is what preceded the
“connection was invalidated” failures on 2026-07-21 and 2026-07-23.
Tool errors use a fixed taxonomy ([validation] | [authz] | [not_found] | [input] | [conflict] | [locked] | [unavailable]) with no raw provider bodies. [unavailable] is the only kind documented
as retryable — nothing was written and the request itself was fine — and it covers both a transient
backend failure (GitHub 5xx, a dropped socket) and a rate-limit rejection. *_SECRET/*_KEY/token
values are redacted from errors and logs,
and per-site secrets set during provisioning are write-only (set into provider env, never
readable back through any tool).
The multi-tenant storage seam
Section titled “The multi-tenant storage seam”Unlike a client site (whose GitHub binding is fixed env constants), the connector selects the target repo per call:
siteId ──► resolveSiteSource (src/lib/site-resolver.ts) │ sites ⋈ github_installations (service-role read) ▼ getInstallationToken(installationId, { repositoryNames: [repo] }) ← repo-scoped, short-lived ▼ createGithubStorage({ owner, repo, octokit }) ← @drawnagency/github factory ▼ @drawnagency/core/content-ops: applyContentWrite / publishContent / validateContentWrites land on the saved draft branch under the same baseVersion optimistic-concurrency
contract as /api/save (StorageConflictError → [conflict]); publish_site calls
promoteDraft(), which overlays the content subtrees onto main and deletes saved — the push
to main is what triggers the client site’s own Netlify rebuild. The connector never calls
Netlify’s deploy API for publishes. upload_media runs the same sharp pipeline as the
authoring CLI (processImageBuffers from @drawnagency/authoring) server-side, writing WebP
variants + image-manifest.json to saved.
Background provisioning
Section titled “Background provisioning”create_site returns fast and provisions asynchronously:
- Authz (
assertCanProvision+assertProvisionAllowed’s server-side installation re-resolution) → input validation → per-user rate limit. - Pre-insert a
sitesrow withprovisioning_status: "pending"and return itssiteId. - Fire an HMAC-SHA256-signed POST (
PROVISION_INVOKE_SECRET,x-provision-signature, timing-safe verify, ≥32-char key enforced fail-closed at both ends) to theprovision-backgroundfunction. Anything but HTTP 202 marks the rowfailedwithfailed_step: "enqueue". - The worker claims the row atomically via the
provision_claimRPC (pending→in_progress, with a staleness guard against replays/duplicate deliveries) and runsprovisionSitefrom@drawnagency/platform— the same orchestration the admin form uses. Any failed step persistsprovisioning_status: "failed"+failed_stepon the row, which is whatget_sitesurfaces for polling.
ADMIN_ORIGIN matters here: the provisioner pins each new site’s PLATFORM_API_URL to the
admin app’s origin (where the token broker actually lives), not to the origin of whatever app
ran the provisioning.
Rate limiting and audit
Section titled “Rate limiting and audit”All tool and OAuth rate limits go through the shared oauth_rate_limit_hit RPC and are
fail-closed (a limiter error denies the call). Tool windows are hourly per user:
create_site 10, delete_site 5, save_sections 120, upload_media 60, upload_document 30,
publish_site 20, update_media 60, delete_media 30. validate_site has none (read-only).
A rejection is reported as [unavailable] with the wait in seconds.
OAuth endpoints have per-minute limits. Mutating tools (plus validate_site) write best-effort
rows to audit_log.
Configuration & deployment
Section titled “Configuration & deployment”Deployed as its own Netlify site (mcp.drawn.guide), built with pnpm --filter portal-mcp build
(which runs build:packages first, so dist/ imports resolve).
Environment. Six MCP-specific vars (MCP_PUBLIC_URL, MCP_JWT_KID,
MCP_JWT_PRIVATE_KEY_B64, MCP_JWT_PUBLIC_JWK, ADMIN_ORIGIN, PROVISION_INVOKE_SECRET) are
documented in the environment variable reference;
assertRequiredEnv() checks them at cold start and on GET /healthz (503 with the missing
names), along with four platform-side vars every request depends on — SUPABASE_URL,
SUPABASE_SERVICE_ROLE_KEY, GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY. Those four are validated
because getAdminClient() (inside verifyBearer) and getInstallationToken() (inside the site
resolver) throw without them, so omitting them from the required set reported a green /healthz
on a deploy that answered every request with an opaque 500. The deployment additionally needs the
rest of what @drawnagency/platform reads — SUPABASE_ANON_KEY, GITHUB_APP_WEBHOOK_SECRET,
NETLIFY_API_TOKEN/NETLIFY_TEAM_SLUG, CLOUDFLARE_API_TOKEN/CLOUDFLARE_ZONE_ID, and
TEMPLATE_OWNER/TEMPLATE_REPO — which stay unvalidated because they gate provisioning only.
Never set DEV_DRY_RUN in production — it turns provisioning
into a no-op. Generate the JWT keypair + kid with apps/mcp/scripts/gen-keys.mjs.
Document storage (optional). Four further vars — R2_ACCOUNT_ID, R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY, R2_DOCS_BUCKET — turn on upload_document’s bucket tier: the connector
presigns the PUT itself (r2DocsConfigFromEnv() + presignPut from @drawnagency/platform) and
stores the object at sites/{siteId}/documents/{documentId}/original.{pdf|html} in the platform’s
private documents bucket, the same one the admin app brokers for client sites. They are deliberately
not in assertRequiredEnv()’s required set: absent, the connector runs normally as a git-tier-only
uploader — documents up to 2.5 MB still commit into the repository, and anything larger is refused
with a typed [input] message pointing at the site’s /edit media library rather than failing
opaquely. The bucket tier has a second precondition the env can’t satisfy: the target site’s own
portal.config.mjs must configure documents:, which the connector checks by reading that file’s
text off main (a heuristic — a false negative refuses safely), because a site without a documents
store answers 502 for every bucket-tier file it is handed.
Supabase. Three migrations back the service: *_oauth.sql (the oauth_* tables + RPCs),
*_mcp_lifecycle.sql (sites.failed_step, unique subdomain, platform_users.can_provision,
audit_log), and *_provision_claim_rpc.sql. The dashboard redirect allow-list (production
source of truth, not config.toml) must include https://mcp.drawn.guide/oauth/callback — an
unlisted redirect silently falls back to the Site URL and connector logins bounce to the wrong
origin, the same failure mode as the admin callback.
Security posture
Section titled “Security posture”This service concentrates the platform’s most sensitive credentials (Supabase service-role key,
GitHub App private key, Netlify/Cloudflare tokens) behind an internet-facing OAuth surface — the
same secret classes apps/admin already holds, deliberately co-located rather than brokered.
The compensating controls are the ones described above, and they are load-bearing; keep all of
them when changing this code:
- strict DCR/redirect allow-listing, PKCE S256, consent interstitial, short identity-only tokens, rotation with reuse detection;
- per-request re-resolution of role and membership (revocation takes effect on the next call);
- GitHub tokens minted repo-scoped per call, never org-wide;
- enumeration-safe errors, fixed error taxonomy, secret redaction, write-only per-site secrets;
- fail-closed rate limiting on everything that creates or destroys real resources.