feat(FN-2703): support custom MCP tools in plan generation

- Remove ToolSearch prerequisite so custom MCP tools can be used during triage and plan generation flows
- Align built-in tool sets between provider wiring and prompt builder handling, including custom ls behavior
- Expand pi-claude-cli tests for event bridge, MCP config, prompt builder, and tool mapping regressions
- Add FN-2703 changeset and delivery documentation for the published @runfusion/fusion package
This commit is contained in:
Fusion
2026-04-27 09:51:49 -07:00
committed by gsxdsm
parent b67d409480
commit fed1d6975a
8 changed files with 141 additions and 16 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix pi-claude-cli planning hangs by simplifying custom MCP tool guidance to direct `mcp__custom-tools__*` calls (no `ToolSearch` prerequisite), aligning custom-tool handling diagnostics, and adding regression coverage for `ls`/triage MCP tool mapping behavior.

View File

@@ -27,6 +27,12 @@ process.on("exit", killAllProcesses);
const PROVIDER_ID = "pi-claude-cli"; const PROVIDER_ID = "pi-claude-cli";
let cachedMcpConfig: { hash: string; configPath: string } | undefined; let cachedMcpConfig: { hash: string; configPath: string } | undefined;
const DEBUG_MCP = process.env.PI_CLAUDE_CLI_DEBUG === "1";
function debugMcp(message: string): void {
if (!DEBUG_MCP) return;
console.error(`[pi-claude-cli] ${message}`);
}
/** /**
* Resolve the MCP config path for the current request, regenerating it when * Resolve the MCP config path for the current request, regenerating it when
@@ -61,6 +67,11 @@ function ensureMcpConfig(
): string | undefined { ): string | undefined {
try { try {
let toolDefs: McpToolDef[] = toolsFromContext(contextTools); let toolDefs: McpToolDef[] = toolsFromContext(contextTools);
if (contextTools && contextTools.length > 0) {
debugMcp(
`MCP config from context.tools: ${contextTools.map((tool) => tool.name).join(", ")}`,
);
}
// Fallback to the pi runtime registry if the context didn't carry tools. // Fallback to the pi runtime registry if the context didn't carry tools.
// (Older agent-loop versions don't populate Context.tools for streamSimple.) // (Older agent-loop versions don't populate Context.tools for streamSimple.)
@@ -83,6 +94,7 @@ function ensureMcpConfig(
.slice(0, 12); .slice(0, 12);
if (cachedMcpConfig?.hash === hash) { if (cachedMcpConfig?.hash === hash) {
debugMcp(`MCP config cache hit (hash=${hash})`);
return cachedMcpConfig.configPath; return cachedMcpConfig.configPath;
} }

View File

@@ -970,6 +970,46 @@ describe("createEventBridge", () => {
// Custom tool args pass through unchanged (no argument renames for MCP tools) // Custom tool args pass through unchanged (no argument renames for MCP tools)
expect(event.toolCall.arguments).toEqual({ target: "prod" }); expect(event.toolCall.arguments).toEqual({ target: "prod" });
}); });
it("handles MCP-prefixed triage tool names", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "toolu_triage1",
name: "mcp__custom-tools__fn_task_list",
},
});
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
bridge.handleEvent({
type: "content_block_start",
index: 1,
content_block: {
type: "tool_use",
id: "toolu_triage2",
name: "mcp__custom-tools__fn_review_spec",
},
});
bridge.handleEvent({
type: "content_block_stop",
index: 1,
});
const output = bridge.getOutput();
const calls = output.content.filter((c: any) => c.type === "toolCall") as any[];
expect(calls.map((call) => call.name)).toEqual([
"fn_task_list",
"fn_review_spec",
]);
expect(calls[0].arguments).toEqual({});
expect(calls[1].arguments).toEqual({});
});
}); });
describe("internal Claude Code tools are filtered", () => { describe("internal Claude Code tools are filtered", () => {

View File

@@ -16,7 +16,7 @@ vi.mock("node:os", () => ({
tmpdir: mocks.tmpdir, tmpdir: mocks.tmpdir,
})); }));
import { getCustomToolDefs, writeMcpConfig } from "../mcp-config"; import { getCustomToolDefs, writeMcpConfig, toolsFromContext } from "../mcp-config";
import type { McpToolDef } from "../mcp-config"; import type { McpToolDef } from "../mcp-config";
describe("getCustomToolDefs", () => { describe("getCustomToolDefs", () => {
@@ -171,6 +171,22 @@ describe("getCustomToolDefs", () => {
}); });
}); });
describe("toolsFromContext", () => {
it("keeps ls as a custom tool (not filtered as built-in)", () => {
const defs = toolsFromContext([
{ name: "read", description: "builtin", parameters: { type: "object" } },
{ name: "ls", description: "list files", parameters: { type: "object" } },
{
name: "fn_task_list",
description: "list tasks",
parameters: { type: "object", properties: {} },
},
]);
expect(defs.map((d) => d.name)).toEqual(["ls", "fn_task_list"]);
});
});
describe("writeMcpConfig", () => { describe("writeMcpConfig", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -949,6 +949,47 @@ describe("buildSystemPrompt", () => {
expect(result).toContain("mcp__custom-tools__fn_review_spec"); expect(result).toContain("mcp__custom-tools__fn_review_spec");
}); });
it("treats ls as custom and rewrites to mcp__custom-tools__ls", async () => {
vi.resetModules();
vi.doMock("node:fs", () => ({
existsSync: () => false,
readFileSync: () => "",
}));
const { buildSystemPrompt: bsp } = await import("../prompt-builder");
const context = {
systemPrompt: "List files with ls, then continue.",
messages: [],
tools: [
{ name: "ls", description: "list directory", parameters: {} },
{ name: "read", description: "builtin", parameters: {} },
],
} as unknown as any;
const result = bsp(context, "/some/project");
expect(result).toContain("mcp__custom-tools__ls");
expect(result).not.toContain("mcp__custom-tools__read");
});
it("custom tools addendum instructs direct MCP calls without ToolSearch prerequisite", async () => {
vi.resetModules();
vi.doMock("node:fs", () => ({
existsSync: () => false,
readFileSync: () => "",
}));
const { buildSystemPrompt: bsp } = await import("../prompt-builder");
const context = {
systemPrompt: "Use tools as needed.",
messages: [],
tools: [{ name: "fn_task_list", description: "list", parameters: {} }],
} as unknown as any;
const result = bsp(context, "/some/project");
expect(result).toContain("mcp__custom-tools__fn_task_list");
expect(result).not.toContain("ToolSearch");
});
it("does not rewrite identifier substrings that happen to overlap a tool name", async () => { it("does not rewrite identifier substrings that happen to overlap a tool name", async () => {
vi.resetModules(); vi.resetModules();
vi.doMock("node:fs", () => ({ vi.doMock("node:fs", () => ({

View File

@@ -228,6 +228,7 @@ describe("tool-mapping", () => {
it("returns true for custom tool names", () => { it("returns true for custom tool names", () => {
expect(isCustomToolName("myTool")).toBe(true); expect(isCustomToolName("myTool")).toBe(true);
expect(isCustomToolName("deploy")).toBe(true); expect(isCustomToolName("deploy")).toBe(true);
expect(isCustomToolName("ls")).toBe(true);
}); });
it("returns false for all 6 built-in tool names", () => { it("returns false for all 6 built-in tool names", () => {

View File

@@ -425,7 +425,6 @@ const BUILT_IN_PI_TOOLS = new Set([
"bash", "bash",
"grep", "grep",
"find", "find",
"ls",
]); ]);
/** /**
@@ -469,10 +468,10 @@ function rewriteCustomToolReferences(
/** /**
* Build a system-prompt addendum that maps each custom pi tool to its * Build a system-prompt addendum that maps each custom pi tool to its
* MCP-exposed name (`mcp__custom-tools__<name>`) and explains Claude Code's * MCP-exposed name (`mcp__custom-tools__<name>`) and tells Claude to call
* deferred-tool protocol. Without this, the model sees instructions like * those names directly. We intentionally avoid a ToolSearch prerequisite:
* "call fn_review_spec()" but only finds `mcp__custom-tools__fn_review_spec` * requiring an internal discovery step can send the model into long internal
* via ToolSearch — and may report "tool not found" or skip the call. * tool loops before it emits actionable pi tool calls.
* *
* Returns an empty string when there are no custom tools so the addendum * Returns an empty string when there are no custom tools so the addendum
* doesn't pollute prompts on plain chat sessions with only built-ins. * doesn't pollute prompts on plain chat sessions with only built-ins.
@@ -491,13 +490,11 @@ function buildCustomToolsAddendum(
.map((name) => `- \`${name}\` is exposed as \`mcp__custom-tools__${name}\``); .map((name) => `- \`${name}\` is exposed as \`mcp__custom-tools__${name}\``);
return [ return [
"## Custom tool naming (Claude Code deferred-tools protocol)", "## Custom tool naming (MCP)",
"", "",
"The following pi extension tools are available, but Claude Code exposes", "The following pi extension tools are available under MCP-prefixed",
"them under MCP-prefixed names. When a system prompt or task instruction", "names. When a system prompt or task instruction asks you to call one",
"asks you to call one of these by its short name, use the MCP-prefixed", "of these by its short name, call the MCP-prefixed name directly.",
"name instead. Their schemas are deferred — call `ToolSearch` with",
'`select:mcp__custom-tools__<name>` first, then call the tool directly.',
"", "",
...lines, ...lines,
].join("\n"); ].join("\n");

View File

@@ -61,6 +61,12 @@ import { isPiKnownClaudeTool } from "./tool-mapping.js";
* arrives (e.g. someone embeds pi-claude-cli without a stuck detector). * arrives (e.g. someone embeds pi-claude-cli without a stuck detector).
*/ */
const INACTIVITY_TIMEOUT_MS = 30 * 60_000; const INACTIVITY_TIMEOUT_MS = 30 * 60_000;
const DEBUG_STREAM = process.env.PI_CLAUDE_CLI_DEBUG === "1";
function debugLog(message: string): void {
if (!DEBUG_STREAM) return;
console.error(`[pi-claude-cli] ${message}`);
}
/** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */ /** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */
type StreamViaCLiOptions = SimpleStreamOptions & { type StreamViaCLiOptions = SimpleStreamOptions & {
@@ -267,10 +273,16 @@ export function streamViaCli(
msg.event.content_block?.type === "tool_use" msg.event.content_block?.type === "tool_use"
) { ) {
const toolName = msg.event.content_block.name; const toolName = msg.event.content_block.name;
if (toolName && isPiKnownClaudeTool(toolName)) { if (toolName) {
// Built-in tool (Read/Write/etc.) OR custom MCP tool (mcp__custom-tools__*) const piKnownTool = isPiKnownClaudeTool(toolName);
// Internal Claude Code tools (ToolSearch, Task, etc.) are excluded debugLog(
sawBuiltInOrCustomTool = true; `top-level tool_use seen: ${toolName} (piKnown=${piKnownTool ? "yes" : "no"})`,
);
if (piKnownTool) {
// Built-in tool (Read/Write/etc.) OR custom MCP tool (mcp__custom-tools__*)
// Internal Claude Code tools (ToolSearch, Task, etc.) are excluded
sawBuiltInOrCustomTool = true;
}
} }
} }
@@ -281,6 +293,7 @@ export function streamViaCli(
msg.event.type === "message_stop" && msg.event.type === "message_stop" &&
sawBuiltInOrCustomTool sawBuiltInOrCustomTool
) { ) {
debugLog("break-early triggered at message_stop after pi-known tool_use");
broken = true; // Set guard BEFORE rl.close() to prevent buffered lines broken = true; // Set guard BEFORE rl.close() to prevent buffered lines
clearTimeout(inactivityTimer); clearTimeout(inactivityTimer);
// Pi will execute these tools. Kill subprocess to prevent CLI from executing them. // Pi will execute these tools. Kill subprocess to prevent CLI from executing them.