From 36f873d277f1167c8519aacd3f221258e24bca59 Mon Sep 17 00:00:00 2001 From: fretchen Date: Wed, 21 Oct 2026 21:58:09 +0200 Subject: [PATCH 1/2] fix the 404 --- bunny_js/src/mcp_routes.ts | 28 +++++++++++++++++++++++----- bunny_js/test/mcp.test.ts | 17 ++++++++++++++++- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/bunny_js/src/mcp_routes.ts b/bunny_js/src/mcp_routes.ts index 81f79d39d..c386e69b8 100644 --- a/bunny_js/src/mcp_routes.ts +++ b/bunny_js/src/mcp_routes.ts @@ -8,11 +8,17 @@ * original 405s every GET ("stateless server, the spec allows it"), which generic scanners * (x402scan-style icon resolution, plain health checks) read as a dead origin — the root * HTML and /favicon.png now resolve, and paths with no content answer 404, the honest - * "not here" rather than "method broken". What is deliberately NOT served: - * /.well-known/agent-registration.json and /openapi.json — this origin holds no ERC-8004 - * identity and no REST spec of its own (it relays to the twins that have both), so serving - * either would fabricate identity. DELETE stays 405 — session termination is the one thing - * this stateless server genuinely does not do, per the MCP spec's session-less allowance. + * "not here" rather than "method broken". ONE exception learned from 8004scan: the MCP + * endpoint itself. A health probe GETs the declared endpoint (/mcp), and 404 made it read + * the endpoint as nonexistent ("HTTP 404 (cached)" -> unhealthy, while POST initialize + * answered in ~300 ms) — the Streamable HTTP spec's GET is the OPTIONAL server-initiated + * SSE stream, and a stateless server that offers none answers 405, Allow: POST. So + * GET /mcp is 405 (endpoint exists, use POST), every other GET path stays the honest 404. + * What is deliberately NOT served: /.well-known/agent-registration.json and + * /openapi.json — this origin holds no ERC-8004 identity and no REST spec of its own + * (it relays to the twins that have both), so serving either would fabricate identity. + * DELETE stays 405 — session termination is the one thing this stateless server genuinely + * does not do, per the MCP spec's session-less allowance. */ import { CORS_HEADERS } from "./utils.js"; import { FAVICON_DISCOVERY_HTML, wantsHtml } from "./discovery.js"; @@ -200,6 +206,18 @@ export async function handleMCPRequest(request: Request): Promise { headers: { ...CORS_HEADERS, "Content-Type": faviconContentType }, }); } + // The MCP endpoint itself: the spec's GET is the optional server-initiated SSE stream, + // which this stateless server does not offer — 405 Allow: POST, not 404 (see the header + // comment: a 404 here read as "endpoint does not exist" to 8004scan's health probe). + if (path === "mcp") { + return new Response( + JSON.stringify({ + error: + "This MCP endpoint is stateless: JSON-RPC 2.0 over POST only. The GET server-initiated stream is not offered; no sessions.", + }), + { status: 405, headers: { ...CORS_HEADERS, Allow: "POST" } }, + ); + } return new Response( JSON.stringify({ error: diff --git a/bunny_js/test/mcp.test.ts b/bunny_js/test/mcp.test.ts index 001262806..b20aadf52 100644 --- a/bunny_js/test/mcp.test.ts +++ b/bunny_js/test/mcp.test.ts @@ -151,8 +151,23 @@ describe("mcp_routes (stateless paid MCP endpoint)", () => { * content, 200 discovery HTML at the root when the caller accepts HTML, and DELETE * keeps 405 (session termination is genuinely not a thing here, per the MCP spec). */ - it("answers GET like a normal origin (404 + notice), not the scw blanket 405", async () => { + /** + * GET /mcp: the Streamable HTTP spec's answer for a stateless server without the + * optional GET SSE stream — 405 Allow: POST. 404 here read as "endpoint does not + * exist" to 8004scan's health probe (marked healthy-POST-serving endpoint unhealthy). + */ + it("answers GET on the MCP endpoint with 405 Allow: POST, not 404", async () => { const res = await handle(new Request("https://mcp-agent.fretchen.eu/mcp", { method: "GET" })); + expect(res.status).toBe(405); + expect(res.headers.get("allow")).toBe("POST"); + const body = JSON.parse(await res.text()) as { error: string }; + expect(body.error).toContain("POST only"); + }); + + it("answers GET on a contentless path with the honest 404 + notice", async () => { + const res = await handle( + new Request("https://mcp-agent.fretchen.eu/other", { method: "GET" }), + ); expect(res.status).toBe(404); const body = JSON.parse(await res.text()) as { error: string }; expect(body.error).toContain("POST only"); From 7532276f1da952dbb74f09c159f791dd921a2251 Mon Sep 17 00:00:00 2001 From: fretchen Date: Wed, 21 Oct 2026 22:06:59 +0200 Subject: [PATCH 2/2] improvements --- website/components/FeedbackWidget.tsx | 7 ++++++- website/test/FeedbackWidget.test.tsx | 8 +++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/website/components/FeedbackWidget.tsx b/website/components/FeedbackWidget.tsx index 6c71f8880..bca3a73da 100644 --- a/website/components/FeedbackWidget.tsx +++ b/website/components/FeedbackWidget.tsx @@ -186,7 +186,12 @@ export function FeedbackWidget({ targets }: FeedbackWidgetProps) { abi: REPUTATION_REGISTRY_ABI, functionName: "giveFeedback", chainId: FEEDBACK.chainId, - args: [BigInt(target.agentId), BigInt(stars), 0, "", "", target.endpoint, "", ZERO_HASH], + // The EIP-8004 "starred" convention (its value/valueDecimals examples table): tag1 + // names the semantic, and a starred rating is expressed on a 0-100 scale. The UI's + // 1-5 stars map to 20/40/60/80/100. A bare 1-5 value with an empty tag1 is + // interface-legal but unclassifiable by an indexer — 8004scan counted such a + // feedback (feedback_count) while excluding it from every score (average_score 0). + args: [BigInt(target.agentId), BigInt((stars * 100) / 5), 0, "starred", "", target.endpoint, "", ZERO_HASH], }); landed.push(hash); setSentHashes([...landed]); diff --git a/website/test/FeedbackWidget.test.tsx b/website/test/FeedbackWidget.test.tsx index 310f201a9..2ab3c6f00 100644 --- a/website/test/FeedbackWidget.test.tsx +++ b/website/test/FeedbackWidget.test.tsx @@ -122,7 +122,9 @@ describe("FeedbackWidget", () => { abi: REPUTATION_REGISTRY_ABI, functionName: "giveFeedback", chainId: FEEDBACK.chainId, - args: [97599n, 4n, 0, "", "", "https://llm-agent.fretchen.eu", "", ZERO_HASH], + // The EIP-8004 "starred" convention: tag1 names the semantic, value on a 0-100 scale + // (4 stars -> 80, 5 stars -> 100) — see the widget's args comment. + args: [97599n, 80n, 0, "starred", "", "https://llm-agent.fretchen.eu", "", ZERO_HASH], }); }); @@ -137,13 +139,13 @@ describe("FeedbackWidget", () => { expect(mockWriteContractAsync).toHaveBeenNthCalledWith( 1, expect.objectContaining({ - args: [97599n, 5n, 0, "", "", "https://llm-agent.fretchen.eu", "", ZERO_HASH], + args: [97599n, 100n, 0, "starred", "", "https://llm-agent.fretchen.eu", "", ZERO_HASH], }), ); expect(mockWriteContractAsync).toHaveBeenNthCalledWith( 2, expect.objectContaining({ - args: [97600n, 5n, 0, "", "", "https://web-agent.fretchen.eu", "", ZERO_HASH], + args: [97600n, 100n, 0, "starred", "", "https://web-agent.fretchen.eu", "", ZERO_HASH], }), );