Skip to content

siteUrl does not reach /.well-known/oauth-protected-resource — MCP discovery advertises http:// on Cloudflare #2016

Description

@marcin-misiewicz-pl

Description

siteUrl cannot reach /.well-known/oauth-protected-resource, so on Cloudflare the MCP discovery document advertises http:// and MCP clients refuse to attach.

The configuration docs say siteUrl fixes "passkeys, CSRF origin matching, OAuth redirects, login redirects, MCP discovery, snapshot exports, sitemap, robots.txt, and JSON-LD". It fixes all of those except MCP discovery, which is the one that sends users looking for the option in the first place.

Root cause

getPublicOrigin() resolves config.siteUrlEMDASH_SITE_URL env → url.origin (src/api/public-url.ts), and the route reads the config off locals:

// src/astro/routes/api/well-known/oauth-protected-resource.ts
const origin = getPublicOrigin(url, locals.emdash?.config);

But the middleware only attaches config to locals.emdash on the full-runtime path. Anonymous requests to routes that are neither under /_emdash nor in PUBLIC_RUNTIME_ROUTES take a fast path whose locals.emdash carries handlers but no config:

// src/astro/middleware.ts:429
const PUBLIC_RUNTIME_ROUTES = new Set(["/sitemap.xml", "/robots.txt"]);

// src/astro/middleware.ts:515
if (!isEmDashRoute && !isPublicRuntimeRoute && !hasEditCookie && !hasPreviewToken) {
  if (!sessionUser && !playgroundDb) {
    // ~:570 — locals.emdash = { handlePublicPluginApiRoute, collectPageMetadata,
    //                           collectPageFragments, getPublicMediaUrl, storage }
    //         no `config`

/.well-known/oauth-protected-resource is root-level and unauthenticated — which is exactly how an MCP client hits it — so locals.emdash?.config is undefined, getPublicOrigin() falls through to url.origin, and behind Cloudflare's proxy that is http://.

The env fallback does not save it either: getEnvSiteUrl() reads process.env, which is empty on Workers unless nodejs_compat_populate_process_env is set. The source comment in public-url.ts ("On Cloudflare Workers process.env is unavailable") points people away from the one mechanism that does work.

Related: security.allowedDomains is set without a protocol

// src/astro/integration/index.ts (~410)
...(resolvedConfig.siteUrl
  ? { allowedDomains: [{ hostname: new URL(resolvedConfig.siteUrl).hostname }] }
  : {}),

The comment above it says this is done "so Astro.url reflects the public origin (helps user template code that reads Astro.url directly)". It does not, because only hostname is passed. Astro's allowedDomains accepts protocol too (Partial<RemotePattern>[] — "Each pattern can specify protocol, hostname, and port"), and without it Astro.url.origin stays http:// on Cloudflare. Any template that builds canonical/OG/hreflang from Astro.url.origin — as the official starter layouts do — ships http:// canonicals to crawlers.

Passing protocol through from siteUrl would likely fix both symptoms at the root, since a correct url.origin also makes the getPublicOrigin() fallback correct.

Steps to reproduce

  1. Deploy an EmDash site to Cloudflare Workers on a proxied custom domain.
  2. Set siteUrl in astro.config.mjs:
    emdash({ siteUrl: "https://cms.example.com" /* … */ });
  3. Build and deploy, then compare a full-runtime route with an anonymous one:
# /sitemap.xml IS in PUBLIC_RUNTIME_ROUTES -> gets `config` -> correct
curl -s https://cms.example.com/sitemap.xml | grep -o '<loc>[^<]*</loc>' | head -1
# <loc>https://cms.example.com/sitemap-work.xml</loc>          ✅ https

# /.well-known/* is NOT -> anonymous fast path -> no `config` -> falls back to url.origin
curl -s https://cms.example.com/.well-known/oauth-protected-resource
# {"resource":"http://cms.example.com/_emdash/api/mcp", …}      ❌ http

Same Worker, same deployment, same request. The only difference is whether the route gets config on locals.

  1. Add the site as a custom connector in Claude → it fails to attach, because the discovery document is not https.

Workaround

Set the origin as an env var instead of (or in addition to) the config option, and turn on process.env population:

// wrangler.jsonc
"compatibility_flags": ["nodejs_compat", "nodejs_compat_populate_process_env"],
"vars": { "EMDASH_SITE_URL": "https://cms.example.com" }

getEnvSiteUrl() reads process.env at module scope, bypassing locals, so it reaches every route. Verified: discovery then returns https://. Both flags are required — with the var but without the compat flag, process.env is empty and the var is silently ignored.

For canonical/OG/hreflang, templates additionally need Astro's own site option, since Astro.url.origin remains http://.

Environment

  • emdash 0.29.0, @emdash-cms/cloudflare 0.29.0
  • Astro 7.0.7, @astrojs/cloudflare 14.1.2
  • Cloudflare Workers (D1 + R2 + KV), proxied custom domain
  • compatibility_date 2026-02-24, compatibility_flags: ["nodejs_compat"]

Logs / error output

Before the workaround, with siteUrl set in config and deployed:

$ curl -s https://cms.example.com/.well-known/oauth-protected-resource
{
  "resource": "http://cms.example.com/_emdash/api/mcp",
  "authorization_servers": ["http://cms.example.com/_emdash"],
  ...
}

Page head from the same deployment (layout builds these from Astro.url.origin):

<link rel="canonical" href="http://cms.example.com/">
<meta property="og:url" content="http://cms.example.com/">
<link rel="alternate" hreflang="en" href="http://cms.example.com/">

After adding EMDASH_SITE_URL + nodejs_compat_populate_process_env (and Astro site for the head):

$ curl -s https://cms.example.com/.well-known/oauth-protected-resource
{
  "resource": "https://cms.example.com/_emdash/api/mcp",
  "authorization_servers": ["https://cms.example.com/_emdash"],
  ...
}

Suggested fixes

  1. Attach config (or at least siteUrl) to locals.emdash on the anonymous fast path too — the .well-known routes are public by design, so they will always land there.
  2. Pass protocol into security.allowedDomains from siteUrl, so Astro.url actually reflects the public origin as the comment intends.
  3. Update the public-url.ts comment and the siteUrl docs: process.env is available on Workers with nodejs_compat_populate_process_env, and it is currently the only mechanism that reaches the anonymous routes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions