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

MCP `profile`: read and update the caller's own name, bio and links, with the same rules as the API #4837

Merged opened this

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

Merged

scout/forge:scout/4822-mcp-profile-read-and-update-the-caller-s → main 10 commits fork 10 threads · 10 unresolved

  • Merged. scout/4822-mcp-profile-read-and-update-the-caller-s was merged into main as 34a8ec2.

Review

Sign in to review this change.
7 files changed +131 −4
docs/MCP.md +1
@@ -103,6 +103,7 @@ for a tunnel and a forge that sleeps.
103103 | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes |
104104 | `preview` | `{ url }`: what the forge pill says of an app host's URL (production or a pull's preview): `{ app, environment, pull: { number, title } \| null, check: "green" \| "red" \| "running" \| null, firstFailure, logUrl, working: [names] }`; `firstFailure` is the check's first failing test or its summary, `logUrl` is set only for someone who may read the log (a writer), `working` names the agents with a run going; a URL that is not one of this forge's app hosts is `invalid`, an app you cannot read is `not_found` and a pull you cannot see is `no_such_pull`, as `repo` and `pull` say | |
105105 | `probe` | knock on a running app's doors as nobody, a user and an admin; the ones that disobey its contract | |
106 | `profile` | `{ displayName?, email?, commitEmail? }`: your own profile (`publicUser` and `emailVerified`, never the address); with an argument it changes it by the rules of `PATCH /api/v1/user`, and needs admin scope: only a site admin can mint such a token, so most people sign in to `/mcp` with their password | yes |
106107 | `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 |
107108 | `proposal_tokens` | `{ repo, verb, child?, origin?, childRepo?, id? }`: give a child forge a propose-only credential: `mint` one (`child`, `origin`), `list` them, or `revoke` one by `id`; writers only | yes |
108109 | `propose_to_parent_forge` | `{ repo }`: offer this forge's landed change to its parent as a pull on `child/<name>/…`; the same tip answers with that pull | yes |
src/http/mcp/shared.ts +3 −3
@@ -44,8 +44,8 @@ export interface Tool {
4444 readonly adminOnly?: true;
4545 /** Listed, and callable, only with a token of this scope. */
4646 readonly needsAdminScope?: true;
47 /** With this argument absent the tool only reads, whatever the token. */
48 readonly readsWithout?: string;
47 /** With this argument (or all of these) absent the tool only reads, whatever the token. */
48 readonly readsWithout?: string | readonly string[];
4949 /** A call this accepts only reads, whatever the token. */
5050 readonly readsWhen?: (args: Args) => boolean;
5151 /** A write that only plans (`dryRun` not false) is a read: `ro` may ask. */
@@ -61,7 +61,7 @@ export const visible = (tool: Tool, ctx: Ctx, user: User): boolean =>
6161 /** Whether this call only plans or reads, so a read-only token may make it. */
6262 export const plansOnly = (tool: Tool, args: Args): boolean =>
6363 (tool.dryByDefault === true && args["dryRun"] !== false) ||
64 (tool.readsWithout !== undefined && args[tool.readsWithout] === undefined) ||
64 (tool.readsWithout !== undefined && [tool.readsWithout].flat().every((k) => args[k] === undefined)) ||
6565 tool.readsWhen?.(args) === true;
6666
6767 export const CHIP_ARGS = {
src/http/mcp/tools/index.ts +1
@@ -83,6 +83,7 @@ export { tool as person } from "./person.ts";
8383 export { tool as plan } from "./plan.ts";
8484 export { tool as preview } from "./preview.ts";
8585 export { tool as probe } from "./probe.ts";
86 export { tool as profile } from "./profile.ts";
8687 export { tool as prompts } from "./prompts.ts";
8788 export { tool as proposal_tokens } from "./proposal_tokens.ts";
8889 export { tool as propose_to_parent_forge } from "./propose_to_parent_forge.ts";
src/http/mcp/tools/profile.ts added +36
@@ -0,0 +1,36 @@
1 import { scopeAllows } from "../../../core/tokens.ts";
2 import { emailVerified } from "../../../core/verify.ts";
3 import { updateProfile } from "../../../actions/users.ts";
4 import { publicUser } from "../../respond.ts";
5 import { good, bad, type Tool } from "../shared.ts";
6
7 export const tool: Tool = {
8 name: "profile",
9 doc: "`{ displayName?, email?, commitEmail? }`: your own profile (`publicUser` and `emailVerified`, never the address); with an argument it changes it by the rules of `PATCH /api/v1/user`, and needs admin scope: only a site admin can mint such a token, so most people sign in to `/mcp` with their password",
10 description: "Read or change your profile.",
11 inputSchema: {
12 type: "object",
13 properties: {
14 displayName: { type: "string" },
15 email: { type: "string" },
16 commitEmail: { type: "string" },
17 },
18 },
19 writes: true,
20 readsWithout: ["displayName", "email", "commitEmail"],
21 run(ctx, user, args) {
22 const { displayName, email, commitEmail } = args;
23 if (displayName === undefined && email === undefined && commitEmail === undefined)
24 return good({ ...publicUser(user), emailVerified: emailVerified(ctx.app.db, user.id) });
25 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
26 return bad("forbidden", "changing your profile needs an admin-scope token");

This repeats the commitEmail allow-list and the three-field patch from api.ts:1406-1420. It's fine for now. If a fourth field is added, a shared profilePatch(input) keeps the two doors from drifting apart.

27 if (commitEmail !== undefined && commitEmail !== "noreply" && commitEmail !== "account")

Nit, not blocking: this commitEmail check and the patch-building below copy the PATCH /api/v1/user handler in src/http/api.ts. A small shared profilePatch(input) (in actions/users.ts) would keep the two doors from drifting the next time a field is added.

28 return bad("invalid_commit_email", "invalid_commit_email");
29 const updated = updateProfile(ctx.app, user, {
30 ...(typeof displayName === "string" ? { displayName } : {}),
31 ...(typeof email === "string" ? { email } : {}),
32 ...(commitEmail === "noreply" || commitEmail === "account" ? { commitEmail } : {}),
33 });
34 return updated.ok ? good(publicUser(updated.value)) : bad(updated.error, updated.error);
35 },
36 };
test/mcp-profile.test.ts added +88
@@ -0,0 +1,88 @@
1 /** The `profile` tool is GET and PATCH /api/v1/user over MCP: the same shape, rules and errors. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createToken } from "../src/core/tokens.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10
11 describe("MCP profile", () => {
12 let dataDir: string;
13 let app: App;
14 let servers: Servers;
15 let admin: string;
16 let ro: string;
17 let rw: string;
18 let id = 1;
19
20 const tool = async (token: string, args: unknown) => {
21 const res = await fetch(`${servers.url}/mcp`, {
22 method: "POST",
23 headers: {
24 "content-type": "application/json",
25 accept: "application/json, text/event-stream",
26 authorization: `Bearer ${token}`,
27 },
28 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "profile", arguments: args } }),
29 });
30 const body: any = await res.json();
31 const text: string = body.result.content[0].text;
32 return body.result.isError === true ? { error: text } : { value: JSON.parse(text), text };
33 };
34
35 beforeAll(async () => {
36 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-profile-"));
37 app = createApp({ dataDir, port: 0 });
38 servers = await serve(app);
39 const a = await createUser(app.db, { origin: "seed", trusted: true,
40 username: "alice",
41 email: "[email protected]",
42 password: "a-good-passphrase",
43 adminIfFirst: true,
44 });
45 if (!a.ok) throw new Error(a.error);
46 admin = createToken(app.db, a.value.id, "admin", "admin").token;
47 ro = createToken(app.db, a.value.id, "ro", "ro").token;
48 rw = createToken(app.db, a.value.id, "rw", "rw").token;
49 }, 30_000);
50
51 afterAll(async () => {
52 servers.stop();
53 app.close();
54 await rm(dataDir, { recursive: true, force: true });
55 });
56
57 test("a person reads their profile as the API shows it, and updates it", async () => {
58 const api: any = await (await fetch(`${servers.url}/api/v1/user`, { headers: { authorization: `Bearer ${admin}` } })).json();
59 const read = await tool(admin, {});
60 expect(read.value).toEqual(api);
61 expect(read.text).not.toContain("[email protected]");
62 const set = await tool(admin, { displayName: "Alice Liddell", commitEmail: "account" });
63 expect(set.value.displayName).toBe("Alice Liddell");
64 expect(set.value.commitEmail).toBe("account");
65 expect(set.text).not.toContain("[email protected]");
66 expect((await tool(admin, {})).value.displayName).toBe("Alice Liddell");
67 });
68
69 test("a bad field is refused with the API's error", async () => {
70 expect((await tool(admin, { email: "x".repeat(400) + "@forge.local" })).error).toContain("invalid_email");
71 expect((await tool(admin, { commitEmail: "everyone" })).error).toContain("invalid_commit_email");
72 expect((await tool(admin, {})).value.displayName).toBe("Alice Liddell");
73 });
74
75 test("a read-only token reads its profile as the API shows it", async () => {
76 const api: any = await (await fetch(`${servers.url}/api/v1/user`, { headers: { authorization: `Bearer ${ro}` } })).json();
outdated

toBeDefined() passes for any refusal, including the generic "this token may read, not act", so it does not prove the tool's own admin-scope rule. Add an rw token case that expects forbidden, and a read-only token read that equals GET /api/v1/user.

77 expect((await tool(ro, {})).value).toEqual(api);
78 });
79
80 test("an update needs an admin-scope token: read-only and rw are refused, nothing changes", async () => {
81 const before = (await tool(admin, {})).value.displayName;
82 expect((await tool(ro, { displayName: "Mallory" })).error).toContain("forbidden");
83 const res = await tool(rw, { displayName: "Mallory" });
84 expect(res.error).toContain("forbidden");
85 expect(res.error).toContain("admin-scope");
86 expect((await tool(admin, {})).value.displayName).toBe(before);
87 });
88 });
test/mcp-write-scope.test.ts +1 −1
@@ -89,7 +89,7 @@ describe("a token that may only read cannot write through any tool", () => {
8989 const writing = (t: ReturnType<typeof toolRegistry>[number]) => {
9090 if (t.name in ADMIN_ARGS) return ADMIN_ARGS[t.name]!;
9191 const args: Record<string, unknown> = { dryRun: false, action: "x" };
92 if (t.readsWithout !== undefined) args[t.readsWithout] = "x";
92 if (t.readsWithout !== undefined) args[[t.readsWithout].flat()[0]!] = "x";
9393 return args;
9494 };
9595
test/mcp.test.ts +1
@@ -213,6 +213,7 @@ describe("the forge over MCP", () => {
213213 "model_grant",
214214 "accounts",
215215 "whoami_can",
216 "profile",
216217 "mint_invite",
217218 "invites",
218219 "account_role",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs