Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/provider-cursor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@t3tools/provider-core": "workspace:*",
"@t3tools/shared": "workspace:*",
"effect": "catalog:",
"jsonc-parser": "3.3.1",
"yaml": "catalog:"
},
"devDependencies": {
Expand Down
166 changes: 166 additions & 0 deletions packages/provider-cursor/src/server/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@ import {
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as ChildProcess from "effect/process/ChildProcess";
import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Stream from "effect/Stream";

import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
Expand All @@ -34,6 +39,7 @@ import {
} from "./adapter.ts";
import * as CursorAgentSdk from "./CursorAgentSdk.ts";
import { loadCursorSdk } from "./sdk.ts";
import { cursorWorkspaceMcpServers } from "./workspaceMcpServers.ts";

const { ConfigurationError } = loadCursorSdk();

Expand Down Expand Up @@ -1775,6 +1781,166 @@ describe("CursorAdapterV2", () => {
assert.notInclude(logged, "secret-cursor-mcp-token");
});

it.effect.skipIf(!symlinksSupported)(
"starts mcp.json servers that use Cursor's workspace variables",
() =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const scratch = yield* fileSystem.realPath(
yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-mcp-" }),
);
const workspace = path.join(scratch, "workspace");
const home = path.join(scratch, "home");
const loadServers = () =>
cursorWorkspaceMcpServers(cwd, { MCP_TYPE: "stdio" }).pipe(
Effect.provideService(HostProcess.HomeDirectory, home),
Effect.provideService(HostProcess.Environment, {}),
);
// Cursor resolves the project root through symlinks, as git does.
yield* fileSystem.makeDirectory(path.join(workspace, "packages", "app"), {
recursive: true,
});
yield* fileSystem.symlink(workspace, path.join(scratch, "link"));
const cwd = path.join(scratch, "link", "packages", "app");
yield* spawnAndCollect("git", ChildProcess.make("git", ["init", workspace]));
yield* fileSystem.makeDirectory(path.join(workspace, ".cursor"), { recursive: true });
yield* fileSystem.makeDirectory(path.join(home, ".cursor"), { recursive: true });
yield* fileSystem.writeFileString(
path.join(workspace, ".cursor", "mcp.json"),
`{
// Cursor's editor accepts comments here.
"mcpServers": {
"repro": { "command": "node", "args": ["\${workspaceFolder}/server.mjs"], "startupTimeoutSec": 120 },
"plain": { "command": "node", "args": ["server.mjs"] },
"argument-only": { "command": "node", "args": ["server.mjs", "--workspace", "\${workspaceFolder}"] },
"env-only": { "command": "node", "args": ["server.mjs"], "env": { "PROJECT_ROOT": "\${workspaceFolder}" } },
"cwd-only": { "command": "node", "args": ["server.mjs"], "cwd": "\${workspaceFolder}" },
"unset-env": { "command": "node", "env": { "CONFIG": "\${env:T3_MCP_UNSET}" } },
"wrapped": { "command": "proto", "args": ["run", "node", "--", "\${workspaceFolder}/server.mjs"] },
"off": { "command": "node", "args": ["\${workspaceFolder}/off.mjs"] },
"typed": { "type": "\${env:MCP_TYPE}", "command": "node", "args": ["server.mjs"] },
"plugin-docs-search": { "command": "node", "args": ["\${workspaceFolder}/search.mjs"] },
},
}`,
);
yield* fileSystem.writeFileString(
path.join(home, ".cursor", "mcp.json"),
JSON.stringify({
mcpServers: {
repro: { command: "node", args: ["${userHome}/shadowed.mjs"] },
docs: {
url: "https://docs.example/${workspaceFolderBasename}",
headers: { Authorization: "Bearer ${env:DOCS_TOKEN}" },
tls: { caBundle: "${userHome}/ca.pem" },
},
},
}),
);
// Cursor keeps per-project state under a directory named after the project path.
const projectDirectory = workspace.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "");
yield* fileSystem.makeDirectory(path.join(home, ".cursor", "projects", projectDirectory), {
recursive: true,
});
yield* fileSystem.writeFileString(
path.join(home, ".cursor", "projects", projectDirectory, "mcp-disabled.json"),
JSON.stringify(["off"]),
);

const instanceId = ProviderInstanceId.make("cursor");
const threadId = ThreadId.make("cursor-mcp-thread");
const modelSelection = { instanceId, model: "composer-2.5" };
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "default",
cwd,
});
let openedWith: CursorAgentSdk.CursorAgentSdkOpenInput["options"] | undefined;
const adapter = yield* makeCursorAdapterV2({
instanceId,
environment: {
HOME: path.join(scratch, "override"),
CURSOR_DATA_DIR: path.join(scratch, "override"),
DOCS_TOKEN: "docs-token",
MCP_TYPE: "stdio",
},
}).pipe(
Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, {
assertComplete: Effect.void,
open: (input) =>
Effect.sync(() => {
openedWith = input.options;
return {
agentId: "native-cursor-mcp",
listMessages: Effect.succeed([]),
close: Effect.void,
send: () => Effect.die("unused"),
};
}),
}),
Effect.provide(TestProviderHost.layer({ cwd })),
);
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("cursor-mcp-session"),
modelSelection,
runtimePolicy,
});
yield* runtime
.ensureThread({ threadId, modelSelection, runtimePolicy })
.pipe(
Effect.provideService(HostProcess.HomeDirectory, home),
Effect.provideService(HostProcess.Environment, {}),
);

