Skip to content

Commit 458154a

Browse files
authored
mcp: button/command for adding MCP servers (#243675)
An editor content button is shown on mcp.json to add a server. Additionally, the command is accessible on the command palette and when invoked from there will prompt the user to pick where to save their config. With registry support I envision the "Select Server Type" would list servers in addition to letting users provide their own. Closes #243617
1 parent 910ab9a commit 458154a

File tree

6 files changed

+215
-12
lines changed

6 files changed

+215
-12
lines changed

src/vs/platform/configuration/common/configuration.ts

+24
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { assertNever } from '../../../base/common/assert.js';
67
import { IStringDictionary } from '../../../base/common/collections.js';
78
import { Event } from '../../../base/common/event.js';
89
import * as types from '../../../base/common/types.js';
@@ -103,6 +104,29 @@ export interface IConfigurationValue<T> {
103104
readonly overrideIdentifiers?: string[];
104105
}
105106

107+
export function getConfigValueInTarget<T>(configValue: IConfigurationValue<T>, scope: ConfigurationTarget): T | undefined {
108+
switch (scope) {
109+
case ConfigurationTarget.APPLICATION:
110+
return configValue.applicationValue;
111+
case ConfigurationTarget.USER:
112+
return configValue.userValue;
113+
case ConfigurationTarget.USER_LOCAL:
114+
return configValue.userLocalValue;
115+
case ConfigurationTarget.USER_REMOTE:
116+
return configValue.userRemoteValue;
117+
case ConfigurationTarget.WORKSPACE:
118+
return configValue.workspaceValue;
119+
case ConfigurationTarget.WORKSPACE_FOLDER:
120+
return configValue.workspaceFolderValue;
121+
case ConfigurationTarget.DEFAULT:
122+
return configValue.defaultValue;
123+
case ConfigurationTarget.MEMORY:
124+
return configValue.memoryValue;
125+
default:
126+
assertNever(scope);
127+
}
128+
}
129+
106130
export function isConfigured<T>(configValue: IConfigurationValue<T>): configValue is IConfigurationValue<T> & { value: T } {
107131
return configValue.applicationValue !== undefined ||
108132
configValue.userValue !== undefined ||

src/vs/platform/mcp/common/mcpPlatformTypes.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
export interface IMcpConfiguration {
7-
inputs: unknown[];
7+
inputs?: unknown[];
88
/** @deprecated Only for rough cross-compat with other formats */
99
mcpServers?: Record<string, IMcpConfigurationStdio>;
10-
servers: Record<string, IMcpConfigurationStdio | IMcpConfigurationSSE>;
10+
servers?: Record<string, IMcpConfigurationStdio | IMcpConfigurationSSE>;
1111
}
1212

13+
export type McpConfigurationServer = IMcpConfigurationStdio | IMcpConfigurationSSE;
14+
1315
export interface IMcpConfigurationStdio {
1416
type?: 'stdio';
1517
command: string;

src/vs/workbench/contrib/mcp/browser/mcp.contribution.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,26 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { registerAction2 } from '../../../../platform/actions/common/actions.js';
67
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
78
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
89
import * as jsonContributionRegistry from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js';
910
import { Registry } from '../../../../platform/registry/common/platform.js';
1011
import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
1112
import { mcpSchemaId } from '../../../services/configuration/common/configuration.js';
1213
import { ConfigMcpDiscovery } from '../common/discovery/configMcpDiscovery.js';
14+
import { ExtensionMcpDiscovery } from '../common/discovery/extensionMcpDiscovery.js';
1315
import { mcpDiscoveryRegistry } from '../common/discovery/mcpDiscovery.js';
1416
import { RemoteNativeMpcDiscovery } from '../common/discovery/nativeMcpRemoteDiscovery.js';
1517
import { mcpServerSchema } from '../common/mcpConfiguration.js';
18+
import { McpContextKeysController } from '../common/mcpContextKeys.js';
1619
import { McpRegistry } from '../common/mcpRegistry.js';
1720
import { IMcpRegistry } from '../common/mcpRegistryTypes.js';
1821
import { McpService } from '../common/mcpService.js';
1922
import { IMcpService } from '../common/mcpTypes.js';
23+
import { AddConfigurationAction, ListMcpServerCommand, MCPServerActionRendering, McpServerOptionsCommand, ResetMcpCachedTools, ResetMcpTrustCommand } from './mcpCommands.js';
2024
import { McpDiscovery } from './mcpDiscovery.js';
2125

22-
import { MCPServerActionRendering, ListMcpServerCommand, ResetMcpTrustCommand, McpServerOptionsCommand, ResetMcpCachedTools } from './mcpCommands.js';
23-
import { registerAction2 } from '../../../../platform/actions/common/actions.js';
24-
import { McpContextKeysController } from '../common/mcpContextKeys.js';
25-
import { ExtensionMcpDiscovery } from '../common/discovery/extensionMcpDiscovery.js';
26-
2726
registerSingleton(IMcpRegistry, McpRegistry, InstantiationType.Delayed);
2827
registerSingleton(IMcpService, McpService, InstantiationType.Delayed);
2928

@@ -38,6 +37,8 @@ registerAction2(ListMcpServerCommand);
3837
registerAction2(McpServerOptionsCommand);
3938
registerAction2(ResetMcpTrustCommand);
4039
registerAction2(ResetMcpCachedTools);
40+
registerAction2(AddConfigurationAction);
41+
4142
registerWorkbenchContribution2('mcpActionRendering', MCPServerActionRendering, WorkbenchPhase.BlockRestore);
4243

4344
const jsonRegistry = <jsonContributionRegistry.IJSONContributionRegistry>Registry.as(jsonContributionRegistry.Extensions.JSONContribution);

src/vs/workbench/contrib/mcp/browser/mcpCommands.ts

+176-1
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,31 @@ import { Event } from '../../../../base/common/event.js';
1010
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
1111
import { autorun, derived } from '../../../../base/common/observable.js';
1212
import { ThemeIcon } from '../../../../base/common/themables.js';
13+
import { URI } from '../../../../base/common/uri.js';
14+
import { generateUuid } from '../../../../base/common/uuid.js';
1315
import { ILocalizedString, localize, localize2 } from '../../../../nls.js';
1416
import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js';
1517
import { MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';
1618
import { Action2, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js';
1719
import { ICommandService } from '../../../../platform/commands/common/commands.js';
20+
import { ConfigurationTarget, getConfigValueInTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
1821
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
1922
import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
23+
import { IMcpConfiguration, IMcpConfigurationSSE } from '../../../../platform/mcp/common/mcpPlatformTypes.js';
2024
import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
2125
import { spinningLoading } from '../../../../platform/theme/common/iconRegistry.js';
26+
import { IWorkspace, IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
27+
import { ActiveEditorContext, ResourceContextKey } from '../../../common/contextkeys.js';
2228
import { IWorkbenchContribution } from '../../../common/contributions.js';
29+
import { IJSONEditingService } from '../../../services/configuration/common/jsonEditing.js';
30+
import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js';
2331
import { ChatContextKeys } from '../../chat/common/chatContextKeys.js';
2432
import { ChatMode } from '../../chat/common/constants.js';
33+
import { TEXT_FILE_EDITOR_ID } from '../../files/common/files.js';
34+
import { IMcpConfigurationStdio } from '../common/mcpConfiguration.js';
2535
import { McpContextKeys } from '../common/mcpContextKeys.js';
2636
import { IMcpRegistry } from '../common/mcpRegistryTypes.js';
27-
import { LazyCollectionState, IMcpServer, IMcpService, McpConnectionState, McpServerToolsState } from '../common/mcpTypes.js';
37+
import { IMcpServer, IMcpService, LazyCollectionState, McpConnectionState, McpServerToolsState } from '../common/mcpTypes.js';
2838

2939
// acroynms do not get localized
3040
const category: ILocalizedString = {
@@ -357,3 +367,168 @@ export class ResetMcpCachedTools extends Action2 {
357367
mcpService.resetCaches();
358368
}
359369
}
370+
371+
export class AddConfigurationAction extends Action2 {
372+
static readonly ID = 'workbench.mcp.addConfiguration';
373+
374+
constructor() {
375+
super({
376+
id: AddConfigurationAction.ID,
377+
title: localize2('mcp.addConfiguration', "Add Server..."),
378+
metadata: {
379+
description: localize2('mcp.addConfiguration.description', "Installs a new Model Context protocol to the mcp.json settings"),
380+
},
381+
category,
382+
f1: true,
383+
menu: {
384+
id: MenuId.EditorContent,
385+
when: ContextKeyExpr.and(
386+
ContextKeyExpr.regex(ResourceContextKey.Path.key, /\.vscode[/\\]mcp\.json$/),
387+
ActiveEditorContext.isEqualTo(TEXT_FILE_EDITOR_ID)
388+
)
389+
}
390+
});
391+
}
392+
393+
private async getServerType(quickInputService: IQuickInputService): Promise<{ id: 'stdio' | 'sse' } | undefined> {
394+
return quickInputService.pick([
395+
{ id: 'stdio', label: localize('mcp.serverType.command', "Command (stdio)"), description: localize('mcp.serverType.command.description', "Run a local command that implements the MCP protocol") },
396+
{ id: 'sse', label: localize('mcp.serverType.http', "HTTP (server-sent events)"), description: localize('mcp.serverType.http.description', "Connect to a remote HTTP server that implements the MCP protocol") }
397+
], {
398+
title: localize('mcp.serverType.title', "Select Server Type"),
399+
placeHolder: localize('mcp.serverType.placeholder', "Choose the type of MCP server to add")
400+
}) as Promise<{ id: 'stdio' | 'sse' } | undefined>;
401+
}
402+
403+
private async getStdioConfig(quickInputService: IQuickInputService): Promise<IMcpConfigurationStdio | undefined> {
404+
const command = await quickInputService.input({
405+
title: localize('mcp.command.title', "Enter Command"),
406+
placeHolder: localize('mcp.command.placeholder', "Command to run (with optional arguments)"),
407+
ignoreFocusLost: true,
408+
});
409+
410+
if (!command) {
411+
return undefined;
412+
}
413+
414+
// Split command into command and args, handling quotes
415+
const parts = command.match(/(?:[^\s"]+|"[^"]*")+/g)!;
416+
return {
417+
type: 'stdio',
418+
command: parts[0].replace(/"/g, ''),
419+
args: parts.slice(1).map(arg => arg.replace(/"/g, ''))
420+
};
421+
}
422+
423+
private async getSSEConfig(quickInputService: IQuickInputService): Promise<IMcpConfigurationSSE | undefined> {
424+
const url = await quickInputService.input({
425+
title: localize('mcp.url.title', "Enter Server URL"),
426+
placeHolder: localize('mcp.url.placeholder', "URL of the MCP server (e.g., http://localhost:3000)"),
427+
ignoreFocusLost: true,
428+
});
429+
430+
if (!url) {
431+
return undefined;
432+
}
433+
434+
return {
435+
type: 'sse',
436+
url
437+
};
438+
}
439+
440+
private async getServerId(quickInputService: IQuickInputService): Promise<string | undefined> {
441+
const suggestedId = `my-mcp-server-${generateUuid().split('-')[0]}`;
442+
const id = await quickInputService.input({
443+
title: localize('mcp.serverId.title', "Enter Server ID"),
444+
placeHolder: localize('mcp.serverId.placeholder', "Unique identifier for this server"),
445+
value: suggestedId,
446+
ignoreFocusLost: true,
447+
});
448+
449+
return id;
450+
}
451+
452+
private async getConfigurationTarget(quickInputService: IQuickInputService, workspace: IWorkspace, isInRemote: boolean): Promise<ConfigurationTarget | undefined> {
453+
const options: (IQuickPickItem & { target: ConfigurationTarget })[] = [
454+
{ target: ConfigurationTarget.USER, label: localize('mcp.target.user', "User Settings"), description: localize('mcp.target.user.description', "Available in all workspaces") }
455+
];
456+
457+
if (isInRemote) {
458+
options.push({ target: ConfigurationTarget.USER_REMOTE, label: localize('mcp.target.remote', "Remote Settings"), description: localize('mcp.target..remote.description', "Available on this remote machine") });
459+
}
460+
461+
if (workspace.folders.length > 0) {
462+
options.push({ target: ConfigurationTarget.WORKSPACE, label: localize('mcp.target.workspace', "Workspace Settings"), description: localize('mcp.target.workspace.description', "Available in this workspace") });
463+
}
464+
465+
if (options.length === 1) {
466+
return options[0].target;
467+
}
468+
469+
470+
const targetPick = await quickInputService.pick(options, {
471+
title: localize('mcp.target.title', "Choose where to save the configuration"),
472+
});
473+
474+
return targetPick?.target;
475+
}
476+
477+
async run(accessor: ServicesAccessor, configUri?: string): Promise<void> {
478+
const quickInputService = accessor.get(IQuickInputService);
479+
const configurationService = accessor.get(IConfigurationService);
480+
const jsonEditingService = accessor.get(IJSONEditingService);
481+
const workspaceService = accessor.get(IWorkspaceContextService);
482+
const environmentService = accessor.get(IWorkbenchEnvironmentService);
483+
484+
// Step 1: Choose server type
485+
const serverType = await this.getServerType(quickInputService);
486+
if (!serverType) {
487+
return;
488+
}
489+
490+
// Step 2: Get server details based on type
491+
const serverConfig = await (serverType.id === 'stdio'
492+
? this.getStdioConfig(quickInputService)
493+
: this.getSSEConfig(quickInputService));
494+
495+
if (!serverConfig) {
496+
return;
497+
}
498+
499+
// Step 3: Get server ID
500+
const serverId = await this.getServerId(quickInputService);
501+
if (!serverId) {
502+
return;
503+
}
504+
505+
// Step 4: Choose configuration target if no configUri provided
506+
let target: ConfigurationTarget | undefined;
507+
const workspace = workspaceService.getWorkspace();
508+
if (!configUri) {
509+
target = await this.getConfigurationTarget(quickInputService, workspace, !!environmentService.remoteAuthority);
510+
if (!target) {
511+
return;
512+
}
513+
}
514+
515+
// Step 5: Update configuration
516+
const writeToUriDirect = configUri
517+
? URI.parse(configUri)
518+
: target === ConfigurationTarget.WORKSPACE && workspace.folders.length === 1
519+
? URI.joinPath(workspace.folders[0].uri, '.vscode', 'mcp.json')
520+
: undefined;
521+
522+
if (writeToUriDirect) {
523+
await jsonEditingService.write(writeToUriDirect, [{
524+
path: ['servers', serverId],
525+
value: serverConfig
526+
}], true);
527+
} else {
528+
const settings: IMcpConfiguration = { ...getConfigValueInTarget(configurationService.inspect<IMcpConfiguration>('mcp'), target!) };
529+
settings.servers ??= {};
530+
settings.servers[serverId] = serverConfig;
531+
await configurationService.updateValue('mcp', settings, target!);
532+
}
533+
}
534+
}

src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { mcpSchemaId } from '../../../services/configuration/common/configuratio
1010
import { inputsSchema } from '../../../services/configurationResolver/common/configurationResolverSchema.js';
1111
import { IExtensionPointDescriptor } from '../../../services/extensions/common/extensionsRegistry.js';
1212

13-
export type { IMcpConfigurationStdio, IMcpConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js';
13+
export type { McpConfigurationServer, IMcpConfigurationStdio, IMcpConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js';
1414

1515
const mcpActivationEventPrefix = 'onMcpCollection:';
1616

src/vs/workbench/services/configuration/common/configurationEditing.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,7 @@ export class ConfigurationEditing {
531531

532532
if (operation.workspaceStandAloneConfigurationKey) {
533533
// Global launches are not supported
534-
if ((operation.workspaceStandAloneConfigurationKey !== TASKS_CONFIGURATION_KEY) && (target === EditableConfigurationTarget.USER_LOCAL || target === EditableConfigurationTarget.USER_REMOTE)) {
534+
if ((operation.workspaceStandAloneConfigurationKey !== TASKS_CONFIGURATION_KEY) && (operation.workspaceStandAloneConfigurationKey !== MCP_CONFIGURATION_KEY) && (target === EditableConfigurationTarget.USER_LOCAL || target === EditableConfigurationTarget.USER_REMOTE)) {
535535
throw this.toConfigurationEditingError(ConfigurationEditingErrorCode.ERROR_INVALID_USER_TARGET, target, operation);
536536
}
537537
}
@@ -590,15 +590,16 @@ export class ConfigurationEditing {
590590
const resource = this.getConfigurationFileResource(target, key, standaloneConfigurationMap[key], overrides.resource, undefined);
591591

592592
// Check for prefix
593+
const keyRemainsNested = this.isWorkspaceConfigurationResource(resource) || resource?.fsPath === this.userDataProfileService.currentProfile.settingsResource.fsPath;
593594
if (config.key === key) {
594-
const jsonPath = this.isWorkspaceConfigurationResource(resource) ? [key] : [];
595+
const jsonPath = keyRemainsNested ? [key] : [];
595596
return { key: jsonPath[jsonPath.length - 1], jsonPath, value: config.value, resource: resource ?? undefined, workspaceStandAloneConfigurationKey: key, target };
596597
}
597598

598599
// Check for prefix.<setting>
599600
const keyPrefix = `${key}.`;
600601
if (config.key.indexOf(keyPrefix) === 0) {
601-
const jsonPath = this.isWorkspaceConfigurationResource(resource) ? [key, config.key.substr(keyPrefix.length)] : [config.key.substr(keyPrefix.length)];
602+
const jsonPath = keyRemainsNested ? [key, config.key.substr(keyPrefix.length)] : [config.key.substr(keyPrefix.length)];
602603
return { key: jsonPath[jsonPath.length - 1], jsonPath, value: config.value, resource: resource ?? undefined, workspaceStandAloneConfigurationKey: key, target };
603604
}
604605
}

0 commit comments

Comments
 (0)