feat: expose Fusion custom tools to ACP agents via loopback MCP bridge (#3476)

## Summary

- ACP runtimes can expose Fusion custom tools (`fn_*`) to external
agents such as Hermes ACP and Prime.
- When the engine passes `customTools`, `AcpRuntimeAdapter` starts a
per-session loopback tool bridge and registers it as a stdio MCP server
in `session/new.mcpServers`.
- The bridge uses a per-session bearer token, binds only to loopback,
preserves the MCP request ID as `toolCallId`, propagates tool `isError`
results, and exposes only runnable non-built-in tools.
- The MCP shim supports `initialize`, `ping`, `tools/list`, and
`tools/call`, reporting transport, authentication, malformed-request,
and unknown-tool failures correctly.
- Bridge startup failures degrade gracefully: the ACP session remains
usable without custom tools and exposes a fixed `fusionToolBridgeError`
reason code for engine auditing.
- Startup, request, and teardown paths clean temporary schemas and
listeners; disposal aborts cooperative tools, bounds non-cooperative
drains, prevents post-dispose execution, and preserves the existing
synchronous session contract through an awaitable `disposePromise`.
- The packaged CLI stages `mcp-schema-server.cjs` beside the ACP runtime
distribution.

## Verification

Validated at source commit `2fa6fd2e26923ac6241d1b9379b3819f1832c079`:

- `pnpm --filter @fusion-plugin-examples/acp-runtime test` — 21 files /
251 tests passed
- `pnpm --filter @fusion-plugin-examples/acp-runtime typecheck` — passed
- Plugin build with `.cjs` asset copy — passed
- `pnpm check:changesets` — passed
- `git diff --check` — passed
- MCP shim smoke test — passed
- Greptile Review — passed
- CodeRabbit — passed
- Devin review — 0 bugs, 6 analyses, `errored_tasks=[]`, quality `4/10`

## Scope

ACP runtime plugin only. The Hermes CLI runtime remains untouched;
Hermes ACP is enabled by selecting the generic ACP runtime with
`acpBinaryPath: hermes` and `acpArgs: ["acp"]`.

---------

Co-authored-by: gsxdsm <gsxdsm@users.noreply.github.com>
This commit is contained in:
Timoteo
2026-08-18 04:11:06 -03:00
committed by GitHub
parent 0540686599
commit 725b0a3330
9 changed files with 1090 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: ACP runtimes can now expose Fusion custom tools (fn_*) to external agents such as Hermes ACP and Prime.
category: feature
dev: AcpRuntimeAdapter starts a per-session loopback tool bridge and registers it as a stdio MCP server in session/new.mcpServers when the engine passes customTools; the bridge authenticates requests with a per-session bearer token, threads the real MCP request id as the toolCallId, and is disposed on session/new failure and session teardown. Build copies mcp-schema-server.cjs beside dist (tsc does not copy .cjs assets).

View File

@@ -33,6 +33,9 @@ const RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER = new Set([
"fusion-plugin-claude-runtime",
// FNXC:OmpAcp 2026-07-14-00:05: OMP ACP ships the same bridge asset for fn_* tools.
"fusion-plugin-omp-runtime",
// FNXC:AcpCustomTools 2026-08-16-00:30: generic ACP runtime ships the same
// bridge asset for Hermes ACP / Prime fn_* tool forwarding.
"fusion-plugin-acp-runtime",
]);
const __dirname = dirname(fileURLToPath(import.meta.url));

View File

@@ -21,7 +21,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",
"typecheck": "tsc --noEmit"
},

View File

