Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions bunny_js/src/mcp_routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -200,6 +206,18 @@ export async function handleMCPRequest(request: Request): Promise<Response> {
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:
Expand Down
17 changes: 16 additions & 1 deletion bunny_js/test/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
7 changes: 6 additions & 1 deletion website/components/FeedbackWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
8 changes: 5 additions & 3 deletions website/test/FeedbackWidget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
});
});

Expand All @@ -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],
}),
);

Expand Down
Loading