Skip to content

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.

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 built dist/ only (no @/ source aliases) sidesteps the registry-duplication hazard that the client-site Astro integration needs resolve.alias machinery for. One module copy, one registry; ensureSchemasRegistered() + the Symbol.for net cover the rest.
  • create_site needs 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:

FunctionEntryServes
mcpsrc/entry.tssrc/router.ts/mcp (the MCP resource), the OAuth endpoints (/authorize, /token, /register, /revoke, /oauth/callback), the /.well-known/* discovery documents, /healthz
provision-backgroundsrc/provision-background.tsThe 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.

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 runtime fs.readFile of a package asset will ENOENT in production.
  • That’s why the narrative authoring guidance (packages/authoring/skills/populate-site/guidance/authoring-core.md — shared with the /populate-site skill) is injected as the esbuild define constant __AUTHORING_GUIDANCE__, consumed by BOTH the populate_site prompt and the get_authoring_guide tool. 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-ts esbuild plugin rewrites internal .js specifiers to .ts, and a banner shims CJS require for deps that need it.

CI (.github/workflows/ci.yml) builds the bundle and smoke-loads it on every push.

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:

  1. 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_callback or loopback http://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).
  2. GET /authorize: requires PKCE S256 and an exact-match registered redirect_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.
  3. GET /oauth/callback: exchanges the Supabase code, then runs checkPlatformAccess (@drawnagency/platform — the identical gate the admin middleware uses: platform_users row, or allowed_signups match that auto-provisions a member). 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.
  4. 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 the expires_in the token endpoint advertises). The TTL is not the authorization boundary — role and account existence are re-resolved from platform_users on every request, so a revoked user is locked out on their next call regardless of it.
  5. 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.

The JWT is deliberately weak evidence. On every request:

  • verifyBearer (src/mcp/bearer.ts) re-resolves the platform_users row by the token’s sub. 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 an installation_members row for the site’s installation. The site’s owner/repo/installation_id are read from the sites row — never from caller input.
  • assertCanProvision gates create_site (admin, or member with platform_users.can_provision, re-read fail-closed); assertSiteAdmin gates delete_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).

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 / validateContent

Writes 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.

create_site returns fast and provisions asynchronously:

  1. Authz (assertCanProvision + assertProvisionAllowed’s server-side installation re-resolution) → input validation → per-user rate limit.
  2. Pre-insert a sites row with provisioning_status: "pending" and return its siteId.
  3. 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 the provision-background function. Anything but HTTP 202 marks the row failed with failed_step: "enqueue".
  4. The worker claims the row atomically via the provision_claim RPC (pendingin_progress, with a staleness guard against replays/duplicate deliveries) and runs provisionSite from @drawnagency/platform — the same orchestration the admin form uses. Any failed step persists provisioning_status: "failed" + failed_step on the row, which is what get_site surfaces 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.

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.

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.

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.