mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-17 23:35:54 +01:00
Dialog Notification when MCP server start fails in sandbox mode. (#297797)
* changes for showing start up errors in a dialog * changes for showing start up errors in a dialog * changes for showing start up errors in a dialog * changes for showing start up errors in a dialog * changes * changes * changes * migrating to event from taillog * changes for runtime errors * refactoring changes * refactoring changes * refactoring changes * changes
This commit is contained in:
@@ -47,6 +47,7 @@ export interface IMcpResourceScannerService {
|
||||
readonly _serviceBrand: undefined;
|
||||
scanMcpServers(mcpResource: URI, target?: McpResourceTarget): Promise<IScannedMcpServers>;
|
||||
addMcpServers(servers: IInstallableMcpServer[], mcpResource: URI, target?: McpResourceTarget): Promise<void>;
|
||||
updateSandboxConfig(updateFn: (data: IScannedMcpServers) => IScannedMcpServers, mcpResource: URI, target?: McpResourceTarget): Promise<void>;
|
||||
removeMcpServers(serverNames: string[], mcpResource: URI, target?: McpResourceTarget): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -82,6 +83,10 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc
|
||||
});
|
||||
}
|
||||
|
||||
async updateSandboxConfig(updateFn: (data: IScannedMcpServers) => IScannedMcpServers, mcpResource: URI, target?: McpResourceTarget): Promise<void> {
|
||||
await this.withProfileMcpServers(mcpResource, target, updateFn);
|
||||
}
|
||||
|
||||
async removeMcpServers(serverNames: string[], mcpResource: URI, target?: McpResourceTarget): Promise<void> {
|
||||
await this.withProfileMcpServers(mcpResource, target, scannedMcpServers => {
|
||||
for (const serverName of serverNames) {
|
||||
@@ -139,7 +144,9 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc
|
||||
}
|
||||
|
||||
private async writeScannedMcpServers(mcpResource: URI, scannedMcpServers: IScannedMcpServers): Promise<void> {
|
||||
if ((scannedMcpServers.servers && Object.keys(scannedMcpServers.servers).length > 0) || (scannedMcpServers.inputs && scannedMcpServers.inputs.length > 0)) {
|
||||
if ((scannedMcpServers.servers && Object.keys(scannedMcpServers.servers).length > 0)
|
||||
|| (scannedMcpServers.inputs && scannedMcpServers.inputs.length > 0)
|
||||
|| scannedMcpServers.sandbox !== undefined) {
|
||||
await this.fileService.writeFile(mcpResource, VSBuffer.fromString(JSON.stringify(scannedMcpServers, null, '\t')));
|
||||
} else {
|
||||
await this.fileService.del(mcpResource);
|
||||
@@ -196,7 +203,8 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc
|
||||
if (servers.length > 0) {
|
||||
scannedMcpServers.servers = {};
|
||||
for (const [serverName, config] of servers) {
|
||||
scannedMcpServers.servers[serverName] = this.sanitizeServer(config, scannedWorkspaceFolderMcpServers.sandbox);
|
||||
const serverConfig = this.sanitizeServer(config, scannedMcpServers.sandbox);
|
||||
scannedMcpServers.servers[serverName] = serverConfig;
|
||||
}
|
||||
}
|
||||
return scannedMcpServers;
|
||||
@@ -219,7 +227,7 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc
|
||||
(<Mutable<ICommonMcpServerConfiguration>>server).type = (<IMcpStdioServerConfiguration>server).command ? McpServerType.LOCAL : McpServerType.REMOTE;
|
||||
}
|
||||
|
||||
if (sandbox && server.type === McpServerType.LOCAL && !(server as IMcpStdioServerConfiguration).sandbox && server.sandboxEnabled) {
|
||||
if (sandbox && server.type === McpServerType.LOCAL) {
|
||||
(<Mutable<IMcpStdioServerConfiguration>>server).sandbox = sandbox;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc
|
||||
label: server.name,
|
||||
launch,
|
||||
sandboxEnabled: config.type === 'http' ? undefined : config.sandboxEnabled,
|
||||
sandbox: config.type === 'http' || !config.sandboxEnabled ? undefined : config.sandbox,
|
||||
sandbox: config.type === 'http' ? undefined : config.sandbox,
|
||||
cacheNonce: await McpServerLaunch.hash(launch),
|
||||
roots: mcpConfigPath?.workspaceFolder ? [mcpConfigPath.workspaceFolder.uri] : undefined,
|
||||
variableReplacement: {
|
||||
|
||||
@@ -10,15 +10,18 @@ import { dirname, posix, win32 } from '../../../../base/common/path.js';
|
||||
import { OperatingSystem, OS } from '../../../../base/common/platform.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { generateUuid } from '../../../../base/common/uuid.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { ConfigurationTarget, ConfigurationTargetToString } from '../../../../platform/configuration/common/configuration.js';
|
||||
import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';
|
||||
import { IFileService } from '../../../../platform/files/common/files.js';
|
||||
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { ILogService } from '../../../../platform/log/common/log.js';
|
||||
import { IMcpResourceScannerService, McpResourceTarget } from '../../../../platform/mcp/common/mcpResourceScannerService.js';
|
||||
import { IRemoteAgentEnvironment } from '../../../../platform/remote/common/remoteAgentEnvironment.js';
|
||||
import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js';
|
||||
import { IMcpSandboxConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js';
|
||||
import { McpServerDefinition, McpServerLaunch, McpServerTransportType } from './mcpTypes.js';
|
||||
import { IMcpSandboxConfiguration, IMcpStdioServerConfiguration, McpServerType } from '../../../../platform/mcp/common/mcpPlatformTypes.js';
|
||||
import { IMcpPotentialSandboxBlock, McpServerDefinition, McpServerLaunch, McpServerTransportType } from './mcpTypes.js';
|
||||
import { Mutable } from '../../../../base/common/types.js';
|
||||
|
||||
export const IMcpSandboxService = createDecorator<IMcpSandboxService>('mcpSandboxService');
|
||||
|
||||
@@ -26,8 +29,20 @@ export interface IMcpSandboxService {
|
||||
readonly _serviceBrand: undefined;
|
||||
launchInSandboxIfEnabled(serverDef: McpServerDefinition, launch: McpServerLaunch, remoteAuthority: string | undefined, configTarget: ConfigurationTarget): Promise<McpServerLaunch>;
|
||||
isEnabled(serverDef: McpServerDefinition, serverLabel?: string): Promise<boolean>;
|
||||
getSandboxConfigSuggestionMessage(serverLabel: string, potentialBlocks: readonly IMcpPotentialSandboxBlock[], existingSandboxConfig?: IMcpSandboxConfiguration): SandboxConfigSuggestionResult | undefined;
|
||||
applySandboxConfigSuggestion(serverDef: McpServerDefinition, mcpResource: URI, configTarget: ConfigurationTarget, potentialBlocks: readonly IMcpPotentialSandboxBlock[], suggestedSandboxConfig?: IMcpSandboxConfiguration): Promise<boolean>;
|
||||
}
|
||||
|
||||
type SandboxConfigSuggestions = {
|
||||
allowWrite: readonly string[];
|
||||
allowedDomains: readonly string[];
|
||||
};
|
||||
|
||||
type SandboxConfigSuggestionResult = {
|
||||
message: string;
|
||||
sandboxConfig: IMcpSandboxConfiguration;
|
||||
};
|
||||
|
||||
type SandboxLaunchDetails = {
|
||||
execPath: string | undefined;
|
||||
srtPath: string | undefined;
|
||||
@@ -40,13 +55,14 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService
|
||||
|
||||
private _sandboxSettingsId: string | undefined;
|
||||
private _remoteEnvDetailsPromise: Promise<IRemoteAgentEnvironment | null>;
|
||||
private readonly _defaultAllowedDomains: readonly string[] = ['*.npmjs.org'];
|
||||
private readonly _defaultAllowedDomains: readonly string[] = ['registry.npmjs.org']; // Default allowed domains that are commonly needed for MCP servers, even if the user doesn't specify them in their sandbox config
|
||||
private _sandboxConfigPerConfigurationTarget: Map<string, string> = new Map();
|
||||
|
||||
constructor(
|
||||
@IFileService private readonly _fileService: IFileService,
|
||||
@IEnvironmentService private readonly _environmentService: IEnvironmentService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
@IMcpResourceScannerService private readonly _mcpResourceScannerService: IMcpResourceScannerService,
|
||||
@IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService,
|
||||
) {
|
||||
super();
|
||||
@@ -69,7 +85,7 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService
|
||||
}
|
||||
if (await this.isEnabled(serverDef, remoteAuthority)) {
|
||||
this._logService.trace(`McpSandboxService: Launching with config target ${configTarget}`);
|
||||
const launchDetails = await this._resolveSandboxLaunchDetails(configTarget, remoteAuthority, serverDef.sandbox);
|
||||
const launchDetails = await this._resolveSandboxLaunchDetails(configTarget, remoteAuthority, serverDef.sandbox, launch.cwd);
|
||||
const sandboxArgs = this._getSandboxCommandArgs(launch.command, launch.args, launchDetails.sandboxConfigPath);
|
||||
const sandboxEnv = this._getSandboxEnvVariables(launchDetails.tempDir, remoteAuthority);
|
||||
if (launchDetails.srtPath) {
|
||||
@@ -100,7 +116,158 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService
|
||||
return launch;
|
||||
}
|
||||
|
||||
private async _resolveSandboxLaunchDetails(configTarget: ConfigurationTarget, remoteAuthority?: string, sandboxConfig?: IMcpSandboxConfiguration): Promise<SandboxLaunchDetails> {
|
||||
public getSandboxConfigSuggestionMessage(serverLabel: string, potentialBlocks: readonly IMcpPotentialSandboxBlock[], existingSandboxConfig?: IMcpSandboxConfiguration): SandboxConfigSuggestionResult | undefined {
|
||||
const suggestions = this._getSandboxConfigSuggestions(potentialBlocks, existingSandboxConfig);
|
||||
if (!suggestions) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const allowWriteList = suggestions.allowWrite;
|
||||
const allowedDomainsList = suggestions.allowedDomains;
|
||||
const suggestionLines: string[] = [];
|
||||
|
||||
if (allowedDomainsList.length) {
|
||||
const shown = allowedDomainsList.map(domain => `"${domain}"`).join(', ');
|
||||
suggestionLines.push(localize('mcpSandboxSuggestion.allowedDomains', "Add to `sandbox.network.allowedDomains`: {0}", shown));
|
||||
}
|
||||
|
||||
if (allowWriteList.length) {
|
||||
const shown = allowWriteList.map(path => `"${path}"`).join(', ');
|
||||
suggestionLines.push(localize('mcpSandboxSuggestion.allowWrite', "Add to `sandbox.filesystem.allowWrite`: {0}", shown));
|
||||
}
|
||||
|
||||
const sandboxConfig: IMcpSandboxConfiguration = {};
|
||||
if (allowedDomainsList.length) {
|
||||
sandboxConfig.network = { allowedDomains: [...allowedDomainsList] };
|
||||
}
|
||||
if (allowWriteList.length) {
|
||||
sandboxConfig.filesystem = { allowWrite: [...allowWriteList] };
|
||||
}
|
||||
|
||||
return {
|
||||
message: localize(
|
||||
'mcpSandboxSuggestion.message',
|
||||
"The MCP server {0} reported potential sandbox blocks. VS Code found possible sandbox configuration updates:\n{1}",
|
||||
serverLabel,
|
||||
suggestionLines.join('\n')
|
||||
),
|
||||
sandboxConfig,
|
||||
};
|
||||
}
|
||||
|
||||
public async applySandboxConfigSuggestion(serverDef: McpServerDefinition, mcpResource: URI, configTarget: ConfigurationTarget, potentialBlocks: readonly IMcpPotentialSandboxBlock[], suggestedSandboxConfig?: IMcpSandboxConfiguration): Promise<boolean> {
|
||||
const scanTarget = this._toMcpResourceTarget(configTarget);
|
||||
let didChange = false;
|
||||
|
||||
await this._mcpResourceScannerService.updateSandboxConfig(data => {
|
||||
const existingSandbox = data.sandbox ?? serverDef.sandbox;
|
||||
const suggestedAllowedDomains = suggestedSandboxConfig?.network?.allowedDomains ?? [];
|
||||
const suggestedAllowWrite = suggestedSandboxConfig?.filesystem?.allowWrite ?? [];
|
||||
|
||||
const currentAllowedDomains = new Set(existingSandbox?.network?.allowedDomains ?? []);
|
||||
for (const domain of suggestedAllowedDomains) {
|
||||
if (domain && !currentAllowedDomains.has(domain)) {
|
||||
currentAllowedDomains.add(domain);
|
||||
}
|
||||
}
|
||||
|
||||
const currentAllowWrite = new Set(existingSandbox?.filesystem?.allowWrite ?? []);
|
||||
for (const path of suggestedAllowWrite) {
|
||||
if (path && !currentAllowWrite.has(path)) {
|
||||
currentAllowWrite.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
didChange = currentAllowedDomains.size !== (existingSandbox?.network?.allowedDomains?.length ?? 0)
|
||||
|| currentAllowWrite.size !== (existingSandbox?.filesystem?.allowWrite?.length ?? 0);
|
||||
|
||||
if (!didChange) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const nextSandboxConfig: IMcpSandboxConfiguration = {
|
||||
...existingSandbox,
|
||||
};
|
||||
|
||||
if (currentAllowedDomains.size > 0 || existingSandbox?.network?.deniedDomains?.length) {
|
||||
nextSandboxConfig.network = {
|
||||
...existingSandbox?.network,
|
||||
allowedDomains: [...currentAllowedDomains],
|
||||
};
|
||||
}
|
||||
|
||||
if (currentAllowWrite.size > 0 || existingSandbox?.filesystem?.denyRead?.length || existingSandbox?.filesystem?.denyWrite?.length) {
|
||||
nextSandboxConfig.filesystem = {
|
||||
...existingSandbox?.filesystem,
|
||||
allowWrite: [...currentAllowWrite],
|
||||
};
|
||||
}
|
||||
|
||||
//always remove sandbox at server level when writing back, it should only exist at the top level. This is to sanitize any old or malformed configs that may have sandbox defined at the server level.
|
||||
if (data.servers) {
|
||||
for (const serverName in data.servers) {
|
||||
const serverConfig = data.servers[serverName];
|
||||
if (serverConfig.type === McpServerType.LOCAL) {
|
||||
delete (serverConfig as Mutable<IMcpStdioServerConfiguration>).sandbox;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
sandbox: nextSandboxConfig,
|
||||
};
|
||||
}, mcpResource, scanTarget);
|
||||
|
||||
return didChange;
|
||||
}
|
||||
|
||||
private _getSandboxConfigSuggestions(potentialBlocks: readonly IMcpPotentialSandboxBlock[], existingSandboxConfig?: IMcpSandboxConfiguration): SandboxConfigSuggestions | undefined {
|
||||
if (!potentialBlocks.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const allowWrite = new Set<string>();
|
||||
const allowedDomains = new Set<string>();
|
||||
const existingAllowWrite = new Set(existingSandboxConfig?.filesystem?.allowWrite ?? []);
|
||||
const existingAllowedDomains = new Set(existingSandboxConfig?.network?.allowedDomains ?? []);
|
||||
|
||||
for (const block of potentialBlocks) {
|
||||
if (block.kind === 'network' && block.host && !existingAllowedDomains.has(block.host)) {
|
||||
allowedDomains.add(block.host);
|
||||
}
|
||||
|
||||
if (block.kind === 'filesystem' && block.path && !existingAllowWrite.has(block.path)) {
|
||||
allowWrite.add(block.path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowWrite.size && !allowedDomains.size) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
allowWrite: [...allowWrite],
|
||||
allowedDomains: [...allowedDomains],
|
||||
};
|
||||
}
|
||||
|
||||
private _toMcpResourceTarget(configTarget: ConfigurationTarget): McpResourceTarget {
|
||||
switch (configTarget) {
|
||||
case ConfigurationTarget.USER:
|
||||
case ConfigurationTarget.USER_LOCAL:
|
||||
case ConfigurationTarget.USER_REMOTE:
|
||||
return ConfigurationTarget.USER;
|
||||
case ConfigurationTarget.WORKSPACE:
|
||||
return ConfigurationTarget.WORKSPACE;
|
||||
case ConfigurationTarget.WORKSPACE_FOLDER:
|
||||
return ConfigurationTarget.WORKSPACE_FOLDER;
|
||||
default:
|
||||
return ConfigurationTarget.USER;
|
||||
}
|
||||
}
|
||||
|
||||
private async _resolveSandboxLaunchDetails(configTarget: ConfigurationTarget, remoteAuthority?: string, sandboxConfig?: IMcpSandboxConfiguration, launchCwd?: string): Promise<SandboxLaunchDetails> {
|
||||
const os = await this._getOperatingSystem(remoteAuthority);
|
||||
if (os === OperatingSystem.Windows) {
|
||||
return { execPath: undefined, srtPath: undefined, sandboxConfigPath: undefined, tempDir: undefined };
|
||||
@@ -110,7 +277,7 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService
|
||||
const execPath = await this._getExecPath(os, appRoot, remoteAuthority);
|
||||
const tempDir = await this._getTempDir(remoteAuthority);
|
||||
const srtPath = this._pathJoin(os, appRoot, 'node_modules', '@anthropic-ai', 'sandbox-runtime', 'dist', 'cli.js');
|
||||
const sandboxConfigPath = tempDir ? await this._updateSandboxConfig(tempDir, configTarget, sandboxConfig) : undefined;
|
||||
const sandboxConfigPath = tempDir ? await this._updateSandboxConfig(tempDir, configTarget, sandboxConfig, launchCwd) : undefined;
|
||||
this._logService.debug(`McpSandboxService: Updated sandbox config path: ${sandboxConfigPath}`);
|
||||
return { execPath, srtPath, sandboxConfigPath, tempDir };
|
||||
}
|
||||
@@ -181,8 +348,8 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
private async _updateSandboxConfig(tempDir: URI, configTarget: ConfigurationTarget, sandboxConfig?: IMcpSandboxConfiguration): Promise<string> {
|
||||
const normalizedSandboxConfig = this._withDefaultSandboxConfig(sandboxConfig);
|
||||
private async _updateSandboxConfig(tempDir: URI, configTarget: ConfigurationTarget, sandboxConfig?: IMcpSandboxConfiguration, launchCwd?: string): Promise<string> {
|
||||
const normalizedSandboxConfig = this._withDefaultSandboxConfig(sandboxConfig, launchCwd);
|
||||
let configFileUri: URI;
|
||||
const configTargetKey = ConfigurationTargetToString(configTarget);
|
||||
if (this._sandboxConfigPerConfigurationTarget.has(configTargetKey)) {
|
||||
@@ -197,9 +364,9 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService
|
||||
|
||||
// this method merges the default allowWrite paths and allowedDomains with the ones provided in the sandbox config, to ensure that the default necessary paths and domains are always included in the sandbox config used for launching,
|
||||
// even if they are not explicitly specified in the config provided by the user or the MCP server config.
|
||||
private _withDefaultSandboxConfig(sandboxConfig?: IMcpSandboxConfiguration): IMcpSandboxConfiguration {
|
||||
private _withDefaultSandboxConfig(sandboxConfig?: IMcpSandboxConfiguration, launchCwd?: string): IMcpSandboxConfiguration {
|
||||
const mergedAllowWrite = new Set(sandboxConfig?.filesystem?.allowWrite ?? []);
|
||||
for (const defaultAllowWrite of this._getDefaultAllowWrite()) {
|
||||
for (const defaultAllowWrite of this._getDefaultAllowWrite(launchCwd ? [launchCwd] : undefined)) {
|
||||
if (defaultAllowWrite) {
|
||||
mergedAllowWrite.add(defaultAllowWrite);
|
||||
}
|
||||
@@ -226,10 +393,15 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService
|
||||
};
|
||||
}
|
||||
|
||||
private _getDefaultAllowWrite(): readonly string[] {
|
||||
return [
|
||||
'~/.npm'
|
||||
];
|
||||
private _getDefaultAllowWrite(directories?: string[]): readonly string[] {
|
||||
const defaultAllowWrite: string[] = ['~/.npm'];
|
||||
for (const launchCwd of directories ?? []) {
|
||||
const trimmed = launchCwd.trim();
|
||||
if (trimmed) {
|
||||
defaultAllowWrite.push(trimmed);
|
||||
}
|
||||
}
|
||||
return defaultAllowWrite;
|
||||
}
|
||||
|
||||
private _pathJoin = (os: OperatingSystem, ...segments: string[]) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com
|
||||
import { Iterable } from '../../../../base/common/iterator.js';
|
||||
import * as json from '../../../../base/common/json.js';
|
||||
import { normalizeDriveLetter } from '../../../../base/common/labels.js';
|
||||
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { LRUCache } from '../../../../base/common/map.js';
|
||||
import { Schemas } from '../../../../base/common/network.js';
|
||||
import { mapValues } from '../../../../base/common/objects.js';
|
||||
@@ -19,6 +19,7 @@ import { createURITransformer } from '../../../../base/common/uriTransformer.js'
|
||||
import { generateUuid } from '../../../../base/common/uuid.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { ICommandService } from '../../../../platform/commands/common/commands.js';
|
||||
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
|
||||
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { ILogger, ILoggerService } from '../../../../platform/log/common/log.js';
|
||||
import { INotificationService, IPromptChoice, Severity } from '../../../../platform/notification/common/notification.js';
|
||||
@@ -36,9 +37,10 @@ import { mcpActivationEvent } from './mcpConfiguration.js';
|
||||
import { McpDevModeServerAttache } from './mcpDevMode.js';
|
||||
import { McpIcons, parseAndValidateMcpIcon, StoredMcpIcons } from './mcpIcons.js';
|
||||
import { IMcpRegistry } from './mcpRegistryTypes.js';
|
||||
import { IMcpSandboxService } from './mcpSandboxService.js';
|
||||
import { McpServerRequestHandler } from './mcpServerRequestHandler.js';
|
||||
import { McpTaskManager } from './mcpTaskManager.js';
|
||||
import { ElicitationKind, extensionMcpCollectionPrefix, IMcpElicitationService, IMcpIcons, IMcpPrompt, IMcpPromptMessage, IMcpResource, IMcpResourceTemplate, IMcpSamplingService, IMcpServer, IMcpServerConnection, IMcpServerStartOpts, IMcpTool, IMcpToolCallContext, McpCapability, McpCollectionDefinition, McpCollectionReference, McpConnectionFailedError, McpConnectionState, McpDefinitionReference, mcpPromptReplaceSpecialChars, McpResourceURI, McpServerCacheState, McpServerDefinition, McpServerStaticToolAvailability, McpServerTransportType, McpToolName, McpToolVisibility, MpcResponseError, UserInteractionRequiredError } from './mcpTypes.js';
|
||||
import { ElicitationKind, extensionMcpCollectionPrefix, IMcpElicitationService, IMcpIcons, IMcpPotentialSandboxBlock, IMcpPrompt, IMcpPromptMessage, IMcpResource, IMcpResourceTemplate, IMcpSamplingService, IMcpServer, IMcpServerConnection, IMcpServerStartOpts, IMcpTool, IMcpToolCallContext, McpCapability, McpCollectionDefinition, McpCollectionReference, McpConnectionFailedError, McpConnectionState, McpDefinitionReference, mcpPromptReplaceSpecialChars, McpResourceURI, McpServerCacheState, McpServerDefinition, McpServerStaticToolAvailability, McpServerTransportType, McpToolName, McpToolVisibility, MpcResponseError, UserInteractionRequiredError } from './mcpTypes.js';
|
||||
import { MCP } from './modelContextProtocol.js';
|
||||
import { McpApps } from './modelContextProtocolApps.js';
|
||||
import { UriTemplate } from './uriTemplate.js';
|
||||
@@ -415,6 +417,10 @@ export class McpServer extends Disposable implements IMcpServer {
|
||||
private readonly _loggerId: string;
|
||||
private readonly _logger: ILogger;
|
||||
private _lastModeDebugged = false;
|
||||
private _isQuietStart = false;
|
||||
private _isSandboxSuggestionDialogVisible = false;
|
||||
private _potentialSandboxBlocks: IMcpPotentialSandboxBlock[] = [];
|
||||
private _potentialSandboxBlockListener = this._register(new MutableDisposable<IDisposable>());
|
||||
/** Count of running tool calls, used to detect if sampling is during an LM call */
|
||||
public runningToolCalls = new Set<IMcpToolCallContext>();
|
||||
|
||||
@@ -433,10 +439,12 @@ export class McpServer extends Disposable implements IMcpServer {
|
||||
@ITelemetryService private readonly _telemetryService: ITelemetryService,
|
||||
@ICommandService private readonly _commandService: ICommandService,
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
@IDialogService private readonly _dialogService: IDialogService,
|
||||
@INotificationService private readonly _notificationService: INotificationService,
|
||||
@IOpenerService private readonly _openerService: IOpenerService,
|
||||
@IMcpSamplingService private readonly _samplingService: IMcpSamplingService,
|
||||
@IMcpElicitationService private readonly _elicitationService: IMcpElicitationService,
|
||||
@IMcpSandboxService private readonly _mcpSandboxService: IMcpSandboxService,
|
||||
@IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService,
|
||||
) {
|
||||
super();
|
||||
@@ -493,6 +501,11 @@ export class McpServer extends Disposable implements IMcpServer {
|
||||
}
|
||||
}));
|
||||
|
||||
this._register(autorun(reader => {
|
||||
const cnx = this._connection.read(reader);
|
||||
this._potentialSandboxBlockListener.value = cnx?.onPotentialSandboxBlock(block => this.recordPotentialSandboxBlock(block));
|
||||
}));
|
||||
|
||||
const staticMetadata = derived(reader => {
|
||||
const def = this._fullDefinitions.read(reader).server;
|
||||
return def && def.cacheNonce !== this._tools.fromCache?.nonce ? def.staticMetadata : undefined;
|
||||
@@ -589,6 +602,7 @@ export class McpServer extends Disposable implements IMcpServer {
|
||||
}
|
||||
|
||||
let connection = this._connection.get();
|
||||
this._isQuietStart = !!errorOnUserInteraction;
|
||||
if (connection && McpConnectionState.canBeStarted(connection.state.get().state)) {
|
||||
connection.dispose();
|
||||
connection = undefined;
|
||||
@@ -629,6 +643,8 @@ export class McpServer extends Disposable implements IMcpServer {
|
||||
}
|
||||
}
|
||||
|
||||
this._potentialSandboxBlocks.length = 0;
|
||||
|
||||
const start = Date.now();
|
||||
let state = await connection.start({
|
||||
createMessageRequestHandler: (params, token) => this._samplingService.sample({
|
||||
@@ -656,10 +672,6 @@ export class McpServer extends Disposable implements IMcpServer {
|
||||
time: Date.now() - start,
|
||||
});
|
||||
|
||||
if (state.state === McpConnectionState.Kind.Error) {
|
||||
this.showInteractiveError(connection, state, debug);
|
||||
}
|
||||
|
||||
// MCP servers that need auth can 'start' but will stop with an interaction-needed
|
||||
// error they first make a request. In this case, wait until the handler fully
|
||||
// initializes before resolving (throwing if it ends up needing auth)
|
||||
@@ -684,6 +696,23 @@ export class McpServer extends Disposable implements IMcpServer {
|
||||
}).finally(() => disposable.dispose());
|
||||
}
|
||||
|
||||
if (state.state === McpConnectionState.Kind.Error) {
|
||||
let disposable: IDisposable;
|
||||
state = await new Promise<McpConnectionState>((resolve, reject) => {
|
||||
disposable = autorun(reader => {
|
||||
const cnx = this._connection.read(reader);
|
||||
const state = cnx?.state.read(reader);
|
||||
if (cnx && state?.state === McpConnectionState.Kind.Error) {
|
||||
if (!this._isQuietStart) {
|
||||
this.showInteractiveError(cnx, state, this._lastModeDebugged);
|
||||
} else {
|
||||
reject(new UserInteractionRequiredError('start'));
|
||||
}
|
||||
}
|
||||
});
|
||||
}).finally(() => disposable.dispose());
|
||||
}
|
||||
|
||||
return state;
|
||||
}).finally(() => {
|
||||
interaction?.participants.set(this.definition.id, { s: 'resolved' });
|
||||
@@ -691,6 +720,12 @@ export class McpServer extends Disposable implements IMcpServer {
|
||||
}
|
||||
|
||||
private showInteractiveError(cnx: IMcpServerConnection, error: McpConnectionState.Error, debug?: boolean) {
|
||||
if (cnx.definition.sandboxEnabled) {
|
||||
if (!this.showSandboxConfigSuggestionFromPotentialBlocks(cnx, this._potentialSandboxBlocks)) {
|
||||
this._notificationService.warn(localize('mcpServerError', 'The MCP server {0} could not be started: {1}', cnx.definition.label, error.message));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (error.code === 'ENOENT' && cnx.launchDefinition.type === McpServerTransportType.Stdio) {
|
||||
let docsLink: string | undefined;
|
||||
switch (cnx.launchDefinition.command) {
|
||||
@@ -734,6 +769,82 @@ export class McpServer extends Disposable implements IMcpServer {
|
||||
}
|
||||
}
|
||||
|
||||
public showSandboxConfigSuggestionFromPotentialBlocks(cnx: IMcpServerConnection, potentialBlocks: readonly IMcpPotentialSandboxBlock[]): boolean {
|
||||
if (!cnx.definition.sandboxEnabled || !potentialBlocks.length || this._isSandboxSuggestionDialogVisible) {
|
||||
return false;
|
||||
}
|
||||
if (this._isQuietStart) {
|
||||
throw new UserInteractionRequiredError('sandbox-suggestion');
|
||||
}
|
||||
|
||||
const existingSandboxConfig = this._fullDefinitions.get().collection?.sandbox;
|
||||
const suggestion = this._mcpSandboxService.getSandboxConfigSuggestionMessage(cnx.definition.label, potentialBlocks, existingSandboxConfig);
|
||||
if (!suggestion) {
|
||||
// clear potential blocks as there are no suggestions for them.
|
||||
this._removePotentialSandboxBlocks(potentialBlocks);
|
||||
return false;
|
||||
}
|
||||
|
||||
this._confirmAndApplySandboxConfigSuggestion(cnx, potentialBlocks, suggestion);
|
||||
return true;
|
||||
}
|
||||
|
||||
private _confirmAndApplySandboxConfigSuggestion(cnx: IMcpServerConnection, potentialBlocks: readonly IMcpPotentialSandboxBlock[], suggestion: NonNullable<ReturnType<IMcpSandboxService['getSandboxConfigSuggestionMessage']>>): void {
|
||||
const mcpResource = cnx.definition.presentation?.origin?.uri ?? this.collection.presentation?.origin;
|
||||
const configTarget = this._fullDefinitions.get().collection?.configTarget;
|
||||
this._isSandboxSuggestionDialogVisible = true;
|
||||
|
||||
void this._dialogService.confirm({
|
||||
type: 'warning',
|
||||
message: localize('mcpSandboxSuggestion.confirm.message', "Update sandbox configuration in mcp.json for {0}?", cnx.definition.label),
|
||||
detail: suggestion.message,
|
||||
primaryButton: localize('mcpSandboxSuggestion.confirm.yes', "Yes"),
|
||||
cancelButton: localize('mcpSandboxSuggestion.confirm.no', "No"),
|
||||
}).then(async result => {
|
||||
if (!result.confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mcpResource || configTarget === undefined) {
|
||||
this._notificationService.warn(localize('mcpSandboxSuggestion.apply.unavailable', "Couldn't determine where to update sandbox configuration for {0}.", cnx.definition.label));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await this._mcpSandboxService.applySandboxConfigSuggestion(cnx.definition, mcpResource, configTarget, potentialBlocks, suggestion.sandboxConfig);
|
||||
if (updated) {
|
||||
this._removePotentialSandboxBlocks(potentialBlocks);
|
||||
this._notificationService.info(localize('mcpSandboxSuggestion.apply.success', "Updated sandbox configuration for {0} in mcp.json. Restart server.", cnx.definition.label));
|
||||
}
|
||||
} catch (e) {
|
||||
this._notificationService.error(localize('mcpSandboxSuggestion.apply.error', "Failed to update sandbox configuration for {0}: {1}", cnx.definition.label, e instanceof Error ? e.message : String(e)));
|
||||
}
|
||||
}).finally(() => {
|
||||
this._isSandboxSuggestionDialogVisible = false;
|
||||
});
|
||||
}
|
||||
|
||||
public recordPotentialSandboxBlock(block: IMcpPotentialSandboxBlock): void {
|
||||
this._potentialSandboxBlocks.push(block);
|
||||
if (this._potentialSandboxBlocks.length > 200) {
|
||||
this._potentialSandboxBlocks.splice(0, this._potentialSandboxBlocks.length - 200);
|
||||
}
|
||||
|
||||
const connection = this._connection.get();
|
||||
if (connection?.state.get().state === McpConnectionState.Kind.Running) {
|
||||
this.showSandboxConfigSuggestionFromPotentialBlocks(connection, this._potentialSandboxBlocks);
|
||||
}
|
||||
}
|
||||
|
||||
private _removePotentialSandboxBlocks(blocks: readonly IMcpPotentialSandboxBlock[]): void {
|
||||
if (!blocks.length || !this._potentialSandboxBlocks.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toRemove = new Set(blocks);
|
||||
this._potentialSandboxBlocks = this._potentialSandboxBlocks.filter(block => !toRemove.has(block));
|
||||
}
|
||||
|
||||
public stop(): Promise<void> {
|
||||
return this._connection.get()?.stop() || Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { CancellationTokenSource } from '../../../../base/common/cancellation.js';
|
||||
import { CancellationError } from '../../../../base/common/errors.js';
|
||||
import { Emitter } from '../../../../base/common/event.js';
|
||||
import { Disposable, DisposableStore, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { autorun, IObservable, observableValue } from '../../../../base/common/observable.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
@@ -13,15 +14,17 @@ import { ILogger, log, LogLevel } from '../../../../platform/log/common/log.js';
|
||||
import { IMcpHostDelegate, IMcpMessageTransport } from './mcpRegistryTypes.js';
|
||||
import { McpServerRequestHandler } from './mcpServerRequestHandler.js';
|
||||
import { McpTaskManager } from './mcpTaskManager.js';
|
||||
import { IMcpClientMethods, IMcpServerConnection, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch } from './mcpTypes.js';
|
||||
import { IMcpClientMethods, IMcpPotentialSandboxBlock, IMcpServerConnection, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch } from './mcpTypes.js';
|
||||
|
||||
export class McpServerConnection extends Disposable implements IMcpServerConnection {
|
||||
private readonly _launch = this._register(new MutableDisposable<IReference<IMcpMessageTransport>>());
|
||||
private readonly _state = observableValue<McpConnectionState>('mcpServerState', { state: McpConnectionState.Kind.Stopped });
|
||||
private readonly _requestHandler = observableValue<McpServerRequestHandler | undefined>('mcpServerRequestHandler', undefined);
|
||||
private readonly _onPotentialSandboxBlock = this._register(new Emitter<IMcpPotentialSandboxBlock>());
|
||||
|
||||
public readonly state: IObservable<McpConnectionState> = this._state;
|
||||
public readonly handler: IObservable<McpServerRequestHandler | undefined> = this._requestHandler;
|
||||
public readonly onPotentialSandboxBlock = this._onPotentialSandboxBlock.event;
|
||||
|
||||
constructor(
|
||||
private readonly _collection: McpCollectionDefinition,
|
||||
@@ -69,6 +72,10 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect
|
||||
store.add(launch);
|
||||
store.add(launch.onDidLog(({ level, message }) => {
|
||||
log(this._logger, level, message);
|
||||
const potentialBlock = this._toPotentialSandboxBlock(message);
|
||||
if (potentialBlock) {
|
||||
this._onPotentialSandboxBlock.fire(potentialBlock);
|
||||
}
|
||||
}));
|
||||
|
||||
let didStart = false;
|
||||
@@ -141,4 +148,65 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _toPotentialSandboxBlock(message: string): IMcpPotentialSandboxBlock | undefined {
|
||||
if (!this.definition.sandboxEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (/No matching config rule, denying:/i.test(message)) {
|
||||
return {
|
||||
kind: 'network',
|
||||
message,
|
||||
host: this._extractSandboxHost(message),
|
||||
};
|
||||
}
|
||||
|
||||
if (/\b(?:EAI_AGAIN|ENOTFOUND)\b/i.test(message)) {
|
||||
return {
|
||||
kind: 'network',
|
||||
message,
|
||||
host: this._extractSandboxHost(message),
|
||||
};
|
||||
}
|
||||
|
||||
if (/(?:\b(?:EACCES|EPERM|ENOENT|fail(?:ed|ure)?)\b|not accessible)/i.test(message)) {
|
||||
return {
|
||||
kind: 'filesystem',
|
||||
message,
|
||||
path: this._extractSandboxPath(message),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _extractSandboxPath(line: string): string | undefined {
|
||||
const bracketedPath = line.match(/\[(\/[^\]\r\n]+)\]/);
|
||||
if (bracketedPath?.[1]) {
|
||||
return bracketedPath[1].trim();
|
||||
}
|
||||
|
||||
const quotedPath = line.match(/["'](\/[^"']+)["']/);
|
||||
if (quotedPath?.[1]) {
|
||||
return quotedPath[1];
|
||||
}
|
||||
|
||||
const trailingPath = line.match(/(\/[\w.\-~/ ]+)$/);
|
||||
return trailingPath?.[1]?.trim();
|
||||
}
|
||||
|
||||
private _extractSandboxHost(value: string): string | undefined {
|
||||
const deniedMatch = value.match(/No matching config rule, denying:\s+(.+)$/i);
|
||||
const matchTarget = deniedMatch?.[1] ?? value;
|
||||
const trimmed = matchTarget.trim().replace(/^["'`]+|["'`,.;]+$/g, '');
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const withoutProtocol = trimmed.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
|
||||
const firstToken = withoutProtocol.split(/[\s/]/, 1)[0] ?? '';
|
||||
const host = firstToken.replace(/:\d+$/, '');
|
||||
return host || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface McpCollectionDefinition {
|
||||
readonly scope: StorageScope;
|
||||
/** Configuration target where configuration related to this server should be stored. */
|
||||
readonly configTarget: ConfigurationTarget;
|
||||
/** Root-level sandbox settings from the mcp config file. */
|
||||
readonly sandbox?: IMcpSandboxConfiguration;
|
||||
|
||||
/** Resolves a server definition. If present, always called before a server starts. */
|
||||
resolveServerLanch?(definition: McpServerDefinition): Promise<McpServerLaunch | undefined>;
|
||||
@@ -112,7 +114,8 @@ export namespace McpCollectionDefinition {
|
||||
return a.id === b.id
|
||||
&& a.remoteAuthority === b.remoteAuthority
|
||||
&& a.label === b.label
|
||||
&& a.trustBehavior === b.trustBehavior;
|
||||
&& a.trustBehavior === b.trustBehavior
|
||||
&& objectsEqual(a.sandbox, b.sandbox);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,10 +584,18 @@ export namespace McpServerLaunch {
|
||||
* stopped, and restarted. Once started and in a running state, it will
|
||||
* eventually build a {@link IMcpServerConnection.handler}.
|
||||
*/
|
||||
export interface IMcpPotentialSandboxBlock {
|
||||
readonly kind: 'network' | 'filesystem';
|
||||
readonly message: string;
|
||||
readonly host?: string;
|
||||
readonly path?: string;
|
||||
}
|
||||
|
||||
export interface IMcpServerConnection extends IDisposable {
|
||||
readonly definition: McpServerDefinition;
|
||||
readonly state: IObservable<McpConnectionState>;
|
||||
readonly handler: IObservable<McpServerRequestHandler | undefined>;
|
||||
readonly onPotentialSandboxBlock: Event<IMcpPotentialSandboxBlock>;
|
||||
|
||||
/**
|
||||
* Resolved launch definition. Might not match the `definition.launch` due to
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as sinon from 'sinon';
|
||||
import { timeout } from '../../../../../base/common/async.js';
|
||||
import { Disposable } from '../../../../../base/common/lifecycle.js';
|
||||
import { ISettableObservable, observableValue } from '../../../../../base/common/observable.js';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { upcast } from '../../../../../base/common/types.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { ConfigurationTarget, IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
|
||||
@@ -17,6 +18,7 @@ import { ServiceCollection } from '../../../../../platform/instantiation/common/
|
||||
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
|
||||
import { ILogger, ILoggerService, ILogService, NullLogger, NullLogService } from '../../../../../platform/log/common/log.js';
|
||||
import { mcpAccessConfig, McpAccessValue } from '../../../../../platform/mcp/common/mcpManagement.js';
|
||||
import { IMcpSandboxConfiguration } from '../../../../../platform/mcp/common/mcpPlatformTypes.js';
|
||||
import { INotificationService } from '../../../../../platform/notification/common/notification.js';
|
||||
import { TestNotificationService } from '../../../../../platform/notification/test/common/testNotificationService.js';
|
||||
import { IProductService } from '../../../../../platform/product/common/productService.js';
|
||||
@@ -33,7 +35,7 @@ import { IMcpHostDelegate, IMcpMessageTransport } from '../../common/mcpRegistry
|
||||
import { IMcpSandboxService } from '../../common/mcpSandboxService.js';
|
||||
import { McpServerConnection } from '../../common/mcpServerConnection.js';
|
||||
import { McpTaskManager } from '../../common/mcpTaskManager.js';
|
||||
import { LazyCollectionState, McpCollectionDefinition, McpServerDefinition, McpServerLaunch, McpServerTransportStdio, McpServerTransportType, McpServerTrust, McpStartServerInteraction } from '../../common/mcpTypes.js';
|
||||
import { IMcpPotentialSandboxBlock, LazyCollectionState, McpCollectionDefinition, McpServerDefinition, McpServerLaunch, McpServerTransportStdio, McpServerTransportType, McpServerTrust, McpStartServerInteraction } from '../../common/mcpTypes.js';
|
||||
import { TestMcpMessageTransport } from './mcpRegistryTypes.js';
|
||||
|
||||
class TestConfigurationResolverService {
|
||||
@@ -161,6 +163,14 @@ class TestMcpSandboxService implements IMcpSandboxService {
|
||||
isEnabled(serverDef: McpServerDefinition): Promise<boolean> {
|
||||
return Promise.resolve(this.enabled);
|
||||
}
|
||||
|
||||
getSandboxConfigSuggestionMessage(_serverLabel: string, _potentialBlocks: readonly IMcpPotentialSandboxBlock[], _existingSandboxConfig?: IMcpSandboxConfiguration): { message: string; sandboxConfig: IMcpSandboxConfiguration } | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
applySandboxConfigSuggestion(_serverDef: McpServerDefinition, _mcpResource: URI, _configTarget: ConfigurationTarget, _potentialBlocks: readonly IMcpPotentialSandboxBlock[], _suggestedSandboxConfig?: IMcpSandboxConfiguration): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
|
||||
suite('Workbench - MCP - Registry', () => {
|
||||
@@ -371,11 +381,15 @@ suite('Workbench - MCP - Registry', () => {
|
||||
|
||||
test('resolveConnection calls launchInSandboxIfEnabled with expected arguments when sandboxing is enabled', async () => {
|
||||
testMcpSandboxService.enabled = true;
|
||||
const mcpResource = URI.file('/test/mcp.json');
|
||||
|
||||
const sandboxCollection: McpCollectionDefinition & { serverDefinitions: ISettableObservable<McpServerDefinition[]> } = {
|
||||
...testCollection,
|
||||
id: 'sandbox-collection',
|
||||
remoteAuthority: 'ssh-remote+test',
|
||||
presentation: {
|
||||
origin: mcpResource,
|
||||
},
|
||||
};
|
||||
|
||||
const definition: McpServerDefinition = {
|
||||
|
||||
Reference in New Issue
Block a user