From 8baec839c89a95f27e4a705cbe1bdd55d1a6b2eb Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 11:27:12 -0500 Subject: [PATCH 1/8] fix(cursor): project MCP servers can use ${workspaceFolder} The Cursor SDK reads .cursor/mcp.json and ~/.cursor/mcp.json itself but only expands environment variables, so Cursor's editor variables reach the server command literally and the server fails to start. The adapter now reads both files, resolves ${workspaceFolder}, ${workspaceFolderBasename}, ${userHome}, ${pathSeparator} and ${/} for each server that uses one, and passes the resolved copy on the agent. Agent-level servers win over same-named file servers in the SDK. Servers Cursor lists as disabled for the project stay off. Fixes #22199 Co-Authored-By: Claude Opus 5.5 --- packages/provider-cursor/package.json | 1 + .../src/server/adapter.test.ts | 96 +++++++++++ .../provider-cursor/src/server/adapter.ts | 17 +- .../src/server/workspaceMcpServers.ts | 151 ++++++++++++++++++ pnpm-lock.yaml | 3 + 5 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 packages/provider-cursor/src/server/workspaceMcpServers.ts diff --git a/packages/provider-cursor/package.json b/packages/provider-cursor/package.json index b139d6ecc8dd8..e58793299eabc 100644 --- a/packages/provider-cursor/package.json +++ b/packages/provider-cursor/package.json @@ -55,6 +55,7 @@ "@t3tools/provider-core": "workspace:*", "@t3tools/shared": "workspace:*", "effect": "catalog:", + "jsonc-parser": "3.3.1", "yaml": "catalog:" }, "devDependencies": { diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 0686b0c831dd2..9c8d2a117fab2 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -1246,6 +1246,102 @@ describe("CursorAdapterV2", () => { assert.notInclude(logged, "secret-cursor-mcp-token"); }); + it.effect("starts mcp.json servers that use Cursor's workspace variables", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-mcp-" }); + const home = path.join(workspace, "home"); + const cwd = path.join(workspace, "packages", "app"); + yield* fileSystem.makeDirectory(path.join(workspace, ".git"), { recursive: true }); + yield* fileSystem.makeDirectory(path.join(workspace, ".cursor"), { recursive: true }); + yield* fileSystem.makeDirectory(path.join(home, ".cursor"), { recursive: true }); + yield* fileSystem.makeDirectory(cwd, { 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"] }, + "plain": { "command": "node", "args": ["server.mjs"] }, + "off": { "command": "node", "args": ["\${workspaceFolder}/off.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}" }, + }, + }, + }), + ); + // 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, + settings: yield* decodeCursorSettings({}), + environment: { HOME: home, DOCS_TOKEN: "docs-token" }, + }).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 }); + + assert.deepEqual(openedWith?.mcpServers, { + repro: { command: "node", args: [`${workspace}/server.mjs`] }, + docs: { + url: `https://docs.example/${path.basename(workspace)}`, + headers: { Authorization: "Bearer docs-token" }, + }, + }); + }).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( diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 825596c04e6da..d33c8739d91b6 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -53,6 +53,7 @@ import { hasCursorSkillMention, rewriteCursorSkillMentions, } from "./skills.ts"; +import { cursorWorkspaceMcpServers } from "./workspaceMcpServers.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { t3OrchestrationPromptForFirstRun } from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; @@ -311,10 +312,11 @@ export function makeCursorAgentOptions(input: { readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; readonly threadId: ThreadId; readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined; + readonly workspaceMcpServers?: Record; 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}`, @@ -329,7 +331,7 @@ export function makeCursorAgentOptions(input: { }, enableAgentRetries: true, }, - ...(mcpServers === undefined ? {} : { mcpServers }), + ...(Object.keys(mcpServers).length === 0 ? {} : { mcpServers }), }; } @@ -2145,6 +2147,17 @@ 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), + ), + }), }); if ( platform === "win32" && diff --git a/packages/provider-cursor/src/server/workspaceMcpServers.ts b/packages/provider-cursor/src/server/workspaceMcpServers.ts new file mode 100644 index 0000000000000..e19a41e6f8042 --- /dev/null +++ b/packages/provider-cursor/src/server/workspaceMcpServers.ts @@ -0,0 +1,151 @@ +/** + * The Cursor SDK reads `.cursor/mcp.json` and `~/.cursor/mcp.json` itself but + * expands only environment variables, so editor variables such as + * `${workspaceFolder}` reach the server command literally. Agent-level servers + * win over file servers with the same name, so the adapter passes a resolved + * copy of each server that uses one. The file order, project root and + * `mcp-disabled.json` lookup follow SDK 1.0.35. + */ + +import type { McpServerConfig } from "@cursor/sdk"; +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 Schema from "effect/Schema"; +import { parse as parseJsonc } from "jsonc-parser"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +// The SDK's own placeholder grammar: `${env:NAME}`, `${NAME}` and `${NAME:-default}`. +const PLACEHOLDER_PATTERN = /\$\{(?:env:([A-Za-z_][A-Za-z0-9_]*)|([^:}]+)(?::-([^}]*))?)\}/g; +const WORKSPACE_VARIABLE_PATTERN = + /\$\{(?:workspaceFolder|workspaceFolderBasename|userHome|pathSeparator|\/)\}/; + +const StringRecord = Schema.Record(Schema.String, Schema.String); +const CursorMcpFile = Schema.Struct({ + mcpServers: Schema.Record( + Schema.String, + Schema.Union([ + Schema.Struct({ + type: Schema.optional(Schema.Literal("stdio")), + command: Schema.String, + args: Schema.optional(Schema.Array(Schema.String)), + env: Schema.optional(StringRecord), + cwd: Schema.optional(Schema.String), + }), + Schema.Struct({ + type: Schema.optional(Schema.Literals(["http", "sse"])), + url: Schema.String, + headers: Schema.optional(StringRecord), + auth: Schema.optional( + Schema.Struct({ + CLIENT_ID: Schema.String, + CLIENT_SECRET: Schema.optional(Schema.String), + scopes: Schema.optional(Schema.Array(Schema.String)), + }), + ), + }), + ]), + ), +}); +const decodeCursorMcpFile = Schema.decodeUnknownOption(CursorMcpFile); +const decodeDisabledServers = Schema.decodeUnknownOption(Schema.Array(Schema.String)); + +function expand(value: A, resolve: (text: string) => string): A { + if (typeof value === "string") return resolve(value) as A; + if (Array.isArray(value)) return value.map((item: unknown) => expand(item, resolve)) as A; + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, expand(item, resolve)]), + ) as A; + } + return value; +} + +/** Cursor's project slug, as `~/.cursor/projects/` names it. */ +function cursorProjectSlug(projectRoot: string): string { + return projectRoot + .replace(/[^a-zA-Z0-9]/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")(function* ( + cwd: string, + environment: NodeJS.ProcessEnv, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const readJsonc = (file: string) => + fileSystem.readFileString(file).pipe( + Effect.map((contents): unknown => parseJsonc(contents)), + Effect.orElseSucceed(() => undefined), + ); + + // The SDK takes the project root from `git rev-parse --show-toplevel`. + let projectRoot = cwd; + for (let directory = cwd; ; directory = path.dirname(directory)) { + if ( + yield* fileSystem.exists(path.join(directory, ".git")).pipe(Effect.orElseSucceed(() => false)) + ) { + projectRoot = directory; + break; + } + if (path.dirname(directory) === directory) break; + } + const userHome = + environment.HOME?.trim() || + environment.USERPROFILE?.trim() || + (yield* HostProcess.HomeDirectory); + const cursorDataDirectory = environment.CURSOR_DATA_DIR?.trim() || path.join(userHome, ".cursor"); + const disabled = new Set( + decodeDisabledServers( + yield* readJsonc( + path.join( + cursorDataDirectory, + "projects", + cursorProjectSlug(projectRoot), + "mcp-disabled.json", + ), + ), + ).pipe(Option.getOrElse(() => [])), + ); + const variables = new Map([ + ["workspaceFolder", projectRoot], + ["workspaceFolderBasename", path.basename(projectRoot)], + ["userHome", userHome], + ["pathSeparator", path.sep], + ["/", path.sep], + ]); + const resolve = (text: string) => + text.replace( + PLACEHOLDER_PATTERN, + (match, envName: string | undefined, name: string, fallback: string | undefined) => + (envName === undefined + ? (variables.get(name) ?? environment[name] ?? fallback) + : environment[envName]) ?? match, + ); + + const seen = new Set(); + const servers: Record = {}; + for (const file of [ + path.join(projectRoot, ".cursor", "mcp.json"), + path.join(userHome, ".cursor", "mcp.json"), + ]) { + const config = decodeCursorMcpFile(yield* readJsonc(file)); + if (Option.isNone(config)) continue; + for (const [name, server] of Object.entries(config.value.mcpServers)) { + if (seen.has(name)) continue; + seen.add(name); + if (disabled.has(name)) continue; + let usesWorkspaceVariable = false; + const resolved = expand(server, (text) => { + usesWorkspaceVariable ||= WORKSPACE_VARIABLE_PATTERN.test(text); + return resolve(text); + }); + // The SDK already resolves every other server the same way. + if (usesWorkspaceVariable) servers[name] = resolved as McpServerConfig; + } + } + return servers; +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc46b03b9f8fe..527d11bd1eac5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1541,6 +1541,9 @@ importers: effect: specifier: 4.0.5 version: 4.0.5 + jsonc-parser: + specifier: 3.3.1 + version: 3.3.1 yaml: specifier: ^2.9.0 version: 2.9.0 From a8a226ad9e3f4b14296a7cc13617d2d292b3c2d2 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 11:51:53 -0500 Subject: [PATCH 2/8] fix(cursor): match the SDK's mcp.json root, expansion and plugin order Expand environment placeholders before validating mcp.json, as the SDK does, so a server whose type comes from the environment no longer hides every workspace-variable server in that file. Resolve the cwd through symlinks before looking for the project root, as git does, so the mcp-disabled.json lookup and the mcp.json read use the SDK's root. Leave file servers named like plugin servers (plugin--) to the SDK, which prefers enabled plugin servers over file servers. Co-Authored-By: Claude Opus 5.5 --- .../src/server/adapter.test.ts | 178 ++++++++++-------- .../src/server/workspaceMcpServers.ts | 35 +++- 2 files changed, 120 insertions(+), 93 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 9c8d2a117fab2..65ef0362ca2b5 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -22,6 +22,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; 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"; @@ -1246,100 +1247,111 @@ describe("CursorAdapterV2", () => { assert.notInclude(logged, "secret-cursor-mcp-token"); }); - it.effect("starts mcp.json servers that use Cursor's workspace variables", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-mcp-" }); - const home = path.join(workspace, "home"); - const cwd = path.join(workspace, "packages", "app"); - yield* fileSystem.makeDirectory(path.join(workspace, ".git"), { recursive: true }); - yield* fileSystem.makeDirectory(path.join(workspace, ".cursor"), { recursive: true }); - yield* fileSystem.makeDirectory(path.join(home, ".cursor"), { recursive: true }); - yield* fileSystem.makeDirectory(cwd, { recursive: true }); - yield* fileSystem.writeFileString( - path.join(workspace, ".cursor", "mcp.json"), - `{ + 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"); + // 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* fileSystem.makeDirectory(path.join(workspace, ".git"), { recursive: true }); + 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"] }, "plain": { "command": "node", "args": ["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}" }, + ); + 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}" }, + }, }, - }, - }), - ); - // 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"]), - ); + }), + ); + // 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, - settings: yield* decodeCursorSettings({}), - environment: { HOME: home, DOCS_TOKEN: "docs-token" }, - }).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 }); + 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, + settings: yield* decodeCursorSettings({}), + environment: { HOME: home, 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 }); - assert.deepEqual(openedWith?.mcpServers, { - repro: { command: "node", args: [`${workspace}/server.mjs`] }, - docs: { - url: `https://docs.example/${path.basename(workspace)}`, - headers: { Authorization: "Bearer docs-token" }, - }, - }); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + assert.deepEqual(openedWith?.mcpServers, { + repro: { command: "node", args: [`${workspace}/server.mjs`] }, + docs: { + url: `https://docs.example/${path.basename(workspace)}`, + headers: { Authorization: "Bearer docs-token" }, + }, + }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + ), ), - ), ); it("recognizes direct and SDK-wrapped abort failures as cancellation", () => { diff --git a/packages/provider-cursor/src/server/workspaceMcpServers.ts b/packages/provider-cursor/src/server/workspaceMcpServers.ts index e19a41e6f8042..3083b4c641663 100644 --- a/packages/provider-cursor/src/server/workspaceMcpServers.ts +++ b/packages/provider-cursor/src/server/workspaceMcpServers.ts @@ -62,6 +62,14 @@ function expand(value: A, resolve: (text: string) => string): A { return value; } +function someString(value: unknown, test: (text: string) => boolean): boolean { + if (typeof value === "string") return test(value); + if (typeof value === "object" && value !== null) { + return Object.values(value).some((item) => someString(item, test)); + } + return false; +} + /** Cursor's project slug, as `~/.cursor/projects/` names it. */ function cursorProjectSlug(projectRoot: string): string { return projectRoot @@ -82,9 +90,11 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( Effect.orElseSucceed(() => undefined), ); - // The SDK takes the project root from `git rev-parse --show-toplevel`. + // The SDK takes the project root from `git rev-parse --show-toplevel`, which + // resolves symlinks, and falls back to the cwd as given. let projectRoot = cwd; - for (let directory = cwd; ; directory = path.dirname(directory)) { + const start = yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)); + for (let directory = start; ; directory = path.dirname(directory)) { if ( yield* fileSystem.exists(path.join(directory, ".git")).pipe(Effect.orElseSucceed(() => false)) ) { @@ -132,19 +142,24 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( path.join(projectRoot, ".cursor", "mcp.json"), path.join(userHome, ".cursor", "mcp.json"), ]) { - const config = decodeCursorMcpFile(yield* readJsonc(file)); + const contents = yield* readJsonc(file); + // Like the SDK, expand before validating, so `"type": "${env:TYPE}"` is valid. + const config = decodeCursorMcpFile(expand(contents, resolve)); if (Option.isNone(config)) continue; + const rawServers = (contents as { readonly mcpServers: Record }).mcpServers; for (const [name, server] of Object.entries(config.value.mcpServers)) { if (seen.has(name)) continue; seen.add(name); if (disabled.has(name)) continue; - let usesWorkspaceVariable = false; - const resolved = expand(server, (text) => { - usesWorkspaceVariable ||= WORKSPACE_VARIABLE_PATTERN.test(text); - return resolve(text); - }); - // The SDK already resolves every other server the same way. - if (usesWorkspaceVariable) servers[name] = resolved as McpServerConfig; + // The SDK already resolves every other server the same way. It names plugin + // servers `plugin--` and prefers them over file servers, so a + // file server with such a name stays with the SDK, which can see plugins. + if ( + !name.startsWith("plugin-") && + someString(rawServers[name], (text) => WORKSPACE_VARIABLE_PATTERN.test(text)) + ) { + servers[name] = server as McpServerConfig; + } } } return servers; From 0cf5408023a3f8500e7ff152cb59b057b7e8af01 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 15:06:07 +0000 Subject: [PATCH 3/8] fix(cursor): preserve sdk state when expanding mcp commands --- .../src/server/adapter.test.ts | 38 ++++++-- .../provider-cursor/src/server/adapter.ts | 2 + .../src/server/workspaceMcpServers.ts | 88 ++++++++----------- 3 files changed, 71 insertions(+), 57 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index a2ba3c4575e07..242de2dfbc508 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -15,6 +15,10 @@ import { import { CursorSettings } from "../settings.ts"; 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"; @@ -37,6 +41,7 @@ import { } from "./adapter.ts"; import * as CursorAgentSdk from "./CursorAgentSdk.ts"; import { ConfigurationError } from "./sdk.ts"; +import { cursorWorkspaceMcpServers } from "./workspaceMcpServers.ts"; const decodeCursorSettings = Schema.decodeEffect(CursorSettings); @@ -1316,13 +1321,18 @@ describe("CursorAdapterV2", () => { ); 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* fileSystem.makeDirectory(path.join(workspace, ".git"), { recursive: true }); + 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( @@ -1346,6 +1356,7 @@ describe("CursorAdapterV2", () => { docs: { url: "https://docs.example/${workspaceFolderBasename}", headers: { Authorization: "Bearer ${env:DOCS_TOKEN}" }, + tls: { caBundle: "${userHome}/ca.pem" }, }, }, }), @@ -1372,7 +1383,12 @@ describe("CursorAdapterV2", () => { const adapter = yield* makeCursorAdapterV2({ instanceId, settings: yield* decodeCursorSettings({}), - environment: { HOME: home, DOCS_TOKEN: "docs-token", MCP_TYPE: "stdio" }, + 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, @@ -1395,15 +1411,23 @@ describe("CursorAdapterV2", () => { modelSelection, runtimePolicy, }); - yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + yield* runtime + .ensureThread({ threadId, modelSelection, runtimePolicy }) + .pipe( + Effect.provideService(HostProcess.HomeDirectory, home), + Effect.provideService(HostProcess.Environment, {}), + ); assert.deepEqual(openedWith?.mcpServers, { repro: { command: "node", args: [`${workspace}/server.mjs`] }, - docs: { - url: `https://docs.example/${path.basename(workspace)}`, - headers: { Authorization: "Bearer docs-token" }, - }, }); + 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()))); + } }).pipe( Effect.scoped, Effect.provide( diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 693d3efc7a6f1..eca5c272175a1 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -37,6 +37,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"; @@ -2727,6 +2728,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( export type CursorAdapterV2DriverEnv = | CursorAgentSdk.CursorAgentSdkRunner + | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | IdAllocator.IdAllocatorV2 diff --git a/packages/provider-cursor/src/server/workspaceMcpServers.ts b/packages/provider-cursor/src/server/workspaceMcpServers.ts index 3083b4c641663..1262444718d17 100644 --- a/packages/provider-cursor/src/server/workspaceMcpServers.ts +++ b/packages/provider-cursor/src/server/workspaceMcpServers.ts @@ -1,19 +1,13 @@ -/** - * The Cursor SDK reads `.cursor/mcp.json` and `~/.cursor/mcp.json` itself but - * expands only environment variables, so editor variables such as - * `${workspaceFolder}` reach the server command literally. Agent-level servers - * win over file servers with the same name, so the adapter passes a resolved - * copy of each server that uses one. The file order, project root and - * `mcp-disabled.json` lookup follow SDK 1.0.35. - */ - +// The SDK leaves editor variables literal; agent-level stdio servers take precedence. import type { McpServerConfig } from "@cursor/sdk"; 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 Schema from "effect/Schema"; -import { parse as parseJsonc } from "jsonc-parser"; +import * as ChildProcess from "effect/process/ChildProcess"; +import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe"; +import { parse as parseJsonc, type ParseError } from "jsonc-parser"; import * as HostProcess from "@t3tools/shared/HostProcess"; // The SDK's own placeholder grammar: `${env:NAME}`, `${NAME}` and `${NAME:-default}`. @@ -36,20 +30,14 @@ const CursorMcpFile = Schema.Struct({ Schema.Struct({ type: Schema.optional(Schema.Literals(["http", "sse"])), url: Schema.String, - headers: Schema.optional(StringRecord), - auth: Schema.optional( - Schema.Struct({ - CLIENT_ID: Schema.String, - CLIENT_SECRET: Schema.optional(Schema.String), - scopes: Schema.optional(Schema.Array(Schema.String)), - }), - ), }), ]), ), }); const decodeCursorMcpFile = Schema.decodeUnknownOption(CursorMcpFile); -const decodeDisabledServers = Schema.decodeUnknownOption(Schema.Array(Schema.String)); +const decodeDisabledServers = Schema.decodeEffect( + Schema.fromJsonString(Schema.Array(Schema.String)), +); function expand(value: A, resolve: (text: string) => string): A { if (typeof value === "string") return resolve(value) as A; @@ -86,40 +74,39 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( const path = yield* Path.Path; const readJsonc = (file: string) => fileSystem.readFileString(file).pipe( - Effect.map((contents): unknown => parseJsonc(contents)), - Effect.orElseSucceed(() => undefined), + Effect.map((contents): unknown => { + const errors: ParseError[] = []; + const value: unknown = parseJsonc(contents, errors, { allowTrailingComma: true }); + return errors.length === 0 ? value : null; + }), + Effect.catch((error) => + error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error), + ), ); - // The SDK takes the project root from `git rev-parse --show-toplevel`, which - // resolves symlinks, and falls back to the cwd as given. - let projectRoot = cwd; - const start = yield* fileSystem.realPath(cwd).pipe(Effect.orElseSucceed(() => cwd)); - for (let directory = start; ; directory = path.dirname(directory)) { - if ( - yield* fileSystem.exists(path.join(directory, ".git")).pipe(Effect.orElseSucceed(() => false)) - ) { - projectRoot = directory; - break; - } - if (path.dirname(directory) === directory) break; - } - const userHome = - environment.HOME?.trim() || - environment.USERPROFILE?.trim() || - (yield* HostProcess.HomeDirectory); - const cursorDataDirectory = environment.CURSOR_DATA_DIR?.trim() || path.join(userHome, ".cursor"); - const disabled = new Set( - decodeDisabledServers( - yield* readJsonc( - path.join( - cursorDataDirectory, - "projects", - cursorProjectSlug(projectRoot), - "mcp-disabled.json", - ), + const hostEnvironment = yield* HostProcess.Environment; + const root = yield* spawnAndCollect( + "git", + ChildProcess.make("git", ["rev-parse", "--show-toplevel"], { cwd }), + ).pipe(Effect.option); + const projectRoot = Option.isSome(root) && root.value.code === 0 ? root.value.stdout.trim() : cwd; + const userHome = yield* HostProcess.HomeDirectory; + const cursorDataDirectory = hostEnvironment.CURSOR_DATA_DIR || path.join(userHome, ".cursor"); + const disabledText = yield* fileSystem + .readFileString( + path.join( + cursorDataDirectory, + "projects", + cursorProjectSlug(projectRoot), + "mcp-disabled.json", + ), + ) + .pipe( + Effect.catch((error) => + error.reason._tag === "NotFound" ? Effect.succeed("[]") : Effect.fail(error), ), - ).pipe(Option.getOrElse(() => [])), - ); + ); + const disabled = new Set(yield* decodeDisabledServers(disabledText)); const variables = new Map([ ["workspaceFolder", projectRoot], ["workspaceFolderBasename", path.basename(projectRoot)], @@ -155,6 +142,7 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( // servers `plugin--` and prefers them over file servers, so a // file server with such a name stays with the SDK, which can see plugins. if ( + "command" in server && !name.startsWith("plugin-") && someString(rawServers[name], (text) => WORKSPACE_VARIABLE_PATTERN.test(text)) ) { From 89a2e13f442efdb7914ee3e00a1bee097cabe50d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 15:09:50 +0000 Subject: [PATCH 4/8] fix(cursor): capture the mcp command service in the adapter --- packages/provider-cursor/src/server/adapter.ts | 2 ++ .../provider-cursor/src/server/workspaceMcpServers.ts | 10 ++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index eca5c272175a1..adb311686d310 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -885,6 +885,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; @@ -2169,6 +2170,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( ).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), }), }); diff --git a/packages/provider-cursor/src/server/workspaceMcpServers.ts b/packages/provider-cursor/src/server/workspaceMcpServers.ts index 1262444718d17..d83a199609c14 100644 --- a/packages/provider-cursor/src/server/workspaceMcpServers.ts +++ b/packages/provider-cursor/src/server/workspaceMcpServers.ts @@ -79,8 +79,9 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( const value: unknown = parseJsonc(contents, errors, { allowTrailingComma: true }); return errors.length === 0 ? value : null; }), - Effect.catch((error) => - error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error), + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.void, ), ); @@ -102,8 +103,9 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( ), ) .pipe( - Effect.catch((error) => - error.reason._tag === "NotFound" ? Effect.succeed("[]") : Effect.fail(error), + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed("[]"), ), ); const disabled = new Set(yield* decodeDisabledServers(disabledText)); From 9b2fbefcf9d7e513fd65ca4d4c7369e38363b923 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 08:02:46 +0000 Subject: [PATCH 5/8] fix(cursor): leave env-only mcp servers with the sdk --- packages/provider-cursor/src/server/adapter.test.ts | 3 ++- .../provider-cursor/src/server/workspaceMcpServers.ts | 11 ++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 99c7c8b51fb4a..fdd42aeab02e1 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -1715,6 +1715,8 @@ describe("CursorAdapterV2", () => { "mcpServers": { "repro": { "command": "node", "args": ["\${workspaceFolder}/server.mjs"] }, "plain": { "command": "node", "args": ["server.mjs"] }, + "env-only": { "command": "node", "args": ["server.mjs"], "env": { "PROJECT_ROOT": "\${workspaceFolder}" } }, + "cwd-only": { "command": "node", "args": ["server.mjs"], "cwd": "\${workspaceFolder}" }, "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"] }, @@ -1755,7 +1757,6 @@ describe("CursorAdapterV2", () => { let openedWith: CursorAgentSdk.CursorAgentSdkOpenInput["options"] | undefined; const adapter = yield* makeCursorAdapterV2({ instanceId, - settings: yield* decodeCursorSettings({}), environment: { HOME: path.join(scratch, "override"), CURSOR_DATA_DIR: path.join(scratch, "override"), diff --git a/packages/provider-cursor/src/server/workspaceMcpServers.ts b/packages/provider-cursor/src/server/workspaceMcpServers.ts index d83a199609c14..51cb045b7ce74 100644 --- a/packages/provider-cursor/src/server/workspaceMcpServers.ts +++ b/packages/provider-cursor/src/server/workspaceMcpServers.ts @@ -1,4 +1,4 @@ -// The SDK leaves editor variables literal; agent-level stdio servers take precedence. +// The SDK leaves editor variables literal; agent-level stdio clients take precedence. import type { McpServerConfig } from "@cursor/sdk"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -135,7 +135,9 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( // Like the SDK, expand before validating, so `"type": "${env:TYPE}"` is valid. const config = decodeCursorMcpFile(expand(contents, resolve)); if (Option.isNone(config)) continue; - const rawServers = (contents as { readonly mcpServers: Record }).mcpServers; + const rawServers = ( + contents as { readonly mcpServers: Record } + ).mcpServers; for (const [name, server] of Object.entries(config.value.mcpServers)) { if (seen.has(name)) continue; seen.add(name); @@ -146,7 +148,10 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( if ( "command" in server && !name.startsWith("plugin-") && - someString(rawServers[name], (text) => WORKSPACE_VARIABLE_PATTERN.test(text)) + // File clients start before SDK deduplication; leave env/cwd-only servers alone. + someString([rawServers[name]?.command, rawServers[name]?.args], (text) => + WORKSPACE_VARIABLE_PATTERN.test(text), + ) ) { servers[name] = server as McpServerConfig; } From 4ea0d8d161042c3c4e38b2590012e6d4e822152d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 08:20:56 +0000 Subject: [PATCH 6/8] fix(cursor): limit workspace overrides to startup paths --- packages/provider-cursor/src/server/adapter.test.ts | 10 ++++++---- .../src/server/workspaceMcpServers.ts | 12 ++++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index fdd42aeab02e1..5e2aa0c7efb0d 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -1713,8 +1713,9 @@ describe("CursorAdapterV2", () => { `{ // Cursor's editor accepts comments here. "mcpServers": { - "repro": { "command": "node", "args": ["\${workspaceFolder}/server.mjs"] }, + "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}" }, "off": { "command": "node", "args": ["\${workspaceFolder}/off.mjs"] }, @@ -1792,9 +1793,10 @@ describe("CursorAdapterV2", () => { Effect.provideService(HostProcess.Environment, {}), ); - assert.deepEqual(openedWith?.mcpServers, { - repro: { command: "node", args: [`${workspace}/server.mjs`] }, - }); + const expectedServers = { + repro: { command: "node", args: [`${workspace}/server.mjs`], startupTimeoutSec: 120 }, + }; + assert.deepEqual(openedWith?.mcpServers, expectedServers); for (const disabledState of ["[", '{"off": true}', '["off",]']) { yield* fileSystem.writeFileString( path.join(home, ".cursor", "projects", projectDirectory, "mcp-disabled.json"), diff --git a/packages/provider-cursor/src/server/workspaceMcpServers.ts b/packages/provider-cursor/src/server/workspaceMcpServers.ts index 51cb045b7ce74..0f62a21f32f68 100644 --- a/packages/provider-cursor/src/server/workspaceMcpServers.ts +++ b/packages/provider-cursor/src/server/workspaceMcpServers.ts @@ -26,6 +26,7 @@ const CursorMcpFile = Schema.Struct({ args: Schema.optional(Schema.Array(Schema.String)), env: Schema.optional(StringRecord), cwd: Schema.optional(Schema.String), + startupTimeoutSec: Schema.optional(Schema.Number), }), Schema.Struct({ type: Schema.optional(Schema.Literals(["http", "sse"])), @@ -136,7 +137,9 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( const config = decodeCursorMcpFile(expand(contents, resolve)); if (Option.isNone(config)) continue; const rawServers = ( - contents as { readonly mcpServers: Record } + contents as { + readonly mcpServers: Record }>; + } ).mcpServers; for (const [name, server] of Object.entries(config.value.mcpServers)) { if (seen.has(name)) continue; @@ -148,9 +151,10 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( if ( "command" in server && !name.startsWith("plugin-") && - // File clients start before SDK deduplication; leave env/cwd-only servers alone. - someString([rawServers[name]?.command, rawServers[name]?.args], (text) => - WORKSPACE_VARIABLE_PATTERN.test(text), + // File clients start before deduplication; only replace executable/script paths. + someString( + [rawServers[name]?.command, rawServers[name]?.args?.[0]], + (text) => !text.startsWith("-") && WORKSPACE_VARIABLE_PATTERN.test(text), ) ) { servers[name] = server as McpServerConfig; From e49122c6e10624411e6194a05d4f292d83a7bff6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 02:05:00 +0000 Subject: [PATCH 7/8] fix(cursor): resolve workspace variables throughout mcp definitions --- .../src/server/adapter.test.ts | 27 ++++++++++++++++++- .../src/server/workspaceMcpServers.ts | 20 +++++++------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 2de00b03305fa..9378c72a4ab29 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -1816,6 +1816,8 @@ describe("CursorAdapterV2", () => { "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"] }, @@ -1892,7 +1894,30 @@ describe("CursorAdapterV2", () => { ); const expectedServers = { - repro: { command: "node", args: [`${workspace}/server.mjs`], startupTimeoutSec: 120 }, + 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",]']) { diff --git a/packages/provider-cursor/src/server/workspaceMcpServers.ts b/packages/provider-cursor/src/server/workspaceMcpServers.ts index 0f62a21f32f68..0fd829310c6aa 100644 --- a/packages/provider-cursor/src/server/workspaceMcpServers.ts +++ b/packages/provider-cursor/src/server/workspaceMcpServers.ts @@ -123,7 +123,7 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( (match, envName: string | undefined, name: string, fallback: string | undefined) => (envName === undefined ? (variables.get(name) ?? environment[name] ?? fallback) - : environment[envName]) ?? match, + : (environment[envName] ?? "")) ?? match, ); const seen = new Set(); @@ -138,26 +138,28 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( if (Option.isNone(config)) continue; const rawServers = ( contents as { - readonly mcpServers: Record }>; + readonly mcpServers: Record; } ).mcpServers; for (const [name, server] of Object.entries(config.value.mcpServers)) { if (seen.has(name)) continue; seen.add(name); if (disabled.has(name)) continue; - // The SDK already resolves every other server the same way. It names plugin - // servers `plugin--` and prefers them over file servers, so a - // file server with such a name stays with the SDK, which can see plugins. + // Plugin definitions take precedence in the SDK, which can see enabled plugins. if ( "command" in server && !name.startsWith("plugin-") && - // File clients start before deduplication; only replace executable/script paths. someString( - [rawServers[name]?.command, rawServers[name]?.args?.[0]], - (text) => !text.startsWith("-") && WORKSPACE_VARIABLE_PATTERN.test(text), + rawServers[name], + (text) => + WORKSPACE_VARIABLE_PATTERN.test(text) || + Array.from(text.matchAll(/\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g)).some( + ([, name]) => name !== undefined && environment[name] === undefined, + ), ) ) { - servers[name] = server as McpServerConfig; + // The SDK starts file clients before deduplication, so a literal copy may also run. + servers[name] = { ...server, cwd: server.cwd ?? projectRoot } as McpServerConfig; } } } From 1b60d28cb686f9adac8f3d3efbe4ef21ec2f99de Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 02:07:53 +0000 Subject: [PATCH 8/8] fix(cursor): read disabled mcp state only for overrides --- packages/provider-cursor/src/server/adapter.test.ts | 6 ++++++ .../provider-cursor/src/server/workspaceMcpServers.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 9378c72a4ab29..1d87696ca4cc4 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -1927,6 +1927,12 @@ describe("CursorAdapterV2", () => { ); 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( diff --git a/packages/provider-cursor/src/server/workspaceMcpServers.ts b/packages/provider-cursor/src/server/workspaceMcpServers.ts index 0fd829310c6aa..4fd56ab781ad8 100644 --- a/packages/provider-cursor/src/server/workspaceMcpServers.ts +++ b/packages/provider-cursor/src/server/workspaceMcpServers.ts @@ -94,7 +94,7 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( const projectRoot = Option.isSome(root) && root.value.code === 0 ? root.value.stdout.trim() : cwd; const userHome = yield* HostProcess.HomeDirectory; const cursorDataDirectory = hostEnvironment.CURSOR_DATA_DIR || path.join(userHome, ".cursor"); - const disabledText = yield* fileSystem + const loadDisabled = fileSystem .readFileString( path.join( cursorDataDirectory, @@ -108,8 +108,10 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( (error) => error.reason._tag === "NotFound", () => Effect.succeed("[]"), ), + Effect.flatMap(decodeDisabledServers), + Effect.map((names) => new Set(names)), ); - const disabled = new Set(yield* decodeDisabledServers(disabledText)); + let disabled: Set | undefined; const variables = new Map([ ["workspaceFolder", projectRoot], ["workspaceFolderBasename", path.basename(projectRoot)], @@ -144,7 +146,6 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( for (const [name, server] of Object.entries(config.value.mcpServers)) { if (seen.has(name)) continue; seen.add(name); - if (disabled.has(name)) continue; // Plugin definitions take precedence in the SDK, which can see enabled plugins. if ( "command" in server && @@ -158,6 +159,8 @@ export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")( ), ) ) { + disabled ??= yield* loadDisabled; + if (disabled.has(name)) continue; // The SDK starts file clients before deduplication, so a literal copy may also run. servers[name] = { ...server, cwd: server.cwd ?? projectRoot } as McpServerConfig; }