fix: regenerate pi-claude-cli MCP config per session and auto-sync skill tools
The MCP config was generated lazily once and locked, so engine session-scoped tools (fn_review_spec, fn_review_step) never reached the Claude CLI subprocess and triage/executor sessions failed with "unknown tool" errors. Now the config is hashed per call and rewritten when the tool set changes. Also adds scripts/sync-fusion-skill-tools.mjs to regenerate the SKILL.md tool-categories block from extension.ts at build time, with a --check mode wired into skill-sync tests so drift fails CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,8 @@
|
||||
"dev:ui": "pnpm --filter @fusion/dashboard dev",
|
||||
"dev:hmr": "node scripts/dev-hmr.mjs",
|
||||
"lint": "eslint .",
|
||||
"sync:fusion-skill": "node scripts/sync-fusion-skill-tools.mjs",
|
||||
"sync:fusion-skill:check": "node scripts/sync-fusion-skill-tools.mjs --check",
|
||||
"build": "pnpm -r build",
|
||||
"verify:workspace": "pnpm lint && pnpm test && pnpm build",
|
||||
"build:exe": "pnpm build && pnpm --filter @runfusion/fusion build:exe",
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsx src/bin.ts",
|
||||
"prebuild": "node ../../scripts/sync-fusion-skill-tools.mjs",
|
||||
"build": "tsup",
|
||||
"build:exe": "bun run build.ts",
|
||||
"build:exe:all": "bun run build.ts --all",
|
||||
|
||||
@@ -24,11 +24,13 @@ Mission → Milestone → Slice → Feature → Task
|
||||
**Naming boundary:** The published skill surface always uses `fn_*` tool names (for example `fn_task_create`, `fn_mission_create`). Internal engine runtime tools like `task_create`, `task_update`, `task_log`, and `task_done` are intentionally unprefixed and not part of this skill.
|
||||
|
||||
**Tool categories:**
|
||||
<!-- BEGIN: tool-categories (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->
|
||||
- **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_plan`
|
||||
- **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues`
|
||||
- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_delete`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_slice_activate`, `fn_feature_link_task`
|
||||
- **Agent tools** — `fn_agent_stop`, `fn_agent_start`
|
||||
- **Skills tools** — `fn_skills_search`, `fn_skills_install`
|
||||
<!-- END: tool-categories -->
|
||||
- **Dashboard** — Use `/fn` command to start/stop the dashboard
|
||||
|
||||
</essential_principles>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
||||
import { resolve, dirname, relative, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const cliRoot = resolve(__dirname, "../..");
|
||||
@@ -194,6 +195,19 @@ describe("Skill-Extension Sync", () => {
|
||||
expect(dashboardCli).toContain("/fn");
|
||||
});
|
||||
|
||||
it("SKILL.md tool-categories block matches the sync script output (no drift)", () => {
|
||||
const repoRoot = resolve(cliRoot, "../..");
|
||||
const script = resolve(repoRoot, "scripts/sync-fusion-skill-tools.mjs");
|
||||
const result = spawnSync("node", [script, "--check"], {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`sync-fusion-skill-tools --check failed:\n${result.stderr || result.stdout}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("package.json includes skills in pi config and files array", () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(resolve(cliRoot, "package.json"), "utf-8"),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
validateCliAuth,
|
||||
killAllProcesses,
|
||||
} from "./src/process-manager.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { getCustomToolDefs, writeMcpConfig } from "./src/mcp-config.js";
|
||||
|
||||
// Kill all active Claude subprocesses on process exit to prevent orphans
|
||||
@@ -20,49 +21,63 @@ process.on("exit", killAllProcesses);
|
||||
|
||||
const PROVIDER_ID = "pi-claude-cli";
|
||||
|
||||
let mcpConfigPath: string | undefined;
|
||||
let mcpConfigResolved = false;
|
||||
let cachedMcpConfig: { hash: string; configPath: string } | undefined;
|
||||
|
||||
/**
|
||||
* Lazily generate MCP config on first request (not at load time).
|
||||
* pi.getAllTools() fails during extension loading; this defers it
|
||||
* until the pi runtime is fully initialized.
|
||||
* Resolve the MCP config path for the current request, regenerating it when
|
||||
* the set of custom tools changes.
|
||||
*
|
||||
* Only locks (sets mcpConfigResolved) when getAllTools() returns a
|
||||
* real array — if it returns undefined/null (registry not ready),
|
||||
* we retry on the next request. Once the registry is ready we
|
||||
* commit to the result even if there are zero custom tools.
|
||||
* Why per-call instead of once-and-lock:
|
||||
* - The engine registers session-scoped custom tools (e.g. `fn_review_spec`,
|
||||
* `fn_review_step`) when it spawns triage/executor sessions. These appear
|
||||
* in `pi.getAllTools()` only while that session is active.
|
||||
* - A locked-on-first-call cache would freeze in the global tool set and
|
||||
* silently drop session tools, so the Claude CLI subprocess would refuse
|
||||
* to call them ("unknown tool fn_review_spec").
|
||||
* - Hashing the tool defs lets us reuse the same temp files when the tool
|
||||
* set is unchanged across calls, and produce fresh files (with the hash
|
||||
* in the filename to avoid races) when it changes.
|
||||
*
|
||||
* Uses warn-don't-block: failure logs a warning but does not
|
||||
* prevent the provider from functioning (built-ins still work).
|
||||
* Uses warn-don't-block: failure logs a warning but does not prevent the
|
||||
* provider from functioning (built-ins still work).
|
||||
*/
|
||||
function ensureMcpConfig(pi: ExtensionAPI): string | undefined {
|
||||
if (mcpConfigResolved) return mcpConfigPath;
|
||||
try {
|
||||
const allTools = pi.getAllTools();
|
||||
|
||||
// Registry not ready yet — don't lock, retry on next call
|
||||
// Registry not ready yet — fall back to whatever we last computed (if any)
|
||||
if (!Array.isArray(allTools)) {
|
||||
return mcpConfigPath;
|
||||
return cachedMcpConfig?.configPath;
|
||||
}
|
||||
|
||||
// Registry is ready — lock regardless of whether custom tools exist
|
||||
mcpConfigResolved = true;
|
||||
|
||||
const toolDefs = getCustomToolDefs(pi);
|
||||
if (toolDefs.length > 0) {
|
||||
mcpConfigPath = writeMcpConfig(toolDefs);
|
||||
console.error(
|
||||
`[pi-claude-cli] MCP config generated with ${toolDefs.length} custom tool(s)`,
|
||||
);
|
||||
if (toolDefs.length === 0) {
|
||||
cachedMcpConfig = undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hash = createHash("sha1")
|
||||
.update(JSON.stringify(toolDefs))
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
|
||||
if (cachedMcpConfig?.hash === hash) {
|
||||
return cachedMcpConfig.configPath;
|
||||
}
|
||||
|
||||
const configPath = writeMcpConfig(toolDefs, hash);
|
||||
cachedMcpConfig = { hash, configPath };
|
||||
console.error(
|
||||
`[pi-claude-cli] MCP config refreshed with ${toolDefs.length} custom tool(s) (hash=${hash})`,
|
||||
);
|
||||
return configPath;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[pi-claude-cli] MCP config generation failed, custom tools unavailable:",
|
||||
err,
|
||||
);
|
||||
return cachedMcpConfig?.configPath;
|
||||
}
|
||||
return mcpConfigPath;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
|
||||
@@ -269,4 +269,26 @@ describe("writeMcpConfig", () => {
|
||||
expect(result).toMatch(/pi-claude-mcp-config/);
|
||||
expect(result).toMatch(/\.json$/);
|
||||
});
|
||||
|
||||
it("includes cacheKey in filenames when provided so distinct tool sets do not collide", () => {
|
||||
const toolDefs: McpToolDef[] = [
|
||||
{
|
||||
name: "search",
|
||||
description: "Search",
|
||||
inputSchema: { type: "object" },
|
||||
},
|
||||
];
|
||||
|
||||
const pathA = writeMcpConfig(toolDefs, "aaaaaaaaaaaa");
|
||||
const pathB = writeMcpConfig(toolDefs, "bbbbbbbbbbbb");
|
||||
|
||||
expect(pathA).toContain("aaaaaaaaaaaa");
|
||||
expect(pathB).toContain("bbbbbbbbbbbb");
|
||||
expect(pathA).not.toBe(pathB);
|
||||
|
||||
const schemaPathA = mocks.writeFileSync.mock.calls[0][0];
|
||||
const schemaPathB = mocks.writeFileSync.mock.calls[2][0];
|
||||
expect(schemaPathA).toContain("aaaaaaaaaaaa");
|
||||
expect(schemaPathB).toContain("bbbbbbbbbbbb");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,13 +75,21 @@ export function getCustomToolDefs(pi: PiInstance): McpToolDef[] {
|
||||
* 2. Config file: MCP config pointing to the schema-only server
|
||||
*
|
||||
* @param toolDefs - Array of custom tool definitions
|
||||
* @param cacheKey - Optional suffix appended to filenames so that distinct
|
||||
* tool sets (e.g. session-scoped tool registrations) get distinct files
|
||||
* and don't race on a single shared path.
|
||||
* @returns Path to the MCP config file
|
||||
*/
|
||||
export function writeMcpConfig(toolDefs: McpToolDef[]): string {
|
||||
export function writeMcpConfig(
|
||||
toolDefs: McpToolDef[],
|
||||
cacheKey?: string,
|
||||
): string {
|
||||
const suffix = cacheKey ? `${process.pid}-${cacheKey}` : `${process.pid}`;
|
||||
|
||||
// Write tool schemas to temp file
|
||||
const schemaFilePath = join(
|
||||
tmpdir(),
|
||||
`pi-claude-mcp-schemas-${process.pid}.json`,
|
||||
`pi-claude-mcp-schemas-${suffix}.json`,
|
||||
);
|
||||
writeFileSync(schemaFilePath, JSON.stringify(toolDefs));
|
||||
|
||||
@@ -103,7 +111,7 @@ export function writeMcpConfig(toolDefs: McpToolDef[]): string {
|
||||
// Write config to temp file
|
||||
const configFilePath = join(
|
||||
tmpdir(),
|
||||
`pi-claude-mcp-config-${process.pid}.json`,
|
||||
`pi-claude-mcp-config-${suffix}.json`,
|
||||
);
|
||||
writeFileSync(configFilePath, JSON.stringify(config));
|
||||
|
||||
|
||||
141
scripts/sync-fusion-skill-tools.mjs
Normal file
141
scripts/sync-fusion-skill-tools.mjs
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Regenerates the `tool-categories` block in the Fusion skill SKILL.md from
|
||||
* the canonical list of `fn_*` tools registered in `packages/cli/src/extension.ts`.
|
||||
*
|
||||
* Source of truth: extension.ts (each tool is registered with `name: "fn_..."`).
|
||||
* Output: the `<!-- BEGIN: tool-categories ... -->` / `<!-- END: tool-categories -->`
|
||||
* block inside `packages/cli/skill/fusion/SKILL.md`.
|
||||
*
|
||||
* The `.claude/skills/fusion` path is a symlink to `packages/cli/skill/fusion`,
|
||||
* so it picks up the change automatically.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/sync-fusion-skill-tools.mjs # rewrite SKILL.md in place
|
||||
* node scripts/sync-fusion-skill-tools.mjs --check # exit 1 if drift detected
|
||||
*
|
||||
* Wired into `packages/cli` build via the `prebuild` script so a fresh build
|
||||
* always emits an up-to-date skill, and CI catches drift via `--check`.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "..");
|
||||
const extensionPath = resolve(repoRoot, "packages/cli/src/extension.ts");
|
||||
const skillPath = resolve(repoRoot, "packages/cli/skill/fusion/SKILL.md");
|
||||
|
||||
const BEGIN_MARKER =
|
||||
"<!-- BEGIN: tool-categories (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->";
|
||||
const END_MARKER = "<!-- END: tool-categories -->";
|
||||
|
||||
/**
|
||||
* Categorize a tool name into one of the published skill categories.
|
||||
* The order of categories here defines the order of bullets in SKILL.md.
|
||||
*/
|
||||
function categorize(name) {
|
||||
if (name.includes("github")) return "GitHub";
|
||||
if (name.startsWith("fn_task_")) return "Task";
|
||||
if (
|
||||
name.startsWith("fn_mission_") ||
|
||||
name.startsWith("fn_milestone_") ||
|
||||
name.startsWith("fn_slice_") ||
|
||||
name.startsWith("fn_feature_")
|
||||
) {
|
||||
return "Mission";
|
||||
}
|
||||
if (name.startsWith("fn_agent_")) return "Agent";
|
||||
if (name.startsWith("fn_skills_")) return "Skills";
|
||||
return "Other";
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER = ["Task", "GitHub", "Mission", "Agent", "Skills", "Other"];
|
||||
const CATEGORY_LABELS = {
|
||||
Task: "Task tools",
|
||||
GitHub: "GitHub tools",
|
||||
Mission: "Mission tools",
|
||||
Agent: "Agent tools",
|
||||
Skills: "Skills tools",
|
||||
Other: "Other tools",
|
||||
};
|
||||
|
||||
function extractToolNames(source) {
|
||||
const names = [];
|
||||
for (const match of source.matchAll(/name:\s*"(fn_[a-z_]+)"/g)) {
|
||||
names.push(match[1]);
|
||||
}
|
||||
if (names.length === 0) {
|
||||
throw new Error(
|
||||
`No fn_* tool registrations found in ${extensionPath} — refusing to wipe the skill.`,
|
||||
);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function buildBlock(toolNames) {
|
||||
const grouped = new Map(CATEGORY_ORDER.map((c) => [c, []]));
|
||||
for (const name of toolNames) {
|
||||
grouped.get(categorize(name)).push(name);
|
||||
}
|
||||
const lines = [BEGIN_MARKER];
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const tools = grouped.get(category);
|
||||
if (tools.length === 0) continue;
|
||||
const formatted = tools.map((t) => `\`${t}\``).join(", ");
|
||||
lines.push(`- **${CATEGORY_LABELS[category]}** — ${formatted}`);
|
||||
}
|
||||
lines.push(END_MARKER);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function replaceBlock(skillContent, newBlock) {
|
||||
const beginIdx = skillContent.indexOf(BEGIN_MARKER);
|
||||
const endIdx = skillContent.indexOf(END_MARKER);
|
||||
if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
|
||||
throw new Error(
|
||||
`Could not find BEGIN/END tool-categories markers in ${skillPath}. ` +
|
||||
`Add them around the tool-categories bullet list.`,
|
||||
);
|
||||
}
|
||||
const endLineEnd = endIdx + END_MARKER.length;
|
||||
return (
|
||||
skillContent.slice(0, beginIdx) + newBlock + skillContent.slice(endLineEnd)
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const checkOnly = process.argv.includes("--check");
|
||||
|
||||
const source = readFileSync(extensionPath, "utf-8");
|
||||
const skillContent = readFileSync(skillPath, "utf-8");
|
||||
|
||||
const toolNames = extractToolNames(source);
|
||||
const newBlock = buildBlock(toolNames);
|
||||
const updated = replaceBlock(skillContent, newBlock);
|
||||
|
||||
if (updated === skillContent) {
|
||||
if (!checkOnly) {
|
||||
console.log(
|
||||
`[sync-fusion-skill-tools] SKILL.md tool-categories block is already up to date (${toolNames.length} tools).`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (checkOnly) {
|
||||
console.error(
|
||||
`[sync-fusion-skill-tools] SKILL.md tool-categories block is out of date.\n` +
|
||||
`Run \`node scripts/sync-fusion-skill-tools.mjs\` to regenerate it.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
writeFileSync(skillPath, updated);
|
||||
console.log(
|
||||
`[sync-fusion-skill-tools] Rewrote tool-categories block in ${skillPath} (${toolNames.length} tools).`,
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user