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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4822-mcp-profile-read-and-update-the-caller-s → main 10 commits fork 10 threads · 10 unresolved
docs/MCP.md +1
| @@ -103,6 +103,7 @@ for a tunnel and a forge that sleeps. | ||
| 103 | 103 | | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes | |
| 104 | 104 | | `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 | | |
| 105 | 105 | | `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 | | |
| 106 | 107 | | `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 | |
| 107 | 108 | | `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 | |
| 108 | 109 | | `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/tools/index.ts +1
| @@ -83,6 +83,7 @@ export { tool as person } from "./person.ts"; | ||
| 83 | 83 | export { tool as plan } from "./plan.ts"; |
| 84 | 84 | export { tool as preview } from "./preview.ts"; |
| 85 | 85 | export { tool as probe } from "./probe.ts"; |
| 86 | export { tool as profile } from "./profile.ts"; | |
| 86 | 87 | export { tool as prompts } from "./prompts.ts"; |
| 87 | 88 | export { tool as proposal_tokens } from "./proposal_tokens.ts"; |
| 88 | 89 | 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"); | |
| 27 | if (commitEmail !== undefined && commitEmail !== "noreply" && commitEmail !== "account") | |
Nit, not blocking: this |
||
| 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
|
||
| 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", () => { | ||
| 89 | 89 | const writing = (t: ReturnType<typeof toolRegistry>[number]) => { |
| 90 | 90 | if (t.name in ADMIN_ARGS) return ADMIN_ARGS[t.name]!; |
| 91 | 91 | 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"; | |
| 93 | 93 | return args; |
| 94 | 94 | }; |
| 95 | 95 |
test/mcp.test.ts +1
| @@ -213,6 +213,7 @@ describe("the forge over MCP", () => { | ||
| 213 | 213 | "model_grant", |
| 214 | 214 | "accounts", |
| 215 | 215 | "whoami_can", |
| 216 | "profile", | |
| 216 | 217 | "mint_invite", |
| 217 | 218 | "invites", |
| 218 | 219 | "account_role", |
This repeats the
commitEmailallow-list and the three-field patch fromapi.ts:1406-1420. It's fine for now. If a fourth field is added, a sharedprofilePatch(input)keeps the two doors from drifting apart.