mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-08 17:19:48 +01:00
mcp: support envFile for mcp.json (#244059)
Fixes #243709 Note it needs #244029 to support ${workspaceFolder}
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user