Skip to content
Source
Sign inSign up

workspaceMcpServers.ts

175 lines6.6 KB

1// The SDK leaves editor variables literal; agent-level stdio clients take precedence.
2import type { McpServerConfig } from "@cursor/sdk";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7import * as Schema from "effect/Schema";
8import * as ChildProcess from "effect/process/ChildProcess";
9import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe";
10import { parse as parseJsonc, type ParseError } from "jsonc-parser";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12​
13// The SDK's own placeholder grammar: `${env:NAME}`, `${NAME}` and `${NAME:-default}`.
14const PLACEHOLDER_PATTERN = /\$\{(?:env:([A-Za-z_][A-Za-z0-9_]*)|([^:}]+)(?::-([^}]*))?)\}/g;
15const WORKSPACE_VARIABLE_PATTERN =
16 /\$\{(?:workspaceFolder|workspaceFolderBasename|userHome|pathSeparator|\/)\}/;
17const WORKSPACE_PATH_PATTERN = /^\$\{(?:workspaceFolder|userHome)\}[\\/]/;
18// These exit when their script argument is missing; other arguments may be labels a server ignores.
19const SCRIPT_INTERPRETERS = new Set(["node", "bun", "tsx", "python", "python3"]);
20​
21const StringRecord = Schema.Record(Schema.String, Schema.String);
22const CursorMcpFile = Schema.Struct({
23 mcpServers: Schema.Record(
24 Schema.String,
25 Schema.Union([
26 Schema.Struct({
27 type: Schema.optional(Schema.Literal("stdio")),
28 command: Schema.String,
29 args: Schema.optional(Schema.Array(Schema.String)),
30 env: Schema.optional(StringRecord),
31 cwd: Schema.optional(Schema.String),
32 startupTimeoutSec: Schema.optional(Schema.Number),
33 }),
34 Schema.Struct({
35 type: Schema.optional(Schema.Literals(["http", "sse"])),
36 url: Schema.String,
37 }),
38 ]),
39 ),
40});
41const decodeCursorMcpFile = Schema.decodeUnknownOption(CursorMcpFile);
42const decodeDisabledServers = Schema.decodeEffect(
43 Schema.fromJsonString(Schema.Array(Schema.String)),
44);
45​
46function expand<A>(value: A, resolve: (text: string) => string): A {
47 if (typeof value === "string") return resolve(value) as A;
48 if (Array.isArray(value)) return value.map((item: unknown) => expand(item, resolve)) as A;
49 if (typeof value === "object" && value !== null) {
50 return Object.fromEntries(
51 Object.entries(value).map(([key, item]) => [key, expand(item, resolve)]),
52 ) as A;
53 }
54 return value;
56​
57// Only servers whose literal copy cannot start: the SDK also starts the file-defined copy and
58// loses its handle when it keeps the inline one, and inline configs drop envFile and enabledTools.
59function needsWorkspaceExpansion(server: unknown): boolean {
60 if (typeof server !== "object" || server === null) return false;
61 if ("envFile" in server || "enabledTools" in server) return false;
62 const { command, cwd, args } = server as Record<string, unknown>;
63 return (
64 [command, cwd].some(
65 (text) => typeof text === "string" && WORKSPACE_VARIABLE_PATTERN.test(text),
66 ) ||
67 (typeof command === "string" &&
68 SCRIPT_INTERPRETERS.has(command.replace(/^.*[\\/]/, "").replace(/\.exe$/i, "")) &&
69 Array.isArray(args) &&
70 typeof args[0] === "string" &&
71 WORKSPACE_PATH_PATTERN.test(args[0]))
72 );
74​
75/** Cursor's project slug, as `~/.cursor/projects/<slug>` names it. */
76function cursorProjectSlug(projectRoot: string): string {
77 return projectRoot
78 .replace(/[^a-zA-Z0-9]/g, "-")
79 .replace(/-+/g, "-")
80 .replace(/^-+|-+$/g, "");
82​
83export const cursorWorkspaceMcpServers = Effect.fn("cursorWorkspaceMcpServers")(function* (
84 cwd: string,
85 environment: NodeJS.ProcessEnv,
86) {
87 const fileSystem = yield* FileSystem.FileSystem;
88 const path = yield* Path.Path;
89 const readJsonc = (file: string) =>
90 fileSystem.readFileString(file).pipe(
91 Effect.map((contents): unknown => {
92 const errors: ParseError[] = [];
93 const value: unknown = parseJsonc(contents, errors, { allowTrailingComma: true });
94 return errors.length === 0 ? value : null;
95 }),
96 Effect.catchIf(
97 (error) => error.reason._tag === "NotFound",
98 () => Effect.void,
99 ),
100 );
101​
102 const hostEnvironment = yield* HostProcess.Environment;
103 const root = yield* spawnAndCollect(
104 "git",
105 ChildProcess.make("git", ["rev-parse", "--show-toplevel"], { cwd }),
106 ).pipe(Effect.option);
107 const projectRoot = Option.isSome(root) && root.value.code === 0 ? root.value.stdout.trim() : cwd;
108 const userHome = yield* HostProcess.HomeDirectory;
109 const cursorDataDirectory = hostEnvironment.CURSOR_DATA_DIR || path.join(userHome, ".cursor");
110 const loadDisabled = fileSystem
111 .readFileString(
112 path.join(
113 cursorDataDirectory,
114 "projects",
115 cursorProjectSlug(projectRoot),
116 "mcp-disabled.json",
117 ),
118 )
119 .pipe(
120 Effect.catchIf(
121 (error) => error.reason._tag === "NotFound",
122 () => Effect.succeed("[]"),
123 ),
124 Effect.flatMap(decodeDisabledServers),
125 Effect.map((names) => new Set(names)),
126 );
127 let disabled: Set<string> | undefined;
128 const variables = new Map([
129 ["workspaceFolder", projectRoot],
130 ["workspaceFolderBasename", path.basename(projectRoot)],
131 ["userHome", userHome],
132 ["pathSeparator", path.sep],
133 ["/", path.sep],
134 ]);
135 const resolve = (text: string) =>
136 text.replace(
137 PLACEHOLDER_PATTERN,
138 (match, envName: string | undefined, name: string, fallback: string | undefined) =>
139 (envName === undefined
140 ? (variables.get(name) ?? environment[name] ?? fallback)
141 : (environment[envName] ?? "")) ?? match,
142 );
143​
144 const seen = new Set<string>();
145 const servers: Record<string, McpServerConfig> = {};
146 for (const file of [
147 path.join(projectRoot, ".cursor", "mcp.json"),
148 path.join(userHome, ".cursor", "mcp.json"),
149 ]) {
150 const contents = yield* readJsonc(file);
151 // Like the SDK, expand before validating, so `"type": "${env:TYPE}"` is valid.
152 const config = decodeCursorMcpFile(expand(contents, resolve));
153 if (Option.isNone(config)) continue;
154 const rawServers = (
155 contents as {
156 readonly mcpServers: Record<string, unknown>;
157 }
158 ).mcpServers;
159 for (const [name, server] of Object.entries(config.value.mcpServers)) {
160 if (seen.has(name)) continue;
161 seen.add(name);
162 // Plugin definitions take precedence in the SDK, which can see enabled plugins.
163 if (
164 "command" in server &&
165 !name.startsWith("plugin-") &&
166 needsWorkspaceExpansion(rawServers[name])
167 ) {
168 disabled ??= yield* loadDisabled;
169 if (disabled.has(name)) continue;
170 servers[name] = { ...server, cwd: server.cwd ?? projectRoot } as McpServerConfig;
171 }
172 }
173 }
174 return servers;
175});