Public: anyone can read this; the crew can change it.
The MCP following, follow_mode and family tools say in their doc what they take and what they answer, as forks and explore do #9114
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9109-the-mcp-following-follow-mode-and-family → main 2 commits fork
4 files
changed +106
−3
src/http/mcp/tools/family.ts +1 −1
| @@ -3,7 +3,7 @@ import { good, type Tool } from "../shared.ts"; | ||
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "family", |
| 6 | doc: "the forge that sent this one and, for a site admin, those it sent, with state and version", | |
| 6 | doc: "`{}`: `{ parent, here, children, more }`, the forge that sent this one (`parent`: `{ url, host, behind }`, `null` when none), this one (`here`: `{ host, version }`) and, for a site admin, those it sent (`children`, each with state, version, `apps` and `placement`; `more` the count not listed); takes no arguments", | |
| 7 | 7 | description: |
| 8 | 8 | "The forge that sent this one and, for a site admin, those it sent, with state, version.", |
| 9 | 9 | inputSchema: { type: "object", properties: {} }, |
src/http/mcp/tools/follow_mode.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 4 | 4 | |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "follow_mode", |
| 7 | doc: "`{ repo, … }`: choose how a spawned forge follows its parent, `tell-me` or `take-updates`; its `forge` repository's admin only", | |
| 7 | doc: "`{ repo, mode }`: choose how a spawned forge follows its parent, `mode` being `tell-me` or `take-updates`; its `forge` repository's admin only; answers `{ says, mode }`; refused `unauthorized`, `forbidden`, `not_following`, `invalid_mode` or `severed`", | |
| 8 | 8 | description: |
| 9 | 9 | "How a spawned forge follows its parent: `tell-me` or `take-updates`.", |
| 10 | 10 | inputSchema: { |
src/http/mcp/tools/following.ts +2 −1
| @@ -7,7 +7,8 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 7 | 7 | |
| 8 | 8 | export const tool: Tool = { |
| 9 | 9 | name: "following", |
| 10 | doc: "where a fork stands against what it follows, and what the crew is doing about it", | |
| 10 | doc: | |
| 11 | "`{ repo }`: for a fork `{ parent, state, behind, ahead, clean, conflicts, parentForge, converge, chain, severedAt, upstream }`, where it stands against what it follows (`state` in step, behind, ahead or diverged; `behind`/`ahead` commits; `clean` whether a merge would apply; `conflicts` the files that clash; `converge` what the crew is doing about a conflict, else `null`; `parentForge`, `chain`, `severedAt` and `upstream` the forge-to-forge link, `null` when there is none); for a repository that is no fork but a spawned forge, only `{ chain, severedAt, upstream }`; refused `not_a_fork` when it follows nothing, `not_found` for a repository the caller may not see", | |
| 11 | 12 | description: |
| 12 | 13 | "Where a fork stands against what it follows: in step, behind, ahead, diverged.", |
| 13 | 14 | inputSchema: { |
test/mcp-fork-tree-docs.test.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * The fork-tree MCP tools say in `doc` what they take and what they answer | |
| 3 | * (#9109): read live from `tools/list` and a sample call, so a changed answer | |
| 4 | * that the doc forgets fails here. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createToken } from "../src/core/tokens.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { tool as family } from "../src/http/mcp/tools/family.ts"; | |
| 17 | import { tool as follow_mode } from "../src/http/mcp/tools/follow_mode.ts"; | |
| 18 | import { tool as following } from "../src/http/mcp/tools/following.ts"; | |
| 19 | import { tool as forks } from "../src/http/mcp/tools/forks.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | import { listAt } from "./mcp-list.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | describe("fork-tree MCP tool docs", () => { | |
| 26 | let dataDir = ""; | |
| 27 | let app: App; | |
| 28 | let servers: Servers; | |
| 29 | let token = ""; | |
| 30 | // `doc` is never sent to a client (it is the /llms.txt line), so it is read from the tool. | |
| 31 | const docs: Record<string, string> = Object.fromEntries([forks, following, follow_mode, family].map((t) => [t.name, t.doc ?? ""])); | |
| 32 | const schemas: Record<string, Record<string, unknown>> = {}; | |
| 33 | ||
| 34 | const call = async (name: string, args: Record<string, unknown>) => { | |
| 35 | const res = await fetch(`${servers.url}/mcp`, { | |
| 36 | method: "POST", | |
| 37 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 38 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }), | |
| 39 | }); | |
| 40 | const body = (await res.json()) as any; | |
| 41 | const text = body.result.content[0].text as string; | |
| 42 | // A refusal is `code: message`, not JSON. | |
| 43 | return body.result.isError === true ? { error: text.split(":")[0] } : (JSON.parse(text) as Record<string, unknown>); | |
| 44 | }; | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-treedocs-")); | |
| 48 | app = createApp({ dataDir, port: 0 }); | |
| 49 | servers = await serve(app); | |
| 50 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-treedocs-pass", adminIfFirst: true }); | |
| 51 | if (!made.ok) throw new Error(made.error); | |
| 52 | const alice = made.value; | |
| 53 | token = createToken(app.db, alice.id, "t", "admin").token; | |
| 54 | const lib = await createRepo(app, alice, { name: "lib", visibility: "public" }); | |
| 55 | if (!lib.ok) throw new Error("lib"); | |
| 56 | const fork = await forkRepo(app, alice, lib.value, "lib-fork"); | |
| 57 | if (!fork.ok) throw new Error("fork"); | |
| 58 | const listed = (await listAt(servers.url, token)).result.tools; | |
| 59 | for (const t of listed) { | |
| 60 | schemas[t.name] = t.inputSchema; | |
| 61 | } | |
| 62 | }, 30_000); | |
| 63 | ||
| 64 | afterAll(async () => { | |
| 65 | servers.stop(); | |
| 66 | app.close(); | |
| 67 | await rm(dataDir, { recursive: true, force: true }); | |
| 68 | }); | |
| 69 | ||
| 70 | const samples: Record<string, Record<string, unknown>> = { | |
| 71 | forks: { repo: "alice/lib" }, | |
| 72 | following: { repo: "alice/lib-fork" }, | |
| 73 | follow_mode: { repo: "alice/lib-fork", mode: "take-updates" }, | |
| 74 | family: {}, | |
| 75 | }; | |
| 76 | ||
| 77 | for (const [name, args] of Object.entries(samples)) { | |
| 78 | test(`${name}: every argument is in backticks in its doc`, () => { | |
| 79 | const doc = docs[name] ?? ""; | |
| 80 | expect(doc).not.toBe(""); | |
| 81 | for (const prop of Object.keys((schemas[name]!["properties"] as object) ?? {})) | |
| 82 | expect(new RegExp(`\`[^\`]*\\b${prop}\\b[^\`]*\``).test(doc)).toBe(true); | |
| 83 | }); | |
| 84 | ||
| 85 | test(`${name}: every top-level key of a sample answer is in its doc`, async () => { | |
| 86 | const answer = await call(name, args); | |
| 87 | expect(answer["error"]).toBeUndefined(); | |
| 88 | for (const key of Object.keys(answer)) expect(docs[name]).toContain(key); | |
| 89 | }); | |
| 90 | } | |
| 91 | ||
| 92 | test("following names the not-a-fork refusal", async () => { | |
| 93 | const answer = await call("following", { repo: "alice/lib" }); | |
| 94 | expect(answer["error"]).toBe("not_a_fork"); | |
| 95 | expect(docs["following"]).toContain(String(answer["error"])); | |
| 96 | }); | |
| 97 | ||
| 98 | test("follow_mode names the refusals it gives", async () => { | |
| 99 | const answer = await call("follow_mode", { repo: "alice/lib", mode: "tell-me" }); | |
| 100 | expect(docs["follow_mode"]).toContain(String(answer["error"])); | |
| 101 | }); | |
| 102 | }); |
new above
offline