Files
fusion/plugins/fusion-plugin-cursor-runtime/src/runtime-adapter.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

76 lines
6.0 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { launchCursorPrompt } from "./prompt-transport.js";
import { classifyCursorConfigTracking } from "./worktree-hygiene.js";
import { reconcileCursorWorktree, releaseHeldLeasesSync, stageCursorMcpLease } from "./cursor-mcp-config.js";
import { fromCursorToolName } from "./tool-mapping.js";
import { startCursorToolBridge } from "./tool-bridge.js";
import type { CursorToolBridge } from "./tool-bridge.js";
import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult, CursorStreamSession } from "./types.js";
/*
FNXC:CursorMcpBridge 2026-08-15-23:46:
A live Cursor session renews its lease below the 15-minute stale threshold so peer reconciliation cannot remove an active bridge during a long turn.
*/
const MCP_HEARTBEAT_INTERVAL_MS = 5 * 60_000;
let exitHookInstalled = false;
function installExitHook() { if (!exitHookInstalled) { exitHookInstalled = true; process.once("exit", () => { try { releaseHeldLeasesSync(); } catch { /* exit hooks must not throw */ } }); } }
function context(options: AgentRuntimeOptions, names: string[] = []): string {
const skills = Array.isArray(options.skills) ? options.skills.filter((value) => typeof value === "string" && value.trim()) : [];
return ["Fusion runtime context:", `- Tool mode: ${options.tools ?? "readonly"}`, skills.length ? `- Requested skills: ${skills.join(", ")}` : "", names.length ? `- Fusion MCP tools: ${names.join(", ")}` : ""].filter(Boolean).join("\n");
}
/*
FNXC:CursorMcpBridge 2026-08-15-21:20:
Runtime setup reconciles previous crash residue before staging a tokenized bridge. Staging failure degrades only custom tools, never the Cursor turn.
The exit hook is a latency backstop; journaled reconciliation remains the correctness owner after hard process death.
*/
export class CursorRuntimeAdapter implements AgentRuntime {
readonly id = "cursor";
readonly name = "Cursor Runtime";
constructor(private readonly settings?: Record<string, unknown>) {}
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
await reconcileCursorWorktree(options.cwd);
installExitHook();
const messages: unknown[] = [];
const session: CursorStreamSession = { model: options.defaultModelId?.replace(/^cursor-cli\//, "") ?? "auto", systemPrompt: options.systemPrompt, messages, state: { messages }, sessionId: "", cwd: options.cwd, tools: options.tools, callbacks: { onText: options.onText, onThinking: options.onThinking, onToolStart: options.onToolStart, onToolEnd: options.onToolEnd }, fusedSystemPrompt: [options.systemPrompt?.trim(), context(options)].filter(Boolean).join("\n\n"), disposed: false, dispose: async () => {
if (session.disposed) return;
session.disposed = true;
session.activeAbortController?.abort();
if (session.mcpHeartbeatTimer) clearInterval(session.mcpHeartbeatTimer);
await session.mcpLease?.dispose().catch(() => undefined);
await session.toolBridge?.dispose().catch(() => undefined);
} };
let startingBridge: CursorToolBridge | null = null;
if (options.fusionTools?.length && classifyCursorConfigTracking(options.cwd) !== "tracked") {
try {
startingBridge = await startCursorToolBridge(options.fusionTools);
if (startingBridge) {
const serverKey = `fusion-custom-tools-${randomUUID()}`;
const lease = await stageCursorMcpLease({ worktreePath: options.cwd, serverKey, serverEntry: startingBridge.serverEntry });
session.toolBridge = startingBridge; session.mcpLease = lease; session.mcpServerKey = serverKey;
session.mcpHeartbeatTimer = setInterval(() => { void lease.heartbeat().catch(() => undefined); }, MCP_HEARTBEAT_INTERVAL_MS);
session.mcpHeartbeatTimer.unref?.();
session.fusedSystemPrompt = [options.systemPrompt?.trim(), context(options, options.fusionTools.map((tool) => `${serverKey}-${tool.name}`))].filter(Boolean).join("\n\n");
startingBridge = null;
}
} catch (error) {
await startingBridge?.dispose().catch(() => undefined);
session.fusionToolBridgeError = { reasonCode: (error as { code?: string }).code === "mcp-schema-server-missing" ? "mcp-schema-server-missing" : "bridge-start-failed" };
}
} else if (options.fusionTools?.length) session.fusionToolBridgeError = { reasonCode: "bridge-start-failed" };
return { session, sessionFile: undefined };
}
async promptWithFallback(session: CursorStreamSession, prompt: string, _options?: unknown): Promise<void> {
if (session.disposed) throw new Error("Cursor session is disposed.");
const priorId = session.sessionId; const first = !priorId;
const sent = first ? `${session.fusedSystemPrompt}\n\nUser request:\n${prompt}` : prompt;
const emitted = new Set<string>(); const controller = new AbortController(); session.activeAbortController = controller;
const map = (name: string) => session.mcpServerKey ? fromCursorToolName(name, session.mcpServerKey) : name;
try {
const outcome = await launchCursorPrompt({ binary: typeof this.settings?.cursorCliBinaryPath === "string" ? this.settings.cursorCliBinaryPath : undefined, model: session.model, cwd: session.cwd, tools: session.tools, prompt: sent, resumeId: priorId || undefined, signal: controller.signal, approveMcps: Boolean(session.mcpLease), onThinking: session.callbacks.onThinking, onToolStart: (name, args) => session.callbacks.onToolStart?.(map(name), args), onToolEnd: (name, isError, result) => session.callbacks.onToolEnd?.(map(name), isError, result), onText: (text) => { if (!emitted.has(text)) { emitted.add(text); session.callbacks.onText?.(text); } } });
session.sessionId = outcome.sessionId ?? session.sessionId; session.messages.push({ role: "user", content: prompt }, { role: "assistant", content: outcome.text });
} catch (error) { session.sessionId = priorId; throw error; } finally { if (session.activeAbortController === controller) session.activeAbortController = undefined; }
}
describeModel(session: CursorStreamSession): string { return `cursor-cli/${(session.model || "auto").replace(/^cursor-cli\//, "")}`; }
}