Public: anyone can read this; the crew can change it.
/llms.txt lists every MCP tool from the generated tool index, and a test fails when one is missing #6456
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5939-llms-txt-lists-every-mcp-tool-from-the-g → main 1 commit fork 1 thread · 1 unresolved
4 files
changed +74
−0
src/http/server.ts +3
| @@ -470,6 +470,8 @@ import { runGate } from "./run_gate.ts"; | ||
| 470 | 470 | import { feedRoutes } from "./feed.ts"; |
| 471 | 471 | import type { Database } from "bun:sqlite"; |
| 472 | 472 | import { mcpRoutes } from "./mcp.ts"; |
| 473 | import { TOOLS } from "./mcp/tools.ts"; | |
| 474 | import { setMcpTools } from "../web/llms_tools.ts"; | |
| 473 | 475 | import { openapiRoute } from "../contract/served.ts"; |
| 474 | 476 | import { createUsageCounter, type UsageCounter } from "../core/usage_count.ts"; |
| 475 | 477 | import { |
| @@ -1095,6 +1097,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1095 | 1097 | // reclaim a path under `/api/`. |
| 1096 | 1098 | // The forge as an MCP server: the studio's verbs for an assistant. |
| 1097 | 1099 | mcpRoutes(router); |
| 1100 | setMcpTools(TOOLS); | |
| 1098 | 1101 | // The public API as OpenAPI, for any client that reads it. |
| 1099 | 1102 | openapiRoute(router); |
| 1100 | 1103 | oauthRoutes(router); |
src/web/llms_tools.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | /** | |
| 2 | * The `## MCP tools` section of /llms.txt: one line per tool in the generated | |
| 3 | * index, `name: description`, then `(admin)` and `(writes)` where they apply. | |
| 4 | * Built once at boot and the same for every reader, whoever they are. | |
| 5 | */ | |
| 6 | import type { Tool } from "../http/mcp/shared.ts"; | |
| 7 | ||
| 8 | const oneLine = (s: string): string => s.replace(/\s+/g, " ").trim(); | |
| 9 | ||
| 10 | export function toolLine(t: Tool): string { | |
| 11 | const admin = t.adminOnly === true || t.needsAdminScope === true ? " (admin)" : ""; | |
| 12 | const writes = t.writes ? " (writes)" : ""; | |
| 13 | return `- ${t.name}: ${oneLine(t.description)}${admin}${writes}`; | |
| 14 | } | |
| 15 | ||
| 16 | export const toolsSection = (tools: readonly Tool[]): string[] => [ | |
| 17 | "## MCP tools", | |
| 18 | "", | |
| 19 | ...tools.map(toolLine), | |
| 20 | "", | |
| 21 | ]; | |
| 22 | ||
| 23 | // The index is handed in at boot by the server rather than imported here: | |
| 24 | // importing it from a page module closes an import cycle through the actions. | |
| 25 | let section: readonly string[] = []; | |
| 26 | export const setMcpTools = (tools: readonly Tool[]): void => { | |
| 27 | section = toolsSection(tools); | |
| 28 | }; | |
| 29 | export const mcpToolsSection = (): readonly string[] => section; |
src/web/standards.ts +2
| @@ -33,6 +33,7 @@ import { canRead, getRepo } from "../core/repos.ts"; | ||
| 33 | 33 | import type { Ctx } from "../http/context.ts"; |
| 34 | 34 | import { notFound } from "../http/respond.ts"; |
| 35 | 35 | import { SITE_LINE } from "./site_line.ts"; |
| 36 | import { mcpToolsSection } from "./llms_tools.ts"; | |
| 36 | 37 | import { mcpCard, mcpPointers } from "../core/mcp_card.ts"; |
| 37 | 38 | import { running } from "../kernel/version.ts"; |
| 38 | 39 | import { timePhase } from "../core/route_timing.ts"; |
| @@ -354,6 +355,7 @@ export function llmsTxt(ctx: Ctx): Response { | ||
| 354 | 355 | `- API: ${o}/api/v1, Authorization: Bearer <token>`, |
| 355 | 356 | `- OpenAPI: GET ${o}/api/v1/openapi.json (redirects to ${o}/contract/openapi.json)`, |
| 356 | 357 | "", |
| 358 | ...mcpToolsSection(), | |
| 357 | 359 | "## Public repositories", |
| 358 | 360 | "", |
| 359 | 361 | // Names and addresses only: a description is whatever its owner |
test/llms-tools.test.ts added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | /** /llms.txt's `## MCP tools` section is the generated tool index, line for line. */ | |
| 2 | import { afterAll, beforeAll, describe, 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 } from "../src/app.ts"; | |
| 7 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | import { TOOLS } from "../src/http/mcp/tools.ts"; | |
| 9 | ||
| 10 | describe("/llms.txt MCP tools", () => { | |
| 11 | let dataDir: string; | |
| 12 | let servers: Servers; | |
| 13 | ||
| 14 | beforeAll(async () => { | |
| 15 | dataDir = await mkdtemp(join(tmpdir(), "forge-llms-tools-")); | |
| 16 | servers = await serve(createApp({ dataDir, port: 0 })); | |
| 17 | }); | |
| 18 | afterAll(async () => { | |
| 19 | await servers.stop(); | |
| 20 | await rm(dataDir, { recursive: true, force: true }); | |
| 21 | }); | |
| 22 | ||
| 23 | test("lists exactly the tools in the index, with (admin) and (writes)", async () => { | |
| 24 | const res = await fetch(`${servers.url}/llms.txt`); | |
| 25 | expect(res.status).toBe(200); | |
| 26 | expect(res.headers.get("cache-control")).toContain("max-age"); | |
| 27 | const lines = (await res.text()).split("\n"); | |
| 28 | const start = lines.indexOf("## MCP tools"); | |
| 29 | expect(start).toBeGreaterThan(-1); | |
| 30 | const end = lines.findIndex((l, i) => i > start && l.startsWith("## ")); | |
| 31 | const listed = lines.slice(start + 1, end).filter((l) => l.startsWith("- ")); | |
| 32 | const expected = TOOLS.map((t) => { | |
| 33 | const admin = t.adminOnly === true || t.needsAdminScope === true ? " (admin)" : ""; | |
| 34 | return `- ${t.name}: ${t.description.replace(/\s+/g, " ").trim()}${admin}${t.writes ? " (writes)" : ""}`; | |
| 35 | }); | |
| 36 | expect(listed).toEqual(expected); | |
| 37 | expect(TOOLS.some((t) => t.writes)).toBe(true); | |
| 38 | expect(TOOLS.some((t) => t.adminOnly === true)).toBe(true); | |
| 39 | }); | |
| 40 | }); |
new above
offline
Non-blocking.
(admin)matches the index flags exactly, but several tools whose own descriptions say they are for admins don't carryadminOnly/needsAdminScope, so they show up without the marker:access,account_role,account_suspend,account_verify,app_env,archive,egress_grants,drills,flow. Their handlers check for admin themselves. The fix belongs in the index (set the flag where a tool is admin-only), not here, and it's worth a small follow-up so the marker in /llms.txt matches what the tool actually allows.