diff --git a/.claude/skills/docs-agent-ready/SKILL.md b/.claude/skills/docs-agent-ready/SKILL.md
index 3700c470a0..f00a2e1dad 100644
--- a/.claude/skills/docs-agent-ready/SKILL.md
+++ b/.claude/skills/docs-agent-ready/SKILL.md
@@ -3,7 +3,7 @@ name: docs-agent-ready
description: Use when adding a new docs section or product area, editing llms.ts / the llms.txt or llms/[...slug] / llms-full.txt routes / get-llm-text / skill.md / .well-known endpoints, or working on the "agent score", "llms.txt", or anything "agent-ready" in the docs and site apps. Explains the invariants the Mintlify agent-readiness audit measures and how to hold them.
metadata:
author: Prisma
- version: "2026.7.21"
+ version: "2026.7.24"
---
# Docs agent-readiness
@@ -15,8 +15,10 @@ Keep Prisma's docs machine-readable so the Mintlify **agent-score** audit does n
- **Root `llms.txt` < 50k bytes** (warn at 35k). It links to per-area section indexes, not every page.
- **Each section index < 50k bytes** (warn at 40k). Over budget means split the section.
- **Every page is reachable** — each `filterPagesForLLMsIndex` page appears in a section file or the root "Other pages" list. The guard asserts against the generated content, not just membership.
-- **Directives in HTML + Markdown** — every page's Markdown (`getLLMText`) starts with the hidden `llms.txt` directive blockquote; the HTML page keeps a hidden directive as its first child.
-- **HTML/Markdown parity** via `data-markdown-ignore` on human-only chrome so the Markdown mirrors the page.
+- **Directives in HTML + Markdown** — every page's Markdown (`getLLMText`) starts with the hidden `llms.txt` directive blockquote; the HTML keeps a hidden directive as the **first child of `
` in the root layout** (`apps/docs/src/app/layout.tsx`), NOT inside the page component. Audits measure the directive's byte position in the body and warn when it sits past 50%, which is where it lands if rendered after the sidebar markup.
+- **HTML/Markdown parity** via `data-markdown-ignore` on human-only chrome so the Markdown mirrors the page. The OpenAPI explorer (`APIPage` wrapper in `src/components/api-page.tsx`) carries `data-markdown-ignore` because the interactive reference has no markdown equivalent — the `.md` serves the generated API summary instead.
+- **Markdown keeps real headings** — fumadocs' processed output emits headings as bare `Text [#anchor]` lines; `getLLMText` restores `##` markers from the page toc (`restoreHeadingMarkers` in `llm-markdown.ts`). Without them, parity checkers strip list-like heading text ("## 1. Set up …") and agents see prose instead of structure.
+- **`` blocks are converted** to a bold summary line + dedented body (`formatDetails` in `llm-markdown.ts`); serialized `` children are 2-space indented, which silently breaks the code fences inside for markdown consumers.
- **`llms-full.txt` excludes** legacy `/orm/v6` and the Accelerate/Optimize products (`getLLMsFullPages`).
- **Skill + MCP endpoints live at BOTH roots**: `www.prisma.io` (apps/site) and `/docs` (apps/docs).
@@ -32,6 +34,8 @@ Keep Prisma's docs machine-readable so the Mintlify **agent-score** audit does n
| `/docs/.well-known/mcp[.json]` | `apps/docs/src/lib/mcp-discovery.ts` |
| `/skill.md`, `/.well-known/agent-skills/*` | `apps/site/src/lib/agent-skills.ts` (`buildSkillMarkdown`) |
| `/.well-known/mcp*` (site) | `apps/site/src/lib/agent-skills.ts` (`buildMcpDiscovery`, server cards) |
+| `/docs/mcp` (MCP proxy) | `apps/docs/src/app/mcp/route.ts` → proxies protocol traffic to `mcp.prisma.io/mcp` |
+| `/mcp` (site, MCP traffic) | header-matched `beforeFiles` rewrites in `apps/site/next.config.mjs` (browser GETs still get the marketing page) |
The route handlers are thin wrappers: shared builders in `llms.ts` are the single source of truth, so the guard measures exactly what the routes serve.
@@ -41,7 +45,7 @@ The route handlers are thin wrappers: shared builders in `llms.ts` are the singl
**(b) Section over budget.** When a section fails/warns on size, split it into two sections in `llmsSections` (narrower `prefixes`, or carve a sub-tree out with a new slug). Re-run the guard.
-**(c) Changing page chrome** in `apps/docs/src/app/(docs)/(default)/[[...slug]]/page.tsx`: keep the hidden `llms.txt` directive as the first child, and put `data-markdown-ignore` on any human-only chrome (banners, nav, badges) so it stays out of the Markdown.
+**(c) Changing page chrome.** The hidden `llms.txt` directive lives in `apps/docs/src/app/layout.tsx` as the first child of `` — keep it there (before `` within the first 10% of the (nav/script/style-stripped) `` on sampled pages and warns when every match sits past 50%; the parity check compares HTML text segments against the `.md`, strips `data-markdown-ignore` elements from the HTML side, and only treats a fenced code block as protected when the fence starts at column 0 — which is why `` bodies must be dedented and headings must keep their `#` markers. The separate "MCP Server Discoverable" check probes `/mcp` with an MCP initialize request (discovery documents alone do not count), which is what the `/docs/mcp` proxy route and the site `/mcp` rewrites are for.
diff --git a/apps/docs/scripts/lint-agent-ready.ts b/apps/docs/scripts/lint-agent-ready.ts
index a77fc4e970..3b87e865f6 100644
--- a/apps/docs/scripts/lint-agent-ready.ts
+++ b/apps/docs/scripts/lint-agent-ready.ts
@@ -146,6 +146,8 @@ if (unmatched.length > CATCHALL_WARN) {
// fast enough to cover the full set.
const directiveFailures: string[] = [];
const missingDescription: string[] = [];
+const headingMarkerFailures: string[] = [];
+const detailsLeaks: string[] = [];
for (const page of indexPages) {
let text: string;
try {
@@ -168,6 +170,27 @@ for (const page of indexPages) {
if (description && !text.includes(description)) {
missingDescription.push(page.url);
}
+
+ // Heading markers: the processed markdown emits headings as bare
+ // "Text [#anchor]" lines; getLLMText restores the `#` markers from the toc.
+ // If a toc anchor appears in the output, the line carrying it must be a real
+ // markdown heading — otherwise agents see prose and the afdocs parity check
+ // strips list-like heading text ("## 1. Set up …") on the markdown side.
+ for (const item of page.data.toc ?? []) {
+ if (typeof item.url !== "string" || !item.url.startsWith("#")) continue;
+ const anchorRef = `[${item.url}]`;
+ const anchorLine = lines.find((line) => line.includes(anchorRef));
+ if (anchorLine !== undefined && !/^#{1,6} /.test(anchorLine)) {
+ headingMarkerFailures.push(`${page.url} (${item.url})`);
+ }
+ }
+
+ // blocks must be converted to plain markdown (formatDetails in
+ // llm-markdown.ts); a leaked means its body is still 2-space
+ // indented, which breaks code fences for markdown consumers.
+ if (text.includes(" 0) {
@@ -192,39 +215,88 @@ if (missingDescription.length > 0) {
pass("Description in markdown", "all frontmatter descriptions present in markdown");
}
+if (headingMarkerFailures.length > 0) {
+ fail(
+ "Heading markers restored",
+ `${headingMarkerFailures.length} toc heading(s) rendered without markdown markers (restoreHeadingMarkers in llm-markdown.ts regressed):\n ${headingMarkerFailures
+ .slice(0, 10)
+ .join("\n ")}`,
+ );
+} else {
+ pass("Heading markers restored", "all toc anchors in markdown output sit on real headings");
+}
+
+if (detailsLeaks.length > 0) {
+ fail(
+ "No leakage",
+ `${detailsLeaks.length} page(s) leak raw into markdown (formatDetails in llm-markdown.ts regressed):\n ${detailsLeaks
+ .slice(0, 10)
+ .join("\n ")}`,
+ );
+} else {
+ pass("No leakage", "all blocks converted to plain markdown");
+}
+
// ── Check 5b: HTML surface source guard ──────────────────────────────────────
-// The rendered HTML page carries the same directive via a hidden element.
-// Rendering React in this script is not worth it; instead guard at the source
-// level that the docs page component emits a hidden element referencing llms.txt
-// BEFORE it renders in the ROOT LAYOUT — not inside the page
+// component. Agent-readiness audits (afdocs "llms-txt-directive-html") measure
+// the directive's byte position within the body and warn when it sits past 50%,
+// which is where it lands if it renders inside the content area after the
+// sidebar markup. Rendering React in this script is not worth it; instead guard
+// at the source level that layout.tsx links llms.txt between and the
+// first real child (= docsPageRenderIndex) {
+ fail("HTML directive source guard", "layout.tsx does not link llms.txt");
+ } else if (llmsRefIndex < bodyIndex) {
+ fail(
+ "HTML directive source guard",
+ "layout.tsx links llms.txt before ; the hidden directive must be the first child of ",
+ );
+ } else if (bannerIndex !== -1 && llmsRefIndex > bannerIndex) {
+ fail(
+ "HTML directive source guard",
+ "layout.tsx links llms.txt after so audits find it near the top of the HTML",
+ );
+ } else if (ignoreIndex === -1 || ignoreIndex > llmsRefIndex) {
fail(
"HTML directive source guard",
- "page.tsx references llms.txt only after ");
}
} catch (error) {
- fail("HTML directive source guard", `could not read ${docsPagePath}: ${String(error)}`);
+ fail("HTML directive source guard", `could not read ${docsLayoutPath}: ${String(error)}`);
+}
+
+// ── Check 5c: APIPage parity guard ───────────────────────────────────────────
+// The interactive OpenAPI explorer on /management-api/endpoints/* has no
+// markdown equivalent (per-language code samples, auth widgets); the wrapper in
+// api-page.tsx must carry data-markdown-ignore so parity checkers compare only
+// the generated markdown API reference.
+const apiPagePath = join(scriptDir, "..", "src", "components", "api-page.tsx");
+try {
+ const apiPageSource = readFileSync(apiPagePath, "utf8");
+ if (!apiPageSource.includes("data-markdown-ignore")) {
+ fail(
+ "APIPage parity guard",
+ "api-page.tsx no longer wraps the OpenAPI explorer in data-markdown-ignore; management-api endpoint pages will fail markdown/HTML parity",
+ );
+ } else {
+ pass("APIPage parity guard", "OpenAPI explorer is excluded from parity comparison");
+ }
+} catch (error) {
+ fail("APIPage parity guard", `could not read ${apiPagePath}: ${String(error)}`);
}
// ── Check 6: common queries resolve to existing pages ────────────────────────
@@ -369,6 +441,63 @@ if (mcpErrors.length > 0) {
);
}
+// ── Check 9b: MCP protocol endpoints (docs + site) ───────────────────────────
+// Discovery documents alone do not satisfy "MCP server discoverable" audits —
+// they probe the conventional `/mcp` endpoints with an MCP initialize
+// request. /docs/mcp is a proxy route in this app; www.prisma.io/mcp is a
+// marketing page, so MCP traffic there is routed by header-matched rewrites in
+// the site next.config. Guard both at the source level.
+const mcpEndpointErrors: string[] = [];
+const docsMcpRoutePath = join(scriptDir, "..", "src", "app", "mcp", "route.ts");
+try {
+ const routeSource = readFileSync(docsMcpRoutePath, "utf8");
+ if (!routeSource.includes(`"${MCP_URL}"`)) {
+ mcpEndpointErrors.push(`docs: src/app/mcp/route.ts does not proxy to "${MCP_URL}"`);
+ }
+ for (const handler of ["GET", "POST", "DELETE"]) {
+ if (!new RegExp(`export (async )?function ${handler}\\b`).test(routeSource)) {
+ mcpEndpointErrors.push(`docs: src/app/mcp/route.ts is missing the ${handler} handler`);
+ }
+ }
+} catch (error) {
+ mcpEndpointErrors.push(`docs: could not read ${docsMcpRoutePath}: ${String(error)}`);
+}
+
+const siteNextConfigPath = join(scriptDir, "..", "..", "site", "next.config.mjs");
+try {
+ const siteConfigSource = readFileSync(siteNextConfigPath, "utf8");
+ // All three header conditions are needed to cover the MCP Streamable HTTP
+ // transport: POST messages (content-type), the server event stream (accept),
+ // and session teardown (mcp-session-id). Split the config into per-rewrite
+ // chunks (each starts at its `source:` and ends before the next one) so a
+ // header key and the MCP destination must appear together in the SAME
+ // rewrite entry — matching across neighbouring entries would let a dropped
+ // header condition slip through.
+ const mcpRewriteChunks = siteConfigSource
+ .split(/(?=source: ")/)
+ .filter((chunk) => chunk.startsWith('source: "/mcp"'));
+ const requiredMcpHeaderKeys = ["accept", "content-type", "mcp-session-id"];
+ const missingMcpHeaderRewrites = requiredMcpHeaderKeys.filter(
+ (key) =>
+ !mcpRewriteChunks.some(
+ (chunk) => chunk.includes(`key: "${key}"`) && chunk.includes(`destination: "${MCP_URL}"`),
+ ),
+ );
+ if (missingMcpHeaderRewrites.length > 0) {
+ mcpEndpointErrors.push(
+ `site: next.config.mjs is missing a /mcp rewrite to "${MCP_URL}" for header key(s): ${missingMcpHeaderRewrites.join(", ")}`,
+ );
+ }
+} catch (error) {
+ mcpEndpointErrors.push(`site: could not read ${siteNextConfigPath}: ${String(error)}`);
+}
+
+if (mcpEndpointErrors.length > 0) {
+ fail("MCP protocol endpoints", `\n ${mcpEndpointErrors.join("\n ")}`);
+} else {
+ pass("MCP protocol endpoints", "/docs/mcp proxy route + site /mcp rewrites present");
+}
+
// ── Check 10: placeholder protectors are collision-safe ──────────────────────
// Regression coverage for the protect/restore pipeline shared by llm-markdown.ts
// and get-llm-text.ts. Adversarial inputs embed text that LOOKS like the internal
diff --git a/apps/docs/src/app/(docs)/(default)/[[...slug]]/page.tsx b/apps/docs/src/app/(docs)/(default)/[[...slug]]/page.tsx
index a3cfa8dfd8..7322fcb113 100644
--- a/apps/docs/src/app/(docs)/(default)/[[...slug]]/page.tsx
+++ b/apps/docs/src/app/(docs)/(default)/[[...slug]]/page.tsx
@@ -31,29 +31,11 @@ export default async function Page({ params }: { params: Promise })
const aiPromptSlug = (page.data as { aiPrompt?: string }).aiPrompt;
const promptContent = aiPromptSlug ? await getPromptContent(aiPromptSlug) : null;
- const pageMarkdownUrl = `https://www.prisma.io${withDocsBasePath(page.url)}.md`;
-
return (
<>
-
+ {/* The hidden llms.txt directive for AI agents lives in the root layout
+ (src/app/layout.tsx) as the first child of — agent-readiness
+ audits require it near the top of the HTML, before the sidebar. */}
+ {/* Hidden llms.txt directive for AI agents. It must be the FIRST child
+ of : agent-readiness audits (afdocs "llms-txt-directive-html")
+ measure the directive's byte position within the body and flag it as
+ "buried" when it sits past 50% — which happens if it renders inside
+ the page content, after the sidebar markup. data-markdown-ignore
+ keeps it out of the HTML/markdown parity comparison; aria-hidden and
+ tabIndex={-1} keep it away from screen readers and the tab order
+ (audits read the raw HTML, not the accessibility tree). */}
+
+ For the complete Prisma documentation index optimized for AI agents, see{" "}
+
+ https://www.prisma.io/docs/llms.txt
+
+ {". A markdown version of every docs page is available by appending "}
+
.md to its URL.
+
/mcp` endpoints with
+ * an MCP initialize request to decide whether an MCP server is discoverable
+ * (the `.well-known/mcp` discovery documents alone are not enough). The real
+ * Prisma MCP server lives at https://mcp.prisma.io/mcp behind OAuth, so this
+ * route proxies MCP protocol traffic (POST messages, GET SSE streams, DELETE
+ * session teardown) to it and preserves the `WWW-Authenticate` challenge that
+ * tells clients how to authenticate. Plain browser GETs are sent to the /mcp
+ * marketing page instead.
+ *
+ * The equivalent endpoint on the site root (www.prisma.io/mcp) is handled by
+ * header-matched rewrites in apps/site/next.config.mjs, because /mcp there is
+ * already a marketing page.
+ */
+export const dynamic = "force-dynamic";
+
+const MCP_SERVER_URL = "https://mcp.prisma.io/mcp";
+
+const FORWARD_REQUEST_HEADERS = [
+ "accept",
+ "authorization",
+ "content-type",
+ "last-event-id",
+ "mcp-protocol-version",
+ "mcp-session-id",
+];
+
+const FORWARD_RESPONSE_HEADERS = [
+ "content-type",
+ "mcp-protocol-version",
+ "mcp-session-id",
+ "www-authenticate",
+];
+
+// MCP messages are small JSON-RPC payloads; this is a public endpoint, so cap
+// what gets buffered into memory.
+const MAX_BODY_BYTES = 1_048_576;
+
+// Bound on establishing the upstream connection and receiving headers. The
+// timer is cleared once headers arrive so long-lived SSE bodies keep streaming.
+const UPSTREAM_HEADER_TIMEOUT_MS = 30_000;
+
+/**
+ * Reads the request body, rejecting once it exceeds MAX_BODY_BYTES. The
+ * declared Content-Length short-circuits, but the stream is counted too so
+ * chunked requests without a length are equally bounded.
+ */
+async function readBoundedBody(request: Request) {
+ const declared = Number(request.headers.get("content-length"));
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return null;
+
+ if (!request.body) return new ArrayBuffer(0);
+
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+ const reader = request.body.getReader();
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ total += value.byteLength;
+ if (total > MAX_BODY_BYTES) {
+ await reader.cancel();
+ return null;
+ }
+ chunks.push(value);
+ }
+
+ const body = new Uint8Array(total);
+ let offset = 0;
+ for (const chunk of chunks) {
+ body.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return body.buffer;
+}
+
+async function proxyToMcpServer(request: Request) {
+ const headers = new Headers();
+ for (const name of FORWARD_REQUEST_HEADERS) {
+ const value = request.headers.get(name);
+ if (value) headers.set(name, value);
+ }
+
+ // Buffering (instead of streaming pass-through) avoids fetch's duplex
+ // request-body requirements; readBoundedBody keeps it memory-safe.
+ let body: ArrayBuffer | undefined;
+ if (request.method !== "GET" && request.method !== "HEAD") {
+ const bounded = await readBoundedBody(request);
+ if (bounded === null) {
+ return new Response("Request body exceeds the 1 MiB limit for MCP messages.", {
+ status: 413,
+ headers: { "Cache-Control": "no-store" },
+ });
+ }
+ body = bounded;
+ }
+
+ const abort = new AbortController();
+ const headerTimer = setTimeout(() => abort.abort(), UPSTREAM_HEADER_TIMEOUT_MS);
+ let upstream: Response;
+ try {
+ upstream = await fetch(MCP_SERVER_URL, {
+ method: request.method,
+ headers,
+ body,
+ redirect: "manual",
+ signal: abort.signal,
+ });
+ } catch (error) {
+ if (abort.signal.aborted) {
+ return new Response("Upstream MCP server timed out.", {
+ status: 504,
+ headers: { "Cache-Control": "no-store" },
+ });
+ }
+ throw error;
+ } finally {
+ clearTimeout(headerTimer);
+ }
+
+ const responseHeaders = new Headers({ "Cache-Control": "no-store" });
+ for (const name of FORWARD_RESPONSE_HEADERS) {
+ const value = upstream.headers.get(name);
+ if (value) responseHeaders.set(name, value);
+ }
+
+ return new Response(upstream.body, {
+ status: upstream.status,
+ headers: responseHeaders,
+ });
+}
+
+export async function GET(request: Request) {
+ const accept = request.headers.get("accept") ?? "";
+ // MCP Streamable HTTP clients open the server event stream with
+ // `Accept: text/event-stream`; anything else is a human with a browser.
+ if (!accept.includes("text/event-stream")) {
+ return Response.redirect("https://www.prisma.io/mcp", 307);
+ }
+ return proxyToMcpServer(request);
+}
+
+export function POST(request: Request) {
+ return proxyToMcpServer(request);
+}
+
+export function DELETE(request: Request) {
+ return proxyToMcpServer(request);
+}
diff --git a/apps/docs/src/components/api-page.tsx b/apps/docs/src/components/api-page.tsx
index 87d22a66b3..324c5b2692 100644
--- a/apps/docs/src/components/api-page.tsx
+++ b/apps/docs/src/components/api-page.tsx
@@ -1,10 +1,24 @@
import { openapi } from "@/lib/openapi";
import { createAPIPage } from "fumadocs-openapi/ui";
+import type { ComponentProps } from "react";
import client from "./api-page.client";
-export const APIPage = createAPIPage(openapi, {
+const BaseAPIPage = createAPIPage(openapi, {
client,
playground: {
enabled: false,
},
});
+
+// data-markdown-ignore: the interactive OpenAPI explorer (per-language code
+// samples, auth widgets, collapsible schemas) is the human-facing rendering of
+// the endpoint. The markdown version of these pages carries the equivalent
+// generated API reference (see formatApiPage in src/lib/llm-markdown.ts), so
+// the explorer must be excluded from HTML/markdown parity comparisons.
+export function APIPage(props: ComponentProps) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/docs/src/lib/get-llm-text.ts b/apps/docs/src/lib/get-llm-text.ts
index fa785739d9..d6e1e4bc4c 100644
--- a/apps/docs/src/lib/get-llm-text.ts
+++ b/apps/docs/src/lib/get-llm-text.ts
@@ -222,8 +222,17 @@ function absolutizeInBodyLinks(markdown: string, page: DocsPage, baseUrl: string
export async function getLLMText(page: DocsPage) {
const baseUrl = getBaseUrl();
+ // The processed markdown emits headings as plain "Text [#anchor]" lines; the
+ // toc's anchor→depth map lets normalizeProcessedMarkdown restore the `##`
+ // markers so agents (and parity checkers) see real headings.
+ const headingDepths = new Map();
+ for (const item of page.data.toc ?? []) {
+ if (typeof item.url === "string" && item.url.startsWith("#")) {
+ headingDepths.set(item.url.slice(1), item.depth);
+ }
+ }
const processed = absolutizeInBodyLinks(
- normalizeProcessedMarkdown(await page.data.getText("processed")),
+ normalizeProcessedMarkdown(await page.data.getText("processed"), { headingDepths }),
page,
baseUrl,
);
diff --git a/apps/docs/src/lib/llm-markdown.ts b/apps/docs/src/lib/llm-markdown.ts
index d67d0a5f27..f5f1f6ff59 100644
--- a/apps/docs/src/lib/llm-markdown.ts
+++ b/apps/docs/src/lib/llm-markdown.ts
@@ -249,6 +249,54 @@ function formatYoutube(attrs: string) {
return `[${title}](https://www.youtube.com/watch?v=${videoId})`;
}
+/**
+ * Converts a ``/`` block into plain markdown. The processed
+ * markdown serializes JSX/HTML children with a 2-space indent, which turns the
+ * code fences inside `` into indented fences that markdown consumers
+ * (and the afdocs parity checker) no longer treat as code blocks. Dedenting the
+ * body back to column 0 and collapsing the summary to a single bold line keeps
+ * the content faithful to the rendered page. Must run BEFORE fenced code blocks
+ * are protected, so the dedent applies to the fences themselves.
+ */
+function formatDetails(content: string) {
+ const summaryMatch = content.match(/]*>([\s\S]*?)<\/summary>/);
+ const summary = summaryMatch
+ ? stripJsxTags(trimComponentContent(summaryMatch[1])).replace(/\s+/g, " ").trim()
+ : "";
+ const body = trimComponentContent(content.replace(//, ""));
+
+ if (!body) return summary ? `**${summary}**` : "";
+ return summary ? `**${summary}**\n\n${body}` : body;
+}
+
+/**
+ * Restores markdown heading markers on lines the processed output emits as
+ * plain `Heading text [#anchor]` lines. Fumadocs' processed markdown drops the
+ * `#` markers from headings, which demotes them to prose for agents and breaks
+ * the afdocs parity check for headings that start with list-like text (e.g.
+ * "## 1. Set up your project" — without the marker, "1. " reads as a list
+ * item). The anchor→depth map built from the page's table of contents decides
+ * which lines are headings and at what level. Run while fenced code blocks are
+ * protected so code lines can never be rewritten.
+ */
+function restoreHeadingMarkers(
+ markdown: string,
+ headingDepths: ReadonlyMap | undefined,
+) {
+ if (!headingDepths || headingDepths.size === 0) return markdown;
+
+ return markdown.replace(
+ /^[ \t]*(.+?)[ \t]*\[#([^\]\n]+)\][ \t]*$/gm,
+ (match, text: string, anchor: string) => {
+ const depth = headingDepths.get(anchor);
+ if (!depth || text.startsWith("#")) return match;
+
+ const level = Math.min(Math.max(Math.trunc(depth), 1), 6);
+ return `${"#".repeat(level)} ${text} [#${anchor}]`;
+ },
+ );
+}
+
function convertHtmlLinks(value: string) {
return value.replace(/]*)>([\s\S]*?)<\/a>/g, (_match, attrs: string, content: string) => {
const href = getAttribute(attrs, "href");
@@ -455,7 +503,10 @@ export function protectInlineCode(markdown: string) {
};
}
-export function normalizeProcessedMarkdown(markdown: string) {
+export function normalizeProcessedMarkdown(
+ markdown: string,
+ options?: { headingDepths?: ReadonlyMap },
+) {
const componentMarkdown = markdown
.replace(/\{\/\*[\s\S]*?\*\/\}/g, "")
.replace(
@@ -487,7 +538,10 @@ export function normalizeProcessedMarkdown(markdown: string) {
.replace(/]*>([\s\S]*?)<\/SharedContent>/g, (_match, content: string) =>
trimComponentContent(content),
)
- .replace(/]*\/>/g, "");
+ .replace(/]*\/>/g, "")
+ .replace(/]*>([\s\S]*?)<\/details>/g, (_match, content: string) =>
+ formatDetails(content),
+ );
const protectedCode = protectFencedCodeBlocks(componentMarkdown);
const withoutJsxComponents = replaceComponentBlocks(
@@ -499,8 +553,21 @@ export function normalizeProcessedMarkdown(markdown: string) {
formatButton,
);
+ // Undo remark-stringify escapes that have no markdown meaning in prose
+ // (`\_`, `\{`, `\}`): they read as noise to agents consuming the raw
+ // markdown ("snake\_case") and break HTML/markdown parity comparisons.
+ // Underscores are only emphasis at word boundaries, which escaped
+ // identifiers like snake_case never hit. Inline code is protected first so
+ // literal backslashes in code spans survive; fenced blocks are already
+ // placeholders at this point.
+ const withHeadings = restoreHeadingMarkers(withoutJsxComponents, options?.headingDepths);
+ const protectedInline = protectInlineCode(withHeadings);
+ const unescaped = protectedInline.restore(
+ protectedInline.markdown.replace(/\\([_{}])/g, "$1"),
+ );
+
return protectedCode
- .restore(withoutJsxComponents)
+ .restore(unescaped)
.replace(/^[ \t]+(#{3,4} )/gm, "$1")
.replace(/^[ \t]+(- \[)/gm, "$1")
.replace(/\n{3,}/g, "\n\n")
diff --git a/apps/site/next.config.mjs b/apps/site/next.config.mjs
index b73d43b2a8..8f6df7f4b3 100644
--- a/apps/site/next.config.mjs
+++ b/apps/site/next.config.mjs
@@ -746,6 +746,31 @@ const config = {
source: "/:path*.mdx",
destination: "/llms.mdx/:path*",
},
+ // MCP protocol traffic on the conventional /mcp endpoint goes to the
+ // real Prisma MCP server (OAuth-gated); plain browser GETs fall
+ // through to the /mcp marketing page. The three header conditions
+ // cover the MCP Streamable HTTP transport: POST messages send
+ // `Accept: application/json, text/event-stream`, the server event
+ // stream is opened with a text/event-stream GET, and session teardown
+ // is a DELETE carrying only `Mcp-Session-Id`. Agent-readiness audits
+ // probe this endpoint with an initialize request to decide whether an
+ // MCP server is discoverable. /docs/mcp is the equivalent endpoint in
+ // apps/docs (src/app/mcp/route.ts).
+ {
+ source: "/mcp",
+ has: [{ type: "header", key: "accept", value: ".*text/event-stream.*" }],
+ destination: "https://mcp.prisma.io/mcp",
+ },
+ {
+ source: "/mcp",
+ has: [{ type: "header", key: "content-type", value: "application/json.*" }],
+ destination: "https://mcp.prisma.io/mcp",
+ },
+ {
+ source: "/mcp",
+ has: [{ type: "header", key: "mcp-session-id" }],
+ destination: "https://mcp.prisma.io/mcp",
+ },
// subdomains
{
source: "/:path*",