diff --git a/core/context/mcp/MCPConnection.ts b/core/context/mcp/MCPConnection.ts index d25e9f6a0e7..624b5cb392a 100644 --- a/core/context/mcp/MCPConnection.ts +++ b/core/context/mcp/MCPConnection.ts @@ -1,6 +1,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { homedir } from "os"; +import { isAbsolute } from "path"; import { fileURLToPath } from "url"; import { @@ -30,6 +31,7 @@ import { MCPTool, } from "../.."; import { resolveRelativePathInDir } from "../../util/ideUtils"; +import { resolveMcpVariables } from "../../util/resolveMcpVariables"; import { getEnvPathFromUserShell } from "../../util/shellPath"; import { getOauthToken } from "./MCPOauth"; @@ -460,8 +462,9 @@ Org-level secrets can only be used for MCP by Background Agents (https://docs.co return fileURLToPath(cwd); } - // Return cwd if cwd is an absolute path. - if (cwd.charAt(0) === "/") { + // Return cwd if cwd is already an absolute path (cross-platform, so this + // also catches Windows drive-letter paths like "C:\Users\..."). + if (isAbsolute(cwd)) { return cwd; } @@ -473,24 +476,48 @@ Org-level secrets can only be used for MCP by Background Agents (https://docs.co if (IDE) { const target = cwd ?? "."; const resolved = await resolveRelativePathInDir(target, IDE); - if (resolved) { - if (resolved.startsWith("file://")) { - return fileURLToPath(resolved); - } - // Remote URIs (e.g. vscode-remote://ssh-remote+host/path) cannot be - // used as a local cwd for child_process.spawn(). When the extension - // runs in the Local Extension Host on Windows while connected to a - // remote workspace, fall back to the user's home directory. - if (resolved.includes("://")) { - return homedir(); - } + if (!resolved) { return resolved; } - return resolved; + // Remote URIs (e.g. vscode-remote://ssh-remote+host/path) cannot be + // used as a local cwd for child_process.spawn(). When the extension + // runs in the Local Extension Host on Windows while connected to a + // remote workspace, fall back to the user's home directory since any + // valid local directory is an acceptable cwd. + return this.workspaceUriToFsPath(resolved) ?? homedir(); } return cwd; } + /** + * Converts a workspace directory URI (as returned by ide.getWorkspaceDirs()) + * into a local filesystem path, or undefined if the URI has no local + * filesystem equivalent (e.g. a remote workspace URI). + */ + private workspaceUriToFsPath(uri: string): string | undefined { + if (uri.startsWith("file://")) { + return fileURLToPath(uri); + } + if (uri.includes("://")) { + return undefined; + } + return uri; + } + + /** + * Resolves the primary workspace folder as a local filesystem path, for use + * as the `${workspaceFolder}` variable in MCP config interpolation. + * Returns undefined (leaving the variable unresolved) if there is no + * workspace open or it's a remote workspace with no local path equivalent - + * unlike cwd resolution, substituting an unrelated directory here would + * silently point configs at the wrong location. + */ + private async getWorkspaceFolderPath(): Promise { + const dirs = await this.extras?.ide?.getWorkspaceDirs(); + const first = dirs?.[0]; + return first ? this.workspaceUriToFsPath(first) : undefined; + } + private constructWebsocketTransport( options: InternalWebsocketMcpOptions, ): WebSocketClientTransport { @@ -556,6 +583,16 @@ Org-level secrets can only be used for MCP by Background Agents (https://docs.co private async constructStdioTransport( options: InternalStdioMcpOptions, ): Promise { + // Resolve ${workspaceFolder}/${workspaceFolderBasename}/${userHome}/${env:VAR} + // exactly once, right before the process is spawned. + const variableContext = { + workspaceDir: await this.getWorkspaceFolderPath(), + }; + const command = resolveMcpVariables(options.command, variableContext); + const args = resolveMcpVariables(options.args ?? [], variableContext); + const cwd = resolveMcpVariables(options.cwd, variableContext); + const resolvedEnv = resolveMcpVariables(options.env ?? {}, variableContext); + const commonEnvVars: Record = Object.fromEntries( COMMONS_ENV_VARS.filter((key) => process.env[key] !== undefined).map( (key) => [key, process.env[key] as string], @@ -564,7 +601,7 @@ Org-level secrets can only be used for MCP by Background Agents (https://docs.co const env = { ...commonEnvVars, - ...(options.env ?? {}), + ...resolvedEnv, }; if (process.env.PATH !== undefined) { @@ -589,18 +626,18 @@ Org-level secrets can only be used for MCP by Background Agents (https://docs.co } } - const { command, args } = await this.resolveCommandForPlatform( - options.command, - options.args || [], + const platformResolved = await this.resolveCommandForPlatform( + command, + args, ); - const cwd = await this.resolveCwd(options.cwd); + const resolvedCwd = await this.resolveCwd(cwd); const transport = new StdioClientTransport({ - command, - args, + command: platformResolved.command, + args: platformResolved.args, env, - cwd, + cwd: resolvedCwd, stderr: "pipe", }); diff --git a/core/context/mcp/MCPConnection.vitest.ts b/core/context/mcp/MCPConnection.vitest.ts index adb4b9c5493..740a3f67677 100644 --- a/core/context/mcp/MCPConnection.vitest.ts +++ b/core/context/mcp/MCPConnection.vitest.ts @@ -1,5 +1,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { pathToFileURL } from "url"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { InternalSseMcpOptions, InternalStdioMcpOptions, @@ -188,6 +189,131 @@ describe("MCPConnection", () => { }); }); + describe("getWorkspaceFolderPath", () => { + const baseOptions: InternalStdioMcpOptions = { + name: "test-mcp", + id: "test-id", + type: "stdio", + command: "test-cmd", + args: [], + }; + + it("should return undefined when there is no ide", async () => { + const conn = new MCPConnection(baseOptions); + await expect( + (conn as any).getWorkspaceFolderPath(), + ).resolves.toBeUndefined(); + }); + + it("should return undefined when no workspace folder is open", async () => { + const ide = { + getWorkspaceDirs: vi.fn().mockResolvedValue([]), + } as any; + const conn = new MCPConnection(baseOptions, { ide }); + await expect( + (conn as any).getWorkspaceFolderPath(), + ).resolves.toBeUndefined(); + }); + + it("should convert a file:// workspace dir to a local fs path", async () => { + const workspacePath = + process.platform === "win32" ? "C:\\workspace" : "/workspace"; + const ide = { + getWorkspaceDirs: vi + .fn() + .mockResolvedValue([pathToFileURL(workspacePath).toString()]), + } as any; + const conn = new MCPConnection(baseOptions, { ide }); + await expect((conn as any).getWorkspaceFolderPath()).resolves.toBe( + workspacePath, + ); + }); + + it("should leave the workspace folder unresolved (not fall back to homedir) for remote workspace URIs", async () => { + // A wrong-but-valid directory (homedir) is an acceptable last resort for + // a spawn cwd, but not for a ${workspaceFolder} variable substituted + // into arbitrary args/env values - that must stay unresolved instead of + // silently pointing configs at the wrong directory. + const ide = { + getWorkspaceDirs: vi + .fn() + .mockResolvedValue([ + "vscode-remote://ssh-remote+host/home/user/project", + ]), + } as any; + const conn = new MCPConnection(baseOptions, { ide }); + await expect( + (conn as any).getWorkspaceFolderPath(), + ).resolves.toBeUndefined(); + }); + }); + + describe("constructStdioTransport variable interpolation", () => { + const optionsWithVariables: InternalStdioMcpOptions = { + name: "test-mcp", + id: "test-id", + type: "stdio", + command: "test-cmd", + args: ["${workspaceFolder}/src", "literal"], + cwd: "${workspaceFolder}", + env: { + PROJECT_DIR: "${workspaceFolder}", + TOKEN: "${env:MCP_TEST_TOKEN}", + }, + }; + + afterEach(() => { + delete process.env.MCP_TEST_TOKEN; + }); + + it("should resolve ${workspaceFolder} and ${env:VAR} in command/args/cwd/env before spawning", async () => { + process.env.MCP_TEST_TOKEN = "secret-value"; + const workspacePath = + process.platform === "win32" ? "C:\\workspace" : "/workspace"; + const ide = { + getWorkspaceDirs: vi + .fn() + .mockResolvedValue([pathToFileURL(workspacePath).toString()]), + getIdeInfo: vi.fn().mockResolvedValue({ remoteName: "" }), + } as any; + const conn = new MCPConnection(optionsWithVariables, { ide }); + + const transport = await (conn as any).constructStdioTransport( + optionsWithVariables, + ); + const params = (transport as any)._serverParams; + + expect(params.args).toEqual([`${workspacePath}/src`, "literal"]); + expect(params.cwd).toBe(workspacePath); + expect(params.env.PROJECT_DIR).toBe(workspacePath); + expect(params.env.TOKEN).toBe("secret-value"); + }); + + it("should leave ${workspaceFolder} unresolved when connected to a remote workspace", async () => { + const ide = { + getWorkspaceDirs: vi + .fn() + .mockResolvedValue([ + "vscode-remote://ssh-remote+host/home/user/project", + ]), + getIdeInfo: vi.fn().mockResolvedValue({ remoteName: "" }), + fileExists: vi.fn().mockResolvedValue(false), + } as any; + const options: InternalStdioMcpOptions = { + ...optionsWithVariables, + cwd: undefined, + }; + const conn = new MCPConnection(options, { ide }); + + const transport = await (conn as any).constructStdioTransport(options); + const params = (transport as any)._serverParams; + + // Must stay literal, not silently become the user's home directory. + expect(params.args).toEqual(["${workspaceFolder}/src", "literal"]); + expect(params.env.PROJECT_DIR).toBe("${workspaceFolder}"); + }); + }); + describe("connectClient", () => { const options: InternalStdioMcpOptions = { name: "test-mcp", diff --git a/core/util/resolveMcpVariables.test.ts b/core/util/resolveMcpVariables.test.ts new file mode 100644 index 00000000000..6fb7cf53cce --- /dev/null +++ b/core/util/resolveMcpVariables.test.ts @@ -0,0 +1,137 @@ +import { homedir } from "os"; +import { resolveMcpVariables } from "./resolveMcpVariables"; + +const context = { workspaceDir: "/Users/test/my-project" }; + +test("resolves ${workspaceFolder}", () => { + expect(resolveMcpVariables("${workspaceFolder}", context)).toBe( + "/Users/test/my-project", + ); +}); + +test("resolves ${workspaceFolder} as a path prefix", () => { + expect(resolveMcpVariables("${workspaceFolder}/src", context)).toBe( + "/Users/test/my-project/src", + ); +}); + +test("resolves ${workspaceFolder} with a surrounding prefix", () => { + expect(resolveMcpVariables("prefix-${workspaceFolder}", context)).toBe( + "prefix-/Users/test/my-project", + ); +}); + +test("resolves ${workspaceFolderBasename}", () => { + expect(resolveMcpVariables("${workspaceFolderBasename}", context)).toBe( + "my-project", + ); +}); + +test("resolves ${userHome}", () => { + expect(resolveMcpVariables("${userHome}", context)).toBe(homedir()); +}); + +test("resolves ${env:VAR} from provided env", () => { + expect( + resolveMcpVariables("${env:HOME}", { + ...context, + env: { HOME: "/home/x" }, + }), + ).toBe("/home/x"); +}); + +test("resolves ${env:VAR} from provided env for another variable name", () => { + expect( + resolveMcpVariables("${env:USERNAME}", { + ...context, + env: { USERNAME: "onkar" }, + }), + ).toBe("onkar"); +}); + +test("leaves unknown variables unchanged", () => { + expect(resolveMcpVariables("${notAVariable}", context)).toBe( + "${notAVariable}", + ); +}); + +test("variable names are case-sensitive, matching VS Code's own variable syntax", () => { + expect(resolveMcpVariables("${WorkspaceFolder}", context)).toBe( + "${WorkspaceFolder}", + ); + expect(resolveMcpVariables("${WORKSPACEFOLDER}", context)).toBe( + "${WORKSPACEFOLDER}", + ); + expect(resolveMcpVariables("${UserHome}", context)).toBe("${UserHome}"); +}); + +test("leaves ${env:VAR} unchanged when the env var is unset", () => { + expect( + resolveMcpVariables("${env:DOES_NOT_EXIST}", { ...context, env: {} }), + ).toBe("${env:DOES_NOT_EXIST}"); +}); + +test("leaves Continue's ${{ secrets.X }} template syntax unchanged", () => { + expect(resolveMcpVariables("${{ secrets.MY_TOKEN }}", context)).toBe( + "${{ secrets.MY_TOKEN }}", + ); +}); + +test("resolves variables inside nested arrays", () => { + expect( + resolveMcpVariables( + ["${workspaceFolder}", ["${userHome}", "literal"]], + context, + ), + ).toEqual(["/Users/test/my-project", [homedir(), "literal"]]); +}); + +test("resolves variables inside nested objects", () => { + expect( + resolveMcpVariables( + { + PROJECT_DIR: "${workspaceFolder}", + nested: { HOME: "${userHome}" }, + }, + context, + ), + ).toEqual({ + PROJECT_DIR: "/Users/test/my-project", + nested: { HOME: homedir() }, + }); +}); + +test("resolves a cwd value", () => { + expect(resolveMcpVariables("${workspaceFolder}", context)).toBe( + "/Users/test/my-project", + ); +}); + +test("resolves an args array", () => { + expect( + resolveMcpVariables( + ["${workspaceFolder}", "${workspaceFolder}/src"], + context, + ), + ).toEqual(["/Users/test/my-project", "/Users/test/my-project/src"]); +}); + +test("resolves an env object", () => { + expect( + resolveMcpVariables( + { PROJECT_DIR: "${workspaceFolder}", HOME: "${userHome}" }, + context, + ), + ).toEqual({ PROJECT_DIR: "/Users/test/my-project", HOME: homedir() }); +}); + +test("leaves ${workspaceFolder} unchanged when no workspace dir is known", () => { + expect(resolveMcpVariables("${workspaceFolder}", {})).toBe( + "${workspaceFolder}", + ); +}); + +test("passes through non-string primitives and undefined", () => { + expect(resolveMcpVariables(undefined, context)).toBeUndefined(); + expect(resolveMcpVariables(42, context)).toBe(42); +}); diff --git a/core/util/resolveMcpVariables.ts b/core/util/resolveMcpVariables.ts new file mode 100644 index 00000000000..63500038525 --- /dev/null +++ b/core/util/resolveMcpVariables.ts @@ -0,0 +1,74 @@ +import { homedir } from "os"; +import { basename } from "path"; + +/** + * Context used to resolve VS Code-style predefined variables in MCP server + * config values (command/args/cwd/env), e.g. `${workspaceFolder}`. + */ +export interface McpVariableContext { + /** Absolute filesystem path of the primary workspace folder, if known. */ + workspaceDir?: string; + /** Environment variables to resolve `${env:VAR}` against. Defaults to process.env. */ + env?: Record; +} + +// Only matches known variable names, so anything else (unknown variables, +// or Continue's own `${{ secrets.X }}` template syntax) is left untouched. +const VARIABLE_PATTERN = + /\$\{(workspaceFolder|workspaceFolderBasename|userHome|env:([^}]+))\}/g; + +function resolveToken( + token: string, + envVarName: string | undefined, + context: McpVariableContext, +): string | undefined { + switch (token) { + case "workspaceFolder": + return context.workspaceDir; + case "workspaceFolderBasename": + return context.workspaceDir ? basename(context.workspaceDir) : undefined; + case "userHome": + return homedir(); + default: + // token is `env:VAR` here, envVarName is the captured VAR + return envVarName === undefined + ? undefined + : (context.env ?? process.env)[envVarName]; + } +} + +function resolveInString(value: string, context: McpVariableContext): string { + return value.replace(VARIABLE_PATTERN, (match, token, envVarName) => { + const resolved = resolveToken(token, envVarName, context); + return resolved ?? match; + }); +} + +/** + * Recursively resolves VS Code-style predefined variables + * (`${workspaceFolder}`, `${workspaceFolderBasename}`, `${userHome}`, + * `${env:VAR}`) inside strings, arrays, and plain objects. Unknown variables + * and unset env vars are left untouched. + */ +export function resolveMcpVariables( + value: T, + context: McpVariableContext, +): T { + if (typeof value === "string") { + return resolveInString(value, context) as unknown as T; + } + if (Array.isArray(value)) { + return value.map((item) => + resolveMcpVariables(item, context), + ) as unknown as T; + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, val]) => [ + key, + resolveMcpVariables(val, context), + ]), + ) as unknown as T; + } + return value; +} diff --git a/docs/customize/deep-dives/mcp.mdx b/docs/customize/deep-dives/mcp.mdx index d90cf398fa3..ba4fc6dcbe5 100644 --- a/docs/customize/deep-dives/mcp.mdx +++ b/docs/customize/deep-dives/mcp.mdx @@ -99,6 +99,36 @@ MCP components include a few additional properties specific to MCP servers. - `command`: The command to run to start the MCP server. - `args`: Arguments to pass to the command. - `env`: Secrets to be injected into the command as environment variables. +- `cwd`: The working directory to launch the command in. If omitted, defaults to the workspace root when a workspace folder is open. Relative paths are resolved against the workspace root; absolute paths are used as-is. + +### How to Use Predefined Variables + +`command`, `args`, `cwd`, and `env` support the following variables, resolved right before the MCP server is launched: + +- `${workspaceFolder}`: The absolute path to your workspace root. +- `${workspaceFolderBasename}`: The name of the workspace root folder. +- `${userHome}`: The current user's home directory. +- `${env:VARIABLE_NAME}`: The value of an environment variable from the environment Continue is running in. Unset variables are left unresolved. + +```yaml +# ... +mcpServers: + - name: Project-aware MCP + command: npx + args: + - "@some-org/mcp-server" + - "${workspaceFolder}/src" + cwd: ${workspaceFolder} + env: + PROJECT_DIR: ${workspaceFolder} + HOME: ${userHome} + TOKEN: ${env:GITHUB_TOKEN} +# ... +``` + + +This is separate from the `${{ secrets.X }}` syntax described below, which is used for injecting secrets stored in Continue. + ### How to Choose MCP Transport Types