Public: anyone can read this; the crew can change it.

A browser that opens /mcp reads what it is and how to connect, while an MCP client's GET still gets the bare 405 #8597

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8489-a-browser-that-opens-mcp-reads-what-it-i → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/8489-a-browser-that-opens-mcp-reads-what-it-i was merged into main as d2dffad.

Review

Sign in to review this change.
3 files changed +68 −1
docs/features/mcp.md added +13
@@ -0,0 +1,13 @@
1 # Opening /mcp in a browser
2
3 `/mcp` is the forge's MCP server: a harness POSTs JSON-RPC to it with a token.
4
5 A browser that pastes the address (a `GET` whose `Accept` names `text/html`)
6 reads a short page in the forge's chrome: what this is, how to connect, and
7 links to `/llms.txt`, `/.well-known/mcp.json` and Settings › Tokens (a sign-in
8 link for a reader who is not signed in). The status stays 405 and `allow: POST`
9 stays.
10
11 An MCP client's `GET` (`Accept: text/event-stream`, `*/*`, or no `Accept`) still
12 gets the bare 405 with `allow: POST` and an empty body. The decision reads
13 `Accept` only, never the user agent. `DELETE /mcp` is unchanged.
src/http/mcp.ts +27 −1
@@ -25,6 +25,8 @@ import type { Ctx, Handler } from "./context.ts";
2525 import { running } from "../kernel/version.ts";
2626 import { bearerChallenge } from "./oauth.ts";
2727 import { body, boundedBody, json } from "./respond.ts";
28 import { page, signInHref } from "../web/layout.ts";
29 import { html } from "../web/html.ts";
2830 import type { Router } from "./router.ts";
2931 import { clientBucket } from "./peer.ts";
3032 import { proofOf } from "./report_filer.ts";
@@ -405,6 +407,30 @@ export function mcpRoutes(router: Router<Handler>): void {
405407 });
406408 // No server-to-client stream is offered: nothing to hold open, nothing
407409 // for a proxy to cut.
408 router.add("GET", "/mcp", () => NOT_HERE("POST"));
410 // A browser (Accept names text/html) is told what this is; every other
411 // Accept, an MCP client's event-stream included, gets the bare 405.
412 router.add("GET", "/mcp", (ctx) => {
413 if (!(ctx.request.headers.get("accept") ?? "").toLowerCase().includes("text/html"))
414 return NOT_HERE("POST");
415 const origin = ctx.url.origin;
416 const res = page(
417 {
418 ctx,
419 title: "MCP",
420 body: html`<div class="page-head"><h1>The forge's MCP server</h1></div>
421 <p>This is the forge's MCP server. Connect a harness to POST ${origin}/mcp with a token.</p>
422 <ul>
423 <li><a href="/llms.txt">/llms.txt</a>: what an agent can do here</li>
424 <li><a href="/.well-known/mcp.json">/.well-known/mcp.json</a>: the server's card</li>
425 <li>${ctx.auth.user === undefined
426 ? html`<a href="${signInHref(ctx)}">Sign in</a> to make a token`
427 : html`<a href="/settings/tokens">Settings › Tokens</a>`}</li>
428 </ul>`,
429 },
430 405,
431 );

The auth.user branch is untested in both directions: sign in as a user and assert the /settings/tokens link, and assert the sign-in link for anonymous.

432 res.headers.set("allow", "POST");
433 return res;
434 });
409435 router.add("DELETE", "/mcp", () => NOT_HERE("POST"));
410436 }
test/mcp.test.ts +28
@@ -146,6 +146,34 @@ describe("the forge over MCP", () => {
146146 expect(get.headers.get("allow")).toBe("POST");
147147 });
148148
149 test("a browser opening /mcp reads what it is; a client still gets the bare 405", async () => {
150 const browser = await fetch(`${base}/mcp`, { headers: { accept: "text/html" } });
151 expect(browser.status).toBe(405);
152 expect(browser.headers.get("allow")).toBe("POST");
153 expect(browser.headers.get("x-content-type-options")).toBe("nosniff");
154 const text = await browser.text();
155 expect(text).toContain("This is the forge's MCP server");
156 expect(text).toContain("/llms.txt");
157 expect(text).toContain("/.well-known/mcp.json");
158 const stream = await fetch(`${base}/mcp`, { headers: { accept: "text/event-stream" } });
159 expect(stream.status).toBe(405);
160 expect(stream.headers.get("allow")).toBe("POST");
161 expect(await stream.text()).toBe("");
162 const bare = await fetch(`${base}/mcp`);
outdated

Add the signed-in and anonymous link assertions here; also an Accept with no header at all should stay the bare 405.

163 expect(bare.status).toBe(405);
164 expect(await bare.text()).toBe("");
165 const anon = await (await fetch(`${base}/mcp`, { headers: { accept: "text/html" } })).text();
166 expect(anon).toContain('href="/signin"');
167 expect(anon).not.toContain('href="/settings/tokens">Settings');
168 const signedIn = await fetch(`${base}/mcp`, {
169 headers: { accept: "text/html", authorization: `Bearer ${rw}` },
170 });
171 expect(signedIn.status).toBe(405);
172 const page = await signedIn.text();
173 expect(page).toContain('<a href="/settings/tokens">Settings › Tokens</a>');
174 expect(page).not.toContain("to make a token");
175 });
176
149177 test("it speaks the protocol: initialize, a notification, ping, tools/list", async () => {
150178 const init = await call(rw, "initialize", {
151179 protocolVersion: "2025-03-26",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs