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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10966-well-known-api-catalog-answers-rfc-9727 → main 4 commits fork 2 threads · 2 unresolved
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 | ||
| 380 | 380 | says. `version` is `dev` on a preview, so `commit` is how an agent tells two |
| 381 | 381 | previews apart. |
| 382 | 382 | |
| 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 | ||
| 383 | 387 | ## Attaching with OAuth |
| 384 | 388 | |
| 385 | 389 | 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"; | ||
| 14 | 14 | /** The endpoint and the protected-resource metadata that names how to sign in. */ |
| 15 | 15 | export function mcpPointers(origin: string): { url: string; oauth: string } { |
| 16 | 16 | return { |
| 17 | url: `${origin}/mcp`, | |
| 17 | url: `${origin}${DISCOVERY.mcp}`, | |
| 18 | 18 | oauth: `${origin}/.well-known/oauth-protected-resource`, |
| 19 | 19 | }; |
| 20 | 20 | } |
| 21 | 21 | |
| 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 | ||
| 22 | 53 | /** |
| 23 | 54 | * The MCP tools a stranger may call with no token: the gate in |
| 24 | 55 | * `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 = | ||
| 102 | 133 | endpoint: url, |
| 103 | 134 | transport: "streamable-http", |
| 104 | 135 | protectedResourceMetadata: oauth, |
| 105 | documentation: `${origin}/llms.txt`, | |
| 106 | openapi: `${origin}/contract/openapi.json`, | |
| 136 | documentation: `${origin}${DISCOVERY.docs}`, | |
| 137 | openapi: `${origin}${DISCOVERY.openapi}`, | |
| 107 | 138 | tokenFreeTools: [...TOKEN_FREE_TOOLS], |
| 108 | 139 | plugins: (repo === undefined ? [] : HARNESS_PLUGINS).map((p) => ({ |
| 109 | 140 | harness: p.harness, |
src/http/server.ts +2
| @@ -175,6 +175,7 @@ import { | ||
| 175 | 175 | releasesJson, |
| 176 | 176 | robotsTxt, |
| 177 | 177 | securityTxt, |
| 178 | apiCatalogJson, | |
| 178 | 179 | mcpJson, |
| 179 | 180 | forgeJson, |
| 180 | 181 | sitemapXml, |
| @@ -750,6 +751,7 @@ export function buildRouter(): Router<Handler> { | ||
| 750 | 751 | // What the open web expects of a site, from the forge's own facts. |
| 751 | 752 | router.add("GET", "/.well-known/security.txt", securityTxt); |
| 752 | 753 | router.add("GET", "/.well-known/mcp.json", mcpJson); |
| 754 | router.add("GET", "/.well-known/api-catalog", apiCatalogJson); | |
| 753 | 755 | router.add("GET", "/.well-known/forge", forgeJson); |
| 754 | 756 | router.add("GET", "/llms.txt", llmsTxt); |
| 755 | 757 | // 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"; | ||
| 26 | 26 | import { hasAnyUser, verifyPasswordThrottled, type User } from "../../core/users.ts"; |
| 27 | 27 | import { isThemeName } from "../../design/themes.ts"; |
| 28 | 28 | import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts"; |
| 29 | import { API_CATALOG_LINK } from "../../core/mcp_card.ts"; | |
| 29 | 30 | import { studioDoor, studioHome } from "../studio/home.ts"; |
| 30 | 31 | import { parentIssuerOf } from "../../core/federation.ts"; |
| 31 | 32 | import { activeEdgeToken, edgeIdentity, edgeLoginUrl, edgeStrangerLogin } from "../../http/edge.ts"; |
| @@ -210,8 +211,10 @@ export function terminalCard(ctx: Ctx): Html { | ||
| 210 | 211 | // the feed (`studioHome`, #599), whatever the mode (#625). |
| 211 | 212 | export async function home(ctx: Ctx): Promise<Response> { |
| 212 | 213 | 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); | |
| 217 | return res; | |
| 215 | 218 | } |
| 216 | 219 | |
| 217 | 220 | // ---- authentication ---- |
src/web/standards.ts +20 −5
| @@ -6,6 +6,8 @@ | ||
| 6 | 6 | * /.well-known/mcp.json the MCP server card: endpoint, sign-in, the |
| 7 | 7 | * tool that needs no token (a draft format: |
| 8 | 8 | * 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 | |
| 9 | 11 | * /.well-known/forge the standards this forge implements, with versions |
| 10 | 12 | * /.well-known/change-password where a password manager sends someone |
| 11 | 13 | * /robots.txt what a crawler may index, and the sitemap |
| @@ -42,7 +44,7 @@ import { notFound } from "../http/respond.ts"; | ||
| 42 | 44 | import { SHARE_TARGET } from "./share_target.ts"; |
| 43 | 45 | import { SITE_LINE } from "./site_line.ts"; |
| 44 | 46 | 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"; | |
| 46 | 48 | import { forgeDocument } from "../core/standards_index.ts"; |
| 47 | 49 | import { running } from "../kernel/version.ts"; |
| 48 | 50 | import { timePhase, timePhaseAsync } from "../core/route_timing.ts"; |
| @@ -115,6 +117,18 @@ export function mcpJson(ctx: Ctx): Response { | ||
| 115 | 117 | return res; |
| 116 | 118 | } |
| 117 | 119 | |
| 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 | ||
| 118 | 132 | /** GET /.well-known/forge — the standards this forge implements, by id and version (docs/standards/). */ |
| 119 | 133 | export function forgeJson(): Response { |
| 120 | 134 | 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 | ||
| 420 | 434 | "", |
| 421 | 435 | `- Page: ${o}/{owner}/{name}/report`, |
| 422 | 436 | `- 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`, | |
| 424 | 438 | `- 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.`, |
| 425 | 439 | "", |
| 426 | 440 | "## Offer a change", |
| @@ -456,9 +470,10 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s | ||
| 456 | 470 | "", |
| 457 | 471 | "## For agents with an account", |
| 458 | 472 | "", |
| 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})`, | |
| 462 | 477 | "", |
| 463 | 478 | ...mcpToolsSection(o, signedIn), |
| 464 | 479 | "## 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 | }); |
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.