Public: anyone can read this; the crew can change it.

The MCP opens with twelve tools, finds the rest on request, reaches every API… #11962

Merged opened this in answer to

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

Merged

scout/forge:scout/11957-the-mcp-opens-with-twelve-tools-finds-th → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/11957-the-mcp-opens-with-twelve-tools-finds-th was merged into main as 8237fb9.

Review

Sign in to review this change.
19 files changed +262 −24
docs/MCP.md +29 −2
@@ -106,6 +106,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
106106 | `feed_chips` | `kind`, `person` | Feed chips you switched off | 262 |
107107 | `feed_dismiss` | `dismissed`, `muted` | Dismiss a feed item or mute its app; `feed` then leaves it out | 280 |
108108 | `feed_like` | — | Like or unlike a feed item as a ledger row, or read its ledger | 308 |
109 | `find_tool` | — | Find a tool by what it does; returns name, description and schema | 263 |
109110 | `flag_set` | — | Set or clear a flag (admin) | 209 |
110111 | `flag_winner` | — | An A/B flag winner becomes default | 206 |
111112 | `flags` | — | Declared flags: why, default, owner issue, live value | 171 |
@@ -174,7 +175,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
174175 | `pulls` | — | A repository's pulls, newest first; no `repo`: all open pulls you may read, oldest first | 411 |
175176 | `queue_gates` | — | Recent merge-queue gates: pulls, verdicts, failing tests | 277 |
176177 | `quotas` | — | Enforced per-account quotas (never an admin): repositories, apps, storage, previews, runs, checks | 342 |
177 | `read_file` | — | Read a file or list a directory at a ref (default branch); over 5 MB answers tooLarge | 307 |
178 | `read_file` | — | No shell? Read a file or list a directory at a ref (default branch); over 5 MB answers tooLarge | 317 |
178179 | `release_hold` | — | Hold or release the release keeper's cuts (admin) | 202 |
179180 | `release_keeper` | — | The release keeper's releases, holds and what blocks the next cut | 192 |
180181 | `release_now` | — | Cut a release now, with a reason (admin) | 176 |
@@ -234,7 +235,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
234235 | `waves` | — | Waves; `version`: one | 260 |
235236 | `week` | — | The week's retrospective; `take` accepts one proposal | 226 |
236237 | `whoami_can` | `role`, `passkeys` | What you can do: your role and its roles-table row, yes or no each | 231 |
237 | `write_file` | — | Commit one file to a branch | 302 |
238 | `write_file` | — | No shell? Commit one file to a branch | 342 |
238239 | `report` | — | Report a problem in a public repository, no account needed | 468 |
239240 | `start` | — | Start building with no account: an account, a repo, a live app and a token in one call | 358 |
240241 <!-- mcp-tools:end -->
@@ -398,6 +399,32 @@ longer sentences:
398399 - `hold.why`: why, in one line; it shows on the pull. `runs.limit`: how many, 1 to 50; 20 when absent.
399400 - `boards.step`: with `panel`, seconds per bucket, or a count of `s`, `m`, `h` or `d`. `access.since`: ISO time; default a day, max 14 days.
400401
402 ## Ask for less: the default list and `find_tool` (#11957)
403
404 The MCP is the forge's control surface; repo work is git's (clone and push).
405 `tools/list` opens with the tools that direct and watch a forge: `tell`,
406 `say`, `next_up`, `now`, `runs`, `knobs`, `usage`, `apps`, `app_deploy`,
407 `forges`, `find_tool`, plus `report` and `start` (`DEFAULT_TOOLS` in
408 `src/http/mcp/catalog.ts`; `api` joins when it exists). `read_file` and
409 `write_file` are not in it: they are the fallback for an agent with no shell
410 ("No shell? ..."), reached by `find_tool`, and no diff, blame, tree or
411 multi-file tool is added on that side. The server instructions say so in two
412 sentences. Every tool stays callable by name. `find_tool({ query })` returns
413 up to eight tools whose name or description match every word, each with its
414 description and schema, so a tool found is callable at once. A client that
415 wants today's list sends `tools/list` with `{"all": true}` (paged as before).
416
417 | What | Before | After |
418 | --- | --- | --- |
419 | `tools/list`, admin | ~62 KB, 196 tools (~15k tokens) | 5.6 KB, 13 tools (~1.4k tokens) |
420 | A session that manages a forge (direct the crew, watch runs, deploy an app) and never loads a file tool | ~62 KB of tools | the same 5.6 KB list, no file tool in it |
421
422 `test/mcp-size.test.ts` caps the default list at 6 KB and asserts the file
423 tools are absent from it. Not done yet: the "full list" token setting, `api`,
424 `run_wait`, `since`/`fields`, the card only for a client that renders cards,
425 the `asks*`/`feed*` collapse; the cost of five common answers is measured
426 when those land.
427
401428 ## Connecting
402429
403430 Mint a token under **Settings → Access tokens** (`rw` to act, `ro` to watch),
src/http/mcp.ts +11 −2
@@ -41,6 +41,7 @@ import { claimDone, completeWrite, dropWrite, recordWrite } from "../core/conver
4141 import { type Args, alreadyDone, notAdminTool, visible, plansOnly, str, REPO_ARG, lean, fenceAnswer, heldWrite, askedFirst, readThrough } from "./mcp/shared.ts";
4242 import { CONVERSATION_BRIEF } from "../crew/conversation_brief.ts";
4343 import { TOOLS } from "./mcp/tools.ts";
44 import { DEFAULT_TOOLS, wantsAll } from "./mcp/catalog.ts";
4445
4546 const PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"] as const;
4647 /** The tools/list entries for these tools, then `report`: what is served, and what docs/MCP.md measures. */
@@ -61,6 +62,9 @@ export function toolEntries(tools: readonly (typeof TOOLS)[number][]) {
6162 ];
6263 }
6364
65 /** The entries this caller may call, `report` and `start` included: what find_tool searches. */
66 export const entriesFor = (ctx: Ctx, user: User) => toolEntries(TOOLS.filter((t) => visible(t, ctx, user)));
67
6468 // One page of tools/list stays within this many bytes of tools; `nextCursor`
6569 // names the rest, so adding a tool never busts one response.
6670 export const TOOLS_PAGE_BYTES = 20_000;
@@ -137,7 +141,7 @@ function rpcOne(
137141 : PROTOCOLS[0],
138142 capabilities: { tools: {}, resources: {} },
139143 serverInfo: server(),
140 instructions: `This is a person's forge: their apps, and the crew that builds them. \`tell\` and \`say\` are how they direct it. Keep what you relay short — three sentences and the real options. A tool with \`dryRun\` plans unless it is \`false\`.${adminNote(ctx, user)}${hidden === "" ? "" : ` ${hidden}`}${
144 instructions: `This is a person's forge: their apps, and the crew that builds them. \`tell\` and \`say\` are how they direct it. Keep what you relay short — three sentences and the real options. This door runs the forge (crew, apps, forges). With a shell, clone and push for repo work; \`read_file\` and \`write_file\` are for agents with none. A tool with \`dryRun\` plans unless it is \`false\`.${adminNote(ctx, user)}${hidden === "" ? "" : ` ${hidden}`}${
141145 ctx.auth.through === undefined ? "" : ` ${CONVERSATION_BRIEF}`
142146 }`,
143147 });
@@ -145,7 +149,12 @@ function rpcOne(
145149 case "ping":
146150 return reply({});
147151 case "tools/list": {
148 const all = toolEntries(TOOLS.filter((t) => visible(t, ctx, user)));
152 // By default a short list (docs/MCP.md, "Ask for less"); `all: true` is every tool, paged.
153 const everything = wantsAll(params);
154 const listed = entriesFor(ctx, user);
155 const all = everything
156 ? listed
157 : listed.filter((t) => DEFAULT_TOOLS.includes(t.name));
149158 // The cursor is the name of the last tool of the page before, so a tool
150159 // added between two requests moves nothing.
151160 const cursor = params["cursor"];
src/http/mcp/catalog.ts added +60
@@ -0,0 +1,60 @@
1 /**
2 * The tools a connection is offered up front, and how `find_tool` reaches the
3 * rest. `tools/list` costs every session ~13k tokens in full, so the default
4 * answer is this short list; any other tool stays callable by name, and
5 * `find_tool` (or `tools/list` with `{"all": true}`) shows its schema.
6 */
7 import type { Args } from "./shared.ts";
8
9 /**
10 * The forge's control surface (#11957): direct the crew, see what happens,
11 * manage apps and forges. Repo work is git's (clone and push), so the
12 * file tools are not here; `find_tool` still reaches them. `report` and
13 * `start` are open without a token, so they are always offered.
14 */
15 export const DEFAULT_TOOLS: readonly string[] = [
16 "tell", "say", "next_up", "now", "runs", "knobs",
17 "usage", "apps", "app_deploy", "forges", "find_tool", "report", "start",
18 ];
19

api does not exist: dead entry. Also fold report and start in here so the default set is in one place.

20 export interface ToolEntry {
21 name: string;
22 description: string;
23 inputSchema: unknown;
24 }
25
26 /** The longest query and the most words of it that matter. */
27 export const QUERY_MAX = 100;
28 export const WORDS_MAX = 8;
29
30 /** How many matches one answer holds. */
31 export const FIND_MAX = 8;
32
33 /** Entries matching every word of `query`, best first; a name weighs more than a description. */
34 export function matching(entries: readonly ToolEntry[], query: string): ToolEntry[] {
35 const words = query
36 .slice(0, QUERY_MAX)
37 .toLowerCase()
38 .split(/[^a-z0-9_]+/)
39 .filter((w) => w !== "")
40 .slice(0, WORDS_MAX);
41 if (words.length === 0) return [];
42 const scored: { e: ToolEntry; score: number }[] = [];
43 for (const e of entries) {
44 const name = e.name.toLowerCase();
45 const desc = e.description.toLowerCase();
46 let score = 0;
47 let all = true;
48 for (const w of words) {
49 const n = name.includes(w) ? (name === w ? 6 : 3) : 0;
50 const d = desc.includes(w) ? 1 : 0;
51 if (n + d === 0) all = false;
52 score += n + d;
53 }
54 if (all) scored.push({ e, score });
55 }
56 return scored.sort((a, b) => b.score - a.score || a.e.name.localeCompare(b.e.name)).map((s) => s.e);
57 }
58
59 /** Whether `args.all` asks for today's full list. */
60 export const wantsAll = (params: Args): boolean => params["all"] === true;
src/http/mcp/tools/find_tool.ts added +28
@@ -0,0 +1,28 @@
1 import { bad, good, str, type Tool } from "../shared.ts";
2 import { FIND_MAX, matching, QUERY_MAX } from "../catalog.ts";
3
4 export const tool: Tool = {
5 name: "find_tool",
6 doc: "`{ query }`: the tools whose name or description match every word, with their one-line description and schema; a tool found is callable at once, listed or not. `tools/list` with `{\"all\": true}` is the full list",
7 description: "Find a tool by what it does; returns name, description and schema. Any tool is callable by name.",
8 inputSchema: {
9 type: "object",
10 properties: { query: { type: "string", maxLength: QUERY_MAX } },
11 required: ["query"],
12 },
13 writes: false,
14 async run(ctx, user, args) {
15 const query = str(args["query"]).trim();

Bound the query in code: slice to 100 chars (as the schema says) and cap the word count in matching(). Test it.

16 if (query === "") return bad("invalid_query", "query is a word or two about what you want to do");
17 // Lazy: mcp.ts imports the tool table, which holds this tool.
18 const { entriesFor } = await import("../../mcp.ts");
19 const found = matching(
20 entriesFor(ctx, user).map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
21 query,
22 );
23 return good({
24 tools: found.slice(0, FIND_MAX),
25 ...(found.length > FIND_MAX ? { more: found.length - FIND_MAX } : {}),
26 });
27 },
28 };
src/http/mcp/tools/index.ts +1
@@ -65,6 +65,7 @@ export { tool as feed } from "./feed.ts";
6565 export { tool as feed_chips } from "./feed_chips.ts";
6666 export { tool as feed_dismiss } from "./feed_dismiss.ts";
6767 export { tool as feed_like } from "./feed_like.ts";
68 export { tool as find_tool } from "./find_tool.ts";
6869 export { tool as flag_set } from "./flag_set.ts";
6970 export { tool as flag_winner } from "./flag_winner.ts";
7071 export { tool as flags } from "./flags.ts";
src/http/mcp/tools/read_file.ts +1 −1
@@ -11,7 +11,7 @@ export const tool: Tool = {
1111 name: "read_file",
1212 doc: "`{ repo, path, ref? }`: a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents; MP4/MOV/M4A files also carry about: {kind, seconds, width?, height?, says}",
1313 description:
14 "Read a file or list a directory at a ref (default branch); over 5 MB answers tooLarge.",
14 "No shell? Read a file or list a directory at a ref (default branch); over 5 MB answers tooLarge.",
1515 inputSchema: {
1616 type: "object",
1717 properties: {
src/http/mcp/tools/write_file.ts +1 −1
@@ -8,7 +8,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"
88 export const tool: Tool = {
99 name: "write_file",
1010 doc: "`{ repo, branch, path, content, message }`: commit one file to an existing branch as `POST …/contents` does, for the same callers and with the same refusals (protected default, too large, a path with `..`), said in `edit_words`' sentences; answers `{ commit, says }`",
11 description: "Commit one file to a branch.",
11 description: "No shell? Commit one file to a branch. With a shell, clone and push.",
1212 inputSchema: {
1313 type: "object",
1414 properties: {
src/release/outsider.ts +1 −1
@@ -125,7 +125,7 @@ export async function outsider(base: string, options: OutsiderOptions = {}): Pro
125125 method: "POST",
126126 signal: AbortSignal.timeout(10_000),
127127 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
128 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: cursor === undefined ? {} : { cursor } }),
128 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: cursor === undefined ? { all: true } : { all: true, cursor } }),
129129 });
130130 const text = await toolList.text();
131131 toolsText += text;
test/explore-mcp-q.test.ts +1 −1
@@ -68,7 +68,7 @@ test("explore with q filters; query is an alias; q wins", async () => {
6868 });
6969
7070 test("tools/list shows q first", async () => {
71 const r = await rpc("tools/list", {});
71 const r = await rpc("tools/list", { all: true });
7272 const t = r.tools.find((x: any) => x.name === "explore");
7373 expect(Object.keys(t.inputSchema.properties)[0]).toBe("q");
7474 });
test/mcp-backups-test.test.ts +4 −4
@@ -119,21 +119,21 @@ describe("MCP backups_test", () => {
119119 test("a non-admin is refused and does not see the tool", async () => {
120120 const r = await test_(plain, fields("http://127.0.0.1:1"));
121121 expect(r.error).toBe(true);
122 const listed = await rpc(plain, "tools/list");
122 const listed = await rpc(plain, "tools/list", { all: true });
123123 expect(listed.result.tools.map((t: any) => t.name)).not.toContain("backups_test");
124124 });
125125
126126 test("an admin's rw token neither sees nor can call it; an admin-scope token can", async () => {
127 const listed = await rpc(rw, "tools/list");
127 const listed = await rpc(rw, "tools/list", { all: true });
128128 expect(listed.result.tools.map((t: any) => t.name)).not.toContain("backups_test");
129129 const r = await test_(rw, fields("ftp://x"));
130130 expect(r.error).toBe(true);
131 const seen = await rpc(admin, "tools/list");
131 const seen = await rpc(admin, "tools/list", { all: true });
132132 expect(seen.result.tools.map((t: any) => t.name)).toContain("backups_test");
133133 });
134134
135135 test("listed to an admin, not as a read", async () => {
136 const listed = await rpc(admin, "tools/list");
136 const listed = await rpc(admin, "tools/list", { all: true });
137137 const tool = listed.result.tools.find((t: any) => t.name === "backups_test");
138138 expect(tool.annotations?.readOnlyHint).not.toBe(true);
139139 });
test/mcp-backups.test.ts +1 −1
@@ -127,7 +127,7 @@ describe("MCP backups", () => {
127127 const res = await fetch(`${servers.url}/mcp`, {
128128 method: "POST",
129129 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
130 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
130 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: { all: true } }),
131131 });
132132 return ((await res.json()) as any).result.tools.map((t: { name: string }) => t.name) as string[];
133133 };
test/mcp-doors.test.ts +1 −1
@@ -58,7 +58,7 @@ describe("the doors onto the MCP tools", () => {
5858 const res = await fetch(`${servers.url}/mcp`, {
5959 method: "POST",
6060 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
61 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: cursor ? { cursor } : {} }),
61 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: cursor ? { all: true, cursor } : { all: true } }),
6262 });
6363 const r = ((await res.json()) as any).result;
6464 names.push(...r.tools.map((t: any) => t.name));
test/mcp-forge-view.test.ts +1 −1
@@ -80,7 +80,7 @@ test("a ro token lists the tool and is answered; polling builds once", async ()
8080 const res = await fetch(`${servers.url}/mcp`, {
8181 method: "POST",
8282 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${adaRo}` },
83 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: cursor === undefined ? {} : { cursor } }),
83 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: cursor === undefined ? { all: true } : { all: true, cursor } }),
8484 });
8585 const page = ((await res.json()) as any).result;
8686 listed = page.tools.find((t: any) => t.name === "forge_view");
test/mcp-list.ts +3 −3
@@ -2,16 +2,16 @@
22
33 /** Every page of `send({ cursor })`, merged into one answer shaped like a page. */
44 export async function listVia(
5 send: (params: { cursor?: string }) => Promise<any>,
5 send: (params: { cursor?: string; all?: true }) => Promise<any>,
66 ): Promise<{ result: { tools: any[] } }> {
77 const tools: any[] = [];
8 let params: { cursor?: string } = {};
8 let params: { cursor?: string; all?: true } = { all: true };
99 for (;;) {
1010 const page = await send(params);
1111 if (page.error) throw new Error(`tools/list: ${page.error.message}`);
1212 tools.push(...page.result.tools);
1313 if (typeof page.result.nextCursor !== "string") return { result: { tools } };
14 params = { cursor: page.result.nextCursor };
14 params = { all: true, cursor: page.result.nextCursor };
1515 }
1616 }
1717
test/mcp-pulls.test.ts +2 −2
@@ -144,14 +144,14 @@ test("a private repository the caller cannot read is not found, as pull says", a
144144
145145 test("tools/list lists pulls and every page stays under 40 KB", async () => {
146146 const names: string[] = [];
147 let params: { cursor?: string } = {};
147 let params: { all: true; cursor?: string } = { all: true };
148148 for (;;) {
149149 const { text } = await rpc(tok["ada"]!, "tools/list", params);
150150 expect(Buffer.byteLength(text)).toBeLessThanOrEqual(38_500); // headroom: a new tool must not tip the lane red
151151 const { result } = JSON.parse(text);
152152 names.push(...result.tools.map((t: any) => t.name));
153153 if (typeof result.nextCursor !== "string") break;
154 params = { cursor: result.nextCursor };
154 params = { all: true, cursor: result.nextCursor };
155155 }
156156 expect(names).toContain("pulls");
157157 });
test/mcp-size.test.ts +114 −2
@@ -16,6 +16,7 @@ import { join } from "node:path";
1616 import { createApp, type App } from "../src/app.ts";
1717 import { createToken } from "../src/core/tokens.ts";
1818 import { createUser } from "../src/core/users.ts";
19 import { FIND_MAX, matching, WORDS_MAX } from "../src/http/mcp/catalog.ts";
1920 import { costliestTools } from "./mcp-size-rank.ts";
2021 import { serve, type Servers } from "../src/http/server.ts";
2122
@@ -45,6 +46,7 @@ let dataDir: string;
4546 let app: App;
4647 let servers: Servers;
4748 let token: string;
49 let aliceId: string;
4850
4951 beforeAll(async () => {
5052 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-size-"));
@@ -60,6 +62,7 @@ beforeAll(async () => {
6062 adminIfFirst: true,
6163 });
6264 if (!made.ok) throw new Error(made.error);
65 aliceId = made.value.id;
6366 token = createToken(app.db, made.value.id, "rw", "rw").token;
6467 }, 30_000);
6568
@@ -69,7 +72,7 @@ afterAll(async () => {
6972 await rm(dataDir, { recursive: true, force: true });
7073 });
7174
72 async function page(cursor?: unknown): Promise<{ text: string; json: any }> {
75 async function page(cursor?: unknown, all = true): Promise<{ text: string; json: any }> {
7376 const res = await fetch(`${servers.url}/mcp`, {
7477 method: "POST",
7578 headers: {
@@ -81,7 +84,7 @@ async function page(cursor?: unknown): Promise<{ text: string; json: any }> {
8184 jsonrpc: "2.0",
8285 id: 1,
8386 method: "tools/list",
84 ...(cursor === undefined ? {} : { params: { cursor } }),
87 params: { ...(all ? { all: true } : {}), ...(cursor === undefined ? {} : { cursor }) },
8588 }),
8689 });
8790 const text = await res.text();
@@ -188,3 +191,112 @@ test("a cursor that names no tool is an invalid-params error", async () => {
188191 expect(json.result).toBeUndefined();
189192 }
190193 });
194
195 const DEFAULT_CEILING = 6_000;
196
197 test(`a fresh connection's tools/list is under ${DEFAULT_CEILING} bytes and names find_tool`, async () => {
198 const { text, json } = await page(undefined, false);
199 const names = json.result.tools.map((t: any) => t.name);
200 expect(names).toContain("find_tool");
201 expect(names).toContain("tell");
202 // Repo work is git's: a session that manages a forge never loads a file tool.
203 expect(names).not.toContain("read_file");
204 expect(names).not.toContain("write_file");
205 expect(names).toContain("report");
206 expect(json.result.nextCursor).toBeUndefined();
207 expect(names.length).toBeLessThanOrEqual(14);
208 expect(Buffer.byteLength(text), `the five costliest tools: ${costliestTools(json.result.tools)}`).toBeLessThanOrEqual(
209 DEFAULT_CEILING,
210 );
211 });
212
213 test("find_tool finds an unlisted tool, whose schema comes back and which is callable", async () => {
214 const call = async (name: string, args: unknown) =>
215 (await (
216 await fetch(`${servers.url}/mcp`, {
217 method: "POST",
218 headers: {
219 "content-type": "application/json",
220 accept: "application/json, text/event-stream",
221 authorization: `Bearer ${token}`,
222 },
223 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
224 })
225 ).json()) as any;
226 const listed = (await page(undefined, false)).json.result.tools.map((t: any) => t.name);
227 const found = JSON.parse((await call("find_tool", { query: "backups" })).result.content[0].text);
228 expect(found.tools.length).toBeGreaterThan(0);
229 const files = JSON.parse((await call("find_tool", { query: "file" })).result.content[0].text);
230 expect(files.tools.map((t: any) => t.name)).toContain("read_file");
231 const hit = found.tools.find((t: any) => !listed.includes(t.name));
232 expect(hit).toBeDefined();
233 expect(hit.inputSchema.type).toBe("object");
outdated

Add a test that a non-admin's find_tool never returns an admin-only tool, and unit tests for matching() ranking, AND semantics and the FIND_MAX/more cut.

234 expect(typeof hit.description).toBe("string");
235 // Not listed, yet called by name at once.
236 expect((await call(hit.name, {})).error?.message ?? "").not.toContain("no tool named");
237 expect((await call("find_tool", { query: " " })).result.isError).toBe(true);
238 });
239
240 const entry = (name: string, description: string) => ({ name, description, inputSchema: {} });
241
242 test("matching: every word must hit, a name outranks a description, FIND_MAX cuts", () => {
243 const es = [
244 entry("alpha_tool", "does beta things"),
245 entry("beta_tool", "does alpha things"),
246 entry("gamma", "alpha and beta both"),
247 ];
248 expect(matching(es, "alpha").map((e) => e.name)).toEqual(["alpha_tool", "beta_tool", "gamma"].sort((a, b) =>
249 a === "alpha_tool" ? -1 : b === "alpha_tool" ? 1 : a.localeCompare(b)));
250 expect(matching(es, "alpha beta").map((e) => e.name)[2]).toBe("gamma");
251 expect(matching(es, "alpha zzz")).toEqual([]);
252 expect(matching(es, " ")).toEqual([]);
253 const many = Array.from({ length: FIND_MAX + 3 }, (_, i) => entry(`t${i}`, "same"));
254 expect(matching(many, "same").length).toBe(FIND_MAX + 3);
255 });
256
257 test("matching bounds the query: past 100 characters or 8 words is ignored", () => {
258 const es = [entry("tool", "needle")];
259 const words = Array.from({ length: WORDS_MAX }, () => "needle").join(" ");
260 expect(matching(es, `${words} absent`).length).toBe(1);
261 expect(matching(es, `${"x".repeat(100)} needle`)).toEqual([]);
262 expect(matching(es, `needle${" ".repeat(200)}x`).length).toBe(1);
263 });
264
265 test("find_tool never returns a tool its caller cannot see; find_tool's `more` counts the cut", async () => {
266 const made = await createUser(app.db, {
267 origin: "seed",
268 trusted: true,
269 username: "bob",
270 email: "[email protected]",
271 password: "a-good-passphrase",
272 });
273 if (!made.ok) throw new Error(made.error);
274 const bob = createToken(app.db, made.value.id, "rw", "rw").token;
275 const call = async (tok: string, query: string) =>
276 JSON.parse(
277 (
278 (await (
279 await fetch(`${servers.url}/mcp`, {
280 method: "POST",
281 headers: {
282 "content-type": "application/json",
283 accept: "application/json, text/event-stream",
284 authorization: `Bearer ${tok}`,
285 },
286 body: JSON.stringify({
287 jsonrpc: "2.0",
288 id: 1,
289 method: "tools/call",
290 params: { name: "find_tool", arguments: { query } },
291 }),
292 })
293 ).json()) as any
294 ).result.content[0].text,
295 );
296 const admin = createToken(app.db, aliceId, "admin", "admin").token;
297 expect((await call(admin, "backups_test")).tools.map((t: any) => t.name)).toContain("backups_test");
298 expect((await call(bob, "backups_test")).tools).toEqual([]);
299 const broad = await call(token, "a");
300 expect(broad.tools.length).toBe(FIND_MAX);
301 expect(broad.more).toBeGreaterThan(0);
302 });
test/mcp-table.test.ts +1 −1
@@ -71,7 +71,7 @@ test("the bytes are those of an admin's real tools/list response", async () => {
7171 const res = await fetch(`${servers.url}/mcp`, {
7272 method: "POST",
7373 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
74 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: cursor === undefined ? {} : { cursor } }),
74 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: cursor === undefined ? { all: true } : { all: true, cursor } }),
7575 });
7676 const body = (await res.json()) as any;
7777 served.push(...body.result.tools);
test/mcp.test.ts +1
@@ -329,6 +329,7 @@ describe("the forge over MCP", () => {
329329 "feed_chips",
330330 "feed_dismiss",
331331 "feed_like",
332 "find_tool",
332333 "maintenance",
333334 "make_it_yours",
334335 "origin_apps",
test/route-marks.test.ts +1 −1
@@ -206,7 +206,7 @@ describe("every admin-only action refuses the everyday account", () => {
206206 const names = new Set<string>();
207207 let cursor: string | undefined;
208208 do {
209 const page = (await mcp(u, "tools/list", cursor === undefined ? {} : { cursor })) as {
209 const page = (await mcp(u, "tools/list", cursor === undefined ? { all: true } : { all: true, cursor })) as {
210210 result?: { tools?: { name: string }[]; nextCursor?: string };
211211 };
212212 for (const t of page.result?.tools ?? []) names.add(t.name);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs