From 5af53bc94a66a1662d956c1cd1994f5390d14d14 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 01:07:14 -0700 Subject: [PATCH] FN-8422: fix Fusion MCP bridge packaging and model markers Ensure Grok and Claude ACP sessions report usable custom-tool bridge outcomes and model labels. - Copy the MCP schema server into runtime build output and fail custom-tool setup with stable diagnostics. - Record bridge failure metadata safely and normalize ACP string model markers. - Add bridge smoke coverage, adapter regressions, documentation, and a patch changeset. Files changed: .changeset/fn-8422-mcp-bridge.md | 7 ++++ docs/grok-cli-contract.md | 8 +++++ .../src/__tests__/agent-session-helpers.test.ts | 37 ++++++++++++++++++++++ packages/engine/src/__tests__/pi.test.ts | 16 ++++++++++ packages/engine/src/agent-session-helpers.ts | 20 ++++++++++++ packages/engine/src/pi.ts | 27 ++++++++++++++-- plugins/fusion-plugin-claude-runtime/README.md | 6 ++++ plugins/fusion-plugin-claude-runtime/package.json | 2 +- .../src/__tests__/runtime-adapter.test.ts | 30 ++++++++++++++++++ .../src/__tests__/tool-bridge.test.ts | 33 ++++++++++++++++++- .../src/runtime-adapter.ts | 30 ++++++++++++++++-- .../src/tool-bridge.ts | 24 +++++++++++--- plugins/fusion-plugin-claude-runtime/src/types.ts | 2 ++ plugins/fusion-plugin-grok-runtime/package.json | 2 +- .../src/__tests__/runtime-adapter.test.ts | 24 ++++++++++++++ .../src/__tests__/tool-bridge.test.ts | 33 ++++++++++++++++++- .../src/runtime-adapter.ts | 30 ++++++++++++++++-- .../fusion-plugin-grok-runtime/src/tool-bridge.ts | 24 +++++++++++--- plugins/fusion-plugin-grok-runtime/src/types.ts | 2 ++ 19 files changed, 338 insertions(+), 19 deletions(-) Fusion-Task-Id: FN-8422 Fusion-Task-Lineage: 6738f1bd-23f7-4fd3-8490-178d956b362d Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8422-mcp-bridge.md | 7 ++++ docs/grok-cli-contract.md | 8 ++++ .../__tests__/agent-session-helpers.test.ts | 37 +++++++++++++++++++ packages/engine/src/__tests__/pi.test.ts | 16 ++++++++ packages/engine/src/agent-session-helpers.ts | 20 ++++++++++ packages/engine/src/pi.ts | 27 ++++++++++++-- .../fusion-plugin-claude-runtime/README.md | 6 +++ .../fusion-plugin-claude-runtime/package.json | 2 +- .../src/__tests__/runtime-adapter.test.ts | 30 +++++++++++++++ .../src/__tests__/tool-bridge.test.ts | 33 ++++++++++++++++- .../src/runtime-adapter.ts | 30 ++++++++++++++- .../src/tool-bridge.ts | 24 ++++++++++-- .../fusion-plugin-claude-runtime/src/types.ts | 2 + .../fusion-plugin-grok-runtime/package.json | 2 +- .../src/__tests__/runtime-adapter.test.ts | 24 ++++++++++++ .../src/__tests__/tool-bridge.test.ts | 33 ++++++++++++++++- .../src/runtime-adapter.ts | 30 ++++++++++++++- .../src/tool-bridge.ts | 24 ++++++++++-- .../fusion-plugin-grok-runtime/src/types.ts | 2 + 19 files changed, 338 insertions(+), 19 deletions(-) create mode 100644 .changeset/fn-8422-mcp-bridge.md diff --git a/.changeset/fn-8422-mcp-bridge.md b/.changeset/fn-8422-mcp-bridge.md new file mode 100644 index 0000000000..4faf1ab3bb --- /dev/null +++ b/.changeset/fn-8422-mcp-bridge.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Grok and Claude Fusion tools MCP bridge packaging and model markers +category: fix +dev: Co-locate mcp-schema-server.cjs, report fixed bridge outcomes, and normalize ACP model markers. diff --git a/docs/grok-cli-contract.md b/docs/grok-cli-contract.md index ab9ee76695..b78451e7b9 100644 --- a/docs/grok-cli-contract.md +++ b/docs/grok-cli-contract.md @@ -71,6 +71,14 @@ mcpServers through createResolvedAgentSession. Grok ACP must not drop them. 2. **Fusion custom tools (`fn_*`)** — engine `customTools` are hosted by a loopback HTTP bridge + stdio MCP server (`mcp-schema-server.cjs`) named `fusion-custom-tools`. Grok invokes tools via real MCP `tools/call`; the bridge runs `ToolDefinition.execute` in-process. Dashboard chat and room responders include the same safe coordination/productivity tools as their pi-shaped sessions: board discovery, task creation and delegation, agent discovery/configuration, web fetch, and goal/memory/research retrieval; destructive agent-lifecycle tools remain excluded from chat. 3. **Skills** — the bundled Fusion skill (`packages/cli/skill/fusion`) plus any `additionalSkillPaths` skill roots are staged into a temp plugin directory and loaded via `grok agent --plugin-dir` and `_meta.pluginDirs`. Requested skill names and tool counts are also written into `_meta.rules` / system prompt context. +### Fusion tool bridge packaging and diagnostics + +`fusion-custom-tools` starts `mcp-schema-server.cjs` next to the loaded tool-bridge module. The asset is committed beside `src/tool-bridge.ts` for source-loaded plugins and the plugin `build` script copies it beside `dist/tool-bridge.js` for dist-loaded plugins. A missing co-located asset otherwise appears at the host as `handshake failed: connection closed: initialize response` during MCP initialize. + +When custom Fusion tools were requested but the bridge cannot start, the session emits `FUSION_TOOL_BRIDGE_FAILED: mcp-schema-server-missing` or `FUSION_TOOL_BRIDGE_FAILED: bridge-start-failed`. It deliberately omits the broken MCP server entry, and the engine records `fusionToolBridgeFailed`, its fixed reason code, and a requested-tool count on the ids-only `session:runtime-resolved` run-audit event. Paths, schemas, error prose, and credentials are not persisted there. + +Grok ACP sessions store a string model plus `lastModelDescription`; lane markers normalize this to `grok/` (for example `grok/grok-4.5`) before appending thinking metadata, never `undefined/undefined`. + ### Session lifecycle 1. `createSession` — spawn `grok agent stdio`, ACP `initialize`, `session/new` over the task cwd. diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index 96efd3dd98..1ba93f6e17 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -692,6 +692,43 @@ describe("createResolvedAgentSession", () => { }); }); + it("records an ids-only bridge failure outcome in session:runtime-resolved", async () => { + const auditDatabaseMock = vi.fn().mockResolvedValue(undefined); + resolveRuntimeMock.mockResolvedValue({ + runtime: { + id: "grok", + name: "Grok Runtime", + createSession: vi.fn().mockResolvedValue({ + session: { fusionToolBridgeError: { reasonCode: "mcp-schema-server-missing" } }, + }), + promptWithFallback: vi.fn(), + describeModel: vi.fn(() => "grok/grok-4.5"), + }, + runtimeId: "grok", + wasConfigured: true, + }); + const { createResolvedAgentSession } = await import("../agent-session-helpers.js"); + + await createResolvedAgentSession({ + sessionPurpose: "triage", + cwd: "/tmp/project", + systemPrompt: "system", + defaultProvider: "xai", + defaultModelId: "grok-4.5", + customTools: [{ name: "fn_task_list", description: "", parameters: {}, execute: async () => ({}) }] as any, + runAuditor: { database: auditDatabaseMock } as any, + }); + + expect(auditDatabaseMock).toHaveBeenCalledWith(expect.objectContaining({ + type: "session:runtime-resolved", + metadata: expect.objectContaining({ + fusionToolBridgeFailed: true, + fusionToolBridgeReasonCode: "mcp-schema-server-missing", + expectedToolCount: 1, + }), + })); + }); + it("succeeds when runAuditor is omitted", async () => { const mockSession = { prompt: vi.fn() } as any; const createSessionMock = vi.fn().mockResolvedValue({ diff --git a/packages/engine/src/__tests__/pi.test.ts b/packages/engine/src/__tests__/pi.test.ts index 740025443e..b5e49441ca 100644 --- a/packages/engine/src/__tests__/pi.test.ts +++ b/packages/engine/src/__tests__/pi.test.ts @@ -130,6 +130,22 @@ describe("describeModel", () => { expect(describeModel(fakeSession)).toBe("anthropic/claude-sonnet-4-5"); }); + it("uses ACP lastModelDescription for string-shaped Grok sessions", () => { + const fakeSession = { + model: "grok-4.5", + lastModelDescription: "grok/grok-4.5", + } as unknown as AgentSession; + + expect(describeModel(fakeSession)).toBe("grok/grok-4.5"); + expect(formatModelMarkerDetails(describeModel(fakeSession), "low")).toBe( + "grok/grok-4.5 (thinking effort: low)", + ); + }); + + it("uses a string model when ACP did not supply a description", () => { + expect(describeModel({ model: "claude/sonnet" } as unknown as AgentSession)).toBe("claude/sonnet"); + }); + it('returns "unknown model" when session model is undefined', () => { const fakeSession = { model: undefined, diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index 0886dcf9e0..c28bb93339 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -800,6 +800,11 @@ export async function createResolvedAgentSession( const noModelResolved = !mockProviderActive && !testModeActive && (!runtimeOptions.defaultProvider || !runtimeOptions.defaultModelId); const runtimeBuiltInFallbackModel = noModelResolved ? resolved.runtime.describeModel(result.session) : undefined; + const fusionToolBridgeError = (result.session as { fusionToolBridgeError?: { reasonCode?: unknown } }).fusionToolBridgeError; + const fusionToolBridgeReasonCode = fusionToolBridgeError?.reasonCode === "mcp-schema-server-missing" + || fusionToolBridgeError?.reasonCode === "bridge-start-failed" + ? fusionToolBridgeError.reasonCode + : undefined; if (noModelResolved) { /* FNXC:ModelResolution 2026-07-10-00:00: @@ -823,6 +828,21 @@ export async function createResolvedAgentSession( mockProviderActive, testModeActive, ...(noModelResolved ? { noModelResolved: true, runtimeBuiltInFallbackModel } : {}), + /* + FNXC:FusionToolBridgeDiagnostics 2026-07-20-08:00: + Plugin bridge failures are session-visible, but they also need one durable + engine signal. Record only fixed reason codes and requested counts here; + tool schemas, paths, error prose, and credentials must never enter run-audit. + */ + ...(fusionToolBridgeReasonCode + ? { + fusionToolBridgeFailed: true, + fusionToolBridgeReasonCode, + expectedToolCount: Array.isArray(effectiveRuntimeOptionsWithModel.customTools) + ? effectiveRuntimeOptionsWithModel.customTools.length + : 0, + } + : {}), ...(effectiveRuntimeHint ? { runtimeHint: effectiveRuntimeHint } : {}), ...(autoGrokRuntimeHint ? { reason: "grok-cli-no-visible-key" } : {}), ...(autoOmpRuntimeHint ? { reason: "omp-cli-runtime" } : {}), diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 8f546176c0..b60330625a 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -516,10 +516,31 @@ export async function promptWithFallback(session: AgentSession, prompt: string, * Returns `"/"` (e.g. `"anthropic/claude-sonnet-4-5"`) * or `"unknown model"` when the session has no model set. */ +/* +FNXC:MultiShapeModelMarker 2026-07-20-08:00: +Lane logs share this formatter across pi and ACP runtimes. Pi supplies a +{provider,id} model object, while Grok/ACP sessions carry a string model plus a +lastModelDescription; accept both so dashboard-parseable provider/model markers +never degrade to `undefined/undefined`. +*/ export function describeModel(session: AgentSession): string { - const model = session.model; - if (!model) return "unknown model"; - return `${model.provider}/${model.id}`; + const model = (session as { model?: unknown }).model; + if (model && typeof model === "object") { + const { provider, id } = model as { provider?: unknown; id?: unknown }; + if (typeof provider === "string" && provider.trim() && typeof id === "string" && id.trim()) { + return `${provider}/${id}`; + } + } + + const lastModelDescription = (session as { lastModelDescription?: unknown }).lastModelDescription; + if (typeof lastModelDescription === "string" && lastModelDescription.trim()) { + return lastModelDescription.trim(); + } + + if (typeof model === "string" && model.trim()) { + return model.trim(); + } + return "unknown model"; } /** diff --git a/plugins/fusion-plugin-claude-runtime/README.md b/plugins/fusion-plugin-claude-runtime/README.md index 34fd98e788..ccb96eaffd 100644 --- a/plugins/fusion-plugin-claude-runtime/README.md +++ b/plugins/fusion-plugin-claude-runtime/README.md @@ -5,3 +5,9 @@ The plugin uses the pinned `claude-code-cli-acp` bridge (`0.1.1`). CLI packaging stages its reviewed launcher beside the bundled plugin, while the published `@runfusion/fusion` dependency installs the matching optional native bridge for the operator's OS and CPU. The runtime never falls back to a same-named executable on `PATH`. This is additive to Fusion's experimental `pi-claude-cli` Route A. Route A remains available; selecting the `claude` runtime explicitly selects this first-class ACP transport. + +## Fusion custom-tools bridge + +The `fusion-custom-tools` MCP server launches `mcp-schema-server.cjs` beside the loaded bridge module. It is kept beside `src/` for source loading and copied beside `dist/` by `pnpm build`; do not remove that postbuild copy or dist-loaded sessions will fail initialize with `handshake failed: connection closed: initialize response`. + +When requested Fusion tools cannot start their bridge, the adapter emits `FUSION_TOOL_BRIDGE_FAILED: mcp-schema-server-missing` or `FUSION_TOOL_BRIDGE_FAILED: bridge-start-failed`, omits the unusable MCP entry, and the engine stores only the fixed outcome and tool count in `session:runtime-resolved` run audit metadata. diff --git a/plugins/fusion-plugin-claude-runtime/package.json b/plugins/fusion-plugin-claude-runtime/package.json index 194b2e4fe3..03f95b0f44 100644 --- a/plugins/fusion-plugin-claude-runtime/package.json +++ b/plugins/fusion-plugin-claude-runtime/package.json @@ -24,7 +24,7 @@ }, "private": true, "scripts": { - "build": "tsc", + "build": "tsc && node -e \"require('node:fs').copyFileSync('src/mcp-schema-server.cjs','dist/mcp-schema-server.cjs')\"", "test": "vitest run --silent=passed-only --reporter=dot" }, "dependencies": { diff --git a/plugins/fusion-plugin-claude-runtime/src/__tests__/runtime-adapter.test.ts b/plugins/fusion-plugin-claude-runtime/src/__tests__/runtime-adapter.test.ts index 8a84659e2b..e4bd739af5 100644 --- a/plugins/fusion-plugin-claude-runtime/src/__tests__/runtime-adapter.test.ts +++ b/plugins/fusion-plugin-claude-runtime/src/__tests__/runtime-adapter.test.ts @@ -1,7 +1,37 @@ import { describe, expect, it, vi } from "vitest"; import { ClaudeRuntimeAdapter } from "../runtime-adapter.js"; +import type { AgentSession } from "../types.js"; const options={cwd:"/tmp",systemPrompt:"",onText:vi.fn()}; describe("ClaudeRuntimeAdapter", () => { it("returns a visible diagnostic instead of rejecting on ACP create failure", async () => { const adapter=new ClaudeRuntimeAdapter({createAcpAdapter:()=>({createSession:async()=>{throw new Error("bridge unavailable")},promptWithFallback:async()=>undefined,describeModel:()=>"claude/default"})}); const result=await adapter.createSession(options); expect(result.session.state.errorMessage).toContain("Claude ACP failed"); expect(options.onText).toHaveBeenCalled(); }); it("returns a visible diagnostic for follow-up prompts without a live connection", async () => { const adapter=new ClaudeRuntimeAdapter({createAcpAdapter:()=>({createSession:async()=>{throw new Error("bridge unavailable")},promptWithFallback:async()=>undefined,describeModel:()=>"claude/default"})}); const {session}=await adapter.createSession(options); await adapter.promptWithFallback(session,"again"); expect(options.onText).toHaveBeenLastCalledWith(expect.stringContaining("no live connection")); }); }); + + +it("surfaces a fixed diagnostic and omits the broken MCP entry when the tool bridge fails", async () => { + let captured: Record | undefined; + const onText = vi.fn(); + const adapter = new ClaudeRuntimeAdapter({ + createAcpAdapter: () => ({ + createSession: async (sessionOptions) => { + captured = sessionOptions as Record; + return { session: { + model: "claude/default", messages: [], state: { messages: [] }, + lastModelDescription: "claude/default", callbacks: {}, connection: {}, dispose: vi.fn(), + } as AgentSession }; + }, + promptWithFallback: async () => undefined, + describeModel: () => "claude/default", + }), + startToolBridge: async () => { throw new Error("bind failed"); }, + }); + + const { session } = await adapter.createSession({ + cwd: "/tmp", systemPrompt: "", onText, + customTools: [{ name: "fn_task_list", execute: async () => ({}) }], + }); + + expect(onText).toHaveBeenCalledWith("FUSION_TOOL_BRIDGE_FAILED: bridge-start-failed"); + expect(session.fusionToolBridgeError).toEqual({ reasonCode: "bridge-start-failed" }); + expect((captured?.mcpServers as Array<{ name: string }>).some((server) => server.name === "fusion-custom-tools")).toBe(false); +}); diff --git a/plugins/fusion-plugin-claude-runtime/src/__tests__/tool-bridge.test.ts b/plugins/fusion-plugin-claude-runtime/src/__tests__/tool-bridge.test.ts index 7f7771cd4d..3faeb5ef7e 100644 --- a/plugins/fusion-plugin-claude-runtime/src/__tests__/tool-bridge.test.ts +++ b/plugins/fusion-plugin-claude-runtime/src/__tests__/tool-bridge.test.ts @@ -1,6 +1,10 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { request } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { startFusionToolBridge } from "../tool-bridge.js"; +import { fusionToolsMcpServerPath, startFusionToolBridge } from "../tool-bridge.js"; function bridgeEnv(bridge: NonNullable>>, name: string): string { if (!("env" in bridge.mcpServer)) throw new Error("custom tool bridge must use stdio MCP"); @@ -37,6 +41,33 @@ async function post(url: string, body: string, token?: string): Promise<{ status } describe("startFusionToolBridge", () => { + it("serves MCP initialize from the co-located schema server", async () => { + const directory = await mkdtemp(join(tmpdir(), "fusion-mcp-smoke-")); + const schemaPath = join(directory, "schemas.json"); + await writeFile(schemaPath, "[]"); + const child = spawn(process.execPath, [fusionToolsMcpServerPath(), schemaPath], { + env: { ...process.env, FUSION_GROK_TOOL_BRIDGE_URL: "http://127.0.0.1:1", FUSION_TOOL_BRIDGE_CAPABILITY: "test" }, + stdio: ["pipe", "pipe", "pipe"], + }); + try { + const response = await new Promise>((resolve, reject) => { + let output = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + output += chunk; + const line = output.split("\n")[0]; + if (line) resolve(JSON.parse(line) as Record); + }); + child.once("error", reject); + child.stdin.write('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n'); + }); + expect((response.result as { serverInfo?: { name?: string } }).serverInfo?.name).toBe("fusion-custom-tools"); + } finally { + child.kill(); + await rm(directory, { recursive: true, force: true }); + } + }); + it("requires a session capability and action-gate authorization before executing a custom tool", async () => { const execute = vi.fn().mockResolvedValue({ text: "done" }); const bridge = await startFusionToolBridge( diff --git a/plugins/fusion-plugin-claude-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-claude-runtime/src/runtime-adapter.ts index 8ed98377a4..d487e93c24 100644 --- a/plugins/fusion-plugin-claude-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-claude-runtime/src/runtime-adapter.ts @@ -65,6 +65,8 @@ export interface ClaudeRuntimeAdapterOptions { * `AcpRuntimeAdapter` with Claude ACP settings. */ createAcpAdapter?: AcpAdapterFactory; + /** Injectable only to assert bridge failure handling without binding a port. */ + startToolBridge?: typeof startFusionToolBridge; } /** Turn-scoped stream accumulators stored on the session for prompt finalization. */ @@ -74,9 +76,16 @@ interface TurnAccum { interface SessionResources { toolBridge?: FusionToolBridge | null; + toolBridgeFailure?: "mcp-schema-server-missing" | "bridge-start-failed"; skillStaging?: { dispose: () => void } | null; } +function bridgeFailureReasonCode(error: unknown): "mcp-schema-server-missing" | "bridge-start-failed" { + return (error as { code?: unknown })?.code === "mcp-schema-server-missing" + ? "mcp-schema-server-missing" + : "bridge-start-failed"; +} + function compactDiagnostic(value: string): string { return value.replace(/\s+/g, " ").trim(); } @@ -166,6 +175,9 @@ function ensureClaudeSessionShape( claude.messages = state.messages; claude.state = state; claude.lastModelDescription = `claude/${model}`; + if (resources.toolBridgeFailure) { + claude.fusionToolBridgeError = { reasonCode: resources.toolBridgeFailure }; + } // Prefer callbacks already installed on the ACP session (wrapped at create // for turnAccum + engine fans-out). Only fall back to the raw engine options. claude.callbacks = { @@ -220,6 +232,7 @@ export class ClaudeRuntimeAdapter implements AgentRuntime { readonly name = "Claude Runtime"; private readonly binary?: string; private readonly createAcpAdapter: AcpAdapterFactory; + private readonly startToolBridge: typeof startFusionToolBridge; /** Per-session ACP adapter so model-specific spawn args stay consistent. */ private readonly adapters = new WeakMap>(); @@ -236,6 +249,7 @@ export class ClaudeRuntimeAdapter implements AgentRuntime { this.createAcpAdapter = options?.createAcpAdapter ?? ((settings) => new AcpRuntimeAdapter(settings) as unknown as ReturnType); + this.startToolBridge = options?.startToolBridge ?? startFusionToolBridge; } async createSession( @@ -262,14 +276,26 @@ export class ClaudeRuntimeAdapter implements AgentRuntime { // ── Operator MCP + Fusion custom tools ──────────────────────────────── const operatorMcp = toAcpMcpServers(options.mcpServers); + const customTools = collectCustomTools(options); let toolBridge: FusionToolBridge | null = null; try { - toolBridge = await startFusionToolBridge(collectCustomTools(options), { + toolBridge = await this.startToolBridge(customTools, { actionGateContext: options.actionGateContext, }); resources.toolBridge = toolBridge; - } catch { + } catch (error) { toolBridge = null; + if (customTools.length > 0) { + const reasonCode = bridgeFailureReasonCode(error); + resources.toolBridgeFailure = reasonCode; + /* + FNXC:FusionToolBridgeDiagnostics 2026-07-20-08:00: + Requested fn_* tools must never silently degrade when their MCP bridge fails. + Emit a stable, schema-free diagnostic and preserve only its fixed reason code + on the session so engine audit metadata stays ids/counts/outcomes-only. + */ + options.onText?.(`FUSION_TOOL_BRIDGE_FAILED: ${reasonCode}`); + } } const mcpServers: AcpMcpServer[] = [ diff --git a/plugins/fusion-plugin-claude-runtime/src/tool-bridge.ts b/plugins/fusion-plugin-claude-runtime/src/tool-bridge.ts index f9d5740d63..71c908ffd1 100644 --- a/plugins/fusion-plugin-claude-runtime/src/tool-bridge.ts +++ b/plugins/fusion-plugin-claude-runtime/src/tool-bridge.ts @@ -8,7 +8,7 @@ is left open after the session ends. */ import { createServer, type Server } from "node:http"; -import { rmSync, writeFileSync } from "node:fs"; +import { existsSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -71,11 +71,23 @@ export function toolsToMcpToolDefs(tools: ReadonlyArray | undefined): })); } -function fusionToolsMcpServerPath(): string { - // Packaged CLI copies this as mcp-schema-server.cjs next to the bundled plugin. +/* +FNXC:FusionToolBridgePackaging 2026-07-20-08:00: +The stdio MCP child resolves this asset beside the loaded bridge module. Keep the +source asset co-located for source-loaded plugins and copy it beside dist output +on local builds (the OMP postbuild pattern); otherwise the host reports +`handshake failed: connection closed: initialize response` for fusion-custom-tools. +*/ +export function fusionToolsMcpServerPath(): string { return join(dirname(fileURLToPath(import.meta.url)), "mcp-schema-server.cjs"); } +function missingMcpSchemaServerError(serverPath: string): Error { + const error = new Error(`Fusion MCP schema server is missing: ${serverPath}`) as Error & { code?: string }; + error.code = "mcp-schema-server-missing"; + return error; +} + function resultToText(result: unknown): string { if (result == null) return ""; if (typeof result === "string") return result; @@ -111,6 +123,11 @@ export async function startFusionToolBridge( const defs = toolsToMcpToolDefs(tools); if (defs.length === 0) return null; + const serverPath = fusionToolsMcpServerPath(); + if (!existsSync(serverPath)) { + throw missingMcpSchemaServerError(serverPath); + } + const byName = new Map(); for (const tool of tools ?? []) { if (tool && typeof tool.name === "string" && typeof tool.execute === "function") { @@ -238,7 +255,6 @@ export async function startFusionToolBridge( }); const bridgeUrl = `http://127.0.0.1:${address.port}`; - const serverPath = fusionToolsMcpServerPath(); return { toolCount: defs.length, diff --git a/plugins/fusion-plugin-claude-runtime/src/types.ts b/plugins/fusion-plugin-claude-runtime/src/types.ts index 9a12d1544b..dad302a0b4 100644 --- a/plugins/fusion-plugin-claude-runtime/src/types.ts +++ b/plugins/fusion-plugin-claude-runtime/src/types.ts @@ -54,6 +54,8 @@ export interface ClaudeSession { state: { errorMessage?: string; messages: unknown[] }; sessionId?: string; lastModelDescription: string; + /** Fixed diagnostic outcome when requested Fusion custom-tool bridge could not start. */ + fusionToolBridgeError?: { reasonCode: "mcp-schema-server-missing" | "bridge-start-failed" }; callbacks: ClaudeCallbacks; /** Live ACP connection when createSession succeeded (composed AcpSession). */ connection?: unknown; diff --git a/plugins/fusion-plugin-grok-runtime/package.json b/plugins/fusion-plugin-grok-runtime/package.json index 5ad29343de..dccc7ca8d3 100644 --- a/plugins/fusion-plugin-grok-runtime/package.json +++ b/plugins/fusion-plugin-grok-runtime/package.json @@ -24,7 +24,7 @@ }, "private": true, "scripts": { - "build": "tsc", + "build": "tsc && node -e \"require('node:fs').copyFileSync('src/mcp-schema-server.cjs','dist/mcp-schema-server.cjs')\"", "test": "vitest run --silent=passed-only --reporter=dot" }, "dependencies": { diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts index 97e0cd1b4b..0763b8e777 100644 --- a/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/runtime-adapter.test.ts @@ -139,6 +139,30 @@ describe("GrokRuntimeAdapter (ACP)", () => { await adapter.dispose(session); }); + it("surfaces a fixed diagnostic and omits the broken MCP entry when the tool bridge fails", async () => { + let captured: Record | undefined; + const onText = vi.fn(); + const adapter = new GrokRuntimeAdapter({ + createAcpAdapter: (settings) => { + const base = makeFakeAcpAdapter()(settings); + return { ...base, createSession: async (options) => { + captured = options as Record; + return base.createSession(options); + } }; + }, + startToolBridge: async () => { throw new Error("bind failed"); }, + }); + + const { session } = await adapter.createSession({ + onText, + customTools: [{ name: "fn_task_list", execute: async () => ({}) }], + }); + + expect(onText).toHaveBeenCalledWith("FUSION_TOOL_BRIDGE_FAILED: bridge-start-failed"); + expect(session.fusionToolBridgeError).toEqual({ reasonCode: "bridge-start-failed" }); + expect((captured?.mcpServers as Array<{ name: string }>).some((server) => server.name === "fusion-custom-tools")).toBe(false); + }); + it("omits -m for the no-model grok/default fallback but still injects plugin-dir", async () => { const settingsOut: Record[] = []; const adapter = new GrokRuntimeAdapter({ createAcpAdapter: makeFakeAcpAdapter({ settingsOut }) }); diff --git a/plugins/fusion-plugin-grok-runtime/src/__tests__/tool-bridge.test.ts b/plugins/fusion-plugin-grok-runtime/src/__tests__/tool-bridge.test.ts index 8c95d3323e..e6340bd03c 100644 --- a/plugins/fusion-plugin-grok-runtime/src/__tests__/tool-bridge.test.ts +++ b/plugins/fusion-plugin-grok-runtime/src/__tests__/tool-bridge.test.ts @@ -1,5 +1,9 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { startFusionToolBridge, toolsToMcpToolDefs } from "../tool-bridge.js"; +import { fusionToolsMcpServerPath, startFusionToolBridge, toolsToMcpToolDefs } from "../tool-bridge.js"; describe("tool-bridge", () => { it("filters built-ins and maps tool schemas", () => { @@ -86,6 +90,33 @@ describe("tool-bridge", () => { await bridge!.dispose(); }); + it("serves MCP initialize from the co-located schema server", async () => { + const directory = await mkdtemp(join(tmpdir(), "fusion-mcp-smoke-")); + const schemaPath = join(directory, "schemas.json"); + await writeFile(schemaPath, "[]"); + const child = spawn(process.execPath, [fusionToolsMcpServerPath(), schemaPath], { + env: { ...process.env, FUSION_GROK_TOOL_BRIDGE_URL: "http://127.0.0.1:1" }, + stdio: ["pipe", "pipe", "pipe"], + }); + try { + const response = await new Promise>((resolve, reject) => { + let output = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + output += chunk; + const line = output.split("\n")[0]; + if (line) resolve(JSON.parse(line) as Record); + }); + child.once("error", reject); + child.stdin.write('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n'); + }); + expect((response.result as { serverInfo?: { name?: string } }).serverInfo?.name).toBe("fusion-custom-tools"); + } finally { + child.kill(); + await rm(directory, { recursive: true, force: true }); + } + }); + it("returns null when there are no custom tools", async () => { expect(await startFusionToolBridge([])).toBeNull(); expect(await startFusionToolBridge(undefined)).toBeNull(); diff --git a/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts index 749509e053..454c2bc7d1 100644 --- a/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-grok-runtime/src/runtime-adapter.ts @@ -65,6 +65,8 @@ export interface GrokRuntimeAdapterOptions { * `AcpRuntimeAdapter` with Grok ACP settings. */ createAcpAdapter?: AcpAdapterFactory; + /** Injectable only to assert bridge failure handling without binding a port. */ + startToolBridge?: typeof startFusionToolBridge; } /** Turn-scoped stream accumulators stored on the session for prompt finalization. */ @@ -74,9 +76,16 @@ interface TurnAccum { interface SessionResources { toolBridge?: FusionToolBridge | null; + toolBridgeFailure?: "mcp-schema-server-missing" | "bridge-start-failed"; skillStaging?: { dispose: () => void } | null; } +function bridgeFailureReasonCode(error: unknown): "mcp-schema-server-missing" | "bridge-start-failed" { + return (error as { code?: unknown })?.code === "mcp-schema-server-missing" + ? "mcp-schema-server-missing" + : "bridge-start-failed"; +} + function compactDiagnostic(value: string): string { return value.replace(/\s+/g, " ").trim(); } @@ -166,6 +175,9 @@ function ensureGrokSessionShape( grok.messages = state.messages; grok.state = state; grok.lastModelDescription = `grok/${model}`; + if (resources.toolBridgeFailure) { + grok.fusionToolBridgeError = { reasonCode: resources.toolBridgeFailure }; + } // Prefer callbacks already installed on the ACP session (wrapped at create // for turnAccum + engine fans-out). Only fall back to the raw engine options. grok.callbacks = { @@ -220,6 +232,7 @@ export class GrokRuntimeAdapter implements AgentRuntime { readonly name = "Grok Runtime"; private readonly binary: string; private readonly createAcpAdapter: AcpAdapterFactory; + private readonly startToolBridge: typeof startFusionToolBridge; /** Per-session ACP adapter so model-specific spawn args stay consistent. */ private readonly adapters = new WeakMap>(); @@ -235,6 +248,7 @@ export class GrokRuntimeAdapter implements AgentRuntime { this.createAcpAdapter = options?.createAcpAdapter ?? ((settings) => new AcpRuntimeAdapter(settings) as unknown as ReturnType); + this.startToolBridge = options?.startToolBridge ?? startFusionToolBridge; } async createSession( @@ -261,12 +275,24 @@ export class GrokRuntimeAdapter implements AgentRuntime { // ── Operator MCP + Fusion custom tools ──────────────────────────────── const operatorMcp = toAcpMcpServers(options.mcpServers); + const customTools = collectCustomTools(options); let toolBridge: FusionToolBridge | null = null; try { - toolBridge = await startFusionToolBridge(collectCustomTools(options)); + toolBridge = await this.startToolBridge(customTools); resources.toolBridge = toolBridge; - } catch { + } catch (error) { toolBridge = null; + if (customTools.length > 0) { + const reasonCode = bridgeFailureReasonCode(error); + resources.toolBridgeFailure = reasonCode; + /* + FNXC:FusionToolBridgeDiagnostics 2026-07-20-08:00: + Requested fn_* tools must never silently degrade when their MCP bridge fails. + Emit a stable, schema-free diagnostic and preserve only its fixed reason code + on the session so engine audit metadata stays ids/counts/outcomes-only. + */ + options.onText?.(`FUSION_TOOL_BRIDGE_FAILED: ${reasonCode}`); + } } const mcpServers: AcpMcpServer[] = [ diff --git a/plugins/fusion-plugin-grok-runtime/src/tool-bridge.ts b/plugins/fusion-plugin-grok-runtime/src/tool-bridge.ts index 0e208b1941..f4cd0e37c5 100644 --- a/plugins/fusion-plugin-grok-runtime/src/tool-bridge.ts +++ b/plugins/fusion-plugin-grok-runtime/src/tool-bridge.ts @@ -8,7 +8,7 @@ is left open after the session ends. */ import { createServer, type Server } from "node:http"; -import { writeFileSync } from "node:fs"; +import { existsSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -53,11 +53,23 @@ export function toolsToMcpToolDefs(tools: ReadonlyArray | undefined): })); } -function fusionToolsMcpServerPath(): string { - // Packaged CLI copies this as mcp-schema-server.cjs next to the bundled plugin. +/* +FNXC:FusionToolBridgePackaging 2026-07-20-08:00: +The stdio MCP child resolves this asset beside the loaded bridge module. Keep the +source asset co-located for source-loaded plugins and copy it beside dist output +on local builds (the OMP postbuild pattern); otherwise the host reports +`handshake failed: connection closed: initialize response` for fusion-custom-tools. +*/ +export function fusionToolsMcpServerPath(): string { return join(dirname(fileURLToPath(import.meta.url)), "mcp-schema-server.cjs"); } +function missingMcpSchemaServerError(serverPath: string): Error { + const error = new Error(`Fusion MCP schema server is missing: ${serverPath}`) as Error & { code?: string }; + error.code = "mcp-schema-server-missing"; + return error; +} + function resultToText(result: unknown): string { if (result == null) return ""; if (typeof result === "string") return result; @@ -90,6 +102,11 @@ export async function startFusionToolBridge(tools: ReadonlyArray | und const defs = toolsToMcpToolDefs(tools); if (defs.length === 0) return null; + const serverPath = fusionToolsMcpServerPath(); + if (!existsSync(serverPath)) { + throw missingMcpSchemaServerError(serverPath); + } + const byName = new Map(); for (const tool of tools ?? []) { if (tool && typeof tool.name === "string" && typeof tool.execute === "function") { @@ -159,7 +176,6 @@ export async function startFusionToolBridge(tools: ReadonlyArray | und }); const bridgeUrl = `http://127.0.0.1:${address.port}`; - const serverPath = fusionToolsMcpServerPath(); return { toolCount: defs.length, diff --git a/plugins/fusion-plugin-grok-runtime/src/types.ts b/plugins/fusion-plugin-grok-runtime/src/types.ts index 276a2fc110..745272fb7e 100644 --- a/plugins/fusion-plugin-grok-runtime/src/types.ts +++ b/plugins/fusion-plugin-grok-runtime/src/types.ts @@ -52,6 +52,8 @@ export interface GrokSession { state: { errorMessage?: string; messages: unknown[] }; sessionId?: string; lastModelDescription: string; + /** Fixed diagnostic outcome when requested Fusion custom-tool bridge could not start. */ + fusionToolBridgeError?: { reasonCode: "mcp-schema-server-missing" | "bridge-start-failed" }; callbacks: GrokCallbacks; /** Live ACP connection when createSession succeeded (composed AcpSession). */ connection?: unknown;