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>
This commit is contained in:
gsxdsm
2026-08-15 16:51:54 -07:00
parent 56a087dac9
commit 5e5b0dbb8f
28 changed files with 934 additions and 179 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Bridge Fusion task tools into Cursor CLI sessions safely.
category: feature
dev: Adds tokenized bridge env vars, baseline-first journaled `.cursor/mcp.json` leases, exclusion-before-creation, operator-edit quarantine/recovery, tracked-config refusal, and awaited disposal.

View File

@@ -1,139 +1,25 @@
# Cursor CLI Contract (FN-3396 Step 0)
Date: 2026-05-07
<!--
FNXC:CursorCli 2026-07-08-00:00:
The original FN-3396 preflight assumed model discovery via JSON-flagged subcommand variants with a plain-text fallback, and stated no auth-status command was confirmed. FN-7697 captured and shipped the real `cursor-agent` CLI contract: model discovery is `cursor-agent models` (plain text `id - Label` lines, no JSON flag) and authentication is derived from `cursor-agent status --format json` (`isAuthenticated`). This doc was corrected on 2026-07-08 to match the verified contract; see FN-7697 for the implementation.
FNXC:CursorCli 2026-08-15-21:17:
Cursor MCP support is enabled only after the CLI contract is observed: project config resolution,
tool-name prefix, MCP approval semantics, and negotiated protocol. Fusion stages its config only
for that verified session contract; it never assumes a fallback CLI flag or config location.
-->
**Update history:** 2026-07-08 — corrected the model-discovery and auth-status contract from FN-3396's assumed `--json` commands to the verified `cursor-agent models` / `cursor-agent status --format json` contract captured and implemented in FN-7697.
## MCP staging and cleanup
## Research method
Fusion creates a unique `fusion-custom-tools-<uuid>` server key per Cursor session. The `.cursor/.fusion-mcp-state.json` manifest retains the complete `{ command, args, env }` entry for every lease, allowing one process to recompose a peer process's live entry. Operator content is taken from current bytes; Fusion content is taken from that manifest.
- Local runtime inspection in the task environment (`which`, direct command execution).
- Local binary wrapper inspection (`cursor`, `cursor-agent` launch scripts and install layout).
- Bounded `fn_research_run` was attempted but failed in this environment with: `table research_runs has no column named projectId`.
Before staging in a git worktree, Fusion writes its marker block to `info/exclude`, then creates `.cursor/` and its lock directory. The marker covers `mcp.json`, the state record, and the lock so step-boundary `git add -A` cannot capture session files. The first stage persists the byte-exact baseline before it writes config bytes. Later changes journal the intended output before atomic config replacement, then promote the record, so a crash can resolve either the intended or previous byte sequence without mistaking Fusion output for an operator edit.
## Confirmed invocation and binary detection
A tracked `.cursor/mcp.json` is refused. Byte-different operator edits are preserved rather than restored over. If an operator edit makes the file unparsable, Fusion quarantines the worktree: no process writes that config, the exclusion remains, and further staging is refused. Reconciliation clears the quarantine only after the config is deleted or has valid JSON with no `fusion-custom-tools-*` keys.
- **Primary executable aliases found on PATH:**
- `cursor`
- `cursor-agent`
- **Not found on PATH:**
- `cursor-cli`
- `cursor` is a wrapper that can delegate to agent mode and emits a targeted message when IDE install is missing.
- `cursor-agent` is the direct CLI runtime entrypoint and is symlinked to a versioned install under:
- `~/.local/share/cursor-agent/versions/<version>/cursor-agent`
### Worktree safety protocol
### Detection strategy
The lease manifest records each `serverEntry` as `{ command, args, env? }`; entries are always recomposed from the durable manifest while non-Fusion content comes from the current on-disk JSON. A peer can therefore dispose without dropping another process's bridge. The first stage commits the raw baseline before its first config write. Every subsequent mutation writes `pending { kind, raw, seq }`, atomically replaces the config, then promotes the pending record. Recovery compares bytes against the pending result and last confirmed result: match pending promotes, match prior discards, and any other bytes latch an operator edit.
1. If the global `cursorCliBinaryPath` setting is a non-empty string, probe that configured binary first.
2. Probe `cursor-agent` from PATH.
3. Probe `cursor` from PATH.
4. Deduplicate candidates when the configured value is exactly `cursor-agent` or `cursor`.
5. Persist the resolved path and executable name in probe results.
6. Report explicit failure reason when neither exists.
Bootstrap is deliberately outside the main lock because that lock lives in `.cursor/`: resolve git shape, serialize the `info/exclude` marker under the git-dir bootstrap lock, observe/create `.cursor/` with an `EEXIST`-safe ownership observation, then acquire the main lock. On final cleanup the inverse is used: the exclusion marker is the last in-lock removal, the main lock is released immediately, and only then may Fusion make one non-recursive `rmdir` attempt. A failed `rmdir` is a benign peer/operator race and is retried only by a later reconciliation.
### Manual binary path override
Lock owners persist PID, hostname, and acquisition time. Contenders retry briefly and may reclaim only a dead same-host owner or an expired critical-section TTL. Leases heartbeat independently for long turns. The synchronous process-exit backstop makes one free-lock attempt only; it never bootstraps, takes over a stale lock, or writes when a peer owns the lock. Reconciliation is the crash-recovery owner.
<!--
FNXC:CursorCli 2026-07-02-00:00:
Operators can set a global Cursor CLI binary path when PATH discovery resolves the wrong shim. The override is optional and must never remove the cursor-agent/cursor fallback probes.
-->
Settings → Authentication → Cursor CLI exposes an optional binary path field. Leave it blank to use PATH auto-detection. When populated, Fusion validates the configured path by running the same `--version` probe used for status/enable, saves it only if that configured candidate itself succeeds, and then uses it for status, enable validation, and Cursor model discovery before falling back to PATH candidates.
If the configured path fails during ordinary status/model-discovery probes but a PATH candidate succeeds, Fusion remains usable and reports the PATH candidate as the effective `binaryPath`; bounded diagnostics include the configured-path failure. If saving a new non-empty override fails or only succeeds via PATH fallback, the Settings save returns a 400 diagnostic and does not persist the path.
Windows paths with spaces, for example `C:\Users\A User\AppData\Roaming\npm\cursor-agent.cmd`, are treated as one operator-provided string. Users should not quote or split the path in the UI.
### Windows PATH shim invocation
<!--
FNXC:CursorCli 2026-07-02-00:00:
Windows Cursor installs may publish `cursor-agent.cmd`, `cursor.cmd`, or equivalent `.bat` shims on PATH; Fusion must invoke Cursor probe and discovery commands through the Windows shell so Node can execute those wrappers.
Unix and macOS stay direct-spawned to avoid broadening shell semantics beyond the platform that requires it.
-->
On Windows, `cursor-agent`, `cursor`, and manual override paths can resolve to `.cmd` / `.bat` wrappers rather than native executables. Node.js direct `spawn(binary, args)` does not execute those wrappers reliably; Fusion's Cursor command runner therefore sets shell execution only when `process.platform === "win32"`.
The Windows shell-backed path applies to every Cursor CLI command Fusion currently runs through the shared runner:
- Configured binary / `cursor-agent --version` / `cursor --version` probe attempts.
- Auth-status probe against the effective probe-selected binary: `cursor-agent status --format json`.
- Model discovery against the effective probe-selected binary: `cursor-agent models` (plain text, no `--json` flag).
Non-Windows probes and discovery continue to use direct spawn. Spawn errors such as `ENOENT` or `EACCES` are included in the unavailable probe reason in bounded diagnostic form so a working terminal command is distinguishable from known Cursor runtime/auth states; Fusion does not dump PATH, environment variables, or unbounded stdout/stderr.
## Confirmed error/auth/runtime signals
Observed command behavior in this environment:
- `cursor --help` (without IDE install):
- `Error: No Cursor IDE installation found. Use 'cursor agent' or 'agent' to run the agent.`
- `cursor-agent --help` and `cursor agent --help` (with locked keychain):
- `Error: Your macOS login keychain is locked.`
- `Run security unlock-keychain and try again.`
### Auth/readiness implications
- Keychain-locked is a distinct, expected failure mode and must be surfaced as an auth/runtime-blocked state (not as unknown crash).
- Missing IDE install is a distinct expected failure mode from missing binary.
## Structured output and model discovery
- **Confirmed:** `cursor-agent models` is the model-list command. Output is plain text — passing an unsupported JSON output flag (e.g. appending `--json` to the `models` subcommand) fails with `error: unknown option '--json'`.
- Output shape: an `Available models` header line, a blank line, then one model per line formatted as `<id> - <Label>` (e.g. `auto - Auto (default)`, `claude-4.5-sonnet - Sonnet 4.5`), followed by a trailing tip line: `Tip: use --model <id> (or /model <id> in interactive mode) to switch.`.
- Empty-account state: `No models available for this account.` (no model lines follow).
- `cursor-agent --list-models` exists but is unreliable — it can report "No models available for this account." even while the CLI is authenticated with models available. Prefer `cursor-agent models`.
### Model discovery parsing strategy (implemented)
1. Run `cursor-agent models` (or the effective probe-selected binary) with a short timeout.
2. Split stdout into lines; extract the bare model id as the segment before the first ` - ` on each line.
3. Filter out the `Available models` header, the trailing `Tip:` line, the `No models available for this account.` empty-state line, and blank lines.
4. Normalize and dedupe the remaining ids into the discovered model set.
5. If the command is unavailable or fails, return an empty discovered set with a machine-readable reason; host surfaces Cursor models only when provider readiness + discovery usability conditions are met.
### Authentication / status
- **Confirmed:** authentication state is derived from `cursor-agent status --format json` (alias `whoami`), which returns a JSON object with `isAuthenticated` (boolean), plus `status`, `hasAccessToken`, and `userInfo`.
- Use `isAuthenticated` as the auth signal instead of treating a successful `--version` probe as a proxy for readiness. `--version` remains the availability/version probe (bare version string), separate from auth.
- Keychain-locked and missing-IDE-install remain distinct expected failure modes on top of this (see "Confirmed error/auth/runtime signals" above) — a locked keychain or missing IDE surfaces as its own runtime-blocked state rather than folding into `isAuthenticated: false`.
## Provider ID decision
- Use **`cursor-cli`** as the provider ID.
- Rationale: aligns with task requirement; no conflicting provider ID observed in current codebase scan.
## Contract freeze for FN-3396 (superseded by the verified contract below)
The original FN-3396 preflight treated the following as canonical pending stronger evidence:
- Binary candidates: `cursor-agent`, `cursor`.
- Expected failure states include: missing binary, missing IDE installation, keychain locked, unauthenticated/not-ready CLI.
- Model discovery must be dynamic-first with resilient fallback and no hardcoded static catalog by default.
Binary candidates and expected failure states above remain accurate. The dynamic-first/no-static-catalog principle also still holds, but the specific commands are now confirmed rather than assumed — see "Structured output and model discovery" and "Windows PATH shim invocation" above for the verified `cursor-agent models` / `cursor-agent status --format json` contract that replaces the earlier `--json`-flag guesswork.
<!--
FNXC:CursorCli 2026-08-15-15:16:
FN-9097 verified the non-interactive Cursor transport against cursor-agent 2026.08.11-e8db854. Prompt content travels on stdin and cwd alone binds the workspace, so the streaming transport omits --workspace and avoids an avoidable command-boundary token.
-->
**Update history:** 2026-08-15 — FN-9097 verified the execution transport and added the supervised Windows launch contract.
## Execution transport contract
External integration evidence: Cursor CLI is closed-source with no canonical upstream source repository; public tracker: https://github.com/cursor/cursor. Docs: https://cursor.com/docs/cli/overview. Installation: `curl https://cursor.com/install -fsS | bash` or `irm 'https://cursor.com/install?win32=true' | iex`. Binary: `cursor-agent`. Checksum: `upstream-pending-verification` because Cursor publishes no per-release manifest; local observed version: `2026.08.11-e8db854`.
On 2026-08-15, in an external scratch directory, `printf 'Reply with exactly: ok\\n' | cursor-agent --print --output-format stream-json --force --trust --model auto` exited 0 and emitted NDJSON `system/init`, `user`, `thinking`, `assistant`, and `result` events. The init event reported the process cwd, proving no `--workspace` argument is needed. A `--mode plan --trust` run read a scratch file, emitted a `readToolCall` started/completed pair, and terminated without `--force`. `-p` help states it has write/shell tool access; `--force` controls approvals, while `--trust` clears workspace trust. `--sandbox` was left config-driven. `--resume <session_id>` and `--model <id>` are supported; no system-prompt flag exists, so the first prompt contains the fused system context. `mcp --help` confirms MCP configuration is `.cursor/mcp.json`-based and has no `--mcp-config` flag.
Contract supports non-interactive execution: **yes**. The transport maps coding to `--force --trust`; readonly and unset tools to `--mode plan --trust`, never `--force`. `--stream-partial-output` is omitted: assistant messages are deduplicated by emitted content because Cursor can emit the same response with and without `model_call_id`.
### Streaming Windows launch decision
Prompt turns always use `superviseSpawn` with `shell:false`, a first-line deadline, and an inactivity deadline reset by every stream line; active output has no total-duration cap. When no binary override is configured, an absent `cursor-agent` launch retries the documented `cursor` PATH fallback. They prefer direct executable targets, send the prompt on stdin, and omit `--workspace`. A cmd shim is the sole cmd boundary: each token is validated then rejected for `" % ! ^ & | < > ( )` and controls before quote-only escaping, ComSpec must be an absolute `cmd.exe`, and command lines over 8000 characters fail. `windowsVerbatimArguments` is used only there (it had no prior repository call site). A `.ps1` target uses PowerShell `-NoProfile -NonInteractive -ExecutionPolicy Bypass -File`; unknown extensions fail.
This avoids cmd's re-parse (Node argv escaping is not cmd-safe), Node's refusal to direct-spawn batch files, and launcher-only kills that orphan agents. Resolution honors the first `where.exe` directory and PATHEXT only within it. POSIX teardown uses the supervisor process group; Windows additionally invokes `taskkill /T /F`. Windows PATH shim shape remains unverified on this macOS host, so launch is shape-agnostic. Probe/discovery retains its existing shell-backed behavior described above; only streaming turns use this hardened branch.
When quarantine is held, `.fusion-mcp-state.json` remains as the durable record and the Fusion `info/exclude` block is intentionally retained so leftover bridge entries cannot be swept by `git add -A`. To recover, repair the JSON and remove every `fusion-custom-tools-*` entry, or delete the config; a later Cursor session clears the record and exclusion automatically. A merely parseable file that still has a Fusion key remains quarantined.

