Public: anyone can read this; the crew can change it.
A test fails when the Claude Code or OpenCode plugin names an MCP tool the forge does not have #3295
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3272-a-test-fails-when-the-claude-code-or-ope → main 1 commit fork 1 thread · 1 unresolved
4 files
changed +50
−0
plugins/claude-code/README.md +2
| @@ -4,3 +4,5 @@ Set `FORGE_URL` (the forge's address, no trailing slash) and `FORGE_TOKEN` | ||
| 4 | 4 | (an `rw` token; `ro` reads only), then add this directory as a plugin |
| 5 | 5 | (`claude --plugin-dir plugins/claude-code`). It registers the forge's MCP |
| 6 | 6 | server and a `/forge` command: `tell`, `status`, `decide`. |
| 7 | ||
| 8 | `test/plugins.test.ts` fails when this plugin names an MCP tool the forge does not list, so a rename in `src/http/mcp.ts` cannot leave it behind. |
plugins/opencode/README.md +2
| @@ -4,3 +4,5 @@ Copy `forge.ts` into `.opencode/plugin/` (or list this package under `plugin` | ||
| 4 | 4 | in `opencode.json`), and set `FORGE_URL` and `FORGE_TOKEN`. It registers the |
| 5 | 5 | forge's MCP server and the `forge` command: `tell`, `status`, `decide`. |
| 6 | 6 | Code Mode can also read `GET /contract/openapi.json`. |
| 7 | ||
| 8 | `test/plugins.test.ts` fails when this plugin names an MCP tool the forge does not list, so a rename in `src/http/mcp.ts` cannot leave it behind. |
src/http/mcp.ts +3
| @@ -3278,6 +3278,9 @@ function reportCall(ctx: Ctx, message: unknown): unknown | undefined { | ||
| 3278 | 3278 | const NOT_HERE = (allow: string): Response => |
| 3279 | 3279 | new Response(null, { status: 405, headers: { allow } }); |
| 3280 | 3280 | |
| 3281 | /** Every tool name the forge lists, admin-only ones included. */ | |
| 3282 | export const toolNames = (): string[] => [...TOOLS.map((t) => t.name), REPORT_TOOL.name]; | |
| 3283 | ||
| 3281 | 3284 | export function mcpRoutes(router: Router<Handler>): void { |
| 3282 | 3285 | router.add("POST", "/mcp", async (ctx) => { |
| 3283 | 3286 | const user = ctx.auth.user; |
test/plugins.test.ts added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | /** The thin plugins name only MCP tools the forge lists, so a rename there fails here. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { readFileSync } from "node:fs"; | |
| 4 | import { toolNames } from "../src/http/mcp.ts"; | |
| 5 | ||
| 6 | const read = (path: string): string => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); | |
| 7 | ||
| 8 | /** Claude Code: the command text is the whole file after the front matter. */ | |
| 9 | const claudeText = (): string => read("plugins/claude-code/commands/forge.md").replace(/^---\n[\s\S]*?\n---\n/, ""); | |
| 10 | ||
| 11 | /** OpenCode: the COMMAND template literal, where backticks are escaped. */ | |
| 12 | const opencodeText = (): string => { | |
| 13 | const m = /const COMMAND = `([\s\S]*?[^\\])`;\n/.exec(read("plugins/opencode/forge.ts")); | |
| 14 | if (!m) throw new Error("plugins/opencode/forge.ts has no COMMAND template"); | |
| 15 | return m[1]!.replaceAll("\\`", "`"); | |
| 16 | }; | |
| 17 | ||
| 18 | /** Backticked lowercase identifiers: tool names and argument names alike. */ | |
| 19 | const backticked = (text: string): string[] => [...text.matchAll(/`([a-z][a-z0-9_]*)`/g)].map((m) => m[1]!); | |
| 20 | ||
| 21 | /** Backticked words that are the server or an argument, not a tool. */ | |
| 22 | const NOT_TOOLS = new Set(["forge", "repo", "text", "context", "page", "run", "commit", "said", "action", "ship", "close", "status"]); | |
| 23 | ||
| 24 | /** Tools the command text tells the agent to call: every backticked name that is not an argument. */ | |
| 25 | const toolsNamed = (text: string): string[] => [...new Set(backticked(text))].filter((n) => !NOT_TOOLS.has(n)); | |
| 26 | ||
| 27 | const tools = new Set(toolNames()); | |
| 28 | ||
| 29 | for (const [label, text] of [ | |
| 30 | ["plugins/claude-code/commands/forge.md", claudeText], | |
| 31 | ["plugins/opencode/forge.ts", opencodeText], | |
| 32 | ] as const) { | |
| 33 | test(`${label} names only tools the forge has`, () => { | |
| 34 | const named = toolsNamed(text()); | |
| 35 | for (const core of ["tell", "now", "asks", "asks_act", "say"]) expect(named).toContain(core); | |
| 36 | expect(named.filter((n) => !tools.has(n))).toEqual([]); | |
| 37 | }); | |
| 38 | } | |
| 39 | ||
| 40 | test("a renamed tool is caught", () => { | |
| 41 | expect(toolsNamed("call `tell_renamed` with `repo`").filter((n) => !tools.has(n))).toEqual(["tell_renamed"]); | |
| 42 | expect(backticked("`a_b` and `C`")).toEqual(["a_b"]); | |
| 43 | }); |
new above
offline
Minor:
tools/listhidesadminOnlytools from non-admins, and this includes them. The plugin test would accept a plugin that names a tool an ordinary token can't call. That's fine today, because no plugin names one. A{ admin: boolean }filter here would let the test check against what a plugin user actually sees.