@@ -1,8 +1,10 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, vi } from "vitest";
import os from "node:os";
import { fileURLToPath } from "node:url";
import { AcpRuntimeAdapter } from "../runtime-adapter.js";
import { killAllProcesses, activeProcessCount } from "../process-manager.js";
import * as provider from "../provider.js";
import * as toolBridge from "../tool-bridge.js";
import type { AcpSession, AgentRuntimeOptions } from "../types.js";
const FIXTURE = fileURLToPath(new URL("./fixtures/echo-agent.mjs", import.meta.url));
@@ -122,3 +124,142 @@ describe("AcpRuntimeAdapter (U3)", () => {
}
});
});
describe("AcpRuntimeAdapter custom-tools bridge (FNXC:AcpCustomTools)", () => {
it("keeps the ACP session alive when the custom-tools bridge cannot start", async () => {
let captured: { mcpServers?: unknown[] } | undefined;
const onText = vi.fn();
const adapter = makeAdapter();
const bridgeSpy = vi.spyOn(toolBridge, "startFusionToolBridge").mockRejectedValue(new Error("bind failed"));
const providerSpy = vi.spyOn(provider, "newAcpSession").mockImplementation(async (_connection, opts) => {
captured = opts;
return { sessionId: "degraded-session" };
});
try {
const { session } = await adapter.createSession(makeOptions({
onText,
customTools: [{ name: "fn_task_list", execute: async () => "ok" }],
}));
expect(session.sessionId).toBe("degraded-session");
expect(session.fusionToolBridgeError).toEqual({ reasonCode: "bridge-start-failed" });
expect(captured?.mcpServers ?? []).toHaveLength(0);
expect(onText).toHaveBeenCalledWith("FUSION_TOOL_BRIDGE_FAILED: bridge-start-failed");
await adapter.dispose(session);
} finally {
providerSpy.mockRestore();
bridgeSpy.mockRestore();
}
});
it("registers the tool bridge in session/new mcpServers when customTools are provided", async () => {
let captured: { mcpServers?: unknown[] } | undefined;
const spy = vi
.spyOn(provider, "newAcpSession")
.mockImplementation(async (_connection, opts) => {
captured = opts;
return { sessionId: "bridge-test-session" };
});
const adapter = makeAdapter();
let session: AcpSession | undefined;
try {
const created = await adapter.createSession(
makeOptions({
customTools: [
{
name: "fn_heartbeat_done",
description: "Finish heartbeat",
parameters: { type: "object", properties: {} },
execute: async () => ({ text: "ok" }),
},
],
}),
);
session = created.session;
expect(captured?.mcpServers).toHaveLength(1);
expect(captured?.mcpServers?.[0]).toMatchObject({
name: "fusion-custom-tools",
command: process.execPath,
});
const bridgeUrl = (captured?.mcpServers?.[0] as { env?: Array<{ name: string; value: string }> }).env?.find(
(entry) => entry.name === "FUSION_ACP_TOOL_BRIDGE_URL",
)?.value;
expect(bridgeUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
await adapter.dispose(session);
await expect(fetch(`${bridgeUrl}/tool-call`)).rejects.toThrow();
} finally {
// Dispose in finally so a failed assertion cannot leak the bridge/socket.
if (session) await adapter.dispose(session);
spy.mockRestore();
}
});
it("direct session.dispose exposes completion of bridge shutdown", async () => {
let captured: { mcpServers?: unknown[] } | undefined;
const spy = vi.spyOn(provider, "newAcpSession").mockImplementation(async (_connection, opts) => {
captured = opts;
return { sessionId: "direct-dispose-session" };
});
let session: AcpSession | undefined;
try {
session = (await makeAdapter().createSession(
makeOptions({ customTools: [{ name: "fn_direct_dispose", execute: async () => "ok" }] }),
)).session as AcpSession;
const bridgeUrl = (captured?.mcpServers?.[0] as { env: Array<{ name: string; value: string }> })
.env.find((entry) => entry.name === "FUSION_ACP_TOOL_BRIDGE_URL")!.value;
session.dispose();
await expect(session.disposePromise).resolves.toBeUndefined();
await expect(fetch(`${bridgeUrl}/tool-call`)).rejects.toThrow();
} finally {
session?.dispose();
await session?.disposePromise;
spy.mockRestore();
}
});
it("does not add a bridge when no customTools are supplied", async () => {
let captured: { mcpServers?: unknown[] } | undefined;
const spy = vi
.spyOn(provider, "newAcpSession")
.mockImplementation(async (_connection, opts) => {
captured = opts;
return { sessionId: "plain-session" };
});
const adapter = makeAdapter();
try {
const { session } = await adapter.createSession(makeOptions());
expect(captured?.mcpServers ?? []).toHaveLength(0);
await adapter.dispose(session);
} finally {
spy.mockRestore();
}
});
it("disposes the bridge when session/new fails", async () => {
const spy = vi
.spyOn(provider, "newAcpSession")
.mockImplementation(async () => {
throw new Error("session/new failed");
});
const adapter = makeAdapter();
try {
await expect(
adapter.createSession(
makeOptions({
customTools: [
{
name: "fn_task_list",
description: "List",
parameters: {},
execute: async () => ({ text: "ok" }),
},
],
}),
),
).rejects.toThrow(/session\/new failed/);
// The subprocess must be cleaned up even though session/new failed.
expect(activeProcessCount()).toBe(0);
} finally {
spy.mockRestore();
}
});
});

View File

@@ -0,0 +1,442 @@
import { spawn } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { existsSync, readdirSync } from "node:fs";
import { Server, request as httpRequest } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { fusionToolsMcpServerPath, startFusionToolBridge, toolsToMcpToolDefs } from "../tool-bridge.js";
async function mcpRequest(child: ReturnType<typeof spawn>, payload: string): Promise<Record<string, unknown>> {
const stdout = child.stdout;
const stdin = child.stdin;
if (!stdout || !stdin) {
throw new Error("MCP child stdio is unavailable");
}
return new Promise((resolve, reject) => {
let output = "";
const onData = (chunk: Buffer) => {
output += chunk.toString("utf8");
const lines = output.split("\n");
const line = lines.find((candidate) => {
try {
JSON.parse(candidate);
return true;
} catch {
return false;
}
});
if (line) {
stdout.off("data", onData);
resolve(JSON.parse(line) as Record<string, unknown>);
}
};
stdout.setEncoding("utf8");
stdout.on("data", onData);
child.once("error", reject);
stdin.write(`${payload}\n`);
});
}
describe("tool-bridge", () => {
it("cleans up the schema and server when startup fails", async () => {
const listen = vi.spyOn(Server.prototype, "listen").mockImplementation(function (this: Server, ...args: unknown[]) {
const callback = args.at(-1);
if (typeof callback === "function") queueMicrotask(callback as () => void);
return this;
});
const address = vi.spyOn(Server.prototype, "address").mockReturnValue(null);
const close = vi.spyOn(Server.prototype, "close").mockImplementation(function (this: Server, callback?: (error?: Error) => void) {
callback?.();
return this;
});
const before = new Set(readdirSync(tmpdir()).filter((name) => name.startsWith("fusion-acp-mcp-schemas-")));
try {
await expect(startFusionToolBridge([{ name: "fn_startup", execute: async () => "ok" }])).rejects.toThrow("failed to bind");
const after = readdirSync(tmpdir()).filter((name) => name.startsWith("fusion-acp-mcp-schemas-"));
expect(after.filter((name) => !before.has(name))).toEqual([]);
expect(close).toHaveBeenCalled();
} finally {
listen.mockRestore();
address.mockRestore();
close.mockRestore();
}
});
it("filters built-ins and maps tool schemas", () => {
expect(
toolsToMcpToolDefs([
{ name: "read", description: "builtin", parameters: {} },
{ name: "fn_not_runnable", description: "missing execute", parameters: {} },
{ name: "fn_task_list", description: "List tasks", parameters: { type: "object", properties: {} }, execute: async () => ({}) },
]),
).toEqual([
{
name: "fn_task_list",
description: "List tasks",
inputSchema: { type: "object", properties: {} },
},
]);
});
it("preserves an isError result and its text", async () => {
const bridge = await startFusionToolBridge([
{
name: "fn_failed",
execute: async () => ({ isError: true, text: "tool failed" }),
},
]);
expect(bridge).not.toBeNull();
const env = bridge!.mcpServer.env;
const bridgeUrl = env.find((entry) => entry.name === "FUSION_ACP_TOOL_BRIDGE_URL")!.value;
const token = env.find((entry) => entry.name === "FUSION_ACP_TOOL_BRIDGE_TOKEN")!.value;
const response = await fetch(`${bridgeUrl}/tool-call`, {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: JSON.stringify({ name: "fn_failed", arguments: {} }),
});
expect(await response.json()).toEqual({
isError: true,
content: [{ type: "text", text: "tool failed" }],
});
await bridge!.dispose();
});
it("returns null when there are no custom tools", async () => {
expect(await startFusionToolBridge([])).toBeNull();
expect(await startFusionToolBridge(undefined)).toBeNull();
});
it("starts a bridge that executes Fusion custom tools over authenticated HTTP", async () => {
const bridge = await startFusionToolBridge([
{
name: "fn_heartbeat_done",
description: "Finish heartbeat",
parameters: { type: "object", properties: {} },
execute: async (toolCallId: string) => ({ text: `done:${typeof toolCallId}:${toolCallId}` }),
},
]);
expect(bridge).not.toBeNull();
expect(bridge!.toolCount).toBe(1);
expect(bridge!.mcpServer.name).toBe("fusion-custom-tools");
expect(bridge!.mcpServer.command).toBe(process.execPath);
const env = bridge!.mcpServer.env;
const bridgeUrl = env.find((e) => e.name === "FUSION_ACP_TOOL_BRIDGE_URL")?.value;
const token = env.find((e) => e.name === "FUSION_ACP_TOOL_BRIDGE_TOKEN")?.value;
expect(bridgeUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(token).toBeTruthy();
// No token → 401 (a same-host probe must not invoke Fusion closures).
const unauthorized = await fetch(`${bridgeUrl}/tool-call`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "fn_heartbeat_done", arguments: {} }),
});
expect(unauthorized.status).toBe(401);
// Authenticated call threads the real toolCallId.
const res = await fetch(`${bridgeUrl}/tool-call`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ name: "fn_heartbeat_done", toolCallId: "call-42", arguments: {} }),
});
const body = (await res.json()) as { isError?: boolean; content?: Array<{ text?: string }> };
expect(body.isError).toBe(false);
expect(body.content?.[0]?.text).toBe("done:string:call-42");
const numericId = await fetch(`${bridgeUrl}/tool-call`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ name: "fn_heartbeat_done", toolCallId: 42, arguments: {} }),
});
const numericBody = (await numericId.json()) as { content?: Array<{ text?: string }> };
// The numeric JSON-RPC id must be threaded as the real toolCallId (coerced to
// string), never replaced by a fabricated UUID fallback.
expect(numericBody.content?.[0]?.text).toBe("done:string:42");
// Unknown tool → 404.
const unknown = await fetch(`${bridgeUrl}/tool-call`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ name: "fn_nope", arguments: {} }),
});
expect(unknown.status).toBe(404);
await bridge!.dispose();
// Port closed after dispose: a follow-up request must fail.
await expect(
fetch(`${bridgeUrl}/tool-call`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ name: "fn_heartbeat_done", arguments: {} }),
}),
).rejects.toThrow();
});
it("does not execute built-in tools posted directly to the bridge", async () => {
let executions = 0;
const bridge = await startFusionToolBridge([
{ name: "read", parameters: {}, execute: async () => { executions += 1; return "should not run"; } },
{ name: "fn_allowed", parameters: {}, execute: async () => "ok" },
]);
expect(bridge).not.toBeNull();
const env = bridge!.mcpServer.env;
const bridgeUrl = env.find((entry) => entry.name === "FUSION_ACP_TOOL_BRIDGE_URL")!.value;
const token = env.find((entry) => entry.name === "FUSION_ACP_TOOL_BRIDGE_TOKEN")!.value;
const response = await fetch(`${bridgeUrl}/tool-call`, {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: JSON.stringify({ name: "read", arguments: {} }),
});
expect(response.status).toBe(404);
expect(executions).toBe(0);
await bridge!.dispose();
});
it("does not leak an unhandled rejection when a request aborts", async () => {
let unhandled: unknown;
let started!: () => void;
let rejectHandler!: (reason: Error) => void;
const handlerStarted = new Promise<void>((resolve) => { started = resolve; });
const handler = new Promise<never>((_resolve, reject) => { rejectHandler = reject; });
const onUnhandled = (reason: unknown) => { unhandled = reason; };
process.on("unhandledRejection", onUnhandled);
let bridge: Awaited<ReturnType<typeof startFusionToolBridge>> | undefined;
try {
bridge = await startFusionToolBridge([
{ name: "fn_reject", parameters: {}, execute: async () => { started(); return handler; } },
]);
expect(bridge).not.toBeNull();
const env = bridge!.mcpServer.env;
const bridgeUrl = env.find((entry) => entry.name === "FUSION_ACP_TOOL_BRIDGE_URL")!.value;
const token = env.find((entry) => entry.name === "FUSION_ACP_TOOL_BRIDGE_TOKEN")!.value;
const controller = new AbortController();
const request = fetch(`${bridgeUrl}/tool-call`, {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: JSON.stringify({ name: "fn_reject", arguments: {} }),
signal: controller.signal,
}).catch(() => undefined);
await handlerStarted;
controller.abort();
rejectHandler(new Error("handler failed"));
await request;
expect(unhandled).toBeUndefined();
} finally {
await bridge?.dispose();
process.off("unhandledRejection", onUnhandled);
}
});
it("dispose is idempotent and removes the temporary schema", async () => {
const bridge = await startFusionToolBridge([
{ name: "fn_task_list", description: "List", parameters: {}, execute: async () => ({ text: "ok" }) },
]);
expect(bridge).not.toBeNull();
const schemaPath = bridge!.mcpServer.args[1] as string;
expect(existsSync(schemaPath)).toBe(true);
await bridge!.dispose();
await bridge!.dispose();
expect(existsSync(schemaPath)).toBe(false);
});
it("does not resume a request into tool execution after disposal", async () => {
const close = vi.spyOn(Server.prototype, "close").mockImplementation(function (this: Server) {
return this;
});
const closeAllConnections = vi.spyOn(Server.prototype, "closeAllConnections").mockImplementation(() => undefined);
let executions = 0;
const bridge = await startFusionToolBridge([
{ name: "fn_after_dispose", parameters: {}, execute: async () => { executions += 1; return "must not run"; } },
]);
expect(bridge).not.toBeNull();
const env = bridge!.mcpServer.env;
const bridgeUrl = new URL(env.find((entry) => entry.name === "FUSION_ACP_TOOL_BRIDGE_URL")!.value);
const token = env.find((entry) => entry.name === "FUSION_ACP_TOOL_BRIDGE_TOKEN")!.value;
const pendingResponse = new Promise<{ status: number }>((resolve, reject) => {
const req = httpRequest({
hostname: bridgeUrl.hostname,
port: Number(bridgeUrl.port),
path: "/tool-call",
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
}, (response) => resolve({ status: response.statusCode ?? 0 }));
req.once("error", reject);
req.flushHeaders();
void bridge!.dispose().then(() => req.end(JSON.stringify({ name: "fn_after_dispose", arguments: {} })));
});
try {
await expect(pendingResponse).resolves.toMatchObject({ status: 503 });
expect(executions).toBe(0);
} finally {
close.mockRestore();
closeAllConnections.mockRestore();
await bridge?.dispose();
}
});
it("aborts and awaits an in-flight tool before disposal completes", async () => {
let started!: () => void;
const executionStarted = new Promise<void>((resolve) => {
started = resolve;
});
let finished = false;
const bridge = await startFusionToolBridge([
{
name: "fn_slow",
parameters: {},
execute: async (_id, _args, signal) => {
started();
await new Promise<void>((resolve) => {
if (signal?.aborted) return resolve();
signal?.addEventListener("abort", () => resolve(), { once: true });
});
finished = true;
return { text: "done" };
},
},
]);
expect(bridge).not.toBeNull();
const env = bridge!.mcpServer.env;
const bridgeUrl = env.find((e) => e.name === "FUSION_ACP_TOOL_BRIDGE_URL")!.value;
const token = env.find((e) => e.name === "FUSION_ACP_TOOL_BRIDGE_TOKEN")!.value;
const request = fetch(`${bridgeUrl}/tool-call`, {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: JSON.stringify({ name: "fn_slow", arguments: {} }),
});
await executionStarted;
let disposed = false;
const disposing = bridge!.dispose().then(() => {
disposed = true;
});
expect(disposed).toBe(false);
await disposing;
expect(finished).toBe(true);
await expect(request).rejects.toThrow();
});
it("does not wait for the server close callback before draining", async () => {
const close = vi.spyOn(Server.prototype, "close").mockImplementation(function (this: Server) {
return this;
});
const closeAllConnections = vi.spyOn(Server.prototype, "closeAllConnections").mockImplementation(() => undefined);
let started!: () => void;
const executionStarted = new Promise<void>((resolve) => {
started = resolve;
});
const bridge = await startFusionToolBridge([
{
name: "fn_cooperative",
parameters: {},
execute: async (_id, _args, signal) => {
started();
await new Promise<void>((resolve) => signal?.addEventListener("abort", () => resolve(), { once: true }));
return "done";
},
},
]);
try {
const env = bridge!.mcpServer.env;
const request = fetch(`${env.find((entry) => entry.name === "FUSION_ACP_TOOL_BRIDGE_URL")!.value}/tool-call`, {
method: "POST",
headers: { authorization: `Bearer ${env.find((entry) => entry.name === "FUSION_ACP_TOOL_BRIDGE_TOKEN")!.value}` },
body: JSON.stringify({ name: "fn_cooperative", arguments: {} }),
});
await executionStarted;
await expect(Promise.race([
bridge!.dispose(),
new Promise((_, reject) => setTimeout(() => reject(new Error("dispose timed out")), 500)),
])).resolves.toBeUndefined();
await request;
expect(close).toHaveBeenCalled();
expect(closeAllConnections).toHaveBeenCalled();
} finally {
close.mockRestore();
closeAllConnections.mockRestore();
await bridge?.dispose();
}
});
it("completes disposal and removes the schema when a tool ignores abort", async () => {
let started!: () => void;
const executionStarted = new Promise<void>((resolve) => {
started = resolve;
});
const bridge = await startFusionToolBridge([
{
name: "fn_never_settles",
parameters: {},
execute: async () => {
started();
return new Promise(() => undefined);
},
},
]);
expect(bridge).not.toBeNull();
const schemaPath = bridge!.mcpServer.args[1] as string;
const env = bridge!.mcpServer.env;
const bridgeUrl = env.find((entry) => entry.name === "FUSION_ACP_TOOL_BRIDGE_URL")!.value;
const token = env.find((entry) => entry.name === "FUSION_ACP_TOOL_BRIDGE_TOKEN")!.value;
const request = fetch(`${bridgeUrl}/tool-call`, {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: JSON.stringify({ name: "fn_never_settles", arguments: {} }),
});
// Handle the eventual socket-close rejection up front so it can never surface
// as an unhandled rejection while dispose drains for TOOL_DRAIN_TIMEOUT_MS.
const requestOutcome = request.then(
() => "resolved",
() => "rejected",
);
// Wait until the tool body is running so the execution is tracked before dispose.
await executionStarted;
await expect(bridge!.dispose()).resolves.toBeUndefined();
expect(existsSync(schemaPath)).toBe(false);
expect(await requestOutcome).toBe("rejected");
}, 10_000);
it("serves the full MCP protocol from the co-located schema server", async () => {
const directory = await mkdtemp(join(tmpdir(), "fusion-acp-mcp-smoke-"));
const schemaPath = join(directory, "schemas.json");
await writeFile(
schemaPath,
JSON.stringify([
{ name: "fn_heartbeat_done", description: "Finish heartbeat", inputSchema: { type: "object", properties: {} } },
]),
);
const child = spawn(process.execPath, [fusionToolsMcpServerPath(), schemaPath], {
env: { ...process.env, FUSION_ACP_TOOL_BRIDGE_URL: "http://127.0.0.1:1" },
stdio: ["pipe", "pipe", "pipe"],
});
try {
const initialized = await mcpRequest(child, '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}');
expect((initialized.result as { serverInfo?: { name?: string } }).serverInfo?.name).toBe("fusion-custom-tools");
const pinged = await mcpRequest(child, '{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}');
expect(pinged.result).toEqual({});
const listed = await mcpRequest(child, '{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}');
const tools = (listed.result as { tools?: Array<{ name: string }> }).tools ?? [];
expect(tools.map((tool) => tool.name)).toEqual(["fn_heartbeat_done"]);
// tools/call against a dead bridge (port 1) must surface an error result,
// never an empty "success".
const called = await mcpRequest(
child,
'{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"fn_heartbeat_done","arguments":{}}}',
);
expect((called.result as { isError?: boolean }).isError).toBe(true);
// Unknown method → -32601.
const missing = await mcpRequest(child, '{"jsonrpc":"2.0","id":5,"method":"bogus","params":{}}');
expect((missing.error as { code?: number }).code).toBe(-32601);
} finally {
child.kill();
await rm(directory, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env node
/*
FNXC:AcpCustomTools 2026-08-16-00:30:
Executable MCP bridge for Fusion custom tools (fn_*) on generic ACP paths
(Hermes ACP, Prime, ...). tools/list is served from a schema file; tools/call
POSTs to a localhost bridge owned by AcpRuntimeAdapter so ToolDefinition.execute
runs in-process with the engine's closures. The bridge authenticates the POST
with a per-session bearer token passed via FUSION_ACP_TOOL_BRIDGE_TOKEN.
*/
"use strict";
const fs = require("fs");
const http = require("http");
const readline = require("readline");
// FNXC:AcpCustomTools 2026-08-16-00:30: CJS has no global URL under eslint no-undef; use node:url.
const { URL } = require("node:url");
const schemaPath = process.argv[2];
const bridgeUrl = process.env.FUSION_ACP_TOOL_BRIDGE_URL;
const bridgeToken = process.env.FUSION_ACP_TOOL_BRIDGE_TOKEN;
if (!schemaPath || !bridgeUrl) {
process.stderr.write("fusion-custom-tools-mcp: missing schema path or FUSION_ACP_TOOL_BRIDGE_URL\n");
process.exit(1);
}
let tools = [];
try {
tools = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
if (!Array.isArray(tools)) tools = [];
} catch {
process.exit(1);
}
function write(msg) {
process.stdout.write(JSON.stringify(msg) + "\n");
}
function callBridge(toolName, toolCallId, args) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({ name: toolName, toolCallId, arguments: args ?? {} });
const url = new URL("/tool-call", bridgeUrl);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: "POST",
headers: {
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
...(bridgeToken ? { authorization: `Bearer ${bridgeToken}` } : {}),
},
timeout: 120_000,
},
(res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
// A non-2xx bridge response is a transport failure, not a tool result:
// surface it as an error instead of fabricating an empty "success".
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`tool bridge responded ${res.statusCode}: ${data.slice(0, 200)}`));
return;
}
try {
resolve(JSON.parse(data || "{}"));
} catch (err) {
reject(err);
}
});
},
);
req.on("error", reject);
req.on("timeout", () => {
req.destroy(new Error("tool bridge timeout"));
});
req.write(body);
req.end();
});
}
const rl = readline.createInterface({ input: process.stdin });
rl.on("line", (line) => {
let msg;
try {
msg = JSON.parse(line);
} catch {
return;
}
if (msg.method === "initialize") {
write({
jsonrpc: "2.0",
id: msg.id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "fusion-custom-tools", version: "1.0.0" },
},
});
return;
}
// Ping must answer: some clients (Hermes ACP) treat a missing handler as a
// method-not-found and back off.
if (msg.method === "ping") {
write({ jsonrpc: "2.0", id: msg.id, result: {} });
return;
}
if (msg.method === "notifications/initialized" || msg.method === "initialized") {
return;
}
if (msg.method === "tools/list") {
write({
jsonrpc: "2.0",
id: msg.id,
result: {
tools: tools.map((tool) => ({
name: tool.name,
description: tool.description ?? "",
inputSchema: tool.inputSchema ?? { type: "object", properties: {} },
})),
},
});
return;
}
if (msg.method === "tools/call") {
const toolName = msg.params?.name;
const args = msg.params?.arguments ?? {};
// JSON-RPC ids are commonly numbers; coerce so the bridge threads the real
// request id as the toolCallId instead of falling back to a fabricated id.
const toolCallId = typeof msg.id === "string" ? msg.id : String(msg.id);
callBridge(toolName, toolCallId, args)
.then((result) => {
write({
jsonrpc: "2.0",
id: msg.id,
result: {
content: Array.isArray(result.content)
? result.content
: [{ type: "text", text: typeof result.text === "string" ? result.text : JSON.stringify(result) }],
isError: result.isError === true,
},
});
})
.catch((err) => {
write({
jsonrpc: "2.0",
id: msg.id,
result: {
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
isError: true,
},
});
});
return;
}
if (msg.id !== undefined) {
write({
jsonrpc: "2.0",
id: msg.id,
error: { code: -32601, message: `Method not found: ${msg.method}` },
});
}
});

