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

Merged opened this

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

Merged

scout/forge:scout/5483-mcp-parent-unlink-unlink-the-caller-s-ac → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/5483-mcp-parent-unlink-unlink-the-caller-s-ac was merged into main as d862204.

Review

Sign in to review this change.
5 files changed +110 −12
docs/MCP.md +1
@@ -113,6 +113,7 @@ for a tunnel and a forge that sleeps.
113113 | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes |
114114 | `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 |
115115 | `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 |
116117 | `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 |
117118 | `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 |
118119 | `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";
193193 import { checkSummary, listActiveRuns } from "../core/runs.ts";
194194 import { scopeAllows } from "../core/tokens.ts";
195195 import { lastOwnerSays } from "../core/orgs.ts";
196 import { LAST_WAY_IN, parentIssuerOf, unlinkParent } from "../core/federation.ts";
196197 import { getUserByName, getUsersByIds, type User } from "../core/users.ts";
197198 import { chipsOf, isKindChip, setChip } from "../core/feed_chips.ts";
198199 import { openapi } from "../contract/openapi.ts";
@@ -856,7 +857,7 @@ const TOOLS: readonly Tool[] = [
856857 {
857858 name: "issue",
858859 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.",
860861 inputSchema: {
861862 type: "object",
862863 properties: {
@@ -1211,7 +1212,7 @@ const TOOLS: readonly Tool[] = [
12111212 {
12121213 name: "asks",
12131214 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",
12151216 inputSchema: { type: "object", properties: { ...CHIP_ARGS } },
12161217 writes: false,
12171218 run(ctx, user, args) {
@@ -1223,7 +1224,7 @@ const TOOLS: readonly Tool[] = [
12231224 {
12241225 name: "asks_act",
12251226 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",
12271228 inputSchema: {
12281229 type: "object",
12291230 properties: {
@@ -2805,7 +2806,7 @@ const TOOLS: readonly Tool[] = [
28052806 {
28062807 name: "propose_to_parent_forge",
28072808 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>/…`",
28092810 inputSchema: {
28102811 type: "object",
28112812 properties: { repo: REPO_ARG },
@@ -3540,7 +3541,7 @@ const TOOLS: readonly Tool[] = [
35403541 {
35413542 name: "limb",
35423543 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`",
35443545 inputSchema: {
35453546 type: "object",
35463547 properties: {
@@ -4313,7 +4314,7 @@ const TOOLS: readonly Tool[] = [
43134314 {
43144315 name: "access",
43154316 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.",
43174318 inputSchema: {
43184319 type: "object",
43194320 properties: {
@@ -4392,7 +4393,7 @@ const TOOLS: readonly Tool[] = [
43924393 {
43934394 name: "flow",
43944395 description:
4395 "The pipeline (forge admin): stage counts, `oldestWaiting`, `capacity`, bottleneck, `queue`, `reviewing`.",
4396 "Pipeline (forge admin): stage counts, `oldestWaiting`, `capacity`, bottleneck, `queue`, `reviewing`.",
43964397 inputSchema: { type: "object", properties: {} },
43974398 writes: false,
43984399 run(ctx, user) {
@@ -4602,7 +4603,7 @@ const TOOLS: readonly Tool[] = [
46024603 {
46034604 name: "cloud_account",
46044605 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",
46064607 inputSchema: {
46074608 type: "object",
46084609 properties: {
@@ -4657,7 +4658,7 @@ const TOOLS: readonly Tool[] = [
46574658 {
46584659 name: "quotas",
46594660 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).",
46614662 inputSchema: {
46624663 type: "object",
46634664 properties: {
@@ -4695,7 +4696,7 @@ const TOOLS: readonly Tool[] = [
46954696 {
46964697 name: "signup",
46974698 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",
46994700 inputSchema: {
47004701 type: "object",
47014702 properties: {
@@ -4746,6 +4747,21 @@ const TOOLS: readonly Tool[] = [
47464747 return bad("failed", "the link could not be sent; try again");
47474748 },
47484749 },
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 },
47494765 {
47504766 name: "boards",
47514767 description:
test/asks-mute.test.ts +4 −2
@@ -24,7 +24,7 @@ import { createToken } from "../src/core/tokens.ts";
2424 import { createUser, getUserByName, type User } from "../src/core/users.ts";
2525 import { serve, type Servers } from "../src/http/server.ts";
2626 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";
2828 import { quietLogs } from "./helpers/globals.ts";
2929
3030 quietLogs();
@@ -152,7 +152,9 @@ describe("muting a repository's asks", () => {
152152 expect(await onYou()).not.toContain("Muted work");
153153 expect(await apiAsks()).toHaveLength(0);
154154 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);
156158 // Said, never silent: the home names what it is holding back.
157159 const muted = await home();
158160 expect(muted).not.toContain('action="/asks/mute"');
test/mcp.test.ts +1
@@ -275,6 +275,7 @@ describe("the forge over MCP", () => {
275275 "quotas",
276276 "signup",
277277 "email_verify",
278 "parent_unlink",
278279 "boards",
279280 "board_write",
280281 "board_pins",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs