Public: anyone can read this; the crew can change it.
MCP `assigned`: the issues and pulls assigned to the caller across every repository #4829
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4809-mcp-assigned-the-issues-and-pulls-assign → main 1 commit fork
4 files
changed +129
−0
docs/MCP.md +1
| @@ -56,6 +56,7 @@ for a tunnel and a forge that sleeps. | ||
| 56 | 56 | | `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 | |
| 57 | 57 | | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes | |
| 58 | 58 | | `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` | | | |
| 59 | 60 | | `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 | |
| 60 | 61 | | `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 | | |
| 61 | 62 | | `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 { | ||
| 109 | 109 | getIssue, |
| 110 | 110 | getIssueById, |
| 111 | 111 | labelsFor, |
| 112 | listAssignedTo, | |
| 112 | 113 | listComments, |
| 113 | 114 | listOpenAgentPullsVisibleTo, |
| 114 | 115 | } from "../core/issues.ts"; |
| @@ -140,6 +141,7 @@ import { | ||
| 140 | 141 | canWrite, |
| 141 | 142 | forkParentFor, |
| 142 | 143 | getRepo, |
| 144 | getReposByIds, | |
| 143 | 145 | isDecisionsBy, |
| 144 | 146 | ownerFactRepoIds, |
| 145 | 147 | type Repo, |
| @@ -2285,6 +2287,30 @@ const TOOLS: readonly Tool[] = [ | ||
| 2285 | 2287 | }); |
| 2286 | 2288 | }, |
| 2287 | 2289 | }, |
| 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 | }, | |
| 2288 | 2314 | { |
| 2289 | 2315 | name: "app_members", |
| 2290 | 2316 | 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", () => { | ||
| 223 | 223 | "next_steps", |
| 224 | 224 | "thought", |
| 225 | 225 | "asked", |
| 226 | "assigned", | |
| 226 | 227 | "app_members", |
| 227 | 228 | "explore", |
| 228 | 229 | "search", |
new above
offline