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

Merged opened this

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

Merged

scout/forge:scout/5939-llms-txt-lists-every-mcp-tool-from-the-g → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/5939-llms-txt-lists-every-mcp-tool-from-the-g was merged into main as 5e7bd0f.

Review

Sign in to review this change.
4 files changed +74 −0
src/http/server.ts +3
@@ -470,6 +470,8 @@ import { runGate } from "./run_gate.ts";
470470 import { feedRoutes } from "./feed.ts";
471471 import type { Database } from "bun:sqlite";
472472 import { mcpRoutes } from "./mcp.ts";
473 import { TOOLS } from "./mcp/tools.ts";
474 import { setMcpTools } from "../web/llms_tools.ts";
473475 import { openapiRoute } from "../contract/served.ts";
474476 import { createUsageCounter, type UsageCounter } from "../core/usage_count.ts";
475477 import {
@@ -1095,6 +1097,7 @@ export function buildRouter(): Router<Handler> {
10951097 // reclaim a path under `/api/`.
10961098 // The forge as an MCP server: the studio's verbs for an assistant.
10971099 mcpRoutes(router);
1100 setMcpTools(TOOLS);
10981101 // The public API as OpenAPI, for any client that reads it.
10991102 openapiRoute(router);
11001103 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)" : "";

Non-blocking. (admin) matches the index flags exactly, but several tools whose own descriptions say they are for admins don't carry adminOnly/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.

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";
3333 import type { Ctx } from "../http/context.ts";
3434 import { notFound } from "../http/respond.ts";
3535 import { SITE_LINE } from "./site_line.ts";
36 import { mcpToolsSection } from "./llms_tools.ts";
3637 import { mcpCard, mcpPointers } from "../core/mcp_card.ts";
3738 import { running } from "../kernel/version.ts";
3839 import { timePhase } from "../core/route_timing.ts";
@@ -354,6 +355,7 @@ export function llmsTxt(ctx: Ctx): Response {
354355 `- API: ${o}/api/v1, Authorization: Bearer <token>`,
355356 `- OpenAPI: GET ${o}/api/v1/openapi.json (redirects to ${o}/contract/openapi.json)`,
356357 "",
358 ...mcpToolsSection(),
357359 "## Public repositories",
358360 "",
359361 // 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs