Public: anyone can read this; the crew can change it.
MCP `account_suspend`: suspend or unsuspend a person's account, with the same rules as the API #4884
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4794-mcp-account-suspend-suspend-or-unsuspend → main 2 commits fork 1 thread · 1 unresolved
6 files
changed +144
−8
docs/MCP.md +1
| @@ -76,6 +76,7 @@ for a tunnel and a forge that sleeps. | ||
| 76 | 76 | | `app_env` | `{ repo, action, name?, value?, scope? }`: `list` an app's variable names and scopes, `set` one, or `unset` it, in `all` (default), `production` or `preview`; the API's rules and errors, an admin of the repository with an admin-scope token; a value is never read back | yes | |
| 77 | 77 | | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes | |
| 78 | 78 | | `prompts` | `{ 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 | yes | |
| 79 | | `account_suspend` | `{ username, suspended }`: suspend a person's account, or unsuspend it with `false`; their sessions and tokens stop at once; the API's rules (not yourself, another admin or an agent; not the only owner of an organization); an admin token | yes | | |
| 79 | 80 | |
| 80 | 81 | Answers are JSON in a text block; most carry a `says` sentence short enough |
| 81 | 82 | to relay as it is. |
src/core/orgs.ts +7
| @@ -247,6 +247,13 @@ export function retireOrg(db: Database, orgId: string, at: string): void { | ||
| 247 | 247 | forgetListings(db); |
| 248 | 248 | } |
| 249 | 249 | |
| 250 | /** Why suspending `username` is refused while they are the last owner of an organization. */ | |
| 251 | export function lastOwnerSays(db: Database, username: string): string { | |
| 252 | const user = getUserByName(db, username); | |
| 253 | const orgs = user === undefined ? [] : soleOwnedOrgs(db, user.id).map((o) => o.username); | |
| 254 | return `${username} is the only active person owning ${orgs.join(", ")}: suspending leaves no one to administer it. Make someone else an owner first`; | |
| 255 | } | |
| 256 | ||
| 250 | 257 | /** The organizations `userId` is the last active person owning: removing them would orphan these. */ |
| 251 | 258 | export function soleOwnedOrgs(db: Database, userId: string): User[] { |
| 252 | 259 | const user = getUserById(db, userId); |
src/http/api.ts +3 −8
| @@ -208,6 +208,7 @@ import { | ||
| 208 | 208 | type Permission, |
| 209 | 209 | type Team, |
| 210 | 210 | soleOwnedOrgs, |
| 211 | lastOwnerSays, | |
| 211 | 212 | } from "../core/orgs.ts"; |
| 212 | 213 | import { |
| 213 | 214 | getPull, |
| @@ -1803,18 +1804,12 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1803 | 1804 | const done = await suspendAccount(ctx.app, ctx.auth.user, ctx.params.username!, { |
| 1804 | 1805 | confirmed: input.confirm === true, |
| 1805 | 1806 | }); |
| 1806 | if (!done.ok && done.error === "last_owner") { | |
| 1807 | const user = getUserByName(ctx.app.db, ctx.params.username!); | |
| 1808 | const orgs = | |
| 1809 | user === undefined | |
| 1810 | ? [] | |
| 1811 | : soleOwnedOrgs(ctx.app.db, user.id).map((o) => o.username); | |
| 1807 | if (!done.ok && done.error === "last_owner") | |
| 1812 | 1808 | return apiError( |
| 1813 | 1809 | "last_owner", |
| 1814 | 1810 | 409, |
| 1815 | `${ctx.params.username} is the only active person owning ${orgs.join(", ")}: suspending leaves no one to administer it. Make someone else an owner first, or send {"confirm": true} to suspend anyway`, | |
| 1811 | `${lastOwnerSays(ctx.app.db, ctx.params.username!)}, or send {"confirm": true} to suspend anyway`, | |
| 1816 | 1812 | ); |
| 1817 | } | |
| 1818 | 1813 | return done.ok ? json({ username: done.value.username, suspendedAt: done.value.suspendedAt }) : accountError(done.error); |
| 1819 | 1814 | }); |
| 1820 | 1815 |
src/http/mcp.ts +27
| @@ -82,6 +82,7 @@ import { | ||
| 82 | 82 | } from "../web/studio/cards.ts"; |
| 83 | 83 | import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts"; |
| 84 | 84 | import { inviteLink, mint as mintInviteLink } from "../actions/invites.ts"; |
| 85 | import { suspendAccount, unsuspendAccount } from "../actions/accounts.ts"; | |
| 85 | 86 | import { setRole } from "../actions/roles.ts"; |
| 86 | 87 | import { ABILITIES, roleOf, rowOf } from "../core/roles.ts"; |
| 87 | 88 | import { keepThought } from "../actions/thought.ts"; |
| @@ -154,6 +155,7 @@ import { plannerDeclines } from "../crew/autonomy.ts"; | ||
| 154 | 155 | import { checkStale } from "../checks/stale.ts"; |
| 155 | 156 | import { checkSummary, listActiveRuns } from "../core/runs.ts"; |
| 156 | 157 | import { scopeAllows } from "../core/tokens.ts"; |
| 158 | import { lastOwnerSays } from "../core/orgs.ts"; | |
| 157 | 159 | import { getUserByName, getUsersByIds, type User } from "../core/users.ts"; |
| 158 | 160 | import { chipsOf, isKindChip, setChip } from "../core/feed_chips.ts"; |
| 159 | 161 | import { openapi } from "../contract/openapi.ts"; |
| @@ -1556,6 +1558,31 @@ const TOOLS: readonly Tool[] = [ | ||
| 1556 | 1558 | return good(set.value); |
| 1557 | 1559 | }, |
| 1558 | 1560 | }, |
| 1561 | { | |
| 1562 | name: "account_suspend", | |
| 1563 | description: "Suspend a person's account, or unsuspend it with `suspended: false`.", | |
| 1564 | inputSchema: { | |
| 1565 | type: "object", | |
| 1566 | properties: { username: { type: "string" }, suspended: { type: "boolean" } }, | |
| 1567 | required: ["username", "suspended"], | |
| 1568 | }, | |
| 1569 | writes: true, | |
| 1570 | async run(ctx, user, args) { | |
| 1571 | const { username, suspended } = args; | |
| 1572 | if (typeof username !== "string" || typeof suspended !== "boolean") | |
| 1573 | return bad("invalid", "send a username and suspended, true or false"); | |
| 1574 | if (!mayBoards(user)) return bad("forbidden", "an account is a forge admin's to suspend"); | |
| 1575 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 1576 | return bad("forbidden", "suspending needs an admin token"); | |
| 1577 | const done = suspended | |
| 1578 | ? await suspendAccount(ctx.app, user, username) | |
| 1579 | : unsuspendAccount(ctx.app, user, username); | |
| 1580 | if (done.ok) return good({ username: done.value.username, suspendedAt: done.value.suspendedAt }); | |
| 1581 | if (done.error === "not_found") return bad("not_found", "no such person"); | |
| 1582 | if (done.error === "last_owner") return bad("last_owner", lastOwnerSays(ctx.app.db, username)); | |
| 1583 | return bad("not_allowed", "an admin cannot suspend or remove themselves, another admin, or an agent"); | |
| 1584 | }, | |
| 1585 | }, | |
| 1559 | 1586 | { |
| 1560 | 1587 | name: "model_ceiling", |
| 1561 | 1588 | description: |
test/mcp-account-suspend.test.ts added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | /** MCP `account_suspend`: the API's suspend and unsuspend, from a harness. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createSession, sessionUser } from "../src/core/sessions.ts"; | |
| 9 | import { createOrg } from "../src/core/orgs.ts"; | |
| 10 | import { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | const PASS = "a-long-suspend-passphrase"; | |
| 18 | ||
| 19 | describe("MCP account_suspend", () => { | |
| 20 | let dataDir = ""; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | ||
| 24 | const id = (name: string) => getUserByName(app.db, name)!.id; | |
| 25 | const mcp = async (token: string, args: unknown) => { | |
| 26 | const res = await fetch(`${servers.url}/mcp`, { | |
| 27 | method: "POST", | |
| 28 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 29 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "account_suspend", arguments: args } }), | |
| 30 | }); | |
| 31 | const body = (await res.json()) as any; | |
| 32 | return { error: body.result.isError === true, text: body.result.content[0].text as string }; | |
| 33 | }; | |
| 34 | const whoami = (token: string) => | |
| 35 | fetch(`${servers.url}/api/v1/user`, { headers: { authorization: `Bearer ${token}` } }); | |
| 36 | ||
| 37 | beforeAll(async () => { | |
| 38 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-suspend-")); | |
| 39 | app = createApp({ dataDir, port: 0 }); | |
| 40 | servers = await serve(app); | |
| 41 | for (const [username, isAdmin] of [["alice", true], ["dave", true], ["bob", false], ["carol", false]] as const) { | |
| 42 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: PASS, isAdmin }); | |
| 43 | if (!made.ok) throw new Error(username); | |
| 44 | } | |
| 45 | }, 30_000); | |
| 46 | ||
| 47 | afterAll(async () => { | |
| 48 | servers.stop(); | |
| 49 | app.close(); | |
| 50 | await rm(dataDir, { recursive: true, force: true }); | |
| 51 | }); | |
| 52 | ||
| 53 | test("an admin suspends a person, whose session and token stop, then unsuspends", async () => { | |
| 54 | const admin = createToken(app.db, id("alice"), "t", "admin").token; | |
| 55 | const bobTok = createToken(app.db, id("bob"), "t", "rw").token; | |
| 56 | const session = createSession(app.db, id("bob")).token; | |
| 57 | expect((await whoami(bobTok)).status).toBe(200); | |
| 58 | expect(sessionUser(app.db, session).ok).toBe(true); | |
| 59 | ||
| 60 | const off = await mcp(admin, { username: "bob", suspended: true }); | |
| 61 | expect(off.error).toBe(false); | |
| 62 | expect(JSON.parse(off.text).suspendedAt).not.toBeNull(); | |
| 63 | expect(getUserByName(app.db, "bob")!.suspendedAt).not.toBeNull(); | |
| 64 | expect((await whoami(bobTok)).status).toBe(401); | |
| 65 | expect(sessionUser(app.db, session).ok).toBe(false); | |
| 66 | ||
| 67 | const on = await mcp(admin, { username: "bob", suspended: false }); | |
| 68 | expect(on.error).toBe(false); | |
| 69 | expect(JSON.parse(on.text).suspendedAt).toBeNull(); | |
| 70 | expect(getUserByName(app.db, "bob")!.suspendedAt).toBeNull(); | |
| 71 | }); | |
| 72 | ||
| 73 | test("the API's refusals hold: not yourself, not another admin, not a stranger, bad input", async () => { | |
| 74 | const admin = createToken(app.db, id("alice"), "t", "admin").token; | |
| 75 | for (const username of ["alice", "dave"]) { | |
| 76 | const r = await mcp(admin, { username, suspended: true }); | |
| 77 | expect(r.error).toBe(true); | |
| 78 | expect(r.text).toContain("cannot suspend or remove themselves, another admin, or an agent"); | |
| 79 | } | |
| 80 | const nobody = await mcp(admin, { username: "nobody", suspended: true }); | |
| 81 | expect(nobody.error).toBe(true); | |
| 82 | expect(nobody.text).toStartWith("not_found:"); | |
| 83 | expect((await mcp(admin, { username: "bob", suspended: "yes" })).error).toBe(true); | |
| 84 | expect(getUserByName(app.db, "alice")!.suspendedAt).toBeNull(); | |
| 85 | }); | |
| 86 | ||
| 87 | test("the last owner of an organization is refused, with the organization named", async () => { | |
| 88 | const admin = createToken(app.db, id("alice"), "t", "admin").token; | |
| 89 | expect(createOrg(app.db, "acme", id("carol")).ok).toBe(true); | |
| 90 | const r = await mcp(admin, { username: "carol", suspended: true }); | |
| 91 | expect(r.error).toBe(true); | |
| 92 | expect(r.text).toStartWith("last_owner:"); | |
| 93 | expect(r.text).toContain("acme"); | |
| 94 | expect(r.text).not.toContain("confirm"); | |
| 95 | expect(getUserByName(app.db, "carol")!.suspendedAt).toBeNull(); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a non-admin, and an admin on a read-write token, are refused", async () => { | |
| 99 | const carol = createToken(app.db, id("carol"), "t", "admin").token; | |
| 100 | const rw = createToken(app.db, id("alice"), "t", "rw").token; | |
| 101 | expect((await mcp(carol, { username: "bob", suspended: true })).error).toBe(true); | |
| 102 | expect((await mcp(rw, { username: "bob", suspended: true })).error).toBe(true); | |
| 103 | expect(getUserByName(app.db, "bob")!.suspendedAt).toBeNull(); | |
| 104 | }); | |
| 105 | }); |
test/mcp.test.ts +1
| @@ -201,6 +201,7 @@ describe("the forge over MCP", () => { | ||
| 201 | 201 | "whoami_can", |
| 202 | 202 | "mint_invite", |
| 203 | 203 | "account_role", |
| 204 | "account_suspend", | |
| 204 | 205 | "model_ceiling", |
| 205 | 206 | "outsiders", |
| 206 | 207 | "decisions_by", |
new above
offline
No test reaches this branch. Without the branch, the tool would fall through to
not_allowedwith the wrong message, and the suite would still pass. Add a case where the target is the only owner of an org, and assert thelast_ownercode, the org's name in the text, and that the account is still unsuspended.