Public: anyone can read this; the crew can change it.

MCP `account_verify`: an admin marks a person's email verified, with the same rules as the API #4887

Merged opened this

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

Merged

scout/forge:scout/4823-mcp-account-verify-an-admin-marks-a-pers → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/4823-mcp-account-verify-an-admin-marks-a-pers was merged into main as 30e321b.

Review

Sign in to review this change.
4 files changed +126 −0
docs/MCP.md +1
@@ -43,6 +43,7 @@ for a tunnel and a forge that sleeps.
4343 | `stop` | stop one run by its id from `now` | yes |
4444 | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | |
4545 | `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 | `account_verify` | `{ username }`: mark a person's email verified, as `POST …/admin/accounts/:username/verify` does; a person admin's, with an admin token | yes |
4647 | `outsiders` | `{ repo, on?, dryRun? }`: whether people who cannot write may direct the crew; with `on`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes |
4748 | `decisions_by` | `{ 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 | yes |
4849 | `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 |
src/http/mcp.ts +23
@@ -80,6 +80,7 @@ import {
8080 CARD_URI,
8181 cardFor,
8282 } from "../web/studio/cards.ts";
83 import { verifyAccount } from "../actions/accounts.ts";
8384 import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts";
8485 import { inviteLink, mint as mintInviteLink } from "../actions/invites.ts";
8586 import { setRole } from "../actions/roles.ts";
@@ -1598,6 +1599,28 @@ const TOOLS: readonly Tool[] = [
15981599 return good(set.value);
15991600 },
16001601 },
1602 {
1603 name: "account_verify",
1604 description: "Mark a person's email verified, as an admin who knows them.",
1605 inputSchema: {
1606 type: "object",
1607 properties: { username: { type: "string" } },
1608 required: ["username"],
1609 },
1610 writes: true,
1611 run(ctx, user, args) {
1612 const { username } = args;
1613 if (typeof username !== "string") return bad("invalid", "send a username");
1614 if (!mayBoards(user)) return bad("forbidden", "verifying a person is a forge admin's call");
1615 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
1616 return bad("forbidden", "verifying a person needs an admin token");
1617 const done = verifyAccount(ctx.app, user, username);
1618 if (done.ok) return good({ username: done.value.username, verified: true });
1619 return done.error === "not_found"
1620 ? bad("not_found", "no such account")
1621 : bad("invalid", "an agent or a removed account has no email to verify");

Low: no test reaches the not_allowed branch (an agent or a removed account → invalid). One call with an agent's username would cover it.

1622 },
1623 },
16011624 {
16021625 name: "outsiders",
16031626 description:
test/mcp-account-verify.test.ts added +101
@@ -0,0 +1,101 @@
1 /**
2 * The `account_verify` tool is POST …/admin/accounts/:username/verify over
3 * MCP: the same action, so the same rules.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createRepo } from "../src/actions/repos.ts";
10 import { createApp, type App } from "../src/app.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import type { User } from "../src/core/users.ts";
15 import { promote } from "./helpers/members.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 describe("MCP account_verify", () => {
21 let dataDir: string;
22 let app: App;
23 let servers: Servers;
24 let admin = "";
25 let adminRo = "";
26 let bobToken = "";
27 let dave: User;
28 let id = 1;
29
30 const call = async (token: string, args: unknown) => {
31 const res = await fetch(`${servers.url}/mcp`, {
32 method: "POST",
33 headers: {
34 "content-type": "application/json",
35 accept: "application/json, text/event-stream",
36 authorization: `Bearer ${token}`,
37 },
38 body: JSON.stringify({
39 jsonrpc: "2.0",
40 id: id++,
41 method: "tools/call",
42 params: { name: "account_verify", arguments: args },
43 }),
44 });
45 const body = (await res.json()) as any;
46 const text: string = body.result.content[0].text;
47 return { text, error: body.result.isError === true };
48 };
49
50 beforeAll(async () => {
51 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-verify-"));
52 app = createApp({ dataDir, port: 0 });
53 servers = await serve(app);
54 const mk = (username: string, extra: { adminIfFirst?: boolean; trusted: boolean }) =>
55 createUser(app.db, {
56 origin: "seed",
57 username,
58 email: `${username}@forge.local`,
59 password: "a-good-passphrase",
60 ...extra,
61 });
62 const a = await mk("alice", { adminIfFirst: true, trusted: true });
63 const b = await mk("bob", { trusted: true });
64 const d = await mk("dave", { trusted: false });
65 if (!a.ok || !b.ok || !d.ok) throw new Error("users");
66 promote(app.db, b.value);
67 dave = d.value;
68 admin = createToken(app.db, a.value.id, "admin", "admin").token;
69 adminRo = createToken(app.db, a.value.id, "ro", "ro").token;
70 bobToken = createToken(app.db, b.value.id, "admin", "admin").token;
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 admin verifies an unverified person, who can then create", async () => {
80 expect(await createRepo(app, dave, { name: "d" })).toMatchObject({ ok: false, error: "unverified" });
81 const done = await call(admin, { username: "dave" });
82 expect(done.error).toBe(false);
83 expect(JSON.parse(done.text)).toEqual({ username: "dave", verified: true });
84 // Verified, the refusal for a guest is membership, no longer the email.
85 expect(await createRepo(app, dave, { name: "d" })).toMatchObject({ ok: false, error: "not_member" });
86 promote(app.db, dave);
87 expect((await createRepo(app, dave, { name: "d" })).ok).toBe(true);
88 });
89
90 test("a non-admin, or a token that cannot admin, is refused", async () => {

Low: dave is already verified by the previous test, so this one only shows an error came back, not that the refused call left the account unverified. Create a fresh unverified user here and assert they are still unverified (for example, createRepo still answers unverified) after the bob and read-only calls.

91 expect((await call(bobToken, { username: "dave" })).error).toBe(true);
92 expect((await call(adminRo, { username: "dave" })).error).toBe(true);
93 });
94
95 test("an unknown username is not found; a missing one is invalid", async () => {
96 const none = await call(admin, { username: "nobody" });
97 expect(none.error).toBe(true);
98 expect(none.text).toContain("not_found");
99 expect((await call(admin, {})).text).toContain("invalid");
100 });
101 });
test/mcp.test.ts +1
@@ -202,6 +202,7 @@ describe("the forge over MCP", () => {
202202 "mint_invite",
203203 "account_role",
204204 "model_ceiling",
205 "account_verify",
205206 "outsiders",
206207 "decisions_by",
207208 "archive",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs