mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-11 14:04:49 -05:00
agentHost: sync .vscode/mcp.json servers, enable SDK config discovery (#324129)
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>
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<IMcpServerConfiguration | undefined> {
|
||||
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<ISyncableMcpServer[]> {
|
||||
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<ClientPluginCustomization[]> {
|
||||
@@ -272,7 +344,7 @@ export async function resolveCustomizationRefs(
|
||||
}
|
||||
|
||||
const refs: Promise<ClientPluginCustomization | undefined>[] = [...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));
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {}): 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<string, readonly IPromptPath[]>): IPromptsService {
|
||||
return {
|
||||
async listPromptFilesForStorage(type: PromptsType, storage: PromptsStorage): Promise<readonly IPromptPath[]> {
|
||||
@@ -72,9 +99,10 @@ function makeFileService(stats: ReadonlyMap<string, { mtime: number }> = 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user