Files
fusion/plugins/fusion-plugin-cursor-runtime/src/tool-bridge.ts
gsxdsm 5e5b0dbb8f FN-9098: bridge scoped Fusion tools into Cursor
Publish engine-owned Fusion tools to Cursor through a crash-safe, worktree-scoped MCP bridge.

- preserve operator MCP configuration with locking, journaling, quarantine, and lease reconciliation
- enforce identity-scoped fn_* provenance so injected custom and MCP tools are never exposed
- secure loopback dispatch with per-session tokens, heartbeats, cleanup, and normalized tool events
- document the Cursor contract and cover bridge lifecycle, config hygiene, and failure handling

Files changed:
 .changeset/fn-9098-cursor-mcp-bridge.md            |   7 +
 docs/cursor-cli-contract.md                        | 140 ++-------------
 docs/mcp.md                                        |   4 +
 .../src/__tests__/agent-session-helpers.test.ts    |  24 +++
 .../src/__tests__/step-session-executor.test.ts    |  16 ++
 .../src/__tests__/web-fetch-universal.test.ts      |   4 +-
 packages/engine/src/agent-heartbeat.ts             |   3 +-
 packages/engine/src/agents/agent-runtime.ts        |   9 +
 .../engine/src/agents/agent-session-helpers.ts     |  26 +--
 packages/engine/src/execution/reviewer.ts          |   1 +
 .../engine/src/execution/step-session-executor.ts  |  31 ++--
 .../engine/src/executor/execute-workflow-step.ts   |   4 +-
 packages/engine/src/merger.ts                      |   4 +-
 plugins/fusion-plugin-cursor-runtime/README.md     |  18 +-
 plugins/fusion-plugin-cursor-runtime/package.json  |   2 +-
 .../src/__tests__/cursor-mcp-config.test.ts        | 100 +++++++++++
 .../cursor-mcp-server-failure.stream.jsonl         |   3 +
 .../fixtures/cursor-mcp-tool-call.stream.jsonl     |   4 +
 .../src/__tests__/runtime-adapter.test.ts          |  57 +++++-
 .../src/__tests__/worktree-hygiene.test.ts         |  52 ++++++
 .../src/cursor-mcp-config.ts                       | 196 +++++++++++++++++++++
 .../src/mcp-schema-server.cjs                      | 155 ++++++++++++++++
 .../src/prompt-transport.ts                        |   4 +-
 .../src/runtime-adapter.ts                         |  67 +++++--
 .../src/tool-bridge.ts                             |  48 +++++
 .../src/tool-mapping.ts                            |  11 ++
 plugins/fusion-plugin-cursor-runtime/src/types.ts  |   6 +-
 .../src/worktree-hygiene.ts                        | 117 ++++++++++++
 28 files changed, 934 insertions(+), 179 deletions(-)

Fusion-Task-Id: FN-9098

Fusion-Task-Lineage: 11b6cb10-ce0e-4f33-9007-c83f2bbf82ea

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-15 16:51:54 -07:00

49 lines
5.1 KiB
TypeScript

