diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index efdc4a147cd66..348db3734a33d 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { StringSHA1 } from '../../../base/common/hash.js'; import { parse as parseJSONC } from '../../../base/common/json.js'; import { cloneAndChange, equals as objectEquals } from '../../../base/common/objects.js'; import { isAbsolute } from '../../../base/common/path.js'; @@ -18,6 +19,12 @@ import { DEFAULT_MCP_APP } from '../../agentHost/common/state/protocol/mcpAppDef import { customizationId } from '../../agentHost/common/state/sessionState.js'; import { readAgentPluginManifest } from './agentPluginParser.js'; +export function getAgentPluginDataDirName(identity: string): string { + const sha = new StringSHA1(); + sha.update(identity); + return sha.digest(); +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index dc3db871a654b..1be0e0f8214ff 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -37,6 +37,10 @@ export interface IAgentPluginAutomation { export interface IAgentPlugin { readonly uri: URI; + /** Stable identity used for the persistent plugin data directory when available. */ + readonly dataDirId?: string; + /** Persistent data directory used for `${PLUGIN_DATA}` in Agent Plugin MCP configurations. */ + readonly dataDir?: IObservable; readonly format: PluginFormat; /** Human-readable display name for the plugin. */ readonly label: string; diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index bf2b4a0b2884f..e758ec92273ff 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -40,6 +40,7 @@ import { readPluginMcpServers, parseMcpServerDefinitionMap, detectPluginFormat, + getAgentPluginDataDirName, type PluginComponent, type IPluginFormatConfig, type IParsedHookGroup, @@ -47,6 +48,8 @@ import { import { Extensions, IExtensionFeaturesRegistry, IExtensionFeatureTableRenderer, IRenderedData, IRowData, ITableData } from '../../../../services/extensionManagement/common/extensionFeatures.js'; import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; +import { IUserDataProfileService } from '../../../../services/userDataProfile/common/userDataProfile.js'; +import { IUserDataProfile } from '../../../../../platform/userDataProfile/common/userDataProfile.js'; import { ChatConfiguration } from '../constants.js'; import { ContributionEnablementState, EnablementModel, IEnablementModel } from '../enablement.js'; import { AUTOMATION_BLUEPRINT_FILE_SUFFIX, parseAutomationBlueprint } from '../automations/automationBlueprint.js'; @@ -232,6 +235,8 @@ interface IPluginManifest { interface IPluginSource { readonly uri: URI; readonly fromMarketplace: IMarketplacePlugin | undefined; + /** Stable identity for persistent plugin data. */ + readonly dataDirId?: string; /** Repository root that serves as the boundary for component path resolution. */ readonly repositoryUri?: URI; /** Called when remove is invoked on the plugin; absent for policy-managed plugins */ @@ -256,13 +261,19 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements private _discoverVersion = 0; protected _enablementModel!: IEnablementModel; + private readonly _currentProfile: IObservable | undefined; + constructor( protected readonly _fileService: IFileService, protected readonly _pathService: IPathService, protected readonly _logService: ILogService, protected readonly _workspaceContextService: IWorkspaceContextService, + protected readonly _userDataProfileService: IUserDataProfileService | undefined, ) { super(); + this._currentProfile = this._userDataProfileService + ? observableFromEvent(this, this._userDataProfileService.onDidChangeCurrentProfile, () => this._userDataProfileService!.currentProfile) + : undefined; } public abstract start(enablementModel: IEnablementModel): void; @@ -299,7 +310,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements if (!this._isCurrentRefresh(version)) { return []; } - const plugin = await this._toPlugin(source.uri, format, source.fromMarketplace, source.repositoryUri, source.remove, version); + const plugin = await this._toPlugin(source.uri, format, source.fromMarketplace, source.dataDirId, source.repositoryUri, source.remove, version); seenPluginUris.add(key); plugins.push(plugin); } catch (error) { @@ -329,7 +340,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements } } - private async _toPlugin(uri: URI, format: IPluginFormatConfig, fromMarketplace: IMarketplacePlugin | undefined, repositoryUri: URI | undefined, removeCallback: (() => Promise) | undefined, version: number): Promise { + private async _toPlugin(uri: URI, format: IPluginFormatConfig, fromMarketplace: IMarketplacePlugin | undefined, dataDirId: string | undefined, repositoryUri: URI | undefined, removeCallback: (() => Promise) | undefined, version: number): Promise { const key = uri.toString(); const existing = this._pluginEntries.get(key); if (existing) { @@ -477,8 +488,14 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements ? initialManifest.name.trim() : undefined; + const dataDir = this._currentProfile + ? derived(reader => joinPath(this._currentProfile!.read(reader).globalStorageHome, 'agentPlugins', 'data', getAgentPluginDataDirName(dataDirId ?? uri.toString()))) + : undefined; + const plugin: PluginEntry = { uri, + dataDirId, + dataDir, format: format.format, label: fromMarketplace?.name ?? manifestName ?? basename(uri), version: pluginVersion, @@ -642,8 +659,9 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, @IPathService pathService: IPathService, @ILogService logService: ILogService, + @IUserDataProfileService userDataProfileService?: IUserDataProfileService, ) { - super(fileService, pathService, logService, workspaceContextService); + super(fileService, pathService, logService, workspaceContextService, userDataProfileService); this._pluginLocationsConfig = observableConfigValue>(ChatConfiguration.PluginLocations, {}, _configurationService); // Enterprise-managed plugin-ID entries (delivered via the `ChatEnabledPlugins` policy). // These are plugin IDs in `@` form, distinct from filesystem paths. @@ -720,9 +738,11 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery return; } + const fromMarketplace = this._pluginMarketplaceService.getMarketplacePluginMetadata(stat.resource); sources.push({ uri: stat.resource, - fromMarketplace: this._pluginMarketplaceService.getMarketplacePluginMetadata(stat.resource), + fromMarketplace, + dataDirId: fromMarketplace && getMarketplacePluginDataDirId(fromMarketplace), remove, }); } @@ -816,8 +836,9 @@ export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscover @IPathService pathService: IPathService, @ILogService logService: ILogService, @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, + @IUserDataProfileService userDataProfileService: IUserDataProfileService, ) { - super(fileService, pathService, logService, workspaceContextService); + super(fileService, pathService, logService, workspaceContextService, userDataProfileService); } public override start(enablementModel: IEnablementModel): void { @@ -853,6 +874,7 @@ export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscover sources.push({ uri: stat.resource, fromMarketplace: entry.plugin, + dataDirId: getMarketplacePluginDataDirId(entry.plugin), repositoryUri, remove: async () => { this._enablementModel.remove(stat.resource.toString()); @@ -902,9 +924,10 @@ export class CopilotCliAgentPluginDiscovery extends AbstractAgentPluginDiscovery @IPathService pathService: IPathService, @ILogService logService: ILogService, @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, + @IUserDataProfileService userDataProfileService: IUserDataProfileService, @IDialogService private readonly _dialogService: IDialogService, ) { - super(fileService, pathService, logService, workspaceContextService); + super(fileService, pathService, logService, workspaceContextService, userDataProfileService); } public override start(enablementModel: IEnablementModel): void { @@ -1091,7 +1114,7 @@ const epPlugins = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint(); + private readonly _extensionPlugins = new Map(); private readonly _whenKeys = new Set(); constructor( @@ -1102,8 +1125,9 @@ export class ExtensionAgentPluginDiscovery extends AbstractAgentPluginDiscovery @IPathService pathService: IPathService, @ILogService logService: ILogService, @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, + @IUserDataProfileService userDataProfileService: IUserDataProfileService, ) { - super(fileService, pathService, logService, workspaceContextService); + super(fileService, pathService, logService, workspaceContextService, userDataProfileService); } public override start(enablementModel: IEnablementModel): void { @@ -1134,7 +1158,7 @@ export class ExtensionAgentPluginDiscovery extends AbstractAgentPluginDiscovery continue; } } - this._extensionPlugins.set(extensionPluginKey(ext.description.identifier, raw.path), { uri: pluginUri, when: whenExpr, extensionId: ext.description.identifier.value }); + this._extensionPlugins.set(extensionPluginKey(ext.description.identifier, raw.path), { uri: pluginUri, when: whenExpr, extensionId: ext.description.identifier.value, path: raw.path }); } } for (const ext of delta.removed) { @@ -1180,6 +1204,7 @@ export class ExtensionAgentPluginDiscovery extends AbstractAgentPluginDiscovery sources.push({ uri: stat.resource, fromMarketplace: undefined, + dataDirId: `extension:${entry.extensionId}/${entry.path}`, remove: () => this._promptUninstallExtension(entry.extensionId), }); } @@ -1202,6 +1227,10 @@ function extensionPluginKey(extensionId: ExtensionIdentifier, path: string): str return `${extensionId.value}/${path}`; } +function getMarketplacePluginDataDirId(plugin: IMarketplacePlugin): string { + return `marketplace:${plugin.marketplaceReference.canonicalId}/${plugin.name}`; +} + class ChatPluginsDataRenderer extends Disposable implements IExtensionFeatureTableRenderer { readonly type = 'table' as const; diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts index 7baa0bdc75497..5828996ef2d5a 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts @@ -42,7 +42,7 @@ class TestPluginDiscovery extends AbstractAgentPluginDiscovery { logService: ILogService, workspaceContextService: IWorkspaceContextService, ) { - super(fileService, pathService, logService, workspaceContextService); + super(fileService, pathService, logService, workspaceContextService, undefined); } start(enablementModel: IEnablementModel): void { diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/configuredAgentPluginDiscovery.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/configuredAgentPluginDiscovery.test.ts index f7fb01cc5713a..6528d47e32a11 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/configuredAgentPluginDiscovery.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/configuredAgentPluginDiscovery.test.ts @@ -100,6 +100,7 @@ suite('ConfiguredAgentPluginDiscovery', () => { } }, new NullLogService(), + undefined, )); } diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index b128bae306c1b..d7f5399095a83 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -3,15 +3,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../../../../base/common/event.js'; import { hash } from '../../../../../base/common/hash.js'; -import { Disposable, DisposableResourceMap } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableResourceMap, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { autorun } from '../../../../../base/common/observable.js'; +import { autorun, constObservable, observableSignalFromEvent } from '../../../../../base/common/observable.js'; +import { posix, win32 } from '../../../../../base/common/path.js'; +import { OperatingSystem, OS } from '../../../../../base/common/platform.js'; import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; import { StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { getAgentPluginDataDirName, PluginFormat } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; +import { McpServerType, type IMcpServerConfiguration, type IMcpStdioServerConfiguration } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { IAgentPlugin, IAgentPluginMcpServerDefinition, @@ -19,8 +24,13 @@ import { } from '../../../chat/common/plugins/agentPluginService.js'; import { isContributionEnabled } from '../../../chat/common/enablement.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { MCP_PLUGIN_COLLECTION_ID_PREFIX, McpCollectionProvenance, McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTrust } from '../mcpTypes.js'; +import { mcpUriToFsPath, MCP_PLUGIN_COLLECTION_ID_PREFIX, McpCollectionProvenance, McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTrust } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { PersistentConnectionEventType } from '../../../../../platform/remote/common/remoteAgentConnection.js'; +import { IRemoteAgentEnvironment } from '../../../../../platform/remote/common/remoteAgentEnvironment.js'; +import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; /** * Prefix used for the {@link McpCollectionDefinition.id | collection id} of @@ -30,20 +40,155 @@ import { IMcpDiscovery } from './mcpDiscovery.js'; */ export { MCP_PLUGIN_COLLECTION_ID_PREFIX } from '../mcpTypes.js'; +export async function toPluginMcpServerDefinition( + collectionId: string, + plugin: Pick, + definition: IAgentPluginMcpServerDefinition, + fileService?: IFileService, + remoteEnvironment?: Pick, +): Promise { + const { name, defaultCwd } = definition; + let configuration = definition.configuration; + if (plugin.format === PluginFormat.AgentPlugin) { + const dataDir = remoteEnvironment + ? URI.joinPath(remoteEnvironment.globalStorageHome, 'agentPlugins', 'data', getAgentPluginDataDirName(plugin.dataDirId ?? plugin.uri.toString())) + : plugin.dataDir?.get(); + if (configuration.type === McpServerType.LOCAL && fileService && dataDir) { + await fileService.createFolder(dataDir); + } + + const os = remoteEnvironment?.os ?? OS; + const resolvedConfiguration = resolveAgentPluginMcpConfiguration(configuration, mcpUriToFsPath(plugin.uri, os), dataDir && mcpUriToFsPath(dataDir, os), os); + if (!resolvedConfiguration) { + return undefined; + } + configuration = resolvedConfiguration; + } + const launch = McpServerLaunch.fromServerConfiguration(configuration); + if (!launch) { + return undefined; + } + + return { + id: `${collectionId}.${name}`, + label: name, + launch, + defaultCwd, + variableReplacement: { target: ConfigurationTarget.USER }, + cacheNonce: String(hash(launch)), + }; +} + +function resolveAgentPluginMcpConfiguration( + configuration: IMcpServerConfiguration, + pluginRoot: string, + pluginData: string | undefined, + os: OperatingSystem, +): IMcpServerConfiguration | undefined { + if (configuration.type !== McpServerType.LOCAL) { + return configuration; + } + + const replace = (value: string): string | undefined => { + if (value.includes('${PLUGIN_DATA}') && !pluginData) { + return undefined; + } + return value + .replaceAll('${PLUGIN_ROOT}', pluginRoot) + .replaceAll('${PLUGIN_DATA}', pluginData ?? ''); + }; + const args = configuration.args?.map(replace); + if (args?.some(arg => arg === undefined)) { + return undefined; + } + const env = { ...(configuration.env ?? {}) }; + const cwd = resolveAgentPluginCwd(configuration.cwd, pluginRoot, pluginData, os); + if (cwd === undefined) { + return undefined; + } + const local: IMcpStdioServerConfiguration = { + ...configuration, + cwd, + args: args as string[] | undefined, + env, + }; + for (const [key, value] of Object.entries(env)) { + if (typeof value === 'string') { + const replaced = replace(value); + if (replaced === undefined) { + return undefined; + } + env[key] = replaced; + } + } + if (pluginData) { + env.PLUGIN_DATA = pluginData; + } + env.PLUGIN_ROOT = pluginRoot; + return local; +} + +function resolveAgentPluginCwd(cwd: string | undefined, pluginRoot: string, pluginData: string | undefined, os: OperatingSystem): string | undefined { + if (cwd === undefined) { + return pluginRoot; + } + + let root: string; + let relativePath: string; + if (cwd.startsWith('./')) { + root = pluginRoot; + relativePath = cwd.slice(2); + } else if (cwd === '${PLUGIN_ROOT}' || cwd.startsWith('${PLUGIN_ROOT}/')) { + root = pluginRoot; + relativePath = cwd.slice('${PLUGIN_ROOT}'.length).replace(/^\//, ''); + } else if (pluginData && (cwd === '${PLUGIN_DATA}' || cwd.startsWith('${PLUGIN_DATA}/'))) { + root = pluginData; + relativePath = cwd.slice('${PLUGIN_DATA}'.length).replace(/^\//, ''); + } else { + return undefined; + } + + if (relativePath.includes('\\')) { + return undefined; + } + const path = os === OperatingSystem.Windows ? win32 : posix; + const resolved = path.normalize(path.join(root, relativePath)); + const relativeToRoot = path.relative(path.normalize(root), resolved); + if (path.isAbsolute(relativeToRoot) || relativeToRoot === '..' || relativeToRoot.startsWith(`..${path.sep}`)) { + return undefined; + } + return resolved; +} + +class CollectionEntry extends MutableDisposable { + constructor(public readonly dataDirKey: string | undefined) { + super(); + } +} + export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { readonly fromGallery = false; - private readonly _collections = this._register(new DisposableResourceMap()); + private readonly _collections = this._register(new DisposableResourceMap()); constructor( @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, + @IFileService private readonly _fileService: IFileService, + @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, + @ILogService private readonly _logService: ILogService, ) { super(); } public start(): void { + const connection = this._remoteAgentService.getConnection(); + const remoteConnectionGain = connection + ? observableSignalFromEvent(this, Event.filter(connection.onDidStateChange, e => e.type === PersistentConnectionEventType.ConnectionGain)) + : undefined; + this._register(autorun(reader => { + remoteConnectionGain?.read(reader); const plugins = this._agentPluginService.plugins.read(reader); const seen = new ResourceSet(); for (const plugin of plugins) { @@ -57,11 +202,28 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { seen.add(plugin.uri); - let collectionState = this._collections.get(plugin.uri); - if (!collectionState) { - // note: all plugin servers are currently defined in the same file - collectionState = this.createCollectionState(plugin, servers[0].uri); - this._collections.set(plugin.uri, collectionState); + const dataDirKey = plugin.dataDir?.read(reader)?.toString(); + const existing = this._collections.get(plugin.uri); + if (existing && existing.dataDirKey !== dataDirKey) { + this._collections.deleteAndDispose(plugin.uri); + } + + if (!this._collections.has(plugin.uri)) { + const collectionDisposable = new CollectionEntry(dataDirKey); + this._collections.set(plugin.uri, collectionDisposable); + + this.createCollectionState(plugin, servers[0].uri).then(disposable => { + if (this._collections.get(plugin.uri) === collectionDisposable) { + collectionDisposable.value = disposable; + } else { + disposable.dispose(); + } + }, error => { + this._logService.error(`Failed to register MCP collection for plugin ${plugin.uri.toString()}`, error); + if (this._collections.get(plugin.uri) === collectionDisposable) { + this._collections.deleteAndDispose(plugin.uri); + } + }); } } @@ -73,8 +235,21 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { })); } - private createCollectionState(plugin: IAgentPlugin, manifestURI: URI) { + private async createCollectionState(plugin: IAgentPlugin, manifestURI: URI) { const collectionId = `${MCP_PLUGIN_COLLECTION_ID_PREFIX}${plugin.uri}`; + const defsObservableValue = plugin.mcpServerDefinitions.get(); + const remoteEnvironment = plugin.uri.scheme === Schemas.vscodeRemote + ? await this._remoteAgentService.getEnvironment() + : undefined; + if (plugin.uri.scheme === Schemas.vscodeRemote && !remoteEnvironment) { + throw new Error(`Remote environment unavailable for plugin ${plugin.uri.toString()}`); + } + const serverDefinitions = await Promise.all( + defsObservableValue.map(async d => toPluginMcpServerDefinition(collectionId, plugin, d, this._fileService, remoteEnvironment ?? undefined)) + ); + + const validDefinitions = serverDefinitions.filter(isDefined); + return this._mcpRegistry.registerCollection({ id: collectionId, provenance: McpCollectionProvenance.Plugin, @@ -83,8 +258,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { configTarget: ConfigurationTarget.USER, scope: StorageScope.PROFILE, trustBehavior: McpServerTrust.Kind.Trusted, - serverDefinitions: plugin.mcpServerDefinitions.map(defs => - defs.map(d => this._toServerDefinition(collectionId, d)).filter(isDefined)), + serverDefinitions: constObservable(validDefinitions), order: McpCollectionSortOrder.Plugin, presentation: { origin: manifestURI, @@ -92,22 +266,4 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { }); } - private _toServerDefinition( - collectionId: string, - { name, configuration, defaultCwd }: IAgentPluginMcpServerDefinition, - ): McpServerDefinition | undefined { - const launch = McpServerLaunch.fromServerConfiguration(configuration); - if (!launch) { - return undefined; - } - - return { - id: `${collectionId}.${name}`, - label: name, - launch, - defaultCwd, - variableReplacement: { target: ConfigurationTarget.USER }, - cacheNonce: String(hash(launch)), - }; - } } diff --git a/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts b/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts index ec87f5570bef4..2242a2443ee57 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts @@ -6,7 +6,7 @@ import { VSBuffer } from '../../../../base/common/buffer.js'; import { untildify } from '../../../../base/common/labels.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { FileAccess, Schemas } from '../../../../base/common/network.js'; +import { FileAccess } from '../../../../base/common/network.js'; import { dirname, posix, win32 } from '../../../../base/common/path.js'; import { OperatingSystem, OS } from '../../../../base/common/platform.js'; import { arch } from '../../../../base/common/process.js'; @@ -22,7 +22,7 @@ import { IMcpResourceScannerService, McpResourceTarget } from '../../../../platf 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 { IMcpPotentialSandboxBlock, McpServerDefinition, McpServerLaunch, McpServerTransportStdio, McpServerTransportType } from './mcpTypes.js'; +import { IMcpPotentialSandboxBlock, mcpUriToFsPath, McpServerDefinition, McpServerLaunch, McpServerTransportStdio, McpServerTransportType } from './mcpTypes.js'; export const IMcpSandboxService = createDecorator('mcpSandboxService'); @@ -54,14 +54,7 @@ type SandboxLaunchDetails = { }; export function mcpDefaultCwdToFsPath(resource: URI, os: OperatingSystem): string { - let value = resource.scheme === Schemas.file && resource.authority ? `//${resource.authority}${resource.path}` : resource.path; - if (os === OperatingSystem.Windows) { - if (/^\/[a-zA-Z]:/.test(value)) { - value = value.slice(1); - } - value = value.replace(/\//g, '\\'); - } - return value; + return mcpUriToFsPath(resource, os); } export function resolveMcpServerSandboxWorkingDirectory(cwd: string | undefined, defaultCwd: URI | undefined, userHome: URI | undefined, os: OperatingSystem): string | undefined { diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index b44f8b3c66df0..35112359526fd 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -13,6 +13,7 @@ import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { equals as objectsEqual } from '../../../../base/common/objects.js'; import { IObservable, ObservableMap } from '../../../../base/common/observable.js'; import { IIterativePager } from '../../../../base/common/paging.js'; +import { OperatingSystem } from '../../../../base/common/platform.js'; import { isEqual } from '../../../../base/common/resources.js'; import Severity from '../../../../base/common/severity.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; @@ -36,9 +37,21 @@ import { ExternalDiscoverySource, IMcpServerSamplingConfiguration } from './mcpC import { McpServerRequestHandler } from './mcpServerRequestHandler.js'; import { MCP } from './modelContextProtocol.js'; import { UriTemplate } from '../../../../base/common/uriTemplate.js'; +import { Schemas } from '../../../../base/common/network.js'; export const extensionMcpCollectionPrefix = 'ext.'; +export function mcpUriToFsPath(resource: URI, os: OperatingSystem): string { + let value = resource.scheme === Schemas.file && resource.authority ? `//${resource.authority}${resource.path}` : resource.path; + if (os === OperatingSystem.Windows) { + if (/^\/[a-zA-Z]:/.test(value)) { + value = value.slice(1); + } + value = value.replace(/\//g, '\\'); + } + return value; +} + /** * Prefix of the collection id used for MCP servers configured via the various * `mcp.json`-style config files (user, remote user, workspace, and diff --git a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts new file mode 100644 index 0000000000000..52a66656fb968 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts @@ -0,0 +1,246 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { constObservable } from '../../../../../base/common/observable.js'; +import { OperatingSystem } from '../../../../../base/common/platform.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { getAgentPluginDataDirName, PluginFormat, type IMcpServerDefinition } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; +import { McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { CustomizationType, McpServerStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { toPluginMcpServerDefinition } from '../../common/discovery/pluginMcpDiscovery.js'; +import { McpServerTransportType as LaunchTransportType } from '../../common/mcpTypes.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IRemoteAgentEnvironment } from '../../../../../platform/remote/common/remoteAgentEnvironment.js'; + +suite('PluginMcpDiscovery', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('interpolates AgentPlugin MCP definitions before creating the launch', async () => { + const pluginUri = URI.file('/plugins/example'); + const pluginDataUri = URI.file('/plugin-data/example'); + const definition: IMcpServerDefinition = { + name: 'example', + uri: URI.joinPath(pluginUri, '.mcp.json'), + configuration: { + type: McpServerType.LOCAL, + command: './server.py', + args: ['--data', '${PLUGIN_DATA}'], + env: { CUSTOM_ROOT: '${PLUGIN_ROOT}' }, + cwd: '${PLUGIN_DATA}/work', + }, + customization: { + type: CustomizationType.McpServer, + id: 'example', + uri: URI.joinPath(pluginUri, '.mcp.json').toString(), + name: 'example', + state: { kind: McpServerStatus.Stopped }, + }, + }; + + const server = await toPluginMcpServerDefinition('plugin:', { dataDir: constObservable(pluginDataUri), format: PluginFormat.AgentPlugin, uri: pluginUri }, definition); + assert.deepStrictEqual(server?.launch, { + type: LaunchTransportType.Stdio, + command: './server.py', + args: ['--data', pluginDataUri.fsPath], + cwd: URI.joinPath(pluginDataUri, 'work').fsPath, + env: { + CUSTOM_ROOT: pluginUri.fsPath, + PLUGIN_ROOT: pluginUri.fsPath, + PLUGIN_DATA: pluginDataUri.fsPath, + }, + envFile: undefined, + sandbox: undefined, + }); + }); + + test('does not interpolate non-AgentPlugin MCP definitions', async () => { + const pluginUri = URI.file('/plugins/example'); + const definition: IMcpServerDefinition = { + name: 'example', + uri: URI.joinPath(pluginUri, '.mcp.json'), + configuration: { type: McpServerType.LOCAL, command: '${PLUGIN_ROOT}/server.py' }, + customization: { + type: CustomizationType.McpServer, + id: 'example', + uri: URI.joinPath(pluginUri, '.mcp.json').toString(), + name: 'example', + state: { kind: McpServerStatus.Stopped }, + }, + }; + + const server = await toPluginMcpServerDefinition('plugin:', { format: PluginFormat.Copilot, uri: pluginUri }, definition); + assert.ok(server?.launch.type === LaunchTransportType.Stdio); + assert.strictEqual(server.launch.command, '${PLUGIN_ROOT}/server.py'); + }); + + test('does not interpolate AgentPlugin HTTP URLs or headers', async () => { + const pluginUri = URI.file('/plugins/example'); + const definition: IMcpServerDefinition = { + name: 'remote', + uri: URI.joinPath(pluginUri, 'mcp.json'), + configuration: { + type: McpServerType.REMOTE, + url: 'https://example.test/${PLUGIN_ROOT}', + headers: { 'X-Plugin': '${PLUGIN_DATA}' }, + }, + customization: { + type: CustomizationType.McpServer, + id: 'remote', + uri: URI.joinPath(pluginUri, 'mcp.json').toString(), + name: 'remote', + state: { kind: McpServerStatus.Stopped }, + }, + }; + + const server = await toPluginMcpServerDefinition('plugin:', { format: PluginFormat.AgentPlugin, uri: pluginUri }, definition); + assert.ok(server?.launch.type === LaunchTransportType.HTTP); + assert.strictEqual(server.launch.uri.toString(true), 'https://example.test/${PLUGIN_ROOT}'); + assert.deepStrictEqual(server.launch.headers, [['X-Plugin', '${PLUGIN_DATA}']]); + }); + + test('creates plugin dataDir on file system when resolving MCP server definition', async () => { + let createdFolderUri: URI | undefined; + + const fileService = { + createFolder: async (resource: URI) => { + createdFolderUri = resource; + return {} as unknown as ReturnType; + } + } as unknown as IFileService; + + const targetDataDir = URI.file('/test/user/globalStorage/agentPlugins/data/a1b2c3d4'); + + const plugin: Parameters[1] = { + format: PluginFormat.AgentPlugin, + uri: URI.file('/test/plugins/my-plugin'), + dataDir: constObservable(targetDataDir), + } as unknown as Parameters[1]; + + const definition: Parameters[2] = { + name: 'test-server', + configuration: { + type: 'stdio', + command: 'node', + args: ['${PLUGIN_DATA}/index.js'], + }, + } as unknown as Parameters[2]; + + const result = await toPluginMcpServerDefinition('collection-1', plugin, definition, fileService); + + assert.ok(result); + assert.strictEqual(createdFolderUri?.toString(), targetDataDir.toString()); + }); + + test('resolves remote plugin paths and dataDir for the remote operating system', async () => { + let createdFolderUri: URI | undefined; + const fileService = { + createFolder: async (resource: URI) => { + createdFolderUri = resource; + return {} as ReturnType; + } + } as IFileService; + const pluginUri = URI.parse('vscode-remote://ssh-remote+linux/home/test/plugins/example'); + const remoteGlobalStorageHome = URI.parse('vscode-remote://ssh-remote+linux/home/test/.vscode-server/data/User/globalStorage'); + const remoteEnvironment: Pick = { + globalStorageHome: remoteGlobalStorageHome, + os: OperatingSystem.Linux, + }; + const dataDirId = 'marketplace:github.com/example/plugin'; + const dataDir = URI.joinPath(remoteGlobalStorageHome, 'agentPlugins', 'data', getAgentPluginDataDirName(dataDirId)); + + const server = await toPluginMcpServerDefinition('plugin:', { + dataDirId, + dataDir: constObservable(URI.file('C:/client-only/plugin-data')), + format: PluginFormat.AgentPlugin, + uri: pluginUri, + }, { + name: 'example', + uri: URI.joinPath(pluginUri, '.mcp.json'), + configuration: { + type: McpServerType.LOCAL, + command: './server.py', + args: ['--data', '${PLUGIN_DATA}'], + env: { CUSTOM_ROOT: '${PLUGIN_ROOT}' }, + cwd: '${PLUGIN_DATA}/work', + }, + customization: { + type: CustomizationType.McpServer, + id: 'example', + uri: URI.joinPath(pluginUri, '.mcp.json').toString(), + name: 'example', + state: { kind: McpServerStatus.Stopped }, + }, + }, fileService, remoteEnvironment); + + assert.deepStrictEqual({ + createdFolderUri: createdFolderUri?.toString(), + launch: server?.launch, + }, { + createdFolderUri: dataDir.toString(), + launch: { + type: LaunchTransportType.Stdio, + command: './server.py', + args: ['--data', dataDir.path], + cwd: `${dataDir.path}/work`, + env: { + CUSTOM_ROOT: pluginUri.path, + PLUGIN_ROOT: pluginUri.path, + PLUGIN_DATA: dataDir.path, + }, + envFile: undefined, + sandbox: undefined, + }, + }); + }); + + test('preserves remote plugin dataDir across installation updates', async () => { + const remoteGlobalStorageHome = URI.parse('vscode-remote://ssh-remote+linux/home/test/.vscode-server/data/User/globalStorage'); + const remoteEnvironment: Pick = { + globalStorageHome: remoteGlobalStorageHome, + os: OperatingSystem.Linux, + }; + const dataDirId = 'marketplace:github.com/example/plugin'; + const createdFolderUris: URI[] = []; + const fileService = { + createFolder: async (resource: URI) => { + createdFolderUris.push(resource); + return {} as ReturnType; + } + } as IFileService; + const definition: IMcpServerDefinition = { + name: 'example', + uri: URI.file('/plugins/example/mcp.json'), + configuration: { type: McpServerType.LOCAL, command: 'node', args: ['${PLUGIN_DATA}/server.js'] }, + customization: { + type: CustomizationType.McpServer, + id: 'example', + uri: 'file:///plugins/example/mcp.json', + name: 'example', + state: { kind: McpServerStatus.Stopped }, + }, + }; + + const pluginUris = [ + URI.parse('vscode-remote://ssh-remote+linux/home/test/plugins/github.com/example/plugin/sha_old'), + URI.parse('vscode-remote://ssh-remote+linux/home/test/plugins/github.com/example/plugin/sha_new'), + ]; + const servers = await Promise.all(pluginUris.map(uri => toPluginMcpServerDefinition('plugin:', { + dataDirId, + format: PluginFormat.AgentPlugin, + uri, + }, definition, fileService, remoteEnvironment))); + + const expectedDataDir = URI.joinPath(remoteGlobalStorageHome, 'agentPlugins', 'data', getAgentPluginDataDirName(dataDirId)); + assert.deepStrictEqual({ + createdFolderUris: createdFolderUris.map(uri => uri.toString()), + pluginDataPaths: servers.map(server => server?.launch.type === LaunchTransportType.Stdio ? server.launch.env?.PLUGIN_DATA : undefined), + }, { + createdFolderUris: [expectedDataDir.toString(), expectedDataDir.toString()], + pluginDataPaths: [expectedDataDir.path, expectedDataDir.path], + }); + }); +});