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>
76 lines
6.0 KiB
TypeScript
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\//, "")}`; }
|
|
}
|