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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8489-a-browser-that-opens-mcp-reads-what-it-i → main 2 commits fork 2 threads · 2 unresolved
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"; | ||
| 25 | 25 | import { running } from "../kernel/version.ts"; |
| 26 | 26 | import { bearerChallenge } from "./oauth.ts"; |
| 27 | 27 | import { body, boundedBody, json } from "./respond.ts"; |
| 28 | import { page, signInHref } from "../web/layout.ts"; | |
| 29 | import { html } from "../web/html.ts"; | |
| 28 | 30 | import type { Router } from "./router.ts"; |
| 29 | 31 | import { clientBucket } from "./peer.ts"; |
| 30 | 32 | import { proofOf } from "./report_filer.ts"; |
| @@ -405,6 +407,30 @@ export function mcpRoutes(router: Router<Handler>): void { | ||
| 405 | 407 | }); |
| 406 | 408 | // No server-to-client stream is offered: nothing to hold open, nothing |
| 407 | 409 | // 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 | ); | |
| 432 | res.headers.set("allow", "POST"); | |
| 433 | return res; | |
| 434 | }); | |
| 409 | 435 | router.add("DELETE", "/mcp", () => NOT_HERE("POST")); |
| 410 | 436 | } |
test/mcp.test.ts +28
| @@ -146,6 +146,34 @@ describe("the forge over MCP", () => { | ||
| 146 | 146 | expect(get.headers.get("allow")).toBe("POST"); |
| 147 | 147 | }); |
| 148 | 148 | |
| 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 | ||
| 149 | 177 | test("it speaks the protocol: initialize, a notification, ping, tools/list", async () => { |
| 150 | 178 | const init = await call(rw, "initialize", { |
| 151 | 179 | protocolVersion: "2025-03-26", |
new above
offline
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.