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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6355-mcp-tools-list-pages-with-nextcursor-so → main 1 commit fork 3 threads · 3 unresolved
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`. | ||
| 179 | 179 | |
| 180 | 180 | ### Room for a new tool |
| 181 | 181 | |
| 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`; | |
| 185 | 190 | a failure names the bytes over and the longest descriptions. |
| 186 | 191 | |
| 187 | 192 | - 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"; | ||
| 36 | 36 | |
| 37 | 37 | const PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"] as const; |
| 38 | 38 | /** Who answers: the forge, at the version its build stamped. */ |
| 39 | // One page of tools/list stays within this many bytes of tools; `nextCursor` | |
| 40 | // names the rest, so adding a tool never busts one response. | |
| 41 | export const TOOLS_PAGE_BYTES = 20_000; | |
| 42 | ||
| 39 | 43 | const server = () => ({ name: "forge", version: running().version }); |
| 40 | 44 | |
| 41 | 45 | |
| @@ -102,27 +106,47 @@ function rpcOne( | ||
| 102 | 106 | } |
| 103 | 107 | case "ping": |
| 104 | 108 | 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 | } | |
| 106 | 145 | 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 } : {}), | |
| 125 | 148 | }); |
| 149 | } | |
| 126 | 150 | case "resources/list": |
| 127 | 151 | return reply({ |
| 128 | 152 | resources: CARD_KINDS.map((k) => ({ |
src/release/outsider.ts +19 −7
| @@ -118,13 +118,25 @@ export async function outsider(base: string, options: OutsiderOptions = {}): Pro | ||
| 118 | 118 | return { error: false, text }; |
| 119 | 119 | } |
| 120 | 120 | }; |
| 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 |
||
| 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 | } | |
| 128 | 140 | for (const tool of ["tell", "make_it_yours", "repo", "usage", "apps", "signup"]) |
| 129 | 141 | if (!toolsText.includes(`"${tool}"`)) bad.push({ check: "MCP tools", why: `tools/list does not name ${tool}; the suite's doors may have moved` }); |
| 130 | 142 |
test/asks-mute.test.ts +2 −6
| @@ -26,6 +26,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 26 | 26 | import { mcpTool } from "./helpers/mcp.ts"; |
| 27 | 27 | import { askCountFor, askLinesFor, forgeNeedsFor, repoAsks } from "../src/web/studio/asks.ts"; |
| 28 | 28 | import { quietLogs } from "./helpers/globals.ts"; |
| 29 | import { listAt } from "./mcp-list.ts"; | |
| 29 | 30 | |
| 30 | 31 | quietLogs(); |
| 31 | 32 | // 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", () => { | ||
| 254 | 255 | expect(((await tool(token, "asks", { hide_people: ["nobody"] })) as any).error).toContain("hide_people"); |
| 255 | 256 | expect(((await tool(token, "asks", { hide_kinds: "reviews" })) as any).error).toContain("lists"); |
| 256 | 257 | // 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[]; | |
| 263 | 259 | for (const name of ["asks", "feed"]) { |
| 264 | 260 | const t = tools.find((x) => x.name === name); |
| 265 | 261 | 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"; | ||
| 16 | 16 | import { serve, type Servers } from "../src/http/server.ts"; |
| 17 | 17 | import { seedAdmin } from "./helpers/admin.ts"; |
| 18 | 18 | import { quietLogs } from "./helpers/globals.ts"; |
| 19 | import { listVia } from "./mcp-list.ts"; | |
| 19 | 20 | |
| 20 | 21 | quietLogs(); |
| 21 | 22 | |
| @@ -132,7 +133,7 @@ describe("the crew scorecard over the API and MCP", () => { | ||
| 132 | 133 | const called = await rpc(admin, "tools/call", { name: "scorecard", arguments: {} }); |
| 133 | 134 | expect(JSON.parse(called.result.content[0].text)).toEqual(api); |
| 134 | 135 | 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)); | |
| 136 | 137 | expect(await names(admin)).toContain("scorecard"); |
| 137 | 138 | expect(await names(member)).not.toContain("scorecard"); |
| 138 | 139 | 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"; | ||
| 26 | 26 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 27 | 27 | import { quietLogs } from "./helpers/globals.ts"; |
| 28 | 28 | import { settleApp } from "./helpers/tui.ts"; |
| 29 | import { listAt } from "./mcp-list.ts"; | |
| 29 | 30 | |
| 30 | 31 | quietLogs(); |
| 31 | 32 | |
| @@ -193,16 +194,7 @@ describe("the standing rule over MCP", () => { | ||
| 193 | 194 | }); |
| 194 | 195 | |
| 195 | 196 | 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[]; | |
| 206 | 198 | expect(list.find((t) => t.name === "asks_decide").inputSchema.properties.by).toBeUndefined(); |
| 207 | 199 | }); |
| 208 | 200 | }); |
test/mcp-accounts.test.ts +3 −2
| @@ -7,6 +7,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 7 | 7 | import { createToken } from "../src/core/tokens.ts"; |
| 8 | 8 | import { createUser } from "../src/core/users.ts"; |
| 9 | 9 | import { serve, type Servers } from "../src/http/server.ts"; |
| 10 | import { listVia } from "./mcp-list.ts"; | |
| 10 | 11 | |
| 11 | 12 | describe("MCP accounts", () => { |
| 12 | 13 | let dataDir: string; |
| @@ -85,9 +86,9 @@ describe("MCP accounts", () => { | ||
| 85 | 86 | }); |
| 86 | 87 | |
| 87 | 88 | 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)); | |
| 89 | 90 | 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)); | |
| 91 | 92 | expect(admin.result.tools.map((t: any) => t.name)).toContain("accounts"); |
| 92 | 93 | expect((await accounts(bobToken)).refused).toContain("no tool named accounts"); |
| 93 | 94 | }); |
test/mcp-backups-destination.test.ts +2 −10
| @@ -14,6 +14,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 14 | 14 | import { sealedSet } from "../src/snapshot/destination.ts"; |
| 15 | 15 | import { seedAdmin } from "./helpers/admin.ts"; |
| 16 | 16 | import { quietLogs } from "./helpers/globals.ts"; |
| 17 | import { listAt } from "./mcp-list.ts"; | |
| 17 | 18 | |
| 18 | 19 | quietLogs(); |
| 19 | 20 | |
| @@ -129,16 +130,7 @@ describe("MCP backups_destination", () => { | ||
| 129 | 130 | }); |
| 130 | 131 | |
| 131 | 132 | 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 }[]; | |
| 142 | 134 | const schema = tools.find((t) => t.name === "backups_destination")!.inputSchema; |
| 143 | 135 | expect(schema.required).toEqual(["action"]); |
| 144 | 136 | 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"; | ||
| 13 | 13 | import { serve, type Servers } from "../src/http/server.ts"; |
| 14 | 14 | import { CARD_KINDS, CARD_OF_TOOL } from "../src/web/studio/cards.ts"; |
| 15 | 15 | import { quietLogs } from "./helpers/globals.ts"; |
| 16 | import { listVia } from "./mcp-list.ts"; | |
| 16 | 17 | |
| 17 | 18 | quietLogs(); |
| 18 | 19 | |
| @@ -53,7 +54,7 @@ describe("MCP cards coverage", () => { | ||
| 53 | 54 | }); |
| 54 | 55 | |
| 55 | 56 | 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)); | |
| 57 | 58 | const missing = Object.keys(CARD_OF_TOOL).filter((t) => !names.has(t)); |
| 58 | 59 | expect(missing).toEqual([]); |
| 59 | 60 | }); |
test/mcp-confirm-wait.test.ts +2 −1
| @@ -18,6 +18,7 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 18 | 18 | import { createUser, type User } from "../src/core/users.ts"; |
| 19 | 19 | import { promote } from "./helpers/members.ts"; |
| 20 | 20 | import { serve, type Servers } from "../src/http/server.ts"; |
| 21 | import { listVia } from "./mcp-list.ts"; | |
| 21 | 22 | |
| 22 | 23 | describe("confirm_wait over MCP", () => { |
| 23 | 24 | let dataDir: string; |
| @@ -123,7 +124,7 @@ describe("confirm_wait over MCP", () => { | ||
| 123 | 124 | }); |
| 124 | 125 | |
| 125 | 126 | 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)); | |
| 127 | 128 | const t = listed.result.tools.find((x: any) => x.name === "confirm_wait"); |
| 128 | 129 | expect(t.inputSchema.required).toEqual(["app", "issue", "blocker"]); |
| 129 | 130 | }); |
test/mcp-docs.test.ts +2 −10
| @@ -8,6 +8,7 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 8 | 8 | import { createUser } from "../src/core/users.ts"; |
| 9 | 9 | import { serve, type Servers } from "../src/http/server.ts"; |
| 10 | 10 | import { quietLogs } from "./helpers/globals.ts"; |
| 11 | import { listAt } from "./mcp-list.ts"; | |
| 11 | 12 | |
| 12 | 13 | quietLogs(); |
| 13 | 14 | |
| @@ -44,16 +45,7 @@ beforeAll(async () => { | ||
| 44 | 45 | }); |
| 45 | 46 | if (!made.ok) throw new Error(JSON.stringify(made.error)); |
| 46 | 47 | 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); | |
| 57 | 49 | }); |
| 58 | 50 | |
| 59 | 51 | afterAll(async () => { |
test/mcp-hooks.test.ts +2 −1
| @@ -9,6 +9,7 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 9 | 9 | import { createUser } from "../src/core/users.ts"; |
| 10 | 10 | import { serve, type Servers } from "../src/http/server.ts"; |
| 11 | 11 | import { promote } from "./helpers/members.ts"; |
| 12 | import { listVia } from "./mcp-list.ts"; | |
| 12 | 13 | |
| 13 | 14 | describe("MCP hooks", () => { |
| 14 | 15 | let dataDir: string; |
| @@ -101,7 +102,7 @@ describe("MCP hooks", () => { | ||
| 101 | 102 | }); |
| 102 | 103 | |
| 103 | 104 | 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); | |
| 105 | 106 | expect(await names(aliceAdmin)).toContain("hooks"); |
| 106 | 107 | expect(await names(aliceRw)).not.toContain("hooks"); |
| 107 | 108 | 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 |
||
| 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"; | ||
| 19 | 19 | import { seedAdmin } from "./helpers/admin.ts"; |
| 20 | 20 | import { promote } from "./helpers/members.ts"; |
| 21 | 21 | import { tokenFor } from "./helpers/tokens.ts"; |
| 22 | import { listAt } from "./mcp-list.ts"; | |
| 22 | 23 | |
| 23 | 24 | describe("MCP preview", () => { |
| 24 | 25 | let dataDir: string; |
| @@ -163,12 +164,7 @@ describe("MCP preview", () => { | ||
| 163 | 164 | }); |
| 164 | 165 | |
| 165 | 166 | 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); | |
| 172 | 168 | expect(body.result.tools.some((t: any) => t.name === "preview")).toBe(true); |
| 173 | 169 | }); |
| 174 | 170 | }); |
test/mcp-private-fix.test.ts +2 −1
| @@ -12,6 +12,7 @@ import { createIssue } from "../src/core/issues.ts"; | ||
| 12 | 12 | import { createToken } from "../src/core/tokens.ts"; |
| 13 | 13 | import { createUser } from "../src/core/users.ts"; |
| 14 | 14 | import { serve, type Servers } from "../src/http/server.ts"; |
| 15 | import { listVia } from "./mcp-list.ts"; | |
| 15 | 16 | |
| 16 | 17 | describe("MCP seal action private", () => { |
| 17 | 18 | let dataDir: string; |
| @@ -80,7 +81,7 @@ describe("MCP seal action private", () => { | ||
| 80 | 81 | }); |
| 81 | 82 | |
| 82 | 83 | 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)); | |
| 84 | 85 | const t = listed.result.tools.find((x: any) => x.name === "seal"); |
| 85 | 86 | expect(t.inputSchema.properties.action.enum).toEqual(["private"]); |
| 86 | 87 | 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 | ||
| 18 | 18 | import { serve, type Servers } from "../src/http/server.ts"; |
| 19 | 19 | import { promote } from "./helpers/members.ts"; |
| 20 | 20 | import { quietLogs } from "./helpers/globals.ts"; |
| 21 | import { listVia } from "./mcp-list.ts"; | |
| 21 | 22 | |
| 22 | 23 | quietLogs(); |
| 23 | 24 | |
| @@ -151,7 +152,7 @@ describe("MCP prompts", () => { | ||
| 151 | 152 | }); |
| 152 | 153 | |
| 153 | 154 | 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); | |
| 155 | 156 | expect(await names(admin)).toContain("prompts"); |
| 156 | 157 | expect(await names(rw)).not.toContain("prompts"); |
| 157 | 158 | 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 | ||
| 142 | 142 | expect((await pulls(tok["ada"]!, { repo: "ada/vault" })).value.pulls).toHaveLength(2); |
| 143 | 143 | }); |
| 144 | 144 | |
| 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"); | |
| 149 | 157 | }); |
| 150 | 158 | |
| 151 | 159 | 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"; | ||
| 8 | 8 | import { createToken } from "../src/core/tokens.ts"; |
| 9 | 9 | import { createUser } from "../src/core/users.ts"; |
| 10 | 10 | import { serve, type Servers } from "../src/http/server.ts"; |
| 11 | import { listAt } from "./mcp-list.ts"; | |
| 11 | 12 | |
| 12 | 13 | describe("MCP repo_labels", () => { |
| 13 | 14 | let dataDir: string; |
| @@ -69,16 +70,7 @@ describe("MCP repo_labels", () => { | ||
| 69 | 70 | }); |
| 70 | 71 | |
| 71 | 72 | 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); | |
| 82 | 74 | const t = body.result.tools.find((x: any) => x.name === "repo_labels"); |
| 83 | 75 | expect(t.inputSchema.required).toEqual(["repo"]); |
| 84 | 76 | }); |
test/mcp-secrets.test.ts +3 −2
| @@ -13,6 +13,7 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 13 | 13 | import { createUser } from "../src/core/users.ts"; |
| 14 | 14 | import { serve, type Servers } from "../src/http/server.ts"; |
| 15 | 15 | import { promote } from "./helpers/members.ts"; |
| 16 | import { listVia } from "./mcp-list.ts"; | |
| 16 | 17 | |
| 17 | 18 | describe("MCP secrets", () => { |
| 18 | 19 | let dataDir: string; |
| @@ -125,9 +126,9 @@ describe("MCP secrets", () => { | ||
| 125 | 126 | }); |
| 126 | 127 | |
| 127 | 128 | 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)); | |
| 129 | 130 | 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)); | |
| 131 | 132 | expect(seen.result.tools.map((t: any) => t.name)).toContain("secrets"); |
| 132 | 133 | const call = await rpc(other, "tools/call", { |
| 133 | 134 | name: "secrets", |
test/mcp-size.test.ts +52 −16
| @@ -69,7 +69,7 @@ afterAll(async () => { | ||
| 69 | 69 | await rm(dataDir, { recursive: true, force: true }); |
| 70 | 70 | }); |
| 71 | 71 | |
| 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 }> { | |
| 73 | 73 | const res = await fetch(`${servers.url}/mcp`, { |
| 74 | 74 | method: "POST", |
| 75 | 75 | headers: { |
| @@ -77,28 +77,47 @@ test(`tools/list stays under 40 KB, with 1.5 KB of headroom (${HEADROOM_CEILING. | ||
| 77 | 77 | accept: "application/json, text/event-stream", |
| 78 | 78 | authorization: `Bearer ${token}`, |
| 79 | 79 | }, |
| 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 | }), | |
| 81 | 86 | }); |
| 82 | 87 | 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); | |
| 90 | 109 | // The argument words are short; the detail lives in docs/MCP.md. |
| 91 | 110 | const long: string[] = []; |
| 92 | const walk = (tool: string, node: any, path: string): void => { | |
| 111 | const walkProps = (tool: string, node: any, path: string): void => { | |
| 93 | 112 | if (!node || typeof node !== "object") return; |
| 94 | 113 | for (const [k, v] of Object.entries<any>(node.properties ?? {})) { |
| 95 | 114 | if (typeof v?.description === "string" && Buffer.byteLength(v.description) > PROPERTY_MAX) |
| 96 | 115 | long.push(`${tool}.${path}${k} (${Buffer.byteLength(v.description)} bytes)`); |
| 97 | walk(tool, v, `${path}${k}.`); | |
| 116 | walkProps(tool, v, `${path}${k}.`); | |
| 98 | 117 | } |
| 99 | walk(tool, node.items, path); | |
| 118 | walkProps(tool, node.items, path); | |
| 100 | 119 | }; |
| 101 | for (const t of tools) walk(t.name, t.inputSchema, ""); | |
| 120 | for (const t of tools) walkProps(t.name, t.inputSchema, ""); | |
| 102 | 121 | expect(long, `property descriptions over ${PROPERTY_MAX} bytes`).toEqual([]); |
| 103 | 122 | // readOnlyHint defaults to false; spelling the default out on every write |
| 104 | 123 | // 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. | ||
| 106 | 125 | const longest = costliestTools(tools); |
| 107 | 126 | expect( |
| 108 | 127 | 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}`, | |
| 110 | 129 | ).toBeLessThanOrEqual(CEILING); |
| 111 | 130 | expect( |
| 112 | 131 | 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}`, | |
| 114 | 133 | ).toBeLessThanOrEqual(HEADROOM_CEILING); |
| 115 | 134 | expect( |
| 116 | 135 | tools |
| @@ -138,7 +157,7 @@ test("`dryRun` is explained once, in the instructions, not on every tool", async | ||
| 138 | 157 | }), |
| 139 | 158 | }) |
| 140 | 159 | ).json()) as any; |
| 141 | const tools = (await call("tools/list")).result.tools as { | |
| 160 | const tools = (await walk()).tools as { | |
| 142 | 161 | name: string; |
| 143 | 162 | inputSchema: { properties?: Record<string, { description?: string }> }; |
| 144 | 163 | }[]; |
| @@ -152,3 +171,20 @@ test("`dryRun` is explained once, in the instructions, not on every tool", async | ||
| 152 | 171 | "A tool with `dryRun` plans unless it is `false`.", |
| 153 | 172 | ); |
| 154 | 173 | }); |
| 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"; | ||
| 13 | 13 | import { createToken } from "../src/core/tokens.ts"; |
| 14 | 14 | import { createUser } from "../src/core/users.ts"; |
| 15 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 16 | import { listVia } from "./mcp-list.ts"; | |
| 16 | 17 | |
| 17 | 18 | describe("MCP subscription_add", () => { |
| 18 | 19 | let dataDir: string; |
| @@ -76,7 +77,7 @@ describe("MCP subscription_add", () => { | ||
| 76 | 77 | }).then((r) => r.text()); |
| 77 | 78 | expect(page).toContain("Team Max"); |
| 78 | 79 | 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; | |
| 80 | 81 | const t = tools.find((x: any) => x.name === "subscription_add"); |
| 81 | 82 | expect(t.annotations?.readOnlyHint).toBeUndefined(); |
| 82 | 83 | }); |
test/mcp-telemetry-grant.test.ts +2 −6
| @@ -10,6 +10,7 @@ import { createToken } from "../src/core/tokens.ts"; | ||
| 10 | 10 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 11 | 11 | import { serve, type Servers } from "../src/http/server.ts"; |
| 12 | 12 | import { quietLogs } from "./helpers/globals.ts"; |
| 13 | import { listAt } from "./mcp-list.ts"; | |
| 13 | 14 | |
| 14 | 15 | quietLogs(); |
| 15 | 16 | |
| @@ -75,12 +76,7 @@ describe("MCP telemetry_grant", () => { | ||
| 75 | 76 | }); |
| 76 | 77 | |
| 77 | 78 | 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[]; | |
| 84 | 80 | }; |
| 85 | 81 | |
| 86 | 82 | 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"; | ||
| 13 | 13 | import { promote } from "./helpers/members.ts"; |
| 14 | 14 | import { createUser } from "../src/core/users.ts"; |
| 15 | 15 | import { quietLogs } from "./helpers/globals.ts"; |
| 16 | import { listVia } from "./mcp-list.ts"; | |
| 16 | 17 | |
| 17 | 18 | quietLogs(); |
| 18 | 19 | |
| @@ -79,7 +80,7 @@ test("another person's tokens are never listed", async () => { | ||
| 79 | 80 | }); |
| 80 | 81 | |
| 81 | 82 | 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)); | |
| 83 | 84 | const t = body.result.tools.find((x: { name: string }) => x.name === "tokens"); |
| 84 | 85 | expect(t.annotations.readOnlyHint).toBe(true); |
| 85 | 86 | }); |
test/mcp-upstream.test.ts +2 −10
| @@ -24,6 +24,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 24 | 24 | import { checkUpstream } from "../src/spawn/upstream.ts"; |
| 25 | 25 | import { keepParentCredential } from "../src/spawn/parent_credential.ts"; |
| 26 | 26 | import { quietLogs } from "./helpers/globals.ts"; |
| 27 | import { listAt } from "./mcp-list.ts"; | |
| 27 | 28 | |
| 28 | 29 | quietLogs(); |
| 29 | 30 | |
| @@ -176,16 +177,7 @@ describe("MCP proposes to the parent forge and lists proposals", () => { | ||
| 176 | 177 | |
| 177 | 178 | test("tools/list names both", async () => { |
| 178 | 179 | 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 }[]; | |
| 189 | 181 | const by = (n: string) => tools.find((t) => t.name === n); |
| 190 | 182 | expect(by("propose_to_parent_forge")?.annotations).toBeUndefined(); |
| 191 | 183 | 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"; | ||
| 32 | 32 | import { promote } from "./helpers/members.ts"; |
| 33 | 33 | import { showBoard } from "../src/actions/boards.ts"; |
| 34 | 34 | import { panelData } from "../src/boards/panel_data.ts"; |
| 35 | import { listVia } from "./mcp-list.ts"; | |
| 35 | 36 | |
| 36 | 37 | /** tools/list's order: the tools by name, then `report`. */ |
| 37 | 38 | const sortedThenReport = (names: string[]): string[] => [ |
| @@ -52,6 +53,10 @@ describe("the forge over MCP", () => { | ||
| 52 | 53 | let game: Repo; |
| 53 | 54 | let nextId = 1; |
| 54 | 55 | |
| 56 | const listCall = async (token: string) => ({ | |
| 57 | body: await listVia(async (p) => (await call(token, "tools/list", p)).body), | |
| 58 | }); | |
| 59 | ||
| 55 | 60 | const call = async ( |
| 56 | 61 | token: string | null, |
| 57 | 62 | method: string, |
| @@ -173,7 +178,7 @@ describe("the forge over MCP", () => { | ||
| 173 | 178 | expect(note.status).toBe(202); |
| 174 | 179 | expect((await call(rw, "ping")).body.result).toEqual({}); |
| 175 | 180 | |
| 176 | const listed = await call(rw, "tools/list"); | |
| 181 | const listed = await listCall(rw); | |
| 177 | 182 | const names = listed.body.result.tools.map((t: { name: string }) => t.name); |
| 178 | 183 | // Listed by name, one file each (#5924), and `report` last. |
| 179 | 184 | expect(names).toEqual(sortedThenReport([ |
| @@ -713,7 +718,7 @@ describe("the forge over MCP", () => { | ||
| 713 | 718 | }); |
| 714 | 719 | |
| 715 | 720 | 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); | |
| 717 | 722 | const hints = Object.fromEntries( |
| 718 | 723 | listed.body.result.tools.map((t: any) => [ |
| 719 | 724 | t.name, |
| @@ -860,7 +865,7 @@ describe("the forge over MCP", () => { | ||
| 860 | 865 | .value.issue; |
| 861 | 866 | const b = (await tool(rw, "tell", { repo: "alice/game", text: "second" })) |
| 862 | 867 | .value.issue; |
| 863 | const listed = await call(rw, "tools/list"); | |
| 868 | const listed = await listCall(rw); | |
| 864 | 869 | const hints = Object.fromEntries( |
| 865 | 870 | listed.body.result.tools.map((t: any) => [ |
| 866 | 871 | t.name, |
| @@ -1210,7 +1215,7 @@ describe("the forge over MCP", () => { | ||
| 1210 | 1215 | expect(c.text).not.toContain("innerHTML"); |
| 1211 | 1216 | const nope = await call(rw, "resources/read", { uri: "ui://forge/nope" }); |
| 1212 | 1217 | 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; | |
| 1214 | 1219 | const meta = (n: string) => tools.find((t: any) => t.name === n)._meta?.ui?.resourceUri; |
| 1215 | 1220 | expect(meta("apps")).toBe("ui://forge/app"); |
| 1216 | 1221 | expect(meta("new_app")).toBe("ui://forge/app"); |
| @@ -1412,7 +1417,7 @@ describe("the forge over MCP", () => { | ||
| 1412 | 1417 | expect(card.rows[1]).toMatchObject({ label: `agent ${id}`, value: "#1 — done" }); |
| 1413 | 1418 | const { body } = await call(rw, "tools/call", { name: "runs", arguments: { repo: "alice/game" } }); |
| 1414 | 1419 | 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; | |
| 1416 | 1421 | expect(tools.find((t: any) => t.name === "runs")._meta?.ui?.resourceUri).toBe("ui://forge/run"); |
| 1417 | 1422 | }); |
| 1418 | 1423 | |
| @@ -1427,7 +1432,7 @@ describe("the forge over MCP", () => { | ||
| 1427 | 1432 | |
| 1428 | 1433 | test("ship and close card actions never apply at once and always meet the target tool's required fields", async () => { |
| 1429 | 1434 | 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; | |
| 1431 | 1436 | const cards = [ |
| 1432 | 1437 | cardFor("apps", [{ app: "alice/game", live: "up" }]), |
| 1433 | 1438 | cardFor("now", [{ who: "scout", app: "alice/game", run: "r1", on: "#1", state: "going" }]), |
new above
offline
This JSDoc used to document
serverbelow. With the constant and its//comment inserted after it, it now attaches toTOOLS_PAGE_BYTES. Put the new block above/** Who answers… */.