mcp: support envFile for mcp.json (#244059)

Fixes #243709

Note it needs #244029 to support ${workspaceFolder}
This commit is contained in:
Connor Peet
2025-03-19 16:30:11 -07:00
committed by GitHub
parent 385dfa554c
commit 39a3d3cd73
13 changed files with 274 additions and 14 deletions
+100
View File
@@ -0,0 +1,100 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* Parses a standard .env/.envrc file into a map of the environment variables
* it defines.
*
* todo@connor4312: this can go away (if only used in Node.js targets) and be
* replaced with `util.parseEnv`. However, currently calling that makes the
* extension host crash.
*/
export function parseEnvFile(src: string) {
const result = new Map<string, string>();
// Normalize line breaks
const normalizedSrc = src.replace(/\r\n?/g, '\n');
const lines = normalizedSrc.split('\n');
for (let line of lines) {
// Skip empty lines and comments
line = line.trim();
if (!line || line.startsWith('#')) {
continue;
}
// Parse the line into key and value
const [key, value] = parseLine(line);
if (key) {
result.set(key, value);
}
}
return result;
function parseLine(line: string): [string, string] | [null, null] {
// Handle export prefix
if (line.startsWith('export ')) {
line = line.substring(7).trim();
}
// Find the key-value separator
const separatorIndex = findIndexOutsideQuotes(line, c => c === '=' || c === ':');
if (separatorIndex === -1) {
return [null, null];
}
const key = line.substring(0, separatorIndex).trim();
let value = line.substring(separatorIndex + 1).trim();
// Handle comments and remove them
const commentIndex = findIndexOutsideQuotes(value, c => c === '#');
if (commentIndex !== -1) {
value = value.substring(0, commentIndex).trim();
}
// Process quoted values
if (value.length >= 2) {
const firstChar = value[0];
const lastChar = value[value.length - 1];
if ((firstChar === '"' && lastChar === '"') ||
(firstChar === '\'' && lastChar === '\'') ||
(firstChar === '`' && lastChar === '`')) {
// Remove surrounding quotes
value = value.substring(1, value.length - 1);
// Handle escaped characters in double quotes
if (firstChar === '"') {
value = value.replace(/\\n/g, '\n').replace(/\\r/g, '\r');
}
}
}
return [key, value];
}
function findIndexOutsideQuotes(text: string, predicate: (char: string) => boolean): number {
let inQuote = false;
let quoteChar = '';
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (inQuote) {
if (char === quoteChar && text[i - 1] !== '\\') {
inQuote = false;
}
} else if (char === '"' || char === '\'' || char === '`') {
inQuote = true;
quoteChar = char;
} else if (predicate(char)) {
return i;
}
}
return -1;
}
}
+130
View File
@@ -0,0 +1,130 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { parseEnvFile } from '../../common/envfile.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js';
import * as assert from 'assert';
/*
Test cases from https://github.com/motdotla/dotenv/blob/master/tests/.env
Copyright (c) 2015, Scott Motte
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
const example = `
BASIC=basic
# previous line intentionally left blank
AFTER_LINE=after_line
EMPTY=
EMPTY_SINGLE_QUOTES=''
EMPTY_DOUBLE_QUOTES=""
EMPTY_BACKTICKS=\`\`
SINGLE_QUOTES='single_quotes'
SINGLE_QUOTES_SPACED=' single quotes '
DOUBLE_QUOTES="double_quotes"
DOUBLE_QUOTES_SPACED=" double quotes "
DOUBLE_QUOTES_INSIDE_SINGLE='double "quotes" work inside single quotes'
DOUBLE_QUOTES_WITH_NO_SPACE_BRACKET="{ port: $MONGOLAB_PORT}"
SINGLE_QUOTES_INSIDE_DOUBLE="single 'quotes' work inside double quotes"
BACKTICKS_INSIDE_SINGLE='\`backticks\` work inside single quotes'
BACKTICKS_INSIDE_DOUBLE="\`backticks\` work inside double quotes"
BACKTICKS=\`backticks\`
BACKTICKS_SPACED=\` backticks \`
DOUBLE_QUOTES_INSIDE_BACKTICKS=\`double "quotes" work inside backticks\`
SINGLE_QUOTES_INSIDE_BACKTICKS=\`single 'quotes' work inside backticks\`
DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS=\`double "quotes" and single 'quotes' work inside backticks\`
EXPAND_NEWLINES="expand\\nnew\\nlines"
DONT_EXPAND_UNQUOTED=dontexpand\\nnewlines
DONT_EXPAND_SQUOTED='dontexpand\\nnewlines'
# COMMENTS=work
INLINE_COMMENTS=inline comments # work #very #well
INLINE_COMMENTS_SINGLE_QUOTES='inline comments outside of #singlequotes' # work
INLINE_COMMENTS_DOUBLE_QUOTES="inline comments outside of #doublequotes" # work
INLINE_COMMENTS_BACKTICKS=\`inline comments outside of #backticks\` # work
INLINE_COMMENTS_SPACE=inline comments start with a#number sign. no space required.
EQUAL_SIGNS=equals==
RETAIN_INNER_QUOTES={"foo": "bar"}
RETAIN_INNER_QUOTES_AS_STRING='{"foo": "bar"}'
RETAIN_INNER_QUOTES_AS_BACKTICKS=\`{"foo": "bar's"}\`
TRIM_SPACE_FROM_UNQUOTED= some spaced out string
USERNAME=therealnerdybeast@example.tld
SPACED_KEY = parsed
`;
suite('parseEnvFile', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('parses', () => {
const parsed = parseEnvFile(example);
assert.strictEqual(parsed.get('BASIC'), 'basic');
assert.strictEqual(parsed.get('AFTER_LINE'), 'after_line');
assert.strictEqual(parsed.get('EMPTY'), '');
assert.strictEqual(parsed.get('EMPTY_SINGLE_QUOTES'), '');
assert.strictEqual(parsed.get('EMPTY_DOUBLE_QUOTES'), '');
assert.strictEqual(parsed.get('EMPTY_BACKTICKS'), '');
assert.strictEqual(parsed.get('SINGLE_QUOTES'), 'single_quotes');
assert.strictEqual(parsed.get('SINGLE_QUOTES_SPACED'), ' single quotes ');
assert.strictEqual(parsed.get('DOUBLE_QUOTES'), 'double_quotes');
assert.strictEqual(parsed.get('DOUBLE_QUOTES_SPACED'), ' double quotes ');
assert.strictEqual(parsed.get('DOUBLE_QUOTES_INSIDE_SINGLE'), 'double "quotes" work inside single quotes');
assert.strictEqual(parsed.get('DOUBLE_QUOTES_WITH_NO_SPACE_BRACKET'), '{ port: $MONGOLAB_PORT}');
assert.strictEqual(parsed.get('SINGLE_QUOTES_INSIDE_DOUBLE'), "single 'quotes' work inside double quotes");
assert.strictEqual(parsed.get('BACKTICKS_INSIDE_SINGLE'), '`backticks` work inside single quotes');
assert.strictEqual(parsed.get('BACKTICKS_INSIDE_DOUBLE'), '`backticks` work inside double quotes');
assert.strictEqual(parsed.get('BACKTICKS'), 'backticks');
assert.strictEqual(parsed.get('BACKTICKS_SPACED'), ' backticks ');
assert.strictEqual(parsed.get('DOUBLE_QUOTES_INSIDE_BACKTICKS'), 'double "quotes" work inside backticks');
assert.strictEqual(parsed.get('SINGLE_QUOTES_INSIDE_BACKTICKS'), "single 'quotes' work inside backticks");
assert.strictEqual(parsed.get('DOUBLE_AND_SINGLE_QUOTES_INSIDE_BACKTICKS'), "double \"quotes\" and single 'quotes' work inside backticks");
assert.strictEqual(parsed.get('EXPAND_NEWLINES'), 'expand\nnew\nlines');
assert.strictEqual(parsed.get('DONT_EXPAND_UNQUOTED'), 'dontexpand\\nnewlines');
assert.strictEqual(parsed.get('DONT_EXPAND_SQUOTED'), 'dontexpand\\nnewlines');
assert.strictEqual(parsed.get('COMMENTS'), undefined);
assert.strictEqual(parsed.get('INLINE_COMMENTS'), 'inline comments');
assert.strictEqual(parsed.get('INLINE_COMMENTS_SINGLE_QUOTES'), 'inline comments outside of #singlequotes');
assert.strictEqual(parsed.get('INLINE_COMMENTS_DOUBLE_QUOTES'), 'inline comments outside of #doublequotes');
assert.strictEqual(parsed.get('INLINE_COMMENTS_BACKTICKS'), 'inline comments outside of #backticks');
assert.strictEqual(parsed.get('INLINE_COMMENTS_SPACE'), 'inline comments start with a');
assert.strictEqual(parsed.get('EQUAL_SIGNS'), 'equals==');
assert.strictEqual(parsed.get('RETAIN_INNER_QUOTES'), '{"foo": "bar"}');
assert.strictEqual(parsed.get('RETAIN_INNER_QUOTES_AS_STRING'), '{"foo": "bar"}');
assert.strictEqual(parsed.get('RETAIN_INNER_QUOTES_AS_BACKTICKS'), '{"foo": "bar\'s"}');
assert.strictEqual(parsed.get('TRIM_SPACE_FROM_UNQUOTED'), 'some spaced out string');
assert.strictEqual(parsed.get('USERNAME'), 'therealnerdybeast@example.tld');
assert.strictEqual(parsed.get('SPACED_KEY'), 'parsed');
const payload = parseEnvFile('BUFFER=true');
assert.strictEqual(payload.get('BUFFER'), 'true');
const expectedPayload = Object.entries({ SERVER: 'localhost', PASSWORD: 'password', DB: 'tests' });
const RPayload = parseEnvFile('SERVER=localhost\rPASSWORD=password\rDB=tests\r');
assert.deepStrictEqual([...RPayload], expectedPayload);
const NPayload = parseEnvFile('SERVER=localhost\nPASSWORD=password\nDB=tests\n');
assert.deepStrictEqual([...NPayload], expectedPayload);
const RNPayload = parseEnvFile('SERVER=localhost\r\nPASSWORD=password\r\nDB=tests\r\n');
assert.deepStrictEqual([...RNPayload], expectedPayload);
});
});
@@ -17,6 +17,7 @@ export interface IMcpConfigurationStdio {
command: string;
args?: readonly string[];
env?: Record<string, string | number | null>;
envFile?: string;
}
export interface IMcpConfigurationSSE {
+2 -1
View File
@@ -108,7 +108,8 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService
cwd: item.cwd,
args: item.args,
command: item.command,
env: item.env
env: item.env,
envFile: undefined,
}
});
}
+25 -11
View File
@@ -4,14 +4,15 @@
*--------------------------------------------------------------------------------------------*/
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
import { mapValues } from '../../../base/common/objects.js';
import { readFile } from 'fs/promises';
import { homedir } from 'os';
import { PassThrough } from 'stream';
import { parseEnvFile } from '../../../base/common/envfile.js';
import { URI } from '../../../base/common/uri.js';
import { StreamSplitter } from '../../../base/node/nodeStreams.js';
import { McpConnectionState, McpServerLaunch, McpServerTransportStdio, McpServerTransportType } from '../../contrib/mcp/common/mcpTypes.js';
import { ExtHostMcpService } from '../common/extHostMcp.js';
import { IExtHostRpcService } from '../common/extHostRpcService.js';
import { homedir } from 'os';
import { PassThrough } from 'stream';
export class NodeExtHostMpcService extends ExtHostMcpService {
constructor(
@@ -47,19 +48,35 @@ export class NodeExtHostMpcService extends ExtHostMcpService {
const nodeServer = this.nodeServers.get(id);
if (nodeServer) {
this._proxy.$onDidPublishLog(id, '[Client Says] ' + message.toString());
nodeServer.child.stdin.write(message + '\n');
} else {
super.$sendMessage(id, message);
}
}
private startNodeMpc(id: number, launch: McpServerTransportStdio): void {
const onError = (err: Error) => this._proxy.$onDidChangeState(id, {
private async startNodeMpc(id: number, launch: McpServerTransportStdio) {
const onError = (err: Error | string) => this._proxy.$onDidChangeState(id, {
state: McpConnectionState.Kind.Error,
message: err.message,
message: typeof err === 'string' ? err : err.message,
});
// MCP servers are run on the same authority where they are defined, so
// reading the envfile based on its path off the filesystem here is fine.
const env = { ...process.env };
if (launch.envFile) {
try {
for (const [key, value] of parseEnvFile(await readFile(launch.envFile, 'utf-8'))) {
env[key] = value;
}
} catch (e) {
onError(`Failed to read envFile '${launch.envFile}': ${e.message}`);
return;
}
}
for (const [key, value] of Object.entries(launch.env)) {
env[key] = value === null ? undefined : String(value);
}
const abortCtrl = new AbortController();
let child: ChildProcessWithoutNullStreams;
try {
@@ -67,10 +84,7 @@ export class NodeExtHostMpcService extends ExtHostMcpService {
stdio: 'pipe',
cwd: launch.cwd ? URI.revive(launch.cwd).fsPath : homedir(),
signal: abortCtrl.signal,
env: {
...process.env,
...mapValues(launch.env, v => typeof v === 'number' ? String(v) : (v === null ? undefined : v)),
},
env,
});
} catch (e) {
onError(e);
@@ -109,6 +109,7 @@ export class ConfigMcpDiscovery extends Disposable implements IMcpDiscovery {
args: value.args || [],
command: value.command,
env: value.env || {},
envFile: value.envFile,
cwd: undefined,
},
variableReplacement: {
@@ -63,6 +63,7 @@ export class ClaudeDesktopMpcDiscoveryAdapter implements NativeMpcDiscoveryAdapt
args: server.args || [],
command: server.command,
env: server.env || {},
envFile: undefined,
cwd: homedir,
}
};
@@ -13,6 +13,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta
import { ILabelService } from '../../../../platform/label/common/label.js';
import { IProductService } from '../../../../platform/product/common/productService.js';
import { StorageScope } from '../../../../platform/storage/common/storage.js';
import { IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js';
import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js';
import { IPreferencesService } from '../../../services/preferences/common/preferences.js';
import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js';
@@ -28,6 +29,8 @@ export interface IMcpConfigPath {
scope: StorageScope;
/** Configuration target that correspond to this file */
target: ConfigurationTarget;
/** Associated workspace folder, for workspace folder values */
folder?: IWorkspaceFolder;
/** Order in which the configuration should be displayed */
order: number;
/** Config's remote authority */
@@ -54,6 +54,11 @@ export const mcpStdioServerSchema: IJSONSchema = {
type: 'string'
},
},
envFile: {
type: 'string',
description: localize('app.mcp.envFile.command', "Path to a file containing environment variables for the server."),
examples: ['${workspaceFolder}/.env'],
},
env: {
description: localize('app.mcp.env.command', "Environment variables passed to the server."),
additionalProperties: {
@@ -284,7 +284,6 @@ export class McpRegistry extends Disposable implements IMcpRegistry {
}
}
// resolve variables requiring user input
await this._configurationResolverService.resolveWithInteraction(folder, expr, section, undefined, target);
@@ -261,6 +261,7 @@ export interface McpServerTransportStdio {
readonly command: string;
readonly args: readonly string[];
readonly env: Record<string, string | number | null>;
readonly envFile: string | undefined;
}
/**
@@ -280,7 +281,7 @@ export type McpServerLaunch =
export namespace McpServerLaunch {
export type Serialized =
| { type: McpServerTransportType.SSE; uri: UriComponents; headers: [string, string][] }
| { type: McpServerTransportType.Stdio; cwd: UriComponents | undefined; command: string; args: readonly string[]; env: Record<string, string | number | null> };
| { type: McpServerTransportType.Stdio; cwd: UriComponents | undefined; command: string; args: readonly string[]; env: Record<string, string | number | null>; envFile: string | undefined };
export function toSerialized(launch: McpServerLaunch): McpServerLaunch.Serialized {
return launch;
@@ -297,6 +298,7 @@ export namespace McpServerLaunch {
command: launch.command,
args: launch.args,
env: launch.env,
envFile: launch.envFile,
};
}
}
@@ -158,6 +158,7 @@ suite('Workbench - MCP - Registry', () => {
command: 'test-command',
args: [],
env: {},
envFile: undefined,
cwd: URI.parse('file:///test')
}
};
@@ -196,6 +197,7 @@ suite('Workbench - MCP - Registry', () => {
env: {
PATH: '${input:testInteractive}'
},
envFile: undefined,
cwd: URI.parse('file:///test')
},
variableReplacement: {
@@ -97,6 +97,7 @@ suite('Workbench - MCP - ServerConnection', () => {
command: 'test-command',
args: [],
env: {},
envFile: undefined,
cwd: URI.parse('file:///test')
}
};