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:
7
.changeset/fn-8422-mcp-bridge.md
Normal file
7
.changeset/fn-8422-mcp-bridge.md
Normal 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.
|
||||||
@@ -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.
|
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.
|
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
|
### Session lifecycle
|
||||||
|
|
||||||
1. `createSession` — spawn `grok agent stdio`, ACP `initialize`, `session/new` over the task cwd.
|
1. `createSession` — spawn `grok agent stdio`, ACP `initialize`, `session/new` over the task cwd.
|
||||||
|
|||||||
@@ -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 () => {
|
it("succeeds when runAuditor is omitted", async () => {
|
||||||
const mockSession = { prompt: vi.fn() } as any;
|
const mockSession = { prompt: vi.fn() } as any;
|
||||||
const createSessionMock = vi.fn().mockResolvedValue({
|
const createSessionMock = vi.fn().mockResolvedValue({
|
||||||
|
|||||||
@@ -130,6 +130,22 @@ describe("describeModel", () => {
|
|||||||
expect(describeModel(fakeSession)).toBe("anthropic/claude-sonnet-4-5");
|
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', () => {
|
it('returns "unknown model" when session model is undefined', () => {
|
||||||
const fakeSession = {
|
const fakeSession = {
|
||||||
model: undefined,
|
model: undefined,
|
||||||
|
|||||||
@@ -800,6 +800,11 @@ export async function createResolvedAgentSession(
|
|||||||
|
|
||||||
const noModelResolved = !mockProviderActive && !testModeActive && (!runtimeOptions.defaultProvider || !runtimeOptions.defaultModelId);
|
const noModelResolved = !mockProviderActive && !testModeActive && (!runtimeOptions.defaultProvider || !runtimeOptions.defaultModelId);
|
||||||
const runtimeBuiltInFallbackModel = noModelResolved ? resolved.runtime.describeModel(result.session) : undefined;
|
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) {
|
if (noModelResolved) {
|
||||||
/*
|
/*
|
||||||
FNXC:ModelResolution 2026-07-10-00:00:
|
FNXC:ModelResolution 2026-07-10-00:00:
|
||||||
@@ -823,6 +828,21 @@ export async function createResolvedAgentSession(
|
|||||||
mockProviderActive,
|
mockProviderActive,
|
||||||
testModeActive,
|
testModeActive,
|
||||||
...(noModelResolved ? { noModelResolved: true, runtimeBuiltInFallbackModel } : {}),
|
...(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 } : {}),
|
...(effectiveRuntimeHint ? { runtimeHint: effectiveRuntimeHint } : {}),
|
||||||
...(autoGrokRuntimeHint ? { reason: "grok-cli-no-visible-key" } : {}),
|
...(autoGrokRuntimeHint ? { reason: "grok-cli-no-visible-key" } : {}),
|
||||||
...(autoOmpRuntimeHint ? { reason: "omp-cli-runtime" } : {}),
|
...(autoOmpRuntimeHint ? { reason: "omp-cli-runtime" } : {}),
|
||||||
|
|||||||
@@ -516,10 +516,31 @@ export async function promptWithFallback(session: AgentSession, prompt: string,
|
|||||||
* Returns `"<provider>/<modelId>"` (e.g. `"anthropic/claude-sonnet-4-5"`)
|
* Returns `"<provider>/<modelId>"` (e.g. `"anthropic/claude-sonnet-4-5"`)
|
||||||
* or `"unknown model"` when the session has no model set.
|
* 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 {
|
export function describeModel(session: AgentSession): string {
|
||||||
const model = session.model;
|
const model = (session as { model?: unknown }).model;
|
||||||
if (!model) return "unknown model";
|
if (model && typeof model === "object") {
|
||||||
return `${model.provider}/${model.id}`;
|
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";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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`.
|
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.
|
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.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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"
|
"test": "vitest run --silent=passed-only --reporter=dot"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,7 +1,37 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { ClaudeRuntimeAdapter } from "../runtime-adapter.js";
|
import { ClaudeRuntimeAdapter } from "../runtime-adapter.js";
|
||||||
|
import type { AgentSession } from "../types.js";
|
||||||
const options={cwd:"/tmp",systemPrompt:"",onText:vi.fn()};
|
const options={cwd:"/tmp",systemPrompt:"",onText:vi.fn()};
|
||||||
describe("ClaudeRuntimeAdapter", () => {
|
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 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("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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { request } from "node:http";
|
import { request } from "node:http";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
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 {
|
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");
|
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", () => {
|
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 () => {
|
it("requires a session capability and action-gate authorization before executing a custom tool", async () => {
|
||||||
const execute = vi.fn().mockResolvedValue({ text: "done" });
|
const execute = vi.fn().mockResolvedValue({ text: "done" });
|
||||||
const bridge = await startFusionToolBridge(
|
const bridge = await startFusionToolBridge(
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export interface ClaudeRuntimeAdapterOptions {
|
|||||||
* `AcpRuntimeAdapter` with Claude ACP settings.
|
* `AcpRuntimeAdapter` with Claude ACP settings.
|
||||||
*/
|
*/
|
||||||
createAcpAdapter?: AcpAdapterFactory;
|
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. */
|
/** Turn-scoped stream accumulators stored on the session for prompt finalization. */
|
||||||
@@ -74,9 +76,16 @@ interface TurnAccum {
|
|||||||
|
|
||||||
interface SessionResources {
|
interface SessionResources {
|
||||||
toolBridge?: FusionToolBridge | null;
|
toolBridge?: FusionToolBridge | null;
|
||||||
|
toolBridgeFailure?: "mcp-schema-server-missing" | "bridge-start-failed";
|
||||||
skillStaging?: { dispose: () => void } | null;
|
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 {
|
function compactDiagnostic(value: string): string {
|
||||||
return value.replace(/\s+/g, " ").trim();
|
return value.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
@@ -166,6 +175,9 @@ function ensureClaudeSessionShape(
|
|||||||
claude.messages = state.messages;
|
claude.messages = state.messages;
|
||||||
claude.state = state;
|
claude.state = state;
|
||||||
claude.lastModelDescription = `claude/${model}`;
|
claude.lastModelDescription = `claude/${model}`;
|
||||||
|
if (resources.toolBridgeFailure) {
|
||||||
|
claude.fusionToolBridgeError = { reasonCode: resources.toolBridgeFailure };
|
||||||
|
}
|
||||||
// Prefer callbacks already installed on the ACP session (wrapped at create
|
// Prefer callbacks already installed on the ACP session (wrapped at create
|
||||||
// for turnAccum + engine fans-out). Only fall back to the raw engine options.
|
// for turnAccum + engine fans-out). Only fall back to the raw engine options.
|
||||||
claude.callbacks = {
|
claude.callbacks = {
|
||||||
@@ -220,6 +232,7 @@ export class ClaudeRuntimeAdapter implements AgentRuntime {
|
|||||||
readonly name = "Claude Runtime";
|
readonly name = "Claude Runtime";
|
||||||
private readonly binary?: string;
|
private readonly binary?: string;
|
||||||
private readonly createAcpAdapter: AcpAdapterFactory;
|
private readonly createAcpAdapter: AcpAdapterFactory;
|
||||||
|
private readonly startToolBridge: typeof startFusionToolBridge;
|
||||||
/** Per-session ACP adapter so model-specific spawn args stay consistent. */
|
/** Per-session ACP adapter so model-specific spawn args stay consistent. */
|
||||||
private readonly adapters = new WeakMap<object, ReturnType<AcpAdapterFactory>>();
|
private readonly adapters = new WeakMap<object, ReturnType<AcpAdapterFactory>>();
|
||||||
|
|
||||||
@@ -236,6 +249,7 @@ export class ClaudeRuntimeAdapter implements AgentRuntime {
|
|||||||
this.createAcpAdapter =
|
this.createAcpAdapter =
|
||||||
options?.createAcpAdapter ??
|
options?.createAcpAdapter ??
|
||||||
((settings) => new AcpRuntimeAdapter(settings) as unknown as ReturnType<AcpAdapterFactory>);
|
((settings) => new AcpRuntimeAdapter(settings) as unknown as ReturnType<AcpAdapterFactory>);
|
||||||
|
this.startToolBridge = options?.startToolBridge ?? startFusionToolBridge;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(
|
async createSession(
|
||||||
@@ -262,14 +276,26 @@ export class ClaudeRuntimeAdapter implements AgentRuntime {
|
|||||||
|
|
||||||
// ── Operator MCP + Fusion custom tools ────────────────────────────────
|
// ── Operator MCP + Fusion custom tools ────────────────────────────────
|
||||||
const operatorMcp = toAcpMcpServers(options.mcpServers);
|
const operatorMcp = toAcpMcpServers(options.mcpServers);
|
||||||
|
const customTools = collectCustomTools(options);
|
||||||
let toolBridge: FusionToolBridge | null = null;
|
let toolBridge: FusionToolBridge | null = null;
|
||||||
try {
|
try {
|
||||||
toolBridge = await startFusionToolBridge(collectCustomTools(options), {
|
toolBridge = await this.startToolBridge(customTools, {
|
||||||
actionGateContext: options.actionGateContext,
|
actionGateContext: options.actionGateContext,
|
||||||
});
|
});
|
||||||
resources.toolBridge = toolBridge;
|
resources.toolBridge = toolBridge;
|
||||||
} catch {
|
} catch (error) {
|
||||||
toolBridge = null;
|
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[] = [
|
const mcpServers: AcpMcpServer[] = [
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ is left open after the session ends.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServer, type Server } from "node:http";
|
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 { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
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");
|
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 {
|
function resultToText(result: unknown): string {
|
||||||
if (result == null) return "";
|
if (result == null) return "";
|
||||||
if (typeof result === "string") return result;
|
if (typeof result === "string") return result;
|
||||||
@@ -111,6 +123,11 @@ export async function startFusionToolBridge(
|
|||||||
const defs = toolsToMcpToolDefs(tools);
|
const defs = toolsToMcpToolDefs(tools);
|
||||||
if (defs.length === 0) return null;
|
if (defs.length === 0) return null;
|
||||||
|
|
||||||
|
const serverPath = fusionToolsMcpServerPath();
|
||||||
|
if (!existsSync(serverPath)) {
|
||||||
|
throw missingMcpSchemaServerError(serverPath);
|
||||||
|
}
|
||||||
|
|
||||||
const byName = new Map<string, ToolLike>();
|
const byName = new Map<string, ToolLike>();
|
||||||
for (const tool of tools ?? []) {
|
for (const tool of tools ?? []) {
|
||||||
if (tool && typeof tool.name === "string" && typeof tool.execute === "function") {
|
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 bridgeUrl = `http://127.0.0.1:${address.port}`;
|
||||||
const serverPath = fusionToolsMcpServerPath();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
toolCount: defs.length,
|
toolCount: defs.length,
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface ClaudeSession {
|
|||||||
state: { errorMessage?: string; messages: unknown[] };
|
state: { errorMessage?: string; messages: unknown[] };
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
lastModelDescription: 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;
|
callbacks: ClaudeCallbacks;
|
||||||
/** Live ACP connection when createSession succeeded (composed AcpSession). */
|
/** Live ACP connection when createSession succeeded (composed AcpSession). */
|
||||||
connection?: unknown;
|
connection?: unknown;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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"
|
"test": "vitest run --silent=passed-only --reporter=dot"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -139,6 +139,30 @@ describe("GrokRuntimeAdapter (ACP)", () => {
|
|||||||
await adapter.dispose(session);
|
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 () => {
|
it("omits -m for the no-model grok/default fallback but still injects plugin-dir", async () => {
|
||||||
const settingsOut: Record<string, unknown>[] = [];
|
const settingsOut: Record<string, unknown>[] = [];
|
||||||
const adapter = new GrokRuntimeAdapter({ createAcpAdapter: makeFakeAcpAdapter({ settingsOut }) });
|
const adapter = new GrokRuntimeAdapter({ createAcpAdapter: makeFakeAcpAdapter({ settingsOut }) });
|
||||||
|
|||||||
@@ -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 { describe, expect, it } from "vitest";
|
||||||
import { startFusionToolBridge, toolsToMcpToolDefs } from "../tool-bridge.js";
|
import { fusionToolsMcpServerPath, startFusionToolBridge, toolsToMcpToolDefs } from "../tool-bridge.js";
|
||||||
|
|
||||||
describe("tool-bridge", () => {
|
describe("tool-bridge", () => {
|
||||||
it("filters built-ins and maps tool schemas", () => {
|
it("filters built-ins and maps tool schemas", () => {
|
||||||
@@ -86,6 +90,33 @@ describe("tool-bridge", () => {
|
|||||||
await bridge!.dispose();
|
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 () => {
|
it("returns null when there are no custom tools", async () => {
|
||||||
expect(await startFusionToolBridge([])).toBeNull();
|
expect(await startFusionToolBridge([])).toBeNull();
|
||||||
expect(await startFusionToolBridge(undefined)).toBeNull();
|
expect(await startFusionToolBridge(undefined)).toBeNull();
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export interface GrokRuntimeAdapterOptions {
|
|||||||
* `AcpRuntimeAdapter` with Grok ACP settings.
|
* `AcpRuntimeAdapter` with Grok ACP settings.
|
||||||
*/
|
*/
|
||||||
createAcpAdapter?: AcpAdapterFactory;
|
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. */
|
/** Turn-scoped stream accumulators stored on the session for prompt finalization. */
|
||||||
@@ -74,9 +76,16 @@ interface TurnAccum {
|
|||||||
|
|
||||||
interface SessionResources {
|
interface SessionResources {
|
||||||
toolBridge?: FusionToolBridge | null;
|
toolBridge?: FusionToolBridge | null;
|
||||||
|
toolBridgeFailure?: "mcp-schema-server-missing" | "bridge-start-failed";
|
||||||
skillStaging?: { dispose: () => void } | null;
|
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 {
|
function compactDiagnostic(value: string): string {
|
||||||
return value.replace(/\s+/g, " ").trim();
|
return value.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
@@ -166,6 +175,9 @@ function ensureGrokSessionShape(
|
|||||||
grok.messages = state.messages;
|
grok.messages = state.messages;
|
||||||
grok.state = state;
|
grok.state = state;
|
||||||
grok.lastModelDescription = `grok/${model}`;
|
grok.lastModelDescription = `grok/${model}`;
|
||||||
|
if (resources.toolBridgeFailure) {
|
||||||
|
grok.fusionToolBridgeError = { reasonCode: resources.toolBridgeFailure };
|
||||||
|
}
|
||||||
// Prefer callbacks already installed on the ACP session (wrapped at create
|
// Prefer callbacks already installed on the ACP session (wrapped at create
|
||||||
// for turnAccum + engine fans-out). Only fall back to the raw engine options.
|
// for turnAccum + engine fans-out). Only fall back to the raw engine options.
|
||||||
grok.callbacks = {
|
grok.callbacks = {
|
||||||
@@ -220,6 +232,7 @@ export class GrokRuntimeAdapter implements AgentRuntime {
|
|||||||
readonly name = "Grok Runtime";
|
readonly name = "Grok Runtime";
|
||||||
private readonly binary: string;
|
private readonly binary: string;
|
||||||
private readonly createAcpAdapter: AcpAdapterFactory;
|
private readonly createAcpAdapter: AcpAdapterFactory;
|
||||||
|
private readonly startToolBridge: typeof startFusionToolBridge;
|
||||||
/** Per-session ACP adapter so model-specific spawn args stay consistent. */
|
/** Per-session ACP adapter so model-specific spawn args stay consistent. */
|
||||||
private readonly adapters = new WeakMap<object, ReturnType<AcpAdapterFactory>>();
|
private readonly adapters = new WeakMap<object, ReturnType<AcpAdapterFactory>>();
|
||||||
|
|
||||||
@@ -235,6 +248,7 @@ export class GrokRuntimeAdapter implements AgentRuntime {
|
|||||||
this.createAcpAdapter =
|
this.createAcpAdapter =
|
||||||
options?.createAcpAdapter ??
|
options?.createAcpAdapter ??
|
||||||
((settings) => new AcpRuntimeAdapter(settings) as unknown as ReturnType<AcpAdapterFactory>);
|
((settings) => new AcpRuntimeAdapter(settings) as unknown as ReturnType<AcpAdapterFactory>);
|
||||||
|
this.startToolBridge = options?.startToolBridge ?? startFusionToolBridge;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(
|
async createSession(
|
||||||
@@ -261,12 +275,24 @@ export class GrokRuntimeAdapter implements AgentRuntime {
|
|||||||
|
|
||||||
// ── Operator MCP + Fusion custom tools ────────────────────────────────
|
// ── Operator MCP + Fusion custom tools ────────────────────────────────
|
||||||
const operatorMcp = toAcpMcpServers(options.mcpServers);
|
const operatorMcp = toAcpMcpServers(options.mcpServers);
|
||||||
|
const customTools = collectCustomTools(options);
|
||||||
let toolBridge: FusionToolBridge | null = null;
|
let toolBridge: FusionToolBridge | null = null;
|
||||||
try {
|
try {
|
||||||
toolBridge = await startFusionToolBridge(collectCustomTools(options));
|
toolBridge = await this.startToolBridge(customTools);
|
||||||
resources.toolBridge = toolBridge;
|
resources.toolBridge = toolBridge;
|
||||||
} catch {
|
} catch (error) {
|
||||||
toolBridge = null;
|
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[] = [
|
const mcpServers: AcpMcpServer[] = [
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ is left open after the session ends.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServer, type Server } from "node:http";
|
import { createServer, type Server } from "node:http";
|
||||||
import { writeFileSync } from "node:fs";
|
import { existsSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
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");
|
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 {
|
function resultToText(result: unknown): string {
|
||||||
if (result == null) return "";
|
if (result == null) return "";
|
||||||
if (typeof result === "string") return result;
|
if (typeof result === "string") return result;
|
||||||
@@ -90,6 +102,11 @@ export async function startFusionToolBridge(tools: ReadonlyArray<ToolLike> | und
|
|||||||
const defs = toolsToMcpToolDefs(tools);
|
const defs = toolsToMcpToolDefs(tools);
|
||||||
if (defs.length === 0) return null;
|
if (defs.length === 0) return null;
|
||||||
|
|
||||||
|
const serverPath = fusionToolsMcpServerPath();
|
||||||
|
if (!existsSync(serverPath)) {
|
||||||
|
throw missingMcpSchemaServerError(serverPath);
|
||||||
|
}
|
||||||
|
|
||||||
const byName = new Map<string, ToolLike>();
|
const byName = new Map<string, ToolLike>();
|
||||||
for (const tool of tools ?? []) {
|
for (const tool of tools ?? []) {
|
||||||
if (tool && typeof tool.name === "string" && typeof tool.execute === "function") {
|
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 bridgeUrl = `http://127.0.0.1:${address.port}`;
|
||||||
const serverPath = fusionToolsMcpServerPath();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
toolCount: defs.length,
|
toolCount: defs.length,
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export interface GrokSession {
|
|||||||
state: { errorMessage?: string; messages: unknown[] };
|
state: { errorMessage?: string; messages: unknown[] };
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
lastModelDescription: 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;
|
callbacks: GrokCallbacks;
|
||||||
/** Live ACP connection when createSession succeeded (composed AcpSession). */
|
/** Live ACP connection when createSession succeeded (composed AcpSession). */
|
||||||
connection?: unknown;
|
connection?: unknown;
|
||||||
|
|||||||
Reference in New Issue
Block a user