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

Merged opened this

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

Merged

scout/forge:scout/3797-mcp-search-find-issues-pulls-repositorie → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/3797-mcp-search-find-issues-pulls-repositorie was merged into main as 1387b4d.

Review

Sign in to review this change.
6 files changed +179 −9
docs/MCP.md +1
@@ -53,6 +53,7 @@ for a tunnel and a forge that sleeps.
5353 | `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 | |
5454 | `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 |
5555 | `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 | |
5657 | `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` | |
5758 | `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 |
5859 | `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 {
2020 cleanQuery,
2121 grepRepos,
2222 searchLocalIssues,
23 matchOneRepo,
2324 searchLocalRepos,
2425 searchOriginRepos,
2526 MAX_CODE_HITS,
@@ -28,6 +29,7 @@ import {
2829 type SearchKind,
2930 type SearchResults,
3031 } from "../core/search.ts";
32 import type { Repo } from "../core/repos.ts";
3133 import type { User } from "../core/users.ts";
3234 import { providerForOrigin } from "../origins/credential.ts";
3335
@@ -57,6 +59,13 @@ export interface SearchInput {
5759 * @default true
5860 */
5961 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;
6069 /** Start no new grep after this many ms and report `moreCode`. @default unset: grep every repository */
6170 codeBudgetMs?: number;
6271 }
@@ -91,16 +100,20 @@ export async function search(
91100 // One visibility pass. Issues and code both search "the repos this actor
92101 // may read", so it is resolved once and shared rather than re-derived.
93102 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 : [];
97108
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 ];
104117
105118 const issues: IssueHit[] = wants("issues")
106119 ? searchLocalIssues(
src/core/search.ts +7
@@ -162,6 +162,13 @@ export function searchLocalRepos(
162162 return repos.slice(0, limit).map(toRepoHit);
163163 }
164164
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
165172 function toRepoHit(repo: Repo): RepoHit {
166173 return {
167174 source: "local",
src/http/mcp.ts +38
@@ -187,6 +187,7 @@ import {
187187 exploreLine,
188188 parseExploreQuery,
189189 } from "../actions/explore.ts";
190 import { search } from "../actions/search.ts";
190191 import { frontInput, frontJson, setFront } from "../actions/front.ts";
191192 import { readHealth } from "../actions/health.ts";
192193 import { noteRead, telemetryReader } from "../actions/telemetry.ts";
@@ -2071,6 +2072,43 @@ const TOOLS: readonly Tool[] = [
20712072 });
20722073 },
20732074 },
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, {

Low: /search passes codeBudgetMs (CODE_BUDGET_MS) so a code search stops starting greps after its budget and reports moreCode. Here every visible repository is grepped to completion on every call. Passing codeBudgetMs: ctx.app.config.codeBudgetMs ?? CODE_BUDGET_MS would 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.

2105 q,
2106 ...(kind ? { kind } : {}),
2107 ...(only ? { repo: only } : {}),
2108 }),
2109 );
2110 },
2111 },
20742112 {
20752113 name: "feed",
20762114 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", () => {
215215 "asked",
216216 "app_members",
217217 "explore",
218 "search",
218219 "feed",
219220 "feed_chips",
220221 "make_it_yours",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs