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

Merged opened this

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

Merged

scout/forge:scout/3272-a-test-fails-when-the-claude-code-or-ope → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/3272-a-test-fails-when-the-claude-code-or-ope was merged into main as 9ad7a23.

Review

Sign in to review this change.
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`
44 (an `rw` token; `ro` reads only), then add this directory as a plugin
55 (`claude --plugin-dir plugins/claude-code`). It registers the forge's MCP
66 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`
44 in `opencode.json`), and set `FORGE_URL` and `FORGE_TOKEN`. It registers the
55 forge's MCP server and the `forge` command: `tell`, `status`, `decide`.
66 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 {
32783278 const NOT_HERE = (allow: string): Response =>
32793279 new Response(null, { status: 405, headers: { allow } });
32803280
3281 /** Every tool name the forge lists, admin-only ones included. */
3282 export const toolNames = (): string[] => [...TOOLS.map((t) => t.name), REPORT_TOOL.name];

Minor: tools/list hides adminOnly tools 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.

3283
32813284 export function mcpRoutes(router: Router<Handler>): void {
32823285 router.add("POST", "/mcp", async (ctx) => {
32833286 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs