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:
5
.changeset/fn-2703-plan-generation-hang.md
Normal file
5
.changeset/fn-2703-plan-generation-hang.md
Normal 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.
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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", () => ({
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user