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

Merged opened this

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

Merged

scout/forge:scout/6850-mcp-notify-reads-sets-or-turns-off-the-c → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/6850-mcp-notify-reads-sets-or-turns-off-the-c was merged into main as 5537abd.

Review

Sign in to review this change.
5 files changed +153 −0
docs/MCP.md +1
@@ -102,6 +102,7 @@ for a tunnel and a forge that sleeps.
102102 | `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 |
103103 | `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 |
104104 | `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 |
105106 | `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`) | |
106107 | `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 |
107108 | `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";
7676 export { tool as model_grant } from "./model_grant.ts";
7777 export { tool as new_app } from "./new_app.ts";
7878 export { tool as next_steps } from "./next_steps.ts";
79 export { tool as notify } from "./notify.ts";
7980 export { tool as now } from "./now.ts";
8081 export { tool as org_teams } from "./org_teams.ts";
8182 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,

High (the documented read path is broken): writes: true alone makes the dispatcher (src/http/mcp.ts:174) refuse ro tokens even for notify {}. Add readsWithout: "url" so a call with no url is a read, as other tools that sometimes write do.

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 rw token, so it cannot catch the ro refusal. Mint an ro token (createToken(app.db, aliceId, "ro", "ro")) and read with it. Today that call answers forbidden: this token may read, not act.

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", () => {
254254 "converge",
255255 "family",
256256 "next_steps",
257 "notify",
257258 "thought",
258259 "asked",
259260 "assigned",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs