Public: anyone can read this; the crew can change it.
/.well-known/mcp.json and llms.txt offer the harness plugins only when their source repository is readable without a token #8455
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8431-well-known-mcp-json-and-llms-txt-offer-t → main 1 commit fork
5 files
changed +63
−20
src/core/mcp_card.ts +11 −3
| @@ -6,6 +6,8 @@ | ||
| 6 | 6 | * The MCP server-card format is still a draft, so the card may not match a |
| 7 | 7 | * later standard's field names; when one settles, the card follows it. |
| 8 | 8 | */ |
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { getRepo } from "./repos.ts"; | |
| 9 | 11 | import { running, type Running } from "../kernel/version.ts"; |
| 10 | 12 | |
| 11 | 13 | /** The endpoint and the protected-resource metadata that names how to sign in. */ |
| @@ -34,9 +36,15 @@ export interface HarnessPlugin { | ||
| 34 | 36 | readonly setup: string; |
| 35 | 37 | } |
| 36 | 38 | |
| 37 | /** The repository that holds the plugins; it must be public on the forge for the lines to work. */ | |
| 39 | /** The repository that holds the plugins; the lines are offered only while it is public on this forge. */ | |
| 38 | 40 | const PLUGIN_REPO = "trevato/forge"; |
| 39 | 41 | |
| 42 | /** Whether the plugin source can be read without a token here, so every printed install line works. */ | |
| 43 | export function pluginsOffered(db: Database): boolean { | |
| 44 | const [owner = "", name = ""] = PLUGIN_REPO.split("/"); | |
| 45 | return getRepo(db, owner, name)?.visibility === "public"; | |
| 46 | } | |
| 47 | ||
| 40 | 48 | /** The clone URL of that repository on this forge's origin. */ |
| 41 | 49 | export function pluginSource(origin: string): string { |
| 42 | 50 | return `${origin}/${PLUGIN_REPO}.git`; |
| @@ -67,7 +75,7 @@ export const HARNESS_PLUGINS: readonly HarnessPlugin[] = [ | ||
| 67 | 75 | * true is named: who answers, where, over what transport, how to sign in, |
| 68 | 76 | * and the one tool that needs no token. |
| 69 | 77 | */ |
| 70 | export function mcpCard(origin: string, now: Running = running()) { | |
| 78 | export function mcpCard(origin: string, offered: boolean, now: Running = running()) { | |
| 71 | 79 | const { url, oauth } = mcpPointers(origin); |
| 72 | 80 | return { |
| 73 | 81 | name: "forge", |
| @@ -79,7 +87,7 @@ export function mcpCard(origin: string, now: Running = running()) { | ||
| 79 | 87 | documentation: `${origin}/llms.txt`, |
| 80 | 88 | openapi: `${origin}/contract/openapi.json`, |
| 81 | 89 | tokenFreeTools: [...TOKEN_FREE_TOOLS], |
| 82 | plugins: HARNESS_PLUGINS.map((p) => ({ | |
| 90 | plugins: (offered ? HARNESS_PLUGINS : []).map((p) => ({ | |
| 83 | 91 | harness: p.harness, |
| 84 | 92 | path: p.path, |
| 85 | 93 | source: pluginSource(origin), |
src/web/standards.ts +14 −12
| @@ -39,7 +39,7 @@ import type { Ctx } from "../http/context.ts"; | ||
| 39 | 39 | import { notFound } from "../http/respond.ts"; |
| 40 | 40 | import { SITE_LINE } from "./site_line.ts"; |
| 41 | 41 | import { mcpToolsSection } from "./llms_tools.ts"; |
| 42 | import { HARNESS_PLUGINS, mcpCard, mcpPointers, pluginSource } from "../core/mcp_card.ts"; | |
| 42 | import { HARNESS_PLUGINS, mcpCard, mcpPointers, pluginSource, pluginsOffered } from "../core/mcp_card.ts"; | |
| 43 | 43 | import { running } from "../kernel/version.ts"; |
| 44 | 44 | import { timePhase, timePhaseAsync } from "../core/route_timing.ts"; |
| 45 | 45 | import { shippedReleases, type Shipped } from "../release/shipped.ts"; |
| @@ -106,7 +106,7 @@ export function securityTxt(ctx: Ctx): Response { | ||
| 106 | 106 | |
| 107 | 107 | /** GET /.well-known/mcp.json — the MCP server card (a draft format; see docs/MCP.md). */ |
| 108 | 108 | export function mcpJson(ctx: Ctx): Response { |
| 109 | const res = text(JSON.stringify(mcpCard(ctx.url.origin), null, 2), "application/json"); | |
| 109 | const res = text(JSON.stringify(mcpCard(ctx.url.origin, pluginsOffered(ctx.app.db)), null, 2), "application/json"); | |
| 110 | 110 | res.headers.set("access-control-allow-origin", "*"); |
| 111 | 111 | return res; |
| 112 | 112 | } |
| @@ -343,8 +343,8 @@ function runningSentence(): string { | ||
| 343 | 343 | : `Running ${r.version} at ${r.commit.slice(0, 7)}`; |
| 344 | 344 | } |
| 345 | 345 | |
| 346 | /** llms.txt and its ETag per origin, valid while the listing read, the build and the tell command are the same. */ | |
| 347 | const llmsBodies = new Map<string, { signedIn: boolean; repos: LlmsListing; running: string; tell: string | undefined; body: string; etag: string }>(); | |
| 346 | /** llms.txt and its ETag per origin, valid while the listing read, the build, the tell command and the plugin source being public are the same. */ | |
| 347 | const llmsBodies = new Map<string, { signedIn: boolean; repos: LlmsListing; running: string; tell: string | undefined; plugins: boolean; body: string; etag: string }>(); | |
| 348 | 348 | |
| 349 | 349 | /** |
| 350 | 350 | * GET /llms.txt — the forge as an agent should meet it (llmstxt.org): what |
| @@ -356,15 +356,16 @@ export function llmsTxt(ctx: Ctx): Response { | ||
| 356 | 356 | const o = ctx.url.origin; |
| 357 | 357 | const tell = sshTellCommand(ctx.app); |
| 358 | 358 | const running = runningSentence(); |
| 359 | const plugins = pluginsOffered(ctx.app.db); | |
| 359 | 360 | const repos = timePhase(ctx.request, "repos", () => llmsRepos(ctx.app.db)); |
| 360 | 361 | // The tools section depends on who asks, so the signed-in and the anonymous body are kept apart. |
| 361 | 362 | const signedIn = ctx.auth.user !== undefined; |
| 362 | 363 | const key = `${o} ${signedIn}`; |
| 363 | 364 | let kept = llmsBodies.get(key); |
| 364 | if (kept === undefined || kept.repos !== repos || kept.running !== running || kept.tell !== tell) { | |
| 365 | const body = timePhase(ctx.request, "render", () => renderLlmsTxt(ctx, o, tell, running, repos, signedIn)); | |
| 365 | if (kept === undefined || kept.repos !== repos || kept.running !== running || kept.tell !== tell || kept.plugins !== plugins) { | |
| 366 | const body = timePhase(ctx.request, "render", () => renderLlmsTxt(ctx, o, tell, running, repos, signedIn, plugins)); | |
| 366 | 367 | const etag = timePhase(ctx.request, "etag", () => `"${new Bun.CryptoHasher("sha256").update(body).digest("hex").slice(0, 32)}"`); |
| 367 | kept = { signedIn, repos, running, tell, body, etag }; | |
| 368 | kept = { signedIn, repos, running, tell, plugins, body, etag }; | |
| 368 | 369 | // Origins come from the Host header: keep a handful, not one per guess. |
| 369 | 370 | if (llmsBodies.size >= 16) llmsBodies.clear(); |
| 370 | 371 | llmsBodies.set(key, kept); |
| @@ -380,7 +381,7 @@ export function llmsTxt(ctx: Ctx): Response { | ||
| 380 | 381 | } |
| 381 | 382 | |
| 382 | 383 | /** The llms.txt document for one origin. */ |
| 383 | function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: string, repos: LlmsListing, signedIn: boolean): string { | |
| 384 | function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: string, repos: LlmsListing, signedIn: boolean, plugins: boolean): string { | |
| 384 | 385 | const mcp = mcpPointers(o); |
| 385 | 386 | return ( |
| 386 | 387 | [ |
| @@ -404,10 +405,11 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s | ||
| 404 | 405 | "## Use it from your harness", |
| 405 | 406 | "", |
| 406 | 407 | `- MCP endpoint: ${mcp.url} (streamable HTTP; OAuth metadata at ${mcp.oauth}). Or send a token as Authorization: Bearer.`, |
| 407 | ...HARNESS_PLUGINS.map( | |
| 408 | (p) => | |
| 409 | `- ${p.label} plugin (source ${pluginSource(o)}, path ${p.path}; ${p.setup}; needs that repository to be public): \`${p.install(o)}\``, | |
| 410 | ), | |
| 408 | ...(plugins | |
| 409 | ? HARNESS_PLUGINS.map( | |
| 410 | (p) => `- ${p.label} plugin (source ${pluginSource(o)}, path ${p.path}; ${p.setup}): \`${p.install(o)}\``, | |
| 411 | ) | |
| 412 | : ["- Harness plugins: not offered on this forge (their source is private)"]), | |
| 411 | 413 | "", |
| 412 | 414 | "## What you can do on any page", |
| 413 | 415 | "", |
test/mcp-card-plugins.test.ts +33 −1
| @@ -5,23 +5,47 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 6 | import { join } from "node:path"; |
| 7 | 7 | import { createApp, type App } from "../src/app.ts"; |
| 8 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 9 | import { getRepo } from "../src/core/repos.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 8 | 11 | import { serve, type Servers } from "../src/http/server.ts"; |
| 9 | 12 | |
| 10 | 13 | let dataDir: string; |
| 11 | 14 | let app: App; |
| 12 | 15 | let servers: Servers; |
| 16 | let owner: User; | |
| 13 | 17 | |
| 14 | 18 | beforeAll(async () => { |
| 15 | 19 | dataDir = await mkdtemp(join(tmpdir(), "forge-card-plugins-")); |
| 16 | 20 | app = createApp({ dataDir, port: 0 }); |
| 17 | 21 | servers = await serve(app); |
| 18 | }); | |
| 22 | const made0 = await createUser(app.db, { origin: "seed", trusted: true, username: "trevato", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 23 | if (!made0.ok) throw new Error(made0.error); | |
| 24 | owner = made0.value; | |
| 25 | const made = await createRepo(app, owner, { name: "forge", visibility: "private" }); | |
| 26 | if (!made.ok) throw new Error(String(made.error)); | |
| 27 | }, 60_000); | |
| 19 | 28 | afterAll(async () => { |
| 20 | 29 | await servers.stop(); |
| 21 | 30 | await rm(dataDir, { recursive: true, force: true }); |
| 22 | 31 | }); |
| 23 | 32 | |
| 33 | const setVisibility = async (visibility: "public" | "private") => { | |
| 34 | const r = await updateRepoSettings(app, owner, getRepo(app.db, "trevato", "forge")!, { visibility }); | |
| 35 | if (!r.ok) throw new Error(String(r.error)); | |
| 36 | }; | |
| 37 | ||
| 38 | test("a private plugin source offers no plugins in the card or llms.txt", async () => { | |
| 39 | const card = (await fetch(`${servers.url}/.well-known/mcp.json`).then((r) => r.json())) as any; | |
| 40 | const llms = await fetch(`${servers.url}/llms.txt`).then((r) => r.text()); | |
| 41 | expect(card.plugins).toEqual([]); | |
| 42 | expect(llms).toContain("Harness plugins: not offered on this forge (their source is private)"); | |
| 43 | expect(llms).not.toContain("Claude Code plugin"); | |
| 44 | expect(llms).not.toContain("needs that repository to be public"); | |
| 45 | }); | |
| 46 | ||
| 24 | 47 | test("card and llms.txt name both plugins, their install lines, and paths that exist", async () => { |
| 48 | await setVisibility("public"); | |
| 25 | 49 | const card = (await fetch(`${servers.url}/.well-known/mcp.json`).then((r) => r.json())) as any; |
| 26 | 50 | const llms = await fetch(`${servers.url}/llms.txt`).then((r) => r.text()); |
| 27 | 51 | expect(card.plugins.map((p: any) => p.harness).sort()).toEqual(["claude-code", "opencode"]); |
| @@ -43,3 +67,11 @@ test("card and llms.txt name both plugins, their install lines, and paths that e | ||
| 43 | 67 | for (const p of card.plugins) expect(existsSync(join(import.meta.dir, "..", p.path))).toBe(true); |
| 44 | 68 | expect(card.name).toBe("forge"); |
| 45 | 69 | }); |
| 70 | ||
| 71 | test("llms.txt follows the source going private again, not its cached body", async () => { | |
| 72 | await setVisibility("public"); | |
| 73 | expect(await fetch(`${servers.url}/llms.txt`).then((r) => r.text())).toContain("OpenCode plugin"); | |
| 74 | await setVisibility("private"); | |
| 75 | expect(await fetch(`${servers.url}/llms.txt`).then((r) => r.text())).not.toContain("OpenCode plugin"); | |
| 76 | expect(((await fetch(`${servers.url}/.well-known/mcp.json`).then((r) => r.json())) as any).plugins).toEqual([]); | |
| 77 | }); |
test/mcp-card.test.ts +2 −2
| @@ -68,8 +68,8 @@ describe("the MCP server card", () => { | ||
| 68 | 68 | |
| 69 | 69 | test("carries the commit it runs, or null when nothing says", () => { |
| 70 | 70 | const sha = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"; |
| 71 | expect(mcpCard("https://x", running({ FORGE_APP_COMMIT: sha })).commit).toBe(sha); | |
| 72 | expect(mcpCard("https://x", running({})).commit).toBeNull(); | |
| 71 | expect(mcpCard("https://x", true, running({ FORGE_APP_COMMIT: sha })).commit).toBe(sha); | |
| 72 | expect(mcpCard("https://x", true, running({})).commit).toBeNull(); | |
| 73 | 73 | }); |
| 74 | 74 | |
| 75 | 75 | test("the live card's commit is what running() says", async () => { |
test/standards.test.ts +3 −2
| @@ -230,8 +230,9 @@ describe("what the open web expects", () => { | ||
| 230 | 230 | expect(section).toContain(`${base}/mcp`); |
| 231 | 231 | expect(section).toContain(`${base}/.well-known/oauth-protected-resource`); |
| 232 | 232 | expect(section).toContain("streamable HTTP"); |
| 233 | expect(section).toContain("Claude Code plugin"); | |
| 234 | expect(section).toContain("OpenCode plugin"); | |
| 233 | // trevato/forge is not here, so no install line is printed that could not work. | |
| 234 | expect(section).toContain("Harness plugins: not offered on this forge (their source is private)"); | |
| 235 | expect(section).not.toContain("needs that repository to be public"); | |
| 235 | 236 | }); |
| 236 | 237 | |
| 237 | 238 | test("sitemap and llms.txt are each built once for 50 requests, and a repository made private drops out at once", async () => { |
new above
offline