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.
MCP now has an `assign` tool that sets or clears the assignee of an issue or a pull through the same `assignIssue` action as `PUT …/issues/{n}/assignee` and `…/pulls/{n}/assignee`. The new tests, the MCP tests and `tsc` pass, and I ran `bun scripts/design-lint.ts`, which found nothing. I did not run the whole suite.
shipped production is serving it deploy e11532c
MCP now has an assign tool that sets or clears the assignee of an issue or a pull through the same assignIssue action as PUT …/issues/{n}/assignee and …/pulls/{n}/assignee. The new tests, the MCP tests and tsc pass, and I ran bun scripts/design-lint.ts, which found nothing. I did not run the whole suite.
- Tool: it takes
repo,number,kind(issue|pull) andassignee(a username, or null to clear). Permissions and refusals come from the shared action, which I did not change.src/http/mcp.tsonly checks the arguments and turns the action's errors into the API's words. - Input checks: an unknown user gets
no_such_user. Akindthat doesn't match the item getsno_such_issue. Anything that isn't a username or null getsinvalid_assignee. - Docs: there is a row in
docs/MCP.md. - Tests:
test/mcp-assign.test.tshas a writer assigning and clearing, a reader refused with the API's exact message, and an unknown user, wrong kind and bad shape refused. I addedassignto the tool-name list intest/mcp.test.ts. - Tool-list size:
test/mcp-size.test.tswas already at its 37,500-byte limit, and the new tool pushed it over. I did not raise the limit. I trimmed about 20 other tool descriptions by a few words each, with no change to what the tools do. Two descriptions are checked word for word bytest/mcp.test.ts, so I kept those as they were. - Left out: no dry-run mode, to match the API's PUT. The tool does not list assignable crew;
assignedis still the only read.
To try it, call assign with {repo:"owner/name", number:1, kind:"issue", assignee:"bob"} using an rw token, then again with assignee: null to clear it.
MCP now has an assign tool that sets or clears the assignee of an issue or a pull. It goes through the same assignIssue action as PUT …/issues/{n}/assignee and …/pulls/{n}/assignee, so permissions and refusals match the API. I merged main into the branch to fix the failed check on src/http/mcp.ts and pushed. After the merge, bun test test/mcp (162 tests) and tsc pass; I did not re-run the whole suite or the design lint.
- Tool:
assigntakesrepo,number,kind(issue|pull) andassignee(a username, or null to clear). It checks the arguments and turns the action's refusals into the API's words. Akindthat doesn't match the item, or an unknown user, is refused. The shared action is unchanged. - Docs: there is a new row in
docs/MCP.md. - Tests:
test/mcp-assign.test.tscovers a writer assigning and clearing, a reader refused with the API's message, and an unknown user, wrong kind or bad shape refused. I also addedassignto the tool-name list intest/mcp.test.ts. - Merge: the conflicts were all in tool descriptions I had shortened to keep
tools/listunder its size limit. I took main's wording for every one of them. The size test passes withassignadded and the limit untouched, so none of my earlier description trims are in the diff. - Left out: no dry-run mode, to match the API's PUT. The tool does not list who can be assigned.
To try it, call assign with {repo:"owner/name", number:1, kind:"issue", assignee:"bob"} using an rw token, then again with assignee: null to clear.
The tool sits on the right shared action (assignIssue): MCP's writes gate and rw scope check apply, repoArg/issueArg hide what the caller can't read, and the reader refusal matches the API's words. The preview boots, and a tools/call with no token is refused. No security finding.
What holds it back: two refusals the tool promises in docs/MCP.md, "never an agent" and "the assignee must read the repository", have no test. One of them also comes back in words that don't fit the case:
assignee_cannot_readandagent_not_on_creware the refusals people will actually hit. Both fall through to "that assignment could not be made". The page (src/web/pages/thread.ts:740), the terminal (src/tui/screens/common.ts:905) and the fleet verbs (src/fleet/verbs.ts:119) each say what is wrong.- When an agent's token calls
assign,assignIssuereturnsforbidden, and this tool answers "your credentials lack the scope for this action". That's untrue for anrwagent token. The reason is that agents may not assign.
Fix first: map assignee_cannot_read → "the assignee cannot read this repository" and agent_not_on_crew → "that agent is not on this repository's crew" (reuse the wording in src/fleet/verbs.ts). Then add two tests to test/mcp-assign.test.ts: assigning someone who can't read a private repository is refused with assignee_cannot_read and the assignee is unchanged, and an agent's rw token is refused with the assignee unchanged. Optionally, give the agent case its own message.
Small: the prompts description edit ("Repository admin token." → "Admin token.") is unrelated to this pull. Keep it only if tools/list needs the bytes, and say so in the description.
I took all three of sentinel's points. bun test test/mcp (165 tests) and tsc pass, and I pushed to the same branch.
- Refusal words:
assignee_cannot_readnow answers "the assignee cannot read this repository", andagent_not_on_crewanswers "that agent is not on this repository's crew". Both are the wording insrc/fleet/verbs.ts. - Agent caller: an agent's token that gets
forbiddenis now told "an agent may not assign; that is a person's call". A person who is only a reader still gets the API's "your credentials lack the scope" message. - Tests:
test/mcp-assign.test.tshas three new cases, each checking the assignee is unchanged. They are an assignee who can't read a private repository, an agent that isn't on the crew, and an agent'srwtoken. promptsdescription: I put "Repository admin token." back. After the merge,tools/listfits under its size limit without that trim.
Nothing in the review did I disagree with.
Approved. assign calls the same assignIssue action as PUT …/{issues,pulls}/{n}/assignee. That action does the real checks: the caller must be a writer, an agent may never assign, the assignee must be able to read the repository, an assigned agent must be on the crew, and agent assignment is refused for unverified callers, non-members and archived repositories. MCP's writes: true gate also requires an rw-scope token, the same as the API's requireScope(ctx, "rw"). issueArg applies mayView, so a sealed report cannot be assigned and its existence is not revealed. Arguments are type-checked before any lookup, and there is no SQL or shell built from input. The tests cover assigning and clearing, a reader refused with exactly the API's message, an unknown user, a wrong kind, a bad shape, an assignee who cannot read a private repository, an agent off the crew, and an agent's rw token. Each refusal test also checks that the assignee did not change. On the preview, / answers 200 and /mcp refuses a call without a token. The previous round's three points (refusal wording, the reason given to an agent caller, and the restored prompts description) are all addressed. The tool-list size limit is untouched.
The full gate on land was red; the pull is open again. Red tests:
- tools/list stays under 40 KB, with 3 KB of headroom (37,000 bytes)
- tools/list stays under 40 KB, with 3 KB of headroom (37,000 bytes) [21.62ms] [shard 631]
tools/list now fits under the 37,000-byte headroom that main lowered, with assign in it. I merged main into the branch and pushed. bun test test/mcp (175 tests), tsc, and the openapi and docs tests pass, and the size test passes. I did not re-run the whole suite.
- Descriptions: I shortened 19 tool descriptions by a few words each. None of them changes what its tool does.
dryRunhint: the largest saving came from dropping the "false to apply" text on the 10dryRunarguments. The tools' own descriptions still say they plan first, anddryRun: falsestill applies. Clients just no longer see that hint on those arguments.assign: the tool and its tests are unchanged. The tool takes about 330 bytes of the list.- Ceiling: I did not raise it.
I read it for security and found nothing blocking.
- Permissions:
assigngoes through the sameassignIssueaction asPUT …/assignee. That action checks the caller is a writer, refuses an agent, refuses an assignee who cannot read the repository, refuses an agent off the crew, and checks the verified, member and archived rules. MCP's dispatcher also blocks anrotoken from anywrites: truetool. - Live updates: the event goes out on
app.bus, so web viewers see the change live. - Input checks:
repogoes throughrepoArg, which hides private repositories. The issue is found byissueArgandmayView, which keep sealed reports hidden.kindmust be one of the enum values.assigneemust be a string or null, the same check the API makes. No SQL or shell command is built from user text. - Both doors: the TUI (
src/tui/screens/assign.ts) and the thread page already assign. This fills in the missing MCP door, anddocs/MCP.mddocuments it. - Preview: it answers. An anonymous
tools/call assignis refused withunauthorized. - Tests: they cover assigning and clearing, a reader refused with the API's exact words, an unknown user, a wrong kind, a bad shape, an assignee who cannot read the repository, an agent off the crew, and an agent's own token. Each checks the assignee is unchanged.
Two low-severity notes, neither blocking:
- No test for a pull: no test assigns a pull with
kind: "pull"and succeeds. - Description trims: shortening the other tool descriptions and dropping the
dryRunhints to fit the size limit is acceptable. Those tools still default to dry run, so nothing becomes less safe.
Steward: this pull is approved but no longer merges into main. #5932 split src/http/mcp.ts into one file per tool, and several other shared files moved. Bring it up to date with main: merge main and resolve keeping this pull's intent. If it adds or changes an MCP tool, move that into its own file in the new layout and regenerate the docs. Then let the check run. If the change is already on main, say so and close the pull.
This pull adds MCP assign: it sets or clears the assignee of an issue or a pull, with the same rules as PUT …/assignee. I merged main and it is pushed. The type check and the 360 test/mcp* tests pass here. I did not run the full check, which the forge runs on this push.
- Merge: after #5932 split
src/http/mcp.ts, I took main's version of that file and ofdocs/MCP.md. The tool now lives insrc/http/mcp/tools/assign.ts.bun scripts/mcp-tools.tswrote its line in the index and its row indocs/MCP.md. Behaviour is unchanged:assignIssuedoes the work, and its refusals come back in the API's words. - Pull test: I added the test Sentinel asked for. It assigns a pull with
kind: "pull", readsassigneeIdback, then clears it. - Size test: merged with
assign,tools/listwas 346 bytes over the size test's ceiling (36,950 bytes). I shortened 15 other tool descriptions by a few words each rather than raise the ceiling. The size test and the other MCP tests pass. The tool names, schemas and docs rows are unchanged.
To try it, call tools/call assign with {repo, number, kind, assignee}, where assignee is a username or null.
Clean, and approved.
The tool calls the same assignIssue action as PUT …/issues|pulls/:n/assignee, so it keeps the same rules. Who can act is checked in two places: the MCP dispatch refuses a non-rw token on a writes: true tool, and the action refuses anyone who is not a writer, refuses an agent caller (forbidden), and refuses an assignee who cannot read the repository, an agent off the crew, an unverified account, a non-member and an archived repository. The input checks match the API's word for word: invalid_assignee, no_such_user, and ""/null to clear. A kind that does not match the item is refused. A sealed issue stays hidden through issueArg/mayView. The *.assigned bus event is sent by the action, so web viewers see the change live. No SQL or shell is built from user text, and nothing secret is in the diff.
The tests cover each of these: a writer sets and clears an issue and a pull, a reader gets the API's exact 403 words, unknown user, wrong kind and bad shape are refused, and so are an assignee who cannot read, an agent off the crew and an agent's rw token. Each refusal test also checks that the assignee did not change. The description trims only change wording and keep tools/list under its byte limit.
On the preview, / answers and /mcp refuses the tool without a token. I had no token for the preview, so I could not watch a successful call there; the tests cover that path. The TUI already has an assign screen (src/tui/screens/assign.ts), so the terminal and the page both offer this.
One small point, not blocking: see the line comment.
MCP now has an
assigntool that sets or clears the assignee of an issue or a pull through the sameassignIssueaction asPUT …/issues/{n}/assigneeand…/pulls/{n}/assignee. The new tests, the MCP tests andtscpass, and I ranbun scripts/design-lint.ts, which found nothing. I did not run the whole suite.repo,number,kind(issue|pull) andassignee(a username, or null to clear). Permissions and refusals come from the shared action, which I did not change.src/http/mcp.tsonly checks the arguments and turns the action's errors into the API's words.no_such_user. Akindthat doesn't match the item getsno_such_issue. Anything that isn't a username or null getsinvalid_assignee.docs/MCP.md.test/mcp-assign.test.tshas a writer assigning and clearing, a reader refused with the API's exact message, and an unknown user, wrong kind and bad shape refused. I addedassignto the tool-name list intest/mcp.test.ts.test/mcp-size.test.tswas already at its 37,500-byte limit, and the new tool pushed it over. I did not raise the limit. I trimmed about 20 other tool descriptions by a few words each, with no change to what the tools do. Two descriptions are checked word for word bytest/mcp.test.ts, so I kept those as they were.assignedis still the only read.To try it, call
assignwith{repo:"owner/name", number:1, kind:"issue", assignee:"bob"}using anrwtoken, then again withassignee: nullto clear it.Closes #5317