const expectedServers = {
repro: {
command: "node",
args: [`${workspace}/server.mjs`],
startupTimeoutSec: 120,
cwd: workspace,
},
"argument-only": {
command: "node",
args: ["server.mjs", "--workspace", workspace],
cwd: workspace,
},
"env-only": {
command: "node",
args: ["server.mjs"],
env: { PROJECT_ROOT: workspace },
cwd: workspace,
},
"cwd-only": { command: "node", args: ["server.mjs"], cwd: workspace },
"unset-env": { command: "node", env: { CONFIG: "" }, cwd: workspace },
wrapped: {
command: "proto",
args: ["run", "node", "--", `${workspace}/server.mjs`],
cwd: workspace,
},
};
assert.deepEqual(openedWith?.mcpServers, expectedServers);
for (const disabledState of ["[", '{"off": true}', '["off",]']) {
yield* fileSystem.writeFileString(
path.join(home, ".cursor", "projects", projectDirectory, "mcp-disabled.json"),
disabledState,
);
assert.isTrue(Exit.isFailure(yield* Effect.exit(loadServers())));
}
yield* fileSystem.writeFileString(
path.join(workspace, ".cursor", "mcp.json"),
JSON.stringify({ mcpServers: { plain: { command: "node", args: ["server.mjs"] } } }),
);
yield* fileSystem.remove(path.join(home, ".cursor", "mcp.json"));
assert.deepEqual(yield* loadServers(), {});
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer),
),
),
);

it("recognizes direct and SDK-wrapped abort failures as cancellation", () => {
assert.isTrue(CursorAgentSdk.isCursorCancellationError({ name: "AbortError" }));
assert.isTrue(
Expand Down
21 changes: 19 additions & 2 deletions packages/provider-cursor/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import * as Queue from "effect/Queue";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
Expand All @@ -52,6 +53,7 @@ import {
hasCursorSkillMention,
rewriteCursorSkillMentions,
} from "./skills.ts";
import { cursorWorkspaceMcpServers } from "./workspaceMcpServers.ts";
import { t3OrchestrationPrompt } from "@t3tools/provider-core/server/orchestrationInstructions";
import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
Expand Down Expand Up @@ -299,10 +301,11 @@ export function makeCursorAgentOptions(input: {
readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
readonly threadId: ThreadId;
readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined;
readonly workspaceMcpServers?: Record<string, McpServerConfig>;
readonly platform: NodeJS.Platform;
}): AgentOptions {
const policy = cursorRuntimeAgentPolicy(input.runtimePolicy, input.platform);
const mcpServers = cursorMcpServers(input.mcpSession);
const mcpServers = { ...input.workspaceMcpServers, ...cursorMcpServers(input.mcpSession) };
return {
model: cursorSdkModelSelection(input.modelSelection),
name: `T3 Code ${input.threadId}`,
Expand All @@ -317,7 +320,7 @@ export function makeCursorAgentOptions(input: {
},
enableAgentRetries: true,
},
...(mcpServers === undefined ? {} : { mcpServers }),
...(Object.keys(mcpServers).length === 0 ? {} : { mcpServers }),
};
}

Expand Down Expand Up @@ -956,6 +959,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
options: CursorAdapterV2Options,
) {
const fileSystem = yield* FileSystem.FileSystem;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const path = yield* Path.Path;
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const runner = yield* CursorAgentSdk.CursorAgentSdkRunner;
Expand Down Expand Up @@ -2238,6 +2242,18 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
platform,
threadId: openInput.threadId,
mcpSession: yield* mcpSessions.read(openInput.threadId),
...(openInput.runtimePolicy.cwd === null
? {}
: {
workspaceMcpServers: yield* cursorWorkspaceMcpServers(
openInput.runtimePolicy.cwd,
adapterOptions.environment,
).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
),
}),
});
if (
platform === "win32" &&
Expand Down Expand Up @@ -2823,6 +2839,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (

export type CursorAdapterV2DriverEnv =
| CursorAgentSdk.CursorAgentSdkRunner
| ChildProcessSpawner.ChildProcessSpawner
| FileSystem.FileSystem
| Path.Path
| IdAllocator.IdAllocatorV2
Expand Down
Loading
Loading