Public: anyone can read this; the crew can change it.
/.well-known/mcp.json and /llms.txt name the ui:// cards a harness can draw, read from CARD_OF_TOOL so they cannot drift #11415
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11412-well-known-mcp-json-and-llms-txt-name-th → main 3 commits fork 1 thread · 1 unresolved
3 files
changed +50
−1
src/core/mcp_card.ts +22
| @@ -11,6 +11,7 @@ import { claudeInstall, opencodeInstall, pluginSource } from "./plugin_install.t | ||
| 11 | 11 | import { running, type Running } from "../kernel/version.ts"; |
| 12 | 12 | import { forgeSource } from "../settings/forge_source.ts"; |
| 13 | 13 | import { repoVisibility } from "./repos.ts"; |
| 14 | import { CARD_OF_TOOL, CARD_URI } from "../web/studio/cards.ts"; | |
| 14 | 15 | |
| 15 | 16 | /** The endpoint and the protected-resource metadata that names how to sign in. */ |
| 16 | 17 | export function mcpPointers(origin: string): { url: string; oauth: string } { |
| @@ -114,6 +115,26 @@ export function offeredPlugins(repo: string | undefined): readonly HarnessPlugin | ||
| 114 | 115 | return HARNESS_PLUGINS.filter((p) => !p.cloned || repo !== undefined); |
| 115 | 116 | } |
| 116 | 117 | |
| 118 | /** | |
| 119 | * The MCP Apps cards (SEP-1865) a harness can draw, one `{ resource, tools }` | |
| 120 | * per `ui://` resource, read from `CARD_OF_TOOL` so the card and /llms.txt | |
| 121 | * cannot drift from what `tools/list` advertises. | |
| 122 | */ | |
| 123 | export function cardList(): { resource: string; tools: string[] }[] { | |
| 124 | const byResource = new Map<string, string[]>(); | |
| 125 | for (const [tool, kind] of Object.entries(CARD_OF_TOOL)) { | |
| 126 | const uri = CARD_URI(kind); | |
| 127 | byResource.set(uri, [...(byResource.get(uri) ?? []), tool]); | |
| 128 | } | |
| 129 | return [...byResource].map(([resource, tools]) => ({ resource, tools })); | |
| 130 | } | |
| 131 | ||
| 132 | /** The one /llms.txt line that says tool results carry cards, and how many. */ | |
| 133 | export function cardsLine(): string { | |
| 134 | const n = cardList().length; | |
| 135 | return `Tool results carry MCP Apps cards: a tool listed with _meta.ui.resourceUri points at a ui:// resource a harness can draw (${n} cards; the list is "cards" in the server card).`; | |
| 136 | } | |
| 137 | ||
| 117 | 138 | /** |
| 118 | 139 | * The server card. The format is still a draft, so only what we can keep |
| 119 | 140 | * true is named: who answers, where, over what transport, how to sign in, |
| @@ -131,6 +152,7 @@ export function mcpCard(origin: string, repo: string | undefined, now: Running = | ||
| 131 | 152 | documentation: `${origin}${DISCOVERY.docs}`, |
| 132 | 153 | openapi: `${origin}${DISCOVERY.openapi}`, |
| 133 | 154 | tokenFreeTools: [...TOKEN_FREE_TOOLS], |
| 155 | cards: cardList(), | |
| 134 | 156 | plugins: offeredPlugins(repo).map((p) => ({ |
| 135 | 157 | harness: p.harness, |
| 136 | 158 | path: p.path, |
src/web/standards.ts +3 −1
| @@ -44,7 +44,7 @@ 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 | 46 | import { mcpToolsSection } from "./llms_tools.ts"; |
| 47 | import { apiCatalog, DISCOVERY, mcpCard, offeredPlugins, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts"; | |
| 47 | import { apiCatalog, cardsLine, DISCOVERY, mcpCard, offeredPlugins, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts"; | |
| 48 | 48 | import { forgeDocument } from "../core/standards_index.ts"; |
| 49 | 49 | import { TAGS } from "../contract/served.ts"; |
| 50 | 50 | import { running } from "../kernel/version.ts"; |
| @@ -483,6 +483,8 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s | ||
| 483 | 483 | `- OpenAPI: GET ${o}${DISCOVERY.openapi}, or one slice with ?tag= (${TAGS.join(", ")})`, |
| 484 | 484 | "", |
| 485 | 485 | ...mcpToolsSection(o, signedIn), |
| 486 | cardsLine(), | |
| 487 | "", | |
| 486 | 488 | "## Public repositories", |
| 487 | 489 | "", |
| 488 | 490 | // Names and addresses only: a description is whatever its owner |
test/mcp-card.test.ts +25
| @@ -25,6 +25,13 @@ describe("the MCP server card", () => { | ||
| 25 | 25 | await rm(dataDir, { recursive: true, force: true }); |
| 26 | 26 | }); |
| 27 | 27 | |
| 28 | test("/llms.txt carries the cards line, counted from the card the server serves", async () => { | |
| 29 | const card = (await (await fetch(`${base}/.well-known/mcp.json`)).json()) as any; | |
| 30 | const text = await (await fetch(`${base}/llms.txt`)).text(); | |
| 31 | expect(text).toContain("_meta.ui.resourceUri"); | |
| 32 | expect(text).toContain(`(${card.cards.length} cards;`); | |
| 33 | }); | |
| 34 | ||
| 28 | 35 | test("names the server, endpoint, sign-in and the tokenless tool, each true", async () => { |
| 29 | 36 | const res = await fetch(`${base}/.well-known/mcp.json`); |
| 30 | 37 | expect(res.status).toBe(200); |
| @@ -88,3 +95,21 @@ describe("the MCP server card", () => { | ||
| 88 | 95 | expect(txt).toContain(`${base}/.well-known/mcp.json`); |
| 89 | 96 | }); |
| 90 | 97 | }); |
| 98 | ||
| 99 | describe("the cards the server card names", () => { | |
| 100 | test("every tool in CARD_OF_TOOL is listed under its card's resource", async () => { | |
| 101 | const { CARD_OF_TOOL, CARD_URI } = await import("../src/web/studio/cards.ts"); | |
| 102 | const card = mcpCard("https://x", undefined, running({})); | |
| 103 | for (const [tool, kind] of Object.entries(CARD_OF_TOOL)) { | |
| 104 | const entry = card.cards.find((c) => c.resource === CARD_URI(kind)); | |
| 105 | expect(entry?.tools).toContain(tool); | |
| 106 | } | |
| 107 | expect(card.cards.flatMap((c) => c.tools).length).toBe(Object.keys(CARD_OF_TOOL).length); | |
| 108 | }); | |
| 109 | ||
| 110 | test("/llms.txt says results carry cards and how many", async () => { | |
| 111 | const { cardsLine, cardList } = await import("../src/core/mcp_card.ts"); | |
| 112 | expect(cardsLine()).toContain("_meta.ui.resourceUri"); | |
| 113 | expect(cardsLine()).toContain(`${cardList().length} cards`); | |
| 114 | }); | |
| 115 | }); |
cardsLine() is called here but no test reads /llms.txt, so removing this line passes the suite. Add a test that fetches /llms.txt and expects the cards line.