From 2a41b4f7deb0dc1abd006173a2b0d2f3e2cf14db Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 2 Jul 2026 17:41:08 -0700 Subject: [PATCH] agentHost: sync .vscode/mcp.json servers, enable SDK config discovery (#324129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refine how VS Code-configured MCP servers reach the agent host: - resolveCustomizationRefs now excludes workspace-discovered MCP servers by default, since the agent host discovers workspace `.mcp.json` itself (via enableConfigDiscovery). The exception is `.vscode/mcp.json`, which the SDK does not discover: those servers are synced explicitly, but only when their config resolves without user interaction. Resolution uses IConfigurationResolverService.resolveAsync + ConfigurationResolverExpression and skips servers that reference interactive variables (${input:…}, ${command:…}) or fail to resolve. - Enable `enableConfigDiscovery` when creating/resuming Copilot agent-host sessions so the host discovers workspace `.mcp.json` on its own. - Move MCP_CONFIGURATION_COLLECTION_ID_PREFIX and add McpCollectionDefinition.isWorkspaceDiscovered / isVscodeMcpJson helpers to mcpTypes so the collection-classification logic lives in one place. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/copilot/copilotSessionLauncher.ts | 1 + .../agentHost/agentHostActiveClientService.ts | 4 +- .../agentHost/agentHostLocalCustomizations.ts | 82 ++++++- .../resolveCustomizationRefs.test.ts | 223 +++++++++++++++++- .../discovery/installedMcpServersDiscovery.ts | 4 +- .../workbench/contrib/mcp/common/mcpTypes.ts | 31 ++- 6 files changed, 332 insertions(+), 13 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 7741f953836..00e04a0b6ee 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -515,6 +515,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { clientName: 'vscode', enableMcpApps: true, enableFileHooks: true, + enableConfigDiscovery: true, onPermissionRequest: request => runtime.handlePermissionRequest(request), onUserInputRequest: (request, invocation) => runtime.handleUserInputRequest(request, invocation), onElicitationRequest: context => runtime.handleElicitationRequest(context), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts index 6f3743356da..83f0005b2fe 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts @@ -19,6 +19,7 @@ import { IAgentPluginService } from '../../../common/plugins/agentPluginService. import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { ILanguageModelToolsService, IToolData, IToolSet } from '../../../common/tools/languageModelToolsService.js'; import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; +import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; import { AgentCustomizationSyncProvider } from './agentCustomizationSyncProvider.js'; import { resolveCustomizationRefs } from './agentHostLocalCustomizations.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; @@ -82,6 +83,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo @IInstantiationService private readonly _instantiationService: IInstantiationService, @IFileService private readonly _fileService: IFileService, @IMcpService private readonly _mcpService: IMcpService, + @IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService, @IAgentHostToolSetEnablementService private readonly _toolSetEnablementService: IAgentHostToolSetEnablementService, ) { super(); @@ -101,7 +103,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo const updateCustomizations = async () => { const seq = ++updateSeq; try { - const refs = await resolveCustomizationRefs(this._fileService, this._promptsService, syncProvider, this._agentPluginService, this._mcpService, bundler, sessionType); + const refs = await resolveCustomizationRefs(this._fileService, this._promptsService, syncProvider, this._agentPluginService, this._mcpService, this._configurationResolverService, bundler, sessionType); if (seq !== updateSeq) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts index 817f98ee448..aebc13acc41 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Iterable } from '../../../../../../base/common/iterator.js'; import { isEqualOrParent } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { CustomizationType, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -16,7 +17,10 @@ import { type ICustomizationSyncProvider } from '../../../common/customizationHa import { IAgentPlugin, IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { isContributionEnabled } from '../../../common/enablement.js'; import { MCP_PLUGIN_COLLECTION_ID_PREFIX } from '../../../../mcp/common/discovery/pluginMcpDiscovery.js'; -import { IMcpService, McpServerLaunch, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; +import { IMcpService, McpCollectionDefinition, McpServerLaunch, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; +import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; +import { ConfigurationResolverExpression } from '../../../../../services/configurationResolver/common/configurationResolverExpression.js'; +import { IWorkspaceFolderData } from '../../../../../../platform/workspace/common/workspace.js'; import type { ISyncableMcpServer, SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { isDefined } from '../../../../../../base/common/types.js'; @@ -155,6 +159,53 @@ function launchToMcpServerConfiguration(launch: McpServerLaunch): IMcpServerConf } } +/** + * Attempts to resolve every configuration variable (`${workspaceFolder}`, + * `${env:…}`, …) in an MCP server config without any user interaction, using + * {@link IConfigurationResolverService.resolveAsync}. Returns the resolved + * config, or `undefined` when it cannot be fully resolved without prompting the + * user. + * + * The synced `.mcp.json` is launched by the agent host verbatim, so any + * variable the agent host can't itself expand must be resolved here up front. + * Variables requiring interaction (`${input:…}`, `${command:…}`) or context we + * don't have (e.g. `${workspaceFolder}` outside a folder) cause the server to + * be skipped. + */ +async function resolveConfigurationForSync( + configurationResolverService: IConfigurationResolverService, + folder: IWorkspaceFolderData | undefined, + configuration: IMcpServerConfiguration, +): Promise { + const expr = ConfigurationResolverExpression.parse(configuration); + + // Interactive variables (`${input:…}`, `${command:…}`) can only be resolved + // by prompting the user, so a server referencing them is skipped. This is + // checked up front because `resolveAsync` "resolves" them to their own + // literal text when no value mapping is supplied, which would otherwise + // leave them out of `unresolved()` below. + for (const replacement of expr.unresolved()) { + if (replacement.name === 'input' || replacement.name === 'command') { + return undefined; + } + } + + try { + // Resolves everything that can be resolved without interaction; throws + // when a variable requires context we don't have (e.g. no folder). + await configurationResolverService.resolveAsync(folder, expr); + } catch { + return undefined; + } + + // Any replacement left unresolved would require user interaction. + if (!Iterable.isEmpty(expr.unresolved())) { + return undefined; + } + + return expr.toObject(); +} + /** * Enumerates MCP servers configured directly in VS Code — i.e. those that * are not contributed by an agent plugin — so they can be bundled into the @@ -162,8 +213,15 @@ function launchToMcpServerConfiguration(launch: McpServerLaunch): IMcpServerConf * are already synced via their owning plugin's customization ref. Disabled * servers and servers whose launch cannot be expressed declaratively are * skipped. + * + * Workspace-discovered servers are also excluded by default: the agent host + * discovers workspace `.mcp.json` itself, so syncing them would duplicate. The + * exception is `.vscode/mcp.json`, which the agent host does not discover + * (despite what the SDK's `enableConfigDiscovery` docs imply) — those are + * synced, but only when their config can be resolved without requiring user + * interaction. */ -export function collectNonPluginMcpServers(mcpService: IMcpService): ISyncableMcpServer[] { +export async function collectNonPluginMcpServers(mcpService: IMcpService, configurationResolverService: IConfigurationResolverService): Promise { const result: ISyncableMcpServer[] = []; for (const server of mcpService.servers.get()) { if (server.collection.id.startsWith(MCP_PLUGIN_COLLECTION_ID_PREFIX)) { @@ -172,14 +230,27 @@ export function collectNonPluginMcpServers(mcpService: IMcpService): ISyncableMc if (!isContributionEnabled(server.enablement.get())) { continue; } - const launch = server.readDefinitions().get().server?.launch; + const definitions = server.readDefinitions().get(); + const definition = definitions.server; + const launch = definition?.launch; if (!launch) { continue; } - const configuration = launchToMcpServerConfiguration(launch); + let configuration = launchToMcpServerConfiguration(launch); if (!configuration) { continue; } + const collection = definitions.collection; + if (collection && McpCollectionDefinition.isWorkspaceDiscovered(collection)) { + if (!McpCollectionDefinition.isVscodeMcpJson(collection)) { + continue; + } + const resolved = await resolveConfigurationForSync(configurationResolverService, definition.variableReplacement?.folder, configuration); + if (!resolved) { + continue; + } + configuration = resolved; + } result.push({ name: server.definition.label, configuration }); } return result; @@ -200,6 +271,7 @@ export async function resolveCustomizationRefs( syncProvider: ICustomizationSyncProvider, agentPluginService: IAgentPluginService, mcpService: IMcpService, + configurationResolverService: IConfigurationResolverService, bundler: SyncedCustomizationBundler, sessionType: string, ): Promise { @@ -272,7 +344,7 @@ export async function resolveCustomizationRefs( } const refs: Promise[] = [...pluginRefs.values()]; - const mcpServers = collectNonPluginMcpServers(mcpService); + const mcpServers = await collectNonPluginMcpServers(mcpService, configurationResolverService); if (looseFiles.length > 0 || mcpServers.length > 0) { refs.push(bundler.bundle(looseFiles, mcpServers).then(r => r?.ref)); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts index eebe64cbe24..9ace81a0c43 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts @@ -9,6 +9,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ConfigurationTarget } from '../../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { resolveCustomizationRefs } from '../../../browser/agentSessions/agentHost/agentHostLocalCustomizations.js'; @@ -19,13 +20,39 @@ import { ContributionEnablementState } from '../../../common/enablement.js'; import { type IAgentPlugin, type IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { type IPromptPath, type IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; -import { type IMcpServer, type IMcpService, McpServerLaunch, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; +import { type IMcpServer, type IMcpService, McpCollectionDefinition, McpServerLaunch, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; +import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; +import { ConfigurationResolverExpression } from '../../../../../services/configurationResolver/common/configurationResolverExpression.js'; import { SessionType } from '../../../common/chatSessionsService.js'; function makePromptPath(uri: URI, type: PromptsType, storage: PromptsStorage): IPromptPath { return { uri, type, storage } as IPromptPath; } +/** + * A fake {@link IConfigurationResolverService} whose `resolveAsync` mirrors the + * real service: it resolves the given `${...}` variables from `resolutions` and + * leaves any others (e.g. `${input:…}`) untouched so they remain unresolved. + */ +function makeConfigurationResolverService(resolutions: Record = {}): IConfigurationResolverService { + return { + async resolveAsync(_folder: unknown, config: unknown) { + const expr = ConfigurationResolverExpression.parse(config as object); + for (const replacement of expr.unresolved()) { + if (Object.prototype.hasOwnProperty.call(resolutions, replacement.id)) { + expr.resolve(replacement, resolutions[replacement.id]); + } else if (replacement.name === 'input' || replacement.name === 'command') { + // Mirror the real resolver: without a value mapping, interactive + // variables "resolve" to their own literal text, dropping out of + // `unresolved()`. + expr.resolve(replacement, replacement.id); + } + } + return expr.toObject(); + }, + } as unknown as IConfigurationResolverService; +} + function makePromptsService(files: ReadonlyMap): IPromptsService { return { async listPromptFilesForStorage(type: PromptsType, storage: PromptsStorage): Promise { @@ -72,9 +99,10 @@ function makeFileService(stats: ReadonlyMap = new Map } as unknown as IFileService; } -function makeMcpServer(options: { id: string; collectionId: string; label?: string; enabled?: boolean; launch?: McpServerLaunch | undefined }): IMcpServer { - const { id, collectionId, label = id, enabled = true, launch } = options; - const definitions = observableValue('definitions', { server: launch ? { launch } : undefined, collection: undefined }); +function makeMcpServer(options: { id: string; collectionId: string; label?: string; enabled?: boolean; launch?: McpServerLaunch | undefined; configTarget?: ConfigurationTarget }): IMcpServer { + const { id, collectionId, label = id, enabled = true, launch, configTarget = ConfigurationTarget.USER } = options; + const collection = { id: collectionId, label: collectionId, order: 0, configTarget } as unknown as McpCollectionDefinition; + const definitions = observableValue('definitions', { server: launch ? { launch } : undefined, collection }); return { definition: { id, label }, collection: { id: collectionId, label: collectionId, order: 0 }, @@ -100,6 +128,26 @@ const stdioLaunch: McpServerLaunch = { sandbox: undefined, }; +const stdioLaunchWithInput: McpServerLaunch = { + type: McpServerTransportType.Stdio, + command: 'my-server', + args: ['--token', '${input:token}'], + env: {}, + envFile: undefined, + cwd: undefined, + sandbox: undefined, +}; + +const stdioLaunchWithFolder: McpServerLaunch = { + type: McpServerTransportType.Stdio, + command: 'my-server', + args: ['--root', '${workspaceFolder}'], + env: {}, + envFile: undefined, + cwd: undefined, + sandbox: undefined, +}; + type LocalSyncableFile = { readonly uri: URI; readonly type: PromptsType }; class FakeBundler { @@ -133,6 +181,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), makeMcpService(), + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -162,6 +211,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(new Set([disabled.toString()])), makeAgentPluginService(), makeMcpService(), + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -184,6 +234,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), makeMcpService(), + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -211,6 +262,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(new Set([builtin.toString()])), makeAgentPluginService(), makeMcpService(), + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -230,6 +282,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService([makePlugin(pluginUri, { label: 'MCP Only', mcpServers: 1 })]), makeMcpService(), + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -248,6 +301,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService([makePlugin(pluginUri, { enabled: false, mcpServers: 1 })]), makeMcpService(), + makeConfigurationResolverService(), new FakeBundler() as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -265,6 +319,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService([makePlugin(pluginUri, { enabled: false })]), makeMcpService(), + makeConfigurationResolverService(), new FakeBundler() as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -279,6 +334,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(new Set([pluginUri.toString()])), makeAgentPluginService([makePlugin(pluginUri, { mcpServers: 1 })]), makeMcpService(), + makeConfigurationResolverService(), new FakeBundler() as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -297,6 +353,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService([makePlugin(pluginUri, { label: 'Combined', mcpServers: 2 })]), makeMcpService(), + makeConfigurationResolverService(), new FakeBundler() as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -313,6 +370,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), makeMcpService(), + makeConfigurationResolverService(), new FakeBundler() as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -333,6 +391,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), mcpService, + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -357,6 +416,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), mcpService, + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -378,10 +438,165 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), mcpService, + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); assert.strictEqual(bundler.received.length, 0); }); + + test('excludes workspace-discovered `.mcp.json` servers (the agent host discovers those itself)', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'wsdot.srv', collectionId: 'workspace-dot-mcp.0', label: 'srv', launch: stdioLaunch, configTarget: ConfigurationTarget.WORKSPACE_FOLDER }), + ]); + + await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 0); + }); + + test('excludes `.code-workspace` configured servers', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'wscfg.srv', collectionId: 'mcp.config.workspace', label: 'srv', launch: stdioLaunch, configTarget: ConfigurationTarget.WORKSPACE }), + ]); + + await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 0); + }); + + test('syncs `.vscode/mcp.json` servers that resolve without user interaction', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'mcp.config.ws0.my-server', collectionId: 'mcp.config.ws0', label: 'my-server', launch: stdioLaunch, configTarget: ConfigurationTarget.WORKSPACE_FOLDER }), + ]); + + const refs = await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 1); + assert.deepStrictEqual(bundler.receivedMcp[0], [ + { name: 'my-server', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined, envFile: undefined, cwd: undefined } }, + ]); + assert.strictEqual(refs.length, 1); + assert.strictEqual(refs[0].name, 'Open Plugin'); + }); + + test('excludes `.vscode/mcp.json` servers with variables that require interaction (e.g. ${input:…})', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'mcp.config.ws0.needs-input', collectionId: 'mcp.config.ws0', label: 'needs-input', launch: stdioLaunchWithInput, configTarget: ConfigurationTarget.WORKSPACE_FOLDER }), + ]); + + await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 0); + }); + + test('syncs `.vscode/mcp.json` servers after resolving non-interactive variables (e.g. ${workspaceFolder})', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'mcp.config.ws0.folder', collectionId: 'mcp.config.ws0', label: 'folder-server', launch: stdioLaunchWithFolder, configTarget: ConfigurationTarget.WORKSPACE_FOLDER }), + ]); + + const refs = await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService({ '${workspaceFolder}': '/ws' }), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 1); + assert.deepStrictEqual(bundler.receivedMcp[0], [ + { name: 'folder-server', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--root', '/ws'], env: undefined, envFile: undefined, cwd: undefined } }, + ]); + assert.strictEqual(refs.length, 1); + }); + + test('excludes `.vscode/mcp.json` servers when variable resolution throws', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'mcp.config.ws0.folder', collectionId: 'mcp.config.ws0', label: 'folder-server', launch: stdioLaunchWithFolder, configTarget: ConfigurationTarget.WORKSPACE_FOLDER }), + ]); + const throwingResolver = { + async resolveAsync() { throw new Error('no workspace folder'); }, + } as unknown as IConfigurationResolverService; + + await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + throwingResolver, + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 0); + }); + + test('still syncs extension-contributed servers (workspace scope, user config target)', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'ext.foo.srv', collectionId: 'ext.foo', label: 'srv', launch: stdioLaunch, configTarget: ConfigurationTarget.USER }), + ]); + + const refs = await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 1); + assert.deepStrictEqual(bundler.receivedMcp[0].map(s => s.name), ['srv']); + assert.strictEqual(refs.length, 1); + }); }); diff --git a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts index 5731e1e3b0e..0b0ed1c1180 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts @@ -18,7 +18,7 @@ import { IWorkbenchLocalMcpServer } from '../../../../services/mcp/common/mcpWor import { getMcpServerMapping } from '../mcpConfigFileUtils.js'; import { mcpConfigurationSection } from '../mcpConfiguration.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { IMcpConfigPath, IMcpWorkbenchService, McpCollectionDefinition, McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust } from '../mcpTypes.js'; +import { IMcpConfigPath, IMcpWorkbenchService, MCP_CONFIGURATION_COLLECTION_ID_PREFIX, McpCollectionDefinition, McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; interface CollectionState extends IDisposable { @@ -77,7 +77,7 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc const config = server.config; const mcpConfigPath = await mcpConfigPathPromise; - const collectionId = `mcp.config.${mcpConfigPath ? mcpConfigPath.id : 'unknown'}`; + const collectionId = `${MCP_CONFIGURATION_COLLECTION_ID_PREFIX}${mcpConfigPath ? mcpConfigPath.id : 'unknown'}`; let definitions = collections.get(collectionId); if (!definitions) { diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 6ca7dc0d6a7..f538b653055 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -27,7 +27,7 @@ import { IGalleryMcpServer, IGalleryMcpServerConfiguration, IInstallableMcpServe import { IMcpDevModeConfig, IMcpSandboxConfiguration, IMcpServerConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { IWorkspaceFolder, IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; -import { IWorkbenchLocalMcpServer, IWorkbencMcpServerInstallOptions } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; +import { IWorkbenchLocalMcpServer, IWorkbencMcpServerInstallOptions, WORKSPACE_FOLDER_CONFIG_ID_PREFIX } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { ContributionEnablementState, IEnablementModel } from '../../chat/common/enablement.js'; import { ToolProgress } from '../../chat/common/tools/languageModelToolsService.js'; import { IMcpServerSamplingConfiguration } from './mcpConfiguration.js'; @@ -37,6 +37,14 @@ import { UriTemplate } from '../../../../base/common/uriTemplate.js'; export const extensionMcpCollectionPrefix = 'ext.'; +/** + * Prefix of the collection id used for MCP servers configured via the various + * `mcp.json`-style config files (user, remote user, workspace, and + * `.vscode/mcp.json` workspace-folder configs). The suffix is the + * {@link IMcpConfigPath.id} of the originating config path. + */ +export const MCP_CONFIGURATION_COLLECTION_ID_PREFIX = 'mcp.config.'; + export function extensionPrefixedIdentifier(identifier: ExtensionIdentifier, id: string): string { return ExtensionIdentifier.toKey(identifier) + '/' + id; } @@ -119,6 +127,27 @@ export namespace McpCollectionDefinition { && a.trustBehavior === b.trustBehavior && objectsEqual(a.sandbox, b.sandbox); } + + /** + * Returns `true` when the collection was discovered from the workspace (its + * config target is the workspace or a workspace folder). This is + * intentionally based on the config target and not the storage scope: + * extension-contributed collections use a workspace storage scope but are + * configured at the user level, so they are not workspace-discovered. + */ + export function isWorkspaceDiscovered(collection: McpCollectionDefinition): boolean { + return collection.configTarget === ConfigurationTarget.WORKSPACE + || collection.configTarget === ConfigurationTarget.WORKSPACE_FOLDER; + } + + /** + * Returns `true` when the collection originates from a `.vscode/mcp.json` + * workspace-folder config, identified by its collection id prefix (the + * shared `mcp.config.` prefix plus the workspace-folder config id). + */ + export function isVscodeMcpJson(collection: McpCollectionDefinition): boolean { + return collection.id.startsWith(`${MCP_CONFIGURATION_COLLECTION_ID_PREFIX}${WORKSPACE_FOLDER_CONFIG_ID_PREFIX}`); + } } export interface McpServerDefinition {