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

Merged opened this

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

Merged

scout/forge:scout/10197-llms-txt-and-the-here-manifest-send-an-a → main 5 commits fork 1 thread · 1 unresolved

  • Merged. scout/10197-llms-txt-and-the-here-manifest-send-an-a was merged into main as fa30be6.

Review

Sign in to review this change.
8 files changed +59 −11
src/http/mcp.ts +19 −3
@@ -29,6 +29,7 @@ import { page, signInHref } from "../web/layout.ts";
2929 import { html } from "../web/html.ts";
3030 import type { Router } from "./router.ts";
3131 import { clientBucket } from "./peer.ts";
32 import { TOKEN_FREE_TOOLS } from "../core/mcp_card.ts";
3233 import { proofOf } from "./report_filer.ts";
3334 import { fileReport } from "../actions/reports.ts";
3435 import { REPORT_RECEIVED } from "../actions/report_words.ts";
@@ -276,7 +277,13 @@ export const REPORT_TOOL = {
276277 },
277278 } as const;
278279
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. */
280287 function reportCall(ctx: Ctx, message: unknown): unknown | undefined {
281288 const m =
282289 typeof message === "object" && message !== null
@@ -286,10 +293,15 @@ function reportCall(ctx: Ctx, message: unknown): unknown | undefined {
286293 typeof m["params"] === "object" && m["params"] !== null
outdated

Dead code. name !== REPORT_TOOL.name already rejects every name but report, so the next line !TOKEN_FREE_TOOLS.includes(...) can never change the result. The comment admits the gate still has a handler for report only. 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.

287294 ? (m["params"] as Record<string, unknown>)
288295 : {};
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;
289301 if (
290302 m["jsonrpc"] !== "2.0" ||
291303 m["method"] !== "tools/call" ||
292 params["name"] !== REPORT_TOOL.name ||
304 handler === undefined ||
293305 m["id"] === undefined
294306 )
295307 return undefined;
@@ -297,9 +309,13 @@ function reportCall(ctx: Ctx, message: unknown): unknown | undefined {
297309 typeof params["arguments"] === "object" && params["arguments"] !== null
298310 ? (params["arguments"] as Args)
299311 : {};
312 return handler(ctx, m["id"], args);
313 }
314
315 function reportAnswer(ctx: Ctx, id: unknown, args: Args): unknown {
300316 const text = (t: string, isError = false) => ({
301317 jsonrpc: "2.0",
302 id: m["id"],
318 id,
303319 result: { content: [{ type: "text", text: t }], ...(isError ? { isError } : {}) },
304320 });
305321 const [owner, name, ...rest] = str(args["repo"]).split("/");
src/web/llms_tools.ts +4 −1
@@ -7,6 +7,7 @@
77 * token-free tools and one line for the rest, so /llms.txt varies by reader.
88 */
99 import type { Tool } from "../http/mcp/shared.ts";
10 import { TOKEN_FREE_TOOLS } from "../core/mcp_card.ts";
1011
1112 /** What a line reads: `report` is not a `Tool`, having no `run`, but is listed. */
1213 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();
1617 export function toolLine(t: Listed): string {
1718 const admin = t.adminOnly === true || t.needsAdminScope === true ? " (admin)" : "";
1819 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}`;
2023 }
2124
2225 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
443443 ]),
444444 "## Find things (no account)",
445445 "",
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.)`,
448448 "- Both read only what is public to you.",
449449 "",
450450 "## For agents with an account",
test/explore-mcp-q.test.ts +1 −1
@@ -75,6 +75,6 @@ test("tools/list shows q first", async () => {
7575
7676 test("llms.txt names every filter explore reads", async () => {
7777 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"')) ?? "";
7979 for (const k of ["kind:", "sort:", "updated:", "owner:", "archived:", "1d, 7d or 30d", "yes or no"]) expect(line).toContain(k);
8080 });
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
1515 expect(body).toContain("## Find things (no account)");
1616 expect(body).toContain("/api/v1/explore?q=kind:app+sort:forks+garden");
1717 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');
2020 expect(body).toMatch(/## Public repositories\n\nNone yet\.\n/);
2121 } finally {
2222 servers.stop();
test/llms-tools.test.ts +1 −1
@@ -47,7 +47,7 @@ describe("/llms.txt MCP tools", () => {
4747 const listed = lines.slice(start + 1, end).filter((l) => l.startsWith("- "));
4848 const expected = [...TOOLS, REPORT_TOOL].map((t: Listed) => {
4949 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)" : ""}`;
5151 });
5252 expect(listed).toEqual(expected);
5353 expect(TOOLS.some((t) => t.writes)).toBe(true);
test/standards.test.ts +25
@@ -245,6 +245,31 @@ describe("what the open web expects", () => {
245245 expect(await fetch(`${base}/llms.txt`).then((r) => r.text())).toContain(`${base}/api/v1 (the root`);
246246 });
247247
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
248273 test("llms.txt says how to use the forge from a harness, with the endpoint built from the URL", async () => {
249274 const body = await fetch(`${base}/llms.txt`).then((r) => r.text());
250275 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";
66 import { createApp, type App } from "../src/app.ts";
77 import { serve, type Servers } from "../src/http/server.ts";
88 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";
1010
1111 describe("token-free MCP tools", () => {
1212 let dataDir: string;
@@ -57,3 +57,7 @@ describe("token-free MCP tools", () => {
5757 expect(open.sort()).toEqual([...TOKEN_FREE_TOOLS].sort());
5858 });
5959 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs