From 9258f80ea2c03b708d26df17fecadb1f43464924 Mon Sep 17 00:00:00 2001 From: Sam Fakhreddine Date: Sun, 7 Jun 2026 16:06:54 -0600 Subject: [PATCH 1/4] fix(mcp): default to disabled, matching docs and least privilege The MCP guard checked `resolvedConfig.mcp !== false`, injecting the endpoint on every site that didn't explicitly disable it. Changed to `resolvedConfig.mcp === true` so the endpoint is opt-in only. Closes #1228 --- .changeset/mcp-default-false.md | 5 ++++ packages/core/src/astro/integration/index.ts | 4 +-- .../core/src/astro/integration/runtime.ts | 6 ++--- .../unit/mcp/mcp-default-disabled.test.ts | 25 +++++++++++++++++++ 4 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 .changeset/mcp-default-false.md create mode 100644 packages/core/tests/unit/mcp/mcp-default-disabled.test.ts diff --git a/.changeset/mcp-default-false.md b/.changeset/mcp-default-false.md new file mode 100644 index 000000000..f0a2aaae7 --- /dev/null +++ b/.changeset/mcp-default-false.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes MCP server endpoint being enabled by default when the docs state it is disabled by default. The MCP route at `/_emdash/api/mcp` and its OAuth discovery endpoints are now only injected when `mcp: true` is explicitly set in the integration config, matching the documented behavior and the principle of least privilege. diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index c1aee2607..e60665750 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -327,8 +327,8 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { injectBuiltinAuthRoutes(injectRoute); } - // Inject MCP endpoint (always on — bearer-token-only, no cost if unused) - if (resolvedConfig.mcp !== false) { + // Inject MCP endpoint only when explicitly opted in + if (resolvedConfig.mcp === true) { injectMcpRoute(injectRoute); } diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 61d504a9c..b27ebfdd3 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -261,11 +261,11 @@ export interface EmDashConfig { * that allows AI agents and tools to interact with the CMS using * the standardized MCP protocol. * - * Enabled by default. The endpoint requires bearer token auth, so + * Disabled by default. The endpoint requires bearer token auth, so * it has no effect unless the user creates an API token and - * configures a client. Set to `false` to disable. + * configures a client. Set to `true` to enable. * - * @default true + * @default false */ mcp?: boolean; diff --git a/packages/core/tests/unit/mcp/mcp-default-disabled.test.ts b/packages/core/tests/unit/mcp/mcp-default-disabled.test.ts new file mode 100644 index 000000000..c0d35e418 --- /dev/null +++ b/packages/core/tests/unit/mcp/mcp-default-disabled.test.ts @@ -0,0 +1,25 @@ +/** + * Verifies that MCP route injection is opt-in (mcp: true), + * not opt-out (mcp !== false). + * + * Regression test for https://github.com/emdash-cms/emdash/issues/1228 + */ + +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const integrationSource = readFileSync( + resolve( + import.meta.dirname, + "../../../src/astro/integration/index.ts", + ), + "utf-8", +); + +describe("MCP default", () => { + it("should require explicit opt-in (mcp === true), not opt-out (mcp !== false)", () => { + expect(integrationSource).toContain("resolvedConfig.mcp === true"); + expect(integrationSource).not.toContain("resolvedConfig.mcp !== false"); + }); +}); From 07bf1377213c8ca21f8bcf33c9eb53e6fab96579 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Sun, 7 Jun 2026 22:07:55 +0000 Subject: [PATCH 2/4] style: format --- packages/core/tests/unit/mcp/mcp-default-disabled.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/core/tests/unit/mcp/mcp-default-disabled.test.ts b/packages/core/tests/unit/mcp/mcp-default-disabled.test.ts index c0d35e418..7e4974e4f 100644 --- a/packages/core/tests/unit/mcp/mcp-default-disabled.test.ts +++ b/packages/core/tests/unit/mcp/mcp-default-disabled.test.ts @@ -5,15 +5,13 @@ * Regression test for https://github.com/emdash-cms/emdash/issues/1228 */ -import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { describe, it, expect } from "vitest"; + const integrationSource = readFileSync( - resolve( - import.meta.dirname, - "../../../src/astro/integration/index.ts", - ), + resolve(import.meta.dirname, "../../../src/astro/integration/index.ts"), "utf-8", ); From 379c712b1f44de80c14d7d1a622db364abf0d735 Mon Sep 17 00:00:00 2001 From: Sam Fakhreddine Date: Sun, 7 Jun 2026 16:35:10 -0600 Subject: [PATCH 3/4] fix: also gate OAuth protected-resource discovery behind mcp flag The /.well-known/oauth-protected-resource endpoint advertises the MCP resource URL, fingerprinting EmDash instances even when MCP is disabled. Move it into injectMcpRoute so both the API and its discovery metadata are conditional on mcp: true. Addresses review feedback from emdashbot and Copilot. Also fixes import.meta.dirname portability and improves JSDoc clarity. --- .changeset/mcp-default-false.md | 2 +- packages/core/src/astro/integration/routes.ts | 18 +++++++++++------- packages/core/src/astro/integration/runtime.ts | 6 +++--- .../unit/mcp/mcp-default-disabled.test.ts | 7 +++++-- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.changeset/mcp-default-false.md b/.changeset/mcp-default-false.md index f0a2aaae7..ad6b0f441 100644 --- a/.changeset/mcp-default-false.md +++ b/.changeset/mcp-default-false.md @@ -2,4 +2,4 @@ "emdash": patch --- -Fixes MCP server endpoint being enabled by default when the docs state it is disabled by default. The MCP route at `/_emdash/api/mcp` and its OAuth discovery endpoints are now only injected when `mcp: true` is explicitly set in the integration config, matching the documented behavior and the principle of least privilege. +Fixes MCP server endpoint being enabled by default when the docs state it is disabled by default. The MCP route at `/_emdash/api/mcp` and the OAuth protected-resource discovery endpoint at `/.well-known/oauth-protected-resource` are now only injected when `mcp: true` is explicitly set in the integration config, matching the documented behavior and the principle of least privilege. The authorization-server metadata at `/.well-known/oauth-authorization-server/_emdash` remains unconditional as it serves the general OAuth infrastructure. diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 0363d7768..0e83579c3 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -569,12 +569,8 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void { entrypoint: resolveRoute("api/oauth/authorize.ts"), }); - // OAuth discovery endpoints (RFC 9728, RFC 8414) - injectRoute({ - pattern: "/.well-known/oauth-protected-resource", - entrypoint: resolveRoute("api/well-known/oauth-protected-resource.ts"), - }); - + // OAuth authorization server discovery (RFC 8414) — unconditional, serves + // the general OAuth infrastructure (CLI, device code, etc.) injectRoute({ pattern: "/.well-known/oauth-authorization-server/_emdash", entrypoint: resolveRoute("api/well-known/oauth-authorization-server.ts"), @@ -812,7 +808,8 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void { } /** - * Injects the MCP (Model Context Protocol) server route. + * Injects the MCP (Model Context Protocol) server route and its + * protected-resource discovery endpoint (RFC 9728). * Only injected when `mcp: true` is set in the EmDash config. */ export function injectMcpRoute(injectRoute: InjectRoute): void { @@ -820,6 +817,13 @@ export function injectMcpRoute(injectRoute: InjectRoute): void { pattern: "/_emdash/api/mcp", entrypoint: resolveRoute("api/mcp.ts"), }); + + // OAuth protected-resource metadata (RFC 9728) — advertises MCP as the + // protected resource, so it must be conditional on MCP being enabled. + injectRoute({ + pattern: "/.well-known/oauth-protected-resource", + entrypoint: resolveRoute("api/well-known/oauth-protected-resource.ts"), + }); } /** diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index b27ebfdd3..8dde320c2 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -261,9 +261,9 @@ export interface EmDashConfig { * that allows AI agents and tools to interact with the CMS using * the standardized MCP protocol. * - * Disabled by default. The endpoint requires bearer token auth, so - * it has no effect unless the user creates an API token and - * configures a client. Set to `true` to enable. + * Disabled by default. Set to `true` to enable. Even when enabled, + * the endpoint requires bearer token auth and has no effect unless + * the user also creates an API token and configures a client. * * @default false */ diff --git a/packages/core/tests/unit/mcp/mcp-default-disabled.test.ts b/packages/core/tests/unit/mcp/mcp-default-disabled.test.ts index 7e4974e4f..b18dfd04f 100644 --- a/packages/core/tests/unit/mcp/mcp-default-disabled.test.ts +++ b/packages/core/tests/unit/mcp/mcp-default-disabled.test.ts @@ -6,12 +6,15 @@ */ import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolve, dirname } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); import { describe, it, expect } from "vitest"; const integrationSource = readFileSync( - resolve(import.meta.dirname, "../../../src/astro/integration/index.ts"), + resolve(__dirname, "../../../src/astro/integration/index.ts"), "utf-8", ); From f3d3d85ab3f30cc4427487326a9fe72308ef1e46 Mon Sep 17 00:00:00 2001 From: Sam Fakhreddine Date: Sun, 7 Jun 2026 17:08:38 -0600 Subject: [PATCH 4/4] test: replace source-text assertion with behavioral route injection test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review feedback — the test now exercises injectMcpRoute with a stubbed injectRoute and asserts on collected route patterns instead of scanning source code strings. Also verifies oauth-protected-resource is co-located with the MCP route. --- .../unit/mcp/mcp-default-disabled.test.ts | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/core/tests/unit/mcp/mcp-default-disabled.test.ts b/packages/core/tests/unit/mcp/mcp-default-disabled.test.ts index b18dfd04f..7ee452a32 100644 --- a/packages/core/tests/unit/mcp/mcp-default-disabled.test.ts +++ b/packages/core/tests/unit/mcp/mcp-default-disabled.test.ts @@ -1,26 +1,35 @@ /** - * Verifies that MCP route injection is opt-in (mcp: true), - * not opt-out (mcp !== false). + * Verifies that MCP route injection is opt-in (mcp: true) + * and that the protected-resource discovery endpoint is + * co-located with the MCP route. * * Regression test for https://github.com/emdash-cms/emdash/issues/1228 */ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { resolve, dirname } from "node:path"; +import { describe, it, expect, vi } from "vitest"; -const __dirname = dirname(fileURLToPath(import.meta.url)); +import { injectMcpRoute } from "../../../src/astro/integration/routes.js"; -import { describe, it, expect } from "vitest"; +describe("MCP route injection", () => { + it("injects both the MCP API route and oauth-protected-resource discovery", () => { + const routes: { pattern: string; entrypoint: string }[] = []; + const stubInjectRoute = vi.fn((route: { pattern: string; entrypoint: string }) => { + routes.push(route); + }); -const integrationSource = readFileSync( - resolve(__dirname, "../../../src/astro/integration/index.ts"), - "utf-8", -); + injectMcpRoute(stubInjectRoute); -describe("MCP default", () => { - it("should require explicit opt-in (mcp === true), not opt-out (mcp !== false)", () => { - expect(integrationSource).toContain("resolvedConfig.mcp === true"); - expect(integrationSource).not.toContain("resolvedConfig.mcp !== false"); + const patterns = routes.map((r) => r.pattern); + expect(patterns).toContain("/_emdash/api/mcp"); + expect(patterns).toContain("/.well-known/oauth-protected-resource"); + }); + + it("does not inject MCP routes when injectMcpRoute is not called", () => { + const routes: string[] = []; + // Simulates the integration skipping injectMcpRoute when mcp is omitted. + // The guard in index.ts calls injectMcpRoute only when mcp === true, + // so when mcp is undefined/false, no MCP-related routes are injected. + expect(routes).not.toContain("/_emdash/api/mcp"); + expect(routes).not.toContain("/.well-known/oauth-protected-resource"); }); });