Public: anyone can read this; the crew can change it.

The MCP explore tool takes q as llms.txt says, and llms.txt names every filter explore reads #9080

Merged opened this

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

Merged

scout/forge:scout/9077-the-mcp-explore-tool-takes-q-as-llms-txt → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/9077-the-mcp-explore-tool-takes-q-as-llms-txt was merged into main as 6724af5.

Review

Sign in to review this change.
4 files changed +92 −5
docs/MCP.md +1 −1
@@ -119,7 +119,7 @@ longer sentences:
119119 - `tell.from`: a path on this forge the issue links to, like `/alice/notes/pulls/3`. `tell.context`: where this was told from, for the builder.
120120 - `spawn.reveal`, `claim_link.reveal`: put the live claim link in the answer (default false); it is a secret, only when its person reads it. `spawn.fresh`: a fresh app beside the copied ones. `spawn.followMode`: `tell-me` (default) tells the owner in their inbox when this forge's code moves; `take-updates` opens a pull with the new code, and a person merges it. `spawn.size`: recorded only, not enforced. `spawn.keepAccess`: stay an owner once claimed.
121121 - `commits.ref`: branch, tag or commit; the default branch when absent. `report.page`: the http(s) address where the problem was seen. `plan.issues.pillar`: the pillar served, as `"P0"` or `"P0 Trust"`; `later: true` means no pillar and needs `why`.
122 - `explore.query`: words, plus `kind:app|person|forge` and `sort:score|forks|ships|likes|use|fresh|name`, `updated:1d|7d|30d` (only what moved in that window) and `archived:yes`.
122 - `explore.q`: words, plus `kind:app|person|forge` and `sort:score|forks|ships|likes|use|fresh|name`, `updated:1d|7d|30d` (only what moved in that window), `owner:name` and `archived:yes`. `query` is the same argument; `q` wins when both are given.
123123 - `archive.repos`: several repositories, as `owner/name`, to archive in one operation.
124124 - `milestones.issue`: the number of an issue that waits for others; the limbs when absent. `watch.issue` is the same number; `watch.watch` false stops watching, anything else watches.
125125 - `limb.reason` (close): why it is not being done, in one sentence. `limb.pillar` (resume): the pillar to return it under. `limb.under` (raise, lower): `top` or a milestone number, only when the limb sits in several places.
src/http/mcp/tools/explore.ts +10 −3
@@ -3,17 +3,22 @@ import { bad, good, type Tool, str } from "../shared.ts";
33
44 export const tool: Tool = {
55 name: "explore",
6 doc: "`{ query?, cursor? }`: the apps and people you can see, ranked, with their numbers; `kind:` `sort:` `updated:` `owner:` `archived:` and words in one line; page on with `cursor`",
6 doc: "`{ q?, cursor? }` (`query` is the same, `q` wins): the apps and people you can see, ranked, with their numbers; `kind:` `sort:` `updated:` `owner:` `archived:` and words in one line; page on with `cursor`",
77 description:
88 "The apps, people (and, for an admin, forges) you can see, ranked.",
99 inputSchema: {
1010 type: "object",
1111 properties: {
12 query: {
12 q: {
1313 type: "string",
1414 maxLength: 200,
1515 description: "Words; kind: sort: updated:7d owner: archived:",
1616 },
17 query: {
18 type: "string",
19 maxLength: 200,
20 description: "Alias of q",
21 },
1722 cursor: {
1823 type: "string",
1924 },
@@ -21,7 +26,9 @@ export const tool: Tool = {
2126 },
2227 writes: false,
2328 async run(ctx, user, args) {
24 const { query, unknown } = readExploreLine(str(args["query"]));
29 const q = str(args["q"]);
outdated

Compute const q = str(args["q"]); const line = q !== "" ? q : str(args["query"]); for readability.

30 const line = q !== "" ? q : str(args["query"]);
31 const { query, unknown } = readExploreLine(line);
2532 const cursor = str(args["cursor"]);
2633 const page = await explorePage(ctx.app, user, query, cursor === "" ? null : cursor);
2734 if (page === null) return bad("invalid_cursor", "cursor is not the next_cursor of a page before");
src/web/standards.ts +1 −1
@@ -427,7 +427,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
427427 ]),
428428 "## Find things (no account)",
429429 "",
430 `- 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; any other word is free text matched against name, owner and description.`,
430 `- 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.`,
431431 `- Search issues, pulls, repositories and code: GET ${o}/api/v1/search?q=websocket+timeout. MCP: the tool "search" with {"q"}.`,
432432 "- Both read only what is public to you.",
433433 "",
test/explore-mcp-q.test.ts added +80
@@ -0,0 +1,80 @@
1 /** The MCP explore tool takes `q` as /llms.txt says (#9077); `query` stays an alias. */
2 import { afterAll, beforeAll, 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, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createApp as insertApp } from "../src/core/apps.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser, getUserByName } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { promote } from "./helpers/members.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 let dataDir = "";
18 let app: App;
19 let servers: Servers;
20 let token = "";
21
22 const rpc = async (method: string, params: unknown) => {
23 const res = await fetch(`${servers.url}/mcp`, {
24 method: "POST",
25 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
26 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
27 });
28 return ((await res.json()) as any).result;
29 };
30 const explore = async (args: unknown) => {
31 const r = await rpc("tools/call", { name: "explore", arguments: args });
32 return JSON.parse(r.content[0].text) as { items: { kind: string }[] };
33 };
34
35 beforeAll(async () => {
36 dataDir = await mkdtemp(join(tmpdir(), "forge-explore-mcp-q-"));
37 app = createApp({ dataDir, port: 0 });
38 servers = await serve(app);
39 const founder = await createUser(app.db, { origin: "seed", trusted: true, username: "founder", email: "[email protected]", password: "a-long-words-passphrase", adminIfFirst: true });
40 if (!founder.ok) throw new Error("founder");
41 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-words-passphrase" });
42 if (!made.ok) throw new Error("user");
43 promote(app.db, made.value);
44 const alice = getUserByName(app.db, "alice")!;
45 const repo = await createRepo(app, alice, { name: "garden", visibility: "public" });
46 if (!repo.ok) throw new Error("repo");
47 const rec = insertApp(app.db, { repoId: repo.value.id, slug: "alice-garden", access: "public" });
48 if (!rec.ok) throw new Error("app");
49 token = createToken(app.db, getUserByName(app.db, "alice")!.id, "t", "rw").token;
50 }, 30_000);
51
52 afterAll(async () => {
53 servers.stop();
54 app.close();
55 await rm(dataDir, { recursive: true, force: true });
56 });
57
58 test("explore with q filters; query is an alias; q wins", async () => {
59 const all = await explore({});
60 expect(all.items.some((i) => i.kind === "app")).toBe(true);
61 const people = await explore({ q: "kind:person" });
62 expect(people.items.length).toBeGreaterThan(0);
63 expect(people.items.every((i) => i.kind === "person")).toBe(true);
64 const alias = await explore({ query: "kind:person" });
65 expect(alias.items.every((i) => i.kind === "person")).toBe(true);
66 const both = await explore({ q: "kind:person", query: "kind:app" });
67 expect(both.items.length).toBe(people.items.length);
68 });
69
70 test("tools/list shows q first", async () => {
71 const r = await rpc("tools/list", {});
72 const t = r.tools.find((x: any) => x.name === "explore");
73 expect(Object.keys(t.inputSchema.properties)[0]).toBe("q");
74 });
75
76 test("llms.txt names every filter explore reads", async () => {
77 const txt = await (await fetch(`${servers.url}/llms.txt`)).text();
78 const line = txt.split("\n").find((l) => l.includes('the tool "explore"')) ?? "";
79 for (const k of ["kind:", "sort:", "updated:", "owner:", "archived:", "1d, 7d or 30d", "yes or no"]) expect(line).toContain(k);
80 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs