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

Merged opened this

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

Merged

mason/forge:mason/9109-the-mcp-following-follow-mode-and-family → main 2 commits fork

  • Merged. mason/9109-the-mcp-following-follow-mode-and-family was merged into main as 61b6936.

Review

Sign in to review this change.
4 files changed +106 −3
src/http/mcp/tools/family.ts +1 −1
@@ -3,7 +3,7 @@ import { good, type Tool } from "../shared.ts";
33
44 export const tool: Tool = {
55 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",
77 description:
88 "The forge that sent this one and, for a site admin, those it sent, with state, version.",
99 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"
44
55 export const tool: Tool = {
66 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`",
88 description:
99 "How a spawned forge follows its parent: `tell-me` or `take-updates`.",
1010 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"
77
88 export const tool: Tool = {
99 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",
1112 description:
1213 "Where a fork stands against what it follows: in step, behind, ahead, diverged.",
1314 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs