From 80fc54834082275aadb5b2dd0e5ca93eccba7220 Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:50:25 -0700 Subject: [PATCH 01/17] added PLUGIN_ROOT and PLUGIN_DATA expansion to agent plugin --- src/vs/platform/agentPlugins/common/pluginParsers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index efdc4a147cd663..cf8686ddc7d9cf 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -214,8 +214,8 @@ const AGENT_PLUGIN_FORMAT: IPluginFormatConfig = { }, manifestExtensionNamespace: AGENT_PLUGIN_COPILOT_EXTENSION_NAMESPACE, requiresManifest: true, - pluginRootTokens: [], - pluginRootEnvVars: [], + pluginRootTokens: ['${PLUGIN_ROOT}', '${PLUGIN_DATA}'], + pluginRootEnvVars: ['PLUGIN_ROOT', 'PLUGIN_DATA'], parseHooks(hookUri, json, _pluginUri, workspaceRoot, userHome) { return parseHooksJson(hookUri, json, workspaceRoot, userHome); }, From 923c1966cef9e7c1c75104ff9719692288c801eb Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 05:05:34 -0700 Subject: [PATCH 02/17] only interpolate if it is not agentplugin --- src/vs/platform/agentPlugins/common/pluginParsers.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index cf8686ddc7d9cf..a6e9c7ec69188e 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -214,8 +214,8 @@ const AGENT_PLUGIN_FORMAT: IPluginFormatConfig = { }, manifestExtensionNamespace: AGENT_PLUGIN_COPILOT_EXTENSION_NAMESPACE, requiresManifest: true, - pluginRootTokens: ['${PLUGIN_ROOT}', '${PLUGIN_DATA}'], - pluginRootEnvVars: ['PLUGIN_ROOT', 'PLUGIN_DATA'], + pluginRootTokens: [], + pluginRootEnvVars: [], parseHooks(hookUri, json, _pluginUri, workspaceRoot, userHome) { return parseHooksJson(hookUri, json, workspaceRoot, userHome); }, @@ -1302,8 +1302,8 @@ export function parseMcpServerDefinitionMap( uri: definitionURI, customization: makeMcpServerCustomization(definitionURI, name), }; - def = interpolateMcpPluginRoot(def, pluginFsPath, formatConfig.pluginRootTokens, formatConfig.pluginRootEnvVars); if (formatConfig.format !== PluginFormat.AgentPlugin) { + def = interpolateMcpPluginRoot(def, pluginFsPath, formatConfig.pluginRootTokens, formatConfig.pluginRootEnvVars); def = convertBareEnvVarsToVsCodeSyntax(def); } definitions.push(def); From 567a847f50ae0ee168f1d8d735d2087ac6e041af Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 06:15:08 -0700 Subject: [PATCH 03/17] added expansion in _toServerDefinition --- .../mcp/common/discovery/pluginMcpDiscovery.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index b128bae306c1b7..0e9177dc708e1d 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -12,6 +12,7 @@ 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 { interpolateMcpPluginRoot, PluginFormat } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; import { IAgentPlugin, IAgentPluginMcpServerDefinition, @@ -84,7 +85,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { scope: StorageScope.PROFILE, trustBehavior: McpServerTrust.Kind.Trusted, serverDefinitions: plugin.mcpServerDefinitions.map(defs => - defs.map(d => this._toServerDefinition(collectionId, d)).filter(isDefined)), + defs.map(d => this._toServerDefinition(collectionId, plugin, d)).filter(isDefined)), order: McpCollectionSortOrder.Plugin, presentation: { origin: manifestURI, @@ -94,8 +95,19 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { private _toServerDefinition( collectionId: string, - { name, configuration, defaultCwd }: IAgentPluginMcpServerDefinition, + plugin: IAgentPlugin, + definition: IAgentPluginMcpServerDefinition, ): McpServerDefinition | undefined { + const { name, defaultCwd } = definition; + let configuration = definition.configuration; + if (plugin.format === PluginFormat.AgentPlugin) { + configuration = interpolateMcpPluginRoot( + definition, + plugin.uri.fsPath, + ['${PLUGIN_ROOT}', '${PLUGIN_DATA}'], + ['PLUGIN_ROOT', 'PLUGIN_DATA'] + ).configuration; + } const launch = McpServerLaunch.fromServerConfiguration(configuration); if (!launch) { return undefined; From 8cfa3fa983ca52bcad82058afe8bbbe665497e45 Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 06:33:36 -0700 Subject: [PATCH 04/17] added tests for expansion behaviour --- .../common/discovery/pluginMcpDiscovery.ts | 54 ++++++++------ .../test/common/pluginMcpDiscovery.test.ts | 73 +++++++++++++++++++ 2 files changed, 104 insertions(+), 23 deletions(-) create mode 100644 src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 0e9177dc708e1d..c2bbb48b931e0b 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -31,6 +31,36 @@ import { IMcpDiscovery } from './mcpDiscovery.js'; */ export { MCP_PLUGIN_COLLECTION_ID_PREFIX } from '../mcpTypes.js'; +export function toPluginMcpServerDefinition( + collectionId: string, + plugin: Pick, + definition: IAgentPluginMcpServerDefinition, +): McpServerDefinition | undefined { + const { name, defaultCwd } = definition; + let configuration = definition.configuration; + if (plugin.format === PluginFormat.AgentPlugin) { + configuration = interpolateMcpPluginRoot( + definition, + plugin.uri.fsPath, + ['${PLUGIN_ROOT}', '${PLUGIN_DATA}'], + ['PLUGIN_ROOT', 'PLUGIN_DATA'] + ).configuration; + } + 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)), + }; +} + export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { readonly fromGallery = false; @@ -98,28 +128,6 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { plugin: IAgentPlugin, definition: IAgentPluginMcpServerDefinition, ): McpServerDefinition | undefined { - const { name, defaultCwd } = definition; - let configuration = definition.configuration; - if (plugin.format === PluginFormat.AgentPlugin) { - configuration = interpolateMcpPluginRoot( - definition, - plugin.uri.fsPath, - ['${PLUGIN_ROOT}', '${PLUGIN_DATA}'], - ['PLUGIN_ROOT', 'PLUGIN_DATA'] - ).configuration; - } - 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)), - }; + return toPluginMcpServerDefinition(collectionId, plugin, definition); } } 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 00000000000000..dff6f99b040ffd --- /dev/null +++ b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { 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'; + +suite('PluginMcpDiscovery', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('interpolates AgentPlugin MCP definitions before creating the launch', () => { + 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', + args: ['--data', '${PLUGIN_DATA}'], + env: { CUSTOM_ROOT: '${PLUGIN_ROOT}' }, + }, + customization: { + type: CustomizationType.McpServer, + id: 'example', + uri: URI.joinPath(pluginUri, '.mcp.json').toString(), + name: 'example', + state: { kind: McpServerStatus.Stopped }, + }, + }; + + const server = toPluginMcpServerDefinition('plugin:', { format: PluginFormat.AgentPlugin, uri: pluginUri }, definition); + assert.deepStrictEqual(server?.launch, { + type: LaunchTransportType.Stdio, + command: `${pluginUri.fsPath}/server.py`, + args: ['--data', pluginUri.fsPath], + cwd: undefined, + env: { + CUSTOM_ROOT: pluginUri.fsPath, + PLUGIN_ROOT: pluginUri.fsPath, + PLUGIN_DATA: pluginUri.fsPath, + }, + envFile: undefined, + sandbox: undefined, + }); + }); + + test('does not interpolate non-AgentPlugin MCP definitions', () => { + 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 = toPluginMcpServerDefinition('plugin:', { format: PluginFormat.Copilot, uri: pluginUri }, definition); + assert.ok(server?.launch.type === LaunchTransportType.Stdio); + assert.strictEqual(server.launch.command, '${PLUGIN_ROOT}/server.py'); + }); +}); From 1bc9a021fea503948dd97fcfd4df6f1678820265 Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:43:06 -0700 Subject: [PATCH 05/17] only exand fiels allowed by agents-plugin spec and added data path --- .../chat/common/plugins/agentPluginService.ts | 2 + .../common/discovery/pluginMcpDiscovery.ts | 105 +++++++++++++++--- .../test/common/pluginMcpDiscovery.test.ts | 39 ++++++- 3 files changed, 124 insertions(+), 22 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index dc3db871a654b8..6e2c10cbc59786 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -37,6 +37,8 @@ export interface IAgentPluginAutomation { export interface IAgentPlugin { readonly uri: URI; + /** Persistent data directory used for `${PLUGIN_DATA}` in Agent Plugin MCP configurations. */ + readonly dataDir?: URI; readonly format: PluginFormat; /** Human-readable display name for the plugin. */ readonly label: string; diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index c2bbb48b931e0b..7f0550d303e4e7 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -8,11 +8,13 @@ import { Disposable, DisposableResourceMap } from '../../../../../base/common/li import { ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { autorun } from '../../../../../base/common/observable.js'; +import { isAbsolute, join, normalize, relative, sep } from '../../../../../base/common/path.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 { interpolateMcpPluginRoot, PluginFormat } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; +import { PluginFormat } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; +import { McpServerType, type IMcpServerConfiguration, type IMcpStdioServerConfiguration } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { IAgentPlugin, IAgentPluginMcpServerDefinition, @@ -33,18 +35,17 @@ export { MCP_PLUGIN_COLLECTION_ID_PREFIX } from '../mcpTypes.js'; export function toPluginMcpServerDefinition( collectionId: string, - plugin: Pick, + plugin: Pick, definition: IAgentPluginMcpServerDefinition, ): McpServerDefinition | undefined { const { name, defaultCwd } = definition; let configuration = definition.configuration; if (plugin.format === PluginFormat.AgentPlugin) { - configuration = interpolateMcpPluginRoot( - definition, - plugin.uri.fsPath, - ['${PLUGIN_ROOT}', '${PLUGIN_DATA}'], - ['PLUGIN_ROOT', 'PLUGIN_DATA'] - ).configuration; + const resolvedConfiguration = resolveAgentPluginMcpConfiguration(configuration, plugin.uri.fsPath, plugin.dataDir?.fsPath); + if (!resolvedConfiguration) { + return undefined; + } + configuration = resolvedConfiguration; } const launch = McpServerLaunch.fromServerConfiguration(configuration); if (!launch) { @@ -61,6 +62,85 @@ export function toPluginMcpServerDefinition( }; } +function resolveAgentPluginMcpConfiguration( + configuration: IMcpServerConfiguration, + pluginRoot: string, + pluginData: string | undefined, +): 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); + 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): 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 resolved = normalize(join(root, relativePath)); + const relativeToRoot = relative(normalize(root), resolved); + if (isAbsolute(relativeToRoot) || relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`)) { + return undefined; + } + return resolved; +} + export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { readonly fromGallery = false; @@ -115,7 +195,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { scope: StorageScope.PROFILE, trustBehavior: McpServerTrust.Kind.Trusted, serverDefinitions: plugin.mcpServerDefinitions.map(defs => - defs.map(d => this._toServerDefinition(collectionId, plugin, d)).filter(isDefined)), + defs.map(d => toPluginMcpServerDefinition(collectionId, plugin, d)).filter(isDefined)), order: McpCollectionSortOrder.Plugin, presentation: { origin: manifestURI, @@ -123,11 +203,4 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { }); } - private _toServerDefinition( - collectionId: string, - plugin: IAgentPlugin, - definition: IAgentPluginMcpServerDefinition, - ): McpServerDefinition | undefined { - return toPluginMcpServerDefinition(collectionId, plugin, definition); - } } diff --git a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts index dff6f99b040ffd..18d353edcb65d0 100644 --- a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts @@ -17,14 +17,16 @@ suite('PluginMcpDiscovery', () => { test('interpolates AgentPlugin MCP definitions before creating the launch', () => { 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: '${PLUGIN_ROOT}/server.py', + command: './server.py', args: ['--data', '${PLUGIN_DATA}'], env: { CUSTOM_ROOT: '${PLUGIN_ROOT}' }, + cwd: '${PLUGIN_DATA}/work', }, customization: { type: CustomizationType.McpServer, @@ -35,16 +37,16 @@ suite('PluginMcpDiscovery', () => { }, }; - const server = toPluginMcpServerDefinition('plugin:', { format: PluginFormat.AgentPlugin, uri: pluginUri }, definition); + const server = toPluginMcpServerDefinition('plugin:', { dataDir: pluginDataUri, format: PluginFormat.AgentPlugin, uri: pluginUri }, definition); assert.deepStrictEqual(server?.launch, { type: LaunchTransportType.Stdio, - command: `${pluginUri.fsPath}/server.py`, - args: ['--data', pluginUri.fsPath], - cwd: undefined, + command: './server.py', + args: ['--data', pluginDataUri.fsPath], + cwd: URI.joinPath(pluginDataUri, 'work').fsPath, env: { CUSTOM_ROOT: pluginUri.fsPath, PLUGIN_ROOT: pluginUri.fsPath, - PLUGIN_DATA: pluginUri.fsPath, + PLUGIN_DATA: pluginDataUri.fsPath, }, envFile: undefined, sandbox: undefined, @@ -70,4 +72,29 @@ suite('PluginMcpDiscovery', () => { 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', () => { + 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 = 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}']]); + }); }); From 8cc751e5b431d8ca3203aae10dba856e5766dde6 Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:13:25 -0700 Subject: [PATCH 06/17] added data path in AppData/Roaming/agentPlugins/data/hash --- .../common/plugins/agentPluginServiceImpl.ts | 18 ++++++++++++++---- .../plugins/agentPluginFormatDetection.test.ts | 2 +- .../configuredAgentPluginDiscovery.test.ts | 1 + 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index bf2b4a0b2884f7..83258c07418a32 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -9,6 +9,7 @@ import { Iterable } from '../../../../../base/common/iterator.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { untildify } from '../../../../../base/common/labels.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { stringHash } from '../../../../../base/common/hash.js'; import { Schemas } from '../../../../../base/common/network.js'; import { equals } from '../../../../../base/common/objects.js'; import { autorun, derived, derivedOpts, IObservable, IReader, ISettableObservable, ITransaction, observableFromEvent, ObservablePromise, observableSignal, observableValue, transaction } from '../../../../../base/common/observable.js'; @@ -47,6 +48,7 @@ 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 { ChatConfiguration } from '../constants.js'; import { ContributionEnablementState, EnablementModel, IEnablementModel } from '../enablement.js'; import { AUTOMATION_BLUEPRINT_FILE_SUFFIX, parseAutomationBlueprint } from '../automations/automationBlueprint.js'; @@ -261,6 +263,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements protected readonly _pathService: IPathService, protected readonly _logService: ILogService, protected readonly _workspaceContextService: IWorkspaceContextService, + protected readonly _userDataProfileService: IUserDataProfileService | undefined, ) { super(); } @@ -479,6 +482,9 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements const plugin: PluginEntry = { uri, + dataDir: this._userDataProfileService + ? joinPath(this._userDataProfileService.currentProfile.globalStorageHome, 'agentPlugins', 'data', (stringHash(uri.toString(), 0) >>> 0).toString(16)) + : undefined, format: format.format, label: fromMarketplace?.name ?? manifestName ?? basename(uri), version: pluginVersion, @@ -642,8 +648,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. @@ -816,8 +823,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 { @@ -902,9 +910,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 { @@ -1102,8 +1111,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 { 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 7baa0bdc754972..5828996ef2d5ab 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 f7fb01cc5713a4..6528d47e32a111 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, )); } From aa3a0c5390cdc46843bc73ec5fe23eef956213d3 Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:48:53 -0700 Subject: [PATCH 07/17] updated tests --- .../common/discovery/pluginMcpDiscovery.ts | 43 +++++++++++++------ .../test/common/pluginMcpDiscovery.test.ts | 12 +++--- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 7f0550d303e4e7..e9d785fc11a187 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { hash } from '../../../../../base/common/hash.js'; -import { Disposable, DisposableResourceMap } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableResourceMap, 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 } from '../../../../../base/common/observable.js'; import { isAbsolute, join, normalize, relative, sep } from '../../../../../base/common/path.js'; import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -24,6 +24,7 @@ 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 { IMcpDiscovery } from './mcpDiscovery.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; /** * Prefix used for the {@link McpCollectionDefinition.id | collection id} of @@ -33,14 +34,19 @@ import { IMcpDiscovery } from './mcpDiscovery.js'; */ export { MCP_PLUGIN_COLLECTION_ID_PREFIX } from '../mcpTypes.js'; -export function toPluginMcpServerDefinition( +export async function toPluginMcpServerDefinition( collectionId: string, plugin: Pick, definition: IAgentPluginMcpServerDefinition, -): McpServerDefinition | undefined { + fileService?: IFileService, +): Promise { const { name, defaultCwd } = definition; let configuration = definition.configuration; if (plugin.format === PluginFormat.AgentPlugin) { + if (plugin.dataDir && fileService) { + await fileService.createFolder(plugin.dataDir); + } + const resolvedConfiguration = resolveAgentPluginMcpConfiguration(configuration, plugin.uri.fsPath, plugin.dataDir?.fsPath); if (!resolvedConfiguration) { return undefined; @@ -149,6 +155,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { constructor( @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, + @IFileService private readonly _fileService: IFileService, ) { super(); } @@ -168,11 +175,17 @@ 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); + if (!this._collections.has(plugin.uri)) { + const collectionDisposable = new MutableDisposable(); + 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(); + } + }); } } @@ -184,8 +197,15 @@ 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 serverDefinitions = await Promise.all( + defsObservableValue.map(async d => toPluginMcpServerDefinition(collectionId, plugin, d, this._fileService)) + ); + + const validDefinitions = serverDefinitions.filter(isDefined); + return this._mcpRegistry.registerCollection({ id: collectionId, provenance: McpCollectionProvenance.Plugin, @@ -194,8 +214,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 => toPluginMcpServerDefinition(collectionId, plugin, d)).filter(isDefined)), + serverDefinitions: constObservable(validDefinitions), order: McpCollectionSortOrder.Plugin, presentation: { origin: manifestURI, diff --git a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts index 18d353edcb65d0..bb74cd4a0354e0 100644 --- a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts @@ -15,7 +15,7 @@ import { McpServerTransportType as LaunchTransportType } from '../../common/mcpT suite('PluginMcpDiscovery', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('interpolates AgentPlugin MCP definitions before creating the launch', () => { + 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 = { @@ -37,7 +37,7 @@ suite('PluginMcpDiscovery', () => { }, }; - const server = toPluginMcpServerDefinition('plugin:', { dataDir: pluginDataUri, format: PluginFormat.AgentPlugin, uri: pluginUri }, definition); + const server = await toPluginMcpServerDefinition('plugin:', { dataDir: pluginDataUri, format: PluginFormat.AgentPlugin, uri: pluginUri }, definition); assert.deepStrictEqual(server?.launch, { type: LaunchTransportType.Stdio, command: './server.py', @@ -53,7 +53,7 @@ suite('PluginMcpDiscovery', () => { }); }); - test('does not interpolate non-AgentPlugin MCP definitions', () => { + test('does not interpolate non-AgentPlugin MCP definitions', async () => { const pluginUri = URI.file('/plugins/example'); const definition: IMcpServerDefinition = { name: 'example', @@ -68,12 +68,12 @@ suite('PluginMcpDiscovery', () => { }, }; - const server = toPluginMcpServerDefinition('plugin:', { format: PluginFormat.Copilot, uri: pluginUri }, definition); + 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', () => { + test('does not interpolate AgentPlugin HTTP URLs or headers', async () => { const pluginUri = URI.file('/plugins/example'); const definition: IMcpServerDefinition = { name: 'remote', @@ -92,7 +92,7 @@ suite('PluginMcpDiscovery', () => { }, }; - const server = toPluginMcpServerDefinition('plugin:', { format: PluginFormat.AgentPlugin, uri: pluginUri }, definition); + 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}']]); From 6f852cec4461af8a52bea6a917fbd1e3c1d289d9 Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:31:20 -0700 Subject: [PATCH 08/17] added data folder test --- .../test/common/pluginMcpDiscovery.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts index bb74cd4a0354e0..471295adbb78dc 100644 --- a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts @@ -11,6 +11,7 @@ import { McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTyp 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'; suite('PluginMcpDiscovery', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -97,4 +98,37 @@ suite('PluginMcpDiscovery', () => { 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: 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()); + }); }); From 4548511ab07696b73cd4ddb1fc4ab547acdd1075 Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:23:21 -0700 Subject: [PATCH 09/17] cleanup --- src/vs/platform/agentPlugins/common/pluginParsers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index a6e9c7ec69188e..efdc4a147cd663 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -1302,8 +1302,8 @@ export function parseMcpServerDefinitionMap( uri: definitionURI, customization: makeMcpServerCustomization(definitionURI, name), }; + def = interpolateMcpPluginRoot(def, pluginFsPath, formatConfig.pluginRootTokens, formatConfig.pluginRootEnvVars); if (formatConfig.format !== PluginFormat.AgentPlugin) { - def = interpolateMcpPluginRoot(def, pluginFsPath, formatConfig.pluginRootTokens, formatConfig.pluginRootEnvVars); def = convertBareEnvVarsToVsCodeSyntax(def); } definitions.push(def); From e4cdf1e3d5e87863852f689a9d92273bbeef5dbc Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:24:36 +0200 Subject: [PATCH 10/17] Potential fix for pull request finding 'Avoid creating data directories for HTTP-only MCP servers' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../contrib/mcp/common/discovery/pluginMcpDiscovery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index e9d785fc11a187..1a44eff3142a8f 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -43,7 +43,7 @@ export async function toPluginMcpServerDefinition( const { name, defaultCwd } = definition; let configuration = definition.configuration; if (plugin.format === PluginFormat.AgentPlugin) { - if (plugin.dataDir && fileService) { + if (configuration.type === McpServerType.LOCAL && plugin.dataDir && fileService) { await fileService.createFolder(plugin.dataDir); } From 67aa374709fa9ab8cad00121c898fa33b4c2c612 Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:27:18 -0700 Subject: [PATCH 11/17] refactor: update dataDir type to IObservable and adjust related logic --- .../chat/common/plugins/agentPluginService.ts | 2 +- .../common/plugins/agentPluginServiceImpl.ts | 14 ++++++++--- .../common/discovery/pluginMcpDiscovery.ts | 25 ++++++++++++++----- .../test/common/pluginMcpDiscovery.test.ts | 3 ++- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index 6e2c10cbc59786..ad55dd3f7c1afd 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -38,7 +38,7 @@ export interface IAgentPluginAutomation { export interface IAgentPlugin { readonly uri: URI; /** Persistent data directory used for `${PLUGIN_DATA}` in Agent Plugin MCP configurations. */ - readonly dataDir?: URI; + 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 83258c07418a32..fbbd9cc09ec339 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -49,6 +49,7 @@ import { Extensions, IExtensionFeaturesRegistry, IExtensionFeatureTableRenderer, 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'; @@ -258,6 +259,8 @@ 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, @@ -266,6 +269,9 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements 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; @@ -480,11 +486,13 @@ 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', (stringHash(uri.toString(), 0) >>> 0).toString(16))) + : undefined; + const plugin: PluginEntry = { uri, - dataDir: this._userDataProfileService - ? joinPath(this._userDataProfileService.currentProfile.globalStorageHome, 'agentPlugins', 'data', (stringHash(uri.toString(), 0) >>> 0).toString(16)) - : undefined, + dataDir, format: format.format, label: fromMarketplace?.name ?? manifestName ?? basename(uri), version: pluginVersion, diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 1a44eff3142a8f..fbefe095281c86 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { hash } from '../../../../../base/common/hash.js'; -import { Disposable, DisposableResourceMap, MutableDisposable } 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, constObservable } from '../../../../../base/common/observable.js'; @@ -43,11 +43,12 @@ export async function toPluginMcpServerDefinition( const { name, defaultCwd } = definition; let configuration = definition.configuration; if (plugin.format === PluginFormat.AgentPlugin) { - if (configuration.type === McpServerType.LOCAL && plugin.dataDir && fileService) { - await fileService.createFolder(plugin.dataDir); + const dataDir = plugin.dataDir?.get(); + if (configuration.type === McpServerType.LOCAL && fileService && dataDir) { + await fileService.createFolder(dataDir); } - const resolvedConfiguration = resolveAgentPluginMcpConfiguration(configuration, plugin.uri.fsPath, plugin.dataDir?.fsPath); + const resolvedConfiguration = resolveAgentPluginMcpConfiguration(configuration, plugin.uri.fsPath, dataDir?.fsPath); if (!resolvedConfiguration) { return undefined; } @@ -147,10 +148,16 @@ function resolveAgentPluginCwd(cwd: string | undefined, pluginRoot: string, plug 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, @@ -175,8 +182,14 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { seen.add(plugin.uri); + 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 MutableDisposable(); + const collectionDisposable = new CollectionEntry(dataDirKey); this._collections.set(plugin.uri, collectionDisposable); this.createCollectionState(plugin, servers[0].uri).then(disposable => { diff --git a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts index 471295adbb78dc..1acf553d72a8c8 100644 --- a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { constObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { PluginFormat, type IMcpServerDefinition } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; @@ -38,7 +39,7 @@ suite('PluginMcpDiscovery', () => { }, }; - const server = await toPluginMcpServerDefinition('plugin:', { dataDir: pluginDataUri, format: PluginFormat.AgentPlugin, uri: pluginUri }, definition); + const server = await toPluginMcpServerDefinition('plugin:', { dataDir: constObservable(pluginDataUri), format: PluginFormat.AgentPlugin, uri: pluginUri }, definition); assert.deepStrictEqual(server?.launch, { type: LaunchTransportType.Stdio, command: './server.py', From 7783e4984a289b8fa15af53e5cbb66b6d003b959 Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:53:21 -0700 Subject: [PATCH 12/17] enhance plugin MCP server definition handling for remote environments --- .../common/discovery/pluginMcpDiscovery.ts | 35 ++++++---- .../contrib/mcp/common/mcpSandboxService.ts | 13 +--- .../workbench/contrib/mcp/common/mcpTypes.ts | 13 ++++ .../test/common/pluginMcpDiscovery.test.ts | 65 ++++++++++++++++++- 4 files changed, 104 insertions(+), 22 deletions(-) diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index fbefe095281c86..43442dc828c8b8 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -3,12 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { hash } from '../../../../../base/common/hash.js'; +import { hash, stringHash } from '../../../../../base/common/hash.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, constObservable } from '../../../../../base/common/observable.js'; -import { isAbsolute, join, normalize, relative, sep } from '../../../../../base/common/path.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'; @@ -22,9 +23,11 @@ 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 { 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 @@ -39,16 +42,20 @@ export async function toPluginMcpServerDefinition( plugin: Pick, definition: IAgentPluginMcpServerDefinition, fileService?: IFileService, + remoteEnvironment?: Pick, ): Promise { const { name, defaultCwd } = definition; let configuration = definition.configuration; if (plugin.format === PluginFormat.AgentPlugin) { - const dataDir = plugin.dataDir?.get(); + const dataDir = remoteEnvironment + ? URI.joinPath(remoteEnvironment.globalStorageHome, 'agentPlugins', 'data', (stringHash(plugin.uri.toString(), 0) >>> 0).toString(16)) + : plugin.dataDir?.get(); if (configuration.type === McpServerType.LOCAL && fileService && dataDir) { await fileService.createFolder(dataDir); } - const resolvedConfiguration = resolveAgentPluginMcpConfiguration(configuration, plugin.uri.fsPath, dataDir?.fsPath); + const os = remoteEnvironment?.os ?? OS; + const resolvedConfiguration = resolveAgentPluginMcpConfiguration(configuration, mcpUriToFsPath(plugin.uri, os), dataDir && mcpUriToFsPath(dataDir, os), os); if (!resolvedConfiguration) { return undefined; } @@ -73,6 +80,7 @@ function resolveAgentPluginMcpConfiguration( configuration: IMcpServerConfiguration, pluginRoot: string, pluginData: string | undefined, + os: OperatingSystem, ): IMcpServerConfiguration | undefined { if (configuration.type !== McpServerType.LOCAL) { return configuration; @@ -91,7 +99,7 @@ function resolveAgentPluginMcpConfiguration( return undefined; } const env = { ...(configuration.env ?? {}) }; - const cwd = resolveAgentPluginCwd(configuration.cwd, pluginRoot, pluginData); + const cwd = resolveAgentPluginCwd(configuration.cwd, pluginRoot, pluginData, os); if (cwd === undefined) { return undefined; } @@ -117,7 +125,7 @@ function resolveAgentPluginMcpConfiguration( return local; } -function resolveAgentPluginCwd(cwd: string | undefined, pluginRoot: string, pluginData: string | undefined): string | undefined { +function resolveAgentPluginCwd(cwd: string | undefined, pluginRoot: string, pluginData: string | undefined, os: OperatingSystem): string | undefined { if (cwd === undefined) { return pluginRoot; } @@ -140,9 +148,10 @@ function resolveAgentPluginCwd(cwd: string | undefined, pluginRoot: string, plug if (relativePath.includes('\\')) { return undefined; } - const resolved = normalize(join(root, relativePath)); - const relativeToRoot = relative(normalize(root), resolved); - if (isAbsolute(relativeToRoot) || relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`)) { + 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; @@ -163,6 +172,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, @IFileService private readonly _fileService: IFileService, + @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, ) { super(); } @@ -213,8 +223,11 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { 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; const serverDefinitions = await Promise.all( - defsObservableValue.map(async d => toPluginMcpServerDefinition(collectionId, plugin, d, this._fileService)) + defsObservableValue.map(async d => toPluginMcpServerDefinition(collectionId, plugin, d, this._fileService, remoteEnvironment ?? undefined)) ); const validDefinitions = serverDefinitions.filter(isDefined); diff --git a/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts b/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts index ec87f5570bef47..2242a2443ee574 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 b44f8b3c66df06..35112359526fd7 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 index 1acf553d72a8c8..8a5b655402abd5 100644 --- a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { stringHash } from '../../../../../base/common/hash.js'; 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 { PluginFormat, type IMcpServerDefinition } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; @@ -13,6 +15,7 @@ import { CustomizationType, McpServerStatus } from '../../../../../platform/agen 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(); @@ -115,7 +118,7 @@ suite('PluginMcpDiscovery', () => { const plugin: Parameters[1] = { format: PluginFormat.AgentPlugin, uri: URI.file('/test/plugins/my-plugin'), - dataDir: targetDataDir, + dataDir: constObservable(targetDataDir), } as unknown as Parameters[1]; const definition: Parameters[2] = { @@ -132,4 +135,64 @@ suite('PluginMcpDiscovery', () => { 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 dataDir = URI.joinPath(remoteGlobalStorageHome, 'agentPlugins', 'data', (stringHash(pluginUri.toString(), 0) >>> 0).toString(16)); + + const server = await toPluginMcpServerDefinition('plugin:', { + 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, + }, + }); + }); }); From a08787c80128459ceb83e1c67b412c054e978517 Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:56:49 -0700 Subject: [PATCH 13/17] add logging for MCP collection registration errors in PluginMcpDiscovery --- .../contrib/mcp/common/discovery/pluginMcpDiscovery.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 43442dc828c8b8..4c65071682423d 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -26,6 +26,7 @@ import { IMcpRegistry } from '../mcpRegistryTypes.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 { IRemoteAgentEnvironment } from '../../../../../platform/remote/common/remoteAgentEnvironment.js'; import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; @@ -173,6 +174,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, @IFileService private readonly _fileService: IFileService, @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, + @ILogService private readonly _logService: ILogService, ) { super(); } @@ -208,6 +210,11 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { } 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); + } }); } } From 4367adf134fcec5c5d95bf4daeb92a357f6318dd Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:19:17 -0700 Subject: [PATCH 14/17] enhance PluginMcpDiscovery to handle remote connection state changes and error handling for unavailable remote environments --- .../mcp/common/discovery/pluginMcpDiscovery.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 4c65071682423d..36216d4c6479a2 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../../../../base/common/event.js'; import { hash, stringHash } from '../../../../../base/common/hash.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, constObservable } 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'; @@ -27,6 +28,7 @@ import { mcpUriToFsPath, MCP_PLUGIN_COLLECTION_ID_PREFIX, McpCollectionProvenanc 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'; @@ -180,7 +182,13 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { } 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) { @@ -233,6 +241,9 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { 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)) ); From aaf5d8bda1f657b77e247117a990d72c4ebbe2bb Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:36:32 -0700 Subject: [PATCH 15/17] getAgentPluginDataDirName function for consistent data directory naming --- src/vs/platform/agentPlugins/common/pluginParsers.ts | 7 +++++++ .../contrib/chat/common/plugins/agentPluginServiceImpl.ts | 4 ++-- .../contrib/mcp/common/discovery/pluginMcpDiscovery.ts | 6 +++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index efdc4a147cd663..b69d7f9b0d59b3 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(uri: URI): string { + const sha = new StringSHA1(); + sha.update(uri.toString()); + return sha.digest(); +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index fbbd9cc09ec339..b443336ef0c873 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -9,7 +9,6 @@ import { Iterable } from '../../../../../base/common/iterator.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { untildify } from '../../../../../base/common/labels.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { stringHash } from '../../../../../base/common/hash.js'; import { Schemas } from '../../../../../base/common/network.js'; import { equals } from '../../../../../base/common/objects.js'; import { autorun, derived, derivedOpts, IObservable, IReader, ISettableObservable, ITransaction, observableFromEvent, ObservablePromise, observableSignal, observableValue, transaction } from '../../../../../base/common/observable.js'; @@ -41,6 +40,7 @@ import { readPluginMcpServers, parseMcpServerDefinitionMap, detectPluginFormat, + getAgentPluginDataDirName, type PluginComponent, type IPluginFormatConfig, type IParsedHookGroup, @@ -487,7 +487,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements : undefined; const dataDir = this._currentProfile - ? derived(reader => joinPath(this._currentProfile!.read(reader).globalStorageHome, 'agentPlugins', 'data', (stringHash(uri.toString(), 0) >>> 0).toString(16))) + ? derived(reader => joinPath(this._currentProfile!.read(reader).globalStorageHome, 'agentPlugins', 'data', getAgentPluginDataDirName(uri))) : undefined; const plugin: PluginEntry = { diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 36216d4c6479a2..7f52bec4914a9b 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../../base/common/event.js'; -import { hash, stringHash } from '../../../../../base/common/hash.js'; +import { hash } from '../../../../../base/common/hash.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'; @@ -15,7 +15,7 @@ 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 { PluginFormat } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; +import { getAgentPluginDataDirName, PluginFormat } from '../../../../../platform/agentPlugins/common/pluginParsers.js'; import { McpServerType, type IMcpServerConfiguration, type IMcpStdioServerConfiguration } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { IAgentPlugin, @@ -51,7 +51,7 @@ export async function toPluginMcpServerDefinition( let configuration = definition.configuration; if (plugin.format === PluginFormat.AgentPlugin) { const dataDir = remoteEnvironment - ? URI.joinPath(remoteEnvironment.globalStorageHome, 'agentPlugins', 'data', (stringHash(plugin.uri.toString(), 0) >>> 0).toString(16)) + ? URI.joinPath(remoteEnvironment.globalStorageHome, 'agentPlugins', 'data', getAgentPluginDataDirName(plugin.uri)) : plugin.dataDir?.get(); if (configuration.type === McpServerType.LOCAL && fileService && dataDir) { await fileService.createFolder(dataDir); From fa1383a8311bf61c8ca04270d2885967bbdbc2f6 Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:49:11 -0700 Subject: [PATCH 16/17] use getAgentPluginDataDirName for consistent dataDir generation in tests --- .../contrib/mcp/test/common/pluginMcpDiscovery.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts index 8a5b655402abd5..11625db2f4d705 100644 --- a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts @@ -4,12 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { stringHash } from '../../../../../base/common/hash.js'; 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 { PluginFormat, type IMcpServerDefinition } from '../../../../../platform/agentPlugins/common/pluginParsers.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'; @@ -150,7 +149,7 @@ suite('PluginMcpDiscovery', () => { globalStorageHome: remoteGlobalStorageHome, os: OperatingSystem.Linux, }; - const dataDir = URI.joinPath(remoteGlobalStorageHome, 'agentPlugins', 'data', (stringHash(pluginUri.toString(), 0) >>> 0).toString(16)); + const dataDir = URI.joinPath(remoteGlobalStorageHome, 'agentPlugins', 'data', getAgentPluginDataDirName(pluginUri)); const server = await toPluginMcpServerDefinition('plugin:', { dataDir: constObservable(URI.file('C:/client-only/plugin-data')), From 17e445bd52034e1c7065191072fe881644d5291f Mon Sep 17 00:00:00 2001 From: revallion <10896441+revallion@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:59:38 -0700 Subject: [PATCH 17/17] refactor: update getAgentPluginDataDirName to use identity and enhance IAgentPlugin interface for stable dataDirId --- .../agentPlugins/common/pluginParsers.ts | 4 +- .../chat/common/plugins/agentPluginService.ts | 2 + .../common/plugins/agentPluginServiceImpl.ts | 23 ++++++--- .../common/discovery/pluginMcpDiscovery.ts | 4 +- .../test/common/pluginMcpDiscovery.test.ts | 51 ++++++++++++++++++- 5 files changed, 73 insertions(+), 11 deletions(-) diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index b69d7f9b0d59b3..348db3734a33dc 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -19,9 +19,9 @@ 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(uri: URI): string { +export function getAgentPluginDataDirName(identity: string): string { const sha = new StringSHA1(); - sha.update(uri.toString()); + sha.update(identity); return sha.digest(); } diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index ad55dd3f7c1afd..1be0e0f8214ff5 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -37,6 +37,8 @@ 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; diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index b443336ef0c873..e758ec92273ff9 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -235,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 */ @@ -308,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) { @@ -338,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) { @@ -487,11 +489,12 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements : undefined; const dataDir = this._currentProfile - ? derived(reader => joinPath(this._currentProfile!.read(reader).globalStorageHome, 'agentPlugins', 'data', getAgentPluginDataDirName(uri))) + ? 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), @@ -735,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, }); } @@ -869,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()); @@ -1108,7 +1114,7 @@ const epPlugins = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint(); + private readonly _extensionPlugins = new Map(); private readonly _whenKeys = new Set(); constructor( @@ -1152,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) { @@ -1198,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), }); } @@ -1220,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/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 7f52bec4914a9b..d7f5399095a83a 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -42,7 +42,7 @@ export { MCP_PLUGIN_COLLECTION_ID_PREFIX } from '../mcpTypes.js'; export async function toPluginMcpServerDefinition( collectionId: string, - plugin: Pick, + plugin: Pick, definition: IAgentPluginMcpServerDefinition, fileService?: IFileService, remoteEnvironment?: Pick, @@ -51,7 +51,7 @@ export async function toPluginMcpServerDefinition( let configuration = definition.configuration; if (plugin.format === PluginFormat.AgentPlugin) { const dataDir = remoteEnvironment - ? URI.joinPath(remoteEnvironment.globalStorageHome, 'agentPlugins', 'data', getAgentPluginDataDirName(plugin.uri)) + ? 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); diff --git a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts index 11625db2f4d705..52a66656fb968d 100644 --- a/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/pluginMcpDiscovery.test.ts @@ -149,9 +149,11 @@ suite('PluginMcpDiscovery', () => { globalStorageHome: remoteGlobalStorageHome, os: OperatingSystem.Linux, }; - const dataDir = URI.joinPath(remoteGlobalStorageHome, 'agentPlugins', 'data', getAgentPluginDataDirName(pluginUri)); + 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, @@ -194,4 +196,51 @@ suite('PluginMcpDiscovery', () => { }, }); }); + + 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], + }); + }); });