/*
FNXC:CursorMcpBridge 2026-08-15-20:56:
Cursor launches MCP servers from its project config, but Fusion tool closures remain in this process.
A per-session capability token limits the loopback bridge to its stdio child; never expose this endpoint beyond localhost or log its token.
*/
import { timingSafeEqual, randomUUID } from "node:crypto";
import { createServer } from "node:http";
import { existsSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
export interface ToolLike { name: string; description?: string; parameters?: Record<string, unknown>; execute?: (id: string, args: unknown, signal?: AbortSignal, update?: unknown, context?: unknown) => unknown | Promise<unknown>; }
export interface McpToolDef { name: string; description: string; inputSchema: Record<string, unknown>; }
export interface CursorToolBridge { serverEntry: { command: string; args: string[]; env: Record<string, string> }; dispose: () => Promise<void>; toolCount: number; token: string; }
/*
FNXC:CursorMcpBridge 2026-08-15-23:46:
The engine passes a dedicated identity-derived Fusion-tool subset. Keep fn_* and executable checks here as defense in depth; names alone never establish provenance.
*/
export const toolsToMcpToolDefs = (tools: readonly ToolLike[] | undefined): McpToolDef[] => (tools ?? [])
.filter((tool) => tool?.name.startsWith("fn_") && typeof tool.execute === "function")
.map((tool) => ({ name: tool.name, description: tool.description ?? "", inputSchema: tool.parameters ?? { type: "object", properties: {} } }));
export const cursorMcpSchemaServerPath = (): string => join(dirname(fileURLToPath(import.meta.url)), "mcp-schema-server.cjs");
const text = (value: unknown): string => { if (value == null) return ""; if (typeof value === "string") return value; if (typeof value === "object" && value && "text" in value && typeof (value as { text?: unknown }).text === "string") return (value as { text: string }).text; try { return JSON.stringify(value); } catch { return String(value); } };
const loopback = (address: string | undefined) => address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
export async function startCursorToolBridge(tools: readonly ToolLike[] | undefined): Promise<CursorToolBridge | null> {
const defs = toolsToMcpToolDefs(tools); if (!defs.length) return null;
const asset = cursorMcpSchemaServerPath(); if (!existsSync(asset)) { const error = Object.assign(new Error(`Fusion MCP schema server is missing: ${asset}`), { code: "mcp-schema-server-missing" }); throw error; }
const token = randomUUID();
const publishedNames = new Set(defs.map((tool) => tool.name));
const byName = new Map((tools ?? []).filter((tool) => publishedNames.has(tool.name) && typeof tool.execute === "function").map((tool) => [tool.name, tool]));
const schema = join(tmpdir(), `fusion-cursor-mcp-schemas-${process.pid}-${randomUUID()}.json`); writeFileSync(schema, JSON.stringify(defs), { mode: 0o600 }); chmodSync(schema, 0o600);
const server = createServer(async (req, res) => {
const host = req.headers.host; const localHost = `127.0.0.1:${(server.address() as { port: number } | null)?.port ?? ""}`;
const auth = req.headers.authorization; const provided = auth?.startsWith("Bearer ") ? auth.slice(7) : "";
const matches = provided.length === token.length && timingSafeEqual(Buffer.from(provided), Buffer.from(token));
if (!loopback(req.socket.remoteAddress) || host !== localHost || !matches) { res.statusCode = 401; res.end(); return; }
if (req.method !== "POST" || req.url !== "/tool-call") { res.statusCode = 404; res.end(); return; }
let raw = ""; for await (const chunk of req) raw += chunk;
let body: { name?: string; arguments?: unknown }; try { body = JSON.parse(raw || "{}"); } catch { res.statusCode = 400; res.end(JSON.stringify({ isError: true, text: "invalid JSON body" })); return; }
const tool = typeof body.name === "string" ? byName.get(body.name) : undefined;
res.setHeader("content-type", "application/json");
if (!tool?.execute) { res.end(JSON.stringify({ isError: true, content: [{ type: "text", text: "Unknown Fusion tool" }] })); return; }
try { const result = await tool.execute(`cursor-mcp-${randomUUID()}`, body.arguments ?? {}); res.end(JSON.stringify({ isError: false, content: [{ type: "text", text: text(result) }] })); } catch (error) { res.end(JSON.stringify({ isError: true, content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }] })); }
});
const port = await new Promise<number>((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => resolve((server.address() as { port: number }).port)); });
return { token, toolCount: defs.length, serverEntry: { command: process.execPath, args: [asset, schema], env: { FUSION_CURSOR_TOOL_BRIDGE_URL: `http://127.0.0.1:${port}`, FUSION_CURSOR_TOOL_BRIDGE_TOKEN: token } }, dispose: async () => { await new Promise<void>((resolve) => server.close(() => resolve())); try { unlinkSync(schema); } catch { /* schema was already removed */ } } };
}