Public: anyone can read this; the crew can change it.
MCP `assign`: set or clear an issue's or a pull's assignee, with the same rules as the API #5356
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5317-mcp-assign-set-or-clear-an-issue-s-or-a → main 7 commits fork 5 threads · 5 unresolved
29 files
changed +245
−24
docs/MCP.md +1
| @@ -41,6 +41,7 @@ for a tunnel and a forge that sleeps. | ||
| 41 | 41 | | `apps` | the apps you can see: live or not, where served, how each runs itself, what is working | | |
| 42 | 42 | | `archive` | `{ repo | repos, archived?, dryRun? }`: archive (true) or wake (false) a repository, by name; planned first (`dryRun` is true unless `false`); only the repository's admin with an admin token, never an agent. `repos` (at most 50) runs the fleet `archive` operation over them, answers each one's outcome and the `operation` id; `limb` with `action: "undo"`, that `operation` and any one of the repositories as `app` reverts it for a day | yes | |
| 43 | 43 | | `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 | | |
| 44 | | `assign` | `{ repo, number, kind, assignee }`: assign an issue or pull (`kind` is `issue` or `pull`) to a user, or clear it with `null`, as `PUT …/assignee` and with its refusals | yes | | |
| 44 | 45 | | `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` | | |
| 45 | 46 | | `audit` | who can do what, per app, read from its contract.json in git: yes, own or no per door, and whether outsiders may direct its crew (`crewOutsiders`) | | |
| 46 | 47 | | `board_delete` | `{ slug }` (person admins): delete a board by slug; its history keeps it | yes | |
src/http/mcp/tools/asks_act.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "asks_act", |
| 6 | 6 | description: |
| 7 | "Decide asks (`items`: ask ids, `issue:<id>`): dismiss, decline, ship (final), close (`reason`), mute, replan", | |
| 7 | "Decide asks (`items`): dismiss, decline, ship, close (`reason`), mute, replan", | |
| 8 | 8 | inputSchema: { |
| 9 | 9 | type: "object", |
| 10 | 10 | properties: { |
src/http/mcp/tools/asks_decide.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "asks_decide", |
| 6 | 6 | description: |
| 7 | "Let the decider answer a hand-over (`item`: `parked:<id>`); only a sure set_aside (70%+) clears it.", | |
| 7 | "Let the decider answer a hand-over (`item`: `parked:<id>`); only a sure set_aside clears it.", | |
| 8 | 8 | inputSchema: { |
| 9 | 9 | type: "object", |
| 10 | 10 | properties: { item: { type: "string", maxLength: 80 } }, |
src/http/mcp/tools/assign.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | import { getUserByName } from "../../../core/users.ts"; | |
| 2 | import { ARCHIVED_WHY } from "../../../core/repos.ts"; | |
| 3 | import { NOT_A_MEMBER } from "../../../core/roles.ts"; | |
| 4 | import { assignIssue } from "../../../actions/issues.ts"; | |
| 5 | import { good, bad, type Tool, REPO_ARG, repoArg, issueArg, isAnswer } from "../shared.ts"; | |
| 6 | ||
| 7 | export const tool: Tool = { | |
| 8 | name: "assign", | |
| 9 | doc: "`{ repo, number, kind, assignee }`: assign an issue or pull (`kind` is `issue` or `pull`) to a user, or clear it with `null`, as `PUT …/assignee` and with its refusals", | |
| 10 | description: "Assign an issue or pull to a user, or clear (null); PUT refusals apply.", | |
| 11 | inputSchema: { | |
| 12 | type: "object", | |
| 13 | properties: { | |
| 14 | repo: REPO_ARG, | |
| 15 | number: { type: "integer", minimum: 1 }, | |
| 16 | kind: { type: "string", enum: ["issue", "pull"] }, | |
| 17 | assignee: { type: ["string", "null"] }, | |
| 18 | }, | |
| 19 | required: ["repo", "number", "kind", "assignee"], | |
| 20 | }, | |
| 21 | writes: true, | |
| 22 | run(ctx, user, args) { | |
| 23 | const repo = repoArg(ctx, user, args); | |
| 24 | if (isAnswer(repo)) return repo; | |
| 25 | const kind = args["kind"]; | |
| 26 | if (kind !== "issue" && kind !== "pull") return bad("invalid", "kind is issue or pull"); | |
| 27 | const name = args["assignee"]; | |
| 28 | if (name !== null && typeof name !== "string") | |
| 29 | return bad("invalid_assignee", "assignee must be a username or null"); | |
| 30 | const issue = issueArg(ctx, repo, args, "number"); | |
| 31 | if ("ok" in issue) return issue; | |
| 32 | if (issue.kind !== kind) return bad("no_such_issue", `no ${kind} #${issue.number}`); | |
| 33 | const assignee = name === null || name === "" ? null : getUserByName(ctx.app.db, name); | |
| 34 | if (assignee === undefined) return bad("no_such_user", `no account named ${name}`); | |
| 35 | const done = assignIssue(ctx.app, user, repo, issue, assignee); | |
| 36 | if (!done.ok) { | |
| 37 | const e = done.error; | |
| 38 | if (e === "not_found") return bad("not_found", "no such app or repository (owner/name)"); | |
| 39 | if (e === "unverified") return bad(e, "verify your email first"); | |
| 40 | if (e === "archived") return bad(e, ARCHIVED_WHY); | |
| 41 | if (e === "not_member") return bad(e, NOT_A_MEMBER); | |
| 42 | if (e === "assignee_cannot_read") return bad(e, "the assignee cannot read this repository"); | |
| 43 | if (e === "agent_not_on_crew") return bad(e, "that agent is not on this repository's crew"); | |
| 44 | if (user.kind === "agent" && e === "forbidden") | |
| 45 | return bad(e, "an agent may not assign; that is a person's call"); | |
| 46 | if (e === "forbidden" || e === "unauthorized") | |
| 47 | return bad(e, "your credentials lack the scope for this action"); | |
| 48 | return bad(e, "that assignment could not be made"); | |
| 49 | } | |
| 50 | return good({ | |
| 51 | says: assignee | |
| 52 | ? `#${issue.number} is assigned to ${assignee.username}.` | |
| 53 | : `#${issue.number} is unassigned.`, | |
| 54 | number: issue.number, | |
| 55 | kind, | |
| 56 | assignee: assignee?.username ?? null, | |
| 57 | }); | |
| 58 | }, | |
| 59 | }; |
src/http/mcp/tools/claim_link.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "claim_link", |
| 6 | 6 | doc: "`{ name }`: a new claim link for a child you spawned that nobody has claimed; the old one stops working. An admin's", |
| 7 | 7 | description: |
| 8 | "A new claim link for an unclaimed child forge, only with `reveal: true`; the old one stops at once.", | |
| 8 | "A new claim link for an unclaimed child forge, only with `reveal: true`; the old one stops.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/decisions_by.ts +1 −1
| @@ -7,7 +7,7 @@ export const tool: Tool = { | ||
| 7 | 7 | name: "decisions_by", |
| 8 | 8 | doc: "`{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent", |
| 9 | 9 | description: |
| 10 | "Who decides a repository's parked hand-over cards: `me`, `decider`, `decider_tell`. No `by` reads it.", | |
| 10 | "Who decides parked hand-over cards: `me`, `decider`, `decider_tell`. No `by` reads.", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
src/http/mcp/tools/egress_grants.ts +1 −1
| @@ -7,7 +7,7 @@ export const tool: Tool = { | ||
| 7 | 7 | name: "egress_grants", |
| 8 | 8 | doc: "`{ repo, op?, destination? }`: the private `host:port` destinations an app's forge.json asks for and which are granted; `op` grant or revoke by a site admin (never an agent), and every op needs an admin-scope token; the same as the settings page and `/api/v1/repos/:owner/:name/egress-grants`", |
| 9 | 9 | description: |
| 10 | "An app's private destinations and their grants: list, grant or revoke (site admin). See docs/MCP.md.", | |
| 10 | "Private destinations and grants: list, grant or revoke (site admin).", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
src/http/mcp/tools/flow.ts +1 −1
| @@ -5,7 +5,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "flow", |
| 7 | 7 | description: |
| 8 | "Pipeline (forge admin): stage counts, `oldestWaiting`, `capacity`, bottleneck, `queue`, `reviewing`.", | |
| 8 | "Pipeline (forge admin): stage counts, `oldestWaiting`, `capacity`, `queue`.", | |
| 9 | 9 | inputSchema: { type: "object", properties: {} }, |
| 10 | 10 | writes: false, |
| 11 | 11 | run(ctx, user) { |
src/http/mcp/tools/follow_mode.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "follow_mode", |
| 7 | 7 | doc: "`{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only", |
| 8 | 8 | description: |
| 9 | "How a spawned forge follows its parent: `tell-me` or `take-updates`. Its `forge` repository's admin.", | |
| 9 | "How a spawned forge follows its parent: `tell-me` or `take-updates`.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/front_page.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "front_page", |
| 7 | 7 | doc: "`{ 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)", |
| 8 | 8 | description: |
| 9 | "The front page as a stranger sees it; a site admin also gets and sets `pinned`, `hidden`, `weights`.", | |
| 9 | "The front page as a stranger sees it; a site admin gets and sets `pinned`, `hidden`, `weights`.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/hooks.ts +1 −1
| @@ -20,7 +20,7 @@ const notAdmin = (): Answer => bad("not_found", "no such app or repository, or y | ||
| 20 | 20 | export const tool: Tool = { |
| 21 | 21 | name: "hooks", |
| 22 | 22 | doc: "`{ repo, action, url, secret, kinds, id }`: `list`, `add` (`secret`, 16–200 characters, is required) or `remove` the repository's webhooks, as `…/hooks`; an admin-scope token on a repository you administer, and a secret is never shown", |
| 23 | description: "List, add (`url`, `secret`, `kinds`) or remove (`id`) a repository's webhooks; admins; a secret is never shown.", | |
| 23 | description: "List, add (`url`, `secret`, `kinds`) or remove (`id`) webhooks; admins.", | |
| 24 | 24 | inputSchema: { |
| 25 | 25 | type: "object", |
| 26 | 26 | properties: { |
src/http/mcp/tools/index.ts +1
| @@ -17,6 +17,7 @@ export { tool as asks_act } from "./asks_act.ts"; | ||
| 17 | 17 | export { tool as asks_decide } from "./asks_decide.ts"; |
| 18 | 18 | export { tool as asks_proposal } from "./asks_proposal.ts"; |
| 19 | 19 | export { tool as asks_undecide } from "./asks_undecide.ts"; |
| 20 | export { tool as assign } from "./assign.ts"; | |
| 20 | 21 | export { tool as assigned } from "./assigned.ts"; |
| 21 | 22 | export { tool as attach_look } from "./attach_look.ts"; |
| 22 | 23 | export { tool as audit } from "./audit.ts"; |
src/http/mcp/tools/issue.ts +1 −1
| @@ -10,7 +10,7 @@ export const tool: Tool = { | ||
| 10 | 10 | name: "issue", |
| 11 | 11 | doc: "`{ repo, number }`: one issue's title, state, closeReason, body, labels, assignee, comments (author, authorIsAgent, body, at) and blockedBy/blocks, as `GET …/issues/:number`; a sealed one you cannot see is not found", |
| 12 | 12 | description: |
| 13 | "One issue with comments, `blockedBy`, `blocks`; crew agents get `untrusted: true` on non-writers' text.", | |
| 13 | "One issue with comments, `blockedBy`, `blocks`; non-writers' text is `untrusted`.", | |
| 14 | 14 | inputSchema: { |
| 15 | 15 | type: "object", |
| 16 | 16 | properties: { |
src/http/mcp/tools/map.ts +1 −1
| @@ -10,7 +10,7 @@ export const tool: Tool = { | ||
| 10 | 10 | name: "map", |
| 11 | 11 | doc: "an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`); draws the `ui://forge/map` card", |
| 12 | 12 | description: |
| 13 | "An app's plan: open issues ready or waiting, critical path, edges, where the crew is, `parkedTwice`.", | |
| 13 | "An app's plan: open issues ready or waiting, critical path, edges, crew, `parkedTwice`.", | |
| 14 | 14 | inputSchema: { |
| 15 | 15 | type: "object", |
| 16 | 16 | properties: { app: APP_ARG }, |
src/http/mcp/tools/model_ceiling.ts +1 −1
| @@ -6,7 +6,7 @@ import { good, bad, type Tool, mayBoards } from "../shared.ts"; | ||
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "model_ceiling", |
| 8 | 8 | description: |
| 9 | "Set an account's daily and per-run model spend ceilings in dollars; a run past one does not start.", | |
| 9 | "Set an account's daily and per-run model spend ceilings in dollars.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/new_app.ts +1 −1
| @@ -11,7 +11,7 @@ export const tool: Tool = { | ||
| 11 | 11 | name: "new_app", |
| 12 | 12 | doc: "`{ name, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken)", |
| 13 | 13 | description: |
| 14 | "Make an app from a sentence: a repository, production started, and with `description` a first issue.", | |
| 14 | "Make an app from a sentence: a repository, production, and with `description` a first issue.", | |
| 15 | 15 | inputSchema: { |
| 16 | 16 | type: "object", |
| 17 | 17 | properties: { |
src/http/mcp/tools/now.ts +1 −1
| @@ -15,7 +15,7 @@ export const tool: Tool = { | ||
| 15 | 15 | name: "now", |
| 16 | 16 | doc: "what the crew is doing right now: who, on what, since when, at what cost; a planner that declined the direction and waits for you comes first (`run: null`, its words in `says`)", |
| 17 | 17 | description: |
| 18 | "What the crew is doing across your apps: who, on what, since when, cost; what waits on your review.", | |
| 18 | "What the crew is doing across your apps: who, on what, since when, cost; what awaits review.", | |
| 19 | 19 | inputSchema: { type: "object", properties: {} }, |
| 20 | 20 | writes: false, |
| 21 | 21 | run(ctx, user) { |
src/http/mcp/tools/probe.ts +1 −1
| @@ -8,7 +8,7 @@ export const tool: Tool = { | ||
| 8 | 8 | name: "probe", |
| 9 | 9 | doc: "knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract", |
| 10 | 10 | description: |
| 11 | "Try every door of a running app as nobody, a user and an admin against its contract.json; read-only.", | |
| 11 | "Try every door of a running app as nobody, a user and an admin; read-only.", | |
| 12 | 12 | inputSchema: { |
| 13 | 13 | type: "object", |
| 14 | 14 | properties: { repo: REPO_ARG }, |
src/http/mcp/tools/prompts.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "prompts", |
| 6 | 6 | doc: "`{ repo, action, kind?, token?, at?, id? }`: list, add, run or remove an app's prompt sources (Instagram), as the `…/prompts` API does; the token goes in once and is never shown; listed only for an admin-scope token, and only the repository's admin may use it", |
| 7 | 7 | description: |
| 8 | "An app's prompt sources (Instagram): list, add (`kind`, `token`, `at`), run or remove (`id`). Admin token.", | |
| 8 | "Prompt sources: list, add (`kind`, `token`, `at`), run or remove (`id`). Admin.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { |
src/http/mcp/tools/review.ts +1 −1
| @@ -9,7 +9,7 @@ export const tool: Tool = { | ||
| 9 | 9 | name: "review", |
| 10 | 10 | doc: "`{ repo, number, verdict, reason? }`: approve a pull (`approve`) or request changes (`changes`, with a `reason`), as the page does; a writer's or the crew's, and never your own pull", |
| 11 | 11 | description: |
| 12 | "Approve a pull, or request changes with a `reason` (required for changes); your own pull is refused.", | |
| 12 | "Approve a pull, or request changes (`reason` required); your own pull is refused.", | |
| 13 | 13 | inputSchema: { |
| 14 | 14 | type: "object", |
| 15 | 15 | properties: { |
src/http/mcp/tools/scorecard.ts +1 −1
| @@ -5,7 +5,7 @@ import { good, bad, type Tool } from "../shared.ts"; | ||
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "scorecard", |
| 7 | 7 | description: |
| 8 | "Crew scorecard (forge admin): per agent, all time and 30 days: landed, rounds, red rate, reverts.", | |
| 8 | "Crew scorecard (forge admin): per agent, all time and 30 days: landed, rounds, red rate.", | |
| 9 | 9 | inputSchema: { type: "object", properties: {} }, |
| 10 | 10 | writes: false, |
| 11 | 11 | adminOnly: true, |
src/http/mcp/tools/spend.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "spend", |
| 6 | 6 | description: |
| 7 | "Report what your session spent on work the forge did not run: who, model, tokens or dollars, issue.", | |
| 7 | "Report what your session spent outside the forge's runs: who, model, tokens or dollars, issue.", | |
| 8 | 8 | inputSchema: { |
| 9 | 9 | type: "object", |
| 10 | 10 | properties: { |
src/http/mcp/tools/stop.ts +1 −1
| @@ -5,7 +5,7 @@ export const tool: Tool = { | ||
| 5 | 5 | name: "stop", |
| 6 | 6 | doc: "stop one run by its id from `now`", |
| 7 | 7 | description: |
| 8 | "Stop one of the crew's runs, by its id from `now`. What it had pushed stays; nothing is re-queued.", | |
| 8 | "Stop one of the crew's runs, by its id from `now`. Its pushes stay; nothing is re-queued.", | |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
| 11 | 11 | properties: { repo: REPO_ARG, run: { type: "string" } }, |
src/http/mcp/tools/system.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "system", |
| 7 | 7 | doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read`", |
| 8 | 8 | description: |
| 9 | "The forge's machine now: CPU, load, memory, disk, network, processes. Forge admin or telemetry:read.", | |
| 9 | "The forge's machine now: CPU, memory, disk, network, processes. Admin or telemetry:read.", | |
| 10 | 10 | inputSchema: { type: "object", properties: {} }, |
| 11 | 11 | writes: false, |
| 12 | 12 | run(ctx, user) { |
src/http/mcp/tools/tell.ts +1 −1
| @@ -8,7 +8,7 @@ export const tool: Tool = { | ||
| 8 | 8 | name: "tell", |
| 9 | 9 | doc: "a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick", |
| 10 | 10 | description: |
| 11 | "Tell an app's crew what should change (first line is the title). No `repo`: kept; answers a thought id.", | |
| 11 | "Tell an app's crew what to change (first line is the title). No `repo`: kept.", | |
| 12 | 12 | inputSchema: { |
| 13 | 13 | type: "object", |
| 14 | 14 | properties: { |
src/http/mcp/tools/watch.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "watch", |
| 7 | 7 | doc: "`{ app, issue, watch? }`: watch a milestone, or stop with `watch: false`: one item in your feed that follows it, and one push to your phone when it lands", |
| 8 | 8 | description: |
| 9 | "Watch a milestone, or stop (`watch: false`): one feed item; your phone is told once when it lands.", | |
| 9 | "Watch a milestone, or stop (`watch: false`); your phone is told once when it lands.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
test/mcp-assign.test.ts added +159
| @@ -0,0 +1,159 @@ | ||
| 1 | /** The `assign` tool does what PUT …/assignee does: a writer assigns and clears; a reader and an unknown user are refused. */ | |
docs/MCP.md promises "a writer, never an agent; the assignee must read the repository", but neither refusal is exercised here. Add a private-repository case where the assignee can't read, and an agent-token case. Both should check that the assignee is unchanged. |
||
| 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 { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 8 | import { createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { createIssue, getIssue } from "../src/core/issues.ts"; | |
| 10 | import { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { promote } from "./helpers/members.ts"; | |
| 14 | ||
| 15 | describe("MCP assign", () => { | |
| 16 | let dataDir: string; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let aliceTok: string; | |
| 20 | let carolTok: string; | |
| 21 | let repoId: string; | |
| 22 | let hiddenId: string; | |
| 23 | let bobId: string; | |
| 24 | let botTok: string; | |
| 25 | let id = 1; | |
| 26 | ||
| 27 | const call = async (token: string, args: unknown) => { | |
| 28 | const res = await fetch(`${servers.url}/mcp`, { | |
| 29 | method: "POST", | |
| 30 | headers: { | |
| 31 | "content-type": "application/json", | |
| 32 | accept: "application/json, text/event-stream", | |
| 33 | authorization: `Bearer ${token}`, | |
| 34 | }, | |
| 35 | body: JSON.stringify({ | |
| 36 | jsonrpc: "2.0", | |
| 37 | id: id++, | |
| 38 | method: "tools/call", | |
| 39 | params: { name: "assign", arguments: args }, | |
| 40 | }), | |
| 41 | }); | |
| 42 | const body: any = await res.json(); | |
| 43 | const text: string = body.result.content[0].text; | |
| 44 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 45 | }; | |
| 46 | const assigneeOf = () => getIssue(app.db, repoId, 1)!.assigneeId; | |
| 47 | ||
| 48 | beforeAll(async () => { | |
| 49 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-assign-")); | |
| 50 | app = createApp({ dataDir, port: 0 }); | |
| 51 | servers = await serve(app); | |
| 52 | const mk = async (username: string, first = false) => { | |
| 53 | const u = await createUser(app.db, { | |
| 54 | origin: "seed", | |
| 55 | trusted: true, | |
| 56 | username, | |
| 57 | email: `${username}@forge.local`, | |
| 58 | password: "a-good-passphrase", | |
| 59 | adminIfFirst: first, | |
| 60 | }); | |
| 61 | if (!u.ok) throw new Error(u.error); | |
| 62 | promote(app.db, u.value); | |
| 63 | return u.value; | |
| 64 | }; | |
| 65 | const alice = await mk("alice", true); | |
| 66 | const bob = await mk("bob"); | |
| 67 | const carol = await mk("carol"); | |
| 68 | bobId = bob.id; | |
| 69 | aliceTok = createToken(app.db, alice.id, "rw", "rw").token; | |
| 70 | carolTok = createToken(app.db, carol.id, "rw", "rw").token; | |
| 71 | const made = await createRepo(app, alice, { name: "one", visibility: "public" }); | |
| 72 | if (!made.ok) throw new Error(String(made.error)); | |
| 73 | repoId = made.value.id; | |
| 74 | const issue = createIssue(app.db, { repoId, authorId: alice.id, title: "task" }); | |
| 75 | if (!issue.ok) throw new Error(issue.error); | |
| 76 | const pull = createIssue(app.db, { repoId, authorId: alice.id, title: "change", kind: "pull" }); | |
| 77 | if (!pull.ok) throw new Error(pull.error); | |
| 78 | const hidden = await createRepo(app, alice, { name: "hidden", visibility: "private" }); | |
| 79 | if (!hidden.ok) throw new Error(String(hidden.error)); | |
| 80 | hiddenId = hidden.value.id; | |
| 81 | const secret = createIssue(app.db, { repoId: hiddenId, authorId: alice.id, title: "secret" }); | |
| 82 | if (!secret.ok) throw new Error(secret.error); | |
| 83 | const bot = await createAgent(app, alice, { username: "bot" }); | |
| 84 | if (!bot.ok) throw new Error(String(bot.error)); | |
| 85 | await createAgent(app, alice, { username: "idle" }); | |
| 86 | if (!addToCrew(app, alice, made.value, "bot").ok) throw new Error("crew"); | |
| 87 | botTok = createToken(app.db, bot.value.user.id, "rw", "rw").token; | |
| 88 | }, 30_000); | |
| 89 | ||
| 90 | afterAll(async () => { | |
| 91 | servers.stop(); | |
| 92 | app.close(); | |
| 93 | await rm(dataDir, { recursive: true, force: true }); | |
| 94 | }); | |
| 95 | ||
| 96 | const args = (assignee: string | null, extra = {}) => ({ | |
| 97 | repo: "alice/one", | |
| 98 | number: 1, | |
| 99 | kind: "issue", | |
| 100 | assignee, | |
| 101 | ...extra, | |
| 102 | }); | |
| 103 | ||
| 104 | test("a writer assigns and clears", async () => { | |
| 105 | const set = await call(aliceTok, args("bob")); | |
| 106 | expect(set.value.assignee).toBe("bob"); | |
| 107 | expect(assigneeOf()).toBe(bobId); | |
| 108 | const cleared = await call(aliceTok, args(null)); | |
| 109 | expect(cleared.value.assignee).toBeNull(); | |
| 110 | expect(assigneeOf()).toBeNull(); | |
| 111 | }); | |
| 112 | ||
| 113 | test("a writer assigns and clears a pull", async () => { | |
| 114 | const set = await call(aliceTok, args("bob", { kind: "pull", number: 2 })); | |
| 115 | expect(set.value.assignee).toBe("bob"); | |
| 116 | expect(getIssue(app.db, repoId, 2)!.assigneeId).toBe(bobId); | |
| 117 | await call(aliceTok, args(null, { kind: "pull", number: 2 })); | |
| 118 | expect(getIssue(app.db, repoId, 2)!.assigneeId).toBeNull(); | |
| 119 | }); | |
| 120 | ||
| 121 | test("a reader is refused with the API's words", async () => { | |
| 122 | const got = await call(carolTok, args("bob")); | |
| 123 | const api = await fetch(`${servers.url}/api/v1/repos/alice/one/issues/1/assignee`, { | |
| 124 | method: "PUT", | |
| 125 | headers: { authorization: `Bearer ${carolTok}`, "content-type": "application/json" }, | |
| 126 | body: JSON.stringify({ assignee: "bob" }), | |
| 127 | }); | |
| 128 | expect(api.status).toBe(403); | |
| 129 | const apiBody: any = await api.json(); | |
| 130 | expect(got.error).toBe(`forbidden: ${apiBody.message}`); | |
| 131 | expect(assigneeOf()).toBeNull(); | |
| 132 | }); | |
| 133 | ||
| 134 | test("an unknown user, a wrong kind and a bad shape are refused", async () => { | |
| 135 | expect((await call(aliceTok, args("nobody"))).error).toStartWith("no_such_user"); | |
| 136 | expect((await call(aliceTok, args("bob", { kind: "pull" }))).error).toStartWith("no_such_issue"); | |
| 137 | expect((await call(aliceTok, args("bob", { kind: "thing" }))).error).toStartWith("invalid"); | |
| 138 | expect((await call(aliceTok, args(5 as any))).error).toStartWith("invalid_assignee"); | |
| 139 | expect(assigneeOf()).toBeNull(); | |
| 140 | }); | |
| 141 | ||
| 142 | test("an assignee who cannot read the repository is refused, and the assignee stays", async () => { | |
| 143 | const got = await call(aliceTok, args("bob", { repo: "alice/hidden" })); | |
| 144 | expect(got.error).toBe("assignee_cannot_read: the assignee cannot read this repository"); | |
| 145 | expect(getIssue(app.db, hiddenId, 1)!.assigneeId).toBeNull(); | |
| 146 | }); | |
| 147 | ||
| 148 | test("an agent off the crew is refused as the API refuses it", async () => { | |
| 149 | const got = await call(aliceTok, args("idle")); | |
| 150 | expect(got.error).toBe("agent_not_on_crew: that agent is not on this repository's crew"); | |
| 151 | expect(assigneeOf()).toBeNull(); | |
| 152 | }); | |
| 153 | ||
| 154 | test("an agent's rw token may not assign, and is told why", async () => { | |
| 155 | const got = await call(botTok, args("bob")); | |
| 156 | expect(got.error).toStartWith("forbidden: an agent may not assign"); | |
| 157 | expect(assigneeOf()).toBeNull(); | |
| 158 | }); | |
| 159 | }); |
test/mcp.test.ts +1
| @@ -243,6 +243,7 @@ describe("the forge over MCP", () => { | ||
| 243 | 243 | "thought", |
| 244 | 244 | "asked", |
| 245 | 245 | "assigned", |
| 246 | "assign", | |
| 246 | 247 | "app_members", |
| 247 | 248 | "egress_grants", |
| 248 | 249 | "explore", |
new above
offline
Low:
assigneehas nomaxLength. Other string arguments here are bounded, and the name is echoed back inno account named ${name}. The request body cap limits the damage, butmaxLength: 64(or the username limit) would make the schema say what it accepts. Optional.