Public: anyone can read this; the crew can change it.
MCP `telemetry_grant`: grant or take back an agent's telemetry:read, with the same rule as the Telemetry page #5472
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5462-mcp-telemetry-grant-grant-or-take-back-a → main 4 commits fork 5 threads · 5 unresolved
3 files
changed +142
−1
docs/MCP.md +1
| @@ -49,6 +49,7 @@ for a tunnel and a forge that sleeps. | ||
| 49 | 49 | | `stop` | stop one run by its id from `now` | yes | |
| 50 | 50 | | `health` | how this forge is: the version and commit it runs, draining or not, the rollout and its incidents | | |
| 51 | 51 | | `system` | the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read` | | |
| 52 | | `telemetry_grant` | `{ agent, on }`: grant an agent `telemetry:read`, or take it back with `false`, as the Telemetry page does; a person admin's, with an admin token; answers `says` | yes | | |
| 52 | 53 | | `cloud_accounts` | the forge's cloud accounts, each attached provider token with its use, verification, write permission and today's calls; a forge admin only (counted as a write so a `ro` token never lists them) | yes | |
| 53 | 54 | | `cloud_account` | `{ verb, provider, name, value?, to?, allowed?, repo? }`: change a cloud account: `attach` or `rotate` a token (`value`), `verify` it, `rename` it (`to`), `remove` it, `allow_writes` (`allowed` true or false) or `audit` (`repo` owner/name); a forge admin with an admin-scope token; answers a one-line `says` | yes | |
| 54 | 55 | | `audit` | who can do what, per app, read from its contract.json in git: yes, own or no per door, and whether outsiders may direct its crew (`crewOutsiders`) | | |
src/http/mcp.ts +28 −1
| @@ -242,7 +242,7 @@ import { | ||
| 242 | 242 | import { search } from "../actions/search.ts"; |
| 243 | 243 | import { frontInput, frontJson, setFront } from "../actions/front.ts"; |
| 244 | 244 | import { readHealth } from "../actions/health.ts"; |
| 245 | import { noteRead, telemetryReader } from "../actions/telemetry.ts"; | |
| 245 | import { noteRead, setTelemetryGrant, telemetryReader } from "../actions/telemetry.ts"; | |
| 246 | 246 | import { flowFor, systemFor, welcome } from "../system/telemetry.ts"; |
| 247 | 247 | import { running } from "../kernel/version.ts"; |
| 248 | 248 | import { parentForgeFor } from "../core/parent_releases.ts"; |
| @@ -4192,6 +4192,33 @@ const TOOLS: readonly Tool[] = [ | ||
| 4192 | 4192 | return good(systemFor(ctx.app, reader)); |
| 4193 | 4193 | }, |
| 4194 | 4194 | }, |
| 4195 | { | |
| 4196 | name: "telemetry_grant", | |
| 4197 | description: "Grant an agent telemetry:read, or take it back with `on: false`.", | |
| 4198 | inputSchema: { | |
| 4199 | type: "object", | |
| 4200 | properties: { agent: { type: "string" }, on: { type: "boolean" } }, | |
| 4201 | required: ["agent", "on"], | |
| 4202 | }, | |
| 4203 | writes: true, | |
| 4204 | adminOnly: true, | |
| 4205 | needsAdminScope: true, | |
This gate, together with the handler's scope check below, can be removed with every test still green. Please add a test that would fail without it: a forge admin with a |
||
| 4206 | run(ctx, user, args) { | |
| 4207 | const { agent, on } = args; | |
| 4208 | if (typeof agent !== "string" || typeof on !== "boolean") | |
| 4209 | return bad("invalid", "send an agent name and on, true or false"); | |
| 4210 | if (!mayBoards(user)) return bad("forbidden", "telemetry:read is a forge admin's to grant"); | |
| 4211 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) | |
| 4212 | return bad("forbidden", "granting needs an admin token"); | |
| 4213 | const set = setTelemetryGrant(ctx.app, user, agent, on); | |
| 4214 | if (!set.ok) | |
| 4215 | return set.error === "not_an_agent" | |
| 4216 | ? bad("invalid", "Only an agent account can be granted telemetry:read.") | |
| 4217 | : bad("not_found", "no such account"); | |
| 4218 | const { username } = set.value; | |
| 4219 | return good({ agent: username, on, says: `${username} ${on ? "may now read" : "no longer reads"} telemetry.` }); | |
| 4220 | }, | |
| 4221 | }, | |
| 4195 | 4222 | { |
| 4196 | 4223 | name: "contribute", |
| 4197 | 4224 | description: |
test/mcp-telemetry-grant.test.ts added +113
| @@ -0,0 +1,113 @@ | ||
| 1 | /** MCP `telemetry_grant`: the Telemetry page's grant, from a harness. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createAgent } from "../src/actions/crew.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | const PASS = "a-long-telemetry-passphrase"; | |
| 17 | ||
| 18 | describe("MCP telemetry_grant", () => { | |
| 19 | let dataDir = ""; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let owner = ""; | |
| 23 | let botToken = ""; | |
| 24 | ||
| 25 | const call = async (token: string, name: string, args: unknown) => { | |
| 26 | const res = await fetch(`${servers.url}/mcp`, { | |
| 27 | method: "POST", | |
| 28 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 29 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }), | |
| 30 | }); | |
| 31 | const body = (await res.json()) as any; | |
| 32 | // A tool the caller cannot see is a JSON-RPC error; one it can see and may not use is an isError result. | |
| 33 | if (body.error) return { error: true, text: body.error.message as string }; | |
| 34 | return { error: body.result.isError === true, text: body.result.content[0].text as string }; | |
| 35 | }; | |
| 36 | const grant = (token: string, agent: string, on: boolean) => call(token, "telemetry_grant", { agent, on }); | |
| 37 | ||
| 38 | beforeAll(async () => { | |
| 39 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-telgrant-")); | |
| 40 | app = createApp({ dataDir, port: 0 }); | |
| 41 | servers = await serve(app); | |
| 42 | const made: Record<string, any> = {}; | |
| 43 | for (const [username, isAdmin] of [["alice", true], ["bob", false]] as const) { | |
| 44 | made[username] = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: PASS, isAdmin }); | |
| 45 | if (!made[username].ok) throw new Error(username); | |
| 46 | } | |
| 47 | owner = createToken(app.db, made["alice"].value.id, "t", "admin").token; | |
| 48 | const alice = getUserByName(app.db, "alice")!; | |
| 49 | const bot = await createAgent(app, alice, { username: "bot" }); | |
| 50 | if (!bot.ok) throw new Error("bot"); | |
| 51 | botToken = createToken(app.db, bot.value.user.id, "t", "ro").token; | |
| 52 | }, 30_000); | |
| 53 | ||
| 54 | afterAll(async () => { | |
| 55 | servers.stop(); | |
| 56 | app.close(); | |
| 57 | await rm(dataDir, { recursive: true, force: true }); | |
| 58 | }); | |
| 59 | ||
| 60 | test("the owner grants an agent, whose telemetry read then succeeds; taking it back fails it again", async () => { | |
| 61 | expect((await call(botToken, "system", {})).error).toBe(true); | |
| 62 | const on = await grant(owner, "bot", true); | |
| 63 | expect(on.error).toBe(false); | |
| 64 | expect(JSON.parse(on.text)).toEqual({ agent: "bot", on: true, says: "bot may now read telemetry." }); | |
| 65 | expect((await call(botToken, "system", {})).error).toBe(false); | |
| 66 | const off = await grant(owner, "bot", false); | |
| 67 | expect(JSON.parse(off.text)).toEqual({ agent: "bot", on: false, says: "bot no longer reads telemetry." }); | |
| 68 | expect((await call(botToken, "system", {})).error).toBe(true); | |
| 69 | }); | |
| 70 | ||
| 71 | test("a person's name is refused", async () => { | |
| 72 | const r = await grant(owner, "bob", true); | |
| 73 | expect(r.error).toBe(true); | |
| 74 | expect(r.text).toContain("Only an agent account can be granted telemetry:read."); | |
| 75 | }); | |
| 76 | ||
| 77 | const list = async (token: string) => { | |
| 78 | const res = await fetch(`${servers.url}/mcp`, { | |
| 79 | method: "POST", | |
| 80 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 81 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), | |
| 82 | }); | |
| 83 | return ((await res.json()) as any).result.tools.map((t: any) => t.name) as string[]; | |
| 84 | }; | |
| 85 | ||
| 86 | test("only an admin-scope token is listed the tool", async () => { | |
| 87 | expect(await list(owner)).toContain("telemetry_grant"); | |
| 88 | expect(await list(botToken)).not.toContain("telemetry_grant"); | |
| 89 | }); | |
| 90 | ||
outdated
Add the case that matters most for this tool: the same admin (alice) with a non-admin-scope token. As written, this test covers only non-admins, and they are already stopped by |
||
| 91 | test("the owner with a rw or ro token is not listed the tool and is refused", async () => { | |
| 92 | const alice = getUserByName(app.db, "alice")!; | |
| 93 | for (const scope of ["rw", "ro"] as const) { | |
| 94 | const tok = createToken(app.db, alice.id, "t", scope).token; | |
| 95 | expect(await list(tok)).not.toContain("telemetry_grant"); | |
| 96 | expect((await grant(tok, "bot", true)).error).toBe(true); | |
| 97 | } | |
| 98 | expect((await call(botToken, "system", {})).error).toBe(true); | |
| 99 | }); | |
| 100 | ||
| 101 | test("an unknown name is not found", async () => { | |
| 102 | const r = await grant(owner, "nobody", true); | |
| 103 | expect(r.error).toBe(true); | |
| 104 | expect(r.text).toContain("no such account"); | |
| 105 | }); | |
| 106 | ||
| 107 | test("a non-owner is refused", async () => { | |
| 108 | const bobTok = createToken(app.db, getUserByName(app.db, "bob")!.id, "t", "admin").token; | |
| 109 | expect((await grant(bobTok, "bot", true)).error).toBe(true); | |
| 110 | expect((await grant(botToken, "bot", true)).error).toBe(true); | |
| 111 | expect((await call(botToken, "system", {})).error).toBe(true); | |
| 112 | }); | |
| 113 | }); |
new above
offline
Per docs/MCP.md "Room for a new tool", this belongs on
systemas an argument (grant,on). If it stays a tool, addadminOnly: true, needsAdminScope: truesovisible()hides it from sessions that can never call it. That also takes it out of most sessions'tools/listwithout trimming other tools.