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

/.well-known/api-catalog answers RFC 9727: one linkset naming the OpenAPI description, the MCP server card and the docs #10974

Merged opened this

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

Merged

torque/forge:torque/10966-well-known-api-catalog-answers-rfc-9727 → main 4 commits fork 2 threads · 2 unresolved

  • Merged. torque/10966-well-known-api-catalog-answers-rfc-9727 was merged into main as 0cf97e7.

Review

Sign in to review this change.
6 files changed +126 −10
docs/MCP.md +4
@@ -380,6 +380,10 @@ and the build that answers: `commit`, the full commit the forge runs
380380 says. `version` is `dev` on a preview, so `commit` is how an agent tells two
381381 previews apart.
382382
383 `/.well-known/api-catalog` (RFC 9727, `application/linkset+json`) is the one
384 request that lists the OpenAPI description, this card and the docs; the home
385 page's `Link` header points at it. It adds no tool.
386
383387 ## Attaching with OAuth
384388
385389 Clients that cannot paste a header — the Claude apps, Grok's connectors —
src/core/mcp_card.ts +34 −3
@@ -14,11 +14,42 @@ import { repoVisibility } from "./repos.ts";
1414 /** The endpoint and the protected-resource metadata that names how to sign in. */
1515 export function mcpPointers(origin: string): { url: string; oauth: string } {
1616 return {
17 url: `${origin}/mcp`,
17 url: `${origin}${DISCOVERY.mcp}`,
1818 oauth: `${origin}/.well-known/oauth-protected-resource`,
1919 };
2020 }
2121
22 /** The paths the discovery documents name each other by; the card, the API catalog and /llms.txt read them, so they cannot drift. */
23 export const DISCOVERY = {
24 openapi: "/contract/openapi.json",
25 docs: "/llms.txt",
26 card: "/.well-known/mcp.json",
27 catalog: "/.well-known/api-catalog",
28 mcp: "/mcp",
29 api: "/api/v1",
30 } as const;
31
32 /** The `Link` header value that points at the catalog (RFC 9727 §3). */
33 export const API_CATALOG_LINK = `<${DISCOVERY.catalog}>; rel="api-catalog"`;
34
35 /** RFC 9727 API catalog: an RFC 9264 linkset, one anchor per API. */
36 export function apiCatalog(origin: string) {
37 return {
38 linkset: [
39 {
40 anchor: `${origin}${DISCOVERY.api}`,
41 "service-desc": [{ href: `${origin}${DISCOVERY.openapi}`, type: "application/json" }],
42 "service-doc": [{ href: `${origin}${DISCOVERY.docs}`, type: "text/plain" }],
43 },
44 {
45 anchor: mcpPointers(origin).url,
46 "service-desc": [{ href: `${origin}${DISCOVERY.card}`, type: "application/json" }],
47 "service-doc": [{ href: `${origin}${DISCOVERY.docs}`, type: "text/plain" }],
48 },
49 ],
50 };
51 }
52
2253 /**
2354 * The MCP tools a stranger may call with no token: the gate in
2455 * `http/mcp.ts` answers these and refuses the rest with 401. The card and
@@ -102,8 +133,8 @@ export function mcpCard(origin: string, repo: string | undefined, now: Running =
102133 endpoint: url,
103134 transport: "streamable-http",
104135 protectedResourceMetadata: oauth,
105 documentation: `${origin}/llms.txt`,
106 openapi: `${origin}/contract/openapi.json`,
136 documentation: `${origin}${DISCOVERY.docs}`,
137 openapi: `${origin}${DISCOVERY.openapi}`,
107138 tokenFreeTools: [...TOKEN_FREE_TOOLS],
108139 plugins: (repo === undefined ? [] : HARNESS_PLUGINS).map((p) => ({
109140 harness: p.harness,
src/http/server.ts +2
@@ -175,6 +175,7 @@ import {
175175 releasesJson,
176176 robotsTxt,
177177 securityTxt,
178 apiCatalogJson,
178179 mcpJson,
179180 forgeJson,
180181 sitemapXml,
@@ -750,6 +751,7 @@ export function buildRouter(): Router<Handler> {
750751 // What the open web expects of a site, from the forge's own facts.
751752 router.add("GET", "/.well-known/security.txt", securityTxt);
752753 router.add("GET", "/.well-known/mcp.json", mcpJson);
754 router.add("GET", "/.well-known/api-catalog", apiCatalogJson);
753755 router.add("GET", "/.well-known/forge", forgeJson);
754756 router.add("GET", "/llms.txt", llmsTxt);
755757 // The same words as a page, for an assistant whose reader takes only HTML.
src/web/pages/site.ts +5 −2
@@ -26,6 +26,7 @@ import { createSession } from "../../core/sessions.ts";
2626 import { hasAnyUser, verifyPasswordThrottled, type User } from "../../core/users.ts";
2727 import { isThemeName } from "../../design/themes.ts";
2828 import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts";
29 import { API_CATALOG_LINK } from "../../core/mcp_card.ts";
2930 import { studioDoor, studioHome } from "../studio/home.ts";
3031 import { parentIssuerOf } from "../../core/federation.ts";
3132 import { activeEdgeToken, edgeIdentity, edgeLoginUrl, edgeStrangerLogin } from "../../http/edge.ts";
@@ -210,8 +211,10 @@ export function terminalCard(ctx: Ctx): Html {
210211 // the feed (`studioHome`, #599), whatever the mode (#625).
211212 export async function home(ctx: Ctx): Promise<Response> {
212213 const user = ctx.auth.user;
213 if (user === undefined) return studioDoor(ctx);
214 return await studioHome(ctx, user);
214 const res = user === undefined ? await studioDoor(ctx) : await studioHome(ctx, user);
215 // RFC 9727 §3: where an agent that knows only the origin finds the API catalog.
216 res.headers.append("link", API_CATALOG_LINK);
outdated

An empty catch that swallows an error is dead code and hides failures: the response here is built by studioDoor/studioHome, whose headers are mutable (the preview shows the Link appended). Remove the try/catch and append directly; if some path really returns immutable headers, clone the Response with new headers explicitly instead of swallowing.

217 return res;
215218 }
216219
217220 // ---- authentication ----
src/web/standards.ts +20 −5
@@ -6,6 +6,8 @@
66 * /.well-known/mcp.json the MCP server card: endpoint, sign-in, the
77 * tool that needs no token (a draft format:
88 * only fields we keep true)
9 * /.well-known/api-catalog the API catalog (RFC 9727): a linkset naming the
10 * OpenAPI description, the MCP card and the docs
911 * /.well-known/forge the standards this forge implements, with versions
1012 * /.well-known/change-password where a password manager sends someone
1113 * /robots.txt what a crawler may index, and the sitemap
@@ -42,7 +44,7 @@ import { notFound } from "../http/respond.ts";
4244 import { SHARE_TARGET } from "./share_target.ts";
4345 import { SITE_LINE } from "./site_line.ts";
4446 import { mcpToolsSection } from "./llms_tools.ts";
45 import { HARNESS_PLUGINS, mcpCard, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts";
47 import { apiCatalog, DISCOVERY, HARNESS_PLUGINS, mcpCard, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts";
4648 import { forgeDocument } from "../core/standards_index.ts";
4749 import { running } from "../kernel/version.ts";
4850 import { timePhase, timePhaseAsync } from "../core/route_timing.ts";
@@ -115,6 +117,18 @@ export function mcpJson(ctx: Ctx): Response {
115117 return res;
116118 }
117119
120 /** GET /.well-known/api-catalog — RFC 9727: one linkset naming each API's description and docs. Anonymous. */
121 export function apiCatalogJson(ctx: Ctx): Response {
122 const res = new Response(JSON.stringify(apiCatalog(ctx.url.origin)), {
123 headers: {
124 "content-type": 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"',
125 "cache-control": "public, max-age=300",
126 "access-control-allow-origin": "*",
127 },
128 });
129 return res;
130 }
131
118132 /** GET /.well-known/forge — the standards this forge implements, by id and version (docs/standards/). */
119133 export function forgeJson(): Response {
120134 const res = text(JSON.stringify(forgeDocument(running().version)), "application/json");
@@ -420,7 +434,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
420434 "",
421435 `- Page: ${o}/{owner}/{name}/report`,
422436 `- API: POST ${o}/api/v1/repos/{owner}/{name}/reports with JSON {"title": "...", "body": "...", "page": "https://..."} -> 202 {"id", "state": "pending"}`,
423 `- MCP: POST ${o}/mcp, method tools/call, name "report", arguments {"repo": "owner/name", "title", "body", "page"}; this one tool needs no token`,
437 `- MCP: POST ${mcp.url}, method tools/call, name "report", arguments {"repo": "owner/name", "title", "body", "page"}; this one tool needs no token`,
424438 `- Plain text: a title of 1-${TITLE_MAX} characters, a body of at most ${BODY_MAX}; a few a minute from one source. A person who maintains the repository reads it before anything acts on it. A private repository answers not_found.`,
425439 "",
426440 "## Offer a change",
@@ -456,9 +470,10 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
456470 "",
457471 "## For agents with an account",
458472 "",
459 `- MCP: ${mcp.url} (OAuth discovery: ${mcp.oauth}; server card, a draft format: ${o}/.well-known/mcp.json)`,
460 `- API: ${o}/api/v1 (the root lists the other URLs), Authorization: Bearer <token>`,
461 `- OpenAPI: GET ${o}/api/v1/openapi.json (redirects to ${o}/contract/openapi.json)`,
473 `- MCP: ${mcp.url} (OAuth discovery: ${mcp.oauth}; server card, a draft format: ${o}${DISCOVERY.card})`,
474 `- API: ${o}${DISCOVERY.api} (the root lists the other URLs), Authorization: Bearer <token>`,
475 `- API catalog (RFC 9727, one linkset of the above): ${o}${DISCOVERY.catalog}`,

Only this line reads DISCOVERY; the neighbouring llms.txt lines still hardcode /.well-known/mcp.json and /api/v1/openapi.json, so those paths can still drift from the catalog (issue box 3: built from the same constants). Use DISCOVERY.card, DISCOVERY.api etc. for them and add a check in the test that llms.txt names every DISCOVERY path.

476 `- OpenAPI: GET ${o}${DISCOVERY.api}/openapi.json (redirects to ${o}${DISCOVERY.openapi})`,
462477 "",
463478 ...mcpToolsSection(o, signedIn),
464479 "## Public repositories",
test/api-catalog.test.ts added +61
@@ -0,0 +1,61 @@
1 /** /.well-known/api-catalog (RFC 9727): one linkset, every href real. */
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 { createApp, type App } from "../src/app.ts";
7 import { serve, type Servers } from "../src/http/server.ts";
8 import { DISCOVERY } from "../src/core/mcp_card.ts";
9
10 let dataDir: string;
11 let app: App;
12 let servers: Servers;
13 let base: string;
14
15 beforeAll(async () => {
16 dataDir = await mkdtemp(join(tmpdir(), "forge-catalog-"));
17 app = createApp({ dataDir, port: 0 });
18 servers = await serve(app);
19 base = servers.url;
20 });
21 afterAll(async () => {
22 await servers.stop?.();
23 await rm(dataDir, { recursive: true, force: true });
24 });
25
26 test("the catalog is a cacheable linkset naming each API", async () => {
27 const res = await fetch(`${base}/.well-known/api-catalog`);
28 expect(res.status).toBe(200);
29 expect(res.headers.get("content-type")).toStartWith("application/linkset+json");
30 expect(res.headers.get("cache-control")).toBe("public, max-age=300");
31 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
32 const { linkset } = (await res.json()) as { linkset: Record<string, unknown>[] };
33 expect(linkset.length).toBe(2);
34 const anchors = linkset.map((l) => new URL(String(l["anchor"])).pathname);
35 expect(anchors).toEqual(["/api/v1", "/mcp"]);
36 const descs = linkset.map((l) => new URL((l["service-desc"] as { href: string }[])[0]!.href).pathname);
37 expect(descs).toEqual(["/contract/openapi.json", "/.well-known/mcp.json"]);
38 });
39
40 test("every href in it answers 200 without a redirect", async () => {
41 const { linkset } = (await (await fetch(`${base}/.well-known/api-catalog`)).json()) as {
42 linkset: Record<string, { href: string }[] | string>[];
43 };
44 const hrefs = new Set<string>();
45 for (const l of linkset)
46 for (const [k, v] of Object.entries(l)) if (k !== "anchor") for (const h of v as { href: string }[]) hrefs.add(h.href);
47 expect(hrefs.size).toBeGreaterThanOrEqual(3);
48 for (const href of hrefs) {
49 const r = await fetch(new URL(new URL(href).pathname, base), { redirect: "manual" });
50 expect(r.status).toBe(200);
51 }
52 });
53
54 test("home points at the catalog and /llms.txt lists it", async () => {
55 const home = await fetch(`${base}/`);
56 expect(home.headers.get("link") ?? "").toContain('</.well-known/api-catalog>; rel="api-catalog"');
57 const llms = await (await fetch(`${base}/llms.txt`)).text();
58 expect(llms).toContain("/.well-known/api-catalog");
59 // Every discovery path but its own is named in /llms.txt, from the same constants.
60 for (const [name, path] of Object.entries(DISCOVERY)) if (name !== "docs") expect(llms).toContain(path);
61 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs