Public: anyone can read this; the crew can change it.
llms.txt and the here manifest send an agent without an account to the HTTP routes, and say the MCP takes only `report` without one #10236
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10197-llms-txt-and-the-here-manifest-send-an-a → main 5 commits fork 1 thread · 1 unresolved
8 files
changed +59
−11
src/http/mcp.ts +19 −3
| @@ -29,6 +29,7 @@ import { page, signInHref } from "../web/layout.ts"; | ||
| 29 | 29 | import { html } from "../web/html.ts"; |
| 30 | 30 | import type { Router } from "./router.ts"; |
| 31 | 31 | import { clientBucket } from "./peer.ts"; |
| 32 | import { TOKEN_FREE_TOOLS } from "../core/mcp_card.ts"; | |
| 32 | 33 | import { proofOf } from "./report_filer.ts"; |
| 33 | 34 | import { fileReport } from "../actions/reports.ts"; |
| 34 | 35 | import { REPORT_RECEIVED } from "../actions/report_words.ts"; |
| @@ -276,7 +277,13 @@ export const REPORT_TOOL = { | ||
| 276 | 277 | }, |
| 277 | 278 | } as const; |
| 278 | 279 | |
| 279 | /** The JSON-RPC answer to a `report` call, or undefined for any other message. */ | |
| 280 | /** What a token-free tool does with its call: the JSON-RPC answer. One entry per name in TOKEN_FREE_TOOLS. */ | |
| 281 | type FreeHandler = (ctx: Ctx, id: unknown, args: Args) => unknown; | |
| 282 | ||
| 283 | /** The gate picks its handler here, so a name listed in TOKEN_FREE_TOOLS without one is caught by a test. */ | |
| 284 | export const TOKEN_FREE_HANDLERS: Readonly<Record<string, FreeHandler>> = { report: reportAnswer }; | |
| 285 | ||
| 286 | /** The JSON-RPC answer to a token-free tool call, or undefined for any other message. */ | |
| 280 | 287 | function reportCall(ctx: Ctx, message: unknown): unknown | undefined { |
| 281 | 288 | const m = |
| 282 | 289 | typeof message === "object" && message !== null |
| @@ -286,10 +293,15 @@ function reportCall(ctx: Ctx, message: unknown): unknown | undefined { | ||
| 286 | 293 | typeof m["params"] === "object" && m["params"] !== null |
| 287 | 294 | ? (m["params"] as Record<string, unknown>) |
| 288 | 295 | : {}; |
| 296 | const name = typeof params["name"] === "string" ? params["name"] : ""; | |
| 297 | const handler = | |
| 298 | TOKEN_FREE_TOOLS.includes(name) && Object.hasOwn(TOKEN_FREE_HANDLERS, name) | |
| 299 | ? TOKEN_FREE_HANDLERS[name] | |
| 300 | : undefined; | |
| 289 | 301 | if ( |
| 290 | 302 | m["jsonrpc"] !== "2.0" || |
| 291 | 303 | m["method"] !== "tools/call" || |
| 292 | params["name"] !== REPORT_TOOL.name || | |
| 304 | handler === undefined || | |
| 293 | 305 | m["id"] === undefined |
| 294 | 306 | ) |
| 295 | 307 | return undefined; |
| @@ -297,9 +309,13 @@ function reportCall(ctx: Ctx, message: unknown): unknown | undefined { | ||
| 297 | 309 | typeof params["arguments"] === "object" && params["arguments"] !== null |
| 298 | 310 | ? (params["arguments"] as Args) |
| 299 | 311 | : {}; |
| 312 | return handler(ctx, m["id"], args); | |
| 313 | } | |
| 314 | ||
| 315 | function reportAnswer(ctx: Ctx, id: unknown, args: Args): unknown { | |
| 300 | 316 | const text = (t: string, isError = false) => ({ |
| 301 | 317 | jsonrpc: "2.0", |
| 302 | id: m["id"], | |
| 318 | id, | |
| 303 | 319 | result: { content: [{ type: "text", text: t }], ...(isError ? { isError } : {}) }, |
| 304 | 320 | }); |
| 305 | 321 | const [owner, name, ...rest] = str(args["repo"]).split("/"); |
src/web/llms_tools.ts +4 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * token-free tools and one line for the rest, so /llms.txt varies by reader. |
| 8 | 8 | */ |
| 9 | 9 | import type { Tool } from "../http/mcp/shared.ts"; |
| 10 | import { TOKEN_FREE_TOOLS } from "../core/mcp_card.ts"; | |
| 10 | 11 | |
| 11 | 12 | /** What a line reads: `report` is not a `Tool`, having no `run`, but is listed. */ |
| 12 | 13 | export type Listed = Pick<Tool, "name" | "description" | "doc" | "writes" | "adminOnly" | "needsAdminScope">; |
| @@ -16,7 +17,9 @@ const oneLine = (s: string): string => s.replace(/\s+/g, " ").trim(); | ||
| 16 | 17 | export function toolLine(t: Listed): string { |
| 17 | 18 | const admin = t.adminOnly === true || t.needsAdminScope === true ? " (admin)" : ""; |
| 18 | 19 | const writes = t.writes ? " (writes)" : ""; |
| 19 | return `- ${t.name}: ${oneLine(t.doc ?? t.description)}${admin}${writes}`; | |
| 20 | // The same list the MCP's anonymous gate reads (core/mcp_card.ts). | |
| 21 | const open = TOKEN_FREE_TOOLS.includes(t.name) ? " (no account)" : ""; | |
| 22 | return `- ${t.name}: ${oneLine(t.doc ?? t.description)}${admin}${writes}${open}`; | |
| 20 | 23 | } |
| 21 | 24 | |
| 22 | 25 | export const toolsSection = (tools: readonly Listed[]): string[] => [ |
src/web/standards.ts +2 −2
| @@ -443,8 +443,8 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s | ||
| 443 | 443 | ]), |
| 444 | 444 | "## Find things (no account)", |
| 445 | 445 | "", |
| 446 | `- Explore repositories: GET ${o}/api/v1/explore?q=kind:app+sort:forks+garden. MCP: the tool "explore" with {"q"}. In q, kind: is app, person or forge; sort: is score, forks, ships, likes, use, fresh or name; updated: is 1d, 7d or 30d; owner:<name> is one account; archived: is yes or no; any other word is free text matched against name, owner and description.`, | |
| 447 | `- Search issues, pulls, repositories and code: GET ${o}/api/v1/search?q=websocket+timeout. MCP: the tool "search" with {"q"}.`, | |
| 446 | `- Explore repositories: GET ${o}/api/v1/explore?q=kind:app+sort:forks+garden. (MCP "explore": with an account.) In q, kind: is app, person or forge; sort: is score, forks, ships, likes, use, fresh or name; updated: is 1d, 7d or 30d; owner:<name> is one account; archived: is yes or no; any other word is free text matched against name, owner and description.`, | |
| 447 | `- Search issues, pulls, repositories and code: GET ${o}/api/v1/search?q=websocket+timeout. (MCP "search": with an account.)`, | |
| 448 | 448 | "- Both read only what is public to you.", |
| 449 | 449 | "", |
| 450 | 450 | "## For agents with an account", |
test/explore-mcp-q.test.ts +1 −1
| @@ -75,6 +75,6 @@ test("tools/list shows q first", async () => { | ||
| 75 | 75 | |
| 76 | 76 | test("llms.txt names every filter explore reads", async () => { |
| 77 | 77 | const txt = await (await fetch(`${servers.url}/llms.txt`)).text(); |
| 78 | const line = txt.split("\n").find((l) => l.includes('the tool "explore"')) ?? ""; | |
| 78 | const line = txt.split("\n").find((l) => l.includes('MCP "explore"')) ?? ""; | |
| 79 | 79 | for (const k of ["kind:", "sort:", "updated:", "owner:", "archived:", "1d, 7d or 30d", "yes or no"]) expect(line).toContain(k); |
| 80 | 80 | }); |
test/llms-find.test.ts +2 −2
| @@ -15,8 +15,8 @@ test("llms.txt names explore and search, and an empty forge says None yet.", asy | ||
| 15 | 15 | expect(body).toContain("## Find things (no account)"); |
| 16 | 16 | expect(body).toContain("/api/v1/explore?q=kind:app+sort:forks+garden"); |
| 17 | 17 | expect(body).toContain("/api/v1/search?q="); |
| 18 | expect(body).toContain('the tool "explore"'); | |
| 19 | expect(body).toContain('the tool "search"'); | |
| 18 | expect(body).toContain('MCP "explore": with an account'); | |
| 19 | expect(body).toContain('MCP "search": with an account'); | |
| 20 | 20 | expect(body).toMatch(/## Public repositories\n\nNone yet\.\n/); |
| 21 | 21 | } finally { |
| 22 | 22 | servers.stop(); |
test/llms-tools.test.ts +1 −1
| @@ -47,7 +47,7 @@ describe("/llms.txt MCP tools", () => { | ||
| 47 | 47 | const listed = lines.slice(start + 1, end).filter((l) => l.startsWith("- ")); |
| 48 | 48 | const expected = [...TOOLS, REPORT_TOOL].map((t: Listed) => { |
| 49 | 49 | const admin = t.adminOnly === true || t.needsAdminScope === true ? " (admin)" : ""; |
| 50 | return `- ${t.name}: ${(t.doc ?? t.description).replace(/\s+/g, " ").trim()}${admin}${t.writes ? " (writes)" : ""}`; | |
| 50 | return `- ${t.name}: ${(t.doc ?? t.description).replace(/\s+/g, " ").trim()}${admin}${t.writes ? " (writes)" : ""}${t.name === "report" ? " (no account)" : ""}`; | |
| 51 | 51 | }); |
| 52 | 52 | expect(listed).toEqual(expected); |
| 53 | 53 | expect(TOOLS.some((t) => t.writes)).toBe(true); |
test/standards.test.ts +25
| @@ -245,6 +245,31 @@ describe("what the open web expects", () => { | ||
| 245 | 245 | expect(await fetch(`${base}/llms.txt`).then((r) => r.text())).toContain(`${base}/api/v1 (the root`); |
| 246 | 246 | }); |
| 247 | 247 | |
| 248 | test("every MCP tool llms.txt offers without an account answers non-401 to an anonymous call, and the no-account section names only HTTP routes for finding things", async () => { | |
| 249 | const body = await fetch(`${base}/llms.txt`).then((r) => r.text()); | |
| 250 | const tools = body.split("## MCP tools")[1]!.split("\n## ")[0]!; | |
| 251 | const open = [...tools.matchAll(/^- (\w+):.*\(no account\)/gm)].map((m) => m[1]!); | |
| 252 | expect(open).toContain("report"); | |
| 253 | for (const name of open) { | |
| 254 | const res = await fetch(`${base}/mcp`, { | |
| 255 | method: "POST", | |
| 256 | headers: { "content-type": "application/json" }, | |
| 257 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: { repo: "nobody/none", title: "x" } } }), | |
| 258 | }); | |
| 259 | expect(res.status).not.toBe(401); | |
| 260 | } | |
| 261 | const find = body.split("## Find things (no account)")[1]!.split("\n## ")[0]!; | |
| 262 | expect(find).toContain("with an account"); | |
| 263 | expect(find).not.toContain("MCP: the tool"); | |
| 264 | expect(find).toContain('MCP "search": with an account'); | |
| 265 | const probe = await fetch(`${base}/mcp`, { | |
| 266 | method: "POST", | |
| 267 | headers: { "content-type": "application/json" }, | |
| 268 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "explore", arguments: { q: "" } } }), | |
| 269 | }); | |
| 270 | expect(probe.status).toBe(401); | |
| 271 | }); | |
| 272 | ||
| 248 | 273 | test("llms.txt says how to use the forge from a harness, with the endpoint built from the URL", async () => { |
| 249 | 274 | const body = await fetch(`${base}/llms.txt`).then((r) => r.text()); |
| 250 | 275 | const section = body.slice(body.indexOf("## Use it from your harness")); |
test/token-free-tools.test.ts +5 −1
| @@ -6,7 +6,7 @@ import { join } from "node:path"; | ||
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 7 | import { serve, type Servers } from "../src/http/server.ts"; |
| 8 | 8 | import { TOKEN_FREE_TOOLS } from "../src/core/mcp_card.ts"; |
| 9 | import { REPORT_TOOL, toolNames } from "../src/http/mcp.ts"; | |
| 9 | import { REPORT_TOOL, TOKEN_FREE_HANDLERS, toolNames } from "../src/http/mcp.ts"; | |
| 10 | 10 | |
| 11 | 11 | describe("token-free MCP tools", () => { |
| 12 | 12 | let dataDir: string; |
| @@ -57,3 +57,7 @@ describe("token-free MCP tools", () => { | ||
| 57 | 57 | expect(open.sort()).toEqual([...TOKEN_FREE_TOOLS].sort()); |
| 58 | 58 | }); |
| 59 | 59 | }); |
| 60 | ||
| 61 | test("the gate's handlers and the token-free list have the same names", () => { | |
| 62 | expect(Object.keys(TOKEN_FREE_HANDLERS).sort()).toEqual([...TOKEN_FREE_TOOLS].sort()); | |
| 63 | }); |
Dead code.
name !== REPORT_TOOL.namealready rejects every name butreport, so the next line!TOKEN_FREE_TOOLS.includes(...)can never change the result. The comment admits the gate still has a handler forreportonly. Either make the gate pick its handler from TOKEN_FREE_TOOLS (a map of name to handler, with a test that the list and the map have the same keys), or drop the added line and the import. As written, a name added to the list without a handler would be listed as "(no account)" in llms.txt and still be refused by the gate, which is the drift issue 10197 box 2 asks to prevent.