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

Merged opened this

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

Merged

scout/forge:scout/11448-well-known-mcp-json-well-known-forge-and → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/11448-well-known-mcp-json-well-known-forge-and was merged into main as 9a182c4.

Review

Sign in to review this change.
2 files changed +124 −15
src/web/standards.ts +34 −15
@@ -43,6 +43,7 @@ import type { Ctx } from "../http/context.ts";
4343 import { notFound } from "../http/respond.ts";
4444 import { SHARE_TARGET } from "./share_target.ts";
4545 import { SITE_LINE } from "./site_line.ts";
46 import { holds } from "./feeds.ts";
4647 import { mcpToolsSection } from "./llms_tools.ts";
4748 import { apiCatalog, cardsLine, DISCOVERY, mcpCard, offeredPlugins, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts";
4849 import { forgeDocument } from "../core/standards_index.ts";
@@ -111,30 +112,48 @@ export function securityTxt(ctx: Ctx): Response {
111112 );
112113 }
113114
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 };
outdated

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.

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 *; proxies often send W/"…". Replace with holds(sent, kept.etag) from ../feeds.ts (the same helper /changelog uses) instead of a second, stricter matcher, and cover a W/ request in the test.

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
114141 /** GET /.well-known/mcp.json — the MCP server card (a draft format; see docs/MCP.md). */
115142 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);
119146 }
120147
121148 /** GET /.well-known/api-catalog — RFC 9727: one linkset naming each API's description and docs. Anonymous. */
122149 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");
131151 }
132152
133153 /** 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");
138157 }
139158
140159 /** 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

expect(body.length).toBeGreaterThan(0) asserts nothing about this change, and the api-catalog branch turns one test into two. Split the paths into those that read the build and those that do not. Then add the missing cases: (1) a changed plugin repo changes mcp.json's etag, and (2) the same path fetched under a second origin gets its own body and etag, not the first origin's.

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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs