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

MCP `assigned`: the issues and pulls assigned to the caller across every repository #4829

Merged opened this

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

Merged

scout/forge:scout/4809-mcp-assigned-the-issues-and-pulls-assign → main 1 commit fork

  • Merged. scout/4809-mcp-assigned-the-issues-and-pulls-assign was merged into main as 8a73ba8.

Review

Sign in to review this change.
4 files changed +129 −0
docs/MCP.md +1
@@ -56,6 +56,7 @@ for a tunnel and a forge that sleeps.
5656 | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused. On a spawned forge's code repository it opens the one `forge/take-updates` pull and never moves the default branch | yes |
5757 | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes |
5858 | `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 | |
59 | `assigned` | `{ limit? }`: the open issues and pulls assigned to you in every repository you can still read (repository, number, title, kind, state), as `GET /api/v1/user/assigned` | |
5960 | `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 |
6061 | `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 | |
6162 | `search` | `{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one | |
src/http/mcp.ts +26
@@ -109,6 +109,7 @@ import {
109109 getIssue,
110110 getIssueById,
111111 labelsFor,
112 listAssignedTo,
112113 listComments,
113114 listOpenAgentPullsVisibleTo,
114115 } from "../core/issues.ts";
@@ -140,6 +141,7 @@ import {
140141 canWrite,
141142 forkParentFor,
142143 getRepo,
144 getReposByIds,
143145 isDecisionsBy,
144146 ownerFactRepoIds,
145147 type Repo,
@@ -2285,6 +2287,30 @@ const TOOLS: readonly Tool[] = [
22852287 });
22862288 },
22872289 },
2290 {
2291 name: "assigned",
2292 description: "The open issues and pulls assigned to you in every repository you can read.",
2293 inputSchema: {
2294 type: "object",
2295 properties: { limit: { type: "integer", minimum: 1, maximum: 50 } },
2296 },
2297 writes: false,
2298 run(ctx, user, args) {
2299 const limit = args["limit"] ?? 50;
2300 if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 50)
2301 return bad("invalid", "`limit` is a whole number from 1 to 50");
2302 const db = ctx.app.db;
2303 const issues = listAssignedTo(db, user.id, limit);
2304 const repos = getReposByIds(db, issues.map((i) => i.repoId));
2305 const items = [];
2306 for (const issue of issues) {
2307 const repo = repos.get(issue.repoId);
2308 if (!repo || !canRead(db, repo, user) || !mayView(db, repo, issue, user)) continue;
2309 items.push({ ...issue, repo: { owner: repo.ownerName, name: repo.name } });
2310 }
2311 return good({ items, next_cursor: null });
2312 },
2313 },
22882314 {
22892315 name: "app_members",
22902316 description:
test/mcp-assigned.test.ts added +101
@@ -0,0 +1,101 @@
1 /** The `assigned` tool answers what GET /api/v1/user/assigned answers: what is mine, across repositories. */
2 import { afterAll, beforeAll, describe, 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 { createIssue } from "../src/core/issues.ts";
9 import { createToken } from "../src/core/tokens.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { promote } from "./helpers/members.ts";
13
14 describe("MCP assigned", () => {
15 let dataDir: string;
16 let app: App;
17 let servers: Servers;
18 let bobTok: string;
19 let carolTok: string;
20 let id = 1;
21
22 const call = async (token: string, args: unknown = {}) => {
23 const res = await fetch(`${servers.url}/mcp`, {
24 method: "POST",
25 headers: {
26 "content-type": "application/json",
27 accept: "application/json, text/event-stream",
28 authorization: `Bearer ${token}`,
29 },
30 body: JSON.stringify({
31 jsonrpc: "2.0",
32 id: id++,
33 method: "tools/call",
34 params: { name: "assigned", arguments: args },
35 }),
36 });
37 const body: any = await res.json();
38 const text: string = body.result.content[0].text;
39 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
40 };
41
42 beforeAll(async () => {
43 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-assigned-"));
44 app = createApp({ dataDir, port: 0 });
45 servers = await serve(app);
46 const mk = async (username: string, first = false) => {
47 const u = await createUser(app.db, {
48 origin: "seed",
49 trusted: true,
50 username,
51 email: `${username}@forge.local`,
52 password: "a-good-passphrase",
53 adminIfFirst: first,
54 });
55 if (!u.ok) throw new Error(u.error);
56 promote(app.db, u.value);
57 return u.value;
58 };
59 const alice = await mk("alice", true);
60 const bob = await mk("bob");
61 const carol = await mk("carol");
62 bobTok = createToken(app.db, bob.id, "ro", "ro").token;
63 carolTok = createToken(app.db, carol.id, "ro", "ro").token;
64 for (const name of ["one", "two"]) {
65 const made = await createRepo(app, alice, { name, visibility: "public" });
66 if (!made.ok) throw new Error(String(made.error));
67 const issue = createIssue(app.db, { repoId: made.value.id, authorId: alice.id, title: `task in ${name}` });
68 if (!issue.ok) throw new Error(issue.error);
69 app.db.query("UPDATE issue SET assignee_id = ?1 WHERE id = ?2").run(bob.id, issue.value.id);
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("an assignee sees the item in each repository, and the tool matches the API", async () => {
80 const got = await call(bobTok);
81 const names = got.value.items.map((i: any) => `${i.repo.owner}/${i.repo.name}#${i.number}`).sort();
82 expect(names).toEqual(["alice/one#1", "alice/two#1"]);
83 expect(got.value.items[0].title).toStartWith("task in");
84 const api: any = await (
85 await fetch(`${servers.url}/api/v1/user/assigned`, { headers: { authorization: `Bearer ${bobTok}` } })
86 ).json();
87 expect(got.value).toEqual(api);
88 expect((await call(bobTok, { limit: 1 })).value.items.length).toBe(1);
89 expect((await call(bobTok, { limit: 0 })).error).toContain("limit");
90 });
91
92 test("after losing access to one repository, only the other is listed", async () => {
93 app.db.query("UPDATE repo SET visibility = 'private' WHERE name = 'two'").run();
94 const got = await call(bobTok);
95 expect(got.value.items.map((i: any) => i.repo.name)).toEqual(["one"]);
96 });
97
98 test("an unassigned person sees none", async () => {
99 expect((await call(carolTok)).value.items).toEqual([]);
100 });
101 });
test/mcp.test.ts +1
@@ -223,6 +223,7 @@ describe("the forge over MCP", () => {
223223 "next_steps",
224224 "thought",
225225 "asked",
226 "assigned",
226227 "app_members",
227228 "explore",
228229 "search",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs