Public: anyone can read this; the crew can change it.
MCP `parent_unlink`: unlink the caller's account from its parent forge's sign-in, refused when it is the last way in, as Settings does #5486
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5483-mcp-parent-unlink-unlink-the-caller-s-ac → main 4 commits fork 1 thread · 1 unresolved
5 files
changed +110
−12
docs/MCP.md +1
| @@ -113,6 +113,7 @@ for a tunnel and a forge that sleeps. | ||
| 113 | 113 | | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes | |
| 114 | 114 | | `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 | |
| 115 | 115 | | `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 | |
| 116 | | `parent_unlink` | no input: stop signing in through the parent forge, as Settings does; refused when it is the account's last way in, and "Nothing to unlink." when nothing is linked; an admin token | yes | | |
| 116 | 117 | | `account_role` | `{ username, role, usdPerMonth?, dryRun? }`: make a person a `member`, or a `guest` again, with an optional starter allowance (`usdPerMonth`, 0.01 to 10000, promoting only); planned first (`dryRun` is true unless `false`) | yes | |
| 117 | 118 | | `mint_invite` | `{ days? }`: mint a single-use invite link that expires in `days` (1–30, default 7) and is shown once; a person admin's, with an admin token | yes | |
| 118 | 119 | | `signup` | `{ mode?, dryRun? }`: who may sign up: `open` (default), `invite` (only a person with an open invite link) or `closed` (no new accounts); without `mode` it reads the setting, with `mode` it plans first (`dryRun` is true unless `false`), and applying needs an admin token | yes | |
src/http/mcp.ts +26 −10
| @@ -193,6 +193,7 @@ import { checkStale } from "../checks/stale.ts"; | ||
| 193 | 193 | import { checkSummary, listActiveRuns } from "../core/runs.ts"; |
| 194 | 194 | import { scopeAllows } from "../core/tokens.ts"; |
| 195 | 195 | import { lastOwnerSays } from "../core/orgs.ts"; |
| 196 | import { LAST_WAY_IN, parentIssuerOf, unlinkParent } from "../core/federation.ts"; | |
| 196 | 197 | import { getUserByName, getUsersByIds, type User } from "../core/users.ts"; |
| 197 | 198 | import { chipsOf, isKindChip, setChip } from "../core/feed_chips.ts"; |
| 198 | 199 | import { openapi } from "../contract/openapi.ts"; |
| @@ -856,7 +857,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 856 | 857 | { |
| 857 | 858 | name: "issue", |
| 858 | 859 | description: |
| 859 | "One issue with comments, `blockedBy`, `blocks`; a crew agent sees `untrusted: true` on text by non-writers.", | |
| 860 | "One issue with comments, `blockedBy`, `blocks`; crew agents see `untrusted: true` on non-writers' text.", | |
| 860 | 861 | inputSchema: { |
| 861 | 862 | type: "object", |
| 862 | 863 | properties: { |
| @@ -1211,7 +1212,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1211 | 1212 | { |
| 1212 | 1213 | name: "asks", |
| 1213 | 1214 | description: |
| 1214 | "What waits on you, most consequential first: decision, options, cost of inaction; your saved filter applies.", | |
| 1215 | "What waits on you, most consequential first: decision, options, cost of inaction; your saved filter applies", | |
| 1215 | 1216 | inputSchema: { type: "object", properties: { ...CHIP_ARGS } }, |
| 1216 | 1217 | writes: false, |
| 1217 | 1218 | run(ctx, user, args) { |
| @@ -1223,7 +1224,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1223 | 1224 | { |
| 1224 | 1225 | name: "asks_act", |
| 1225 | 1226 | description: |
| 1226 | "Decide asks (`items`: `asks` ids, `issue:<id>`): dismiss, decline, ship (final), close (`reason`), mute, replan.", | |
| 1227 | "Decide asks (`items`: ask ids, `issue:<id>`): dismiss, decline, ship (final), close (`reason`), mute, replan", | |
| 1227 | 1228 | inputSchema: { |
| 1228 | 1229 | type: "object", |
| 1229 | 1230 | properties: { |
| @@ -2805,7 +2806,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2805 | 2806 | { |
| 2806 | 2807 | name: "propose_to_parent_forge", |
| 2807 | 2808 | description: |
| 2808 | "Offer this forge's landed change to its parent as a pull on `child/<name>/…`; the same tip answers it.", | |
| 2809 | "Offer this forge's landed change to its parent as a pull on `child/<name>/…`", | |
| 2809 | 2810 | inputSchema: { |
| 2810 | 2811 | type: "object", |
| 2811 | 2812 | properties: { repo: REPO_ARG }, |
| @@ -3540,7 +3541,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 3540 | 3541 | { |
| 3541 | 3542 | name: "limb", |
| 3542 | 3543 | description: |
| 3543 | "Act on a plan limb: `defer`, `resume`, `close` (`reason`), `raise`, `lower`, `replan`, `takeback`, `undo`.", | |
| 3544 | "Act on a limb: `defer`, `resume`, `close` (`reason`), `raise`, `lower`, `replan`, `takeback`, `undo`", | |
| 3544 | 3545 | inputSchema: { |
| 3545 | 3546 | type: "object", |
| 3546 | 3547 | properties: { |
| @@ -4313,7 +4314,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 4313 | 4314 | { |
| 4314 | 4315 | name: "access", |
| 4315 | 4316 | description: |
| 4316 | "Outside agents' non-browser requests to public paths: redacted, newest first, max 500. Forge admin.", | |
| 4317 | "Non-browser public-path requests, redacted, newest first (max 500). Forge admin.", | |
| 4317 | 4318 | inputSchema: { |
| 4318 | 4319 | type: "object", |
| 4319 | 4320 | properties: { |
| @@ -4392,7 +4393,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 4392 | 4393 | { |
| 4393 | 4394 | name: "flow", |
| 4394 | 4395 | description: |
| 4395 | "The pipeline (forge admin): stage counts, `oldestWaiting`, `capacity`, bottleneck, `queue`, `reviewing`.", | |
| 4396 | "Pipeline (forge admin): stage counts, `oldestWaiting`, `capacity`, bottleneck, `queue`, `reviewing`.", | |
| 4396 | 4397 | inputSchema: { type: "object", properties: {} }, |
| 4397 | 4398 | writes: false, |
| 4398 | 4399 | run(ctx, user) { |
| @@ -4602,7 +4603,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 4602 | 4603 | { |
| 4603 | 4604 | name: "cloud_account", |
| 4604 | 4605 | description: |
| 4605 | "Change a cloud account (admin token): `verb` attach, verify, rotate, rename, remove, allow_writes, audit.", | |
| 4606 | "Change a cloud account (admin token): `verb` attach, verify, rotate, rename, remove, allow_writes, audit", | |
| 4606 | 4607 | inputSchema: { |
| 4607 | 4608 | type: "object", |
| 4608 | 4609 | properties: { |
| @@ -4657,7 +4658,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 4657 | 4658 | { |
| 4658 | 4659 | name: "quotas", |
| 4659 | 4660 | description: |
| 4660 | "Per-account repo and app quotas (number or null; not enforced yet). With `key`, `value` (\"\" clears), plans.", | |
| 4661 | "Per-account repo and app quotas (number or null; not enforced yet). With `key`, `value` (\"\" clears).", | |
| 4661 | 4662 | inputSchema: { |
| 4662 | 4663 | type: "object", |
| 4663 | 4664 | properties: { |
| @@ -4695,7 +4696,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 4695 | 4696 | { |
| 4696 | 4697 | name: "signup", |
| 4697 | 4698 | description: |
| 4698 | "Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account.", | |
| 4699 | "Sign-ups: open (default); invite lets in only a person with an open invite link; closed refuses every new account", | |
| 4699 | 4700 | inputSchema: { |
| 4700 | 4701 | type: "object", |
| 4701 | 4702 | properties: { |
| @@ -4746,6 +4747,21 @@ const TOOLS: readonly Tool[] = [ | ||
| 4746 | 4747 | return bad("failed", "the link could not be sent; try again"); |
| 4747 | 4748 | }, |
| 4748 | 4749 | }, |
| 4750 | { | |
| 4751 | name: "parent_unlink", | |
| 4752 | description: "Unlink parent-forge sign-in.", | |
| 4753 | inputSchema: { type: "object", properties: {} }, | |
| 4754 | writes: true, | |
| 4755 | run(ctx, user) { | |
| 4756 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "unlinking needs an admin token"); | |
| 4757 | const issuer = parentIssuerOf(ctx.app.config); | |
| 4758 | const done = issuer === undefined ? undefined : unlinkParent(ctx.app.db, user.id, issuer); | |
| 4759 | if (done === undefined || (done.ok && done.value === "none")) return good({ says: "Nothing to unlink." }); | |
| 4760 | if (!done.ok) return bad("last_way_in", LAST_WAY_IN); | |
| 4761 | ctx.app.bus.append({ kind: "account.parent_unlinked", actorId: user.id }); | |
| 4762 | return good({ says: "Unlinked." }); | |
| 4763 | }, | |
| 4764 | }, | |
| 4749 | 4765 | { |
| 4750 | 4766 | name: "boards", |
| 4751 | 4767 | description: |
test/asks-mute.test.ts +4 −2
| @@ -24,7 +24,7 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 24 | 24 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 25 | 25 | import { serve, type Servers } from "../src/http/server.ts"; |
| 26 | 26 | import { mcpTool } from "./helpers/mcp.ts"; |
| 27 | import { askCountFor, askLinesFor, repoAsks } from "../src/web/studio/asks.ts"; | |
| 27 | import { askCountFor, askLinesFor, forgeNeedsFor, repoAsks } from "../src/web/studio/asks.ts"; | |
| 28 | 28 | import { quietLogs } from "./helpers/globals.ts"; |
| 29 | 29 | |
| 30 | 30 | quietLogs(); |
| @@ -152,7 +152,9 @@ describe("muting a repository's asks", () => { | ||
| 152 | 152 | expect(await onYou()).not.toContain("Muted work"); |
| 153 | 153 | expect(await apiAsks()).toHaveLength(0); |
| 154 | 154 | expect(askLinesFor(app.db, alice)).toHaveLength(0); |
| 155 | expect(askCountFor({ app, auth: { user: alice } } as never, alice)).toBe(0); | |
| 155 | // The count also carries the forge's own needs, which an admin has and muting does not touch. | |
| 156 | const ctx = { app, auth: { user: alice } } as never; | |
| 157 | expect(askCountFor(ctx, alice)).toBe(forgeNeedsFor(ctx, alice).length); | |
| 156 | 158 | // Said, never silent: the home names what it is holding back. |
| 157 | 159 | const muted = await home(); |
| 158 | 160 | expect(muted).not.toContain('action="/asks/mute"'); |
test/mcp-parent-unlink.test.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | /** MCP `parent_unlink`: Settings' unlink of the parent forge's sign-in, 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 { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | ||
| 13 | quietLogs(); | |
| 14 | ||
| 15 | const ISSUER = "https://parent.test"; | |
| 16 | ||
| 17 | describe("MCP parent_unlink", () => { | |
| 18 | let dataDir = ""; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | ||
| 22 | const id = (name: string) => getUserByName(app.db, name)!.id; | |
| 23 | const links = (name: string) => | |
| 24 | (app.db.query("SELECT COUNT(*) AS n FROM federated_identity WHERE user_id = ?1").get(id(name)) as { n: number }).n; | |
| 25 | const mcp = async (token: string) => { | |
| 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: "parent_unlink", arguments: {} } }), | |
| 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 | ||
| 35 | beforeAll(async () => { | |
| 36 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-unlink-")); | |
| 37 | app = createApp({ dataDir, port: 0, parentUrl: ISSUER }); | |
| 38 | servers = await serve(app); | |
| 39 | for (const [username, password] of [["alice", "a-long-unlink-passphrase"], ["bob", undefined], ["carol", "a-long-unlink-passphrase"], ["dave", "a-long-unlink-passphrase"]] as const) { | |
| 40 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, ...(password ? { password } : {}) }); | |
| 41 | if (!made.ok) throw new Error(username); | |
| 42 | } | |
| 43 | for (const name of ["alice", "bob", "dave"]) | |
| 44 | app.db.query("INSERT INTO federated_identity (issuer, sub, user_id) VALUES (?1, ?2, ?3)").run(ISSUER, `sub-${name}`, id(name)); | |
| 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 account with a password and a parent link is unlinked", async () => { | |
| 54 | const out = await mcp(createToken(app.db, id("alice"), "t", "admin").token); | |
| 55 | expect(out.error).toBe(false); | |
| 56 | expect(out.text).toContain("Unlinked."); | |
| 57 | expect(links("alice")).toBe(0); | |
| 58 | }); | |
| 59 | ||
| 60 | test("an account whose parent link is its only way in is refused and stays linked", async () => { | |
| 61 | const out = await mcp(createToken(app.db, id("bob"), "t", "admin").token); | |
| 62 | expect(out.error).toBe(true); | |
| 63 | expect(out.text).toContain("Add a passkey or a password first"); | |
| 64 | expect(links("bob")).toBe(1); | |
| 65 | }); | |
| 66 | ||
| 67 | test("nothing linked says so", async () => { | |
| 68 | const out = await mcp(createToken(app.db, id("carol"), "t", "admin").token); | |
| 69 | expect(out.error).toBe(false); | |
| 70 | expect(out.text).toContain("Nothing to unlink."); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a read-write token is refused", async () => { | |
| 74 | const out = await mcp(createToken(app.db, id("dave"), "t", "rw").token); | |
| 75 | expect(out.error).toBe(true); | |
| 76 | expect(links("dave")).toBe(1); | |
| 77 | }); | |
| 78 | }); |
test/mcp.test.ts +1
| @@ -275,6 +275,7 @@ describe("the forge over MCP", () => { | ||
| 275 | 275 | "quotas", |
| 276 | 276 | "signup", |
| 277 | 277 | "email_verify", |
| 278 | "parent_unlink", | |
| 278 | 279 | "boards", |
| 279 | 280 | "board_write", |
| 280 | 281 | "board_pins", |
new above
offline