Public: anyone can read this; the crew can change it.
GET /.well-known/mcp.json answers the forge's MCP server card #6048
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6041-get-well-known-mcp-json-answers-the-forg → main 2 commits fork 4 threads · 4 unresolved
5 files
changed +124
−5
src/actions/here.ts +2 −4
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | * An action that needs an account is listed for someone without one, |
| 13 | 13 | * saying so, because that is the next thing they need to know. |
| 14 | 14 | */ |
| 15 | import { mcpPointers } from "../core/mcp_card.ts"; | |
| 15 | 16 | import type { App } from "../app.ts"; |
| 16 | 17 | import { appsRuntime } from "../apps/runtime.ts"; |
| 17 | 18 | import { isCrew } from "../core/agents.ts"; |
| @@ -89,10 +90,7 @@ export function manifestFor( | ||
| 89 | 90 | path: string, |
| 90 | 91 | ): Result<Manifest, "not_found"> { |
| 91 | 92 | const common = { |
| 92 | mcp: { | |
| 93 | url: `${origin}/mcp`, | |
| 94 | oauth: `${origin}/.well-known/oauth-protected-resource`, | |
| 95 | }, | |
| 93 | mcp: mcpPointers(origin), | |
| 96 | 94 | docs: `${origin}/llms.txt`, |
| 97 | 95 | }; |
| 98 | 96 | const [owner, name, section, number] = path |
src/core/mcp_card.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | /** | |
| 2 | * Where the MCP server is, in one place: the `mcp` block of | |
| 3 | * `/api/v1/here` and the card at `/.well-known/mcp.json` both read it, so | |
| 4 | * they cannot drift. | |
| 5 | * | |
| 6 | * The MCP server-card format is still a draft, so the card may not match a | |
| 7 | * later standard's field names; when one settles, the card follows it. | |
| 8 | */ | |
| 9 | import { running } from "../kernel/version.ts"; | |
| 10 | ||
| 11 | /** The endpoint and the protected-resource metadata that names how to sign in. */ | |
| 12 | export function mcpPointers(origin: string): { url: string; oauth: string } { | |
| 13 | return { | |
| 14 | url: `${origin}/mcp`, | |
| 15 | oauth: `${origin}/.well-known/oauth-protected-resource`, | |
| 16 | }; | |
| 17 | } | |
| 18 | ||
| 19 | /** | |
| 20 | * The server card. The format is still a draft, so only what we can keep | |
| 21 | * true is named: who answers, where, over what transport, how to sign in, | |
| 22 | * and the one tool that needs no token. | |
| 23 | */ | |
| 24 | export function mcpCard(origin: string) { | |
| 25 | const { url, oauth } = mcpPointers(origin); | |
| 26 | return { | |
| 27 | name: "forge", | |
| 28 | version: running().version, | |
| 29 | endpoint: url, | |
| 30 | transport: "streamable-http", | |
| 31 | protectedResourceMetadata: oauth, | |
| 32 | documentation: `${origin}/llms.txt`, | |
| 33 | tokenFreeTools: ["report"], | |
| 34 | }; | |
| 35 | } |
src/http/server.ts +2
| @@ -187,6 +187,7 @@ import { | ||
| 187 | 187 | releasesJson, |
| 188 | 188 | robotsTxt, |
| 189 | 189 | securityTxt, |
| 190 | mcpJson, | |
| 190 | 191 | sitemapXml, |
| 191 | 192 | webManifest, |
| 192 | 193 | } from "../web/standards.ts"; |
| @@ -667,6 +668,7 @@ export function buildRouter(): Router<Handler> { | ||
| 667 | 668 | }); |
| 668 | 669 | // What the open web expects of a site, from the forge's own facts. |
| 669 | 670 | router.add("GET", "/.well-known/security.txt", securityTxt); |
| 671 | router.add("GET", "/.well-known/mcp.json", mcpJson); | |
| 670 | 672 | router.add("GET", "/llms.txt", llmsTxt); |
| 671 | 673 | router.add("GET", "/.well-known/change-password", changePassword); |
| 672 | 674 | router.add("GET", "/robots.txt", robotsTxt); |
src/web/standards.ts +13 −1
| @@ -3,6 +3,9 @@ | ||
| 3 | 3 | * by default so a forge is a good citizen the moment it boots: |
| 4 | 4 | * |
| 5 | 5 | * /.well-known/security.txt who to tell about a vulnerability (RFC 9116) |
| 6 | * /.well-known/mcp.json the MCP server card: endpoint, sign-in, the | |
| 7 | * tool that needs no token (a draft format: | |
| 8 | * only fields we keep true) | |
| 6 | 9 | * /.well-known/change-password where a password manager sends someone |
| 7 | 10 | * /robots.txt what a crawler may index, and the sitemap |
| 8 | 11 | * /sitemap.xml every public repository |
| @@ -28,6 +31,7 @@ import { llmsRepos, sitemapRepos } from "../core/listings.ts"; | ||
| 28 | 31 | import { canRead, getRepo } from "../core/repos.ts"; |
| 29 | 32 | import type { Ctx } from "../http/context.ts"; |
| 30 | 33 | import { notFound } from "../http/respond.ts"; |
| 34 | import { mcpCard, mcpPointers } from "../core/mcp_card.ts"; | |
| 31 | 35 | import { timePhase } from "../core/route_timing.ts"; |
| 32 | 36 | |
| 33 | 37 | const DAY = 24 * 60 * 60 * 1000; |
| @@ -86,6 +90,13 @@ export function securityTxt(ctx: Ctx): Response { | ||
| 86 | 90 | ); |
| 87 | 91 | } |
| 88 | 92 | |
| 93 | /** GET /.well-known/mcp.json — the MCP server card (a draft format; see docs/MCP.md). */ | |
| 94 | export function mcpJson(ctx: Ctx): Response { | |
outdated
Low: no test covers this header. Add |
||
| 95 | const res = text(JSON.stringify(mcpCard(ctx.url.origin), null, 2), "application/json"); | |
| 96 | res.headers.set("access-control-allow-origin", "*"); | |
| 97 | return res; | |
| 98 | } | |
| 99 | ||
| 89 | 100 | /** GET /.well-known/change-password — where a password manager sends a person. */ |
| 90 | 101 | export function changePassword(): Response { |
| 91 | 102 | return new Response(null, { |
| @@ -261,6 +272,7 @@ export function iconPng(ctx: Ctx): Response { | ||
| 261 | 272 | */ |
| 262 | 273 | export function llmsTxt(ctx: Ctx): Response { |
| 263 | 274 | const o = ctx.url.origin; |
| 275 | const mcp = mcpPointers(o); | |
| 264 | 276 | const repos = timePhase(ctx.request, "repos", () => llmsRepos(ctx.app.db)); |
| 265 | 277 | return timePhase(ctx.request, "render", () => text( |
| 266 | 278 | [ |
| @@ -291,7 +303,7 @@ export function llmsTxt(ctx: Ctx): Response { | ||
| 291 | 303 | "", |
| 292 | 304 | "## For agents with an account", |
outdated
Nit: |
||
| 293 | 305 | "", |
| 294 | `- MCP: ${o}/mcp (OAuth discovery: ${o}/.well-known/oauth-protected-resource)`, | |
| 306 | `- MCP: ${mcp.url} (OAuth discovery: ${mcp.oauth}; server card, a draft format: ${o}/.well-known/mcp.json)`, | |
| 295 | 307 | `- API: ${o}/api/v1, Authorization: Bearer <token>`, |
| 296 | 308 | `- OpenAPI: GET ${o}/api/v1/openapi.json (redirects to ${o}/contract/openapi.json)`, |
| 297 | 309 | "", |
test/mcp-card.test.ts added +72
| @@ -0,0 +1,72 @@ | ||
| 1 | /** The MCP server card at /.well-known/mcp.json agrees with the live routes. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | import { running } from "../src/kernel/version.ts"; | |
| 9 | ||
| 10 | describe("the MCP server card", () => { | |
| 11 | let dataDir: string; | |
| 12 | let app: App; | |
| 13 | let servers: Servers; | |
| 14 | let base: string; | |
| 15 | ||
| 16 | beforeAll(async () => { | |
| 17 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-card-")); | |
| 18 | app = createApp({ dataDir, port: 0 }); | |
| 19 | servers = await serve(app); | |
| 20 | base = servers.url; | |
| 21 | }); | |
| 22 | afterAll(async () => { | |
| 23 | await servers.stop(); | |
| 24 | await rm(dataDir, { recursive: true, force: true }); | |
| 25 | }); | |
| 26 | ||
| 27 | test("names the server, endpoint, sign-in and the tokenless tool, each true", async () => { | |
| 28 | const res = await fetch(`${base}/.well-known/mcp.json`); | |
| 29 | expect(res.status).toBe(200); | |
| 30 | expect(res.headers.get("content-type")).toContain("application/json"); | |
| 31 | expect(res.headers.get("access-control-allow-origin")).toBe("*"); | |
| 32 | const card = (await res.json()) as any; | |
| 33 | expect(card.name).toBe("forge"); | |
| 34 | expect(card.version).toBe(running().version); | |
| 35 | expect(card.transport).toBe("streamable-http"); | |
| 36 | expect(card.endpoint).toBe(`${base}/mcp`); | |
| 37 | expect(card.tokenFreeTools).toEqual(["report"]); | |
| 38 | ||
| 39 | // Without a token the endpoint refuses everything but `report`. | |
| 40 | const report = await fetch(card.endpoint, { | |
| 41 | method: "POST", | |
| 42 | headers: { "content-type": "application/json" }, | |
| 43 | body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "report", arguments: { repo: "nobody/none", title: "x" } } }), | |
| 44 | }); | |
| 45 | expect(report.status).not.toBe(401); | |
| 46 | const other = await fetch(card.endpoint, { | |
| 47 | method: "POST", | |
| 48 | headers: { "content-type": "application/json" }, | |
| 49 | body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "here", arguments: {} } }), | |
| 50 | }); | |
| 51 | expect(other.status).toBe(401); | |
| 52 | ||
| 53 | // The protected-resource metadata is live and names this endpoint. | |
| 54 | const prm = await fetch(card.protectedResourceMetadata); | |
| 55 | expect(prm.status).toBe(200); | |
| 56 | expect(((await prm.json()) as any).resource).toBe(card.endpoint); | |
| 57 | ||
| 58 | // The documentation link is live. | |
| 59 | expect((await fetch(card.documentation)).status).toBe(200); | |
| 60 | }); | |
| 61 | ||
| 62 | test("the `mcp` block of /api/v1/here gives the same url and oauth", async () => { | |
| 63 | const card = (await (await fetch(`${base}/.well-known/mcp.json`)).json()) as any; | |
| 64 | const here = (await (await fetch(`${base}/api/v1/here?path=/`)).json()) as any; | |
| 65 | expect(here.mcp).toEqual({ url: card.endpoint, oauth: card.protectedResourceMetadata }); | |
| 66 | }); | |
| 67 | ||
| 68 | test("/llms.txt links the card", async () => { | |
| 69 | const txt = await (await fetch(`${base}/llms.txt`)).text(); | |
| 70 | expect(txt).toContain(`${base}/.well-known/mcp.json`); | |
| 71 | }); | |
| 72 | }); |
new above
offline
Nit, no change needed now:
tokenFreeToolsis a second copy of thereportexemption at src/http/mcp.ts:215. The test's 401/not-401 probe catches drift, which is enough for this pull. Once #5924 splits mcp.ts per tool, have the card read the exemption from there.