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

MCP tools/list pages with nextCursor, so adding a tool never busts one response's budget #6879

Merged opened this

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

Merged

scout/forge:scout/6355-mcp-tools-list-pages-with-nextcursor-so → main 1 commit fork 3 threads · 3 unresolved

  • Merged. scout/6355-mcp-tools-list-pages-with-nextcursor-so was merged into main as 2d0ec20.

Review

Sign in to review this change.
25 files changed +216 −135
docs/MCP.md +8 −3
@@ -179,9 +179,14 @@ tool's own behaviour is tested in `test/mcp-<name>.test.ts`.
179179
180180 ### Room for a new tool
181181
182 Every session pays for `tools/list` first, so `test/mcp-size.test.ts` holds it
183 to a 40,000-byte ceiling and a 38,500-byte headroom (the answer an admin gets,
184 the largest). Measure before you push with `bun test test/mcp-size.test.ts`;
182 Every session pays for `tools/list` first. It is paged, as the MCP spec
183 allows: a page holds about 20,000 bytes of tools (`TOOLS_PAGE_BYTES` in
184 `src/http/mcp.ts`) and carries `nextCursor` while more remain; send it back as
185 `params.cursor` for the next page. A cursor that names no tool is a `-32602`
186 error. A client must follow `nextCursor`; the Claude Code and OpenCode
187 plugins only register `/mcp`, so paging is their MCP client's job. Adding a
188 tool no longer busts one response, but `test/mcp-size.test.ts` still holds
189 each page to a 40,000-byte ceiling and a 38,500-byte headroom. Measure before you push with `bun test test/mcp-size.test.ts`;
185190 a failure names the bytes over and the longest descriptions.
186191
187192 - A new door is an `action` or `kind` on the tool that already owns the
src/http/mcp.ts +43 −19
@@ -36,6 +36,10 @@ import { TOOLS } from "./mcp/tools.ts";
3636
3737 const PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"] as const;
3838 /** Who answers: the forge, at the version its build stamped. */
39 // One page of tools/list stays within this many bytes of tools; `nextCursor`

This JSDoc used to document server below. With the constant and its // comment inserted after it, it now attaches to TOOLS_PAGE_BYTES. Put the new block above /** Who answers… */.

40 // names the rest, so adding a tool never busts one response.
41 export const TOOLS_PAGE_BYTES = 20_000;
42
3943 const server = () => ({ name: "forge", version: running().version });
4044
4145
@@ -102,27 +106,47 @@ function rpcOne(
102106 }
103107 case "ping":
104108 return reply({});
105 case "tools/list":
109 case "tools/list": {
110 const all = [
111 ...TOOLS.filter((t) => visible(t, ctx, user)).map((t) => ({
112 name: t.name,
113 description: t.description,
114 inputSchema: lean(t.inputSchema),
115 // readOnlyHint defaults to false (MCP tool annotations), so only a
116 // read says so: 57 copies of the default were 2 KB of the 40 KB.
117 ...(t.writes ? {} : { annotations: { readOnlyHint: true } }),
118 ...(CARD_OF_TOOL[t.name] === undefined
119 ? {}
120 : {
121 _meta: {
122 ui: { resourceUri: CARD_URI(CARD_OF_TOOL[t.name]!) },
123 },
124 }),
125 })),
126 REPORT_TOOL,
127 ];
128 // The cursor is the name of the last tool of the page before, so a tool
129 // added between two requests moves nothing.
130 const cursor = params["cursor"];
131 let from = 0;
132 if (cursor !== undefined) {
133 const at =
134 typeof cursor === "string" ? all.findIndex((t) => t.name === cursor) : -1;
135 if (at < 0) return refuse(-32602, "unknown cursor");
136 from = at + 1;
137 }
138 let end = from;
139 let bytes = 0;
140 while (end < all.length) {
141 bytes += Buffer.byteLength(JSON.stringify(all[end]));
142 if (end > from && bytes > TOOLS_PAGE_BYTES) break;
143 end++;
144 }
106145 return reply({
107 tools: [
108 ...TOOLS.filter((t) => visible(t, ctx, user)).map((t) => ({
109 name: t.name,
110 description: t.description,
111 inputSchema: lean(t.inputSchema),
112 // readOnlyHint defaults to false (MCP tool annotations), so only a
113 // read says so: 57 copies of the default were 2 KB of the 40 KB.
114 ...(t.writes ? {} : { annotations: { readOnlyHint: true } }),
115 ...(CARD_OF_TOOL[t.name] === undefined
116 ? {}
117 : {
118 _meta: {
119 ui: { resourceUri: CARD_URI(CARD_OF_TOOL[t.name]!) },
120 },
121 }),
122 })),
123 REPORT_TOOL,
124 ],
146 tools: all.slice(from, end),
147 ...(end < all.length ? { nextCursor: all[end - 1]!.name } : {}),
125148 });
149 }
126150 case "resources/list":
127151 return reply({
128152 resources: CARD_KINDS.map((k) => ({
src/release/outsider.ts +19 −7
@@ -118,13 +118,25 @@ export async function outsider(base: string, options: OutsiderOptions = {}): Pro
118118 return { error: false, text };
119119 }
120120 };
121 const toolList = await doFetch(new URL("/mcp", root).href, {
122 method: "POST",
123 signal: AbortSignal.timeout(10_000),
124 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
125 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
126 });
127 const toolsText = await toolList.text();
121 // tools/list is paged: follow nextCursor, so a tool on a later page still counts.
122 let toolsText = "";
123 for (let cursor: string | undefined, pages = 0; pages < 10; pages++) {

Non-blocking: if there are ever more than 10 pages, this stops without saying so, and the check below then blames a missing tool with "the suite's doors may have moved". That's fine at today's two pages. If it is ever reached, a line in bad saying the page cap was hit would be the honest message.

124 const toolList = await doFetch(new URL("/mcp", root).href, {
125 method: "POST",
126 signal: AbortSignal.timeout(10_000),
127 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 } }),
129 });
130 const text = await toolList.text();
131 toolsText += text;
132 try {
133 const next = (JSON.parse(text) as { result?: { nextCursor?: unknown } }).result?.nextCursor;
134 cursor = typeof next === "string" ? next : undefined;
135 } catch {
136 cursor = undefined;
137 }
138 if (cursor === undefined) break;
139 }
128140 for (const tool of ["tell", "make_it_yours", "repo", "usage", "apps", "signup"])
129141 if (!toolsText.includes(`"${tool}"`)) bad.push({ check: "MCP tools", why: `tools/list does not name ${tool}; the suite's doors may have moved` });
130142
test/asks-mute.test.ts +2 −6
@@ -26,6 +26,7 @@ import { serve, type Servers } from "../src/http/server.ts";
2626 import { mcpTool } from "./helpers/mcp.ts";
2727 import { askCountFor, askLinesFor, forgeNeedsFor, repoAsks } from "../src/web/studio/asks.ts";
2828 import { quietLogs } from "./helpers/globals.ts";
29 import { listAt } from "./mcp-list.ts";
2930
3031 quietLogs();
3132 // Every test here is HTTP round trips against a server on a shared machine: the 5s default measures its load, not the code.
@@ -254,12 +255,7 @@ describe("muting a repository's asks", () => {
254255 expect(((await tool(token, "asks", { hide_people: ["nobody"] })) as any).error).toContain("hide_people");
255256 expect(((await tool(token, "asks", { hide_kinds: "reviews" })) as any).error).toContain("lists");
256257 // The manifest says so.
257 const res = await fetch(`${servers.url}/mcp`, {
258 method: "POST",
259 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
260 body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
261 });
262 const tools = ((await res.json()) as any).result.tools as any[];
258 const tools = (await listAt(servers.url, token)).result.tools as any[];
263259 for (const name of ["asks", "feed"]) {
264260 const t = tools.find((x) => x.name === name);
265261 expect(t.description).toContain("saved filter");
test/crew-scorecard-api.test.ts +2 −1
@@ -16,6 +16,7 @@ import { createUser, type User } from "../src/core/users.ts";
1616 import { serve, type Servers } from "../src/http/server.ts";
1717 import { seedAdmin } from "./helpers/admin.ts";
1818 import { quietLogs } from "./helpers/globals.ts";
19 import { listVia } from "./mcp-list.ts";
1920
2021 quietLogs();
2122
@@ -132,7 +133,7 @@ describe("the crew scorecard over the API and MCP", () => {
132133 const called = await rpc(admin, "tools/call", { name: "scorecard", arguments: {} });
133134 expect(JSON.parse(called.result.content[0].text)).toEqual(api);
134135 const names = (token: string) =>
135 rpc(token, "tools/list").then((r) => r.result.tools.map((t: { name: string }) => t.name));
136 listVia((p) => rpc(token, "tools/list", p)).then((r) => r.result.tools.map((t: { name: string }) => t.name));
136137 expect(await names(admin)).toContain("scorecard");
137138 expect(await names(member)).not.toContain("scorecard");
138139 const refused = await rpc(member, "tools/call", { name: "scorecard", arguments: {} });
test/decisions-by-doors.test.ts +2 −10
@@ -26,6 +26,7 @@ import { createForgeApp } from "../src/tui/forge.ts";
2626 import { headlessTerminal } from "../src/tui/term/terminal.ts";
2727 import { quietLogs } from "./helpers/globals.ts";
2828 import { settleApp } from "./helpers/tui.ts";
29 import { listAt } from "./mcp-list.ts";
2930
3031 quietLogs();
3132
@@ -193,16 +194,7 @@ describe("the standing rule over MCP", () => {
193194 });
194195
195196 test("asks_decide no longer takes a `by`", async () => {
196 const tools = await fetch(`${servers.url}/mcp`, {
197 method: "POST",
198 headers: {
199 "content-type": "application/json",
200 accept: "application/json, text/event-stream",
201 authorization: `Bearer ${admin["founder"]}`,
202 },
203 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
204 });
205 const list = ((await tools.json()) as any).result.tools as any[];
197 const list = (await listAt(servers.url, admin["founder"]!)).result.tools as any[];
206198 expect(list.find((t) => t.name === "asks_decide").inputSchema.properties.by).toBeUndefined();
207199 });
208200 });
test/mcp-accounts.test.ts +3 −2
@@ -7,6 +7,7 @@ import { createApp, type App } from "../src/app.ts";
77 import { createToken } from "../src/core/tokens.ts";
88 import { createUser } from "../src/core/users.ts";
99 import { serve, type Servers } from "../src/http/server.ts";
10 import { listVia } from "./mcp-list.ts";
1011
1112 describe("MCP accounts", () => {
1213 let dataDir: string;
@@ -85,9 +86,9 @@ describe("MCP accounts", () => {
8586 });
8687
8788 test("a non-admin is not listed the tool and is refused it", async () => {
88 const list = await rpc(bobToken, "tools/list", {});
89 const list = await listVia((p) => rpc(bobToken, "tools/list", p));
8990 expect(list.result.tools.map((t: any) => t.name)).not.toContain("accounts");
90 const admin = await rpc(adminToken, "tools/list", {});
91 const admin = await listVia((p) => rpc(adminToken, "tools/list", p));
9192 expect(admin.result.tools.map((t: any) => t.name)).toContain("accounts");
9293 expect((await accounts(bobToken)).refused).toContain("no tool named accounts");
9394 });
test/mcp-backups-destination.test.ts +2 −10
@@ -14,6 +14,7 @@ import { serve, type Servers } from "../src/http/server.ts";
1414 import { sealedSet } from "../src/snapshot/destination.ts";
1515 import { seedAdmin } from "./helpers/admin.ts";
1616 import { quietLogs } from "./helpers/globals.ts";
17 import { listAt } from "./mcp-list.ts";
1718
1819 quietLogs();
1920
@@ -129,16 +130,7 @@ describe("MCP backups_destination", () => {
129130 });
130131
131132 test("tools/list names the action and the fields an agent sends", async () => {
132 const res = await fetch(`${servers.url}/mcp`, {
133 method: "POST",
134 headers: {
135 "content-type": "application/json",
136 accept: "application/json, text/event-stream",
137 authorization: `Bearer ${admin}`,
138 },
139 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/list" }),
140 });
141 const tools = ((await res.json()) as any).result.tools as { name: string; inputSchema: any }[];
133 const tools = (await listAt(servers.url, admin)).result.tools as { name: string; inputSchema: any }[];
142134 const schema = tools.find((t) => t.name === "backups_destination")!.inputSchema;
143135 expect(schema.required).toEqual(["action"]);
144136 expect(schema.properties.action.enum).toEqual(["set", "forget"]);
test/mcp-cards-coverage.test.ts +2 −1
@@ -13,6 +13,7 @@ import { createUser } from "../src/core/users.ts";
1313 import { serve, type Servers } from "../src/http/server.ts";
1414 import { CARD_KINDS, CARD_OF_TOOL } from "../src/web/studio/cards.ts";
1515 import { quietLogs } from "./helpers/globals.ts";
16 import { listVia } from "./mcp-list.ts";
1617
1718 quietLogs();
1819
@@ -53,7 +54,7 @@ describe("MCP cards coverage", () => {
5354 });
5455
5556 test("every tool a card names is in tools/list", async () => {
56 const names = new Set<string>((await rpc("tools/list")).result.tools.map((t: any) => t.name));
57 const names = new Set<string>((await listVia((p) => rpc("tools/list", p))).result.tools.map((t: any) => t.name));
5758 const missing = Object.keys(CARD_OF_TOOL).filter((t) => !names.has(t));
5859 expect(missing).toEqual([]);
5960 });
test/mcp-confirm-wait.test.ts +2 −1
@@ -18,6 +18,7 @@ import { createToken } from "../src/core/tokens.ts";
1818 import { createUser, type User } from "../src/core/users.ts";
1919 import { promote } from "./helpers/members.ts";
2020 import { serve, type Servers } from "../src/http/server.ts";
21 import { listVia } from "./mcp-list.ts";
2122
2223 describe("confirm_wait over MCP", () => {
2324 let dataDir: string;
@@ -123,7 +124,7 @@ describe("confirm_wait over MCP", () => {
123124 });
124125
125126 test("is listed with a schema", async () => {
126 const listed = await post(rw, "tools/list", {});
127 const listed = await listVia((p) => post(rw, "tools/list", p));
127128 const t = listed.result.tools.find((x: any) => x.name === "confirm_wait");
128129 expect(t.inputSchema.required).toEqual(["app", "issue", "blocker"]);
129130 });
test/mcp-docs.test.ts +2 −10
@@ -8,6 +8,7 @@ import { createToken } from "../src/core/tokens.ts";
88 import { createUser } from "../src/core/users.ts";
99 import { serve, type Servers } from "../src/http/server.ts";
1010 import { quietLogs } from "./helpers/globals.ts";
11 import { listAt } from "./mcp-list.ts";
1112
1213 quietLogs();
1314
@@ -44,16 +45,7 @@ beforeAll(async () => {
4445 });
4546 if (!made.ok) throw new Error(JSON.stringify(made.error));
4647 const token = createToken(app.db, made.value.id, "admin", "admin").token;
47 const res = await fetch(`${servers.url}/mcp`, {
48 method: "POST",
49 headers: {
50 "content-type": "application/json",
51 accept: "application/json, text/event-stream",
52 authorization: `Bearer ${token}`,
53 },
54 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
55 });
56 served = JSON.parse(await res.text()).result.tools.map((t: { name: string }) => t.name);
48 served = (await listAt(servers.url, token)).result.tools.map((t: { name: string }) => t.name);
5749 });
5850
5951 afterAll(async () => {
test/mcp-hooks.test.ts +2 −1
@@ -9,6 +9,7 @@ import { createToken } from "../src/core/tokens.ts";
99 import { createUser } from "../src/core/users.ts";
1010 import { serve, type Servers } from "../src/http/server.ts";
1111 import { promote } from "./helpers/members.ts";
12 import { listVia } from "./mcp-list.ts";
1213
1314 describe("MCP hooks", () => {
1415 let dataDir: string;
@@ -101,7 +102,7 @@ describe("MCP hooks", () => {
101102 });
102103
103104 test("it is listed, and callable, only with an admin-scope token", async () => {
104 const names = async (t: string) => (await rpc(t, "tools/list", {})).result.tools.map((x: any) => x.name);
105 const names = async (t: string) => (await listVia((p) => rpc(t, "tools/list", p))).result.tools.map((x: any) => x.name);
105106 expect(await names(aliceAdmin)).toContain("hooks");
106107 expect(await names(aliceRw)).not.toContain("hooks");
107108 const called = await rpc(aliceRw, "tools/call", { name: "hooks", arguments: { repo: "alice/game", action: "list" } });
test/mcp-list.ts added +33
@@ -0,0 +1,33 @@
1 /** `tools/list` is paged: these walk `nextCursor` so a test sees every tool. */

Shared test helpers live in test/helpers/ (helpers/mcp.ts, helpers/admin.ts). This one belongs there too, e.g. as test/helpers/mcp-list.ts.

2
3 /** Every page of `send({ cursor })`, merged into one answer shaped like a page. */
4 export async function listVia(
5 send: (params: { cursor?: string }) => Promise<any>,
6 ): Promise<{ result: { tools: any[] } }> {
7 const tools: any[] = [];
8 let params: { cursor?: string } = {};
9 for (;;) {
10 const page = await send(params);
11 if (page.error) throw new Error(`tools/list: ${page.error.message}`);
12 tools.push(...page.result.tools);
13 if (typeof page.result.nextCursor !== "string") return { result: { tools } };
14 params = { cursor: page.result.nextCursor };
15 }
16 }
17
18 /** Every tool the bearer `token` is listed at `url` (the forge's origin). */
19 export function listAt(url: string, token: string): Promise<{ result: { tools: any[] } }> {
20 return listVia(async (params) =>
21 (
22 await fetch(`${url}/mcp`, {
23 method: "POST",
24 headers: {
25 "content-type": "application/json",
26 accept: "application/json, text/event-stream",
27 authorization: `Bearer ${token}`,
28 },
29 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params }),
30 })
31 ).json(),
32 );
33 }
test/mcp-preview.test.ts +2 −6
@@ -19,6 +19,7 @@ import { serve, type Servers } from "../src/http/server.ts";
1919 import { seedAdmin } from "./helpers/admin.ts";
2020 import { promote } from "./helpers/members.ts";
2121 import { tokenFor } from "./helpers/tokens.ts";
22 import { listAt } from "./mcp-list.ts";
2223
2324 describe("MCP preview", () => {
2425 let dataDir: string;
@@ -163,12 +164,7 @@ describe("MCP preview", () => {
163164 });
164165
165166 test("it is listed in tools/list", async () => {
166 const res = await fetch(`${servers.url}/mcp`, {
167 method: "POST",
168 headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${bobRw}` },
169 body: JSON.stringify({ jsonrpc: "2.0", id: 999, method: "tools/list" }),
170 });
171 const body: any = await res.json();
167 const body = await listAt(servers.url, bobRw);
172168 expect(body.result.tools.some((t: any) => t.name === "preview")).toBe(true);
173169 });
174170 });
test/mcp-private-fix.test.ts +2 −1
@@ -12,6 +12,7 @@ import { createIssue } from "../src/core/issues.ts";
1212 import { createToken } from "../src/core/tokens.ts";
1313 import { createUser } from "../src/core/users.ts";
1414 import { serve, type Servers } from "../src/http/server.ts";
15 import { listVia } from "./mcp-list.ts";
1516
1617 describe("MCP seal action private", () => {
1718 let dataDir: string;
@@ -80,7 +81,7 @@ describe("MCP seal action private", () => {
8081 });
8182
8283 test("seal takes the private action in its schema", async () => {
83 const listed = await rpc(alice, "tools/list");
84 const listed = await listVia((p) => rpc(alice, "tools/list", p));
8485 const t = listed.result.tools.find((x: any) => x.name === "seal");
8586 expect(t.inputSchema.properties.action.enum).toEqual(["private"]);
8687 expect(listed.result.tools.some((x: any) => x.name === "private_fix")).toBe(false);
test/mcp-prompts.test.ts +2 −1
@@ -18,6 +18,7 @@ import { startPromptRoutine, type PromptRoutineHandle } from "../src/crew/prompt
1818 import { serve, type Servers } from "../src/http/server.ts";
1919 import { promote } from "./helpers/members.ts";
2020 import { quietLogs } from "./helpers/globals.ts";
21 import { listVia } from "./mcp-list.ts";
2122
2223 quietLogs();
2324
@@ -151,7 +152,7 @@ describe("MCP prompts", () => {
151152 });
152153
153154 test("only an admin-scope token sees the tool or may call it", async () => {
154 const names = async (t: string) => (await rpc(t, "tools/list", {})).result.tools.map((x: any) => x.name);
155 const names = async (t: string) => (await listVia((p) => rpc(t, "tools/list", p))).result.tools.map((x: any) => x.name);
155156 expect(await names(admin)).toContain("prompts");
156157 expect(await names(rw)).not.toContain("prompts");
157158 const direct = await rpc(rw, "tools/call", { name: "prompts", arguments: { repo: "alice/game", action: "list" } });
test/mcp-pulls.test.ts +12 −4
@@ -142,10 +142,18 @@ test("a private repository the caller cannot read is not found, as pull says", a
142142 expect((await pulls(tok["ada"]!, { repo: "ada/vault" })).value.pulls).toHaveLength(2);
143143 });
144144
145 test("tools/list lists pulls and stays under 40 KB", async () => {
146 const { text } = await rpc(tok["ada"]!, "tools/list");
147 expect(Buffer.byteLength(text)).toBeLessThanOrEqual(38_500); // headroom: a new tool must not tip the lane red
148 expect(JSON.parse(text).result.tools.map((t: any) => t.name)).toContain("pulls");
145 test("tools/list lists pulls and every page stays under 40 KB", async () => {
146 const names: string[] = [];
147 let params: { cursor?: string } = {};
148 for (;;) {
149 const { text } = await rpc(tok["ada"]!, "tools/list", params);
150 expect(Buffer.byteLength(text)).toBeLessThanOrEqual(38_500); // headroom: a new tool must not tip the lane red
151 const { result } = JSON.parse(text);
152 names.push(...result.tools.map((t: any) => t.name));
153 if (typeof result.nextCursor !== "string") break;
154 params = { cursor: result.nextCursor };
155 }
156 expect(names).toContain("pulls");
149157 });
150158
151159 const labPulls = async (who: string, args: object = {}): Promise<any[]> =>
test/mcp-repo-labels.test.ts +2 −10
@@ -8,6 +8,7 @@ import { createRepo } from "../src/actions/repos.ts";
88 import { createToken } from "../src/core/tokens.ts";
99 import { createUser } from "../src/core/users.ts";
1010 import { serve, type Servers } from "../src/http/server.ts";
11 import { listAt } from "./mcp-list.ts";
1112
1213 describe("MCP repo_labels", () => {
1314 let dataDir: string;
@@ -69,16 +70,7 @@ describe("MCP repo_labels", () => {
6970 });
7071
7172 test("it is listed with a schema", async () => {
72 const res = await fetch(`${servers.url}/mcp`, {
73 method: "POST",
74 headers: {
75 "content-type": "application/json",
76 accept: "application/json, text/event-stream",
77 authorization: `Bearer ${admin}`,
78 },
79 body: JSON.stringify({ jsonrpc: "2.0", id: 999, method: "tools/list" }),
80 });
81 const body: any = await res.json();
73 const body = await listAt(servers.url, admin);
8274 const t = body.result.tools.find((x: any) => x.name === "repo_labels");
8375 expect(t.inputSchema.required).toEqual(["repo"]);
8476 });
test/mcp-secrets.test.ts +3 −2
@@ -13,6 +13,7 @@ import { createToken } from "../src/core/tokens.ts";
1313 import { createUser } from "../src/core/users.ts";
1414 import { serve, type Servers } from "../src/http/server.ts";
1515 import { promote } from "./helpers/members.ts";
16 import { listVia } from "./mcp-list.ts";
1617
1718 describe("MCP secrets", () => {
1819 let dataDir: string;
@@ -125,9 +126,9 @@ describe("MCP secrets", () => {
125126 });
126127
127128 test("a non-admin is refused and not listed the tool", async () => {
128 const listed = await rpc(other, "tools/list");
129 const listed = await listVia((p) => rpc(other, "tools/list", p));
129130 expect(listed.result.tools.map((t: any) => t.name)).not.toContain("secrets");
130 const seen = await rpc(admin, "tools/list");
131 const seen = await listVia((p) => rpc(admin, "tools/list", p));
131132 expect(seen.result.tools.map((t: any) => t.name)).toContain("secrets");
132133 const call = await rpc(other, "tools/call", {
133134 name: "secrets",
test/mcp-size.test.ts +52 −16
@@ -69,7 +69,7 @@ afterAll(async () => {
6969 await rm(dataDir, { recursive: true, force: true });
7070 });
7171
72 test(`tools/list stays under 40 KB, with 1.5 KB of headroom (${HEADROOM_CEILING.toLocaleString("en-US")} bytes)`, async () => {
72 async function page(cursor?: unknown): Promise<{ text: string; json: any }> {
7373 const res = await fetch(`${servers.url}/mcp`, {
7474 method: "POST",
7575 headers: {
@@ -77,28 +77,47 @@ test(`tools/list stays under 40 KB, with 1.5 KB of headroom (${HEADROOM_CEILING.
7777 accept: "application/json, text/event-stream",
7878 authorization: `Bearer ${token}`,
7979 },
80 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
80 body: JSON.stringify({
81 jsonrpc: "2.0",
82 id: 1,
83 method: "tools/list",
84 ...(cursor === undefined ? {} : { params: { cursor } }),
85 }),
8186 });
8287 const text = await res.text();
83 const bytes = Buffer.byteLength(text);
84 const tools = (JSON.parse(text) as any).result.tools as {
85 name: string;
86 description: string;
87 inputSchema: unknown;
88 annotations?: { readOnlyHint?: boolean };
89 }[];
88 return { text, json: JSON.parse(text) };
89 }
90
91 /** Every page, in order: each page's bytes and all the tools. */
92 async function walk(): Promise<{ sizes: number[]; tools: any[] }> {
93 const sizes: number[] = [];
94 const tools: any[] = [];
95 let cursor: string | undefined;
96 do {
97 const { text, json } = await page(cursor);
98 sizes.push(Buffer.byteLength(text));
99 tools.push(...json.result.tools);
100 cursor = json.result.nextCursor;
101 } while (cursor !== undefined);
102 return { sizes, tools };
103 }
104
105 test(`each page of tools/list stays under 40 KB, with 1.5 KB of headroom (${HEADROOM_CEILING.toLocaleString("en-US")} bytes)`, async () => {
106 const { sizes, tools } = await walk();
107 const bytes = Math.max(...sizes);
108 expect(sizes.length).toBeGreaterThan(1);
90109 // The argument words are short; the detail lives in docs/MCP.md.
91110 const long: string[] = [];
92 const walk = (tool: string, node: any, path: string): void => {
111 const walkProps = (tool: string, node: any, path: string): void => {
93112 if (!node || typeof node !== "object") return;
94113 for (const [k, v] of Object.entries<any>(node.properties ?? {})) {
95114 if (typeof v?.description === "string" && Buffer.byteLength(v.description) > PROPERTY_MAX)
96115 long.push(`${tool}.${path}${k} (${Buffer.byteLength(v.description)} bytes)`);
97 walk(tool, v, `${path}${k}.`);
116 walkProps(tool, v, `${path}${k}.`);
98117 }
99 walk(tool, node.items, path);
118 walkProps(tool, node.items, path);
100119 };
101 for (const t of tools) walk(t.name, t.inputSchema, "");
120 for (const t of tools) walkProps(t.name, t.inputSchema, "");
102121 expect(long, `property descriptions over ${PROPERTY_MAX} bytes`).toEqual([]);
103122 // readOnlyHint defaults to false; spelling the default out on every write
104123 // tool cost 2 KB, so only a read tool carries annotations.
@@ -106,11 +125,11 @@ test(`tools/list stays under 40 KB, with 1.5 KB of headroom (${HEADROOM_CEILING.
106125 const longest = costliestTools(tools);
107126 expect(
108127 bytes,
109 `tools/list is ${bytes} bytes, ${bytes - CEILING} over the ${CEILING} ceiling; the five costliest tools: ${longest}`,
128 `a tools/list page is ${bytes} bytes, ${bytes - CEILING} over the ${CEILING} ceiling; the five costliest tools: ${longest}`,
110129 ).toBeLessThanOrEqual(CEILING);
111130 expect(
112131 bytes,
113 `tools/list is ${bytes} bytes, ${bytes - HEADROOM_CEILING} over the ${HEADROOM_CEILING} headroom (${CEILING - bytes} left under the ceiling); the five costliest tools: ${longest}`,
132 `a tools/list page is ${bytes} bytes, ${bytes - HEADROOM_CEILING} over the ${HEADROOM_CEILING} headroom (${CEILING - bytes} left under the ceiling); the five costliest tools: ${longest}`,
114133 ).toBeLessThanOrEqual(HEADROOM_CEILING);
115134 expect(
116135 tools
@@ -138,7 +157,7 @@ test("`dryRun` is explained once, in the instructions, not on every tool", async
138157 }),
139158 })
140159 ).json()) as any;
141 const tools = (await call("tools/list")).result.tools as {
160 const tools = (await walk()).tools as {
142161 name: string;
143162 inputSchema: { properties?: Record<string, { description?: string }> };
144163 }[];
@@ -152,3 +171,20 @@ test("`dryRun` is explained once, in the instructions, not on every tool", async
152171 "A tool with `dryRun` plans unless it is `false`.",
153172 );
154173 });
174
175 test("walking every page lists every tool exactly once", async () => {
176 const { tools } = await walk();
177 const names = tools.map((t) => t.name);
178 expect(new Set(names).size).toBe(names.length);
179 expect(names).toContain("report");
180 const first = await page();
181 expect(first.json.result.nextCursor).toBe(first.json.result.tools.at(-1).name);
182 });
183
184 test("a cursor that names no tool is an invalid-params error", async () => {
185 for (const bad of ["nope", "", 7]) {
186 const { json } = await page(bad);
187 expect(json.error.code).toBe(-32602);
188 expect(json.result).toBeUndefined();
189 }
190 });
test/mcp-subscription-add.test.ts +2 −1
@@ -13,6 +13,7 @@ import { createSession } from "../src/core/sessions.ts";
1313 import { createToken } from "../src/core/tokens.ts";
1414 import { createUser } from "../src/core/users.ts";
1515 import { serve, type Servers } from "../src/http/server.ts";
16 import { listVia } from "./mcp-list.ts";
1617
1718 describe("MCP subscription_add", () => {
1819 let dataDir: string;
@@ -76,7 +77,7 @@ describe("MCP subscription_add", () => {
7677 }).then((r) => r.text());
7778 expect(page).toContain("Team Max");
7879 expect(page).not.toContain(TOKEN);
79 const tools = (await rpc(alice, "tools/list", {})).result.tools;
80 const tools = (await listVia((p) => rpc(alice, "tools/list", p))).result.tools;
8081 const t = tools.find((x: any) => x.name === "subscription_add");
8182 expect(t.annotations?.readOnlyHint).toBeUndefined();
8283 });
test/mcp-telemetry-grant.test.ts +2 −6
@@ -10,6 +10,7 @@ import { createToken } from "../src/core/tokens.ts";
1010 import { createUser, getUserByName } from "../src/core/users.ts";
1111 import { serve, type Servers } from "../src/http/server.ts";
1212 import { quietLogs } from "./helpers/globals.ts";
13 import { listAt } from "./mcp-list.ts";
1314
1415 quietLogs();
1516
@@ -75,12 +76,7 @@ describe("MCP telemetry_grant", () => {
7576 });
7677
7778 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[];
79 return (await listAt(servers.url, token)).result.tools.map((t: any) => t.name) as string[];
8480 };
8581
8682 test("only an admin-scope token is listed the tool", async () => {
test/mcp-tokens.test.ts +2 −1
@@ -13,6 +13,7 @@ import { seedAdmin } from "./helpers/admin.ts";
1313 import { promote } from "./helpers/members.ts";
1414 import { createUser } from "../src/core/users.ts";
1515 import { quietLogs } from "./helpers/globals.ts";
16 import { listVia } from "./mcp-list.ts";
1617
1718 quietLogs();
1819
@@ -79,7 +80,7 @@ test("another person's tokens are never listed", async () => {
7980 });
8081
8182 test("tools/list marks it read-only", async () => {
82 const body = await rpc(mine[0]!.token, "tools/list");
83 const body = await listVia((p) => rpc(mine[0]!.token, "tools/list", p));
8384 const t = body.result.tools.find((x: { name: string }) => x.name === "tokens");
8485 expect(t.annotations.readOnlyHint).toBe(true);
8586 });
test/mcp-upstream.test.ts +2 −10
@@ -24,6 +24,7 @@ import { serve, type Servers } from "../src/http/server.ts";
2424 import { checkUpstream } from "../src/spawn/upstream.ts";
2525 import { keepParentCredential } from "../src/spawn/parent_credential.ts";
2626 import { quietLogs } from "./helpers/globals.ts";
27 import { listAt } from "./mcp-list.ts";
2728
2829 quietLogs();
2930
@@ -176,16 +177,7 @@ describe("MCP proposes to the parent forge and lists proposals", () => {
176177
177178 test("tools/list names both", async () => {
178179 const token = createToken(child.db, kid.id, "rw", "rw").token;
179 const res = await fetch(`${childServer.url}/mcp`, {
180 method: "POST",
181 headers: {
182 "content-type": "application/json",
183 accept: "application/json, text/event-stream",
184 authorization: `Bearer ${token}`,
185 },
186 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
187 });
188 const tools = ((await res.json()) as any).result.tools as { name: string; annotations: any }[];
180 const tools = (await listAt(childServer.url, token)).result.tools as { name: string; annotations: any }[];
189181 const by = (n: string) => tools.find((t) => t.name === n);
190182 expect(by("propose_to_parent_forge")?.annotations).toBeUndefined();
191183 expect(by("forge_proposals")?.annotations.readOnlyHint).toBe(true);
test/mcp.test.ts +11 −6
@@ -32,6 +32,7 @@ import { limitChanges, limitValue } from "../src/settings/limits.ts";
3232 import { promote } from "./helpers/members.ts";
3333 import { showBoard } from "../src/actions/boards.ts";
3434 import { panelData } from "../src/boards/panel_data.ts";
35 import { listVia } from "./mcp-list.ts";
3536
3637 /** tools/list's order: the tools by name, then `report`. */
3738 const sortedThenReport = (names: string[]): string[] => [
@@ -52,6 +53,10 @@ describe("the forge over MCP", () => {
5253 let game: Repo;
5354 let nextId = 1;
5455
56 const listCall = async (token: string) => ({
57 body: await listVia(async (p) => (await call(token, "tools/list", p)).body),
58 });
59
5560 const call = async (
5661 token: string | null,
5762 method: string,
@@ -173,7 +178,7 @@ describe("the forge over MCP", () => {
173178 expect(note.status).toBe(202);
174179 expect((await call(rw, "ping")).body.result).toEqual({});
175180
176 const listed = await call(rw, "tools/list");
181 const listed = await listCall(rw);
177182 const names = listed.body.result.tools.map((t: { name: string }) => t.name);
178183 // Listed by name, one file each (#5924), and `report` last.
179184 expect(names).toEqual(sortedThenReport([
@@ -713,7 +718,7 @@ describe("the forge over MCP", () => {
713718 });
714719
715720 test("probe only reads, yet needs the access the page needs", async () => {
716 const listed = await call(ro, "tools/list");
721 const listed = await listCall(ro);
717722 const hints = Object.fromEntries(
718723 listed.body.result.tools.map((t: any) => [
719724 t.name,
@@ -860,7 +865,7 @@ describe("the forge over MCP", () => {
860865 .value.issue;
861866 const b = (await tool(rw, "tell", { repo: "alice/game", text: "second" }))
862867 .value.issue;
863 const listed = await call(rw, "tools/list");
868 const listed = await listCall(rw);
864869 const hints = Object.fromEntries(
865870 listed.body.result.tools.map((t: any) => [
866871 t.name,
@@ -1210,7 +1215,7 @@ describe("the forge over MCP", () => {
12101215 expect(c.text).not.toContain("innerHTML");
12111216 const nope = await call(rw, "resources/read", { uri: "ui://forge/nope" });
12121217 expect(nope.body.error.code).toBe(-32002);
1213 const tools = (await call(rw, "tools/list")).body.result.tools;
1218 const tools = (await listCall(rw)).body.result.tools;
12141219 const meta = (n: string) => tools.find((t: any) => t.name === n)._meta?.ui?.resourceUri;
12151220 expect(meta("apps")).toBe("ui://forge/app");
12161221 expect(meta("new_app")).toBe("ui://forge/app");
@@ -1412,7 +1417,7 @@ describe("the forge over MCP", () => {
14121417 expect(card.rows[1]).toMatchObject({ label: `agent ${id}`, value: "#1 — done" });
14131418 const { body } = await call(rw, "tools/call", { name: "runs", arguments: { repo: "alice/game" } });
14141419 expect(body.result.structuredContent.card.kind).toBe("run");
1415 const tools = (await call(rw, "tools/list")).body.result.tools;
1420 const tools = (await listCall(rw)).body.result.tools;
14161421 expect(tools.find((t: any) => t.name === "runs")._meta?.ui?.resourceUri).toBe("ui://forge/run");
14171422 });
14181423
@@ -1427,7 +1432,7 @@ describe("the forge over MCP", () => {
14271432
14281433 test("ship and close card actions never apply at once and always meet the target tool's required fields", async () => {
14291434 const { cardFor } = await import("../src/web/studio/cards.ts");
1430 const tools = (await call(rw, "tools/list")).body.result.tools;
1435 const tools = (await listCall(rw)).body.result.tools;
14311436 const cards = [
14321437 cardFor("apps", [{ app: "alice/game", live: "up" }]),
14331438 cardFor("now", [{ who: "scout", app: "alice/game", run: "r1", on: "#1", state: "going" }]),

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs