Public: anyone can read this; the crew can change it.
/.well-known/mcp.json, /.well-known/forge and /.well-known/api-catalog answer 304 to a matching If-None-Match, as /llms.txt does #11452
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11448-well-known-mcp-json-well-known-forge-and → main 4 commits fork 3 threads · 3 unresolved
src/web/standards.ts +34 −15
| @@ -43,6 +43,7 @@ import type { Ctx } from "../http/context.ts"; | ||
| 43 | 43 | import { notFound } from "../http/respond.ts"; |
| 44 | 44 | import { SHARE_TARGET } from "./share_target.ts"; |
| 45 | 45 | import { SITE_LINE } from "./site_line.ts"; |
| 46 | import { holds } from "./feeds.ts"; | |
| 46 | 47 | import { mcpToolsSection } from "./llms_tools.ts"; |
| 47 | 48 | import { apiCatalog, cardsLine, DISCOVERY, mcpCard, offeredPlugins, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts"; |
| 48 | 49 | import { forgeDocument } from "../core/standards_index.ts"; |
| @@ -111,30 +112,48 @@ export function securityTxt(ctx: Ctx): Response { | ||
| 111 | 112 | ); |
| 112 | 113 | } |
| 113 | 114 | |
| 115 | /** A discovery document and its ETag, per kind and origin, made again only when its inputs read differently. */ | |
| 116 | const discoveryBodies = new Map<string, { inputs: string; body: string; etag: string }>(); | |
| 117 | ||
| 118 | /** | |
| 119 | * Serve a public discovery document with an ETag: `inputs` names what the | |
| 120 | * body reads (the plugin repo offered, the running build), so the body is | |
| 121 | * made once per origin until that changes. A matching If-None-Match is a 304. | |
| 122 | */ | |
| 123 | function discovery(ctx: Ctx, kind: string, inputs: string, make: () => unknown, type: string, cache: string, pretty = false): Response { | |
| 124 | const key = `${kind} ${ctx.url.origin}`; | |
| 125 | let kept = discoveryBodies.get(key); | |
| 126 | if (kept === undefined || kept.inputs !== inputs) { | |
| 127 | const body = JSON.stringify(make(), null, pretty ? 2 : undefined); | |
| 128 | const etag = `"${new Bun.CryptoHasher("sha256").update(body).digest("hex").slice(0, 32)}"`; | |
| 129 | kept = { inputs, body, etag }; | |
| 130 | // Origins come from the Host header: keep a handful, not one per guess. | |
| 131 | // The oldest goes first (a Map keeps insertion order), so junk hosts never flush the real ones at once. | |
| 132 | discoveryBodies.delete(key); | |
| 133 | if (discoveryBodies.size >= 48) discoveryBodies.delete(discoveryBodies.keys().next().value!); | |
| 134 | discoveryBodies.set(key, kept); | |
outdated
Exact string match only. If-None-Match uses weak comparison and may be |
||
| 135 | } | |
| 136 | const headers = { etag: kept.etag, "cache-control": cache, "access-control-allow-origin": "*" }; | |
| 137 | if (holds(ctx.request.headers.get("if-none-match"), kept.etag)) return new Response(null, { status: 304, headers }); | |
| 138 | return new Response(kept.body, { headers: { ...headers, "content-type": type } }); | |
| 139 | } | |
| 140 | ||
| 114 | 141 | /** GET /.well-known/mcp.json — the MCP server card (a draft format; see docs/MCP.md). */ |
| 115 | 142 | export function mcpJson(ctx: Ctx): Response { |
| 116 | const res = text(JSON.stringify(mcpCard(ctx.url.origin, offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo, UPSTREAM_REPO)), null, 2), "application/json"); | |
| 117 | res.headers.set("access-control-allow-origin", "*"); | |
| 118 | return res; | |
| 143 | const repo = offeredPluginRepo(ctx.app.db, ctx.app.config.forgeRepo, UPSTREAM_REPO); | |
| 144 | const now = running(); | |
| 145 | return discovery(ctx, "mcp", JSON.stringify([repo, now.version, now.commit]), () => mcpCard(ctx.url.origin, repo, now), "application/json", "public, max-age=3600", true); | |
| 119 | 146 | } |
| 120 | 147 | |
| 121 | 148 | /** GET /.well-known/api-catalog — RFC 9727: one linkset naming each API's description and docs. Anonymous. */ |
| 122 | 149 | export function apiCatalogJson(ctx: Ctx): Response { |
| 123 | const res = new Response(JSON.stringify(apiCatalog(ctx.url.origin)), { | |
| 124 | headers: { | |
| 125 | "content-type": 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"', | |
| 126 | "cache-control": "public, max-age=300", | |
| 127 | "access-control-allow-origin": "*", | |
| 128 | }, | |
| 129 | }); | |
| 130 | return res; | |
| 150 | return discovery(ctx, "api-catalog", "", () => apiCatalog(ctx.url.origin), 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"', "public, max-age=300"); | |
| 131 | 151 | } |
| 132 | 152 | |
| 133 | 153 | /** GET /.well-known/forge — the standards this forge implements, by id and version (docs/standards/). */ |
| 134 | export function forgeJson(): Response { | |
| 135 | const res = text(JSON.stringify(forgeDocument(running().version)), "application/json"); | |
| 136 | res.headers.set("access-control-allow-origin", "*"); | |
| 137 | return res; | |
| 154 | export function forgeJson(ctx: Ctx): Response { | |
| 155 | const version = running().version; | |
| 156 | return discovery(ctx, "forge", version, () => forgeDocument(version), "application/json", "public, max-age=3600"); | |
| 138 | 157 | } |
| 139 | 158 | |
| 140 | 159 | /** GET /.well-known/change-password — where a password manager sends a person. */ |
test/standards-etag.test.ts added +90
| @@ -0,0 +1,90 @@ | ||
| 1 | /** The three discovery documents carry an ETag per origin and answer 304 to a match. */ | |
| 2 | import { afterAll, beforeAll, 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 { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createUser } from "../src/core/users.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | ||
| 11 | let dataDir: string; | |
| 12 | let app: App; | |
| 13 | let servers: Servers; | |
| 14 | const was = process.env["FORGE_VERSION"]; | |
| 15 | ||
| 16 | beforeAll(async () => { | |
| 17 | dataDir = await mkdtemp(join(tmpdir(), "forge-standards-etag-")); | |
| 18 | app = createApp({ dataDir, port: 0 }); | |
| 19 | servers = await serve(app); | |
| 20 | }); | |
| 21 | ||
| 22 | afterAll(async () => { | |
| 23 | if (was === undefined) delete process.env["FORGE_VERSION"]; | |
| 24 | else process.env["FORGE_VERSION"] = was; | |
| 25 | servers.stop(); | |
| 26 | app.close(); | |
| 27 | await rm(dataDir, { recursive: true, force: true }); | |
| 28 | }); | |
| 29 | ||
| 30 | const BUILD_PATHS = ["/.well-known/mcp.json", "/.well-known/forge"]; | |
| 31 | ||
| 32 | for (const path of ["/.well-known/api-catalog", ...BUILD_PATHS]) { | |
| 33 | test(`${path}: etag, cache-control, cors, 304 on a match`, async () => { | |
| 34 | const url = `${servers.url}${path}`; | |
| 35 | const first = await fetch(url); | |
| 36 | const etag = first.headers.get("etag"); | |
| 37 | expect(first.status).toBe(200); | |
| 38 | expect(etag).toMatch(/^"[0-9a-f]{32}"$/); | |
| 39 | expect(first.headers.get("cache-control")).toContain("max-age"); | |
| 40 | expect(first.headers.get("access-control-allow-origin")).toBe("*"); | |
| 41 | await first.text(); | |
| 42 | const again = await fetch(url, { headers: { "if-none-match": etag! } }); | |
| 43 | expect(again.status).toBe(304); | |
| 44 | expect(again.headers.get("etag")).toBe(etag); | |
| 45 | expect(again.headers.get("access-control-allow-origin")).toBe("*"); | |
| 46 | expect(await again.text()).toBe(""); | |
| 47 | const weak = await fetch(url, { headers: { "if-none-match": `W/${etag}` } }); | |
| 48 | expect(weak.status).toBe(304); | |
outdated
|
||
| 49 | expect(weak.headers.get("etag")).toBe(etag); | |
| 50 | }); | |
| 51 | } | |
| 52 | ||
| 53 | for (const path of BUILD_PATHS) { | |
| 54 | test(`${path}: a new build changes the etag`, async () => { | |
| 55 | process.env["FORGE_VERSION"] = "1.0.0"; | |
| 56 | const url = `${servers.url}${path}`; | |
| 57 | const etag = (await fetch(url)).headers.get("etag")!; | |
| 58 | process.env["FORGE_VERSION"] = "2.0.0"; | |
| 59 | const changed = await fetch(url, { headers: { "if-none-match": etag } }); | |
| 60 | expect(changed.status).toBe(200); | |
| 61 | expect(changed.headers.get("etag")).not.toBe(etag); | |
| 62 | }); | |
| 63 | } | |
| 64 | ||
| 65 | test("mcp.json: a changed plugin repo changes the etag", async () => { | |
| 66 | const url = `${servers.url}/.well-known/mcp.json`; | |
| 67 | const before = (await fetch(url)).headers.get("etag")!; | |
| 68 | const alice = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 69 | if (!alice.ok) throw new Error(alice.error); | |
| 70 | const r = await createRepo(app, alice.value, { name: "forge", visibility: "public" }); | |
| 71 | if (!r.ok) throw new Error(String(r.error)); | |
| 72 | (app.config as { forgeRepo?: string }).forgeRepo = "alice/forge"; | |
| 73 | const after = await fetch(url, { headers: { "if-none-match": before } }); | |
| 74 | expect(after.status).toBe(200); | |
| 75 | expect(after.headers.get("etag")).not.toBe(before); | |
| 76 | }); | |
| 77 | ||
| 78 | // /.well-known/forge names no origin, so its body is the same everywhere. | |
| 79 | for (const path of ["/.well-known/api-catalog", "/.well-known/mcp.json"]) { | |
| 80 | test(`${path}: each origin gets its own body and etag`, async () => { | |
| 81 | const url = `${servers.url}${path}`; | |
| 82 | const a = await fetch(url); | |
| 83 | const other = await fetch(url, { headers: { host: "other.example" } }); | |
| 84 | const [bodyA, bodyOther] = [await a.text(), await other.text()]; | |
| 85 | expect(bodyOther).toContain("http://other.example"); | |
| 86 | expect(bodyOther).not.toContain(new URL(servers.url).host); | |
| 87 | expect(other.headers.get("etag")).not.toBe(a.headers.get("etag")); | |
| 88 | expect(bodyA).not.toBe(bodyOther); | |
| 89 | }); | |
| 90 | } |
Minor:
clear()at 48 origins means a client sending 48 junk Host values flushes every real origin's entry, so each is rebuilt on the next request. Rebuilding is cheap so this is not blocking; deleting the oldest key (Map keeps insertion order) would avoid the flush.