View File

@@ -18,6 +18,7 @@ import {
} from "./provider.js";
import { buildSpawnEnv } from "./process-manager.js";
import { buildPromptBlocks, extractPromptImagesFromOptions } from "./prompt-builder.js";
import { startFusionToolBridge, type FusionToolBridge, type ToolLike } from "./tool-bridge.js";
import type {
AgentRuntime,
AgentRuntimeOptions,
@@ -88,29 +89,56 @@ export class AcpRuntimeAdapter implements AgentRuntime {
// caller supplied them (U10 — Route A); absent/empty keeps the Route B
// read-only ask posture. Tool calls still route through the U5 permission floor.
//
// FNXC:AcpCustomTools 2026-08-16-00:30:
// Engine customTools (fn_*) ride the same mcpServers channel: a loopback tool
// bridge is started in-process and registered as a stdio MCP server so any
// ACP agent (Hermes ACP, Prime, ...) can invoke Fusion closures. The bridge
// is disposed on session/new failure and on session teardown.
//
// FNXC:GrokAcp 2026-07-11-14:00:
// Callers (Grok runtime) may also pass `_meta` (pluginDirs / rules /
// systemPromptOverride) via options.sessionMeta so agent-specific skill and
// prompt setup rides on session/new without a second protocol hop.
let toolBridge: FusionToolBridge | null = null;
let toolBridgeFailure: "mcp-schema-server-missing" | "bridge-start-failed" | undefined;
let sessionId: string;
const customTools = Array.isArray(options.customTools) ? (options.customTools as ToolLike[]) : [];
try {
if (customTools.length > 0) {
try {
toolBridge = await startFusionToolBridge(customTools);
} catch (error) {
toolBridgeFailure = (error as { code?: string }).code === "mcp-schema-server-missing"
? "mcp-schema-server-missing"
: "bridge-start-failed";
options.onText?.(`FUSION_TOOL_BRIDGE_FAILED: ${toolBridgeFailure}`);
}
}
const sessionMeta =
options && typeof options === "object" && "sessionMeta" in options
? (options as { sessionMeta?: Record<string, unknown> }).sessionMeta
: undefined;
const opened = await newAcpSession(connection, {
cwd: options.cwd,
mcpServers: options.mcpServers,
mcpServers: [...(options.mcpServers ?? []), ...(toolBridge ? [toolBridge.mcpServer] : [])],
meta: sessionMeta,
});
sessionId = opened.sessionId;
} catch (err) {
// Don't leak the subprocess if session/new fails after a good handshake.
// Don't leak the subprocess or the bridge if session/new fails after a
// good handshake.
await toolBridge?.dispose();
connection.dispose();
throw err;
}
let disposed = false;
let bridgeDisposePromise: Promise<void> | undefined;
let disposePromise = Promise.resolve();
const disposeBridge = (): Promise<void> => {
bridgeDisposePromise ??= toolBridge?.dispose() ?? Promise.resolve();
return bridgeDisposePromise;
};
const session: AcpSession = {
model,
systemPrompt: options.systemPrompt,
@@ -118,6 +146,7 @@ export class AcpRuntimeAdapter implements AgentRuntime {
cwd: options.cwd,
lastModelDescription: `acp/${model}`,
callbacks,
fusionToolBridgeError: toolBridgeFailure ? { reasonCode: toolBridgeFailure } : undefined,
// Persist the per-run gate (KTD3) so U5/U7 can reach the live action gate.
gate: options.actionGateContext,
connection,
@@ -125,12 +154,19 @@ export class AcpRuntimeAdapter implements AgentRuntime {
// turn that trips the per-turn output cap can't latch and suppress every
// subsequent turn (FIX 1).
resetTurn,
disposeBridge,
get disposePromise() {
return disposePromise;
},
dispose: () => {
if (disposed) return;
disposed = true;
// Drain in-flight permission requests BEFORE the registry kill so a
// blocked agent is released (KTD4a — the SIGKILL is still authoritative).
cancelPending();
// Close the loopback tool bridge so no port or schema outlives the
// session (idempotent).
disposePromise = disposeBridge();
connection.dispose();
},
};
@@ -184,6 +220,7 @@ export class AcpRuntimeAdapter implements AgentRuntime {
if (acp.connection && acp.sessionId) {
await cancelAcpSession(acp.connection, acp.sessionId);
}
await acp.disposeBridge?.();
session.dispose();
}
}

View File

@@ -0,0 +1,273 @@
/*
FNXC:AcpCustomTools 2026-08-16-00:30:
Host Fusion custom tools (fn_*) for any ACP agent (Hermes ACP, Prime, ...).
ToolDefinition.execute closures only run in-process, so AcpRuntimeAdapter starts
a loopback HTTP bridge and pairs it with mcp-schema-server.cjs (stdio MCP) that
the agent connects to via session/new.mcpServers. Dispose closes the bridge and
removes the temporary schema so no port or file outlives the session.
*/
import { createServer, type Server } from "node:http";
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { randomBytes, randomUUID } from "node:crypto";
import type { AcpMcpServerStdio } from "./types.js";
const BUILT_IN_TOOL_NAMES = new Set(["read", "write", "edit", "bash", "grep", "find"]);
// Custom tools may ignore AbortSignal; bound the drain so dispose never hangs
// on a tool that never settles (abort already unblocks cooperative tools).
const TOOL_DRAIN_TIMEOUT_MS = 5_000;
export interface ToolLike {
name: string;
description?: string;
parameters?: Record<string, unknown>;
execute?: (
toolCallId: string,
params: unknown,
signal?: AbortSignal,
onUpdate?: unknown,
ctx?: unknown,
) => Promise<unknown> | unknown;
}
export interface McpToolDef {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
export interface FusionToolBridge {
mcpServer: AcpMcpServerStdio;
dispose: () => Promise<void>;
toolCount: number;
}
export function toolsToMcpToolDefs(tools: ReadonlyArray<ToolLike> | undefined): McpToolDef[] {
if (!Array.isArray(tools)) return [];
return tools
.filter((tool) => tool && typeof tool.name === "string" && tool.name.trim().length > 0 && !BUILT_IN_TOOL_NAMES.has(tool.name) && typeof tool.execute === "function")
.map((tool) => ({
name: tool.name,
description: typeof tool.description === "string" ? tool.description : "",
inputSchema: tool.parameters ?? { type: "object", properties: {} },
}));
}
/*
FNXC:AcpCustomToolsPackaging 2026-08-16-00:30:
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 builds (the Grok 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;
if (typeof result === "object") {
const obj = result as { content?: unknown; text?: unknown };
if (typeof obj.text === "string") return obj.text;
if (Array.isArray(obj.content)) {
return obj.content
.map((block) => {
if (block && typeof block === "object" && "text" in block && typeof (block as { text: unknown }).text === "string") {
return (block as { text: string }).text;
}
return JSON.stringify(block);
})
.join("\n");
}
}
try {
return JSON.stringify(result);
} catch {
return String(result);
}
}
/**
* Start a loopback tool bridge and return the ACP mcpServers stdio entry the
* agent should connect to for Fusion custom tools. Returns null when there are
* no tools. The bridge authenticates every request with a per-session bearer
* token so a same-host probe cannot invoke Fusion closures.
*/
export async function startFusionToolBridge(tools: ReadonlyArray<ToolLike> | undefined): Promise<FusionToolBridge | null> {
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" && !BUILT_IN_TOOL_NAMES.has(tool.name) && typeof tool.execute === "function") {
byName.set(tool.name, tool);
}
}
const token = randomBytes(24).toString("hex");
const schemaPath = join(tmpdir(), `fusion-acp-mcp-schemas-${process.pid}-${randomUUID()}.json`);
writeFileSync(schemaPath, JSON.stringify(defs));
const activeExecutions = new Set<Promise<void>>();
const activeControllers = new Set<AbortController>();
const server: Server = createServer((req, res) => {
void (async () => {
if (req.method !== "POST" || req.url !== "/tool-call") {
res.statusCode = 404;
res.end(JSON.stringify({ isError: true, text: "not found" }));
return;
}
// Per-session bearer token: the MCP shim carries it; anything else is rejected.
const auth = req.headers.authorization;
if (auth !== `Bearer ${token}`) {
res.statusCode = 401;
res.end(JSON.stringify({ isError: true, text: "unauthorized" }));
return;
}
let body = "";
for await (const chunk of req) body += chunk;
if (disposed) {
res.statusCode = 503;
res.end(JSON.stringify({ isError: true, text: "tool bridge disposed" }));
return;
}
let parsed: { name?: string; toolCallId?: string | number; arguments?: unknown };
try {
parsed = JSON.parse(body || "{}") as { name?: string; toolCallId?: string | number; arguments?: unknown };
} catch {
res.statusCode = 400;
res.end(JSON.stringify({ isError: true, text: "invalid JSON body" }));
return;
}
const name = typeof parsed.name === "string" ? parsed.name : "";
const tool = byName.get(name);
if (!tool?.execute) {
res.statusCode = 404;
res.end(JSON.stringify({ isError: true, text: `Unknown Fusion tool: ${name}` }));
return;
}
const execute = tool.execute;
const controller = new AbortController();
activeControllers.add(controller);
const execution = (async () => {
try {
// Thread the real MCP request id as the toolCallId so correlation,
// cancellation, and dedupe keep working (never a fabricated id).
const result = await execute(
(typeof parsed.toolCallId === "string" || typeof parsed.toolCallId === "number") && String(parsed.toolCallId)
? String(parsed.toolCallId)
: `acp-mcp-${randomUUID()}`,
parsed.arguments ?? {},
controller.signal,
undefined,
undefined,
);
res.statusCode = 200;
res.setHeader("content-type", "application/json");
res.end(
JSON.stringify({
isError: typeof result === "object" && result !== null && "isError" in result && (result as { isError?: unknown }).isError === true,
content: [{ type: "text", text: resultToText(result) }],
}),
);
} catch (err) {
res.statusCode = 200;
res.setHeader("content-type", "application/json");
res.end(
JSON.stringify({
isError: true,
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
}),
);
}
})();
activeExecutions.add(execution);
try {
await execution;
} finally {
activeExecutions.delete(execution);
activeControllers.delete(controller);
}
})().catch(() => {
// Request streams and response sockets can abort independently of the handler.
});
});
let address: { port: number };
try {
address = await new Promise<{ port: number }>((resolve, reject) => {
server.once("error", reject);
// Bind loopback only — never expose Fusion tools on a public interface.
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
if (!addr || typeof addr === "string") {
reject(new Error("tool bridge failed to bind"));
return;
}
resolve({ port: addr.port });
});
});
} catch (error) {
server.close();
try {
unlinkSync(schemaPath);
} catch {
// Schema cleanup is best effort after startup failure.
}
throw error;
}
const bridgeUrl = `http://127.0.0.1:${address.port}`;
let disposed = false;
return {
toolCount: defs.length,
mcpServer: {
name: "fusion-custom-tools",
command: process.execPath,
args: [serverPath, schemaPath],
env: [
{ name: "FUSION_ACP_TOOL_BRIDGE_URL", value: bridgeUrl },
{ name: "FUSION_ACP_TOOL_BRIDGE_TOKEN", value: token },
],
},
dispose: async () => {
if (disposed) return;
disposed = true;
for (const controller of activeControllers) controller.abort();
server.close(() => undefined);
if (typeof server.closeAllConnections === "function") {
server.closeAllConnections();
}
await Promise.allSettled(
[...activeExecutions].map((execution) =>
Promise.race([
execution,
new Promise<void>((resolve) => {
const timer = setTimeout(resolve, TOOL_DRAIN_TIMEOUT_MS);
timer.unref?.();
}),
]),
),
);
try {
unlinkSync(schemaPath);
} catch {
// Schema may already be gone; disposal stays idempotent.
}
},
};
}

