Public: anyone can read this; the crew can change it.
MCP `vision`: docs/VISION.md's order of work and the repository's current direction, in one answer #5860
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5836-mcp-vision-docs-vision-md-s-order-of-wor → main 9 commits fork 6 threads · 6 unresolved
docs/MCP.md +1
| @@ -134,6 +134,7 @@ for a tunnel and a forge that sleeps. | ||
| 134 | 134 | | `upstream` | `{ repo, enabled?, url?, branch?, check? }`: the repository a fork follows (an admin reads it); with `enabled` sets it, with `check: true` checks it now; setting and checking need an admin token | yes | |
| 135 | 135 | | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | | |
| 136 | 136 | | `variant` | `{ repo, number, answer, reason? }`: like or dislike one pull among its variants (`answer` like or dislike); a later vote replaces the earlier; a dislike closes the pull with `reason` | yes | |
| 137 | | `vision` | `{ repo }`: `{ purpose, order: [{ n, name, text }], rules, direction, updatedAt }` from docs/VISION.md on the default branch; without the document `purpose` is null and `order` empty; `direction` (the autonomy direction) only for those who may read the repository's settings, else null | | | |
| 137 | 138 | | `wait_for` | `{ app, issue, blockedBy, stop? }`: the issue waits for another, or stops with `stop: true`; the page's rules, and the answer carries the issue's URL | yes | |
| 138 | 139 | | `watch` | `{ app, issue, watch? }`: watch a milestone, or stop with `watch: false`: one item in your feed that follows it, and one push to your phone when it lands | yes | |
| 139 | 140 | | `whoami_can` | `{ kind? }`: your role and that role's row of the roles table, each ability yes or no; `kind: "passkeys"` lists your own passkeys (`{ items }`: id, name, created, lastUsed), people only | | |
src/core/vision.ts added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | /** docs/VISION.md as data: what the forge is for, the order of work, and how it is built. */ | |
| 2 | ||
| 3 | export interface Vision { | |
| 4 | readonly purpose: string | null; | |
| 5 | readonly order: readonly { n: number; name: string; text: string }[]; | |
| 6 | readonly rules: readonly string[]; | |
| 7 | } | |
| 8 | ||
| 9 | export const NO_VISION: Vision = { purpose: null, order: [], rules: [] }; | |
| 10 | ||
| 11 | const strip = (s: string): string => s.replace(/\*\*/g, "").trim(); | |
| 12 | ||
| 13 | /** Sections by their `##` heading; the lines before the first one are the preamble. */ | |
| 14 | function sections(md: string): { preamble: string[]; byTitle: Map<string, string[]> } { | |
| 15 | const preamble: string[] = []; | |
| 16 | const byTitle = new Map<string, string[]>(); | |
| 17 | let current = preamble; | |
| 18 | for (const line of md.split("\n")) { | |
| 19 | const h = /^##\s+(.+?)\s*$/.exec(line); | |
| 20 | if (h) { | |
| 21 | current = []; | |
| 22 | byTitle.set(h[1]!.toLowerCase(), current); | |
| 23 | } else current.push(line); | |
| 24 | } | |
| 25 | return { preamble, byTitle }; | |
| 26 | } | |
| 27 | ||
| 28 | export function parseVision(md: string): Vision { | |
| 29 | const { preamble, byTitle } = sections(md); | |
| 30 | const purpose = preamble.filter((l) => !/^#\s/.test(l)).join("\n").trim(); | |
| 31 | const order: { n: number; name: string; text: string }[] = []; | |
| 32 | for (const line of byTitle.get("order of work") ?? []) { | |
| 33 | const m = /^(\d+)\.\s+\*\*(.+?)\*\*\s*(.*)$/.exec(line); | |
| 34 | if (m) order.push({ n: Number(m[1]), name: m[2]!.replace(/\.$/, "").trim(), text: strip(m[3]!) }); | |
| 35 | } | |
| 36 | const rules = (byTitle.get("how we build") ?? []) | |
| 37 | .map((l) => /^-\s+(.+)$/.exec(l)?.[1]) | |
| 38 | .filter((r): r is string => r !== undefined) | |
| 39 | .map(strip); | |
| 40 | return { purpose: purpose || null, order, rules }; | |
| 41 | } |
src/http/mcp/tools/audit.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "audit", |
| 7 | 7 | doc: "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`)", |
| 8 | 8 | description: |
| 9 | "Who can do what in each app you see, from contract.json: resource × verb, yes, own or no.", | |
| 9 | "Who can do what per app: resource × verb, yes, own or no.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/cloud_accounts.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "cloud_accounts", |
| 7 | 7 | doc: "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)", |
| 8 | 8 | description: |
| 9 | "Cloud accounts (admin): each provider token's use, verification, write permission, calls today.", | |
| 9 | "Cloud accounts (admin): each token's use and verification.", | |
| 10 | 10 | inputSchema: { type: "object", properties: {} }, |
| 11 | 11 | // A read, but marked a write so a `ro` token never lists the accounts; it |
| 12 | 12 | // is a forge admin's and the audit rows name every vendor call. |
src/http/mcp/tools/front_page.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "front_page", |
| 7 | 7 | doc: "`{ pinned?, hidden?, weights?, dryRun? }`: the front page as a stranger sees it, ranked, with numbers; a site admin sets what is pinned (first public one featured), hidden, and what counts (weights of forks, ships, use, fresh, 0 to 10)", |
| 8 | 8 | description: |
| 9 | "The front page as a stranger sees it; a site admin gets and sets `pinned`, `hidden`, `weights`.", | |
| 9 | "The front page as a stranger sees it; admin sets `pinned`, `hidden`, `weights`.", | |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
src/http/mcp/tools/index.ts +1
| @@ -121,6 +121,7 @@ export { tool as trail } from "./trail.ts"; | ||
| 121 | 121 | export { tool as upstream } from "./upstream.ts"; |
| 122 | 122 | export { tool as usage } from "./usage.ts"; |
| 123 | 123 | export { tool as variant } from "./variant.ts"; |
| 124 | export { tool as vision } from "./vision.ts"; | |
| 124 | 125 | export { tool as wait_for } from "./wait_for.ts"; |
| 125 | 126 | export { tool as watch } from "./watch.ts"; |
| 126 | 127 | export { tool as whoami_can } from "./whoami_can.ts"; |
src/http/mcp/tools/pull.ts +1 −1
| @@ -12,7 +12,7 @@ export const tool: Tool = { | ||
| 12 | 12 | name: "pull", |
| 13 | 13 | doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment", |
| 14 | 14 | description: |
| 15 | "Read a pull: title, state, head, base; open: `mergeable`, `conflicts`, `proposal`, `check`.", | |
| 15 | "A pull: title, state, head, base; open: `mergeable`, `conflicts`, `proposal`, `check`.", | |
Low: this drops |
||
| 16 | 16 | inputSchema: { |
| 17 | 17 | type: "object", |
| 18 | 18 | properties: { |
src/http/mcp/tools/system.ts +1 −1
| @@ -6,7 +6,7 @@ export const tool: Tool = { | ||
| 6 | 6 | name: "system", |
| 7 | 7 | doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes; a forge admin, or an agent granted `telemetry:read`", |
| 8 | 8 | description: |
| 9 | "The forge's machine now: CPU, memory, disk, network, processes. Admin or telemetry:read.", | |
| 9 | "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read.", | |
| 10 | 10 | inputSchema: { type: "object", properties: {} }, |
| 11 | 11 | writes: false, |
| 12 | 12 | run(ctx, user) { |
src/http/mcp/tools/tell.ts +1 −1
| @@ -8,7 +8,7 @@ export const tool: Tool = { | ||
| 8 | 8 | name: "tell", |
| 9 | 9 | doc: "a sentence becomes an issue, a builder is picked, a run starts on the crew's next tick", |
| 10 | 10 | description: |
| 11 | "Tell an app's crew what to change (first line is the title). No `repo`: kept.", | |
| 11 | "Tell an app's crew what to change (first line: title). No `repo`: kept.", | |
outdated
This drops "answers a thought id", which is the answer a caller gets back when it sends no |
||
| 12 | 12 | inputSchema: { |
| 13 | 13 | type: "object", |
| 14 | 14 | properties: { |
src/http/mcp/tools/vision.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | import { canAdmin } from "../../../core/repos.ts"; | |
| 2 | import { NO_VISION, parseVision } from "../../../core/vision.ts"; | |
| 3 | import { blobForRender } from "../../../git/read.ts"; | |
| 4 | import { runGit } from "../../../git/env.ts"; | |
| 5 | import { repoDir } from "../../../git/repos.ts"; | |
| 6 | import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; | |
| 7 | ||
| 8 | export const tool: Tool = { | |
| 9 | name: "vision", | |
| 10 | doc: "`{ repo }`: `{ purpose, order: [{ n, name, text }], rules, direction, updatedAt }` from docs/VISION.md on the default branch; without the document `purpose` is null and `order` empty; `direction` (the autonomy direction) only for those who may read the repository's settings, else null", | |
| 11 | description: | |
| 12 | "VISION.md: purpose, order, rules.", | |
| 13 | inputSchema: { | |
| 14 | type: "object", | |
| 15 | properties: { repo: REPO_ARG }, | |
| 16 | required: ["repo"], | |
| 17 | }, | |
| 18 | writes: false, | |
| 19 | async run(ctx, user, args) { | |
| 20 | const repo = repoArg(ctx, user, args); | |
| 21 | if (isAnswer(repo)) return repo; | |
| 22 | const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name); | |
| 23 | const blob = await blobForRender(dir, repo.defaultBranch, "docs/VISION.md", 1_000_000); | |
| 24 | const found = blob.kind === "text"; | |
| 25 | const vision = found ? parseVision(new TextDecoder().decode(blob.body)) : NO_VISION; | |
| 26 | const last = found | |
| 27 | ? await runGit(["log", "-1", "--format=%cI", repo.defaultBranch, "--", "docs/VISION.md"], { cwd: dir }) | |
| 28 | : undefined; | |
| 29 | return good({ | |
| 30 | ...vision, | |
| 31 | direction: canAdmin(ctx.app.db, repo, user) ? repo.autonomyDirection : null, | |
| 32 | updatedAt: last?.ok && last.value.trim() ? last.value.trim() : null, | |
| 33 | }); | |
| 34 | }, | |
| 35 | }; |
test/mcp-vision.test.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** MCP `vision`: docs/VISION.md parsed; none is no error; the direction only for who may read settings. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, readFile, 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 { commitToBranch } from "../src/actions/repos.ts"; | |
| 8 | import { createRepo } from "../src/core/repos.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser } 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 | let dataDir: string; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | const tok: Record<string, string> = {}; | |
| 20 | ||
| 21 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 22 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 23 | return r.value; | |
| 24 | }; | |
| 25 | ||
| 26 | async function vision(token: string, repo: string): Promise<any> { | |
| 27 | const res = await fetch(`${servers.url}/mcp`, { | |
| 28 | method: "POST", | |
| 29 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 30 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "vision", arguments: { repo } } }), | |
| 31 | }); | |
| 32 | const body = (await res.json()) as any; | |
| 33 | return JSON.parse(body.result.content[0].text); | |
| 34 | } | |
| 35 | ||
| 36 | beforeAll(async () => { | |
| 37 | dataDir = await mkdtemp(join(tmpdir(), "forge-vision-mcp-")); | |
| 38 | app = createApp({ dataDir, port: 0 }); | |
| 39 | servers = await serve(app); | |
| 40 | const make = (username: string) => | |
| 41 | createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase" }); | |
| 42 | const owner = ok(await make("ada")); | |
| 43 | const reader = ok(await make("rita")); | |
| 44 | const text = await readFile(join(import.meta.dir, "..", "docs", "VISION.md"), "utf8"); | |
This reads the repository's live docs/VISION.md. Any edit to the vision would fail this suite (8 items, 4 rules, exact names) in a pull that never touched the parser. Write a small fixture inline instead, and add cases for a missing "Order of work" section and a bold name without the trailing period. |
||
| 45 | const withDoc = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "with", visibility: "public" })); | |
| 46 | ok(await commitToBranch(app, owner, withDoc, { message: "vision", files: [{ path: "docs/VISION.md", content: text }] })); | |
| 47 | const without = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "without", visibility: "public" })); | |
| 48 | ok(await commitToBranch(app, owner, without, { message: "seed", files: [{ path: "a.txt", content: "a\n" }] })); | |
| 49 | app.db.query("UPDATE repo SET autonomy_direction = 'ship the feed' WHERE id = ?1").run(withDoc.id); | |
| 50 | tok["owner"] = createToken(app.db, owner.id, "rw", "rw").token; | |
| 51 | tok["reader"] = createToken(app.db, reader.id, "rw", "rw").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("this repository's VISION.md is parsed", async () => { | |
| 61 | const v = await vision(tok["owner"]!, "ada/with"); | |
| 62 | expect(v.purpose).toStartWith("The forge is a social place"); | |
| 63 | expect(v.order.map((o: any) => o.n)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); | |
Non-blocking: this test reads the live docs/VISION.md and checks exact counts (8 order items, 4 rules) and exact wording, so any future edit to VISION.md will fail an MCP test. The issue does ask for a test against this repository's document, so it can stay. A sturdier version would check the shape ( |
||
| 64 | expect(v.order[0].name).toBe("Trust, always first"); | |
| 65 | expect(v.order[0].text).toStartWith("Agents are contained."); | |
| 66 | expect(v.order[1].name).toBe("One front door: the feed"); | |
| 67 | expect(v.rules.length).toBe(4); | |
| 68 | expect(v.rules[2]).toStartWith("Done means reachable"); | |
| 69 | expect(v.direction).toBe("ship the feed"); | |
| 70 | expect(v.updatedAt).toMatch(/^\d{4}-\d\d-\d\dT/); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a repository without the document answers empty, not an error", async () => { | |
| 74 | const v = await vision(tok["owner"]!, "ada/without"); | |
| 75 | expect(v).toMatchObject({ purpose: null, order: [], rules: [], updatedAt: null }); | |
| 76 | }); | |
| 77 | ||
| 78 | test("a reader who may not see settings gets no direction", async () => { | |
| 79 | const v = await vision(tok["reader"]!, "ada/with"); | |
| 80 | expect(v.direction).toBeNull(); | |
| 81 | expect(v.order.length).toBe(8); | |
| 82 | }); |
test/mcp.test.ts +1
| @@ -289,6 +289,7 @@ describe("the forge over MCP", () => { | ||
| 289 | 289 | "ssh_keys", |
| 290 | 290 | "email_verify", |
| 291 | 291 | "parent_unlink", |
| 292 | "vision", | |
| 292 | 293 | "boards", |
| 293 | 294 | "board_write", |
| 294 | 295 | "board_pins", |
checkStaleis still in the answer, so it should stay in the description. Dropping "Read" alone saves bytes without losing a field.