mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-08 17:19:48 +01:00
improve mcp server editor and view (#252291)
This commit is contained in:
committed by
GitHub
parent
4fed93462a
commit
21e7d58551
@@ -9,6 +9,7 @@ import { IStringDictionary } from '../../../base/common/collections.js';
|
||||
import { parse, ParseError } from '../../../base/common/json.js';
|
||||
import { Disposable } from '../../../base/common/lifecycle.js';
|
||||
import { ResourceMap } from '../../../base/common/map.js';
|
||||
import { Mutable } from '../../../base/common/types.js';
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import { ConfigurationTarget, ConfigurationTargetToString } from '../../configuration/common/configuration.js';
|
||||
import { FileOperationResult, IFileService, toFileOperationResult } from '../../files/common/files.js';
|
||||
@@ -16,7 +17,7 @@ import { InstantiationType, registerSingleton } from '../../instantiation/common
|
||||
import { createDecorator } from '../../instantiation/common/instantiation.js';
|
||||
import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js';
|
||||
import { IScannedMcpServers, IScannedMcpServer } from './mcpManagement.js';
|
||||
import { IMcpServerConfiguration, IMcpServerVariable } from './mcpPlatformTypes.js';
|
||||
import { IMcpServerConfiguration, IMcpServerVariable, IMcpStdioServerConfiguration } from './mcpPlatformTypes.js';
|
||||
|
||||
interface IScannedWorkspaceFolderMcpServers {
|
||||
servers?: IStringDictionary<IMcpServerConfiguration>;
|
||||
@@ -188,6 +189,9 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc
|
||||
if (servers.length > 0) {
|
||||
scannedMcpServers.servers = {};
|
||||
for (const [serverName, config] of servers) {
|
||||
if (config.type === undefined) {
|
||||
(<Mutable<IMcpServerConfiguration>>config).type = (<IMcpStdioServerConfiguration>config).command ? 'stdio' : 'http';
|
||||
}
|
||||
scannedMcpServers.servers[serverName] = {
|
||||
id: serverName,
|
||||
name: serverName,
|
||||
|
||||
@@ -34,7 +34,7 @@ import { McpSamplingService } from '../common/mcpSamplingService.js';
|
||||
import { McpService } from '../common/mcpService.js';
|
||||
import { HasInstalledMcpServersContext, IMcpElicitationService, IMcpSamplingService, IMcpService, IMcpWorkbenchService, InstalledMcpServersViewId } from '../common/mcpTypes.js';
|
||||
import { McpAddContextContribution } from './mcpAddContextContribution.js';
|
||||
import { AddConfigurationAction, EditStoredInput, InstallFromActivation, ListMcpServerCommand, McpBrowseCommand, McpBrowseResourcesCommand, McpConfigureSamplingModels, MCPServerActionRendering, McpServerOptionsCommand, McpStartPromptingServerCommand, RemoveStoredInput, ResetMcpCachedTools, ResetMcpTrustCommand, RestartServer, ShowConfiguration, ShowOutput, StartServer, StopServer } from './mcpCommands.js';
|
||||
import { AddConfigurationAction, EditStoredInput, InstallFromActivation, ListMcpServerCommand, McpBrowseCommand, McpBrowseResourcesCommand, McpConfigureSamplingModels, MCPServerActionRendering, McpServerOptionsCommand, McpStartPromptingServerCommand, RemoveStoredInput, ResetMcpCachedTools, ResetMcpTrustCommand, RestartServer, ShowConfiguration, ShowInstalledMcpServersCommand, ShowOutput, StartServer, StopServer } from './mcpCommands.js';
|
||||
import { McpDiscovery } from './mcpDiscovery.js';
|
||||
import { McpElicitationService } from './mcpElicitationService.js';
|
||||
import { McpLanguageFeatures } from './mcpLanguageFeatures.js';
|
||||
@@ -42,7 +42,7 @@ import { McpConfigMigrationContribution } from './mcpMigration.js';
|
||||
import { McpResourceQuickAccess } from './mcpResourceQuickAccess.js';
|
||||
import { McpServerEditor } from './mcpServerEditor.js';
|
||||
import { McpServerEditorInput } from './mcpServerEditorInput.js';
|
||||
import { McpServersListView } from './mcpServersView.js';
|
||||
import { DefaultBrowseMcpServersView, McpServersListView } from './mcpServersView.js';
|
||||
import { McpUrlHandler } from './mcpUrlHandler.js';
|
||||
import { MCPContextsInitialisation, McpWorkbenchService } from './mcpWorkbenchService.js';
|
||||
|
||||
@@ -78,6 +78,7 @@ registerAction2(InstallFromActivation);
|
||||
registerAction2(RestartServer);
|
||||
registerAction2(ShowConfiguration);
|
||||
registerAction2(McpBrowseCommand);
|
||||
registerAction2(ShowInstalledMcpServersCommand);
|
||||
registerAction2(McpBrowseResourcesCommand);
|
||||
registerAction2(McpConfigureSamplingModels);
|
||||
registerAction2(McpStartPromptingServerCommand);
|
||||
@@ -94,16 +95,25 @@ Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry).registerViews([
|
||||
{
|
||||
id: InstalledMcpServersViewId,
|
||||
name: localize2('mcp-installed', "MCP Servers - Installed"),
|
||||
ctorDescriptor: new SyncDescriptor(McpServersListView),
|
||||
ctorDescriptor: new SyncDescriptor(McpServersListView, [{ showWelcomeOnEmpty: false }]),
|
||||
when: ContextKeyExpr.and(DefaultViewsContext, HasInstalledMcpServersContext),
|
||||
weight: 40,
|
||||
order: 4,
|
||||
canToggleVisibility: true
|
||||
},
|
||||
{
|
||||
id: 'workbench.views.mcp.default.marketplace',
|
||||
name: localize2('mcp', "MCP Servers"),
|
||||
ctorDescriptor: new SyncDescriptor(DefaultBrowseMcpServersView, [{ showWelcomeOnEmpty: true }]),
|
||||
when: ContextKeyExpr.and(DefaultViewsContext, HasInstalledMcpServersContext.toNegated()),
|
||||
weight: 40,
|
||||
order: 4,
|
||||
canToggleVisibility: true
|
||||
},
|
||||
{
|
||||
id: 'workbench.views.mcp.marketplace',
|
||||
name: localize2('mcp', "MCP Servers"),
|
||||
ctorDescriptor: new SyncDescriptor(McpServersListView),
|
||||
ctorDescriptor: new SyncDescriptor(McpServersListView, [{ showWelcomeOnEmpty: true }]),
|
||||
when: ContextKeyExpr.and(SearchMcpServersContext),
|
||||
}
|
||||
], VIEW_CONTAINER);
|
||||
|
||||
@@ -18,11 +18,10 @@ import { SuggestController } from '../../../../editor/contrib/suggest/browser/su
|
||||
import { ILocalizedString, localize, localize2 } from '../../../../nls.js';
|
||||
import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js';
|
||||
import { MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';
|
||||
import { Action2, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js';
|
||||
import { Action2, MenuId, MenuItemAction, MenuRegistry } from '../../../../platform/actions/common/actions.js';
|
||||
import { ICommandService } from '../../../../platform/commands/common/commands.js';
|
||||
import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js';
|
||||
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
|
||||
import { ExtensionsLocalizedLabel } from '../../../../platform/extensionManagement/common/extensionManagement.js';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { IProductService } from '../../../../platform/product/common/productService.js';
|
||||
import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js';
|
||||
@@ -46,7 +45,7 @@ import { TEXT_FILE_EDITOR_ID } from '../../files/common/files.js';
|
||||
import { McpCommandIds } from '../common/mcpCommandIds.js';
|
||||
import { McpContextKeys } from '../common/mcpContextKeys.js';
|
||||
import { IMcpRegistry } from '../common/mcpRegistryTypes.js';
|
||||
import { IMcpSamplingService, IMcpServer, IMcpServerStartOpts, IMcpService, LazyCollectionState, McpCapability, McpConnectionState, mcpPromptPrefix, McpServerCacheState, McpServersGalleryEnabledContext } from '../common/mcpTypes.js';
|
||||
import { HasInstalledMcpServersContext, IMcpSamplingService, IMcpServer, IMcpServerStartOpts, IMcpService, InstalledMcpServersViewId, LazyCollectionState, McpCapability, McpConnectionState, mcpPromptPrefix, McpServerCacheState } from '../common/mcpTypes.js';
|
||||
import { McpAddConfigurationCommand } from './mcpCommandsAddConfiguration.js';
|
||||
import { McpResourceQuickAccess, McpResourceQuickPick } from './mcpResourceQuickAccess.js';
|
||||
import { McpUrlHandler } from './mcpUrlHandler.js';
|
||||
@@ -763,11 +762,8 @@ export class McpBrowseCommand extends Action2 {
|
||||
super({
|
||||
id: McpCommandIds.Browse,
|
||||
title: localize2('mcp.command.browse', "MCP Servers"),
|
||||
category: ExtensionsLocalizedLabel,
|
||||
category,
|
||||
menu: [{
|
||||
id: MenuId.CommandPalette,
|
||||
when: McpServersGalleryEnabledContext,
|
||||
}, {
|
||||
id: extensionsFilterSubMenu,
|
||||
group: '1_predefined',
|
||||
order: 1,
|
||||
@@ -780,6 +776,30 @@ export class McpBrowseCommand extends Action2 {
|
||||
}
|
||||
}
|
||||
|
||||
MenuRegistry.appendMenuItem(MenuId.CommandPalette, {
|
||||
command: {
|
||||
id: McpCommandIds.Browse,
|
||||
title: localize2('mcp.command.browse.mcp', "Browse Servers"),
|
||||
category
|
||||
},
|
||||
});
|
||||
|
||||
export class ShowInstalledMcpServersCommand extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
id: McpCommandIds.ShowInstalled,
|
||||
title: localize2('mcp.command.show.installed', "Show Installed Servers"),
|
||||
category,
|
||||
precondition: HasInstalledMcpServersContext,
|
||||
f1: true,
|
||||
});
|
||||
}
|
||||
|
||||
async run(accessor: ServicesAccessor) {
|
||||
accessor.get(IViewsService).openView(InstalledMcpServersViewId, true);
|
||||
}
|
||||
}
|
||||
|
||||
export class McpBrowseResourcesCommand extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Disposable } from '../../../../base/common/lifecycle.js';
|
||||
import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js';
|
||||
import { ILogService } from '../../../../platform/log/common/log.js';
|
||||
import { IMcpServerConfiguration, IMcpServerVariable } from '../../../../platform/mcp/common/mcpPlatformTypes.js';
|
||||
import { IMcpServerConfiguration, IMcpServerVariable, IMcpStdioServerConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js';
|
||||
import { IStringDictionary } from '../../../../base/common/collections.js';
|
||||
import { mcpConfigurationSection } from '../../../contrib/mcp/common/mcpConfiguration.js';
|
||||
import { IWorkbenchMcpManagementService } from '../../../services/mcp/common/mcpWorkbenchManagementService.js';
|
||||
@@ -15,7 +15,7 @@ import { IUserDataProfileService } from '../../../services/userDataProfile/commo
|
||||
import { IFileService } from '../../../../platform/files/common/files.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { parse } from '../../../../base/common/jsonc.js';
|
||||
import { isObject } from '../../../../base/common/types.js';
|
||||
import { isObject, Mutable } from '../../../../base/common/types.js';
|
||||
import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js';
|
||||
import { IJSONEditingService } from '../../../services/configuration/common/jsonEditing.js';
|
||||
|
||||
@@ -73,7 +73,15 @@ export class McpConfigMigrationContribution extends Disposable implements IWorkb
|
||||
if (!isObject(settingsObject)) {
|
||||
return undefined;
|
||||
}
|
||||
return settingsObject[mcpConfigurationSection] as IMcpConfiguration;
|
||||
const mcpConfiguration = settingsObject[mcpConfigurationSection] as IMcpConfiguration;
|
||||
if (mcpConfiguration && mcpConfiguration.servers) {
|
||||
for (const [, config] of Object.entries(mcpConfiguration.servers)) {
|
||||
if (config.type === undefined) {
|
||||
(<Mutable<IMcpServerConfiguration>>config).type = (<IMcpStdioServerConfiguration>config).command ? 'stdio' : 'http';
|
||||
}
|
||||
}
|
||||
}
|
||||
return mcpConfiguration;
|
||||
} catch (error) {
|
||||
this.logService.warn(`MCP migration: Failed to parse MCP config from ${settingsFile}:`, error);
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import './media/mcpServerEditor.css';
|
||||
import { $, Dimension, 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';
|
||||
@@ -46,6 +47,7 @@ import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/ac
|
||||
|
||||
const enum McpServerEditorTab {
|
||||
Readme = 'readme',
|
||||
Configuration = 'configuration',
|
||||
}
|
||||
|
||||
function toDateString(date: Date) {
|
||||
@@ -172,7 +174,7 @@ export class McpServerEditor extends EditorPane {
|
||||
}
|
||||
|
||||
protected createEditor(parent: HTMLElement): void {
|
||||
const root = append(parent, $('.extension-editor'));
|
||||
const root = append(parent, $('.extension-editor.mcp-server-editor'));
|
||||
this._scopedContextKeyService.value = this.contextKeyService.createScoped(root);
|
||||
this._scopedContextKeyService.value.createKey('inExtensionEditor', true);
|
||||
|
||||
@@ -316,6 +318,11 @@ export class McpServerEditor extends EditorPane {
|
||||
}
|
||||
|
||||
template.navbar.push(McpServerEditorTab.Readme, localize('details', "Details"), localize('detailstooltip', "Extension details, rendered from the extension's 'README.md' file"));
|
||||
|
||||
if (extension.local) {
|
||||
template.navbar.push(McpServerEditorTab.Configuration, localize('configuration', "Configuration"), localize('configurationtooltip', "Server configuration details"));
|
||||
}
|
||||
|
||||
if (template.navbar.currentId) {
|
||||
this.onNavbarChange(extension, { id: template.navbar.currentId, focus: !preserveFocus }, template);
|
||||
}
|
||||
@@ -371,6 +378,7 @@ export class McpServerEditor extends EditorPane {
|
||||
|
||||
private open(id: string, extension: IWorkbenchMcpServer, template: IExtensionEditorTemplate, token: CancellationToken): Promise<IActiveElement | null> {
|
||||
switch (id) {
|
||||
case McpServerEditorTab.Configuration: return this.openConfiguration(extension, template, token);
|
||||
case McpServerEditorTab.Readme: return this.openDetails(extension, template, token);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
@@ -536,6 +544,73 @@ export class McpServerEditor extends EditorPane {
|
||||
return activeElement;
|
||||
}
|
||||
|
||||
private async openConfiguration(mcpServer: IWorkbenchMcpServer, template: IExtensionEditorTemplate, token: CancellationToken): Promise<IActiveElement | null> {
|
||||
const configContainer = append(template.content, $('.configuration'));
|
||||
const content = $('div', { class: 'configuration-content', tabindex: '0' });
|
||||
|
||||
this.renderConfigurationDetails(content, mcpServer);
|
||||
|
||||
const scrollableContent = new DomScrollableElement(content, {});
|
||||
const layout = () => scrollableContent.scanDomNode();
|
||||
this.contentDisposables.add(toDisposable(arrays.insert(this.layoutParticipants, { layout })));
|
||||
|
||||
append(configContainer, scrollableContent.getDomNode());
|
||||
|
||||
return { focus: () => content.focus() };
|
||||
}
|
||||
|
||||
private renderConfigurationDetails(container: HTMLElement, mcpServer: IWorkbenchMcpServer): void {
|
||||
container.remove();
|
||||
|
||||
if (!mcpServer.local) {
|
||||
const noConfigMessage = append(container, $('.no-config'));
|
||||
noConfigMessage.textContent = localize('noConfig', "No configuration available for this MCP server.");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = mcpServer.local.config;
|
||||
|
||||
// Server Name
|
||||
const nameSection = append(container, $('.config-section'));
|
||||
const nameLabel = append(nameSection, $('.config-label'));
|
||||
nameLabel.textContent = localize('serverName', "Name:");
|
||||
const nameValue = append(nameSection, $('.config-value'));
|
||||
nameValue.textContent = mcpServer.local.name;
|
||||
|
||||
// Server Type
|
||||
const typeSection = append(container, $('.config-section'));
|
||||
const typeLabel = append(typeSection, $('.config-label'));
|
||||
typeLabel.textContent = localize('serverType', "Type:");
|
||||
const typeValue = append(typeSection, $('.config-value'));
|
||||
typeValue.textContent = config.type;
|
||||
|
||||
// Type-specific configuration
|
||||
if (config.type === 'stdio') {
|
||||
// Command
|
||||
const commandSection = append(container, $('.config-section'));
|
||||
const commandLabel = append(commandSection, $('.config-label'));
|
||||
commandLabel.textContent = localize('command', "Command:");
|
||||
const commandValue = append(commandSection, $('code.config-value'));
|
||||
commandValue.textContent = config.command;
|
||||
|
||||
// Arguments (if present)
|
||||
if (config.args && config.args.length > 0) {
|
||||
const argsSection = append(container, $('.config-section'));
|
||||
const argsLabel = append(argsSection, $('.config-label'));
|
||||
argsLabel.textContent = localize('arguments', "Arguments:");
|
||||
const argsValue = append(argsSection, $('code.config-value'));
|
||||
argsValue.textContent = config.args.join(' ');
|
||||
}
|
||||
} else if (config.type === 'http') {
|
||||
// URL
|
||||
const urlSection = append(container, $('.config-section'));
|
||||
const urlLabel = append(urlSection, $('.config-label'));
|
||||
urlLabel.textContent = localize('url', "URL:");
|
||||
const urlValue = append(urlSection, $('code.config-value'));
|
||||
urlValue.textContent = config.url;
|
||||
}
|
||||
}
|
||||
|
||||
private renderAdditionalDetails(container: HTMLElement, extension: IWorkbenchMcpServer): void {
|
||||
const content = $('div', { class: 'additional-details-content', tabindex: '0' });
|
||||
const scrollableContent = new DomScrollableElement(content, {});
|
||||
|
||||
@@ -36,6 +36,10 @@ import { URI } from '../../../../base/common/uri.js';
|
||||
import { ThemeIcon } from '../../../../base/common/themables.js';
|
||||
import { IProductService } from '../../../../platform/product/common/productService.js';
|
||||
|
||||
export interface McpServerListViewOptions {
|
||||
showWelcomeOnEmpty?: boolean;
|
||||
}
|
||||
|
||||
export class McpServersListView extends ViewPane {
|
||||
|
||||
private list: WorkbenchPagedList<IWorkbenchMcpServer> | null = null;
|
||||
@@ -44,6 +48,7 @@ export class McpServersListView extends ViewPane {
|
||||
private readonly contextMenuActionRunner = this._register(new ActionRunner());
|
||||
|
||||
constructor(
|
||||
private readonly mpcViewOptions: McpServerListViewOptions,
|
||||
options: IViewletViewOptions,
|
||||
@IKeybindingService keybindingService: IKeybindingService,
|
||||
@IContextMenuService contextMenuService: IContextMenuService,
|
||||
@@ -143,7 +148,7 @@ export class McpServersListView extends ViewPane {
|
||||
const servers = query ? await this.mcpWorkbenchService.queryGallery({ text: query.replace('@mcp', '') }) : await this.mcpWorkbenchService.queryLocal();
|
||||
this.list.model = new DelayedPagedModel(new PagedModel(servers));
|
||||
|
||||
this.showWelcomeContent(!this.mcpGalleryService.isEnabled() && servers.length === 0);
|
||||
this.showWelcomeContent(!this.mcpGalleryService.isEnabled() && servers.length === 0 && !!this.mpcViewOptions.showWelcomeOnEmpty);
|
||||
return this.list.model;
|
||||
}
|
||||
|
||||
@@ -273,3 +278,10 @@ class McpServerRenderer implements IListRenderer<IWorkbenchMcpServer, IMcpServer
|
||||
data.disposables = dispose(data.disposables);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class DefaultBrowseMcpServersView extends McpServersListView {
|
||||
override async show(): Promise<IPagedModel<IWorkbenchMcpServer>> {
|
||||
return super.show('@mcp');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.mcp-server-editor {
|
||||
.configuration-content {
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.config-label {
|
||||
font-weight: 600;
|
||||
min-width: 80px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.config-value {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.no-config {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-style: italic;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
export const enum McpCommandIds {
|
||||
AddConfiguration = 'workbench.mcp.addConfiguration',
|
||||
Browse = 'workbench.mcp.browseServers',
|
||||
ShowInstalled = 'workbench.mcp.showInstalledServers',
|
||||
BrowseResources = 'workbench.mcp.browseResources',
|
||||
ConfigureSamplingModels = 'workbench.mcp.configureSamplingModels',
|
||||
EditStoredInput = 'workbench.mcp.editStoredInput',
|
||||
|
||||
@@ -629,7 +629,7 @@ export class McpServerContainers extends Disposable {
|
||||
}
|
||||
|
||||
export const McpServersGalleryEnabledContext = new RawContextKey<boolean>('mcpServersGalleryEnabled', false);
|
||||
export const HasInstalledMcpServersContext = new RawContextKey<boolean>('hasInstalledMcpServers', false);
|
||||
export const HasInstalledMcpServersContext = new RawContextKey<boolean>('hasInstalledMcpServers', true);
|
||||
export const InstalledMcpServersViewId = 'workbench.views.mcp.installed';
|
||||
export const mcpServerIcon = registerIcon('mcp-server', Codicon.mcp, localize('mcpServer', 'Icon used for the MCP server.'));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user