Public: anyone can read this; the crew can change it.
MCP `search`: find issues, pulls, repositories and code with the same results as /search #3812
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3797-mcp-search-find-issues-pulls-repositorie → main 3 commits fork 4 threads · 4 unresolved
6 files
changed +179
−9
docs/MCP.md +1
| @@ -53,6 +53,7 @@ for a tunnel and a forge that sleeps. | ||
| 53 | 53 | | `asked` | `{ repo?, show?, order? }`: what people asked the crew for, by default nothing merged yet and oldest first: who and when, the pulls that close it, the crew's last activity, waiting/building/shipped/closed, stale when not shipped in three days and flagged when stale with nothing built; repositories you can write | | |
| 54 | 54 | | `app_members` | `{ repo, members?, dryRun? }`: the people a private app names, who may enter it and its previews and nothing else; a writer (never an agent) changes the list with an admin-scope token | yes | |
| 55 | 55 | | `explore` | `{ query? }`: the apps and people you can see, ranked by forks, ships, use and freshness, with their numbers; `kind:` `sort:` and words in one line | | |
| 56 | | `search` | `{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one | | | |
| 56 | 57 | | `feed` | `{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed` | | |
| 57 | 58 | | `make_it_yours` | `{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false | yes | |
| 58 | 59 | | `front_page` | `{ pinned?, hidden?, weights?, dryRun? }`: the front page as a stranger sees it, ranked, with numbers; a site admin sets what is pinned (first public one featured), hidden, and what counts (weights of forks, ships, use, fresh, 0 to 10) | yes | |
src/actions/search.ts +22 −9
| @@ -20,6 +20,7 @@ import { | ||
| 20 | 20 | cleanQuery, |
| 21 | 21 | grepRepos, |
| 22 | 22 | searchLocalIssues, |
| 23 | matchOneRepo, | |
| 23 | 24 | searchLocalRepos, |
| 24 | 25 | searchOriginRepos, |
| 25 | 26 | MAX_CODE_HITS, |
| @@ -28,6 +29,7 @@ import { | ||
| 28 | 29 | type SearchKind, |
| 29 | 30 | type SearchResults, |
| 30 | 31 | } from "../core/search.ts"; |
| 32 | import type { Repo } from "../core/repos.ts"; | |
| 31 | 33 | import type { User } from "../core/users.ts"; |
| 32 | 34 | import { providerForOrigin } from "../origins/credential.ts"; |
| 33 | 35 | |
| @@ -57,6 +59,13 @@ export interface SearchInput { | ||
| 57 | 59 | * @default true |
| 58 | 60 | */ |
| 59 | 61 | withCode?: boolean; |
| 62 | /** | |
| 63 | * Keep to this one repository, which the caller has already been checked | |
| 64 | * to read. It is the whole scope, not a filter on a capped answer: its | |
| 65 | * issues and code are searched even when it is not among the first | |
| 66 | * `MAX_SEARCH_REPOS` visible, and origin repositories are skipped. | |
| 67 | */ | |
| 68 | repo?: Repo; | |
| 60 | 69 | /** Start no new grep after this many ms and report `moreCode`. @default unset: grep every repository */ |
| 61 | 70 | codeBudgetMs?: number; |
| 62 | 71 | } |
| @@ -91,16 +100,20 @@ export async function search( | ||
| 91 | 100 | // One visibility pass. Issues and code both search "the repos this actor |
| 92 | 101 | // may read", so it is resolved once and shared rather than re-derived. |
| 93 | 102 | const visible = |
| 94 | wants("issues") || wants("code") | |
| 95 | ? listReposVisibleTo(app.db, actor, { limit: MAX_SEARCH_REPOS }) | |
| 96 | : []; | |
| 103 | input.repo !== undefined | |
| 104 | ? [input.repo] | |
| 105 | : wants("issues") || wants("code") | |
| 106 | ? listReposVisibleTo(app.db, actor, { limit: MAX_SEARCH_REPOS }) | |
| 107 | : []; | |
| 97 | 108 | |
| 98 | const repos = wants("repos") | |
| 99 | ? [ | |
| 100 | ...searchLocalRepos(app.db, actor, q, limit), | |
| 101 | ...(actor ? searchOriginRepos(app.db, actor.id, q, limit) : []), | |
| 102 | ] | |
| 103 | : []; | |
| 109 | const repos = !wants("repos") | |
| 110 | ? [] | |
| 111 | : input.repo !== undefined | |
| 112 | ? matchOneRepo(input.repo, q) | |
| 113 | : [ | |
| 114 | ...searchLocalRepos(app.db, actor, q, limit), | |
| 115 | ...(actor ? searchOriginRepos(app.db, actor.id, q, limit) : []), | |
| 116 | ]; | |
| 104 | 117 | |
| 105 | 118 | const issues: IssueHit[] = wants("issues") |
| 106 | 119 | ? searchLocalIssues( |
src/core/search.ts +7
| @@ -162,6 +162,13 @@ export function searchLocalRepos( | ||
| 162 | 162 | return repos.slice(0, limit).map(toRepoHit); |
| 163 | 163 | } |
| 164 | 164 | |
| 165 | /** The one repository a scoped search is about, when its owner, name or description holds the words. */ | |
| 166 | export function matchOneRepo(repo: Repo, query: string): RepoHit[] { | |
| 167 | const needle = query.trim().toLowerCase(); | |
| 168 | const hay = `${repo.ownerName}/${repo.name} ${repo.description}`.toLowerCase(); | |
| 169 | return needle !== "" && hay.includes(needle) ? [toRepoHit(repo)] : []; | |
| 170 | } | |
| 171 | ||
| 165 | 172 | function toRepoHit(repo: Repo): RepoHit { |
| 166 | 173 | return { |
| 167 | 174 | source: "local", |
src/http/mcp.ts +38
| @@ -187,6 +187,7 @@ import { | ||
| 187 | 187 | exploreLine, |
| 188 | 188 | parseExploreQuery, |
| 189 | 189 | } from "../actions/explore.ts"; |
| 190 | import { search } from "../actions/search.ts"; | |
| 190 | 191 | import { frontInput, frontJson, setFront } from "../actions/front.ts"; |
| 191 | 192 | import { readHealth } from "../actions/health.ts"; |
| 192 | 193 | import { noteRead, telemetryReader } from "../actions/telemetry.ts"; |
| @@ -2071,6 +2072,43 @@ const TOOLS: readonly Tool[] = [ | ||
| 2071 | 2072 | }); |
| 2072 | 2073 | }, |
| 2073 | 2074 | }, |
| 2075 | { | |
| 2076 | name: "search", | |
| 2077 | description: | |
| 2078 | "Find issues, pulls, repositories and code you can read, the same results as /search; `q` is the words, `kind` narrows it, `repo` (owner/name) keeps to one repository.", | |
| 2079 | inputSchema: { | |
| 2080 | type: "object", | |
| 2081 | properties: { | |
| 2082 | q: { type: "string", minLength: 1, maxLength: 200 }, | |
| 2083 | kind: { type: "string", enum: ["issues", "repos", "code"] }, | |
| 2084 | repo: { type: "string", maxLength: 140 }, | |
| 2085 | }, | |
| 2086 | required: ["q"], | |
| 2087 | }, | |
| 2088 | writes: false, | |
| 2089 | async run(ctx, user, args) { | |
| 2090 | const q = args["q"]; | |
| 2091 | if (typeof q !== "string" || q.trim() === "" || q.length > 200) | |
| 2092 | return bad("invalid", "`q` is 1 to 200 characters"); | |
| 2093 | const kind = args["kind"]; | |
| 2094 | if (kind !== undefined && kind !== "issues" && kind !== "repos" && kind !== "code") | |
| 2095 | return bad("invalid", "`kind` is issues, repos or code"); | |
| 2096 | let only: Repo | undefined; | |
| 2097 | if (args["repo"] !== undefined) { | |
| 2098 | if (typeof args["repo"] !== "string") return bad("invalid", "`repo` is owner/name"); | |
| 2099 | const repo = repoArg(ctx, user, args); | |
| 2100 | if (isAnswer(repo)) return repo; | |
| 2101 | only = repo; | |
| 2102 | } | |
| 2103 | return good( | |
| 2104 | await search(ctx.app, user, { | |
| 2105 | q, | |
| 2106 | ...(kind ? { kind } : {}), | |
| 2107 | ...(only ? { repo: only } : {}), | |
| 2108 | }), | |
| 2109 | ); | |
| 2110 | }, | |
| 2111 | }, | |
| 2074 | 2112 | { |
| 2075 | 2113 | name: "feed", |
| 2076 | 2114 | description: |
test/mcp-search.test.ts added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | /** | |
| 2 | * The `search` tool answers what /api/v1/search answers the same caller, | |
| 3 | * never more: a private repository is not found to a stranger, and bad | |
| 4 | * arguments stop at the boundary. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createIssue } from "../src/core/issues.ts"; | |
| 13 | import { createToken } from "../src/core/tokens.ts"; | |
| 14 | import { SEARCH_LIMIT } from "../src/actions/search.ts"; | |
| 15 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | ||
| 18 | describe("MCP search", () => { | |
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let aliceRo: string; | |
| 23 | let bobRo: string; | |
| 24 | let id = 1; | |
| 25 | ||
| 26 | const tool = async (token: string, args: unknown) => { | |
| 27 | const res = await fetch(`${servers.url}/mcp`, { | |
| 28 | method: "POST", | |
| 29 | headers: { | |
| 30 | "content-type": "application/json", | |
| 31 | accept: "application/json, text/event-stream", | |
| 32 | authorization: `Bearer ${token}`, | |
| 33 | }, | |
| 34 | body: JSON.stringify({ | |
| 35 | jsonrpc: "2.0", | |
| 36 | id: id++, | |
| 37 | method: "tools/call", | |
| 38 | params: { name: "search", arguments: args }, | |
| 39 | }), | |
| 40 | }); | |
| 41 | const body: any = await res.json(); | |
| 42 | const text: string = body.result.content[0].text; | |
| 43 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 44 | }; | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-search-")); | |
| 48 | app = createApp({ dataDir, port: 0 }); | |
| 49 | servers = await serve(app); | |
| 50 | const mk = async (username: string, first = false) => { | |
| 51 | const u = await createUser(app.db, { | |
| 52 | origin: "seed", | |
| 53 | trusted: true, | |
| 54 | username, | |
| 55 | email: `${username}@forge.local`, | |
| 56 | password: "a-good-passphrase", | |
| 57 | adminIfFirst: first, | |
| 58 | }); | |
| 59 | if (!u.ok) throw new Error(u.error); | |
| 60 | return u.value; | |
| 61 | }; | |
| 62 | const alice = await mk("alice", true); | |
| 63 | const bob = await mk("bob"); | |
| 64 | aliceRo = createToken(app.db, alice.id, "ro", "ro").token; | |
| 65 | bobRo = createToken(app.db, bob.id, "ro", "ro").token; | |
| 66 | for (const [name, visibility] of [["pebble", "public"], ["secretpebble", "private"]] as const) { | |
| 67 | const made = await createRepo(app, alice, { name, visibility }); | |
| 68 | if (!made.ok) throw new Error(String(made.error)); | |
| 69 | createIssue(app.db, { repoId: made.value.id, authorId: alice.id, title: `Polish the ${name} glow` }); | |
| 70 | } | |
| 71 | }, 30_000); | |
| 72 | ||
| 73 | afterAll(async () => { | |
| 74 | servers.stop(); | |
| 75 | app.close(); | |
| 76 | await rm(dataDir, { recursive: true, force: true }); | |
| 77 | }); | |
| 78 | ||
| 79 | test("a good answer: the owner finds the issue and the repository", async () => { | |
| 80 | const r = await tool(aliceRo, { q: "glow", kind: "issues" }); | |
| 81 | expect(r.value.issues.map((i: any) => i.repo.name).sort()).toEqual(["pebble", "secretpebble"]); | |
outdated
With two repositories this passes whether the scope is real or a post-filter. Add a case where more than SEARCH_LIMIT matching issues in another repository crowd out the scoped one. |
||
| 82 | const repos = await tool(aliceRo, { q: "pebble", kind: "repos", repo: "alice/pebble" }); | |
| 83 | expect(repos.value.repos.map((x: any) => x.name)).toEqual(["pebble"]); | |
| 84 | }); | |
| 85 | ||
| 86 | test("a caller who cannot read a private repository finds nothing of it", async () => { | |
| 87 | const r = await tool(bobRo, { q: "glow", kind: "issues" }); | |
| 88 | expect(r.value.issues.map((i: any) => i.repo.name)).toEqual(["pebble"]); | |
| 89 | const named = await tool(bobRo, { q: "glow", repo: "alice/secretpebble" }); | |
| 90 | expect(named.error).toContain("no such app or repository"); | |
| 91 | }); | |
| 92 | ||
| 93 | test("repo is the scope, not a filter: newer matches elsewhere do not crowd it out", async () => { | |
| 94 | const alice = getUserByName(app.db, "alice")!; | |
| 95 | const noisy = await createRepo(app, alice, { name: "noisy", visibility: "public" }); | |
| 96 | if (!noisy.ok) throw new Error(String(noisy.error)); | |
| 97 | for (let i = 0; i < SEARCH_LIMIT + 5; i++) | |
| 98 | createIssue(app.db, { repoId: noisy.value.id, authorId: alice.id, title: `Polish the glow ${i}` }); | |
| 99 | const wide = await tool(aliceRo, { q: "glow", kind: "issues" }); | |
| 100 | expect(wide.value.issues.some((i: any) => i.repo.name === "pebble")).toBe(false); | |
| 101 | const scoped = await tool(aliceRo, { q: "glow", kind: "issues", repo: "alice/pebble" }); | |
| 102 | expect(scoped.value.issues.map((i: any) => i.repo.name)).toEqual(["pebble"]); | |
| 103 | }); | |
| 104 | ||
| 105 | test("bad arguments are refused", async () => { | |
| 106 | for (const args of [{}, { q: "" }, { q: "x".repeat(201) }, { q: 5 }, { q: "a", kind: "all" }, { q: "a", repo: 3 }]) { | |
| 107 | expect((await tool(aliceRo, args)).error).toBeDefined(); | |
| 108 | } | |
| 109 | }); | |
| 110 | }); |
test/mcp.test.ts +1
| @@ -215,6 +215,7 @@ describe("the forge over MCP", () => { | ||
| 215 | 215 | "asked", |
| 216 | 216 | "app_members", |
| 217 | 217 | "explore", |
| 218 | "search", | |
| 218 | 219 | "feed", |
| 219 | 220 | "feed_chips", |
| 220 | 221 | "make_it_yours", |
new above
offline
Low: /search passes
codeBudgetMs(CODE_BUDGET_MS) so a code search stops starting greps after its budget and reportsmoreCode. Here every visible repository is grepped to completion on every call. PassingcodeBudgetMs: ctx.app.config.codeBudgetMs ?? CODE_BUDGET_MSwould make this match the page and keep a chatty agent's calls short. It is not blocking, because REST behaves the same for signed-in callers.