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

Merged opened this

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

Merged

scout/forge:scout/4794-mcp-account-suspend-suspend-or-unsuspend → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/4794-mcp-account-suspend-suspend-or-unsuspend was merged into main as f50f414.

Review

Sign in to review this change.
6 files changed +144 −8
docs/MCP.md +1
@@ -76,6 +76,7 @@ for a tunnel and a forge that sleeps.
7676 | `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 |
7777 | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes |
7878 | `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 |
7980
8081 Answers are JSON in a text block; most carry a `says` sentence short enough
8182 to relay as it is.
src/core/orgs.ts +7
@@ -247,6 +247,13 @@ export function retireOrg(db: Database, orgId: string, at: string): void {
247247 forgetListings(db);
248248 }
249249
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
250257 /** The organizations `userId` is the last active person owning: removing them would orphan these. */
251258 export function soleOwnedOrgs(db: Database, userId: string): User[] {
252259 const user = getUserById(db, userId);
src/http/api.ts +3 −8
@@ -208,6 +208,7 @@ import {
208208 type Permission,
209209 type Team,
210210 soleOwnedOrgs,
211 lastOwnerSays,
211212 } from "../core/orgs.ts";
212213 import {
213214 getPull,
@@ -1803,18 +1804,12 @@ export function apiRoutes(router: Router<Handler>): void {
18031804 const done = await suspendAccount(ctx.app, ctx.auth.user, ctx.params.username!, {
18041805 confirmed: input.confirm === true,
18051806 });
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")
18121808 return apiError(
18131809 "last_owner",
18141810 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`,
18161812 );
1817 }
18181813 return done.ok ? json({ username: done.value.username, suspendedAt: done.value.suspendedAt }) : accountError(done.error);
18191814 });
18201815
src/http/mcp.ts +27
@@ -82,6 +82,7 @@ import {
8282 } from "../web/studio/cards.ts";
8383 import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts";
8484 import { inviteLink, mint as mintInviteLink } from "../actions/invites.ts";
85 import { suspendAccount, unsuspendAccount } from "../actions/accounts.ts";
8586 import { setRole } from "../actions/roles.ts";
8687 import { ABILITIES, roleOf, rowOf } from "../core/roles.ts";
8788 import { keepThought } from "../actions/thought.ts";
@@ -154,6 +155,7 @@ import { plannerDeclines } from "../crew/autonomy.ts";
154155 import { checkStale } from "../checks/stale.ts";
155156 import { checkSummary, listActiveRuns } from "../core/runs.ts";
156157 import { scopeAllows } from "../core/tokens.ts";
158 import { lastOwnerSays } from "../core/orgs.ts";
157159 import { getUserByName, getUsersByIds, type User } from "../core/users.ts";
158160 import { chipsOf, isKindChip, setChip } from "../core/feed_chips.ts";
159161 import { openapi } from "../contract/openapi.ts";
@@ -1556,6 +1558,31 @@ const TOOLS: readonly Tool[] = [
15561558 return good(set.value);
15571559 },
15581560 },
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));
outdated

No test reaches this branch. Without the branch, the tool would fall through to not_allowed with the wrong message, and the suite would still pass. Add a case where the target is the only owner of an org, and assert the last_owner code, the org's name in the text, and that the account is still unsuspended.

1583 return bad("not_allowed", "an admin cannot suspend or remove themselves, another admin, or an agent");
1584 },
1585 },
15591586 {
15601587 name: "model_ceiling",
15611588 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", () => {
201201 "whoami_can",
202202 "mint_invite",
203203 "account_role",
204 "account_suspend",
204205 "model_ceiling",
205206 "outsiders",
206207 "decisions_by",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs