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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9077-the-mcp-explore-tool-takes-q-as-llms-txt → main 3 commits fork 2 threads · 2 unresolved
4 files
changed +92
−5
docs/MCP.md +1 −1
| @@ -119,7 +119,7 @@ longer sentences: | ||
| 119 | 119 | - `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. |
| 120 | 120 | - `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. |
| 121 | 121 | - `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. | |
| 123 | 123 | - `archive.repos`: several repositories, as `owner/name`, to archive in one operation. |
| 124 | 124 | - `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. |
| 125 | 125 | - `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"; | ||
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | 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`", | |
| 7 | 7 | description: |
| 8 | 8 | "The apps, people (and, for an admin, forges) you can see, ranked.", |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
| 12 | query: { | |
| 12 | q: { | |
| 13 | 13 | type: "string", |
| 14 | 14 | maxLength: 200, |
| 15 | 15 | description: "Words; kind: sort: updated:7d owner: archived:", |
| 16 | 16 | }, |
| 17 | query: { | |
| 18 | type: "string", | |
| 19 | maxLength: 200, | |
| 20 | description: "Alias of q", | |
| 21 | }, | |
| 17 | 22 | cursor: { |
| 18 | 23 | type: "string", |
| 19 | 24 | }, |
| @@ -21,7 +26,9 @@ export const tool: Tool = { | ||
| 21 | 26 | }, |
| 22 | 27 | writes: false, |
| 23 | 28 | async run(ctx, user, args) { |
| 24 | const { query, unknown } = readExploreLine(str(args["query"])); | |
| 29 | const q = str(args["q"]); | |
| 30 | const line = q !== "" ? q : str(args["query"]); | |
| 31 | const { query, unknown } = readExploreLine(line); | |
| 25 | 32 | const cursor = str(args["cursor"]); |
| 26 | 33 | const page = await explorePage(ctx.app, user, query, cursor === "" ? null : cursor); |
| 27 | 34 | 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 | ||
| 427 | 427 | ]), |
| 428 | 428 | "## Find things (no account)", |
| 429 | 429 | "", |
| 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.`, | |
| 431 | 431 | `- Search issues, pulls, repositories and code: GET ${o}/api/v1/search?q=websocket+timeout. MCP: the tool "search" with {"q"}.`, |
| 432 | 432 | "- Both read only what is public to you.", |
| 433 | 433 | "", |
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 | }); |
new above
offline
Compute
const q = str(args["q"]); const line = q !== "" ? q : str(args["query"]);for readability.