Skip to content

Commit 97c9b02

Browse files
ankur-archclaude
andcommitted
fix(agent-score): address CodeRabbit review
- Hide the llms.txt directive from screen readers and the tab order (aria-hidden + tabIndex={-1}); audits read raw HTML, not the a11y tree. - Bound the /docs/mcp proxy: reject request bodies over 1 MiB (413, also for chunked bodies without Content-Length) and abort the upstream fetch if headers take more than 30s, while keeping SSE bodies streaming. - lint-agent-ready now requires all three header-matched /mcp rewrites (accept, content-type, mcp-session-id), not just one. - Pin the afdocs version in the skill's reproduction commands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d765846 commit 97c9b02

4 files changed

Lines changed: 101 additions & 20 deletions

File tree

.claude/skills/docs-agent-ready/SKILL.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,10 @@ The guard prints a size table with per-file headroom so reviewers see how close
6565
**(f) Reproducing the audit.** The audit is the `afdocs` npm CLI (https://afdocs.dev). To reproduce a report locally:
6666

6767
```bash
68-
npx afdocs check https://www.prisma.io/docs --sampling deterministic -v
68+
# version pinned against supply-chain surprises — bump deliberately
69+
npx afdocs@0.18.7 check https://www.prisma.io/docs --sampling deterministic -v
6970
# parity needs its upstream checks in the same run:
70-
npx afdocs check https://www.prisma.io/docs \
71+
npx afdocs@0.18.7 check https://www.prisma.io/docs \
7172
--checks markdown-url-support,content-negotiation,markdown-content-parity \
7273
--sampling deterministic --format json -v
7374
```

apps/docs/scripts/lint-agent-ready.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -466,14 +466,21 @@ try {
466466
const siteNextConfigPath = join(scriptDir, "..", "..", "site", "next.config.mjs");
467467
try {
468468
const siteConfigSource = readFileSync(siteNextConfigPath, "utf8");
469-
const mcpRewriteCount = (
470-
siteConfigSource.match(new RegExp(`source: "/mcp",\\s*\\n\\s*has:`, "g")) ?? []
471-
).length;
472-
if (mcpRewriteCount === 0 || !siteConfigSource.includes(`destination: "${MCP_URL}"`)) {
469+
// All three header conditions are needed to cover the MCP Streamable HTTP
470+
// transport: POST messages (content-type), the server event stream (accept),
471+
// and session teardown (mcp-session-id).
472+
const requiredMcpHeaderKeys = ["accept", "content-type", "mcp-session-id"];
473+
const missingMcpHeaderRewrites = requiredMcpHeaderKeys.filter(
474+
(key) => !new RegExp(`source: "/mcp",[\\s\\S]{0,200}?key: "${key}"`).test(siteConfigSource),
475+
);
476+
if (missingMcpHeaderRewrites.length > 0) {
473477
mcpEndpointErrors.push(
474-
`site: next.config.mjs is missing the header-matched /mcp rewrites to "${MCP_URL}"`,
478+
`site: next.config.mjs is missing /mcp rewrite(s) for header key(s): ${missingMcpHeaderRewrites.join(", ")}`,
475479
);
476480
}
481+
if (!siteConfigSource.includes(`destination: "${MCP_URL}"`)) {
482+
mcpEndpointErrors.push(`site: next.config.mjs /mcp rewrites do not target "${MCP_URL}"`);
483+
}
477484
} catch (error) {
478485
mcpEndpointErrors.push(`site: could not read ${siteNextConfigPath}: ${String(error)}`);
479486
}

apps/docs/src/app/layout.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,12 @@ export default function Layout({ children }: { children: ReactNode }) {
116116
measure the directive's byte position within the body and flag it as
117117
"buried" when it sits past 50% — which happens if it renders inside
118118
the page content, after the sidebar markup. data-markdown-ignore
119-
keeps it out of the HTML/markdown parity comparison. */}
119+
keeps it out of the HTML/markdown parity comparison; aria-hidden and
120+
tabIndex={-1} keep it away from screen readers and the tab order
121+
(audits read the raw HTML, not the accessibility tree). */}
120122
<div
121123
data-markdown-ignore
124+
aria-hidden="true"
122125
style={{
123126
position: "absolute",
124127
width: 1,
@@ -132,8 +135,11 @@ export default function Layout({ children }: { children: ReactNode }) {
132135
}}
133136
>
134137
For the complete Prisma documentation index optimized for AI agents, see{" "}
135-
<a href="https://www.prisma.io/docs/llms.txt">https://www.prisma.io/docs/llms.txt</a>. A
136-
markdown version of every docs page is available by appending <code>.md</code> to its URL.
138+
<a href="https://www.prisma.io/docs/llms.txt" tabIndex={-1}>
139+
https://www.prisma.io/docs/llms.txt
140+
</a>
141+
{". A markdown version of every docs page is available by appending "}
142+
<code>.md</code> to its URL.
137143
</div>
138144
<Banner
139145
id="prisma-next-docs"

apps/docs/src/app/mcp/route.ts

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,24 +34,91 @@ const FORWARD_RESPONSE_HEADERS = [
3434
"www-authenticate",
3535
];
3636

37+
// MCP messages are small JSON-RPC payloads; this is a public endpoint, so cap
38+
// what gets buffered into memory.
39+
const MAX_BODY_BYTES = 1_048_576;
40+
41+
// Bound on establishing the upstream connection and receiving headers. The
42+
// timer is cleared once headers arrive so long-lived SSE bodies keep streaming.
43+
const UPSTREAM_HEADER_TIMEOUT_MS = 30_000;
44+
45+
/**
46+
* Reads the request body, rejecting once it exceeds MAX_BODY_BYTES. The
47+
* declared Content-Length short-circuits, but the stream is counted too so
48+
* chunked requests without a length are equally bounded.
49+
*/
50+
async function readBoundedBody(request: Request) {
51+
const declared = Number(request.headers.get("content-length"));
52+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return null;
53+
54+
if (!request.body) return new ArrayBuffer(0);
55+
56+
const chunks: Uint8Array[] = [];
57+
let total = 0;
58+
const reader = request.body.getReader();
59+
for (;;) {
60+
const { done, value } = await reader.read();
61+
if (done) break;
62+
total += value.byteLength;
63+
if (total > MAX_BODY_BYTES) {
64+
await reader.cancel();
65+
return null;
66+
}
67+
chunks.push(value);
68+
}
69+
70+
const body = new Uint8Array(total);
71+
let offset = 0;
72+
for (const chunk of chunks) {
73+
body.set(chunk, offset);
74+
offset += chunk.byteLength;
75+
}
76+
return body.buffer;
77+
}
78+
3779
async function proxyToMcpServer(request: Request) {
3880
const headers = new Headers();
3981
for (const name of FORWARD_REQUEST_HEADERS) {
4082
const value = request.headers.get(name);
4183
if (value) headers.set(name, value);
4284
}
4385

44-
// MCP messages are small JSON-RPC payloads; buffering avoids the streaming
45-
// request-body (duplex) requirements of pass-through fetch.
46-
const body =
47-
request.method === "GET" || request.method === "HEAD" ? undefined : await request.arrayBuffer();
86+
// Buffering (instead of streaming pass-through) avoids fetch's duplex
87+
// request-body requirements; readBoundedBody keeps it memory-safe.
88+
let body: ArrayBuffer | undefined;
89+
if (request.method !== "GET" && request.method !== "HEAD") {
90+
const bounded = await readBoundedBody(request);
91+
if (bounded === null) {
92+
return new Response("Request body exceeds the 1 MiB limit for MCP messages.", {
93+
status: 413,
94+
headers: { "Cache-Control": "no-store" },
95+
});
96+
}
97+
body = bounded;
98+
}
4899

49-
const upstream = await fetch(MCP_SERVER_URL, {
50-
method: request.method,
51-
headers,
52-
body,
53-
redirect: "manual",
54-
});
100+
const abort = new AbortController();
101+
const headerTimer = setTimeout(() => abort.abort(), UPSTREAM_HEADER_TIMEOUT_MS);
102+
let upstream: Response;
103+
try {
104+
upstream = await fetch(MCP_SERVER_URL, {
105+
method: request.method,
106+
headers,
107+
body,
108+
redirect: "manual",
109+
signal: abort.signal,
110+
});
111+
} catch (error) {
112+
if (abort.signal.aborted) {
113+
return new Response("Upstream MCP server timed out.", {
114+
status: 504,
115+
headers: { "Cache-Control": "no-store" },
116+
});
117+
}
118+
throw error;
119+
} finally {
120+
clearTimeout(headerTimer);
121+
}
55122

56123
const responseHeaders = new Headers({ "Cache-Control": "no-store" });
57124
for (const name of FORWARD_RESPONSE_HEADERS) {

0 commit comments

Comments
 (0)