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
81 changes: 59 additions & 22 deletions core/context/mcp/MCPConnection.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}

Expand All @@ -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<string | undefined> {
const dirs = await this.extras?.ide?.getWorkspaceDirs();
const first = dirs?.[0];
return first ? this.workspaceUriToFsPath(first) : undefined;
}

private constructWebsocketTransport(
options: InternalWebsocketMcpOptions,
): WebSocketClientTransport {
Expand Down Expand Up @@ -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<StdioClientTransport> {
// 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<string, string> = Object.fromEntries(
COMMONS_ENV_VARS.filter((key) => process.env[key] !== undefined).map(
(key) => [key, process.env[key] as string],
Expand All @@ -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) {
Expand All @@ -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",
});

Expand Down
128 changes: 127 additions & 1 deletion core/context/mcp/MCPConnection.vitest.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading