implement mcp server gallery view (#248926)

* first cut

* change schema and types

* show mcp servers in extensions view

* implement install and uninstall mcp server

* move imports

* fix layer check
This commit is contained in:
Sandeep Somavarapu
2025-05-14 16:54:41 +02:00
committed by GitHub
parent eef034e941
commit 6f5a7f83b8
31 changed files with 2959 additions and 15 deletions

View File

@@ -342,6 +342,7 @@
"--vscode-editorWidget-foreground",
"--vscode-editorWidget-resizeBorder",
"--vscode-errorForeground",
"--vscode-extension-border",
"--vscode-extensionBadge-remoteBackground",
"--vscode-extensionBadge-remoteForeground",
"--vscode-extensionButton-background",
@@ -956,4 +957,4 @@
"--monaco-editor-warning-decoration",
"--animation-opacity"
]
}
}

View File

@@ -0,0 +1,205 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancellationToken } from '../../../base/common/cancellation.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { Schemas } from '../../../base/common/network.js';
import { dirname, joinPath } from '../../../base/common/resources.js';
import { uppercaseFirstLetter } from '../../../base/common/strings.js';
import { URI } from '../../../base/common/uri.js';
import { IConfigurationService } from '../../configuration/common/configuration.js';
import { IFileService } from '../../files/common/files.js';
import { ILogService } from '../../log/common/log.js';
import { IProductService } from '../../product/common/productService.js';
import { asJson, asText, IRequestService } from '../../request/common/request.js';
import { IGalleryMcpServer, IMcpGalleryService, IMcpServerManifest, IQueryOptions, mcpGalleryServiceUrlConfig, PackageType } from './mcpManagement.js';
interface IRawGalleryMcpServer {
readonly id: string;
readonly name: string;
readonly description: string;
readonly displayName?: string;
readonly repository: {
readonly url: string;
readonly source: string;
};
readonly version_detail: {
readonly version: string;
readonly releaseData: string;
readonly is_latest: boolean;
};
readonly readmeUrl: string;
readonly publisher?: {
readonly displayName: string;
readonly url: string;
readonly is_verified: boolean;
};
readonly package_types?: readonly PackageType[];
}
type RawGalleryMcpServerManifest = IRawGalleryMcpServer & IMcpServerManifest;
export class McpGalleryService extends Disposable implements IMcpGalleryService {
_serviceBrand: undefined;
constructor(
@IConfigurationService private readonly configurationService: IConfigurationService,
@IRequestService private readonly requestService: IRequestService,
@IFileService private readonly fileService: IFileService,
@IProductService private readonly productService: IProductService,
@ILogService private readonly logService: ILogService,
) {
super();
}
async query(options?: IQueryOptions, token: CancellationToken = CancellationToken.None): Promise<IGalleryMcpServer[]> {
let result = await this.fetchGallery(token);
if (options?.text) {
const searchText = options.text.toLowerCase();
result = result.filter(item => item.name.toLowerCase().includes(searchText) || item.description.toLowerCase().includes(searchText));
}
const galleryServers: IGalleryMcpServer[] = [];
for (const item of result) {
galleryServers.push(this.toGalleryMcpServer(item));
}
return galleryServers;
}
async getManifest(gallery: IGalleryMcpServer, token: CancellationToken): Promise<IMcpServerManifest> {
const uri = URI.parse(gallery.manifestUrl);
if (uri.scheme === Schemas.file) {
try {
const content = await this.fileService.readFile(uri);
const data = content.value.toString();
return JSON.parse(data);
} catch (error) {
this.logService.error(`Failed to read file from ${uri}: ${error}`);
}
}
const context = await this.requestService.request({
type: 'GET',
url: gallery.manifestUrl,
}, token);
const result = await asJson<RawGalleryMcpServerManifest>(context);
if (!result) {
throw new Error(`Failed to fetch manifest from ${gallery.manifestUrl}`);
}
return {
packages: result.packages,
remotes: result.remotes,
};
}
async getReadme(readmeUrl: string, token: CancellationToken): Promise<string> {
const uri = URI.parse(readmeUrl);
if (uri.scheme === Schemas.file) {
try {
const content = await this.fileService.readFile(uri);
return content.value.toString();
} catch (error) {
this.logService.error(`Failed to read file from ${uri}: ${error}`);
}
}
const context = await this.requestService.request({
type: 'GET',
url: readmeUrl,
}, token);
const result = await asText(context);
if (!result) {
throw new Error(`Failed to fetch README from ${readmeUrl}`);
}
return result;
}
private toGalleryMcpServer(item: IRawGalleryMcpServer): IGalleryMcpServer {
let publisher = '';
const nameParts = item.name.split('/');
if (nameParts.length > 0) {
const domainParts = nameParts[0].split('.');
if (domainParts.length > 0) {
publisher = domainParts[domainParts.length - 1]; // Always take the last part as owner
}
}
return {
id: item.id,
name: item.name,
displayName: item.displayName ?? nameParts[nameParts.length - 1].split('-').map(s => uppercaseFirstLetter(s)).join(' '),
url: item.repository.url,
description: item.description,
version: item.version_detail.version,
lastUpdated: Date.parse(item.version_detail.releaseData),
repositoryUrl: item.repository.url,
readmeUrl: item.readmeUrl,
manifestUrl: this.getManifestUrl(item),
packageTypes: item.package_types ?? [],
publisher,
publisherDisplayName: item.publisher?.displayName,
publisherDomain: item.publisher ? {
link: item.publisher.url,
verified: item.publisher.is_verified,
} : undefined,
};
}
private async fetchGallery(token: CancellationToken): Promise<IRawGalleryMcpServer[]> {
const mcpGalleryUrl = this.getMcpGalleryUrl();
if (!mcpGalleryUrl) {
return Promise.resolve([]);
}
const uri = URI.parse(mcpGalleryUrl);
if (uri.scheme === Schemas.file) {
try {
const content = await this.fileService.readFile(uri);
const data = content.value.toString();
return JSON.parse(data);
} catch (error) {
this.logService.error(`Failed to read file from ${uri}: ${error}`);
}
}
const context = await this.requestService.request({
type: 'GET',
url: mcpGalleryUrl,
}, token);
const result = await asJson<IRawGalleryMcpServer[]>(context);
return result || [];
}
private getManifestUrl(item: IRawGalleryMcpServer): string {
const mcpGalleryUrl = this.getMcpGalleryUrl();
if (!mcpGalleryUrl) {
return item.repository.url;
}
const uri = URI.parse(mcpGalleryUrl);
if (uri.scheme === Schemas.file) {
return joinPath(dirname(uri), item.id).fsPath;
}
return `${mcpGalleryUrl}/${item.id}`;
}
private getMcpGalleryUrl(): string | undefined {
if (this.productService.quality === 'stable') {
return undefined;
}
return this.configurationService.getValue<string>(mcpGalleryServiceUrlConfig);
}
}

View File

@@ -0,0 +1,169 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancellationToken } from '../../../base/common/cancellation.js';
import { Event } from '../../../base/common/event.js';
import { SortBy, SortOrder } from '../../extensionManagement/common/extensionManagement.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import { IMcpServerConfiguration } from './mcpPlatformTypes.js';
export interface ILocalMcpServer {
readonly name: string;
readonly config: IMcpServerConfiguration;
readonly version: string;
readonly id?: string;
readonly displayName?: string;
readonly url?: string;
readonly description?: string;
readonly repositoryUrl?: string;
readonly readmeUrl?: string;
readonly publisher?: string;
readonly publisherDisplayName?: string;
readonly iconUrl?: string;
readonly manifest?: IMcpServerManifest;
}
export interface IMcpServerInput {
readonly description?: string;
readonly is_required?: boolean;
readonly format?: 'string' | 'number' | 'boolean' | 'filepath';
readonly value?: string;
readonly is_secret?: boolean;
readonly default?: string;
readonly choices?: readonly string[];
}
export interface IMcpServerVariableInput extends IMcpServerInput {
readonly variables?: Record<string, IMcpServerInput>;
}
export interface IMcpServerPositionalArgument extends IMcpServerVariableInput {
readonly type: 'positional';
readonly value_hint: string;
readonly is_repeatable: boolean;
}
export interface IMcpServerNamedArgument extends IMcpServerVariableInput {
readonly type: 'named';
readonly name: string;
readonly is_repeatable: boolean;
}
export interface IMcpServerKeyValueInput extends IMcpServerVariableInput {
readonly name: string;
readonly value: string;
}
export type IMcpServerArgument = IMcpServerPositionalArgument | IMcpServerNamedArgument;
export const enum PackageType {
NODE = 'npm',
DOCKER = 'docker',
PYTHON = 'pypi',
REMOTE = 'remote',
}
export interface IMcpServerPackage {
readonly name: string;
readonly version: string;
readonly registry_name: PackageType;
readonly package_arguments?: readonly IMcpServerArgument[];
readonly runtime_arguments?: readonly IMcpServerArgument[];
readonly environment_variables?: ReadonlyArray<IMcpServerKeyValueInput>;
}
export interface IMcpServerRemote {
readonly url: string;
readonly transport_type: 'streamable' | 'sse';
readonly headers: ReadonlyArray<IMcpServerKeyValueInput>;
}
export interface IMcpServerManifest {
readonly packages: readonly IMcpServerPackage[];
readonly remotes: readonly IMcpServerRemote[];
}
export interface IGalleryMcpServer {
readonly id: string;
readonly name: string;
readonly displayName: string;
readonly url: string;
readonly description: string;
readonly version: string;
readonly lastUpdated: number;
readonly repositoryUrl: string;
readonly manifestUrl: string;
readonly packageTypes: readonly PackageType[];
readonly readmeUrl?: string;
readonly publisher: string;
readonly publisherDisplayName?: string;
readonly publisherDomain?: { link: string; verified: boolean };
readonly iconUrl?: string;
readonly licenseUrl?: string;
readonly installCount?: number;
readonly rating?: number;
readonly ratingCount?: number;
readonly categories?: readonly string[];
readonly tags?: readonly string[];
readonly releaseDate?: number;
}
export interface IQueryOptions {
text?: string;
sortBy?: SortBy;
sortOrder?: SortOrder;
}
export interface InstallMcpServerEvent {
readonly name: string;
readonly source?: IGalleryMcpServer;
readonly applicationScoped?: boolean;
readonly workspaceScoped?: boolean;
}
export interface InstallMcpServerResult {
readonly name: string;
readonly source?: IGalleryMcpServer;
readonly local?: ILocalMcpServer;
readonly error?: Error;
readonly applicationScoped?: boolean;
readonly workspaceScoped?: boolean;
}
export interface UninstallMcpServerEvent {
readonly name: string;
readonly applicationScoped?: boolean;
readonly workspaceScoped?: boolean;
}
export interface DidUninstallMcpServerEvent {
readonly name: string;
readonly error?: string;
readonly applicationScoped?: boolean;
readonly workspaceScoped?: boolean;
}
export const IMcpGalleryService = createDecorator<IMcpGalleryService>('IMcpGalleryService');
export interface IMcpGalleryService {
readonly _serviceBrand: undefined;
query(options?: IQueryOptions, token?: CancellationToken): Promise<IGalleryMcpServer[]>;
getManifest(extension: IGalleryMcpServer, token: CancellationToken): Promise<IMcpServerManifest>;
getReadme(readmeUrl: string, token: CancellationToken): Promise<string>;
}
export const IMcpManagementService = createDecorator<IMcpManagementService>('IMcpManagementService');
export interface IMcpManagementService {
readonly _serviceBrand: undefined;
readonly onInstallMcpServer: Event<InstallMcpServerEvent>;
readonly onDidInstallMcpServers: Event<readonly InstallMcpServerResult[]>;
readonly onUninstallMcpServer: Event<UninstallMcpServerEvent>;
readonly onDidUninstallMcpServer: Event<DidUninstallMcpServerEvent>;
getInstalled(): Promise<ILocalMcpServer[]>;
installFromGallery(server: IGalleryMcpServer, packageType: PackageType): Promise<void>;
uninstall(server: ILocalMcpServer): Promise<void>;
}
export const mcpGalleryServiceUrlConfig = 'chat.mcp.gallery.serviceUrl';

View File

