From 59489936b4902b90725689925fe33063ec9dee00 Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Tue, 7 Apr 2026 21:51:02 -0700 Subject: [PATCH 1/7] fix(core): return OAuth discovery 401 for MCP POSTs --- packages/core/src/astro/middleware/auth.ts | 45 ++++- .../unit/auth/mcp-discovery-post.test.ts | 159 ++++++++++++++++++ 2 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 packages/core/tests/unit/auth/mcp-discovery-post.test.ts diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index e9fe0303e..30ee66c9d 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -201,13 +201,44 @@ export const onRequest = defineMiddleware(async (context, next) => { ) { const csrfHeader = context.request.headers.get("X-EmDash-Request"); if (csrfHeader !== "1") { - return new Response( - JSON.stringify({ error: { code: "CSRF_REJECTED", message: "Missing required header" } }), - { - status: 403, - headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS }, - }, - ); + if (url.pathname === "/_emdash/api/mcp") { + try { + const sessionUser = await context.session?.get("user"); + // Allow tokenless MCP discovery POSTs to reach the normal 401 path. + // Keep CSRF protection for cookie-backed session requests. + if (!sessionUser?.id) { + // Fall through to passkey/external auth below. + } else { + return new Response( + JSON.stringify({ + error: { code: "CSRF_REJECTED", message: "Missing required header" }, + }), + { + status: 403, + headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS }, + }, + ); + } + } catch { + return new Response( + JSON.stringify({ + error: { code: "CSRF_REJECTED", message: "Missing required header" }, + }), + { + status: 403, + headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS }, + }, + ); + } + } else { + return new Response( + JSON.stringify({ error: { code: "CSRF_REJECTED", message: "Missing required header" } }), + { + status: 403, + headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS }, + }, + ); + } } } diff --git a/packages/core/tests/unit/auth/mcp-discovery-post.test.ts b/packages/core/tests/unit/auth/mcp-discovery-post.test.ts new file mode 100644 index 000000000..acd65cb5f --- /dev/null +++ b/packages/core/tests/unit/auth/mcp-discovery-post.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("virtual:emdash/auth", () => ({ authenticate: vi.fn() })); +vi.mock("astro:middleware", () => ({ + defineMiddleware: (handler: unknown) => handler, +})); +vi.mock("@emdash-cms/auth", () => ({ + TOKEN_PREFIXES: {}, + generatePrefixedToken: vi.fn(), + hashPrefixedToken: vi.fn(), + VALID_SCOPES: [], + validateScopes: vi.fn(), + hasScope: vi.fn(() => false), + computeS256Challenge: vi.fn(), + Role: { ADMIN: 50 }, +})); +vi.mock("@emdash-cms/auth/adapters/kysely", () => ({ + createKyselyAdapter: vi.fn(() => ({ + getUserById: vi.fn(async (id: string) => ({ + id, + email: "admin@test.com", + name: "Admin", + role: 50, + disabled: 0, + })), + getUserByEmail: vi.fn(), + })), +})); + +type AuthMiddlewareModule = typeof import("../../../src/astro/middleware/auth.js"); + +let onRequest: AuthMiddlewareModule["onRequest"]; + +beforeAll(async () => { + ({ onRequest } = await import("../../../src/astro/middleware/auth.js")); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +async function runAuthMiddleware(opts: { + pathname: string; + method?: string; + headers?: HeadersInit; + sessionUserId?: string | null; +}) { + const url = new URL(opts.pathname, "https://example.com"); + const session = { + get: vi.fn().mockResolvedValue( + opts.sessionUserId ? { id: opts.sessionUserId } : null, + ), + set: vi.fn(), + destroy: vi.fn(), + }; + const next = vi.fn(async () => new Response("ok")); + const response = await onRequest( + { + url, + request: new Request(url, { + method: opts.method ?? "POST", + headers: opts.headers, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "debug", version: "1.0" }, + }, + }), + }), + locals: { + emdash: { + db: {}, + config: {}, + }, + }, + session, + redirect: (location: string) => + new Response(null, { + status: 302, + headers: { Location: location }, + }), + } as Parameters[0], + next, + ); + + return { response, next, session }; +} + +describe("MCP discovery auth middleware", () => { + it("returns 401 with discovery metadata for unauthenticated MCP POST requests", async () => { + const { response, next } = await runAuthMiddleware({ + pathname: "/_emdash/api/mcp", + headers: { "Content-Type": "application/json" }, + }); + + expect(next).not.toHaveBeenCalled(); + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + 'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"', + ); + await expect(response.json()).resolves.toEqual({ + error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" }, + }); + }); + + it("returns 401 with discovery metadata for invalid bearer tokens on MCP POST", async () => { + const { response, next } = await runAuthMiddleware({ + pathname: "/_emdash/api/mcp", + headers: { + Authorization: "Bearer invalid", + "Content-Type": "application/json", + }, + }); + + expect(next).not.toHaveBeenCalled(); + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + 'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"', + ); + await expect(response.json()).resolves.toEqual({ + error: { code: "INVALID_TOKEN", message: "Invalid or expired token" }, + }); + }); + + it("still rejects session-backed MCP POST requests without the CSRF header", async () => { + const { response, next } = await runAuthMiddleware({ + pathname: "/_emdash/api/mcp", + headers: { "Content-Type": "application/json" }, + sessionUserId: "user_1", + }); + + expect(next).not.toHaveBeenCalled(); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: { code: "CSRF_REJECTED", message: "Missing required header" }, + }); + }); + + it("still rejects non-MCP API POST requests without the CSRF header", async () => { + const { response, next } = await runAuthMiddleware({ + pathname: "/_emdash/api/content/posts", + headers: { "Content-Type": "application/json" }, + }); + + expect(next).not.toHaveBeenCalled(); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: { code: "CSRF_REJECTED", message: "Missing required header" }, + }); + }); +}); From e3eb9cabc811fcf3917c0766b98448c903f7ac16 Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Tue, 7 Apr 2026 21:54:52 -0700 Subject: [PATCH 2/7] chore: add changeset for MCP OAuth discovery fix --- .changeset/fresh-mice-battle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fresh-mice-battle.md diff --git a/.changeset/fresh-mice-battle.md b/.changeset/fresh-mice-battle.md new file mode 100644 index 000000000..b405a473f --- /dev/null +++ b/.changeset/fresh-mice-battle.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fix MCP OAuth discovery for unauthenticated POST requests. From 116e6a9f577ddbfef32ad0f5fa2543e5161cfd24 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Wed, 8 Apr 2026 04:56:04 +0000 Subject: [PATCH 3/7] style: format --- packages/core/tests/unit/auth/mcp-discovery-post.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/tests/unit/auth/mcp-discovery-post.test.ts b/packages/core/tests/unit/auth/mcp-discovery-post.test.ts index acd65cb5f..31502f7be 100644 --- a/packages/core/tests/unit/auth/mcp-discovery-post.test.ts +++ b/packages/core/tests/unit/auth/mcp-discovery-post.test.ts @@ -51,9 +51,7 @@ async function runAuthMiddleware(opts: { }) { const url = new URL(opts.pathname, "https://example.com"); const session = { - get: vi.fn().mockResolvedValue( - opts.sessionUserId ? { id: opts.sessionUserId } : null, - ), + get: vi.fn().mockResolvedValue(opts.sessionUserId ? { id: opts.sessionUserId } : null), set: vi.fn(), destroy: vi.fn(), }; From 7f57fc7c41148ceaf984d7ae4e51a94a2be09d68 Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Thu, 9 Apr 2026 08:27:20 -0700 Subject: [PATCH 4/7] fix(core): centralize MCP discovery auth handling --- packages/core/src/astro/middleware/auth.ts | 97 +++++++++---------- .../unit/auth/mcp-discovery-post.test.ts | 12 +++ 2 files changed, 59 insertions(+), 50 deletions(-) diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index 30ee66c9d..151c09066 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -51,6 +51,35 @@ declare global { // Role level constants (matching @emdash-cms/auth) const ROLE_ADMIN = 50; +const MCP_ENDPOINT_PATH = "/_emdash/api/mcp"; + +function isUnsafeMethod(method: string): boolean { + return method !== "GET" && method !== "HEAD" && method !== "OPTIONS"; +} + +function csrfRejectedResponse(): Response { + return new Response( + JSON.stringify({ error: { code: "CSRF_REJECTED", message: "Missing required header" } }), + { + status: 403, + headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS }, + }, + ); +} + +function mcpUnauthorizedResponse(url: URL): Response { + return Response.json( + { error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" } }, + { + status: 401, + headers: { + "WWW-Authenticate": + `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`, + ...MW_CACHE_HEADERS, + }, + }, + ); +} /** * API routes that skip auth — each handles its own access control. @@ -190,55 +219,18 @@ export const onRequest = defineMiddleware(async (context, next) => { // include custom headers. The consent flow is protected by session + single-use codes. const method = context.request.method.toUpperCase(); const isOAuthConsent = url.pathname.startsWith("/_emdash/oauth/authorize"); + const isMcpEndpoint = url.pathname === MCP_ENDPOINT_PATH; if ( isApiRoute && !isTokenAuth && !isOAuthConsent && - method !== "GET" && - method !== "HEAD" && - method !== "OPTIONS" && + !isMcpEndpoint && + isUnsafeMethod(method) && !isPublicApiRoute ) { const csrfHeader = context.request.headers.get("X-EmDash-Request"); if (csrfHeader !== "1") { - if (url.pathname === "/_emdash/api/mcp") { - try { - const sessionUser = await context.session?.get("user"); - // Allow tokenless MCP discovery POSTs to reach the normal 401 path. - // Keep CSRF protection for cookie-backed session requests. - if (!sessionUser?.id) { - // Fall through to passkey/external auth below. - } else { - return new Response( - JSON.stringify({ - error: { code: "CSRF_REJECTED", message: "Missing required header" }, - }), - { - status: 403, - headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS }, - }, - ); - } - } catch { - return new Response( - JSON.stringify({ - error: { code: "CSRF_REJECTED", message: "Missing required header" }, - }), - { - status: 403, - headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS }, - }, - ); - } - } else { - return new Response( - JSON.stringify({ error: { code: "CSRF_REJECTED", message: "Missing required header" } }), - { - status: 403, - headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS }, - }, - ); - } + return csrfRejectedResponse(); } } @@ -585,8 +577,10 @@ async function handlePasskeyAuth( next: Parameters[0]>[1], isApiRoute: boolean, ): Promise { - const { url, locals, session } = context; + const { url, locals, request, session } = context; const { emdash } = locals; + const isMcpEndpoint = url.pathname === MCP_ENDPOINT_PATH; + const method = request.method.toUpperCase(); try { // Check session for user (session.get returns a Promise) @@ -594,17 +588,13 @@ async function handlePasskeyAuth( if (!sessionUser?.id) { // Not authenticated + if (isMcpEndpoint) { + return mcpUnauthorizedResponse(url); + } if (isApiRoute) { - const headers: Record = { ...MW_CACHE_HEADERS }; - // Add WWW-Authenticate on MCP endpoint 401s to trigger OAuth discovery - if (url.pathname === "/_emdash/api/mcp") { - const origin = getPublicOrigin(url, emdash?.config); - headers["WWW-Authenticate"] = - `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`; - } return Response.json( { error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" } }, - { status: 401, headers }, + { status: 401, headers: MW_CACHE_HEADERS }, ); } const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config)); @@ -612,6 +602,13 @@ async function handlePasskeyAuth( return context.redirect(loginUrl.toString()); } + if (isMcpEndpoint && isUnsafeMethod(method)) { + const csrfHeader = request.headers.get("X-EmDash-Request"); + if (csrfHeader !== "1") { + return csrfRejectedResponse(); + } + } + // Get full user from database const adapter = createKyselyAdapter(emdash!.db); const user = await adapter.getUserById(sessionUser.id); diff --git a/packages/core/tests/unit/auth/mcp-discovery-post.test.ts b/packages/core/tests/unit/auth/mcp-discovery-post.test.ts index 31502f7be..9e798fb51 100644 --- a/packages/core/tests/unit/auth/mcp-discovery-post.test.ts +++ b/packages/core/tests/unit/auth/mcp-discovery-post.test.ts @@ -109,6 +109,18 @@ describe("MCP discovery auth middleware", () => { }); }); + it("only reads the session once for anonymous MCP POST discovery requests", async () => { + const { response, next, session } = await runAuthMiddleware({ + pathname: "/_emdash/api/mcp", + headers: { "Content-Type": "application/json" }, + }); + + expect(next).not.toHaveBeenCalled(); + expect(response.status).toBe(401); + expect(session.get).toHaveBeenCalledTimes(1); + expect(session.get).toHaveBeenCalledWith("user"); + }); + it("returns 401 with discovery metadata for invalid bearer tokens on MCP POST", async () => { const { response, next } = await runAuthMiddleware({ pathname: "/_emdash/api/mcp", From 4fa8c41d41aaf69fadec808ce3d47dc8d8a34ca3 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Thu, 9 Apr 2026 15:27:55 +0000 Subject: [PATCH 5/7] style: format --- packages/core/src/astro/middleware/auth.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index 151c09066..f61e1fde5 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -73,8 +73,7 @@ function mcpUnauthorizedResponse(url: URL): Response { { status: 401, headers: { - "WWW-Authenticate": - `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`, + "WWW-Authenticate": `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`, ...MW_CACHE_HEADERS, }, }, From 0ed9652bb19eebfb42f46af6669372a5a57fcfd1 Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Thu, 9 Apr 2026 09:11:12 -0700 Subject: [PATCH 6/7] fix(core): make MCP auth bearer-only --- packages/core/src/astro/middleware/auth.ts | 27 +++++++------------ .../unit/auth/mcp-discovery-post.test.ts | 19 +++++++------ 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index f61e1fde5..ddc664214 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -211,19 +211,25 @@ export const onRequest = defineMiddleware(async (context, next) => { const isTokenAuth = bearerResult === "authenticated"; + // MCP discovery/tooling is bearer-only. Session/external auth should never + // be consulted for this endpoint, and unauthenticated requests must return + // the OAuth discovery-style 401 response. + const method = context.request.method.toUpperCase(); + const isMcpEndpoint = url.pathname === MCP_ENDPOINT_PATH; + if (isMcpEndpoint && !isTokenAuth) { + return mcpUnauthorizedResponse(url); + } + // CSRF protection: require X-EmDash-Request header on state-changing requests. // Skip for token-authenticated requests (tokens aren't ambient credentials). // Browsers block cross-origin custom headers, so this prevents CSRF without tokens. // OAuth authorize consent is exempt: it's a standard HTML form POST that can't // include custom headers. The consent flow is protected by session + single-use codes. - const method = context.request.method.toUpperCase(); const isOAuthConsent = url.pathname.startsWith("/_emdash/oauth/authorize"); - const isMcpEndpoint = url.pathname === MCP_ENDPOINT_PATH; if ( isApiRoute && !isTokenAuth && !isOAuthConsent && - !isMcpEndpoint && isUnsafeMethod(method) && !isPublicApiRoute ) { @@ -576,20 +582,14 @@ async function handlePasskeyAuth( next: Parameters[0]>[1], isApiRoute: boolean, ): Promise { - const { url, locals, request, session } = context; + const { url, locals, session } = context; const { emdash } = locals; - const isMcpEndpoint = url.pathname === MCP_ENDPOINT_PATH; - const method = request.method.toUpperCase(); try { // Check session for user (session.get returns a Promise) const sessionUser = await session?.get("user"); if (!sessionUser?.id) { - // Not authenticated - if (isMcpEndpoint) { - return mcpUnauthorizedResponse(url); - } if (isApiRoute) { return Response.json( { error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" } }, @@ -601,13 +601,6 @@ async function handlePasskeyAuth( return context.redirect(loginUrl.toString()); } - if (isMcpEndpoint && isUnsafeMethod(method)) { - const csrfHeader = request.headers.get("X-EmDash-Request"); - if (csrfHeader !== "1") { - return csrfRejectedResponse(); - } - } - // Get full user from database const adapter = createKyselyAdapter(emdash!.db); const user = await adapter.getUserById(sessionUser.id); diff --git a/packages/core/tests/unit/auth/mcp-discovery-post.test.ts b/packages/core/tests/unit/auth/mcp-discovery-post.test.ts index 9e798fb51..5cac8a895 100644 --- a/packages/core/tests/unit/auth/mcp-discovery-post.test.ts +++ b/packages/core/tests/unit/auth/mcp-discovery-post.test.ts @@ -109,7 +109,7 @@ describe("MCP discovery auth middleware", () => { }); }); - it("only reads the session once for anonymous MCP POST discovery requests", async () => { + it("does not read the session for anonymous MCP POST discovery requests", async () => { const { response, next, session } = await runAuthMiddleware({ pathname: "/_emdash/api/mcp", headers: { "Content-Type": "application/json" }, @@ -117,8 +117,7 @@ describe("MCP discovery auth middleware", () => { expect(next).not.toHaveBeenCalled(); expect(response.status).toBe(401); - expect(session.get).toHaveBeenCalledTimes(1); - expect(session.get).toHaveBeenCalledWith("user"); + expect(session.get).not.toHaveBeenCalled(); }); it("returns 401 with discovery metadata for invalid bearer tokens on MCP POST", async () => { @@ -140,17 +139,21 @@ describe("MCP discovery auth middleware", () => { }); }); - it("still rejects session-backed MCP POST requests without the CSRF header", async () => { - const { response, next } = await runAuthMiddleware({ + it("rejects MCP POST requests that only have session auth", async () => { + const { response, next, session } = await runAuthMiddleware({ pathname: "/_emdash/api/mcp", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "X-EmDash-Request": "1", + }, sessionUserId: "user_1", }); expect(next).not.toHaveBeenCalled(); - expect(response.status).toBe(403); + expect(response.status).toBe(401); + expect(session.get).not.toHaveBeenCalled(); await expect(response.json()).resolves.toEqual({ - error: { code: "CSRF_REJECTED", message: "Missing required header" }, + error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" }, }); }); From 5f83a8ddded2eaa12a9fcbd44bf86f414e0df7c8 Mon Sep 17 00:00:00 2001 From: Pejman Pour-Moezzi Date: Sat, 11 Apr 2026 13:03:06 -0700 Subject: [PATCH 7/7] fix(core): use public origin for MCP discovery 401 --- packages/core/src/astro/middleware/auth.ts | 10 +++++++--- .../tests/unit/auth/mcp-discovery-post.test.ts | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index ddc664214..4b161ae5a 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -67,13 +67,17 @@ function csrfRejectedResponse(): Response { ); } -function mcpUnauthorizedResponse(url: URL): Response { +function mcpUnauthorizedResponse( + url: URL, + config?: Parameters[1], +): Response { + const origin = getPublicOrigin(url, config); return Response.json( { error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" } }, { status: 401, headers: { - "WWW-Authenticate": `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`, + "WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`, ...MW_CACHE_HEADERS, }, }, @@ -217,7 +221,7 @@ export const onRequest = defineMiddleware(async (context, next) => { const method = context.request.method.toUpperCase(); const isMcpEndpoint = url.pathname === MCP_ENDPOINT_PATH; if (isMcpEndpoint && !isTokenAuth) { - return mcpUnauthorizedResponse(url); + return mcpUnauthorizedResponse(url, context.locals.emdash?.config); } // CSRF protection: require X-EmDash-Request header on state-changing requests. diff --git a/packages/core/tests/unit/auth/mcp-discovery-post.test.ts b/packages/core/tests/unit/auth/mcp-discovery-post.test.ts index 5cac8a895..5cb800214 100644 --- a/packages/core/tests/unit/auth/mcp-discovery-post.test.ts +++ b/packages/core/tests/unit/auth/mcp-discovery-post.test.ts @@ -48,6 +48,7 @@ async function runAuthMiddleware(opts: { method?: string; headers?: HeadersInit; sessionUserId?: string | null; + siteUrl?: string; }) { const url = new URL(opts.pathname, "https://example.com"); const session = { @@ -76,7 +77,7 @@ async function runAuthMiddleware(opts: { locals: { emdash: { db: {}, - config: {}, + config: opts.siteUrl ? { siteUrl: opts.siteUrl } : {}, }, }, session, @@ -120,6 +121,20 @@ describe("MCP discovery auth middleware", () => { expect(session.get).not.toHaveBeenCalled(); }); + it("uses the configured public origin for anonymous MCP POST discovery responses", async () => { + const { response, next } = await runAuthMiddleware({ + pathname: "/_emdash/api/mcp", + headers: { "Content-Type": "application/json" }, + siteUrl: "https://public.example.com", + }); + + expect(next).not.toHaveBeenCalled(); + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + 'Bearer resource_metadata="https://public.example.com/.well-known/oauth-protected-resource"', + ); + }); + it("returns 401 with discovery metadata for invalid bearer tokens on MCP POST", async () => { const { response, next } = await runAuthMiddleware({ pathname: "/_emdash/api/mcp",