View File

@@ -120,6 +120,10 @@ The scanner reads only these well-known paths; missing files are normal and malf
Claude Desktop, Claude Code, Cursor, and Windsurf use the Claude-style `{ "mcpServers": { ... } }` shape. VS Code project config can use `{ "servers": { ... } }`; Fusion normalizes it to the same import parser before rendering candidates.
## Cursor runtime task tools
Cursor runtime sessions bridge Fusion `fn_*` tools through a session-scoped project `.cursor/mcp.json` stdio entry. Fusion writes an `info/exclude` block before staging the entry, retains a durable lease manifest so concurrent sessions preserve each other's server definitions, and restores a pre-existing operator config after the last lease. The initial baseline is recorded before Fusion writes config bytes; later writes use an intent journal so crash recovery can distinguish its own atomic write from an operator edit. A tracked `.cursor/mcp.json` disables the bridge. An operator edit is preserved; an unparsable operator edit quarantines the worktree, retains the exclusion, and disables staging until the JSON is repaired and `fusion-custom-tools-*` entries are removed (or the file is deleted).
Sensitive discovery follows the same no-plaintext rule as manual import. If a third-party file contains inline environment values, header values, or token-like values, the API response includes only secret descriptor metadata (`field`, `key`, `suggestedKey`, `scope`) and the candidate definition uses Fusion `McpSecretRef` placeholders. The dashboard **Add** flow opens the server editor so operators choose existing Fusion secrets or create new Fusion-managed secrets; the settings blob stores only `{ secretRef, scope }` references.
The dashboard uses this route:

View File

