Public: anyone can read this; the crew can change it.
MCP `notify` reads, sets or turns off the caller's phone notify URL, needing what the Settings page needs #6959
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6850-mcp-notify-reads-sets-or-turns-off-the-c → main 3 commits fork 2 threads · 2 unresolved
5 files
changed +153
−0
docs/MCP.md +1
| @@ -102,6 +102,7 @@ for a tunnel and a forge that sleeps. | ||
| 102 | 102 | | `model_grant` | `{ username, usdPerMonth, dryRun? }` (a forge admin who is a person): give an account a capped monthly model grant in dollars (0 takes it back); planned unless `dryRun` is false, applying takes an admin token | yes | |
| 103 | 103 | | `new_app` | `{ name, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken) | yes | |
| 104 | 104 | | `next_steps` | on a spawned forge, your first three steps and whether each is done; empty elsewhere or for anyone but its owner | | |
| 105 | | `notify` | `{ url? }`: read (its host only), set or, with an empty `url`, turn off your phone notify URL, as Settings does; a person's, saving needs an admin token | yes | | |
| 105 | 106 | | `now` | what the crew is doing right now: who, on what, since when, at what cost; a planner that declined the direction and waits for you comes first (`run: null`, its words in `says`) | | |
| 106 | 107 | | `org_teams` | `{ org, action, team, user?, permission? }` (admin-scope token, an org owner): `create` a team (`permission` read, write or admin; read by default), `add_member` a `user`, or `remove` the team; the same as POST/DELETE `/api/v1/orgs/:org/teams` | yes | |
| 107 | 108 | | `orgs` | `{ org? }`: without `org`, the organizations you belong to; with it, the organization, the teams you may see and each team's members; the same as GET `/api/v1/user/orgs`, `/api/v1/orgs/:org`, `…/teams` and `…/members` | | |
src/http/mcp/tools/index.ts +1
| @@ -76,6 +76,7 @@ export { tool as model_ceiling } from "./model_ceiling.ts"; | ||
| 76 | 76 | export { tool as model_grant } from "./model_grant.ts"; |
| 77 | 77 | export { tool as new_app } from "./new_app.ts"; |
| 78 | 78 | export { tool as next_steps } from "./next_steps.ts"; |
| 79 | export { tool as notify } from "./notify.ts"; | |
| 79 | 80 | export { tool as now } from "./now.ts"; |
| 80 | 81 | export { tool as org_teams } from "./org_teams.ts"; |
| 81 | 82 | export { tool as orgs } from "./orgs.ts"; |
src/http/mcp/tools/notify.ts added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | import { scopeAllows } from "../../../core/tokens.ts"; | |
| 2 | import { NOTIFY_ERRORS, notifyHost, notifyUrlOf, setNotifyUrl } from "../../../crew/notify.ts"; | |
| 3 | import { good, bad, type Tool } from "../shared.ts"; | |
| 4 | ||
| 5 | export const tool: Tool = { | |
| 6 | name: "notify", | |
| 7 | doc: "`{ url? }`: read (its host only), set or, with an empty `url`, turn off your phone notify URL, as Settings does; a person's, saving needs an admin token", | |
| 8 | description: "Read, set or clear your phone notify URL.", | |
| 9 | inputSchema: { type: "object", properties: { url: { type: "string" } } }, | |
| 10 | writes: true, | |
| 11 | readsWithout: "url", | |
| 12 | run(ctx, user, args) { | |
| 13 | if (user.kind === "agent") return bad("forbidden", "a notify URL is a person's, not an agent's"); | |
| 14 | const { url } = args; | |
| 15 | if (url === undefined) { | |
| 16 | const host = notifyHost(notifyUrlOf(ctx.app.db, user.id)); | |
| 17 | return good({ set: host !== undefined, host: host ?? null }); | |
| 18 | } | |
| 19 | if (typeof url !== "string") return bad("invalid", "send url as text, or empty to turn it off"); | |
| 20 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 21 | return bad("forbidden", "changing the notify URL needs an admin token"); | |
| 22 | const saved = setNotifyUrl(ctx.app.db, user.id, url); | |
| 23 | if (!saved.ok) return bad("invalid", NOTIFY_ERRORS[saved.error]); | |
| 24 | ctx.app.bus.append({ kind: "account.notify_set", actorId: user.id }); | |
| 25 | return good({ set: saved.value === "set", host: notifyHost(notifyUrlOf(ctx.app.db, user.id)) ?? null }); | |
| 26 | }, | |
| 27 | }; |
test/mcp-notify.test.ts added +123
| @@ -0,0 +1,123 @@ | ||
| 1 | /** | |
| 2 | * The `notify` tool is the Settings page's notify URL over MCP: the same | |
| 3 | * refusals, the same event, the same token scope for a change. | |
| 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 { createApp, type App } from "../src/app.ts"; | |
| 10 | import { createAgent } from "../src/actions/crew.ts"; | |
| 11 | import { createToken } from "../src/core/tokens.ts"; | |
| 12 | import { createUser } from "../src/core/users.ts"; | |
| 13 | import { notifyUrlOf } from "../src/crew/notify.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | describe("MCP notify", () => { | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let servers: Servers; | |
| 23 | let aliceId = ""; | |
| 24 | let admin = ""; | |
| 25 | let narrow = ""; | |
| 26 | let ro = ""; | |
| 27 | let botToken = ""; | |
| 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: "notify", 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-notify-")); | |
| 52 | app = createApp({ dataDir, port: 0 }); | |
| 53 | servers = await serve(app); | |
| 54 | const a = await createUser(app.db, { | |
| 55 | origin: "seed", | |
| 56 | username: "alice", | |
| 57 | email: "[email protected]", | |
| 58 | password: "a-good-passphrase", | |
| 59 | adminIfFirst: true, | |
| 60 | trusted: true, | |
| 61 | }); | |
| 62 | if (!a.ok) throw new Error("users"); | |
| 63 | aliceId = a.value.id; | |
| 64 | admin = createToken(app.db, aliceId, "admin", "admin").token; | |
| 65 | narrow = createToken(app.db, aliceId, "rw", "rw").token; | |
| 66 | ro = createToken(app.db, aliceId, "ro", "ro").token; | |
| 67 | const bot = await createAgent(app, a.value, { username: "bot" }); | |
| 68 | if (!bot.ok) throw new Error("agent"); | |
| 69 | botToken = createToken(app.db, bot.value.user.id, "admin", "admin").token; | |
| 70 | }, 30_000); | |
| 71 | ||
| 72 | afterAll(async () => { | |
| 73 | servers.stop(); | |
| 74 | app.close(); | |
| 75 | await rm(dataDir, { recursive: true, force: true }); | |
| 76 | }); | |
| 77 | ||
| 78 | test("set, then read back its host only; empty turns it off", async () => { | |
| 79 | expect(JSON.parse((await call(admin, {})).text)).toEqual({ set: false, host: null }); | |
| 80 | const heard: string[] = []; | |
| 81 | const off = app.bus.subscribe((e) => heard.push(e.kind)); | |
| 82 | const set = await call(admin, { url: "https://ntfy.example.com/secret-topic" }); | |
| 83 | off(); | |
| 84 | expect(JSON.parse(set.text)).toEqual({ set: true, host: "ntfy.example.com" }); | |
outdated
This read uses an |
||
| 85 | expect(heard).toContain("account.notify_set"); | |
| 86 | const read = await call(narrow, {}); | |
| 87 | expect(read.text).not.toContain("secret-topic"); | |
| 88 | expect(JSON.parse(read.text)).toEqual({ set: true, host: "ntfy.example.com" }); | |
| 89 | const cleared = await call(admin, { url: "" }); | |
| 90 | expect(JSON.parse(cleared.text)).toEqual({ set: false, host: null }); | |
| 91 | expect(notifyUrlOf(app.db, aliceId)).toBeUndefined(); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a private-network or non-https address is refused and nothing is written", async () => { | |
| 95 | const priv = await call(admin, { url: "https://192.168.1.5/topic" }); | |
| 96 | expect(priv.error).toBe(true); | |
| 97 | expect(priv.text).toContain("inside a network"); | |
| 98 | expect((await call(admin, { url: "http://ntfy.example.com/x" })).error).toBe(true); | |
| 99 | expect((await call(admin, { url: 5 })).error).toBe(true); | |
| 100 | expect(notifyUrlOf(app.db, aliceId)).toBeUndefined(); | |
| 101 | }); | |
| 102 | ||
| 103 | test("a token narrower than the page needs may read but not change", async () => { | |
| 104 | const refused = await call(narrow, { url: "https://ntfy.example.com/x" }); | |
| 105 | expect(refused.error).toBe(true); | |
| 106 | expect(refused.text).toContain("forbidden"); | |
| 107 | expect(notifyUrlOf(app.db, aliceId)).toBeUndefined(); | |
| 108 | }); | |
| 109 | ||
| 110 | test("a read-only token reads the host but cannot change it", async () => { | |
| 111 | expect(JSON.parse((await call(ro, {})).text)).toEqual({ set: false, host: null }); | |
| 112 | const refused = await call(ro, { url: "https://ntfy.example.com/x" }); | |
| 113 | expect(refused.error).toBe(true); | |
| 114 | expect(notifyUrlOf(app.db, aliceId)).toBeUndefined(); | |
| 115 | }); | |
| 116 | ||
| 117 | test("an agent token is refused", async () => { | |
| 118 | const read = await call(botToken, {}); | |
| 119 | expect(read.error).toBe(true); | |
| 120 | expect(read.text).toContain("forbidden"); | |
| 121 | expect((await call(botToken, { url: "https://ntfy.example.com/x" })).error).toBe(true); | |
| 122 | }); | |
| 123 | }); |
test/mcp.test.ts +1
| @@ -254,6 +254,7 @@ describe("the forge over MCP", () => { | ||
| 254 | 254 | "converge", |
| 255 | 255 | "family", |
| 256 | 256 | "next_steps", |
| 257 | "notify", | |
| 257 | 258 | "thought", |
| 258 | 259 | "asked", |
| 259 | 260 | "assigned", |
new above
offline
High (the documented read path is broken):
writes: truealone makes the dispatcher (src/http/mcp.ts:174) refuserotokens even fornotify {}. AddreadsWithout: "url"so a call with nourlis a read, as other tools that sometimes write do.