@@ -0,0 +1,298 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { VSBuffer } from '../../../base/common/buffer.js';
import { CancellationToken } from '../../../base/common/cancellation.js';
import { Emitter } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { deepClone } from '../../../base/common/objects.js';
import { uppercaseFirstLetter } from '../../../base/common/strings.js';
import { URI } from '../../../base/common/uri.js';
import { ConfigurationTarget, IConfigurationService } from '../../configuration/common/configuration.js';
import { IEnvironmentService } from '../../environment/common/environment.js';
import { IFileService } from '../../files/common/files.js';
import { ILogService } from '../../log/common/log.js';
import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js';
import { DidUninstallMcpServerEvent, IGalleryMcpServer, ILocalMcpServer, IMcpGalleryService, IMcpManagementService, IMcpServerInput, IMcpServerManifest, InstallMcpServerEvent, InstallMcpServerResult, PackageType, UninstallMcpServerEvent } from './mcpManagement.js';
import { McpConfigurationServer, IMcpServerVariable, McpServerVariableType, IMcpServersConfiguration, IMcpServerConfiguration } from './mcpPlatformTypes.js';
type LocalMcpServer = Omit<ILocalMcpServer, 'config'>;
export class McpManagementService extends Disposable implements IMcpManagementService {
_serviceBrand: undefined;
private readonly mcpLocation: URI;
private readonly _onInstallMcpServer = this._register(new Emitter<InstallMcpServerEvent>());
readonly onInstallMcpServer = this._onInstallMcpServer.event;
protected readonly _onDidInstallMcpServers = this._register(new Emitter<InstallMcpServerResult[]>());
get onDidInstallMcpServers() { return this._onDidInstallMcpServers.event; }
protected readonly _onUninstallMcpServer = this._register(new Emitter<UninstallMcpServerEvent>());
get onUninstallMcpServer() { return this._onUninstallMcpServer.event; }
protected _onDidUninstallMcpServer = this._register(new Emitter<DidUninstallMcpServerEvent>());
get onDidUninstallMcpServer() { return this._onDidUninstallMcpServer.event; }
constructor(
@IConfigurationService private readonly configurationService: IConfigurationService,
@IMcpGalleryService private readonly mcpGalleryService: IMcpGalleryService,
@IFileService private readonly fileService: IFileService,
@IEnvironmentService environmentService: IEnvironmentService,
@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,
@ILogService private readonly logService: ILogService,
) {
super();
this.mcpLocation = uriIdentityService.extUri.joinPath(environmentService.userRoamingDataHome, 'mcp');
}
async getInstalled(): Promise<ILocalMcpServer[]> {
const { userLocal } = this.configurationService.inspect<IMcpServersConfiguration>('mcp');
if (!userLocal?.value?.servers) {
return [];
}
return Promise.all(Object.entries(userLocal.value.servers).map(([name, config]) => this.scanServer(name, config)));
}
private async scanServer(name: string, config: IMcpServerConfiguration): Promise<ILocalMcpServer> {
let scanned: LocalMcpServer | undefined;
if (config.manifestLocation) {
try {
const content = await this.fileService.readFile(URI.revive(config.manifestLocation));
scanned = JSON.parse(content.value.toString());
} catch (e) {
this.logService.error('MCP Management Service: failed to read manifest', config.manifestLocation.toString(), e);
}
}
if (!scanned) {
let publisher = '';
const nameParts = name.split('/');
if (nameParts.length > 0) {
const domainParts = nameParts[0].split('.');
if (domainParts.length > 0) {
publisher = domainParts[domainParts.length - 1]; // Always take the last part as owner
}
}
scanned = {
name,
version: '1.0.0',
displayName: nameParts[nameParts.length - 1].split('-').map(s => uppercaseFirstLetter(s)).join(' '),
publisher
};
}
return {
name,
config,
version: scanned.version ?? '1.0.0',
id: scanned.id,
displayName: scanned.displayName,
description: scanned.description,
publisher: scanned.publisher,
publisherDisplayName: scanned.publisherDisplayName,
repositoryUrl: scanned.repositoryUrl,
readmeUrl: scanned.readmeUrl,
iconUrl: scanned.iconUrl,
manifest: scanned.manifest
};
}
async installFromGallery(server: IGalleryMcpServer, packageType: PackageType): Promise<void> {
this.logService.trace('MCP Management Service: installGallery', server.url);
this._onInstallMcpServer.fire({ name: server.name });
try {
const manifest = await this.mcpGalleryService.getManifest(server, CancellationToken.None);
const manifestPath = this.uriIdentityService.extUri.joinPath(this.mcpLocation, `${server.name.replace('/', '.')}-${server.version}`, 'manifest.json');
await this.fileService.writeFile(manifestPath, VSBuffer.fromString(JSON.stringify({
id: server.id,
name: server.name,
displayName: server.displayName,
description: server.description,
version: server.version,
publisher: server.publisher,
publisherDisplayName: server.publisherDisplayName,
repository: server.repositoryUrl,
readmeUrl: server.readmeUrl,
iconUrl: server.iconUrl,
licenseUrl: server.licenseUrl,
...manifest,
})));
const { userLocal } = this.configurationService.inspect<IMcpServersConfiguration>('mcp');
const value: IMcpServersConfiguration = deepClone(userLocal?.value ?? { servers: {} });
if (!value.servers) {
value.servers = {};
}
const serverConfig = this.getServerConfig(manifest, packageType);
value.servers[server.name] = {
...serverConfig,
manifestLocation: manifestPath.toJSON(),
};
if (serverConfig.inputs) {
value.inputs = value.inputs ?? [];
for (const input of serverConfig.inputs) {
if (!value.inputs.some(i => (<IMcpServerVariable>i).id === input.id)) {
value.inputs.push({ ...input, serverName: server.name });
}
}
}
await this.configurationService.updateValue('mcp', value, ConfigurationTarget.USER_LOCAL);
const local = await this.scanServer(server.name, value.servers[server.name]);
this._onDidInstallMcpServers.fire([{ name: server.name, source: server, local }]);
} catch (e) {
this._onDidInstallMcpServers.fire([{ name: server.name, source: server, error: e }]);
throw e;
}
}
async uninstall(server: ILocalMcpServer): Promise<void> {
this.logService.trace('MCP Management Service: uninstall', server.name);
this._onUninstallMcpServer.fire({ name: server.name });
try {
const { userLocal } = this.configurationService.inspect<IMcpServersConfiguration>('mcp');
const value: IMcpServersConfiguration = deepClone(userLocal?.value ?? { servers: {} });
if (!value.servers) {
value.servers = {};
}
delete value.servers[server.name];
if (value.inputs) {
const index = value.inputs.findIndex(i => (<IMcpServerVariable>i).serverName === server.name);
if (index !== undefined && index >= 0) {
value.inputs?.splice(index, 1);
}
}
await this.configurationService.updateValue('mcp', value, ConfigurationTarget.USER_LOCAL);
this._onDidUninstallMcpServer.fire({ name: server.name });
} catch (e) {
this._onDidUninstallMcpServer.fire({ name: server.name, error: e });
throw e;
}
}
private getServerConfig(manifest: IMcpServerManifest, packageType: PackageType): McpConfigurationServer & { inputs?: IMcpServerVariable[] } {
if (packageType === PackageType.REMOTE) {
const inputs: IMcpServerVariable[] = [];
const headers: Record<string, string> = {};
for (const input of manifest.remotes[0].headers ?? []) {
headers[input.name] = input.value;
if (input.variables) {
inputs.push(...this.getVariables(input.variables));
}
}
return {
type: 'http',
url: manifest.remotes[0].url,
headers: Object.keys(headers).length ? headers : undefined,
inputs: inputs.length ? inputs : undefined,
};
}
const serverPackage = manifest.packages.find(p => p.registry_name === packageType) ?? manifest.packages[0];
const inputs: IMcpServerVariable[] = [];
const args: string[] = [];
const env: Record<string, string> = {};
if (packageType === PackageType.DOCKER) {
args.push('run');
args.push('-i');
args.push('--rm');
}
for (const arg of serverPackage.runtime_arguments ?? []) {
if (arg.type === 'positional') {
args.push(arg.value ?? arg.value_hint);
} else if (arg.type === 'named') {
args.push(arg.name);
if (arg.value) {
args.push(arg.value);
}
}
if (arg.variables) {
inputs.push(...this.getVariables(arg.variables));
}
}
for (const input of serverPackage.environment_variables ?? []) {
env[input.name] = input.value;
if (input.variables) {
inputs.push(...this.getVariables(input.variables));
}
if (serverPackage.registry_name === PackageType.DOCKER) {
args.push('-e');
args.push(input.name);
}
}
if (packageType === PackageType.NODE) {
args.push(`${serverPackage.name}@${serverPackage.version}`);
}
else if (packageType === PackageType.PYTHON) {
args.push(`${serverPackage.name}==${serverPackage.version}`);
}
else if (packageType === PackageType.DOCKER) {
args.push(`${serverPackage.name}:${serverPackage.version}`);
}
for (const arg of serverPackage.package_arguments ?? []) {
if (arg.type === 'positional') {
args.push(arg.value ?? arg.value_hint);
} else if (arg.type === 'named') {
args.push(arg.name);
if (arg.value) {
args.push(arg.value);
}
}
if (arg.variables) {
inputs.push(...this.getVariables(arg.variables));
}
}
return {
type: 'stdio',
command: this.getCommandName(serverPackage.registry_name),
args: args.length ? args : undefined,
env: Object.keys(env).length ? env : undefined,
inputs: inputs.length ? inputs : undefined,
};
}
private getCommandName(packageType: PackageType): string {
switch (packageType) {
case PackageType.NODE: return 'npx';
case PackageType.DOCKER: return 'docker';
case PackageType.PYTHON: return 'uvx';
}
return packageType;
}
private getVariables(variableInputs: Record<string, IMcpServerInput>): IMcpServerVariable[] {
const variables: IMcpServerVariable[] = [];
for (const [key, value] of Object.entries(variableInputs)) {
variables.push({
id: key,
type: value.choices ? McpServerVariableType.PICK : McpServerVariableType.PROMPT,
description: value.description ?? '',
password: !!value.is_secret,
default: value.default,
options: value.choices,
});
}
return variables;
}
}

View File

