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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11957-the-mcp-opens-with-twelve-tools-finds-th → main 5 commits fork 3 threads · 3 unresolved
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`; | ||
| 106 | 106 | | `feed_chips` | `kind`, `person` | Feed chips you switched off | 262 | |
| 107 | 107 | | `feed_dismiss` | `dismissed`, `muted` | Dismiss a feed item or mute its app; `feed` then leaves it out | 280 | |
| 108 | 108 | | `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 | | |
| 109 | 110 | | `flag_set` | — | Set or clear a flag (admin) | 209 | |
| 110 | 111 | | `flag_winner` | — | An A/B flag winner becomes default | 206 | |
| 111 | 112 | | `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`; | ||
| 174 | 175 | | `pulls` | — | A repository's pulls, newest first; no `repo`: all open pulls you may read, oldest first | 411 | |
| 175 | 176 | | `queue_gates` | — | Recent merge-queue gates: pulls, verdicts, failing tests | 277 | |
| 176 | 177 | | `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 | | |
| 178 | 179 | | `release_hold` | — | Hold or release the release keeper's cuts (admin) | 202 | |
| 179 | 180 | | `release_keeper` | — | The release keeper's releases, holds and what blocks the next cut | 192 | |
| 180 | 181 | | `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`; | ||
| 234 | 235 | | `waves` | — | Waves; `version`: one | 260 | |
| 235 | 236 | | `week` | — | The week's retrospective; `take` accepts one proposal | 226 | |
| 236 | 237 | | `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 | | |
| 238 | 239 | | `report` | — | Report a problem in a public repository, no account needed | 468 | |
| 239 | 240 | | `start` | — | Start building with no account: an account, a repo, a live app and a token in one call | 358 | |
| 240 | 241 | <!-- mcp-tools:end --> |
| @@ -398,6 +399,32 @@ longer sentences: | ||
| 398 | 399 | - `hold.why`: why, in one line; it shows on the pull. `runs.limit`: how many, 1 to 50; 20 when absent. |
| 399 | 400 | - `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. |
| 400 | 401 | |
| 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 | ||
| 401 | 428 | ## Connecting |
| 402 | 429 | |
| 403 | 430 | 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 | ||
| 41 | 41 | import { type Args, alreadyDone, notAdminTool, visible, plansOnly, str, REPO_ARG, lean, fenceAnswer, heldWrite, askedFirst, readThrough } from "./mcp/shared.ts"; |
| 42 | 42 | import { CONVERSATION_BRIEF } from "../crew/conversation_brief.ts"; |
| 43 | 43 | import { TOOLS } from "./mcp/tools.ts"; |
| 44 | import { DEFAULT_TOOLS, wantsAll } from "./mcp/catalog.ts"; | |
| 44 | 45 | |
| 45 | 46 | const PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"] as const; |
| 46 | 47 | /** 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][]) { | ||
| 61 | 62 | ]; |
| 62 | 63 | } |
| 63 | 64 | |
| 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 | ||
| 64 | 68 | // One page of tools/list stays within this many bytes of tools; `nextCursor` |
| 65 | 69 | // names the rest, so adding a tool never busts one response. |
| 66 | 70 | export const TOOLS_PAGE_BYTES = 20_000; |
| @@ -137,7 +141,7 @@ function rpcOne( | ||
| 137 | 141 | : PROTOCOLS[0], |
| 138 | 142 | capabilities: { tools: {}, resources: {} }, |
| 139 | 143 | 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}`}${ | |
| 141 | 145 | ctx.auth.through === undefined ? "" : ` ${CONVERSATION_BRIEF}` |
| 142 | 146 | }`, |
| 143 | 147 | }); |
| @@ -145,7 +149,12 @@ function rpcOne( | ||
| 145 | 149 | case "ping": |
| 146 | 150 | return reply({}); |
| 147 | 151 | 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)); | |
| 149 | 158 | // The cursor is the name of the last tool of the page before, so a tool |
| 150 | 159 | // added between two requests moves nothing. |
| 151 | 160 | 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 | ||
| 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"; | ||
| 65 | 65 | export { tool as feed_chips } from "./feed_chips.ts"; |
| 66 | 66 | export { tool as feed_dismiss } from "./feed_dismiss.ts"; |
| 67 | 67 | export { tool as feed_like } from "./feed_like.ts"; |
| 68 | export { tool as find_tool } from "./find_tool.ts"; | |
| 68 | 69 | export { tool as flag_set } from "./flag_set.ts"; |
| 69 | 70 | export { tool as flag_winner } from "./flag_winner.ts"; |
| 70 | 71 | export { tool as flags } from "./flags.ts"; |
src/http/mcp/tools/read_file.ts +1 −1
| @@ -11,7 +11,7 @@ export const tool: Tool = { | ||
| 11 | 11 | name: "read_file", |
| 12 | 12 | 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}", |
| 13 | 13 | 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.", | |
| 15 | 15 | inputSchema: { |
| 16 | 16 | type: "object", |
| 17 | 17 | 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" | ||
| 8 | 8 | export const tool: Tool = { |
| 9 | 9 | name: "write_file", |
| 10 | 10 | 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.", | |
| 12 | 12 | inputSchema: { |
| 13 | 13 | type: "object", |
| 14 | 14 | properties: { |
src/release/outsider.ts +1 −1
| @@ -125,7 +125,7 @@ export async function outsider(base: string, options: OutsiderOptions = {}): Pro | ||
| 125 | 125 | method: "POST", |
| 126 | 126 | signal: AbortSignal.timeout(10_000), |
| 127 | 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 } }), | |
| 128 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: cursor === undefined ? { all: true } : { all: true, cursor } }), | |
| 129 | 129 | }); |
| 130 | 130 | const text = await toolList.text(); |
| 131 | 131 | 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 () => { | ||
| 68 | 68 | }); |
| 69 | 69 | |
| 70 | 70 | test("tools/list shows q first", async () => { |
| 71 | const r = await rpc("tools/list", {}); | |
| 71 | const r = await rpc("tools/list", { all: true }); | |
| 72 | 72 | const t = r.tools.find((x: any) => x.name === "explore"); |
| 73 | 73 | expect(Object.keys(t.inputSchema.properties)[0]).toBe("q"); |
| 74 | 74 | }); |
test/mcp-backups-test.test.ts +4 −4
| @@ -119,21 +119,21 @@ describe("MCP backups_test", () => { | ||
| 119 | 119 | test("a non-admin is refused and does not see the tool", async () => { |
| 120 | 120 | const r = await test_(plain, fields("http://127.0.0.1:1")); |
| 121 | 121 | expect(r.error).toBe(true); |
| 122 | const listed = await rpc(plain, "tools/list"); | |
| 122 | const listed = await rpc(plain, "tools/list", { all: true }); | |
| 123 | 123 | expect(listed.result.tools.map((t: any) => t.name)).not.toContain("backups_test"); |
| 124 | 124 | }); |
| 125 | 125 | |
| 126 | 126 | 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 }); | |
| 128 | 128 | expect(listed.result.tools.map((t: any) => t.name)).not.toContain("backups_test"); |
| 129 | 129 | const r = await test_(rw, fields("ftp://x")); |
| 130 | 130 | expect(r.error).toBe(true); |
| 131 | const seen = await rpc(admin, "tools/list"); | |
| 131 | const seen = await rpc(admin, "tools/list", { all: true }); | |
| 132 | 132 | expect(seen.result.tools.map((t: any) => t.name)).toContain("backups_test"); |
| 133 | 133 | }); |
| 134 | 134 | |
| 135 | 135 | 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 }); | |
| 137 | 137 | const tool = listed.result.tools.find((t: any) => t.name === "backups_test"); |
| 138 | 138 | expect(tool.annotations?.readOnlyHint).not.toBe(true); |
| 139 | 139 | }); |
test/mcp-backups.test.ts +1 −1
| @@ -127,7 +127,7 @@ describe("MCP backups", () => { | ||
| 127 | 127 | const res = await fetch(`${servers.url}/mcp`, { |
| 128 | 128 | method: "POST", |
| 129 | 129 | 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 } }), | |
| 131 | 131 | }); |
| 132 | 132 | return ((await res.json()) as any).result.tools.map((t: { name: string }) => t.name) as string[]; |
| 133 | 133 | }; |
test/mcp-doors.test.ts +1 −1
| @@ -58,7 +58,7 @@ describe("the doors onto the MCP tools", () => { | ||
| 58 | 58 | const res = await fetch(`${servers.url}/mcp`, { |
| 59 | 59 | method: "POST", |
| 60 | 60 | 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 } }), | |
| 62 | 62 | }); |
| 63 | 63 | const r = ((await res.json()) as any).result; |
| 64 | 64 | 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 () | ||
| 80 | 80 | const res = await fetch(`${servers.url}/mcp`, { |
| 81 | 81 | method: "POST", |
| 82 | 82 | 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 } }), | |
| 84 | 84 | }); |
| 85 | 85 | const page = ((await res.json()) as any).result; |
| 86 | 86 | listed = page.tools.find((t: any) => t.name === "forge_view"); |
test/mcp-list.ts +3 −3
| @@ -2,16 +2,16 @@ | ||
| 2 | 2 | |
| 3 | 3 | /** Every page of `send({ cursor })`, merged into one answer shaped like a page. */ |
| 4 | 4 | export async function listVia( |
| 5 | send: (params: { cursor?: string }) => Promise<any>, | |
| 5 | send: (params: { cursor?: string; all?: true }) => Promise<any>, | |
| 6 | 6 | ): Promise<{ result: { tools: any[] } }> { |
| 7 | 7 | const tools: any[] = []; |
| 8 | let params: { cursor?: string } = {}; | |
| 8 | let params: { cursor?: string; all?: true } = { all: true }; | |
| 9 | 9 | for (;;) { |
| 10 | 10 | const page = await send(params); |
| 11 | 11 | if (page.error) throw new Error(`tools/list: ${page.error.message}`); |
| 12 | 12 | tools.push(...page.result.tools); |
| 13 | 13 | if (typeof page.result.nextCursor !== "string") return { result: { tools } }; |
| 14 | params = { cursor: page.result.nextCursor }; | |
| 14 | params = { all: true, cursor: page.result.nextCursor }; | |
| 15 | 15 | } |
| 16 | 16 | } |
| 17 | 17 |
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 | ||
| 144 | 144 | |
| 145 | 145 | test("tools/list lists pulls and every page stays under 40 KB", async () => { |
| 146 | 146 | const names: string[] = []; |
| 147 | let params: { cursor?: string } = {}; | |
| 147 | let params: { all: true; cursor?: string } = { all: true }; | |
| 148 | 148 | for (;;) { |
| 149 | 149 | const { text } = await rpc(tok["ada"]!, "tools/list", params); |
| 150 | 150 | expect(Buffer.byteLength(text)).toBeLessThanOrEqual(38_500); // headroom: a new tool must not tip the lane red |
| 151 | 151 | const { result } = JSON.parse(text); |
| 152 | 152 | names.push(...result.tools.map((t: any) => t.name)); |
| 153 | 153 | if (typeof result.nextCursor !== "string") break; |
| 154 | params = { cursor: result.nextCursor }; | |
| 154 | params = { all: true, cursor: result.nextCursor }; | |
| 155 | 155 | } |
| 156 | 156 | expect(names).toContain("pulls"); |
| 157 | 157 | }); |
test/mcp-size.test.ts +114 −2
| @@ -16,6 +16,7 @@ import { join } from "node:path"; | ||
| 16 | 16 | import { createApp, type App } from "../src/app.ts"; |
| 17 | 17 | import { createToken } from "../src/core/tokens.ts"; |
| 18 | 18 | import { createUser } from "../src/core/users.ts"; |
| 19 | import { FIND_MAX, matching, WORDS_MAX } from "../src/http/mcp/catalog.ts"; | |
| 19 | 20 | import { costliestTools } from "./mcp-size-rank.ts"; |
| 20 | 21 | import { serve, type Servers } from "../src/http/server.ts"; |
| 21 | 22 | |
| @@ -45,6 +46,7 @@ let dataDir: string; | ||
| 45 | 46 | let app: App; |
| 46 | 47 | let servers: Servers; |
| 47 | 48 | let token: string; |
| 49 | let aliceId: string; | |
| 48 | 50 | |
| 49 | 51 | beforeAll(async () => { |
| 50 | 52 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-size-")); |
| @@ -60,6 +62,7 @@ beforeAll(async () => { | ||
| 60 | 62 | adminIfFirst: true, |
| 61 | 63 | }); |
| 62 | 64 | if (!made.ok) throw new Error(made.error); |
| 65 | aliceId = made.value.id; | |
| 63 | 66 | token = createToken(app.db, made.value.id, "rw", "rw").token; |
| 64 | 67 | }, 30_000); |
| 65 | 68 | |
| @@ -69,7 +72,7 @@ afterAll(async () => { | ||
| 69 | 72 | await rm(dataDir, { recursive: true, force: true }); |
| 70 | 73 | }); |
| 71 | 74 | |
| 72 | async function page(cursor?: unknown): Promise<{ text: string; json: any }> { | |
| 75 | async function page(cursor?: unknown, all = true): Promise<{ text: string; json: any }> { | |
| 73 | 76 | const res = await fetch(`${servers.url}/mcp`, { |
| 74 | 77 | method: "POST", |
| 75 | 78 | headers: { |
| @@ -81,7 +84,7 @@ async function page(cursor?: unknown): Promise<{ text: string; json: any }> { | ||
| 81 | 84 | jsonrpc: "2.0", |
| 82 | 85 | id: 1, |
| 83 | 86 | method: "tools/list", |
| 84 | ...(cursor === undefined ? {} : { params: { cursor } }), | |
| 87 | params: { ...(all ? { all: true } : {}), ...(cursor === undefined ? {} : { cursor }) }, | |
| 85 | 88 | }), |
| 86 | 89 | }); |
| 87 | 90 | const text = await res.text(); |
| @@ -188,3 +191,112 @@ test("a cursor that names no tool is an invalid-params error", async () => { | ||
| 188 | 191 | expect(json.result).toBeUndefined(); |
| 189 | 192 | } |
| 190 | 193 | }); |
| 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/ |
||
| 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 () => { | ||
| 71 | 71 | const res = await fetch(`${servers.url}/mcp`, { |
| 72 | 72 | method: "POST", |
| 73 | 73 | 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 } }), | |
| 75 | 75 | }); |
| 76 | 76 | const body = (await res.json()) as any; |
| 77 | 77 | served.push(...body.result.tools); |
test/mcp.test.ts +1
| @@ -329,6 +329,7 @@ describe("the forge over MCP", () => { | ||
| 329 | 329 | "feed_chips", |
| 330 | 330 | "feed_dismiss", |
| 331 | 331 | "feed_like", |
| 332 | "find_tool", | |
| 332 | 333 | "maintenance", |
| 333 | 334 | "make_it_yours", |
| 334 | 335 | "origin_apps", |
test/route-marks.test.ts +1 −1
| @@ -206,7 +206,7 @@ describe("every admin-only action refuses the everyday account", () => { | ||
| 206 | 206 | const names = new Set<string>(); |
| 207 | 207 | let cursor: string | undefined; |
| 208 | 208 | 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 { | |
| 210 | 210 | result?: { tools?: { name: string }[]; nextCursor?: string }; |
| 211 | 211 | }; |
| 212 | 212 | for (const t of page.result?.tools ?? []) names.add(t.name); |
new above
offline
apidoes not exist: dead entry. Also foldreportandstartin here so the default set is in one place.