View File

@@ -116,6 +116,13 @@ export interface AgentRuntimeOptions {
cwd: string;
systemPrompt: string;
tools?: "coding" | "readonly";
/**
* Engine-assembled Fusion custom tools (fn_*). ToolDefinition.execute closures
* only run in-process, so the ACP runtime exposes them to the agent through a
* loopback tool bridge registered in `session/new.mcpServers` (same pattern as
* the Grok runtime). Absent/empty keeps Route B's read-only ask posture.
*/
customTools?: unknown;
onText?: (text: string) => void;
onThinking?: (text: string) => void;
onToolStart?: (toolName: string, args?: unknown) => void;
@@ -149,6 +156,7 @@ export interface AcpSession {
/** Working directory the agent operates over (the task worktree). */
cwd: string;
lastModelDescription: string;
fusionToolBridgeError?: { reasonCode: "mcp-schema-server-missing" | "bridge-start-failed" };
callbacks: AcpCallbacks;
/** Per-run permission gate captured at createSession (U5/U7 read this). */
gate?: PermissionGate;
@@ -163,6 +171,10 @@ export interface AcpSession {
* of each turn (FIX 1). Undefined for the bare session shell used in tests.
*/
resetTurn?: () => void;
/** Awaitable bridge cleanup used by AgentRuntime.dispose; absent for bare sessions. */
disposeBridge?: () => Promise<void>;
/** Completion of the most recent direct dispose call. */
disposePromise?: Promise<void>;
dispose(): void;
}