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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-20 01:07:14 -07:00
parent 57636226b1
commit 5af53bc94a
19 changed files with 338 additions and 19 deletions

View File

@@ -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.

View File

@@ -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/<model>` (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.

View File

@@ -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({

View File

@@ -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,

View File

@@ -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" } : {}),

View File

@@ -516,10 +516,31 @@ export async function promptWithFallback(session: AgentSession, prompt: string,
* Returns `"<provider>/<modelId>"` (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";
}
/**

View File

@@ -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.

View File

@@ -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": {

View File

@@ -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<string, unknown> | undefined;
const onText = vi.fn();
const adapter = new ClaudeRuntimeAdapter({
createAcpAdapter: () => ({
createSession: async (sessionOptions) => {
captured = sessionOptions as Record<string, unknown>;
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);
});

View File

@@ -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<Awaited<ReturnType<typeof startFusionToolBridge>>>, 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<Record<string, unknown>>((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<string, unknown>);
});
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(

View File

@@ -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<object, ReturnType<AcpAdapterFactory>>();
@@ -236,6 +249,7 @@ export class ClaudeRuntimeAdapter implements AgentRuntime {
this.createAcpAdapter =
options?.createAcpAdapter ??
((settings) => new AcpRuntimeAdapter(settings) as unknown as ReturnType<AcpAdapterFactory>);
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[] = [

View File

@@ -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<ToolLike> | 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<string, ToolLike>();
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,

View File

@@ -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;

View File

@@ -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": {

View File

@@ -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<string, unknown> | undefined;
const onText = vi.fn();
const adapter = new GrokRuntimeAdapter({
createAcpAdapter: (settings) => {
const base = makeFakeAcpAdapter()(settings);
return { ...base, createSession: async (options) => {
captured = options as Record<string, unknown>;
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<string, unknown>[] = [];
const adapter = new GrokRuntimeAdapter({ createAcpAdapter: makeFakeAcpAdapter({ settingsOut }) });

View File

@@ -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<Record<string, unknown>>((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<string, unknown>);
});
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();

View File

@@ -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<object, ReturnType<AcpAdapterFactory>>();
@@ -235,6 +248,7 @@ export class GrokRuntimeAdapter implements AgentRuntime {
this.createAcpAdapter =
options?.createAcpAdapter ??
((settings) => new AcpRuntimeAdapter(settings) as unknown as ReturnType<AcpAdapterFactory>);
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[] = [

View File

@@ -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<ToolLike> | 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<ToolLike> | und
const defs = toolsToMcpToolDefs(tools);
if (defs.length === 0) return null;
const serverPath = fusionToolsMcpServerPath();
if (!existsSync(serverPath)) {
throw missingMcpSchemaServerError(serverPath);
}
const byName = new Map<string, ToolLike>();
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<ToolLike> | und
});
const bridgeUrl = `http://127.0.0.1:${address.port}`;
const serverPath = fusionToolsMcpServerPath();
return {
toolCount: defs.length,

View File

@@ -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;