@@ -972,6 +972,30 @@ describe("createResolvedAgentSession", () => {
expect(execute).not.toHaveBeenCalled();
});
it("preserves an identity-scoped Fusion subset without trusting injected fn_* names", async () => {
const createSessionMock = vi.fn().mockResolvedValue({ session: { prompt: vi.fn() } });
resolveRuntimeMock.mockResolvedValue({
runtime: { id: "cursor", name: "Cursor", createSession: createSessionMock, promptWithFallback: vi.fn(), describeModel: vi.fn(() => "cursor/auto") },
runtimeId: "cursor",
wasConfigured: true,
});
const trusted = { name: "fn_task_list", description: "", parameters: {}, execute: vi.fn() } as any;
const injected = { name: "fn_evil", description: "", parameters: {}, execute: vi.fn() } as any;
await createResolvedAgentSession({
sessionPurpose: "executor",
cwd: "/tmp/project",
systemPrompt: "system",
customTools: [injected, trusted],
fusionTools: [trusted],
});
const passed = createSessionMock.mock.calls[0][0];
expect(passed.customTools.map((tool: { name: string }) => tool.name)).toEqual(["fn_evil", "fn_task_list"]);
expect(passed.fusionTools.map((tool: { name: string }) => tool.name)).toEqual(["fn_task_list"]);
expect(passed.fusionTools[0]).toBe(passed.customTools[1]);
});
it("does not pre-wrap customTools for the pi runtime (createFnAgent owns the chain)", async () => {
const mockSession = { prompt: vi.fn() } as any;
const createSessionMock = vi.fn().mockResolvedValue({ session: mockSession });

View File

@@ -2618,6 +2618,22 @@ describe("StepSessionExecutor", () => {
});
describe("cleanup failure diagnostics", () => {
it("awaits asynchronous session disposal before the step boundary completes", async () => {
const task = makeTaskDetail({ prompt: makeStepPrompt("FN-ASYNC-DISPOSE", 1), steps: [{ name: "Step 0", status: "pending" }] });
let resolveDispose!: () => void;
const session = makeMockSession();
session.dispose = vi.fn(() => new Promise<void>((resolve) => { resolveDispose = resolve; }));
mockedCreateFnAgent.mockResolvedValue({ session } as any);
const executor = new StepSessionExecutor({ taskDetail: task, worktreePath: "/project/.worktrees/main", rootDir: "/project", settings: makeSettings() });
let completed = false;
const execution = executor.executeAll().then((result) => { completed = true; return result; });
await vi.waitFor(() => expect(session.dispose).toHaveBeenCalledOnce());
expect(completed).toBe(false);
resolveDispose();
await execution;
expect(completed).toBe(true);
});
it("logs warning when session dispose fails during error cleanup", async () => {
const task = makeTaskDetail({
prompt: makeStepPrompt("FN-001", 1),

View File

@@ -31,7 +31,9 @@ describe("fn_web_fetch universal registration", () => {
});
it("merger registers fn_web_fetch", () => {
expect(readSource("merger.ts")).toContain("customTools: [reportBuildFailureTool, createWebFetchTool()]");
const mergerSrc = readSource("merger.ts");
expect(mergerSrc).toContain("const mergerFusionTools = [reportBuildFailureTool, createWebFetchTool()]");
expect(mergerSrc).toContain("customTools: mergerFusionTools");
});
it("triage registers fn_web_fetch", () => {

View File

@@ -3172,6 +3172,7 @@ export class HeartbeatMonitor {
systemPromptLayers: heartbeatLayers,
tools: "coding",
customTools: heartbeatTools,
fusionTools: heartbeatTools,
defaultProvider: heartbeatSessionModels.defaultProvider,
defaultModelId: heartbeatSessionModels.defaultModelId,
...(heartbeatSessionModels.credentialInstanceId ? { credentialInstanceId: heartbeatSessionModels.credentialInstanceId } : {}),
@@ -3727,7 +3728,7 @@ export class HeartbeatMonitor {
session.dispose();
const created = await createResolvedAgentSession({
sessionPurpose: "heartbeat", runtimeHint: extractRuntimeHint(agent.runtimeConfig), pluginRunner: this.pluginRunner,
cwd: sessionCwd, systemPrompt: systemPromptFinal, systemPromptLayers: heartbeatLayers, tools: "coding", customTools: heartbeatTools,
cwd: sessionCwd, systemPrompt: systemPromptFinal, systemPromptLayers: heartbeatLayers, tools: "coding", customTools: heartbeatTools, fusionTools: heartbeatTools,
defaultProvider: heartbeatSessionModels.defaultProvider, defaultModelId: heartbeatSessionModels.defaultModelId,
credentialInstanceId: activeInstanceId, fallbackProvider: heartbeatSessionModels.fallbackProvider,
fallbackModelId: heartbeatSessionModels.fallbackModelId,

View File

@@ -89,6 +89,15 @@ export interface AgentRuntimeOptions {
tools?: "coding" | "readonly";
/** Additional custom tools to merge with the base toolset */
customTools?: ToolDefinition[];
/**
* Engine-owned Fusion tools that a runtime may publish through an external bridge.
* This must be an identity-based subset of customTools; plugin/MCP tools remain custom-only.
*
* FNXC:CursorMcpBridge 2026-08-15-23:46:
* Cursor must never infer Fusion ownership from an `fn_` name or a forgeable marker.
* The engine records provenance structurally before plugin-runtime wrappers run.
*/
fusionTools?: ToolDefinition[];
/** Per-result shared tool-output cap. `null` disables the wrapper; undefined uses the built-in default. */
toolOutputMaxChars?: number | null;
/** Callback for text output from the agent */

View File

@@ -113,6 +113,20 @@ export function wrapCustomToolsForPluginRuntime(
return wrapToolsWithOutputBudget(withActionGate, { maxChars: options.toolOutputMaxChars });
}
function wrapPluginRuntimeToolOptions(
options: AgentRuntimeOptions,
logContext: { runtimeId: string; sessionPurpose: string },
): Pick<AgentRuntimeOptions, "customTools" | "fusionTools"> {
const original = options.customTools;
const wrapped = wrapCustomToolsForPluginRuntime(original, options, logContext);
if (!original || !wrapped) return { customTools: wrapped, fusionTools: undefined };
const scoped = new Set(options.fusionTools ?? []);
const fusionTools = original.flatMap((tool, index) =>
scoped.has(tool) && tool.name.startsWith("fn_") && wrapped[index] ? [wrapped[index]] : []);
return { customTools: wrapped, fusionTools };
}
function shouldWrapCustomToolsForRuntime(runtimeId: string): boolean {
return !RUNTIMES_WITH_INTERNAL_TOOL_GATING.has(runtimeId);
}
@@ -976,11 +990,7 @@ export async function createResolvedAgentSession(
? {
...effectiveRuntimeOptionsWithModel,
sessionPurpose,
customTools: wrapCustomToolsForPluginRuntime(
effectiveRuntimeOptionsWithModel.customTools,
effectiveRuntimeOptionsWithModel,
{ runtimeId: resolved.runtimeId, sessionPurpose },
),
...wrapPluginRuntimeToolOptions(effectiveRuntimeOptionsWithModel, { runtimeId: resolved.runtimeId, sessionPurpose }),
}
: {
...effectiveRuntimeOptionsWithModel,
@@ -1107,11 +1117,7 @@ export async function createResolvedAgentSession(
grokCreateOptions: shouldWrapCustomToolsForRuntime("grok")
? {
...grokBaseOptions,
customTools: wrapCustomToolsForPluginRuntime(
effectiveRuntimeOptionsWithModel.customTools,
effectiveRuntimeOptionsWithModel,
{ runtimeId: "grok", sessionPurpose },
),
...wrapPluginRuntimeToolOptions(grokBaseOptions, { runtimeId: "grok", sessionPurpose }),
}
: grokBaseOptions,
primaryProvider: runtimeOptions.defaultProvider,

View File

@@ -534,6 +534,7 @@ export async function reviewStep(
systemPromptLayers: layers,
tools: options.allowInlineFixes === true && reviewType === "code" ? "coding" : "readonly",
customTools: reviewCustomTools,
fusionTools: reviewCustomTools,
onText: handleReviewerText,
onThinking: agentLogger?.onThinking,
onToolStart: agentLogger?.onToolStart,

View File

@@ -690,7 +690,7 @@ const RETRY_DELAYS_MS = [1_000, 5_000, 15_000];
/** A minimal session handle stored for termination support. */
interface SessionHandle {
dispose: () => void;
dispose: () => void | Promise<void>;
/** Abort the session's currently-running bash command (if any) so its
* detached subprocess tree — including grandchildren like vitest workers —
* is killed via pi-coding-agent's killProcessTree. dispose() alone only
@@ -824,7 +824,7 @@ export class StepSessionExecutor {
stepExecLog.warn(`Failed to abort reusable primary step session: ${err}`);
}
try {
this.reusablePrimarySession.dispose();
await this.reusablePrimarySession.dispose();
} catch (err) {
stepExecLog.warn(`Failed to dispose reusable primary step session: ${err}`);
} finally {
@@ -1039,7 +1039,8 @@ export class StepSessionExecutor {
stepExecLog.warn(`Failed to abort bash for step ${stepIdx}: ${err}`);
}
try {
handle.dispose();
// FNXC:CursorMcpBridge 2026-08-15-21:20: Abort teardown stays fire-and-forget; normal step cleanup awaits disposal before git worktree cleanup, while journal reconciliation repairs a hard-abort lease.
void handle.dispose();
} catch (err) {
stepExecLog.warn(`Failed to dispose session for step ${stepIdx}: ${err}`);
}
@@ -1428,6 +1429,16 @@ export class StepSessionExecutor {
]
: [];
const fusionTools = [
...documentTools,
webFetchTool,
...memoryTools,
...taskLogTool,
...taskCreateTool,
...delegationTools,
...messagingTools,
];
// Create or reuse the agent session for this attempt
// Resolve executor model using canonical lane hierarchy:
// 1. Task override pair (taskDetail.modelProvider + taskDetail.modelId)
@@ -1483,16 +1494,8 @@ Follow instructions precisely and avoid unrelated changes.`,
settings,
// FNXC:McpConfig 2026-06-25-23:02: Workflow model-node step sessions receive the same resolved, secret-materialized MCP server set as the parent executor; runtime support is still enforced inside the pi session seam without logging server contents.
mcpServers: this.options.mcpServers,
customTools: [
...pluginTools,
...documentTools,
webFetchTool,
...memoryTools,
...taskLogTool,
...taskCreateTool,
...delegationTools,
...messagingTools,
],
customTools: [...pluginTools, ...fusionTools],
fusionTools,
onText: (delta) => {
const telemetry = reusePrimarySession ? this.selectReusableTelemetry(localTelemetry) : localTelemetry;
telemetry.agentLogger.onText(delta);
@@ -1708,7 +1711,7 @@ Follow instructions precisely and avoid unrelated changes.`,
this.reusableStepTelemetry = null;
} else {
try {
session?.dispose();
await session?.dispose();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
stepExecLog.warn(`Failed to dispose session for step ${stepIndex}: ${msg}`);

View File

@@ -686,7 +686,9 @@ CRITICAL SCOPING RULES — read before doing anything else:
// Skill selection: assigned-agent / role-fallback skills, plus the step's own named skill (U1) made discoverable via additionalSkillPaths.
...(effectiveSkillSelection ? { skillSelection: effectiveSkillSelection } : {}),
...(additionalSkillPaths ? { additionalSkillPaths } : {}),
...(readonlyCustomTools.allowed.length > 0 ? { customTools: readonlyCustomTools.allowed } : {}),
...(readonlyCustomTools.allowed.length > 0
? { customTools: readonlyCustomTools.allowed, fusionTools: readonlyCustomTools.allowed }
: {}),
});
// FNXC:CommandCenterActivity 2026-08-15-22:15: session boundary for the workflow-step runtime session (restored post-wave-18).
emitAgentSessionStart({ store: deps.store, agentId: task.assignedAgentId ?? null, taskId: task.id, nodeId: task.effectiveNodeId ?? task.nodeId ?? null, model: primaryModelId ?? null, provider: primaryProvider ?? null, lane: "executor" });

View File

@@ -11029,6 +11029,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
// FNXC:Settings-MergerModel 2026-07-16-00:00: merger retries use the dedicated project fallback lane before the shared global fallback pair.
const mergerFallbackModel = resolveMergerFallbackModel(settings);
const mergerFusionTools = [reportBuildFailureTool, createWebFetchTool()];
// FN-5279: Layer 3 / merge-authoring AI runs in the resolved integration
// root so arbiter edits land in the reused task worktree when handoff mode
@@ -11040,7 +11041,8 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
cwd: rootDir,
systemPrompt: mergerSystemPrompt,
tools: "coding",
customTools: [reportBuildFailureTool, createWebFetchTool()],
customTools: mergerFusionTools,
fusionTools: mergerFusionTools,
onText: agentLogger.onText,
onThinking: agentLogger.onThinking,
onToolStart: agentLogger.onToolStart,

View File

@@ -31,6 +31,22 @@ Fusion runs one supervised `cursor-agent --print --output-format stream-json` tu
| `coding` | `--force --trust` |
| `readonly` or unset | `--mode plan --trust` |
`--print` already grants built-in write and shell tools. `--force` controls approval, so it is limited to coding sessions whose cwd is Fusion's isolated task worktree. Fusion does not use `--auto-review`, worktree, add-dir, MCP approval, plugin-dir, or sandbox override flags. Fusion `fn_*` tools are not bridged into Cursor; Cursor uses only its own built-in tools.
`--print` already grants built-in write and shell tools. `--force` controls approval, so it is limited to coding sessions whose cwd is Fusion's isolated task worktree. Fusion does not use `--auto-review`, worktree, add-dir, plugin-dir, or sandbox override flags.
## Fusion MCP bridge
When a session has Fusion `fn_*` custom tools, Fusion starts a token-protected loopback bridge and stages a per-session stdio entry in the task worktree's `.cursor/mcp.json`. Cursor receives `--approve-mcps` only after that lease is staged. The stdio child receives `FUSION_CURSOR_TOOL_BRIDGE_URL` and `FUSION_CURSOR_TOOL_BRIDGE_TOKEN`; `mcp-schema-server.cjs` must be copied into the built package.
Fusion records the original config before its first write, persists live server entries in `.cursor/.fusion-mcp-state.json`, and removes its entry on disposal. The config is excluded from git while a lease is live. A git-tracked `.cursor/mcp.json` disables the bridge rather than risking a step-boundary commit. If an operator makes the config unparsable during a session, Fusion quarantines that worktree and leaves the config and exclusion untouched; repair the JSON and remove `fusion-custom-tools-*` entries (or delete the config), then the next Cursor session reconciles it.
<!-- FNXC:CursorMcpBridge 2026-08-15-21:17: The exclusion is installed before `.cursor/` exists because the nested lock has an owner file that `git add -A` could otherwise commit. Cleanup removes that exclusion only after the final lease's lock-protected compose. -->
### Worktree hygiene and recovery
Fusion may briefly create a git-dir `fusion-cursor-exclude.lock` during bootstrap, then `.cursor/.fusion-mcp.lock` while a session composes the config. Both are excluded before they exist. The state record can contain a `pending` journal after a crash; the next Cursor session resolves it from exact config bytes. A hard-killed lease is reaped by its heartbeat TTL, not by assuming an exit hook ran. A final empty `.cursor/` directory is removed with a single non-recursive attempt after its nested lock is released; a peer race leaves it for the next reconciliation.
### Quarantined configuration
A lane reports `bridge-start-failed` when `.cursor/.fusion-mcp-state.json` contains a `quarantine` record. Fusion deliberately refuses to rewrite that config and keeps its `info/exclude` marker, because the malformed file can still contain `fusion-custom-tools-*` entries that must not enter git. Do not delete the state file by hand. Instead repair `mcp.json` and remove every Fusion entry, or delete `mcp.json`; the next Cursor session clears quarantine and removes the marker automatically.
All turns use `superviseSpawn` with a finite lifetime. The Windows prompt transport prefers a direct executable; `.cmd`/`.bat` shims validate and reject cmd metacharacters before a quoted cmd launch, `.ps1` uses PowerShell `-File`, and unknown extensions fail loudly. `PI_CURSOR_CLI_FIRST_LINE_TIMEOUT_MS` and `PI_CURSOR_CLI_TIMEOUT_MS` optionally tune cold-start and inactivity guards.

View File

@@ -22,7 +22,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"
},
"dependencies": {

View File

@@ -0,0 +1,100 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { hostname } from "node:os";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { composeMcpConfig, evaluateQuarantine, reconcileCursorWorktree, stageCursorMcpLease } from "../cursor-mcp-config.js";
const dirs: string[] = [];
const dir = () => { const value = mkdtempSync(join(tmpdir(), "fusion-cursor-mcp-")); dirs.push(value); return value; };
afterEach(() => dirs.splice(0).forEach((value) => rmSync(value, { recursive: true, force: true })));
describe("Cursor MCP lease config", () => {
it("quarantines an initially malformed operator config without changing its bytes", async () => {
const root = dir(); const cursor = join(root, ".cursor"); mkdirSync(cursor);
const config = join(cursor, "mcp.json"); const malformed = '{"mcpServers":{"operator":'; writeFileSync(config, malformed);
await expect(stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-one", serverEntry: { command: "bridge", args: [] } })).rejects.toMatchObject({ code: "bridge-start-failed" });
expect(readFileSync(config, "utf8")).toBe(malformed);
const state = JSON.parse(readFileSync(join(cursor, ".fusion-mcp-state.json"), "utf8"));
expect(state.baseline.parsable).toBe(false);
expect(state.quarantine.raw).toBe(malformed);
});
it("restores a pre-existing operator config after the final lease", async () => {
const root = dir(); const cursor = join(root, ".cursor");
await import("node:fs/promises").then(({ mkdir }) => mkdir(cursor));
const config = join(cursor, "mcp.json"); const baseline = '{\n "mcpServers": { "operator": { "command": "echo" } }\n}\n'; writeFileSync(config, baseline);
const lease = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-one", serverEntry: { command: process.execPath, args: ["bridge"] } });
expect(readFileSync(config, "utf8")).toContain("fusion-custom-tools-one");
await lease.dispose(); expect(readFileSync(config, "utf8")).toBe(baseline);
});
it("preserves a parsable operator edit made before disposal", async () => {
const root = dir(); const cursor = join(root, ".cursor"); mkdirSync(cursor);
const config = join(cursor, "mcp.json"); writeFileSync(config, '{"mcpServers":{"operator":{"command":"before"}}}\n');
const lease = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-one", serverEntry: { command: "bridge", args: [] } });
const edited = '{\n "mcpServers": { "operator-edit": { "command": "after" }, "fusion-custom-tools-one": { "command": "stale" } }\n}\n';
writeFileSync(config, edited);
await lease.dispose();
const after = readFileSync(config, "utf8"); expect(after).toContain("operator-edit"); expect(after).not.toContain("fusion-custom-tools-one");
});
it("removes a Fusion-created cursor directory after releasing its nested lock", async () => {
const root = dir(); const lease = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-one", serverEntry: { command: "bridge", args: [] } });
await lease.dispose();
expect(() => readFileSync(join(root, ".cursor"))).toThrow();
});
it("reclaims a stale owner-recorded lock before staging", async () => {
const root = dir(); const cursor = join(root, ".cursor"); mkdirSync(join(cursor, ".fusion-mcp.lock"), { recursive: true });
writeFileSync(join(cursor, ".fusion-mcp.lock", "owner.json"), JSON.stringify({ pid: process.pid, hostname: hostname(), acquiredAt: Date.now() - 31_000 }));
const lease = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-one", serverEntry: { command: "bridge", args: [] } });
await lease.dispose();
});
it("does not steal a freshly-created lock before its owner is published", async () => {
const root = dir(); const cursor = join(root, ".cursor"); const lock = join(cursor, ".fusion-mcp.lock");
mkdirSync(lock, { recursive: true });
// Model a second process between atomic mkdir and its subsequent owner.json publication.
const contender = stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-contender", serverEntry: { command: "bridge", args: [] } });
await new Promise((resolve) => setTimeout(resolve, 1));
const owner = { pid: process.pid, hostname: hostname(), acquiredAt: Date.now() };
writeFileSync(join(lock, "owner.json"), JSON.stringify(owner));
await expect(contender).rejects.toMatchObject({ code: "bridge-start-failed" });
expect(JSON.parse(readFileSync(join(lock, "owner.json"), "utf8"))).toEqual(owner);
expect(() => readFileSync(join(cursor, "mcp.json"), "utf8")).toThrow();
});
it("keeps peer entries from the durable manifest", async () => {
const root = dir(); const first = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-one", serverEntry: { command: "one", args: ["a"] } });
const second = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-two", serverEntry: { command: "two", args: ["b"], env: { TOKEN: "x" } } });
await first.dispose(); const raw = readFileSync(join(root, ".cursor", "mcp.json"), "utf8"); expect(raw).toContain('"two"'); expect(raw).not.toContain('"one"'); await second.dispose();
});
it("reaps a dead same-host lease before its heartbeat TTL", async () => {
const root = dir(); const first = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-dead", serverEntry: { command: "dead", args: [] } });
const statePath = join(root, ".cursor", ".fusion-mcp-state.json");
const state = JSON.parse(readFileSync(statePath, "utf8"));
state.leases["fusion-custom-tools-dead"].pid = 999_999_999;
state.leases["fusion-custom-tools-dead"].hostname = hostname();
state.leases["fusion-custom-tools-dead"].heartbeatAt = Date.now();
writeFileSync(statePath, JSON.stringify(state));
const second = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-live", serverEntry: { command: "live", args: [] } });
const config = readFileSync(join(root, ".cursor", "mcp.json"), "utf8");
expect(config).not.toContain("fusion-custom-tools-dead");
expect(config).toContain("fusion-custom-tools-live");
await first.dispose(); await second.dispose();
});
it("recomposes after reconciliation reaps a dead peer while another lease remains", async () => {
const root = dir();
const first = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-dead", serverEntry: { command: "dead", args: [] } });
const second = await stageCursorMcpLease({ worktreePath: root, serverKey: "fusion-custom-tools-live", serverEntry: { command: "live", args: [] } });
const statePath = join(root, ".cursor", ".fusion-mcp-state.json");
const state = JSON.parse(readFileSync(statePath, "utf8"));
state.leases["fusion-custom-tools-dead"].pid = 999_999_999;
state.leases["fusion-custom-tools-dead"].hostname = hostname();
writeFileSync(statePath, JSON.stringify(state));
await reconcileCursorWorktree(root);
const config = readFileSync(join(root, ".cursor", "mcp.json"), "utf8");
expect(config).not.toContain("fusion-custom-tools-dead");
expect(config).toContain("fusion-custom-tools-live");
await first.dispose(); await second.dispose();
});
it("does not create a cursor directory when reconciliation has no residue", async () => { const root = dir(); await reconcileCursorWorktree(root); expect(() => readFileSync(join(root, ".cursor"))).toThrow(); });
it("composes current operator content without Fusion keys", () => { const raw = composeMcpConfig({ currentRaw: '{"mcpServers":{"operator":{"command":"x"},"fusion-custom-tools-old":{"command":"old"}}}', leases: { "fusion-custom-tools-new": { serverEntry: { command: "new", args: [] } } } }); expect(raw).toContain("operator"); expect(raw).toContain("fusion-custom-tools-new"); expect(raw).not.toContain("fusion-custom-tools-old"); });
it("re-pins a changed unparsable quarantine instead of clearing it", () => { expect(evaluateQuarantine({ currentRaw: "{broken", quarantine: { raw: "{older", fusionKeys: [], observedAt: "now" } })).toBe("repin"); });
});

View File

@@ -0,0 +1,3 @@
{"type":"system","subtype":"init","cwd":"<scratch>","session_id":"<session>","model":"Auto"}
{"type":"tool_call","subtype":"completed","tool_call":{"getMcpToolsToolCall":{"result":{"success":{"content":"{\"mode\":\"catalog\",\"servers\":[]}"}}}}}
{"type":"result","subtype":"success","result":"No MCP servers were available."}

View File

@@ -0,0 +1,4 @@
{"type":"system","subtype":"init","cwd":"<scratch>","session_id":"<session>","model":"Auto"}
{"type":"tool_call","subtype":"started","tool_call":{"mcpToolCall":{"args":{"name":"fn9098-echo-echo","args":{"message":"hello"},"providerIdentifier":"fn9098-echo","toolName":"echo","serverIdentifier":"fn9098-echo"}}}}
{"type":"tool_call","subtype":"completed","tool_call":{"mcpToolCall":{"result":{"success":{"content":[{"text":{"text":"ECHO:hello"}}],"isError":false}}}}}
{"type":"result","subtype":"success","result":"ECHO:hello"}

View File

@@ -1,8 +1,25 @@
import { describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CursorRuntimeAdapter } from "../runtime-adapter.js";
import { toolsToMcpToolDefs } from "../tool-bridge.js";
import * as transport from "../prompt-transport.js";
const dirs: string[] = [];
afterEach(() => { vi.useRealTimers(); dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })); });
const tempWorktree = () => { const dir = mkdtempSync(join(tmpdir(), "fusion-cursor-runtime-")); dirs.push(dir); return dir; };
const schemaFiles = () => readdirSync(tmpdir()).filter((name) => name.startsWith(`fusion-cursor-mcp-schemas-${process.pid}-`)).sort();
describe("CursorRuntimeAdapter", () => {
it("maps only executable fn_* tools from the engine-scoped set", () => {
expect(toolsToMcpToolDefs([
{ name: "fn_task_list", execute: vi.fn() },
{ name: "plugin_tool", execute: vi.fn() },
{ name: "fn_missing_execute" },
]).map((tool) => tool.name)).toEqual(["fn_task_list"]);
});
it("normalizes model and creates a cwd-bound session", async () => {
const result = await new CursorRuntimeAdapter().createSession({ cwd: "/tmp", systemPrompt: "sys", defaultModelId: "cursor-cli/pro", tools: "readonly" });
expect(result.session.model).toBe("pro");
@@ -19,6 +36,44 @@ describe("CursorRuntimeAdapter", () => {
expect(spy.mock.calls[0][0].prompt).toContain("system");
expect(spy.mock.calls[1][0]).toMatchObject({ prompt: "two", resumeId: "chat-1", tools: "readonly" });
});
it("does not publish an arbitrary custom tool merely because its name starts with fn_", async () => {
const cwd = tempWorktree();
const { session } = await new CursorRuntimeAdapter().createSession({
cwd,
systemPrompt: "system",
customTools: [{ name: "fn_evil", execute: vi.fn() }],
});
expect(session.toolBridge).toBeUndefined();
expect(() => readFileSync(join(cwd, ".cursor", "mcp.json"), "utf8")).toThrow();
});
it("renews an active Cursor MCP lease before the stale threshold", async () => {
vi.useFakeTimers(); vi.setSystemTime(new Date("2026-08-15T23:00:00Z"));
const cwd = tempWorktree();
const { session } = await new CursorRuntimeAdapter().createSession({ cwd, systemPrompt: "system", fusionTools: [{ name: "fn_task_list", execute: vi.fn() }] });
const statePath = join(cwd, ".cursor", ".fusion-mcp-state.json");
const initial = JSON.parse(readFileSync(statePath, "utf8")).leases[session.mcpServerKey!].heartbeatAt;
await vi.advanceTimersByTimeAsync(5 * 60_000);
const renewed = JSON.parse(readFileSync(statePath, "utf8")).leases[session.mcpServerKey!].heartbeatAt;
expect(renewed).toBeGreaterThan(initial);
await session.dispose();
});
it("disposes a started bridge when malformed operator config prevents lease staging", async () => {
const cwd = tempWorktree(); const cursor = join(cwd, ".cursor"); mkdirSync(cursor);
const config = join(cursor, "mcp.json"); const malformed = "{broken"; writeFileSync(config, malformed);
const before = schemaFiles();
const { session } = await new CursorRuntimeAdapter().createSession({
cwd,
systemPrompt: "system",
fusionTools: [{ name: "fn_task_list", execute: vi.fn() }],
});
expect(session.fusionToolBridgeError).toEqual({ reasonCode: "bridge-start-failed" });
expect(session.toolBridge).toBeUndefined();
expect(schemaFiles()).toEqual(before);
expect(readFileSync(config, "utf8")).toBe(malformed);
});
it("restores the session id on transport failure and disposal aborts an active turn exactly once", async () => {
vi.spyOn(transport, "launchCursorPrompt").mockRejectedValueOnce(new Error("failed"));
const adapter = new CursorRuntimeAdapter();

View File

@@ -0,0 +1,52 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { afterEach, describe, expect, it } from "vitest";
import { ensureCursorConfigExcluded, resolveGitShape } from "../worktree-hygiene.js";
const roots: string[] = [];
const git = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const root = () => {
const value = mkdtempSync(join(tmpdir(), "fusion-cursor-hygiene-"));
roots.push(value);
return value;
};
afterEach(() => roots.splice(0).forEach((value) => rmSync(value, { recursive: true, force: true })));
describe("Cursor worktree hygiene", () => {
it("does not reclaim a fresh bootstrap lock before its owner is published", () => {
const repository = root();
git(repository, "init");
const shape = resolveGitShape(repository)!;
const lock = join(shape.gitDir, "fusion-cursor-exclude.lock");
mkdirSync(lock);
expect(() => ensureCursorConfigExcluded(repository)).toThrow(/exclude lock unavailable/);
expect(existsSync(lock)).toBe(true);
expect(existsSync(join(repository, ".cursor"))).toBe(false);
});
it("uses Git's absolute linked-worktree exclude path instead of nesting it below the worktree", () => {
const repository = root();
git(repository, "init");
git(repository, "config", "user.email", "test@example.invalid");
git(repository, "config", "user.name", "Cursor test");
writeFileSync(join(repository, "README.md"), "seed\n");
git(repository, "add", "README.md");
git(repository, "commit", "-m", "seed");
const linked = join(repository, "linked");
git(repository, "worktree", "add", "-b", "cursor-hygiene-test", linked);
const shape = resolveGitShape(linked);
expect(shape).toBeDefined();
expect(shape!.excludePath).toBe(git(linked, "rev-parse", "--git-path", "info/exclude"));
ensureCursorConfigExcluded(linked);
expect(readFileSync(shape!.excludePath, "utf8")).toContain("# >>> fusion cursor-runtime >>>");
expect(readFileSync(shape!.excludePath, "utf8")).toContain("/.cursor/mcp.json");
mkdirSync(join(linked, ".cursor"));
writeFileSync(join(linked, ".cursor", "mcp.json"), "{}\n");
// A regression joined the absolute Git path under `linked`, creating this bogus tree.
expect(existsSync(join(linked, shape!.excludePath.replace(/^[/\\]+/, "")))).toBe(false);
});
});

View File

@@ -0,0 +1,196 @@
/*
FNXC:CursorMcpBridge 2026-08-15-21:20:
The manifest is the authority for Fusion server entries, while current on-disk bytes remain authority for operator entries.
A baseline is committed before the first config write and every later write is journaled, so a crash cannot recapture Fusion output as operator content.
An unparsable operator edit is quarantined: it is never rewritten and the exclusion remains until reconciliation observes a repaired, Fusion-key-free file.
*/
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { hostname } from "node:os";
import { dirname, join } from "node:path";
import { randomUUID } from "node:crypto";
import { classifyCursorConfigTracking, ensureCursorConfigExcluded, removeCursorConfigExclusion } from "./worktree-hygiene.js";
export interface ServerEntry { command: string; args: string[]; env?: Record<string, string>; }
type Pending = { kind: "write" | "restore" | "delete" | "quarantine"; raw: string | null; seq: number };
type Lease = { pid: number; hostname: string; heartbeatAt: number; serverEntry: ServerEntry };
type State = { version: 1; baseline: { existed: boolean; raw: string | null; mode: number | null; parsable: boolean; createdDir: boolean }; exclusionOwnedByFusion: boolean; lastWrittenRaw: string | null; pending: Pending | null; operatorEditObserved: boolean; configUnwritable: boolean; quarantine: { raw: string | null; fusionKeys: string[]; observedAt: string } | null; leases: Record<string, Lease> };
const TTL = 15 * 60_000;
const LOCK_TTL_MS = 30_000;
const LOCK_ATTEMPTS = 5;
const held = new Map<string, Set<string>>();
const paths = (worktreePath: string) => { const dir = join(worktreePath, ".cursor"); return { dir, config: join(dir, "mcp.json"), state: join(dir, ".fusion-mcp-state.json"), lock: join(dir, ".fusion-mcp.lock") }; };
const rawFile = (file: string) => existsSync(file) ? readFileSync(file, "utf8") : null;
/*
FNXC:CursorMcpBridge 2026-08-15-21:26:
The baseline, pending journal, and quarantine record are recovery authorities after
power loss. Fsync their temp file before rename and the containing directory after
rename so Fusion never writes a config whose only baseline or write intent vanished.
*/
const atomic = (file: string, raw: string, durable = false) => {
const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
writeFileSync(temp, raw, { mode: 0o600 });
if (durable) {
const fileDescriptor = openSync(temp, "r");
try { fsyncSync(fileDescriptor); } finally { closeSync(fileDescriptor); }
}
renameSync(temp, file);
if (durable) {
// Some Windows filesystems reject directory fsync; the renamed file fsync remains durable there.
try { const directoryDescriptor = openSync(dirname(file), "r"); try { fsyncSync(directoryDescriptor); } finally { closeSync(directoryDescriptor); } } catch { /* platform does not support directory fsync */ }
}
};
const writeState = (file: string, state: State, durable = false) => atomic(file, JSON.stringify(state), durable);
const same = (raw: string | null, expected: string | null) => raw === expected;
const own = (key: string) => /^fusion-custom-tools-/.test(key);
export const parseCursorMcpConfig = (raw: string) => JSON.parse(raw) as { mcpServers?: Record<string, unknown> };
export const detectFusionKeysInRaw = (raw: string | null) => { try { return Object.keys(parseCursorMcpConfig(raw ?? "{}").mcpServers ?? {}).filter(own); } catch { return []; } };
export function stripFusionKeys(parsed: { mcpServers?: Record<string, unknown> }) { const copy = { ...parsed, mcpServers: { ...(parsed.mcpServers ?? {}) } }; for (const key of Object.keys(copy.mcpServers)) if (own(key)) delete copy.mcpServers[key]; return copy; }
export function composeMcpConfig({ currentRaw, leases }: { currentRaw: string | null; leases: Record<string, { serverEntry: ServerEntry }> }) { let parsed: { mcpServers?: Record<string, unknown> }; try { parsed = currentRaw ? parseCursorMcpConfig(currentRaw) : {}; } catch { parsed = {}; } const next = stripFusionKeys(parsed); next.mcpServers ??= {}; for (const [key, lease] of Object.entries(leases)) next.mcpServers[key] = lease.serverEntry; return `${JSON.stringify(next, null, 2)}\n`; }
export function detectOperatorEdit({ currentRaw, lastWrittenRaw, pending, baseline, stateExists }: { currentRaw: string | null; lastWrittenRaw: string | null; pending: Pending | null; baseline: { raw: string | null }; stateExists: boolean }) { if (!stateExists) return false; return !same(currentRaw, lastWrittenRaw ?? baseline.raw) && !same(currentRaw, pending?.raw ?? "__no_pending__"); }
export function resolvePendingJournal({ currentRaw, lastWrittenRaw, pending, baseline }: { currentRaw: string | null; lastWrittenRaw: string | null; pending: Pending | null; baseline: { raw: string | null } }): "promote" | "discard" | "discard-edited" { if (!pending) return "discard"; if (pending.kind === "quarantine" || same(currentRaw, pending.raw)) return "promote"; return same(currentRaw, lastWrittenRaw ?? baseline.raw) ? "discard" : "discard-edited"; }
export function evaluateQuarantine({ currentRaw, quarantine }: { currentRaw: string | null; quarantine: State["quarantine"] }): "clear" | "hold" | "repin" | "unknown" { if (!quarantine) return "unknown"; if (currentRaw === null) return "clear"; if (currentRaw === quarantine.raw) return "hold"; try { return Object.keys(parseCursorMcpConfig(currentRaw).mcpServers ?? {}).some(own) ? "hold" : "clear"; } catch { return "repin"; } }
/*
FNXC:CursorMcpBridge 2026-08-15-21:17:
The lock owns an on-disk identity, not merely a directory. A crashed holder may
be reclaimed only after a dead same-host PID or the short critical-section TTL;
bounded retries let a peer finish its compose without writing unlocked.
*/
type LockOwner = { pid: number; hostname: string; acquiredAt: number };
const lockOwnerPath = (lock: string) => join(lock, "owner.json");
const currentHost = () => hostname();
const pidAlive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
const readLockOwner = (lock: string): LockOwner | undefined => { try { return JSON.parse(readFileSync(lockOwnerPath(lock), "utf8")) as LockOwner; } catch { return undefined; } };
const lockAgeMs = (lock: string) => { try { return Date.now() - statSync(lock).mtimeMs; } catch { return Number.POSITIVE_INFINITY; } };
/*
FNXC:CursorMcpBridge 2026-08-15-21:46:
A contender can observe the atomic lock directory after mkdir but before owner.json
is published. Treat missing or malformed metadata as a live acquisition until the
lock directory itself exceeds the TTL; reclaiming it immediately would admit two
writers to the MCP config critical section.
*/
const lockIsStale = (lock: string, owner: LockOwner | undefined) =>
lockAgeMs(lock) > LOCK_TTL_MS ||
(owner !== undefined && (Date.now() - owner.acquiredAt > LOCK_TTL_MS ||
(owner.hostname === currentHost() && !pidAlive(owner.pid))));
const pause = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
async function withLock<T>(worktreePath: string, create: boolean, fn: () => T | Promise<T>): Promise<T | undefined> {
const p = paths(worktreePath); if (!existsSync(p.dir)) { if (!create) return undefined; mkdirSync(p.dir, { recursive: true }); }
const mine: LockOwner = { pid: process.pid, hostname: currentHost(), acquiredAt: Date.now() };
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
try { mkdirSync(p.lock); writeFileSync(lockOwnerPath(p.lock), JSON.stringify(mine), { mode: 0o600 }); break; }
catch {
if (existsSync(p.lock) && lockIsStale(p.lock, readLockOwner(p.lock))) { try { rmSync(p.lock, { recursive: true, force: true }); } catch { /* contender won; retry */ } }
if (attempt === LOCK_ATTEMPTS - 1) throw Object.assign(new Error("Cursor MCP config lock unavailable"), { code: "bridge-start-failed" });
await pause(8 + Math.floor(Math.random() * 12));
}
}
try { return await fn(); } finally {
const owner = readLockOwner(p.lock);
if (owner?.pid === mine.pid && owner.hostname === mine.hostname) { try { rmSync(p.lock, { recursive: true, force: true }); } catch { /* stale cleanup will repair */ } }
}
}
/** Exit handlers cannot await retry or stale-owner checks; they only take a free lock once. */
function withLockSync<T>(worktreePath: string, fn: () => T): T | undefined {
const p = paths(worktreePath); if (!existsSync(p.dir)) return undefined;
const mine: LockOwner = { pid: process.pid, hostname: currentHost(), acquiredAt: Date.now() };
try { mkdirSync(p.lock); writeFileSync(lockOwnerPath(p.lock), JSON.stringify(mine), { mode: 0o600 }); } catch { return undefined; }
try { return fn(); } catch { return undefined; } finally {
const owner = readLockOwner(p.lock);
if (owner?.pid === mine.pid && owner.hostname === mine.hostname) { try { rmSync(p.lock, { recursive: true, force: true }); } catch { /* exit backstop never throws */ } }
}
}
function readState(p: ReturnType<typeof paths>): State | undefined { try { const state = JSON.parse(readFileSync(p.state, "utf8")) as State; return state.version === 1 && state.baseline ? state : undefined; } catch { return undefined; } }
function recoverJournal(p: ReturnType<typeof paths>, state: State, current: string | null) { if (!state.pending) return; const resolution = resolvePendingJournal({ currentRaw: current, lastWrittenRaw: state.lastWrittenRaw, pending: state.pending, baseline: state.baseline }); if (resolution === "promote") state.lastWrittenRaw = state.pending.raw; if (resolution === "discard-edited") { state.operatorEditObserved = true; try { if (current !== null) parseCursorMcpConfig(current); } catch { state.configUnwritable = true; } } state.pending = null; writeState(p.state, state); }
function journalWrite(p: ReturnType<typeof paths>, state: State, kind: Pending["kind"], raw: string | null) { state.pending = { kind, raw, seq: (state.pending?.seq ?? 0) + 1 }; writeState(p.state, state, true); if (kind === "delete") { if (existsSync(p.config)) unlinkSync(p.config); } else if (kind !== "quarantine") atomic(p.config, raw ?? ""); state.lastWrittenRaw = raw; state.pending = null; writeState(p.state, state); }
/*
FNXC:CursorMcpBridge 2026-08-15-21:26:
A same-host process ID can be proven dead immediately, so retain neither its bridge
entry nor its stale MCP server until the lease TTL. Foreign-host records remain
TTL-only because process IDs are not portable across shared worktrees.
*/
function stale(state: State) {
for (const [key, lease] of Object.entries(state.leases)) {
if (Date.now() - lease.heartbeatAt > TTL || (lease.hostname === currentHost() && !pidAlive(lease.pid))) delete state.leases[key];
}
}
/*
FNXC:CursorMcpBridge 2026-08-15-21:17:
Release must latch a byte-different operator edit before composing its own write.
When the last lease exits, this function returns directory cleanup to its caller
so the nested lock is removed first; otherwise rmdir can never succeed.
*/
function terminal(worktreePath: string, p: ReturnType<typeof paths>, state: State): boolean {
const current = rawFile(p.config);
if (state.configUnwritable) { state.leases = {}; state.quarantine = { raw: current, fusionKeys: detectFusionKeysInRaw(current), observedAt: new Date().toISOString() }; state.pending = { kind: "quarantine", raw: current, seq: 1 }; writeState(p.state, state, true); state.pending = null; state.lastWrittenRaw = null; writeState(p.state, state, true); return false; }
if (!state.operatorEditObserved) journalWrite(p, state, state.baseline.existed ? "restore" : "delete", state.baseline.raw);
else if (current !== null) journalWrite(p, state, "write", composeMcpConfig({ currentRaw: current, leases: {} }));
unlinkSync(p.state); if (state.exclusionOwnedByFusion) removeCursorConfigExclusion(worktreePath);
// The nested main lock still exists here. Its owner performs this one rmdir only after releasing it.
return state.baseline.createdDir;
}
function removeCursorDirectoryAfterLock(dir: string, shouldRemove: boolean) {
if (!shouldRemove) return;
try { rmdirSync(dir); } catch { /* a peer or operator directory is retained without retry */ }
}
function detectAndLatch(p: ReturnType<typeof paths>, state: State, current: string | null) {
recoverJournal(p, state, current);
const observed = rawFile(p.config);
if (detectOperatorEdit({ currentRaw: observed, lastWrittenRaw: state.lastWrittenRaw, pending: state.pending, baseline: state.baseline, stateExists: true })) {
state.operatorEditObserved = true;
try { if (observed) parseCursorMcpConfig(observed); } catch { state.configUnwritable = true; }
writeState(p.state, state);
}
}
export function bootstrapCursorWorktree(worktreePath: string) { if (classifyCursorConfigTracking(worktreePath) === "tracked") throw Object.assign(new Error("Cursor MCP config is git-tracked"), { code: "bridge-start-failed" }); const exclusion = ensureCursorConfigExcluded(worktreePath); const p = paths(worktreePath); const existed = existsSync(p.dir); mkdirSync(p.dir, { recursive: true }); return { exclusion, createdDirObserved: !existed }; }
export async function stageCursorMcpLease({ worktreePath, serverKey, serverEntry }: { worktreePath: string; serverKey: string; serverEntry: ServerEntry }): Promise<{ heartbeat: () => Promise<unknown>; dispose: () => Promise<void> }> {
const boot = bootstrapCursorWorktree(worktreePath); const p = paths(worktreePath); let clearedQuarantine = false;
await withLock(worktreePath, true, () => {
const current = rawFile(p.config); let state = readState(p);
if (state?.quarantine) {
const verdict = evaluateQuarantine({ currentRaw: current, quarantine: state.quarantine });
if (verdict !== "clear") {
if (verdict === "repin") { state.quarantine = { raw: current, fusionKeys: detectFusionKeysInRaw(current), observedAt: new Date().toISOString() }; writeState(p.state, state, true); }
throw Object.assign(new Error("Cursor MCP worktree is quarantined"), { code: "bridge-start-failed" });
}
/*
FNXC:CursorMcpBridge 2026-08-15-21:36:
A quarantine clear is terminal: remove its exclusion only as the last
in-lock act, then release this nested lock before a fresh stage bootstraps.
Writing a new lease after removal would expose config and lock files to git add -A.
*/
unlinkSync(p.state);
if (state.exclusionOwnedByFusion) removeCursorConfigExclusion(worktreePath);
clearedQuarantine = true;
return;
}
if (!state) {
let parsable = true;
try { if (current) parseCursorMcpConfig(current); } catch { parsable = false; }
state = { version: 1, baseline: { existed: current !== null, raw: current, mode: current === null ? null : statSync(p.config).mode, parsable, createdDir: boot.createdDirObserved }, exclusionOwnedByFusion: boot.exclusion.fusionAuthored, lastWrittenRaw: null, pending: null, operatorEditObserved: false, configUnwritable: !parsable, quarantine: parsable ? null : { raw: current, fusionKeys: detectFusionKeysInRaw(current), observedAt: new Date().toISOString() }, leases: {} };
writeState(p.state, state, true);
/*
FNXC:CursorMcpBridge 2026-08-15-23:46:
An initially malformed operator config is quarantined before composition; byte equality with the captured baseline must never authorize replacing it with Fusion-only JSON.
*/
if (!parsable) throw Object.assign(new Error("Cursor MCP config is not safely writable"), { code: "bridge-start-failed" });
}
detectAndLatch(p, state, current);
if (state.configUnwritable) throw Object.assign(new Error("Cursor MCP config is not safely writable"), { code: "bridge-start-failed" });
const exclusion = ensureCursorConfigExcluded(worktreePath, { lockHeld: true }); state.exclusionOwnedByFusion ||= exclusion.fusionAuthored; stale(state); state.leases[serverKey] = { pid: process.pid, hostname: currentHost(), heartbeatAt: Date.now(), serverEntry }; const composed = composeMcpConfig({ currentRaw: rawFile(p.config), leases: state.leases }); journalWrite(p, state, "write", composed); const worktreeLeases = held.get(worktreePath) ?? new Set<string>(); worktreeLeases.add(serverKey); held.set(worktreePath, worktreeLeases);
});
if (clearedQuarantine) return stageCursorMcpLease({ worktreePath, serverKey, serverEntry });
let released = false;
return { heartbeat: async () => await withLock(worktreePath, false, () => { const state = readState(p); if (state?.leases[serverKey]) { detectAndLatch(p, state, rawFile(p.config)); if (!state.configUnwritable) { state.leases[serverKey].heartbeatAt = Date.now(); writeState(p.state, state); } } }), dispose: async () => { if (released) return; released = true; let removeDir = false; await withLock(worktreePath, false, () => { const state = readState(p); if (!state) return; detectAndLatch(p, state, rawFile(p.config)); delete state.leases[serverKey]; stale(state); if (Object.keys(state.leases).length) { if (!state.configUnwritable) journalWrite(p, state, "write", composeMcpConfig({ currentRaw: rawFile(p.config), leases: state.leases })); else writeState(p.state, state); } else removeDir = terminal(worktreePath, p, state); held.get(worktreePath)?.delete(serverKey); }); removeCursorDirectoryAfterLock(p.dir, removeDir); } };
}
export async function reconcileCursorWorktree(worktreePath: string) { const p = paths(worktreePath); if (!existsSync(p.dir)) return; let removeDir = false; try { await withLock(worktreePath, false, () => { const state = readState(p); if (!state) return; const current = rawFile(p.config); if (state.quarantine) { const verdict = evaluateQuarantine({ currentRaw: current, quarantine: state.quarantine }); if (verdict === "clear") { unlinkSync(p.state); if (state.exclusionOwnedByFusion) removeCursorConfigExclusion(worktreePath); removeDir = state.baseline.createdDir; } else if (verdict === "repin") { state.quarantine = { raw: current, fusionKeys: detectFusionKeysInRaw(current), observedAt: new Date().toISOString() }; writeState(p.state, state, true); } return; } detectAndLatch(p, state, current); const leaseCount = Object.keys(state.leases).length; stale(state); if (!Object.keys(state.leases).length) removeDir = terminal(worktreePath, p, state); else if (!state.configUnwritable && Object.keys(state.leases).length !== leaseCount) {
// Reaping a dead peer must also remove its MCP entry; the manifest is authoritative.
journalWrite(p, state, "write", composeMcpConfig({ currentRaw: rawFile(p.config), leases: state.leases }));
} else if (Object.keys(state.leases).length !== leaseCount) writeState(p.state, state); }); } catch { /* reconciliation is deliberately best effort */ } removeCursorDirectoryAfterLock(p.dir, removeDir); }
export function releaseHeldLeasesSync(worktreePath?: string) { for (const [path, keys] of held) { if (worktreePath && path !== worktreePath) continue; for (const key of [...keys]) { const p = paths(path); let removeDir = false; withLockSync(path, () => { const state = readState(p); if (!state || state.quarantine) return; detectAndLatch(p, state, rawFile(p.config)); delete state.leases[key]; if (!Object.keys(state.leases).length) removeDir = terminal(path, p, state); else if (!state.configUnwritable) journalWrite(p, state, "write", composeMcpConfig({ currentRaw: rawFile(p.config), leases: state.leases })); else writeState(p.state, state); }); removeCursorDirectoryAfterLock(p.dir, removeDir); } } }

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env node
/*
FNXC:GrokAcp 2026-07-11-14:00:
Executable MCP bridge for Fusion custom tools (fn_*) on the Grok ACP path.
tools/list is served from a schema file; tools/call POSTs to a localhost bridge
owned by GrokRuntimeAdapter so ToolDefinition.execute runs in-process with the
engine's closures. Unlike the Claude/Droid schema-only break-early servers,
Grok actually invokes MCP tools/call itself.
*/
"use strict";
const fs = require("fs");
const http = require("http");
const readline = require("readline");
// FNXC:GrokAcp 2026-07-11-18: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_CURSOR_TOOL_BRIDGE_URL;
const bridgeToken = process.env.FUSION_CURSOR_TOOL_BRIDGE_TOKEN;
if (!schemaPath || !bridgeUrl || !bridgeToken) {
process.stderr.write("fusion-cursor-mcp-server: missing schema path or FUSION_CURSOR_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, args) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({ name: toolName, 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),
authorization: `Bearer ${bridgeToken}`,
},
timeout: 120_000,
},
(res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
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;
}
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 ?? {};
callBridge(toolName, 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

@@ -12,7 +12,7 @@ const STDERR_MAX = 16_384;
function timeout(name: string, fallback: number) { const value = Number(process.env[name]); return Number.isFinite(value) && value > 0 ? value : fallback; }
export interface CursorPromptCallbacks { onText?: (text: string) => void; onThinking?: (text: string) => void; onToolStart?: (name: string, args?: Record<string, unknown>) => void; onToolEnd?: (name: string, isError: boolean, result?: unknown) => void; }
export interface CursorPromptInput extends CursorPromptCallbacks { binary?: string; model?: string; cwd: string; tools?: "coding" | "readonly"; prompt: string; resumeId?: string; signal?: AbortSignal; workspaceFlagRequired?: boolean; }
export interface CursorPromptInput extends CursorPromptCallbacks { binary?: string; model?: string; cwd: string; tools?: "coding" | "readonly"; prompt: string; resumeId?: string; signal?: AbortSignal; workspaceFlagRequired?: boolean; approveMcps?: boolean; }
export interface CursorPromptResult { sessionId?: string; text: string; usage?: unknown; }
export interface CursorPromptDependencies { supervise?: typeof superviseSpawn; taskkill?: typeof spawn; platform?: NodeJS.Platform; resolvePowerShell?: () => string; }
@@ -32,6 +32,8 @@ export async function launchCursorPrompt(input: CursorPromptInput, deps: CursorP
const args = ["--print", "--output-format", "stream-json", "--model", model, "--trust"];
if (input.workspaceFlagRequired) args.push("--workspace", input.cwd);
if (input.tools === "coding") args.push("--force"); else args.push("--mode", "plan");
// FNXC:CursorMcpBridge 2026-08-15-21:20: Cursor approves MCP calls only when a lease was safely staged for this turn; failed staging must remain a tool-less turn.
if (input.approveMcps) args.push("--approve-mcps");
if (input.resumeId) args.push("--resume", input.resumeId);
/*
FNXC:CursorCli 2026-08-15-15:47:

View File

@@ -1,44 +1,75 @@
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";
function context(options: AgentRuntimeOptions): string {
/*
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(", ")}` : ""].filter(Boolean).join("\n");
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:CursorCli 2026-08-15-15:16:
Cursor has no system-prompt flag, so Fusion fuses system context only into the first stdin prompt.
The stored cwd and tools are immutable session authority: later turns cannot turn a review session into --force.
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: () => {
/* FNXC:CursorCli 2026-08-15-15:32: Disposing a Fusion session must abort its live autonomous Cursor turn so prompt-transport performs supervised process-tree teardown. */
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 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 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, onThinking: session.callbacks.onThinking, onToolStart: session.callbacks.onToolStart, onToolEnd: session.callbacks.onToolEnd, 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;
}
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\//, "")}`; }
}

View File

@@ -0,0 +1,48 @@
/*
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 */ } } };
}

View File

@@ -0,0 +1,11 @@
/*
FNXC:CursorMcpBridge 2026-08-15-21:20:
FN-9098 observed Cursor stream tool names as <serverKey>-<toolName>.
Normalize only that session-owned prefix so Fusion callbacks continue to receive bare fn_* tool names.
*/
export function toCursorToolName(fnName: string, serverKey: string): string { return `${serverKey}-${fnName}`; }
export function fromCursorToolName(observedName: string, serverKey: string): string {
const exact = `${serverKey}-`;
if (observedName.startsWith(exact)) return observedName.slice(exact.length);
return observedName.replace(new RegExp(`^mcp[_-]*${serverKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[_-]*`), "");
}

View File

@@ -1,6 +1,8 @@
export interface CursorBinaryStatus { available: boolean; authenticated?: boolean; binaryPath?: string; binaryName?: string; configuredBinaryPath?: string; usingConfiguredBinaryPath?: boolean; diagnostics?: string[]; version?: string; reason?: string; probeDurationMs: number; }
export interface AgentRuntimeOptions { cwd: string; systemPrompt: string; tools?: "coding" | "readonly"; defaultModelId?: string; skills?: string[]; skillSelection?: unknown; onText?: (text: string) => void; onThinking?: (text: string) => void; onToolStart?: (name: string, args?: Record<string, unknown>) => void; onToolEnd?: (name: string, isError: boolean, result?: unknown) => void; }
export interface CursorStreamSession { model?: string; systemPrompt?: string; messages: unknown[]; state: { messages: unknown[] }; sessionId: string; cwd: string; tools?: "coding" | "readonly"; callbacks: Pick<AgentRuntimeOptions, "onText" | "onThinking" | "onToolStart" | "onToolEnd">; fusedSystemPrompt: string; disposed: boolean; activeAbortController?: AbortController; dispose: () => void; }
import type { CursorToolBridge, ToolLike } from "./tool-bridge.js";
export interface AgentRuntimeOptions { cwd: string; systemPrompt: string; tools?: "coding" | "readonly"; defaultModelId?: string; skills?: string[]; skillSelection?: unknown; customTools?: ToolLike[]; fusionTools?: ToolLike[]; onText?: (text: string) => void; onThinking?: (text: string) => void; onToolStart?: (name: string, args?: Record<string, unknown>) => void; onToolEnd?: (name: string, isError: boolean, result?: unknown) => void; }
export interface CursorStreamSession { model?: string; systemPrompt?: string; messages: unknown[]; state: { messages: unknown[] }; sessionId: string; cwd: string; tools?: "coding" | "readonly"; callbacks: Pick<AgentRuntimeOptions, "onText" | "onThinking" | "onToolStart" | "onToolEnd">; fusedSystemPrompt: string; disposed: boolean; activeAbortController?: AbortController; toolBridge?: CursorToolBridge; mcpLease?: { dispose: () => Promise<void>; heartbeat: () => Promise<unknown> }; mcpHeartbeatTimer?: ReturnType<typeof setInterval>; mcpServerKey?: string; fusionToolBridgeError?: { reasonCode: "mcp-schema-server-missing" | "bridge-start-failed" }; dispose: () => void | Promise<void>; }
export interface AgentSessionResult { session: CursorStreamSession; sessionFile?: string; }
export interface AgentRuntime { readonly id: string; readonly name: string; createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>; promptWithFallback(session: CursorStreamSession, prompt: string, options?: unknown): Promise<void>; describeModel(session: CursorStreamSession): string; }

View File

@@ -0,0 +1,117 @@
/*
FNXC:CursorMcpBridge 2026-08-15-21:20:
Cursor loads project MCP configuration from the task worktree, while merger paths may use git add -A.
Keep every Fusion-owned Cursor file excluded before it exists; tracked operator configuration is refused instead of hidden.
*/
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { hostname } from "node:os";
import { dirname, isAbsolute, join, relative, sep } from "node:path";
import { spawnSync } from "node:child_process";
const START = "# >>> fusion cursor-runtime >>>";
const END = "# <<< fusion cursor-runtime <<<";
export interface GitShape { topLevel: string; excludePath: string; gitDir: string; relativeWorktree: string; }
const git = (cwd: string, ...args: string[]) => spawnSync("git", args, { cwd, encoding: "utf8" });
const output = (cwd: string, ...args: string[]) => { const result = git(cwd, ...args); return result.status === 0 ? result.stdout.trim() : undefined; };
const atomic = (file: string, raw: string) => { mkdirSync(dirname(file), { recursive: true }); const tmp = `${file}.${process.pid}.tmp`; writeFileSync(tmp, raw, { mode: 0o600 }); renameSync(tmp, file); };
export function resolveGitShape(worktreePath: string): GitShape | undefined {
const topLevel = output(worktreePath, "rev-parse", "--show-toplevel");
const excludePath = output(worktreePath, "rev-parse", "--git-path", "info/exclude");
const gitDir = output(worktreePath, "rev-parse", "--git-path", ".");
if (!topLevel || !excludePath || !gitDir) return undefined;
const rel = relative(topLevel, worktreePath).split(sep).filter(Boolean).join("/");
/*
FNXC:CursorMcpBridge 2026-08-15-21:26:
Git may return absolute --git-path values, especially for linked worktrees. Preserve
those paths; joining an absolute-looking result below the worktree creates a stray
`Users/...` tree and leaves the real info/exclude unprotected from git add -A.
*/
const fromGitPath = (value: string) => isAbsolute(value) ? value : join(worktreePath, value);
return { topLevel, excludePath: fromGitPath(excludePath), gitDir: fromGitPath(gitDir), relativeWorktree: rel };
}
export function classifyCursorConfigTracking(worktreePath: string): "tracked" | "untracked" | "non-git" {
const shape = resolveGitShape(worktreePath); if (!shape) return "non-git";
const path = `${shape.relativeWorktree ? `${shape.relativeWorktree}/` : ""}.cursor/mcp.json`;
return git(shape.topLevel, "ls-files", "--error-unmatch", "--", path).status === 0 ? "tracked" : "untracked";
}
const block = (shape: GitShape) => {
const prefix = shape.relativeWorktree ? `/${shape.relativeWorktree}/` : "/";
return `${START}\n${prefix}.cursor/mcp.json\n${prefix}.cursor/.fusion-mcp-state.json\n${prefix}.cursor/.fusion-mcp.lock/\n${END}\n`;
};
const withoutBlock = (raw: string) => raw.replace(new RegExp(`${START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n[\\s\\S]*?${END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\n?`, "g"), "");
const BOOTSTRAP_LOCK_TTL_MS = 10_000;
const BOOTSTRAP_LOCK_ATTEMPTS = 5;
const BOOTSTRAP_LOCK_RETRY_MS = 10;
const bootstrapLock = (shape: GitShape) => join(shape.gitDir, "fusion-cursor-exclude.lock");
const sleepSync = (milliseconds: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
const lockAgeMs = (lock: string) => {
try { return Date.now() - statSync(lock).mtimeMs; } catch { return Number.POSITIVE_INFINITY; }
};
/*
FNXC:CursorMcpBridge 2026-08-15-21:36:
The main MCP lock lives under .cursor and cannot protect the exclusion that must
exist before .cursor is created. Bootstrap therefore serializes info/exclude with a
short git-dir lock; callers already holding the main lock pass lockHeld to avoid a
lock-order inversion.
*/
function withBootstrapLock<T>(shape: GitShape, fn: () => T): T {
const lock = bootstrapLock(shape);
const mine = { pid: process.pid, hostname: hostname(), acquiredAt: Date.now() };
let acquired = false;
for (let attempt = 0; attempt < BOOTSTRAP_LOCK_ATTEMPTS; attempt++) {
try {
mkdirSync(lock);
writeFileSync(join(lock, "owner.json"), JSON.stringify(mine), { mode: 0o600 });
acquired = true;
break;
} catch {
let owner: { pid?: number; hostname?: string; acquiredAt?: number } | undefined;
try { owner = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")); } catch { /* owner publication may still be in progress */ }
const deadLocalOwner = owner?.hostname === mine.hostname && typeof owner.pid === "number" && (() => { try { process.kill(owner.pid!, 0); return false; } catch { return true; } })();
/*
FNXC:CursorMcpBridge 2026-08-15-21:46:
mkdir is atomic but owner.json publication is a second operation. A fresh lock
without metadata is an in-progress acquisition, not proof of abandonment;
only its age may authorize reclamation, preventing two composers from entering.
*/
const stale = lockAgeMs(lock) > BOOTSTRAP_LOCK_TTL_MS || deadLocalOwner || (owner !== undefined && Date.now() - (owner.acquiredAt ?? 0) > BOOTSTRAP_LOCK_TTL_MS);
if (stale) { try { rmSync(lock, { recursive: true, force: true }); } catch { /* contender owns the next retry */ } }
if (attempt < BOOTSTRAP_LOCK_ATTEMPTS - 1) sleepSync(BOOTSTRAP_LOCK_RETRY_MS);
}
}
if (!acquired) throw Object.assign(new Error("Cursor MCP exclude lock unavailable"), { code: "bridge-start-failed" });
try { return fn(); } finally {
try {
const owner = JSON.parse(readFileSync(join(lock, "owner.json"), "utf8")) as typeof mine;
if (owner.pid === mine.pid && owner.hostname === mine.hostname) rmSync(lock, { recursive: true, force: true });
} catch { /* a stale-lock reconciler owns recovery */ }
}
}
export function ensureCursorConfigExcluded(worktreePath: string, options: { lockHeld?: boolean } = {}): { present: boolean; fusionAuthored: boolean; created: boolean } {
const shape = resolveGitShape(worktreePath); if (!shape) return { present: false, fusionAuthored: false, created: false };
const ensure = () => {
const raw = existsSync(shape.excludePath) ? readFileSync(shape.excludePath, "utf8") : "";
if (raw.includes(START) && raw.includes(END)) return { present: true, fusionAuthored: true, created: false };
atomic(shape.excludePath, `${raw}${raw && !raw.endsWith("\n") ? "\n" : ""}${block(shape)}`);
return { present: true, fusionAuthored: true, created: true };
};
return options.lockHeld ? ensure() : withBootstrapLock(shape, ensure);
}
export function removeCursorConfigExclusion(worktreePath: string): void {
const shape = resolveGitShape(worktreePath); if (!shape || !existsSync(shape.excludePath)) return;
const raw = readFileSync(shape.excludePath, "utf8"); if (!raw.includes(START) || !raw.includes(END)) return;
const next = withoutBlock(raw); if (next) atomic(shape.excludePath, next); else unlinkSync(shape.excludePath);
}
export function detectCursorResidue(worktreePath: string, _options: { preservedConfig?: "operator-edit" | "quarantined" } = {}): string[] {
const cursor = join(worktreePath, ".cursor");
return ["mcp.json", ".fusion-mcp-state.json", ".fusion-mcp.lock"].filter((name) => existsSync(join(cursor, name))).map((name) => join(cursor, name));
}