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

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

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

Fusion-Task-Id: FN-9098

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

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

95 lines
5.4 KiB
TypeScript

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");
expect(result.session.cwd).toBe("/tmp");
expect(result.session.tools).toBe("readonly");
expect(new CursorRuntimeAdapter().describeModel(result.session)).toBe("cursor-cli/pro");
});
it("fuses the first prompt and resumes retained Cursor chat", async () => {
const spy = vi.spyOn(transport, "launchCursorPrompt").mockResolvedValueOnce({ sessionId: "chat-1", text: "first" }).mockResolvedValueOnce({ sessionId: "chat-1", text: "second" });
const text = vi.fn(); const adapter = new CursorRuntimeAdapter();
const { session } = await adapter.createSession({ cwd: "/tmp", systemPrompt: "system", onText: text, tools: "readonly" });
await adapter.promptWithFallback(session, "one"); await adapter.promptWithFallback(session, "two");
expect(spy.mock.calls[0][0]).toMatchObject({ cwd: "/tmp", tools: "readonly", resumeId: undefined });
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();
const { session } = await adapter.createSession({ cwd: "/tmp", systemPrompt: "system" }); session.sessionId = "prior";
await expect(adapter.promptWithFallback(session, "x")).rejects.toThrow("failed"); expect(session.sessionId).toBe("prior");
let signal: AbortSignal | undefined;
vi.spyOn(transport, "launchCursorPrompt").mockImplementationOnce((input) => new Promise((_resolve, reject) => {
signal = input.signal;
input.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
}));
const active = adapter.promptWithFallback(session, "active");
session.dispose(); session.dispose();
expect(signal?.aborted).toBe(true);
await expect(active).rejects.toThrow("aborted");
expect(session.activeAbortController).toBeUndefined();
});
});