@@ -3,6 +3,9 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IStringDictionary } from '../../../base/common/collections.js';
import { UriComponents } from '../../../base/common/uri.js';
export interface IMcpConfiguration {
inputs?: unknown[];
/** @deprecated Only for rough cross-compat with other formats */
@@ -25,3 +28,41 @@ export interface IMcpConfigurationHTTP {
url: string;
headers?: Record<string, string>;
}
export const enum McpServerVariableType {
PROMPT = 'promptString',
PICK = 'pickString',
}
export interface IMcpServerVariable {
readonly id: string;
readonly type: McpServerVariableType;
readonly description: string;
readonly password: boolean;
readonly default?: string;
readonly options?: readonly string[];
readonly serverName?: string;
}
export interface IMcpServerConfiguration {
readonly manifestLocation?: UriComponents;
}
export interface IMcpStdioServerConfiguration extends IMcpServerConfiguration {
readonly type: 'stdio';
readonly command: string;
readonly args?: readonly string[];
readonly env?: Record<string, string | number | null>;
readonly envFile?: string;
}
export interface IMcpRemtoeServerConfiguration extends IMcpServerConfiguration {
readonly type: 'http';
readonly url: string;
readonly headers?: Record<string, string>;
}
export interface IMcpServersConfiguration {
servers?: IStringDictionary<IMcpServerConfiguration>;
inputs?: IMcpServerVariable[];
}

View File

@@ -18,6 +18,7 @@ import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurati
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { mcpGalleryServiceUrlConfig } from '../../../../platform/mcp/common/mcpManagement.js';
import { PromptsConfig } from '../../../../platform/prompts/common/config.js';
import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../platform/prompts/common/constants.js';
import { Registry } from '../../../../platform/registry/common/platform.js';
@@ -304,6 +305,18 @@ configurationRegistry.registerConfiguration({
default: true,
markdownDescription: nls.localize('mpc.discovery.enabled', "Configures discovery of Model Context Protocol servers on the machine. It may be set to `true` or `false` to disable or enable all sources, and an mapping sources you wish to enable."),
},
[mcpGalleryServiceUrlConfig]: {
type: 'string',
description: nls.localize('mcp.gallery.serviceUrl', "Configure the MCP Gallery service URL to connect to"),
default: '',
scope: ConfigurationScope.APPLICATION,
tags: ['usesOnlineServices'],
included: false,
policy: {
name: 'McpGalleryServiceUrl',
minimumVersion: '1.101',
},
},
[PromptsConfig.KEY]: {
type: 'boolean',
title: nls.localize(

View File

@@ -13,11 +13,11 @@ import { EnablementState, IExtensionManagementServerService, IPublisherInfo, IWo
import { IExtensionIgnoredRecommendationsService, IExtensionRecommendationsService } from '../../../services/extensionRecommendations/common/extensionRecommendations.js';
import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
import { VIEWLET_ID, IExtensionsWorkbenchService, IExtensionsViewPaneContainer, TOGGLE_IGNORE_EXTENSION_ACTION_ID, INSTALL_EXTENSION_FROM_VSIX_COMMAND_ID, WORKSPACE_RECOMMENDATIONS_VIEW_ID, IWorkspaceRecommendedExtensionsView, AutoUpdateConfigurationKey, HasOutdatedExtensionsContext, SELECT_INSTALL_VSIX_EXTENSION_COMMAND_ID, LIST_WORKSPACE_UNSUPPORTED_EXTENSIONS_COMMAND_ID, ExtensionEditorTab, THEME_ACTIONS_GROUP, INSTALL_ACTIONS_GROUP, OUTDATED_EXTENSIONS_VIEW_ID, CONTEXT_HAS_GALLERY, extensionsSearchActionsMenu, UPDATE_ACTIONS_GROUP, IExtensionArg, ExtensionRuntimeActionType, EXTENSIONS_CATEGORY, AutoRestartConfigurationKey } from '../common/extensions.js';
import { VIEWLET_ID, IExtensionsWorkbenchService, IExtensionsViewPaneContainer, TOGGLE_IGNORE_EXTENSION_ACTION_ID, INSTALL_EXTENSION_FROM_VSIX_COMMAND_ID, WORKSPACE_RECOMMENDATIONS_VIEW_ID, IWorkspaceRecommendedExtensionsView, AutoUpdateConfigurationKey, HasOutdatedExtensionsContext, SELECT_INSTALL_VSIX_EXTENSION_COMMAND_ID, LIST_WORKSPACE_UNSUPPORTED_EXTENSIONS_COMMAND_ID, ExtensionEditorTab, THEME_ACTIONS_GROUP, INSTALL_ACTIONS_GROUP, OUTDATED_EXTENSIONS_VIEW_ID, CONTEXT_HAS_GALLERY, extensionsSearchActionsMenu, UPDATE_ACTIONS_GROUP, IExtensionArg, ExtensionRuntimeActionType, EXTENSIONS_CATEGORY, AutoRestartConfigurationKey, extensionsFilterSubMenu, DefaultViewsContext } from '../common/extensions.js';
import { InstallSpecificVersionOfExtensionAction, ConfigureWorkspaceRecommendedExtensionsAction, ConfigureWorkspaceFolderRecommendedExtensionsAction, SetColorThemeAction, SetFileIconThemeAction, SetProductIconThemeAction, ClearLanguageAction, ToggleAutoUpdateForExtensionAction, ToggleAutoUpdatesForPublisherAction, TogglePreReleaseExtensionAction, InstallAnotherVersionAction, InstallAction } from './extensionsActions.js';
import { ExtensionsInput } from '../common/extensionsInput.js';
import { ExtensionEditor } from './extensionEditor.js';
import { StatusUpdater, MaliciousExtensionChecker, ExtensionsViewletViewsContribution, ExtensionsViewPaneContainer, BuiltInExtensionsContext, SearchMarketplaceExtensionsContext, RecommendedExtensionsContext, DefaultViewsContext, ExtensionsSortByContext, SearchHasTextContext, ExtensionsSearchValueContext } from './extensionsViewlet.js';
import { StatusUpdater, MaliciousExtensionChecker, ExtensionsViewletViewsContribution, ExtensionsViewPaneContainer, BuiltInExtensionsContext, SearchMarketplaceExtensionsContext, RecommendedExtensionsContext, ExtensionsSortByContext, SearchHasTextContext, ExtensionsSearchValueContext } from './extensionsViewlet.js';
import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js';
import * as jsonContributionRegistry from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js';
import { ExtensionsConfigurationSchema, ExtensionsConfigurationSchemaId } from '../common/extensionsFileTemplate.js';
@@ -108,7 +108,7 @@ Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane
new SyncDescriptor(ExtensionsInput)
]);
Registry.as<IViewContainersRegistry>(ViewContainerExtensions.ViewContainersRegistry).registerViewContainer(
export const VIEW_CONTAINER = Registry.as<IViewContainersRegistry>(ViewContainerExtensions.ViewContainersRegistry).registerViewContainer(
{
id: VIEWLET_ID,
title: localize2('extensions', "Extensions"),
@@ -949,7 +949,6 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi
}
});
const extensionsFilterSubMenu = new MenuId('extensionsFilterSubMenu');
MenuRegistry.appendMenuItem(extensionsSearchActionsMenu, {
submenu: extensionsFilterSubMenu,
title: localize('filterExtensions', "Filter Extensions..."),

View File

@@ -4,7 +4,9 @@
*--------------------------------------------------------------------------------------------*/
import { Codicon } from '../../../../base/common/codicons.js';
import { Color, RGBA } from '../../../../base/common/color.js';
import { localize } from '../../../../nls.js';
import { contrastBorder, registerColor } from '../../../../platform/theme/common/colorRegistry.js';
import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js';
export const extensionsViewIcon = registerIcon('extensions-view-icon', Codicon.extensions, localize('extensionsViewIcon', 'View icon of the extensions view.'));
@@ -38,3 +40,9 @@ export const infoIcon = registerIcon('extensions-info-message', Codicon.info, lo
export const trustIcon = registerIcon('extension-workspace-trust', Codicon.shield, localize('trustIcon', 'Icon shown with a workspace trust message in the extension editor.'));
export const activationTimeIcon = registerIcon('extension-activation-time', Codicon.history, localize('activationtimeIcon', 'Icon shown with a activation time message in the extension editor.'));
export const extensionBorder = registerColor(
'extension.border',
{ dark: new Color(new RGBA(255, 255, 255, 0.10)), light: new Color(new RGBA(0, 0, 0, 0.10)), hcDark: contrastBorder, hcLight: contrastBorder, },
localize('extension.border', 'The border color of an extension.')
);

View File

@@ -16,7 +16,7 @@ import { append, $, Dimension, hide, show, DragAndDropObserver, trackFocus, addD
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { IExtensionService } from '../../../services/extensions/common/extensions.js';
import { IExtensionsWorkbenchService, IExtensionsViewPaneContainer, VIEWLET_ID, CloseExtensionDetailsOnViewChangeKey, INSTALL_EXTENSION_FROM_VSIX_COMMAND_ID, WORKSPACE_RECOMMENDATIONS_VIEW_ID, AutoCheckUpdatesConfigurationKey, OUTDATED_EXTENSIONS_VIEW_ID, CONTEXT_HAS_GALLERY, extensionsSearchActionsMenu, AutoRestartConfigurationKey, ExtensionRuntimeActionType } from '../common/extensions.js';
import { IExtensionsWorkbenchService, IExtensionsViewPaneContainer, VIEWLET_ID, CloseExtensionDetailsOnViewChangeKey, INSTALL_EXTENSION_FROM_VSIX_COMMAND_ID, WORKSPACE_RECOMMENDATIONS_VIEW_ID, AutoCheckUpdatesConfigurationKey, OUTDATED_EXTENSIONS_VIEW_ID, CONTEXT_HAS_GALLERY, extensionsSearchActionsMenu, AutoRestartConfigurationKey, ExtensionRuntimeActionType, SearchMcpServersContext, DefaultViewsContext } from '../common/extensions.js';
import { InstallLocalExtensionsInRemoteAction, InstallRemoteExtensionsInLocalAction } from './extensionsActions.js';
import { IExtensionManagementService, ILocalExtension } from '../../../../platform/extensionManagement/common/extensionManagement.js';
import { IWorkbenchExtensionEnablementService, IExtensionManagementServerService, IExtensionManagementServer } from '../../../services/extensionManagement/common/extensionManagement.js';
@@ -69,8 +69,8 @@ import { ThemeIcon } from '../../../../base/common/themables.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { IExtensionGalleryManifest, IExtensionGalleryManifestService } from '../../../../platform/extensionManagement/common/extensionGalleryManifest.js';
import { URI } from '../../../../base/common/uri.js';
import { mcpGalleryServiceUrlConfig } from '../../../../platform/mcp/common/mcpManagement.js';
export const DefaultViewsContext = new RawContextKey<boolean>('defaultExtensionViews', true);
export const ExtensionsSortByContext = new RawContextKey<string>('extensionsSortByValue', '');
export const SearchMarketplaceExtensionsContext = new RawContextKey<boolean>('searchMarketplaceExtensions', false);
export const SearchHasTextContext = new RawContextKey<boolean>('extensionSearchHasText', false);
@@ -483,6 +483,7 @@ export class ExtensionsViewPaneContainer extends ViewPaneContainer implements IE
private readonly defaultViewsContextKey: IContextKey<boolean>;
private readonly sortByContextKey: IContextKey<string>;
private readonly searchMarketplaceExtensionsContextKey: IContextKey<boolean>;
private readonly searchMcpServersContextKey: IContextKey<boolean>;
private readonly searchHasTextContextKey: IContextKey<boolean>;
private readonly sortByUpdateDateContextKey: IContextKey<boolean>;
private readonly installedExtensionsContextKey: IContextKey<boolean>;
@@ -537,6 +538,7 @@ export class ExtensionsViewPaneContainer extends ViewPaneContainer implements IE
this.defaultViewsContextKey = DefaultViewsContext.bindTo(contextKeyService);
this.sortByContextKey = ExtensionsSortByContext.bindTo(contextKeyService);
this.searchMarketplaceExtensionsContextKey = SearchMarketplaceExtensionsContext.bindTo(contextKeyService);
this.searchMcpServersContextKey = SearchMcpServersContext.bindTo(contextKeyService);
this.searchHasTextContextKey = SearchHasTextContext.bindTo(contextKeyService);
this.sortByUpdateDateContextKey = SortByUpdateDateContext.bindTo(contextKeyService);
this.installedExtensionsContextKey = InstalledExtensionsContext.bindTo(contextKeyService);
@@ -819,7 +821,8 @@ export class ExtensionsViewPaneContainer extends ViewPaneContainer implements IE
this.searchDeprecatedExtensionsContextKey.set(ExtensionsListView.isSearchDeprecatedExtensionsQuery(value));
this.builtInExtensionsContextKey.set(ExtensionsListView.isBuiltInExtensionsQuery(value));
this.recommendedExtensionsContextKey.set(isRecommendedExtensionsQuery);
this.searchMarketplaceExtensionsContextKey.set(!!value && !ExtensionsListView.isLocalExtensionsQuery(value) && !isRecommendedExtensionsQuery);
this.searchMcpServersContextKey.set(!!this.configurationService.getValue(mcpGalleryServiceUrlConfig) && !!value && /@mcp\s?.*/i.test(value));
this.searchMarketplaceExtensionsContextKey.set(!!value && !ExtensionsListView.isLocalExtensionsQuery(value) && !isRecommendedExtensionsQuery && !this.searchMcpServersContextKey.get());
this.sortByUpdateDateContextKey.set(ExtensionsListView.isSortUpdateDateQuery(value));
this.defaultViewsContextKey.set(!value || ExtensionsListView.isSortInstalledExtensionsQuery(value));
});

View File

@@ -250,9 +250,11 @@ export const INSTALL_EXTENSION_FROM_VSIX_COMMAND_ID = 'workbench.extensions.comm
export const LIST_WORKSPACE_UNSUPPORTED_EXTENSIONS_COMMAND_ID = 'workbench.extensions.action.listWorkspaceUnsupportedExtensions';
// Context Keys
export const DefaultViewsContext = new RawContextKey<boolean>('defaultExtensionViews', true);
export const HasOutdatedExtensionsContext = new RawContextKey<boolean>('hasOutdatedExtensions', false);
export const CONTEXT_HAS_GALLERY = new RawContextKey<boolean>('hasGallery', false);
export const ExtensionResultsListFocused = new RawContextKey<boolean>('extensionResultListFocused ', true);
export const SearchMcpServersContext = new RawContextKey<boolean>('searchMcpServers', false);
// Context Menu Groups
export const THEME_ACTIONS_GROUP = '_theme_';
@@ -260,6 +262,7 @@ export const INSTALL_ACTIONS_GROUP = '0_install';
export const UPDATE_ACTIONS_GROUP = '0_update';
export const extensionsSearchActionsMenu = new MenuId('extensionsSearchActionsMenu');
export const extensionsFilterSubMenu = new MenuId('extensionsFilterSubMenu');
export interface IExtensionArg {
id: string;

View File

@@ -3,13 +3,20 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { localize, localize2 } from '../../../../nls.js';
import { registerAction2 } from '../../../../platform/actions/common/actions.js';
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import * as jsonContributionRegistry from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js';
import { Registry } from '../../../../platform/registry/common/platform.js';
import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js';
import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
import { EditorExtensions } from '../../../common/editor.js';
import { IViewsRegistry, Extensions as ViewExtensions } from '../../../common/views.js';
import { mcpSchemaId } from '../../../services/configuration/common/configuration.js';
import { VIEW_CONTAINER } from '../../extensions/browser/extensions.contribution.js';
import { DefaultViewsContext, SearchMcpServersContext } from '../../extensions/common/extensions.js';
import { ConfigMcpDiscovery } from '../common/discovery/configMcpDiscovery.js';
import { ExtensionMcpDiscovery } from '../common/discovery/extensionMcpDiscovery.js';
import { mcpDiscoveryRegistry } from '../common/discovery/mcpDiscovery.js';
@@ -21,14 +28,19 @@ import { McpContextKeysController } from '../common/mcpContextKeys.js';
import { McpRegistry } from '../common/mcpRegistry.js';
import { IMcpRegistry } from '../common/mcpRegistryTypes.js';
import { McpService } from '../common/mcpService.js';
import { IMcpService } from '../common/mcpTypes.js';
import { AddConfigurationAction, EditStoredInput, InstallFromActivation, ListMcpServerCommand, MCPServerActionRendering, McpServerOptionsCommand, RemoveStoredInput, ResetMcpCachedTools, ResetMcpTrustCommand, RestartServer, ShowConfiguration, ShowOutput, StartServer, StopServer } from './mcpCommands.js';
import { HasInstalledMcpServersContext, IMcpService, IMcpWorkbenchService, McpServersGalleryEnabledContext } from '../common/mcpTypes.js';
import { AddConfigurationAction, EditStoredInput, InstallFromActivation, ListMcpServerCommand, McpBrowseCommand, MCPServerActionRendering, McpServerOptionsCommand, RemoveStoredInput, ResetMcpCachedTools, ResetMcpTrustCommand, RestartServer, ShowConfiguration, ShowOutput, StartServer, StopServer } from './mcpCommands.js';
import { McpDiscovery } from './mcpDiscovery.js';
import { McpLanguageFeatures } from './mcpLanguageFeatures.js';
import { McpServerEditor } from './mcpServerEditor.js';
import { McpServerEditorInput } from './mcpServerEditorInput.js';
import { McpServersListView } from './mcpServersView.js';
import { McpUrlHandler } from './mcpUrlHandler.js';
import { MCPContextsInitialisation, McpWorkbenchService } from './mcpWorkbenchService.js';
registerSingleton(IMcpRegistry, McpRegistry, InstantiationType.Delayed);
registerSingleton(IMcpService, McpService, InstantiationType.Delayed);
registerSingleton(IMcpWorkbenchService, McpWorkbenchService, InstantiationType.Eager);
registerSingleton(IMcpConfigPathsService, McpConfigPathsService, InstantiationType.Delayed);
mcpDiscoveryRegistry.register(new SyncDescriptor(RemoteNativeMpcDiscovery));
@@ -54,8 +66,38 @@ registerAction2(ShowOutput);
registerAction2(InstallFromActivation);
registerAction2(RestartServer);
registerAction2(ShowConfiguration);
registerAction2(McpBrowseCommand);
registerWorkbenchContribution2('mcpActionRendering', MCPServerActionRendering, WorkbenchPhase.BlockRestore);
registerWorkbenchContribution2(MCPContextsInitialisation.ID, MCPContextsInitialisation, WorkbenchPhase.AfterRestored);
const jsonRegistry = <jsonContributionRegistry.IJSONContributionRegistry>Registry.as(jsonContributionRegistry.Extensions.JSONContribution);
jsonRegistry.registerSchema(mcpSchemaId, mcpServerSchema);
Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry).registerViews([
{
id: 'workbench.views.mcp.installed',
name: localize2('mcp', "MCP Servers"),
ctorDescriptor: new SyncDescriptor(McpServersListView),
when: ContextKeyExpr.and(DefaultViewsContext, HasInstalledMcpServersContext, McpServersGalleryEnabledContext),
weight: 40,
order: 4,
canToggleVisibility: true
},
{
id: 'workbench.views.mcp.marketplace',
name: localize2('mcp', "MCP Servers"),
ctorDescriptor: new SyncDescriptor(McpServersListView),
when: ContextKeyExpr.and(SearchMcpServersContext, McpServersGalleryEnabledContext),
}
], VIEW_CONTAINER);
Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane(
EditorPaneDescriptor.create(
McpServerEditor,
McpServerEditor.ID,
localize('mcpServer', "MCP Server")
),
[
new SyncDescriptor(McpServerEditorInput)
]);

View File

@@ -32,10 +32,12 @@ import { ChatMode } from '../../chat/common/constants.js';
import { TEXT_FILE_EDITOR_ID } from '../../files/common/files.js';
import { McpContextKeys } from '../common/mcpContextKeys.js';
import { IMcpRegistry } from '../common/mcpRegistryTypes.js';
import { IMcpServer, IMcpService, LazyCollectionState, McpConnectionState, McpServerToolsState } from '../common/mcpTypes.js';
import { IMcpServer, IMcpService, LazyCollectionState, McpConnectionState, McpServersGalleryEnabledContext, McpServerToolsState } from '../common/mcpTypes.js';
import { McpAddConfigurationCommand } from './mcpCommandsAddConfiguration.js';
import { McpUrlHandler } from './mcpUrlHandler.js';
import { McpCommandIds } from '../common/mcpCommandIds.js';
import { extensionsFilterSubMenu, IExtensionsWorkbenchService } from '../../extensions/common/extensions.js';
import { ExtensionsLocalizedLabel } from '../../../../platform/extensionManagement/common/extensionManagement.js';
// acroynms do not get localized
const category: ILocalizedString = {
@@ -566,3 +568,26 @@ export class InstallFromActivation extends Action2 {
addConfigHelper.pickForUrlHandler(uri);
}
}
export class McpBrowseCommand extends Action2 {
constructor() {
super({
id: McpCommandIds.Browse,
title: localize2('mcp.command.browse', "MCP Servers"),
category: ExtensionsLocalizedLabel,
menu: [{
id: MenuId.CommandPalette,
when: McpServersGalleryEnabledContext,
}, {
id: extensionsFilterSubMenu,
when: McpServersGalleryEnabledContext,
group: '1_predefined',
order: 1,
}],
});
}
async run(accessor: ServicesAccessor) {
accessor.get(IExtensionsWorkbenchService).openSearch('@mcp ');
}
}

View File

@@ -0,0 +1,91 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Action } from '../../../../base/common/actions.js';
import { localize } from '../../../../nls.js';
import { IMcpServerContainer, IMcpWorkbenchService, IWorkbenchMcpServer } from '../common/mcpTypes.js';
export abstract class McpServerAction extends Action implements IMcpServerContainer {
static readonly EXTENSION_ACTION_CLASS = 'mcp-server-action';
static readonly TEXT_ACTION_CLASS = `${McpServerAction.EXTENSION_ACTION_CLASS} text`;
static readonly LABEL_ACTION_CLASS = `${McpServerAction.EXTENSION_ACTION_CLASS} label`;
static readonly PROMINENT_LABEL_ACTION_CLASS = `${McpServerAction.LABEL_ACTION_CLASS} prominent`;
static readonly ICON_ACTION_CLASS = `${McpServerAction.EXTENSION_ACTION_CLASS} icon`;
private _mcpServer: IWorkbenchMcpServer | null = null;
get mcpServer(): IWorkbenchMcpServer | null { return this._mcpServer; }
set mcpServer(mcpServer: IWorkbenchMcpServer | null) { this._mcpServer = mcpServer; this.update(); }
abstract update(): void;
}
export class InstallAction extends McpServerAction {
static readonly CLASS = `${this.LABEL_ACTION_CLASS} prominent install`;
private static readonly HIDE = `${this.CLASS} hide`;
constructor(
@IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService,
) {
super('extensions.install', localize('add', "Add"), InstallAction.CLASS, false);
this.update();
}
update(): void {
this.enabled = false;
this.class = InstallAction.HIDE;
if (!this.mcpServer?.gallery) {
return;
}
if (this.mcpServer.local) {
return;
}
this.class = InstallAction.CLASS;
this.enabled = true;
this.label = localize('add', "Add");
}
override async run(): Promise<any> {
if (!this.mcpServer) {
return;
}
await this.mcpWorkbenchService.install(this.mcpServer);
}
}
export class UninstallAction extends McpServerAction {
static readonly CLASS = `${this.LABEL_ACTION_CLASS} prominent uninstall`;
private static readonly HIDE = `${this.CLASS} hide`;
constructor(
@IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService,
) {
super('extensions.uninstall', localize('remove', "Remove"), UninstallAction.CLASS, false);
this.update();
}
update(): void {
this.enabled = false;
this.class = UninstallAction.HIDE;
if (!this.mcpServer) {
return;
}
if (!this.mcpServer.local) {
return;
}
this.class = UninstallAction.CLASS;
this.enabled = true;
this.label = localize('remove', "Remove");
}
override async run(): Promise<any> {
if (!this.mcpServer) {
return;
}
await this.mcpWorkbenchService.uninstall(this.mcpServer);
}
}

View File

@@ -0,0 +1,648 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { $, Dimension, addDisposableListener, append, setParentFlowTo } from '../../../../base/browser/dom.js';
import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js';
import { Action } from '../../../../base/common/actions.js';
import * as arrays from '../../../../base/common/arrays.js';
import { Cache, CacheResult } from '../../../../base/common/cache.js';
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
import { isCancellationError } from '../../../../base/common/errors.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable, DisposableStore, MutableDisposable, dispose, toDisposable } from '../../../../base/common/lifecycle.js';
import { Schemas, matchesScheme } from '../../../../base/common/network.js';
import { language } from '../../../../base/common/platform.js';
import { URI } from '../../../../base/common/uri.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import { TokenizationRegistry } from '../../../../editor/common/languages.js';
import { ILanguageService } from '../../../../editor/common/languages/language.js';
import { generateTokensCSSForColorMap } from '../../../../editor/common/languages/supports/tokenization.js';
import { localize } from '../../../../nls.js';
import { IContextKeyService, IScopedContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { INotificationService } from '../../../../platform/notification/common/notification.js';
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
import { IStorageService } from '../../../../platform/storage/common/storage.js';
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
import { EditorPane } from '../../../browser/parts/editor/editorPane.js';
import { IEditorOpenContext } from '../../../common/editor.js';
import { DEFAULT_MARKDOWN_STYLES, renderMarkdownDocument } from '../../markdown/browser/markdownDocumentRenderer.js';
import { IWebview, IWebviewService } from '../../webview/browser/webview.js';
import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js';
import { IExtensionService } from '../../../services/extensions/common/extensions.js';
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
import { IWorkbenchMcpServer, McpServerContainers } from '../common/mcpTypes.js';
import { InstallCountWidget, McpServerWidget, onClick, PublisherWidget, RatingsWidget } from './mcpServerWidgets.js';
import { InstallAction, UninstallAction } from './mcpServerActions.js';
import { McpServerEditorInput } from './mcpServerEditorInput.js';
import { IEditorOptions } from '../../../../platform/editor/common/editor.js';
import { IMcpGalleryService } from '../../../../platform/mcp/common/mcpManagement.js';
import { DefaultIconPath } from '../../../services/extensionManagement/common/extensionManagement.js';
const enum McpServerEditorTab {
Readme = 'readme',
}
function toDateString(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}, ${date.toLocaleTimeString(language, { hourCycle: 'h23' })}`;
}
class NavBar extends Disposable {
private _onChange = this._register(new Emitter<{ id: string | null; focus: boolean }>());
get onChange(): Event<{ id: string | null; focus: boolean }> { return this._onChange.event; }
private _currentId: string | null = null;
get currentId(): string | null { return this._currentId; }
private actions: Action[];
private actionbar: ActionBar;
constructor(container: HTMLElement) {
super();
const element = append(container, $('.navbar'));
this.actions = [];
this.actionbar = this._register(new ActionBar(element));
}
push(id: string, label: string, tooltip: string): void {
const action = new Action(id, label, undefined, true, () => this.update(id, true));
action.tooltip = tooltip;
this.actions.push(action);
this.actionbar.push(action);
if (this.actions.length === 1) {
this.update(id);
}
}
clear(): void {
this.actions = dispose(this.actions);
this.actionbar.clear();
}
switch(id: string): boolean {
const action = this.actions.find(action => action.id === id);
if (action) {
action.run();
return true;
}
return false;
}
private update(id: string, focus?: boolean): void {
this._currentId = id;
this._onChange.fire({ id, focus: !!focus });
this.actions.forEach(a => a.checked = a.id === id);
}
}
interface ILayoutParticipant {
layout(): void;
}
interface IActiveElement {
focus(): void;
}
interface IExtensionEditorTemplate {
iconContainer: HTMLElement;
icon: HTMLImageElement;
name: HTMLElement;
description: HTMLElement;
actionsAndStatusContainer: HTMLElement;
actionBar: ActionBar;
navbar: NavBar;
content: HTMLElement;
header: HTMLElement;
mcpServer: IWorkbenchMcpServer;
}
const enum WebviewIndex {
Readme,
Changelog
}
export class McpServerEditor extends EditorPane {
static readonly ID: string = 'workbench.editor.mcpServer';
private readonly _scopedContextKeyService = this._register(new MutableDisposable<IScopedContextKeyService>());
private template: IExtensionEditorTemplate | undefined;
private mcpServerReadme: Cache<string> | null;
// Some action bar items use a webview whose vertical scroll position we track in this map
private initialScrollProgress: Map<WebviewIndex, number> = new Map();
// Spot when an ExtensionEditor instance gets reused for a different extension, in which case the vertical scroll positions must be zeroed
private currentIdentifier: string = '';
private layoutParticipants: ILayoutParticipant[] = [];
private readonly contentDisposables = this._register(new DisposableStore());
private readonly transientDisposables = this._register(new DisposableStore());
private activeElement: IActiveElement | null = null;
private dimension: Dimension | undefined;
constructor(
group: IEditorGroup,
@ITelemetryService telemetryService: ITelemetryService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IMcpGalleryService private readonly galleryService: IMcpGalleryService,
@IThemeService themeService: IThemeService,
@INotificationService private readonly notificationService: INotificationService,
@IOpenerService private readonly openerService: IOpenerService,
@IStorageService storageService: IStorageService,
@IExtensionService private readonly extensionService: IExtensionService,
@IWebviewService private readonly webviewService: IWebviewService,
@ILanguageService private readonly languageService: ILanguageService,
@IContextKeyService private readonly contextKeyService: IContextKeyService,
@IHoverService private readonly hoverService: IHoverService,
) {
super(McpServerEditor.ID, group, telemetryService, themeService, storageService);
this.mcpServerReadme = null;
}
override get scopedContextKeyService(): IContextKeyService | undefined {
return this._scopedContextKeyService.value;
}
protected createEditor(parent: HTMLElement): void {
const root = append(parent, $('.extension-editor'));
this._scopedContextKeyService.value = this.contextKeyService.createScoped(root);
this._scopedContextKeyService.value.createKey('inExtensionEditor', true);
root.tabIndex = 0; // this is required for the focus tracker on the editor
root.style.outline = 'none';
root.setAttribute('role', 'document');
const header = append(root, $('.header'));
const iconContainer = append(header, $('.icon-container'));
const icon = append(iconContainer, $<HTMLImageElement>('img.icon', { draggable: false, alt: '' }));
const details = append(header, $('.details'));
const title = append(details, $('.title'));
const name = append(title, $('span.name.clickable', { role: 'heading', tabIndex: 0 }));
this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), name, localize('name', "Extension name")));
const subtitle = append(details, $('.subtitle'));
const subTitleEntryContainers: HTMLElement[] = [];
const publisherContainer = append(subtitle, $('.subtitle-entry'));
subTitleEntryContainers.push(publisherContainer);
const publisherWidget = this.instantiationService.createInstance(PublisherWidget, publisherContainer, false);
const installCountContainer = append(subtitle, $('.subtitle-entry'));
subTitleEntryContainers.push(installCountContainer);
const installCountWidget = this.instantiationService.createInstance(InstallCountWidget, installCountContainer, false);
const ratingsContainer = append(subtitle, $('.subtitle-entry'));
subTitleEntryContainers.push(ratingsContainer);
const ratingsWidget = this.instantiationService.createInstance(RatingsWidget, ratingsContainer, false);
const widgets: McpServerWidget[] = [
publisherWidget,
installCountWidget,
ratingsWidget,
];
const description = append(details, $('.description'));
const actions = [
this.instantiationService.createInstance(InstallAction),
this.instantiationService.createInstance(UninstallAction),
];
const actionsAndStatusContainer = append(details, $('.actions-status-container.mcp-server-actions'));
const actionBar = this._register(new ActionBar(actionsAndStatusContainer, {
focusOnlyEnabledItems: true
}));
actionBar.push(actions, { icon: true, label: true });
actionBar.setFocusable(true);
// update focusable elements when the enablement of an action changes
this._register(Event.any(...actions.map(a => Event.filter(a.onDidChange, e => e.enabled !== undefined)))(() => {
actionBar.setFocusable(false);
actionBar.setFocusable(true);
}));
const mcpServerContainers: McpServerContainers = this.instantiationService.createInstance(McpServerContainers, [...actions, ...widgets]);
for (const disposable of [...actions, ...widgets, mcpServerContainers]) {
this._register(disposable);
}
const onError = Event.chain(actionBar.onDidRun, $ =>
$.map(({ error }) => error)
.filter(error => !!error)
);
this._register(onError(this.onError, this));
const body = append(root, $('.body'));
const navbar = new NavBar(body);
const content = append(body, $('.content'));
content.id = generateUuid(); // An id is needed for the webview parent flow to
this.template = {
content,
description,
header,
icon,
iconContainer,
name,
navbar,
actionsAndStatusContainer,
actionBar: actionBar,
set mcpServer(mcpServer: IWorkbenchMcpServer) {
mcpServerContainers.mcpServer = mcpServer;
let lastNonEmptySubtitleEntryContainer;
for (const subTitleEntryElement of subTitleEntryContainers) {
subTitleEntryElement.classList.remove('last-non-empty');
if (subTitleEntryElement.children.length > 0) {
lastNonEmptySubtitleEntryContainer = subTitleEntryElement;
}
}
if (lastNonEmptySubtitleEntryContainer) {
lastNonEmptySubtitleEntryContainer.classList.add('last-non-empty');
}
}
};
}
override async setInput(input: McpServerEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise<void> {
await super.setInput(input, options, context, token);
if (this.template) {
await this.render(input.mcpServer, this.template, !!options?.preserveFocus);
}
}
private async render(mcpServer: IWorkbenchMcpServer, template: IExtensionEditorTemplate, preserveFocus: boolean): Promise<void> {
this.activeElement = null;
this.transientDisposables.clear();
const token = this.transientDisposables.add(new CancellationTokenSource()).token;
this.mcpServerReadme = new Cache(() => mcpServer.readmeUrl ? this.galleryService.getReadme(mcpServer.readmeUrl, token) : Promise.resolve(localize('noReadme', "No README available.")));
template.mcpServer = mcpServer;
this.transientDisposables.add(addDisposableListener(template.icon, 'error', () => template.icon.src = DefaultIconPath, { once: true }));
template.icon.src = mcpServer.iconUrl;
template.name.textContent = mcpServer.label;
template.name.classList.toggle('clickable', !!mcpServer.url);
template.description.textContent = mcpServer.description;
if (mcpServer.url) {
this.transientDisposables.add(onClick(template.name, () => this.openerService.open(URI.parse(mcpServer.url!))));
}
this.renderNavbar(mcpServer, template, preserveFocus);
}
private renderNavbar(extension: IWorkbenchMcpServer, template: IExtensionEditorTemplate, preserveFocus: boolean): void {
template.content.innerText = '';
template.navbar.clear();
if (this.currentIdentifier !== extension.id) {
this.initialScrollProgress.clear();
this.currentIdentifier = extension.id;
}
template.navbar.push(McpServerEditorTab.Readme, localize('details', "Details"), localize('detailstooltip', "Extension details, rendered from the extension's 'README.md' file"));
if (template.navbar.currentId) {
this.onNavbarChange(extension, { id: template.navbar.currentId, focus: !preserveFocus }, template);
}
template.navbar.onChange(e => this.onNavbarChange(extension, e, template), this, this.transientDisposables);
}
override clearInput(): void {
this.contentDisposables.clear();
this.transientDisposables.clear();
super.clearInput();
}
override focus(): void {
super.focus();
this.activeElement?.focus();
}
showFind(): void {
this.activeWebview?.showFind();
}
runFindAction(previous: boolean): void {
this.activeWebview?.runFindAction(previous);
}
public get activeWebview(): IWebview | undefined {
if (!this.activeElement || !(this.activeElement as IWebview).runFindAction) {
return undefined;
}
return this.activeElement as IWebview;
}
private onNavbarChange(extension: IWorkbenchMcpServer, { id, focus }: { id: string | null; focus: boolean }, template: IExtensionEditorTemplate): void {
this.contentDisposables.clear();
template.content.innerText = '';
this.activeElement = null;
if (id) {
const cts = new CancellationTokenSource();
this.contentDisposables.add(toDisposable(() => cts.dispose(true)));
this.open(id, extension, template, cts.token)
.then(activeElement => {
if (cts.token.isCancellationRequested) {
return;
}
this.activeElement = activeElement;
if (focus) {
this.focus();
}
});
}
}
private open(id: string, extension: IWorkbenchMcpServer, template: IExtensionEditorTemplate, token: CancellationToken): Promise<IActiveElement | null> {
switch (id) {
case McpServerEditorTab.Readme: return this.openDetails(extension, template, token);
}
return Promise.resolve(null);
}
private async openMarkdown(extension: IWorkbenchMcpServer, cacheResult: CacheResult<string>, noContentCopy: string, container: HTMLElement, webviewIndex: WebviewIndex, title: string, token: CancellationToken): Promise<IActiveElement | null> {
try {
const body = await this.renderMarkdown(extension, cacheResult, container, token);
if (token.isCancellationRequested) {
return Promise.resolve(null);
}
const webview = this.contentDisposables.add(this.webviewService.createWebviewOverlay({
title,
options: {
enableFindWidget: true,
tryRestoreScrollPosition: true,
disableServiceWorker: true,
},
contentOptions: {},
extension: undefined,
}));
webview.initialScrollProgress = this.initialScrollProgress.get(webviewIndex) || 0;
webview.claim(this, this.window, this.scopedContextKeyService);
setParentFlowTo(webview.container, container);
webview.layoutWebviewOverElement(container);
webview.setHtml(body);
webview.claim(this, this.window, undefined);
this.contentDisposables.add(webview.onDidFocus(() => this._onDidFocus?.fire()));
this.contentDisposables.add(webview.onDidScroll(() => this.initialScrollProgress.set(webviewIndex, webview.initialScrollProgress)));
const removeLayoutParticipant = arrays.insert(this.layoutParticipants, {
layout: () => {
webview.layoutWebviewOverElement(container);
}
});
this.contentDisposables.add(toDisposable(removeLayoutParticipant));
let isDisposed = false;
this.contentDisposables.add(toDisposable(() => { isDisposed = true; }));
this.contentDisposables.add(this.themeService.onDidColorThemeChange(async () => {
// Render again since syntax highlighting of code blocks may have changed
const body = await this.renderMarkdown(extension, cacheResult, container);
if (!isDisposed) { // Make sure we weren't disposed of in the meantime
webview.setHtml(body);
}
}));
this.contentDisposables.add(webview.onDidClickLink(link => {
if (!link) {
return;
}
// Only allow links with specific schemes
if (matchesScheme(link, Schemas.http) || matchesScheme(link, Schemas.https) || matchesScheme(link, Schemas.mailto)) {
this.openerService.open(link);
}
}));
return webview;
} catch (e) {
const p = append(container, $('p.nocontent'));
p.textContent = noContentCopy;
return p;
}
}
private async renderMarkdown(extension: IWorkbenchMcpServer, cacheResult: CacheResult<string>, container: HTMLElement, token?: CancellationToken): Promise<string> {
const contents = await this.loadContents(() => cacheResult, container);
if (token?.isCancellationRequested) {
return '';
}
const content = await renderMarkdownDocument(contents, this.extensionService, this.languageService, { shouldSanitize: true, token });
if (token?.isCancellationRequested) {
return '';
}
return this.renderBody(content);
}
private renderBody(body: string): string {
const nonce = generateUuid();
const colorMap = TokenizationRegistry.getColorMap();
const css = colorMap ? generateTokensCSSForColorMap(colorMap) : '';
return `<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: data:; media-src https:; script-src 'none'; style-src 'nonce-${nonce}';">
<style nonce="${nonce}">
${DEFAULT_MARKDOWN_STYLES}
/* prevent scroll-to-top button from blocking the body text */
body {
padding-bottom: 75px;
}
#scroll-to-top {
position: fixed;
width: 32px;
height: 32px;
right: 25px;
bottom: 25px;
background-color: var(--vscode-button-secondaryBackground);
border-color: var(--vscode-button-border);
border-radius: 50%;
cursor: pointer;
box-shadow: 1px 1px 1px rgba(0,0,0,.25);
outline: none;
display: flex;
justify-content: center;
align-items: center;
}
#scroll-to-top:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
box-shadow: 2px 2px 2px rgba(0,0,0,.25);
}
body.vscode-high-contrast #scroll-to-top {
border-width: 2px;
border-style: solid;
box-shadow: none;
}
#scroll-to-top span.icon::before {
content: "";
display: block;
background: var(--vscode-button-secondaryForeground);
/* Chevron up icon */
webkit-mask-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE5LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHZpZXdCb3g9IjAgMCAxNiAxNiIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMTYgMTY7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KCS5zdDB7ZmlsbDojRkZGRkZGO30KCS5zdDF7ZmlsbDpub25lO30KPC9zdHlsZT4KPHRpdGxlPnVwY2hldnJvbjwvdGl0bGU+CjxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik04LDUuMWwtNy4zLDcuM0wwLDExLjZsOC04bDgsOGwtMC43LDAuN0w4LDUuMXoiLz4KPHJlY3QgY2xhc3M9InN0MSIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2Ii8+Cjwvc3ZnPgo=');
-webkit-mask-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDE5LjIuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHZpZXdCb3g9IjAgMCAxNiAxNiIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMTYgMTY7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KCS5zdDB7ZmlsbDojRkZGRkZGO30KCS5zdDF7ZmlsbDpub25lO30KPC9zdHlsZT4KPHRpdGxlPnVwY2hldnJvbjwvdGl0bGU+CjxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik04LDUuMWwtNy4zLDcuM0wwLDExLjZsOC04bDgsOGwtMC43LDAuN0w4LDUuMXoiLz4KPHJlY3QgY2xhc3M9InN0MSIgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2Ii8+Cjwvc3ZnPgo=');
width: 16px;
height: 16px;
}
${css}
</style>
</head>
<body>
<a id="scroll-to-top" role="button" aria-label="scroll to top" href="#"><span class="icon"></span></a>
${body}
</body>
</html>`;
}
private async openDetails(extension: IWorkbenchMcpServer, template: IExtensionEditorTemplate, token: CancellationToken): Promise<IActiveElement | null> {
const details = append(template.content, $('.details'));
const readmeContainer = append(details, $('.readme-container'));
const additionalDetailsContainer = append(details, $('.additional-details-container'));
const layout = () => details.classList.toggle('narrow', this.dimension && this.dimension.width < 500);
layout();
this.contentDisposables.add(toDisposable(arrays.insert(this.layoutParticipants, { layout })));
const activeElement = await this.openMarkdown(extension, this.mcpServerReadme!.get(), localize('noReadme', "No README available."), readmeContainer, WebviewIndex.Readme, localize('Readme title', "Readme"), token);
this.renderAdditionalDetails(additionalDetailsContainer, extension);
return activeElement;
}
private renderAdditionalDetails(container: HTMLElement, extension: IWorkbenchMcpServer): void {
const content = $('div', { class: 'additional-details-content', tabindex: '0' });
const scrollableContent = new DomScrollableElement(content, {});
const layout = () => scrollableContent.scanDomNode();
const removeLayoutParticipant = arrays.insert(this.layoutParticipants, { layout });
this.contentDisposables.add(toDisposable(removeLayoutParticipant));
this.contentDisposables.add(scrollableContent);
this.contentDisposables.add(this.instantiationService.createInstance(AdditionalDetailsWidget, content, extension));
append(container, scrollableContent.getDomNode());
scrollableContent.scanDomNode();
}
private loadContents<T>(loadingTask: () => CacheResult<T>, container: HTMLElement): Promise<T> {
container.classList.add('loading');
const result = this.contentDisposables.add(loadingTask());
const onDone = () => container.classList.remove('loading');
result.promise.then(onDone, onDone);
return result.promise;
}
layout(dimension: Dimension): void {
this.dimension = dimension;
this.layoutParticipants.forEach(p => p.layout());
}
private onError(err: any): void {
if (isCancellationError(err)) {
return;
}
this.notificationService.error(err);
}
}
class AdditionalDetailsWidget extends Disposable {
private readonly disposables = this._register(new DisposableStore());
constructor(
private readonly container: HTMLElement,
extension: IWorkbenchMcpServer,
@IHoverService private readonly hoverService: IHoverService,
@IOpenerService private readonly openerService: IOpenerService,
) {
super();
this.render(extension);
}
private render(extension: IWorkbenchMcpServer): void {
this.container.innerText = '';
this.disposables.clear();
if (extension.gallery) {
this.renderMarketplaceInfo(this.container, extension);
}
this.renderExtensionResources(this.container, extension);
}
private renderExtensionResources(container: HTMLElement, extension: IWorkbenchMcpServer): void {
const resources: [string, URI][] = [];
if (extension.url) {
resources.push([localize('Marketplace', "Marketplace"), URI.parse(extension.url)]);
}
if (extension.repository) {
try {
resources.push([localize('repository', "Repository"), URI.parse(extension.repository)]);
} catch (error) {/* Ignore */ }
}
if (extension.publisherUrl && extension.publisherDisplayName) {
resources.push([extension.publisherDisplayName, URI.parse(extension.publisherUrl)]);
}
if (resources.length) {
const extensionResourcesContainer = append(container, $('.resources-container.additional-details-element'));
append(extensionResourcesContainer, $('.additional-details-title', undefined, localize('resources', "Resources")));
const resourcesElement = append(extensionResourcesContainer, $('.resources'));
for (const [label, uri] of resources) {
const resource = append(resourcesElement, $('a.resource', { tabindex: '0' }, label));
this.disposables.add(onClick(resource, () => this.openerService.open(uri)));
this.disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), resource, uri.toString()));
}
}
}
private renderMarketplaceInfo(container: HTMLElement, extension: IWorkbenchMcpServer): void {
const gallery = extension.gallery;
const moreInfoContainer = append(container, $('.more-info-container.additional-details-element'));
append(moreInfoContainer, $('.additional-details-title', undefined, localize('Marketplace Info', "Marketplace")));
const moreInfo = append(moreInfoContainer, $('.more-info'));
if (gallery) {
if (!extension.local) {
append(moreInfo,
$('.more-info-entry', undefined,
$('div.more-info-entry-name', undefined, localize('Version', "Version")),
$('code', undefined, gallery.version)
)
);
}
append(moreInfo,
$('.more-info-entry', undefined,
$('div.more-info-entry-name', undefined, localize('last released', "Last Released")),
$('div', undefined, toDateString(new Date(gallery.lastUpdated)))
)
);
}
}
}

View File

@@ -0,0 +1,59 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Schemas } from '../../../../base/common/network.js';
import { URI } from '../../../../base/common/uri.js';
import { localize } from '../../../../nls.js';
import { EditorInputCapabilities, IUntypedEditorInput } from '../../../common/editor.js';
import { EditorInput } from '../../../common/editor/editorInput.js';
import { join } from '../../../../base/common/path.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js';
import { IWorkbenchMcpServer } from '../common/mcpTypes.js';
const ExtensionEditorIcon = registerIcon('extensions-editor-label-icon', Codicon.extensions, localize('extensionsEditorLabelIcon', 'Icon of the extensions editor label.'));
export class McpServerEditorInput extends EditorInput {
static readonly ID = 'workbench.mcpServer.input2';
override get typeId(): string {
return McpServerEditorInput.ID;
}
override get capabilities(): EditorInputCapabilities {
return EditorInputCapabilities.Readonly | EditorInputCapabilities.Singleton;
}
override get resource() {
return URI.from({
scheme: Schemas.extension,
path: join(this.mcpServer.id, 'mcpServer')
});
}
constructor(private _mcpServer: IWorkbenchMcpServer) {
super();
}
get mcpServer(): IWorkbenchMcpServer { return this._mcpServer; }
override getName(): string {
return localize('extensionsInputName', "Extension: {0}", this._mcpServer.label);
}
override getIcon(): ThemeIcon | undefined {
return ExtensionEditorIcon;
}
override matches(other: EditorInput | IUntypedEditorInput): boolean {
if (super.matches(other)) {
return true;
}
return other instanceof McpServerEditorInput && this._mcpServer.name === other._mcpServer.name;
}
}

View File

@@ -0,0 +1,252 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as dom from '../../../../base/browser/dom.js';
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
import { IManagedHover } from '../../../../base/browser/ui/hover/hover.js';
import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
import { KeyCode } from '../../../../base/common/keyCodes.js';
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import * as platform from '../../../../base/common/platform.js';
import { URI } from '../../../../base/common/uri.js';
import { localize } from '../../../../nls.js';
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
import { verifiedPublisherIcon } from '../../../services/extensionManagement/common/extensionsIcons.js';
import { installCountIcon, starEmptyIcon, starFullIcon, starHalfIcon } from '../../extensions/browser/extensionsIcons.js';
import { IMcpServerContainer, IWorkbenchMcpServer } from '../common/mcpTypes.js';
export abstract class McpServerWidget extends Disposable implements IMcpServerContainer {
private _mcpServer: IWorkbenchMcpServer | null = null;
get mcpServer(): IWorkbenchMcpServer | null { return this._mcpServer; }
set mcpServer(mcpServer: IWorkbenchMcpServer | null) { this._mcpServer = mcpServer; this.update(); }
update(): void { this.render(); }
abstract render(): void;
}
export function onClick(element: HTMLElement, callback: () => void): IDisposable {
const disposables: DisposableStore = new DisposableStore();
disposables.add(dom.addDisposableListener(element, dom.EventType.CLICK, dom.finalHandler(callback)));
disposables.add(dom.addDisposableListener(element, dom.EventType.KEY_UP, e => {
const keyboardEvent = new StandardKeyboardEvent(e);
if (keyboardEvent.equals(KeyCode.Space) || keyboardEvent.equals(KeyCode.Enter)) {
e.preventDefault();
e.stopPropagation();
callback();
}
}));
return disposables;
}
export class PublisherWidget extends McpServerWidget {
private element: HTMLElement | undefined;
private containerHover: IManagedHover | undefined;
private readonly disposables = this._register(new DisposableStore());
constructor(
readonly container: HTMLElement,
private small: boolean,
@IHoverService private readonly hoverService: IHoverService,
@IOpenerService private readonly openerService: IOpenerService,
) {
super();
this.render();
this._register(toDisposable(() => this.clear()));
}
private clear(): void {
this.element?.remove();
this.disposables.clear();
}
render(): void {
this.clear();
if (!this.mcpServer?.publisherDisplayName) {
return;
}
this.element = dom.append(this.container, dom.$('.publisher'));
const publisherDisplayName = dom.$('.publisher-name.ellipsis');
publisherDisplayName.textContent = this.mcpServer.publisherDisplayName;
const verifiedPublisher = dom.$('.verified-publisher');
dom.append(verifiedPublisher, dom.$('span.extension-verified-publisher.clickable'), renderIcon(verifiedPublisherIcon));
if (this.small) {
if (this.mcpServer.gallery?.publisherDomain?.verified) {
dom.append(this.element, verifiedPublisher);
}
dom.append(this.element, publisherDisplayName);
} else {
this.element.setAttribute('role', 'button');
this.element.tabIndex = 0;
this.containerHover = this.disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this.element, localize('publisher', "Publisher ({0})", this.mcpServer.publisherDisplayName)));
dom.append(this.element, publisherDisplayName);
if (this.mcpServer.gallery?.publisherDomain?.verified) {
dom.append(this.element, verifiedPublisher);
const publisherDomainLink = URI.parse(this.mcpServer.gallery?.publisherDomain.link);
verifiedPublisher.tabIndex = 0;
verifiedPublisher.setAttribute('role', 'button');
this.containerHover.update(localize('verified publisher', "This publisher has verified ownership of {0}", this.mcpServer.gallery?.publisherDomain.link));
verifiedPublisher.setAttribute('role', 'link');
dom.append(verifiedPublisher, dom.$('span.extension-verified-publisher-domain', undefined, publisherDomainLink.authority.startsWith('www.') ? publisherDomainLink.authority.substring(4) : publisherDomainLink.authority));
this.disposables.add(onClick(verifiedPublisher, () => this.openerService.open(publisherDomainLink)));
}
}
}
}
export class InstallCountWidget extends McpServerWidget {
private readonly disposables = this._register(new DisposableStore());
constructor(
readonly container: HTMLElement,
private small: boolean,
@IHoverService private readonly hoverService: IHoverService,
) {
super();
this.render();
this._register(toDisposable(() => this.clear()));
}
private clear(): void {
this.container.innerText = '';
this.disposables.clear();
}
render(): void {
this.clear();
if (!this.mcpServer?.installCount) {
return;
}
const installLabel = InstallCountWidget.getInstallLabel(this.mcpServer, this.small);
if (!installLabel) {
return;
}
const parent = this.small ? this.container : dom.append(this.container, dom.$('span.install', { tabIndex: 0 }));
dom.append(parent, dom.$('span' + ThemeIcon.asCSSSelector(installCountIcon)));
const count = dom.append(parent, dom.$('span.count'));
count.textContent = installLabel;
if (!this.small) {
this.disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this.container, localize('install count', "Install count")));
}
}
static getInstallLabel(extension: IWorkbenchMcpServer, small: boolean): string | undefined {
const installCount = extension.installCount;
if (!installCount) {
return undefined;
}
let installLabel: string;
if (small) {
if (installCount > 1000000) {
installLabel = `${Math.floor(installCount / 100000) / 10}M`;
} else if (installCount > 1000) {
installLabel = `${Math.floor(installCount / 1000)}K`;
} else {
installLabel = String(installCount);
}
}
else {
installLabel = installCount.toLocaleString(platform.language);
}
return installLabel;
}
}
export class RatingsWidget extends McpServerWidget {
private containerHover: IManagedHover | undefined;
private readonly disposables = this._register(new DisposableStore());
constructor(
readonly container: HTMLElement,
private small: boolean,
@IHoverService private readonly hoverService: IHoverService,
) {
super();
container.classList.add('extension-ratings');
if (this.small) {
container.classList.add('small');
}
this.render();
this._register(toDisposable(() => this.clear()));
}
private clear(): void {
this.container.innerText = '';
this.disposables.clear();
}
render(): void {
this.clear();
if (!this.mcpServer) {
return;
}
if (this.mcpServer.rating === undefined) {
return;
}
if (this.small && !this.mcpServer.ratingCount) {
return;
}
if (!this.mcpServer.url) {
return;
}
const rating = Math.round(this.mcpServer.rating * 2) / 2;
if (this.small) {
dom.append(this.container, dom.$('span' + ThemeIcon.asCSSSelector(starFullIcon)));
const count = dom.append(this.container, dom.$('span.count'));
count.textContent = String(rating);
} else {
const element = dom.append(this.container, dom.$('span.rating.clickable', { tabIndex: 0 }));
for (let i = 1; i <= 5; i++) {
if (rating >= i) {
dom.append(element, dom.$('span' + ThemeIcon.asCSSSelector(starFullIcon)));
} else if (rating >= i - 0.5) {
dom.append(element, dom.$('span' + ThemeIcon.asCSSSelector(starHalfIcon)));
} else {
dom.append(element, dom.$('span' + ThemeIcon.asCSSSelector(starEmptyIcon)));
}
}
if (this.mcpServer.ratingCount) {
const ratingCountElement = dom.append(element, dom.$('span', undefined, ` (${this.mcpServer.ratingCount})`));
ratingCountElement.style.paddingLeft = '1px';
}
this.containerHover = this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), element, ''));
this.containerHover.update(localize('ratedLabel', "Average rating: {0} out of 5", rating));
element.setAttribute('role', 'link');
}
}
}

View File

@@ -0,0 +1,206 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import './media/mcpServers.css';
import * as dom from '../../../../base/browser/dom.js';
import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
import { IListRenderer } from '../../../../base/browser/ui/list/list.js';
import { Event } from '../../../../base/common/event.js';
import { combinedDisposable, dispose, IDisposable } from '../../../../base/common/lifecycle.js';
import { DelayedPagedModel, IPagedModel, PagedModel } from '../../../../base/common/paging.js';
import { localize } from '../../../../nls.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
import { WorkbenchPagedList } from '../../../../platform/list/browser/listService.js';
import { INotificationService } from '../../../../platform/notification/common/notification.js';
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
import { getLocationBasedViewColors, ViewPane } from '../../../browser/parts/views/viewPane.js';
import { IViewletViewOptions } from '../../../browser/parts/views/viewsViewlet.js';
import { IViewDescriptorService } from '../../../common/views.js';
import { DefaultIconPath } from '../../../services/extensionManagement/common/extensionManagement.js';
import { IMcpWorkbenchService, IWorkbenchMcpServer, McpServerContainers } from '../common/mcpTypes.js';
import { InstallAction, UninstallAction } from './mcpServerActions.js';
import { PublisherWidget, InstallCountWidget, RatingsWidget } from './mcpServerWidgets.js';
export class McpServersListView extends ViewPane {
private list: WorkbenchPagedList<IWorkbenchMcpServer> | null = null;
constructor(
options: IViewletViewOptions,
@IKeybindingService keybindingService: IKeybindingService,
@IContextMenuService contextMenuService: IContextMenuService,
@IInstantiationService instantiationService: IInstantiationService,
@IThemeService themeService: IThemeService,
@IHoverService hoverService: IHoverService,
@IConfigurationService configurationService: IConfigurationService,
@IContextKeyService contextKeyService: IContextKeyService,
@IViewDescriptorService viewDescriptorService: IViewDescriptorService,
@IOpenerService openerService: IOpenerService,
@IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService,
) {
super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);
}
protected override renderBody(container: HTMLElement): void {
super.renderBody(container);
const mcpServersList = dom.append(container, dom.$('.mcp-servers-list'));
this.list = this._register(this.instantiationService.createInstance(WorkbenchPagedList,
`${this.id}-MCP-Servers`,
mcpServersList,
{
getHeight() { return 72; },
getTemplateId: () => McpServerRenderer.templateId,
},
[this.instantiationService.createInstance(McpServerRenderer)],
{
multipleSelectionSupport: false,
setRowLineHeight: false,
horizontalScrolling: false,
accessibilityProvider: {
getAriaLabel(mcpServer: IWorkbenchMcpServer | null): string {
return mcpServer?.label ?? '';
},
getWidgetAriaLabel(): string {
return localize('mcp servers', "MCP Servers");
}
},
overrideStyles: getLocationBasedViewColors(this.viewDescriptorService.getViewLocationById(this.id)).listOverrideStyles,
openOnSingleClick: true,
}) as WorkbenchPagedList<IWorkbenchMcpServer>);
this._register(Event.debounce(Event.filter(this.list.onDidOpen, e => e.element !== null), (_, event) => event, 75, true)(options => {
this.mcpWorkbenchService.open(options.element!, options.editorOptions);
}));
}
protected override layoutBody(height: number, width: number): void {
super.layoutBody(height, width);
this.list?.layout(height, width);
}
async show(query: string): Promise<IPagedModel<IWorkbenchMcpServer>> {
if (!this.list) {
return new PagedModel([]);
}
query = query.trim();
const servers = query ? await this.mcpWorkbenchService.queryGallery({ text: query.replace('@mcp', '') }) : await this.mcpWorkbenchService.queryLocal();
this.list.model = new DelayedPagedModel(new PagedModel(servers));
return this.list.model;
}
}
interface IMcpServerTemplateData {
root: HTMLElement;
element: HTMLElement;
icon: HTMLImageElement;
name: HTMLElement;
description: HTMLElement;
installCount: HTMLElement;
ratings: HTMLElement;
mcpServer: IWorkbenchMcpServer | null;
disposables: IDisposable[];
mcpServerDisposables: IDisposable[];
actionbar: ActionBar;
}
class McpServerRenderer implements IListRenderer<IWorkbenchMcpServer, IMcpServerTemplateData> {
static readonly templateId = 'mcpServer';
readonly templateId = McpServerRenderer.templateId;
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@INotificationService private readonly notificationService: INotificationService,
) { }
renderTemplate(root: HTMLElement): IMcpServerTemplateData {
const element = dom.append(root, dom.$('.mcp-server-item'));
const iconContainer = dom.append(element, dom.$('.icon-container'));
const icon = dom.append(iconContainer, dom.$<HTMLImageElement>('img.icon', { alt: '' }));
const details = dom.append(element, dom.$('.details-container'));
const nameContainer = dom.append(details, dom.$('.name-container'));
const name = dom.append(nameContainer, dom.$('span.name'));
const description = dom.append(details, dom.$('.description.ellipsis'));
const footerContainer = dom.append(details, dom.$('.footer-container'));
const publisherWidget = this.instantiationService.createInstance(PublisherWidget, dom.append(footerContainer, dom.$('.publisher-container')), true);
const statsContainer = dom.append(footerContainer, dom.$('.stats-container'));
const installCount = dom.append(statsContainer, dom.$('span.install-count'));
const ratings = dom.append(statsContainer, dom.$('span.ratings'));
const actionbar = new ActionBar(dom.append(footerContainer, dom.$('.actions-container.mcp-server-actions')));
actionbar.setFocusable(false);
const actionBarListener = actionbar.onDidRun(({ error }) => error && this.notificationService.error(error));
const actions = [
this.instantiationService.createInstance(InstallAction),
this.instantiationService.createInstance(UninstallAction),
];
const widgets = [
publisherWidget,
this.instantiationService.createInstance(InstallCountWidget, installCount, true),
this.instantiationService.createInstance(RatingsWidget, ratings, true),
];
const extensionContainers: McpServerContainers = this.instantiationService.createInstance(McpServerContainers, [...actions, ...widgets]);
actionbar.push(actions, { icon: true, label: true });
const disposable = combinedDisposable(...actions, ...widgets, actionbar, actionBarListener, extensionContainers);
return {
root, element, icon, name, description, installCount, ratings, disposables: [disposable], actionbar,
mcpServerDisposables: [],
set mcpServer(mcpServer: IWorkbenchMcpServer) {
extensionContainers.mcpServer = mcpServer;
}
};
}
renderElement(mcpServer: IWorkbenchMcpServer, index: number, data: IMcpServerTemplateData): void {
data.element.classList.remove('loading');
data.element.classList.remove('hidden');
data.mcpServerDisposables = dispose(data.mcpServerDisposables);
if (!mcpServer) {
data.element.classList.add('hidden');
data.mcpServer = null;
return;
}
data.root.setAttribute('data-mcp-server-id', mcpServer.id);
data.mcpServerDisposables.push(dom.addDisposableListener(data.icon, 'error', () => data.icon.src = DefaultIconPath, { once: true }));
data.icon.src = mcpServer.iconUrl;
if (!data.icon.complete) {
data.icon.style.visibility = 'hidden';
data.icon.onload = () => data.icon.style.visibility = 'inherit';
} else {
data.icon.style.visibility = 'inherit';
}
data.name.textContent = mcpServer.label;
data.description.textContent = mcpServer.description;
data.installCount.style.display = '';
data.ratings.style.display = '';
data.mcpServer = mcpServer;
}
disposeElement(mcpServer: IWorkbenchMcpServer, index: number, data: IMcpServerTemplateData): void {
data.mcpServerDisposables = dispose(data.mcpServerDisposables);
}
disposeTemplate(data: IMcpServerTemplateData): void {
data.mcpServerDisposables = dispose(data.mcpServerDisposables);
data.disposables = dispose(data.disposables);
}
}

View File

@@ -0,0 +1,180 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { Emitter } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { IEditorOptions } from '../../../../platform/editor/common/editor.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { DidUninstallMcpServerEvent, IGalleryMcpServer, ILocalMcpServer, IMcpGalleryService, IMcpManagementService, InstallMcpServerResult, IQueryOptions } from '../../../../platform/mcp/common/mcpManagement.js';
import { IWorkbenchContribution } from '../../../common/contributions.js';
import { ACTIVE_GROUP, IEditorService } from '../../../services/editor/common/editorService.js';
import { DefaultIconPath } from '../../../services/extensionManagement/common/extensionManagement.js';
import { HasInstalledMcpServersContext, IMcpWorkbenchService, IWorkbenchMcpServer } from '../common/mcpTypes.js';
import { McpServerEditorInput } from './mcpServerEditorInput.js';
class McpWorkbenchServer implements IWorkbenchMcpServer {
constructor(
public local: ILocalMcpServer | undefined,
public gallery: IGalleryMcpServer | undefined,
) {
}
get id(): string {
return this.gallery?.id ?? this.local?.id ?? '';
}
get name(): string {
return this.gallery?.name ?? this.local?.name ?? '';
}
get label(): string {
return this.gallery?.displayName ?? this.local?.displayName ?? '';
}
get iconUrl(): string {
return this.gallery?.iconUrl ?? this.local?.iconUrl ?? DefaultIconPath;
}
get publisherDisplayName(): string | undefined {
return this.gallery?.publisherDisplayName ?? this.local?.publisherDisplayName ?? this.gallery?.publisher ?? this.local?.publisher;
}
get publisherUrl(): string | undefined {
return this.gallery?.publisherDomain?.link;
}
get description(): string {
return this.gallery?.description ?? this.local?.description ?? '';
}
get installCount(): number {
return this.gallery?.installCount ?? 0;
}
get url(): string | undefined {
return this.gallery?.url;
}
get repository(): string | undefined {
return this.gallery?.repositoryUrl;
}
get readmeUrl(): string | undefined {
return this.gallery?.readmeUrl ?? this.local?.readmeUrl;
}
}
export class McpWorkbenchService extends Disposable implements IMcpWorkbenchService {
_serviceBrand: undefined;
private _local: McpWorkbenchServer[] = [];
get local(): readonly McpWorkbenchServer[] { return this._local; }
private readonly _onChange = this._register(new Emitter<IWorkbenchMcpServer | undefined>());
readonly onChange = this._onChange.event;
constructor(
@IMcpGalleryService private readonly mcpGalleryService: IMcpGalleryService,
@IMcpManagementService private readonly mcpManagementService: IMcpManagementService,
@IEditorService private readonly editorService: IEditorService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
) {
super();
this._register(this.mcpManagementService.onDidInstallMcpServers(e => this.onDidInstallMcpServers(e)));
this._register(this.mcpManagementService.onDidUninstallMcpServer(e => this.onDidUninstallMcpServer(e)));
this.queryLocal().then(async () => {
await this.queryGallery();
this._onChange.fire(undefined);
});
}
private onDidUninstallMcpServer(e: DidUninstallMcpServerEvent) {
if (e.error) {
return;
}
const server = this._local.find(server => server.local?.name === e.name);
if (server) {
this._local = this._local.filter(server => server.local?.name !== e.name);
server.local = undefined;
this._onChange.fire(server);
}
}
private onDidInstallMcpServers(e: readonly InstallMcpServerResult[]) {
for (const result of e) {
if (!result.local) {
continue;
}
let server = this._local.find(server => server.local?.name === result.name);
if (server) {
server.local = result.local;
} else {
server = new McpWorkbenchServer(result.local, result.source);
this._local.push(server);
}
this._onChange.fire(server);
}
}
private fromGallery(gallery: IGalleryMcpServer): IWorkbenchMcpServer | undefined {
for (const local of this._local) {
if (local.id === gallery.id) {
local.gallery = gallery;
return local;
}
}
return undefined;
}
async queryGallery(options?: IQueryOptions, token?: CancellationToken): Promise<IWorkbenchMcpServer[]> {
const result = await this.mcpGalleryService.query(options, token);
return result.map(gallery => this.fromGallery(gallery) ?? new McpWorkbenchServer(undefined, gallery));
}
async queryLocal(): Promise<IWorkbenchMcpServer[]> {
const local = await this.mcpManagementService.getInstalled();
this._local = local.map(local => new McpWorkbenchServer(local, undefined));
return this._local;
}
async install(server: IWorkbenchMcpServer): Promise<void> {
if (!server.gallery) {
throw new Error('Gallery server is missing');
}
await this.mcpManagementService.installFromGallery(server.gallery, server.gallery.packageTypes[0]);
}
async uninstall(server: IWorkbenchMcpServer): Promise<void> {
if (!server.local) {
throw new Error('Local server is missing');
}
await this.mcpManagementService.uninstall(server.local);
}
async open(extension: IWorkbenchMcpServer, options?: IEditorOptions): Promise<void> {
await this.editorService.openEditor(this.instantiationService.createInstance(McpServerEditorInput, extension), options, ACTIVE_GROUP);
}
}
export class MCPContextsInitialisation extends Disposable implements IWorkbenchContribution {
static ID = 'workbench.mcp.contexts.initialisation';
constructor(
@IMcpWorkbenchService mcpWorkbenchService: IMcpWorkbenchService,
@IContextKeyService contextKeyService: IContextKeyService,
) {
super();
const hasInstalledMcpServersContextKey = HasInstalledMcpServersContext.bindTo(contextKeyService);
this._register(mcpWorkbenchService.onChange(() => hasInstalledMcpServersContextKey.set(mcpWorkbenchService.local.length > 0)));
}
}

View File

@@ -0,0 +1,170 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
.mcp-server-item {
height: 100%;
padding: 0 0 0 16px;
overflow: hidden;
display: flex;
.icon-container {
position: relative;
display: flex;
align-items: center;
.icon {
width: 42px;
height: 42px;
padding-right: 14px;
flex-shrink: 0;
object-fit: contain;
}
}
.details-container {
flex: 1;
display: flex;
overflow: hidden;
flex-direction: column;
justify-content: center;
.name-container {
height: 24px;
display: flex;
overflow: hidden;
padding-right: 11px;
align-items: center;
flex-wrap: nowrap;
.name {
font-weight: bold;
flex: 1;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
.publisher-container {
line-height: 24px;
.publisher {
display: flex;
align-items: center;
.publisher-name {
font-size: 90%;
color: var(--vscode-descriptionForeground);
font-weight: 600;
}
.publisher-name:not(:first-child) {
padding-left: 1px;
}
}
}
.footer-container {
display: flex;
padding-right: 7px;
height: 24px;
overflow: hidden;
align-items: center;
.stats-container {
flex: 1;
.codicon {
font-size: 120%;
margin-right: 3px;
-webkit-mask: inherit;
}
.install-count,
.ratings {
display: flex;
align-items: center;
}
.ratings {
text-align: right;
}
.install-count:not(:empty) {
font-size: 80%;
}
}
}
}
.ellipsis {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.mcp-server-actions > .monaco-action-bar > .actions-container {
.action-item.disabled {
.action-label {
display: none;
}
}
.action-label:not(.icon) {
border-radius: 2px;
}
.action-label {
max-width: 150px;
line-height: 16px;
line-height: initial;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.label,
.text {
width: auto;
height: auto;
line-height: 14px;
}
.label {
padding: 0 5px;
outline-offset: 1px;
color: var(--vscode-extensionButton-foreground) !important;
background-color: var(--vscode-extensionButton-background) !important;
.prominent {
background-color: var(--vscode-extensionButton-prominentBackground);
color: var(--vscode-extensionButton-prominentForeground) !important;
}
.prominent:hover {
background-color: var(--vscode-extensionButton-prominentHoverBackground);
}
}
.label:hover {
background-color: var(--vscode-extensionButton-hoverBackground) !important;
}
}
.monaco-list-row.selected {
.mcp-server-item {
.details {
.description {
color: unset;
}
}
}
}
.hc-black .mcp-server-item > .details > .description,
.hc-light .mcp-server-item > .details > .description {
color: unset;
}

View File

@@ -19,5 +19,6 @@ export const enum McpCommandIds {
RestartServer = 'workbench.mcp.restartServer',
StartServer = 'workbench.mcp.startServer',
StopServer = 'workbench.mcp.stopServer',
InstallFromActivation = 'workbench.mcp.installFromActivation'
InstallFromActivation = 'workbench.mcp.installFromActivation',
Browse = 'workbench.mcp.browseServers'
}

View File

@@ -4,9 +4,10 @@
*--------------------------------------------------------------------------------------------*/
import { equals as arraysEqual } from '../../../../base/common/arrays.js';
import { Event } from '../../../../base/common/event.js';
import { assertNever } from '../../../../base/common/assert.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { IDisposable } from '../../../../base/common/lifecycle.js';
import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';
import { equals as objectsEqual } from '../../../../base/common/objects.js';
import { IObservable } from '../../../../base/common/observable.js';
import { URI, UriComponents } from '../../../../base/common/uri.js';
@@ -20,6 +21,9 @@ import { IWorkspaceFolderData } from '../../../../platform/workspace/common/work
import { ToolProgress } from '../../chat/common/languageModelToolsService.js';
import { McpServerRequestHandler } from './mcpServerRequestHandler.js';
import { MCP } from './modelContextProtocol.js';
import { IGalleryMcpServer, ILocalMcpServer, IQueryOptions, mcpGalleryServiceUrlConfig } from '../../../../platform/mcp/common/mcpManagement.js';
import { IEditorOptions } from '../../../../platform/editor/common/editor.js';
import { ContextKeyDefinedExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
export const extensionMcpCollectionPrefix = 'ext.';
@@ -437,3 +441,67 @@ export class MpcResponseError extends Error {
}
export class McpConnectionFailedError extends Error { }
export interface IMcpServerContainer extends IDisposable {
mcpServer: IWorkbenchMcpServer | null;
update(): void;
}
export interface IWorkbenchMcpServer {
readonly gallery: IGalleryMcpServer | undefined;
readonly local: ILocalMcpServer | undefined;
readonly id: string;
readonly name: string;
readonly label: string;
readonly description: string;
readonly iconUrl: string;
readonly publisherUrl?: string;
readonly publisherDisplayName?: string;
readonly installCount?: number;
readonly ratingCount?: number;
readonly rating?: number;
readonly url?: string;
readonly repository?: string;
readonly readmeUrl?: string;
}
export const IMcpWorkbenchService = createDecorator<IMcpWorkbenchService>('IMcpWorkbenchService');
export interface IMcpWorkbenchService {
readonly _serviceBrand: undefined;
readonly onChange: Event<IWorkbenchMcpServer | undefined>;
readonly local: readonly IWorkbenchMcpServer[];
queryLocal(): Promise<IWorkbenchMcpServer[]>;
queryGallery(options?: IQueryOptions, token?: CancellationToken): Promise<IWorkbenchMcpServer[]>;
install(mcpServer: IWorkbenchMcpServer): Promise<void>;
uninstall(mcpServer: IWorkbenchMcpServer): Promise<void>;
open(extension: IWorkbenchMcpServer | string, options?: IEditorOptions): Promise<void>;
}
export class McpServerContainers extends Disposable {
constructor(
private readonly containers: IMcpServerContainer[],
@IMcpWorkbenchService mcpWorkbenchService: IMcpWorkbenchService
) {
super();
this._register(mcpWorkbenchService.onChange(this.update, this));
}
set mcpServer(extension: IWorkbenchMcpServer | null) {
this.containers.forEach(c => c.mcpServer = extension);
}
update(server: IWorkbenchMcpServer | undefined): void {
for (const container of this.containers) {
if (server && container.mcpServer) {
if (server.name === container.mcpServer.name) {
container.mcpServer = server;
}
} else {
container.update();
}
}
}
}
export const McpServersGalleryEnabledContext = ContextKeyDefinedExpr.create(`config.${mcpGalleryServiceUrlConfig}`);
export const HasInstalledMcpServersContext = new RawContextKey<boolean>('hasInstalledMcpServers', false);

View File

@@ -0,0 +1,104 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
.preferences-editor {
height: 100%;
overflow: hidden;
max-width: 1200px;
margin: auto;
.preferences-editor-header {
box-sizing: border-box;
margin: auto;
overflow: hidden;
margin-top: 11px;
padding-top: 3px;
padding-left: 24px;
padding-right: 24px;
max-width: 1200px;
.search-container {
position: relative;
.suggest-input-container {
border: 1px solid #ddd;
}
}
.preferences-tabs-container {
height: 32px;
display: flex;
border-bottom: solid 1px;
margin-top: 10px;
border-color: var(--vscode-settings-headerBorder);
.action-item {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
.action-title {
text-overflow: ellipsis;
overflow: hidden;
}
.action-details {
opacity: 0.9;
text-transform: none;
margin-left: 0.5em;
font-size: 10px;
}
.action-label {
font-size: 13px;
padding: 7px 8px 6.5px 8px;
opacity: 0.9;
border-radius: 0;
color: var(--vscode-foreground);
overflow: hidden;
text-overflow: ellipsis;
background: none !important;
color: var(--vscode-panelTitle-inactiveForeground);
}
.action-label.checked {
opacity: 1;
color: var(--vscode-settings-headerForeground);
border-bottom: 1px solid var(--vscode-panelTitle-activeBorder);
outline: 1px solid var(--vscode-contrastActiveBorder, transparent);
outline-offset: -1px;
}
.action-label:hover {
color: var(--vscode-panelTitle-activeForeground);
border-bottom: 1px solid var(--vscode-panelTitle-activeBorder);
outline: 1px solid var(--vscode-contrastActiveBorder, transparent);
outline-offset: -1px;
}
.action-label:focus {
border-bottom: 1px solid var(--vscode-focusBorder);
outline: 1px solid transparent;
outline-offset: -1px;
}
.action-label.checked:not(:focus) {
border-bottom-color: var(--vscode-settings-headerForeground);
}
.action-label:not(.checked):not(:focus) {
/* Still maintain a border for alignment, but keep it transparent */
border-bottom: 1px solid transparent;
}
.action-label:not(.checked):hover {
outline-style: dashed;
}
}
}
}
}

View File

@@ -39,7 +39,7 @@ import { IWorkbenchEnvironmentService } from '../../../services/environment/comm
import { IExtensionService } from '../../../services/extensions/common/extensions.js';
import { KeybindingsEditorInput } from '../../../services/preferences/browser/keybindingsEditorInput.js';
import { DEFINE_KEYBINDING_EDITOR_CONTRIB_ID, IDefineKeybindingEditorContribution, IPreferencesService } from '../../../services/preferences/common/preferences.js';
import { SettingsEditor2Input } from '../../../services/preferences/common/preferencesEditorInput.js';
import { PreferencesEditorInput, SettingsEditor2Input } from '../../../services/preferences/common/preferencesEditorInput.js';
import { IUserDataProfileService, CURRENT_PROFILE_CONTEXT } from '../../../services/userDataProfile/common/userDataProfile.js';
import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js';
import { ICodeEditor, isCodeEditor } from '../../../../editor/browser/editorBrowser.js';
@@ -50,6 +50,7 @@ import { IListService } from '../../../../platform/list/browser/listService.js';
import { SettingsEditorModel } from '../../../services/preferences/common/preferencesModels.js';
import { IPreferencesRenderer, WorkspaceSettingsRenderer, UserSettingsRenderer } from './preferencesRenderers.js';
import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js';
import { PreferencesEditor } from './preferencesEditor.js';
const SETTINGS_EDITOR_COMMAND_SEARCH = 'settings.action.search';
@@ -78,6 +79,32 @@ Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane
]
);
Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane(
EditorPaneDescriptor.create(
PreferencesEditor,
PreferencesEditor.ID,
nls.localize('preferencesEditor', "Preferences Editor")
),
[
new SyncDescriptor(PreferencesEditorInput)
]
);
class PreferencesEditorInputSerializer implements IEditorSerializer {
canSerialize(editorInput: EditorInput): boolean {
return true;
}
serialize(editorInput: EditorInput): string {
return '';
}
deserialize(instantiationService: IInstantiationService): EditorInput {
return instantiationService.createInstance(PreferencesEditorInput);
}
}
Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane(
EditorPaneDescriptor.create(
KeybindingsEditor,
@@ -119,6 +146,7 @@ class SettingsEditor2InputSerializer implements IEditorSerializer {
}
}
Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory).registerEditorSerializer(PreferencesEditorInput.ID, PreferencesEditorInputSerializer);
Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory).registerEditorSerializer(KeybindingsEditorInput.ID, KeybindingsEditorInputSerializer);
Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory).registerEditorSerializer(SettingsEditor2Input.ID, SettingsEditor2InputSerializer);

View File

@@ -0,0 +1,174 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import './media/preferencesEditor.css';
import * as DOM from '../../../../base/browser/dom.js';
import { localize } from '../../../../nls.js';
import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { IStorageService } from '../../../../platform/storage/common/storage.js';
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
import { Event } from '../../../../base/common/event.js';
import { getInputBoxStyle } from '../../../../platform/theme/browser/defaultStyles.js';
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
import { EditorPane } from '../../../browser/parts/editor/editorPane.js';
import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js';
import { CONTEXT_PREFERENCES_SEARCH_FOCUS } from '../common/preferences.js';
import { settingsTextInputBorder } from '../common/settingsEditorColorRegistry.js';
import { SearchWidget } from './preferencesWidgets.js';
import { ActionBar, ActionsOrientation } from '../../../../base/browser/ui/actionbar/actionbar.js';
import { Registry } from '../../../../platform/registry/common/platform.js';
import { IPreferencesEditorPaneRegistry, Extensions, IPreferencesEditorPaneDescriptor, IPreferencesEditorPane } from './preferencesEditorRegistry.js';
import { Action } from '../../../../base/common/actions.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { IEditorOptions } from '../../../../platform/editor/common/editor.js';
import { IEditorOpenContext } from '../../../common/editor.js';
import { EditorInput } from '../../../common/editor/editorInput.js';
import { MutableDisposable } from '../../../../base/common/lifecycle.js';
class PreferenceTabAction extends Action {
constructor(readonly descriptor: IPreferencesEditorPaneDescriptor, actionCallback: () => void) {
super(descriptor.id, descriptor.title, '', true, actionCallback);
}
}
export class PreferencesEditor extends EditorPane {
static readonly ID: string = 'workbench.editor.preferences';
private readonly editorPanesRegistry = Registry.as<IPreferencesEditorPaneRegistry>(Extensions.PreferencesEditorPane);
private readonly element: HTMLElement;
private readonly bodyElement: HTMLElement;
private readonly searchWidget: SearchWidget;
private readonly preferencesTabActionBar: ActionBar;
private readonly preferencesTabActions: PreferenceTabAction[] = [];
private readonly preferencesEditorPane = this._register(new MutableDisposable<IPreferencesEditorPane>());
private readonly searchFocusContextKey: IContextKey<boolean>;
private dimension: DOM.Dimension | undefined;
constructor(
group: IEditorGroup,
@ITelemetryService telemetryService: ITelemetryService,
@IThemeService themeService: IThemeService,
@IStorageService storageService: IStorageService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IContextKeyService contextKeyService: IContextKeyService,
) {
super(PreferencesEditor.ID, group, telemetryService, themeService, storageService);
this.searchFocusContextKey = CONTEXT_PREFERENCES_SEARCH_FOCUS.bindTo(contextKeyService);
this.element = DOM.$('.preferences-editor');
const headerContainer = DOM.append(this.element, DOM.$('.preferences-editor-header'));
const searchContainer = DOM.append(headerContainer, DOM.$('.search-container'));
this.searchWidget = this._register(this.instantiationService.createInstance(SearchWidget, searchContainer, {
focusKey: this.searchFocusContextKey,
inputBoxStyles: getInputBoxStyle({
inputBorder: settingsTextInputBorder
})
}));
this._register(Event.debounce(this.searchWidget.onDidChange, () => undefined, 300)(() => {
this.preferencesEditorPane.value?.search(this.searchWidget.getValue());
}));
const preferencesTabsContainer = DOM.append(headerContainer, DOM.$('.preferences-tabs-container'));
this.preferencesTabActionBar = this._register(new ActionBar(preferencesTabsContainer, {
orientation: ActionsOrientation.HORIZONTAL,
focusOnlyEnabledItems: true,
ariaLabel: localize('preferencesTabSwitcherBarAriaLabel', "Preferences Tab Switcher"),
ariaRole: 'tablist',
}));
this.onDidChangePreferencesEditorPane(this.editorPanesRegistry.getPreferencesEditorPanes(), []);
this._register(this.editorPanesRegistry.onDidRegisterPreferencesEditorPanes(descriptors => this.onDidChangePreferencesEditorPane(descriptors, [])));
this._register(this.editorPanesRegistry.onDidDeregisterPreferencesEditorPanes(descriptors => this.onDidChangePreferencesEditorPane([], descriptors)));
this.bodyElement = DOM.append(this.element, DOM.$('.preferences-editor-body'));
}
protected createEditor(parent: HTMLElement): void {
DOM.append(parent, this.element);
}
layout(dimension: DOM.Dimension): void {
this.dimension = dimension;
this.searchWidget.layout(dimension);
this.searchWidget.inputBox.inputElement.style.paddingRight = `12px`;
this.preferencesEditorPane.value?.layout(new DOM.Dimension(this.bodyElement.clientWidth, dimension.height - 87 /* header height */));
}
override async setInput(input: EditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise<void> {
await super.setInput(input, options, context, token);
if (this.preferencesTabActions.length) {
this.onDidSelectPreferencesEditorPane(this.preferencesTabActions[0].id);
}
}
private onDidChangePreferencesEditorPane(toAdd: readonly IPreferencesEditorPaneDescriptor[], toRemove: readonly IPreferencesEditorPaneDescriptor[]): void {
for (const desc of toRemove) {
const index = this.preferencesTabActions.findIndex(action => action.id === desc.id);
if (index !== -1) {
this.preferencesTabActionBar.pull(index);
this.preferencesTabActions[index].dispose();
this.preferencesTabActions.splice(index, 1);
}
}
if (toAdd.length > 0) {
const all = this.editorPanesRegistry.getPreferencesEditorPanes();
for (const desc of toAdd) {
const index = all.findIndex(action => action.id === desc.id);
if (index !== -1) {
const action = new PreferenceTabAction(desc, () => this.onDidSelectPreferencesEditorPane(desc.id));
this.preferencesTabActions.splice(index, 0, action);
this.preferencesTabActionBar.push(action, { index });
}
}
}
}
private onDidSelectPreferencesEditorPane(id: string): void {
let selectedAction: PreferenceTabAction | undefined;
for (const action of this.preferencesTabActions) {
if (action.id === id) {
action.checked = true;
selectedAction = action;
} else {
action.checked = false;
}
}
if (selectedAction) {
this.searchWidget.inputBox.setPlaceHolder(localize('FullTextSearchPlaceholder', "Search {0}", selectedAction.descriptor.title));
this.searchWidget.inputBox.setAriaLabel(localize('FullTextSearchPlaceholder', "Search {0}", selectedAction.descriptor.title));
}
this.renderBody(selectedAction?.descriptor);
if (this.dimension) {
this.layout(this.dimension);
}
}
private renderBody(descriptor?: IPreferencesEditorPaneDescriptor): void {
this.preferencesEditorPane.value = undefined;
DOM.clearNode(this.bodyElement);
if (descriptor) {
const editorPane = this.instantiationService.createInstance<IPreferencesEditorPane>(descriptor.ctorDescriptor.ctor);
this.preferencesEditorPane.value = editorPane;
this.bodyElement.appendChild(editorPane.getDomNode());
}
}
override dispose(): void {
super.dispose();
this.preferencesTabActions.forEach(action => action.dispose());
}
}

View File

@@ -0,0 +1,106 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';
import * as DOM from '../../../../base/browser/dom.js';
import { Event, Emitter } from '../../../../base/common/event.js';
import { ThemeIcon } from '../../../../base/common/themables.js';
import { URI } from '../../../../base/common/uri.js';
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
import { Registry } from '../../../../platform/registry/common/platform.js';
export namespace Extensions {
export const PreferencesEditorPane = 'workbench.registry.preferences.editorPanes';
}
export interface IPreferencesEditorPane extends IDisposable {
getDomNode(): HTMLElement;
layout(dimension: DOM.Dimension): void;
search(text: string): void;
}
export interface IPreferencesEditorPaneDescriptor {
/**
* The id of the view container
*/
readonly id: string;
/**
* The title of the view container
*/
readonly title: string;
/**
* Icon representation of the View container
*/
readonly icon?: ThemeIcon | URI;
/**
* Order of the view container.
*/
readonly order: number;
/**
* IViewPaneContainer Ctor to instantiate
*/
readonly ctorDescriptor: SyncDescriptor<IPreferencesEditorPane>;
/**
* Storage id to use to store the view container state.
* If not provided, it will be derived.
*/
readonly storageId?: string;
}
export interface IPreferencesEditorPaneRegistry {
readonly onDidRegisterPreferencesEditorPanes: Event<IPreferencesEditorPaneDescriptor[]>;
readonly onDidDeregisterPreferencesEditorPanes: Event<IPreferencesEditorPaneDescriptor[]>;
registerPreferencesEditorPane(descriptor: IPreferencesEditorPaneDescriptor): IDisposable;
getPreferencesEditorPanes(): readonly IPreferencesEditorPaneDescriptor[];
}
class PreferencesEditorPaneRegistryImpl extends Disposable implements IPreferencesEditorPaneRegistry {
private readonly descriptors = new Map<string, IPreferencesEditorPaneDescriptor>();
private readonly _onDidRegisterPreferencesEditorPanes = this._register(new Emitter<IPreferencesEditorPaneDescriptor[]>());
readonly onDidRegisterPreferencesEditorPanes = this._onDidRegisterPreferencesEditorPanes.event;
private readonly _onDidDeregisterPreferencesEditorPanes = this._register(new Emitter<IPreferencesEditorPaneDescriptor[]>());
readonly onDidDeregisterPreferencesEditorPanes = this._onDidDeregisterPreferencesEditorPanes.event;
constructor() {
super();
}
registerPreferencesEditorPane(descriptor: IPreferencesEditorPaneDescriptor): IDisposable {
if (this.descriptors.has(descriptor.id)) {
throw new Error(`PreferencesEditorPane with id ${descriptor.id} already registered`);
}
this.descriptors.set(descriptor.id, descriptor);
this._onDidRegisterPreferencesEditorPanes.fire([descriptor]);
return {
dispose: () => {
if (this.descriptors.delete(descriptor.id)) {
this._onDidDeregisterPreferencesEditorPanes.fire([descriptor]);
}
}
};
}
getPreferencesEditorPanes(): readonly IPreferencesEditorPaneDescriptor[] {
return [...this.descriptors.values()].sort((a, b) => a.order - b.order);
}
}
Registry.add(Extensions.PreferencesEditorPane, new PreferencesEditorPaneRegistryImpl());

View File

@@ -5,8 +5,11 @@
import { Codicon } from '../../../../base/common/codicons.js';
import { localize } from '../../../../nls.js';
import { registerColor } from '../../../../platform/theme/common/colorRegistry.js';
import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js';
import { PANEL_BORDER } from '../../../common/theme.js';
export const preferencesSashBorder = registerColor('preferences.sashBorder', PANEL_BORDER, localize('preferencesSashBorder', "The color of the Preferences editor splitview sash border."));
export const settingsScopeDropDownIcon = registerIcon('settings-folder-dropdown', Codicon.triangleDown, localize('settingsScopeDropDownIcon', 'Icon for the folder dropdown button in the split JSON Settings editor.'));
export const settingsMoreActionIcon = registerIcon('settings-more-action', Codicon.gear, localize('settingsMoreActionIcon', 'Icon for the \'more actions\' action in the Settings UI.'));

View File

@@ -50,6 +50,9 @@ export interface IRemoteSearchProvider extends ISearchProvider {
setFilter(filter: string): void;
}
export const PREFERENCES_EDITOR_COMMAND_OPEN = 'workbench.preferences.action.openPreferencesEditor';
export const CONTEXT_PREFERENCES_SEARCH_FOCUS = new RawContextKey<boolean>('inPreferencesSearch', false);
export const SETTINGS_EDITOR_COMMAND_CLEAR_SEARCH_RESULTS = 'settings.action.clearSearchResults';
export const SETTINGS_EDITOR_COMMAND_SHOW_CONTEXT_MENU = 'settings.action.showContextMenu';
export const SETTINGS_EDITOR_COMMAND_SUGGEST_FILTERS = 'settings.action.suggestFilters';

View File

@@ -33,7 +33,7 @@ import { GroupDirection, IEditorGroup, IEditorGroupsService } from '../../editor
import { IEditorService, SIDE_GROUP } from '../../editor/common/editorService.js';
import { KeybindingsEditorInput } from './keybindingsEditorInput.js';
import { DEFAULT_SETTINGS_EDITOR_SETTING, FOLDER_SETTINGS_PATH, IKeybindingsEditorPane, IOpenKeybindingsEditorOptions, IOpenSettingsOptions, IPreferencesEditorModel, IPreferencesService, ISetting, ISettingsEditorOptions, ISettingsGroup, SETTINGS_AUTHORITY, USE_SPLIT_JSON_SETTING, validateSettingsEditorOptions } from '../common/preferences.js';
import { SettingsEditor2Input } from '../common/preferencesEditorInput.js';
import { PreferencesEditorInput, SettingsEditor2Input } from '../common/preferencesEditorInput.js';
import { defaultKeybindingsContents, DefaultKeybindingsEditorModel, DefaultRawSettingsEditorModel, DefaultSettings, DefaultSettingsEditorModel, Settings2EditorModel, SettingsEditorModel, WorkspaceConfigurationEditorModel } from '../common/preferencesModels.js';
import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js';
import { ITextEditorService } from '../../textfile/common/textEditorService.js';
@@ -214,6 +214,10 @@ export class PreferencesService extends Disposable implements IPreferencesServic
return this.configurationService.getValue('workbench.settings.editor') === 'json';
}
async openPreferences(): Promise<void> {
await this.editorGroupService.activeGroup.openEditor(this.instantiationService.createInstance(PreferencesEditorInput));
}
openSettings(options: IOpenSettingsOptions = {}): Promise<IEditorPane | undefined> {
options = {
...options,

View File

@@ -262,6 +262,8 @@ export interface IPreferencesService {
hasDefaultSettingsContent(uri: URI): boolean;
createSettings2EditorModel(): Settings2EditorModel; // TODO
openPreferences(): Promise<void>;
openRawDefaultSettings(): Promise<IEditorPane | undefined>;
openSettings(options?: IOpenSettingsOptions): Promise<IEditorPane | undefined>;
openApplicationSettings(options?: IOpenSettingsOptions): Promise<IEditorPane | undefined>;

View File

@@ -60,3 +60,35 @@ export class SettingsEditor2Input extends EditorInput {
super.dispose();
}
}
const PreferencesEditorIcon = registerIcon('preferences-editor-label-icon', Codicon.settings, nls.localize('preferencesEditorLabelIcon', 'Icon of the preferences editor label.'));
export class PreferencesEditorInput extends EditorInput {
static readonly ID: string = 'workbench.input.preferences';
readonly resource: URI = URI.from({
scheme: Schemas.vscodeSettings,
path: `preferenceseditor`
});
override matches(otherInput: EditorInput | IUntypedEditorInput): boolean {
return super.matches(otherInput) || otherInput instanceof PreferencesEditorInput;
}
override get typeId(): string {
return PreferencesEditorInput.ID;
}
override getName(): string {
return nls.localize('preferencesEditorInputName', "Preferences");
}
override getIcon(): ThemeIcon {
return PreferencesEditorIcon;
}
override async resolve(): Promise<null> {
return null;
}
}

View File

@@ -149,6 +149,9 @@ import { ExtensionStorageService, IExtensionStorageService } from '../platform/e
import { IUserDataSyncLogService } from '../platform/userDataSync/common/userDataSync.js';
import { UserDataSyncLogService } from '../platform/userDataSync/common/userDataSyncLog.js';
import { AllowedExtensionsService } from '../platform/extensionManagement/common/allowedExtensionsService.js';
import { IMcpGalleryService, IMcpManagementService } from '../platform/mcp/common/mcpManagement.js';
import { McpGalleryService } from '../platform/mcp/common/mcpGalleryService.js';
import { McpManagementService } from '../platform/mcp/common/mcpManagementService.js';
registerSingleton(IUserDataSyncLogService, UserDataSyncLogService, InstantiationType.Delayed);
registerSingleton(IAllowedExtensionsService, AllowedExtensionsService, InstantiationType.Delayed);
@@ -164,6 +167,8 @@ registerSingleton(IContextKeyService, ContextKeyService, InstantiationType.Delay
registerSingleton(ITextResourceConfigurationService, TextResourceConfigurationService, InstantiationType.Delayed);
registerSingleton(IDownloadService, DownloadService, InstantiationType.Delayed);
registerSingleton(IOpenerService, OpenerService, InstantiationType.Delayed);
registerSingleton(IMcpGalleryService, McpGalleryService, InstantiationType.Delayed);
registerSingleton(IMcpManagementService, McpManagementService, InstantiationType.Delayed);
//#endregion
@@ -402,4 +407,5 @@ import './contrib/inlineCompletions/browser/inlineCompletions.contribution.js';
import './contrib/dropOrPasteInto/browser/dropOrPasteInto.contribution.js';
//#endregion