From 620968ded03930c1ace063fc9f72edbbdc211f12 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Fri, 2 Oct 2026 16:23:03 -0700 Subject: [PATCH 1/2] mcp: show where built-in MCP servers come from The Chat Customizations MCP details page showed no source for built-in servers: the header fell back to a literal "mcp.json" and the Configuration section was empty without explanation. - Forward the runtime-reported display name and source plugin from the Copilot agent host as namespaced customization metadata. - Show where a server comes from when it has no configuration file (e.g. "Built-in: Copilot", "Managed by Copilot", "Agent host configuration"); Copilot-contributed servers link to their extension. - Explain why the agent's own servers have no definition, and link github-mcp-server to chat.agentHost.githubMcpServer.enabled. - Show the definition of servers added to the agent host configuration and group them under User. - Add Show Extension / Show Configuration to built-in row menus. Fixes #338719 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/githubEndpoints.ts | 3 + .../common/meta/mcpCustomizationMeta.ts | 2 +- .../meta/vscode/mcpCustomizationMeta.ts | 50 ++++ .../node/copilot/copilotAgentSession.ts | 8 +- .../agentHost/node/shared/githubMcpServer.ts | 2 +- .../node/shared/mcpCustomizationController.ts | 53 ++++- .../test/common/agentMetaReaders.test.ts | 33 ++- .../test/node/copilotAgentSession.test.ts | 24 +- .../shared/mcpCustomizationController.test.ts | 50 +++- .../common/agentHostSessionsProvider.ts | 17 ++ .../agentHostCustomizationService.ts | 20 +- .../embeddedMcpServerDetail.ts | 108 ++++++--- .../browser/aiCustomization/mcpListWidget.ts | 143 ++++++++++-- .../browser/aiCustomization/mcpServerCount.ts | 13 +- .../media/aiCustomizationManagement.css | 22 ++ .../agentHostCustomizationService.test.ts | 46 +++- .../embeddedMcpServerDetail.test.ts | 69 +++++- .../aiCustomization/mcpListWidget.test.ts | 217 +++++++++++++++++- ...aiCustomizationManagementEditor.fixture.ts | 36 ++- 19 files changed, 837 insertions(+), 79 deletions(-) diff --git a/src/vs/platform/agentHost/common/githubEndpoints.ts b/src/vs/platform/agentHost/common/githubEndpoints.ts index 75284a17da87..149896f861b4 100644 --- a/src/vs/platform/agentHost/common/githubEndpoints.ts +++ b/src/vs/platform/agentHost/common/githubEndpoints.ts @@ -6,6 +6,9 @@ import { IGitHubEndpoints } from '../../github/common/githubEndpoints.js'; import { ProtectedResourceMetadata } from './state/protocol/state.js'; +/** Name under which agent hosts register the official GitHub MCP server. */ +export const GITHUB_MCP_SERVER_NAME = 'github-mcp-server'; + /** * The GitHub Copilot protected resource for the given endpoints. Shared by the * endpoint service and tests so the resource identity is defined once. diff --git a/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts b/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts index 682826484180..37eb09dbdfe7 100644 --- a/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts @@ -4,4 +4,4 @@ *--------------------------------------------------------------------------------------------*/ export type { McpServerSource } from './vscode/mcpCustomizationMeta.js'; -export { readMcpServerSource, withMcpServerSourceMeta } from './vscode/mcpCustomizationMeta.js'; +export { readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from './vscode/mcpCustomizationMeta.js'; diff --git a/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts index 257cf48a442c..4d2fa0517cfb 100644 --- a/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts @@ -6,6 +6,8 @@ import type { McpServerCustomization } from '../../state/protocol/state.js'; const sourceKey = 'agentHost.mcpServerSource'; +const displayNameKey = 'agentHost.mcpServerDisplayName'; +const sourcePluginKey = 'agentHost.mcpServerSourcePlugin'; export type McpServerSource = | 'user' // Defined in user-level configuration. @@ -36,3 +38,51 @@ export function withMcpServerSourceMeta(meta: Record | undefine } return { ...(meta ?? {}), [sourceKey]: source }; } + +/** + * Reads the human-readable name the runtime reports for a server: a configured display name, a + * managed catalog's name for an opaque `name`, or the server's advertised title. + */ +export function readMcpServerDisplayName(customization: McpServerCustomization | undefined): string | undefined { + return readNonEmptyString(customization?._meta?.[displayNameKey]); +} + +/** + * Records the runtime-reported display name in an open metadata bag. An absent name removes a + * previously recorded one, because it describes the current configuration; every other entry is preserved. + */ +export function withMcpServerDisplayNameMeta(meta: Record | undefined, displayName: string | undefined): Record | undefined { + return withMetaEntry(meta, displayNameKey, displayName); +} + +/** + * Reads the plugin the runtime reports as the source of the server's configuration. The + * plugin may be one the client never published, such as a plugin bundled with the agent. + */ +export function readMcpServerSourcePlugin(customization: McpServerCustomization | undefined): string | undefined { + return readNonEmptyString(customization?._meta?.[sourcePluginKey]); +} + +/** + * Records the runtime-reported source plugin in an open metadata bag. An absent plugin removes a + * previously recorded one, because it describes the current configuration; every other entry is preserved. + */ +export function withMcpServerSourcePluginMeta(meta: Record | undefined, sourcePlugin: string | undefined): Record | undefined { + return withMetaEntry(meta, sourcePluginKey, sourcePlugin); +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} + +function withMetaEntry(meta: Record | undefined, key: string, value: string | undefined): Record | undefined { + if (value === undefined) { + if (!meta || !Object.hasOwn(meta, key)) { + return meta; + } + const remaining = { ...meta }; + delete remaining[key]; + return Object.keys(remaining).length > 0 ? remaining : undefined; + } + return meta?.[key] === value ? meta : { ...(meta ?? {}), [key]: value }; +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 76bcd1f8b582..d50590b45d75 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -7801,12 +7801,15 @@ export class CopilotAgentSession extends Disposable { */ private _toSdkMcpServer(server: McpServer): ISdkMcpServer { const hasPendingAuthentication = this._hasPendingMcpAuthentication(server.name); - const source = server.source !== undefined + // Only inventory records report a source, so only they replace (or clear) provenance; lifecycle updates keep it. + const provenance = server.source !== undefined ? { source: server.source, sourceUri: server.source === 'user' ? URI.file(getCopilotMcpConfigurationPath(this._environmentService.userHome.fsPath, process.env)).toString() : null, + displayName: server.displayName ?? null, + pluginName: server.sourcePlugin ?? null, } : {}; return { @@ -7814,8 +7817,7 @@ export class CopilotAgentSession extends Disposable { state: this._translateSdkMcpStatus(server.name, server.status, server.error, hasPendingAuthentication), ...(server.status === 'pending' && !hasPendingAuthentication ? { allowAuthRequiredToStarting: true } : {}), enabled: server.status !== 'disabled' && server.status !== 'not_configured', - ...source, - pluginName: server.sourcePlugin, + ...provenance, pluginVersion: server.sourcePluginVersion, }; } diff --git a/src/vs/platform/agentHost/node/shared/githubMcpServer.ts b/src/vs/platform/agentHost/node/shared/githubMcpServer.ts index beea9ff8df71..26f1ec95f507 100644 --- a/src/vs/platform/agentHost/node/shared/githubMcpServer.ts +++ b/src/vs/platform/agentHost/node/shared/githubMcpServer.ts @@ -8,7 +8,7 @@ import { McpServerType, type IMcpServerConfiguration } from '../../../mcp/common import type { ICopilotApiService } from './copilotApiService.js'; import { findExecutable } from '../../../../base/node/processes.js'; -export const GITHUB_MCP_SERVER_NAME = 'github-mcp-server'; +export { GITHUB_MCP_SERVER_NAME } from '../../common/githubEndpoints.js'; export const GITHUB_MCP_FEATURES_HEADER = 'X-MCP-Features'; export const GITHUB_MCP_FEATURES = 'remote_mcp_ui_apps,mcp_apps_disable_form_deferral'; export const GITHUB_MCP_TOOLS_HEADER = 'X-MCP-Tools'; diff --git a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts index 08fdde060172..0fdbcd5df820 100644 --- a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts +++ b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../base/common/uri.js'; import { AgentSession } from '../../common/agent.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { isCustomizationEnabled } from '../../common/customizationEnablement.js'; -import { McpServerSource, readMcpServerSource, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js'; +import { McpServerSource, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from '../../common/meta/mcpCustomizationMeta.js'; import { CustomizationLoadStatus, CustomizationType, McpServerStatus, type AhpMcpUiHostCapabilities, type Customization, type CustomizationEnablement, type McpServerCustomization, type McpServerState } from '../../common/state/protocol/channels-session/state.js'; import { DEFAULT_MCP_APP, DEFAULT_MCP_APP_CAPABILITIES } from '../../common/state/protocol/mcpAppDefaults.js'; import { parseChatUri } from '../../common/state/sessionState.js'; @@ -38,11 +38,31 @@ export interface ISdkMcpServer { readonly allowAuthRequiredToStarting?: boolean; /** Explicit runtime enablement when the SDK distinguishes disabled from stopped. */ readonly enabled?: boolean; - /** Plugin that contributed this MCP server customization. */ - readonly pluginName?: string; + /** + * Human-readable name the SDK reports: a configured display name, a managed catalog's name for an + * opaque {@link name}, or the server's advertised title. Omitted on lifecycle updates; null clears it. + */ + readonly displayName?: string | null; + /** Plugin that supplied this server's configuration. Omitted on lifecycle updates; null clears a previously known plugin. */ + readonly pluginName?: string | null; readonly pluginVersion?: string; } +/** Where a top-level server comes from, as last reported by its provider. */ +interface ITopLevelProvenance { + readonly source: McpServerSource | undefined; + readonly displayName: string | undefined; + readonly sourcePlugin: string | undefined; +} + +function readTopLevelProvenance(customization: McpServerCustomization | undefined): ITopLevelProvenance { + return { + source: readMcpServerSource(customization), + displayName: readMcpServerDisplayName(customization), + sourcePlugin: readMcpServerSourcePlugin(customization), + }; +} + /** * Runtime fields of an MCP server customization that this controller * owns — the high-frequency `state`/`channel` pair. Consumers overlay @@ -223,7 +243,7 @@ export class McpCustomizationController extends Disposable { if (entry.topLevelId === undefined) { continue; } - out.push(this._buildTopLevel(entry.topLevelId, entry.serverName, entry.state, entry.enabled, readMcpServerSource(entry.topLevelCustomization), entry.topLevelCustomization?.uri)); + out.push(this._buildTopLevel(entry.topLevelId, entry.serverName, entry.state, entry.enabled, readTopLevelProvenance(entry.topLevelCustomization), entry.topLevelCustomization?.uri)); } return out; } @@ -388,9 +408,15 @@ export class McpCustomizationController extends Disposable { } topLevelId = published?.topLevelId ?? this._mintTopLevelId(server.name); } - const source = server.source ?? readMcpServerSource(previous?.topLevelCustomization); + // Lifecycle updates carry no provenance, so keep what was last reported or restored. + const known = readTopLevelProvenance(previous?.topLevelCustomization ?? this._findPublishedTopLevel(topLevelId)); + const provenance: ITopLevelProvenance = { + source: server.source ?? known.source, + displayName: server.displayName !== undefined ? server.displayName ?? undefined : known.displayName, + sourcePlugin: server.pluginName !== undefined ? server.pluginName ?? undefined : known.sourcePlugin, + }; const sourceUri = server.sourceUri !== undefined ? server.sourceUri : previous?.topLevelCustomization?.uri; - const customization = this._buildTopLevel(topLevelId, server.name, state, enabled, source, sourceUri); + const customization = this._buildTopLevel(topLevelId, server.name, state, enabled, provenance, sourceUri); const customizationChanged = force || previous?.topLevelId !== topLevelId || !equals(previous?.topLevelCustomization, customization); if (customizationChanged || previous?.enabled !== enabled) { this._setLiveEntry(server.name, { serverName: server.name, state, enabled, publishedId: topLevelId, topLevelId, topLevelCustomization: customization }, tx); @@ -504,6 +530,11 @@ export class McpCustomizationController extends Disposable { && customization.load?.kind === CustomizationLoadStatus.Loading); } + private _findPublishedTopLevel(id: string): McpServerCustomization | undefined { + return getMcpServerCustomizations(this._stateManager.getSessionState(this._sessionUri.toString())?.customizations ?? []) + .find(customization => customization.id === id); + } + private _buildChannel(serverName: string, state: McpServerState): string | undefined { if (state.kind !== McpServerStatus.Ready) { return undefined; @@ -511,7 +542,7 @@ export class McpCustomizationController extends Disposable { return buildMcpChannel(this._chatUri, serverName); } - private _buildTopLevel(id: string, serverName: string, state: McpServerState, enabled: boolean, source?: McpServerSource, sourceUri?: string | null): McpServerCustomization { + private _buildTopLevel(id: string, serverName: string, state: McpServerState, enabled: boolean, provenance: ITopLevelProvenance, sourceUri?: string | null): McpServerCustomization { const channel = this._buildChannel(serverName, state); const owningPluginUri = this.pluginMcpServerSources?.get(serverName); // Per AHP spec, `mcpApp` is a static capability declaration — @@ -522,10 +553,12 @@ export class McpCustomizationController extends Disposable { const mcpApp = this._options.capabilities ? { capabilities: this._options.capabilities } : DEFAULT_MCP_APP; - const existing = getMcpServerCustomizations(this._stateManager.getSessionState(this._sessionUri.toString())?.customizations ?? []) - .find(customization => customization.id === id); + const existing = this._findPublishedTopLevel(id); // `SessionCustomizationUpdated` replaces the whole customization, so keep opaque entries owned by others. - const meta = withMcpServerSourceMeta(existing?._meta, source); + const meta = withMcpServerSourcePluginMeta( + withMcpServerDisplayNameMeta(withMcpServerSourceMeta(existing?._meta, provenance.source), provenance.displayName), + provenance.sourcePlugin, + ); const uri = (sourceUri === undefined ? existing?.uri : sourceUri) ?? this._mintTopLevelId(serverName); const customization: McpServerCustomization = { type: CustomizationType.McpServer, diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index 22ff94a89039..60aafeaaab4c 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -12,7 +12,7 @@ import { readSessionSandboxPolicy, withSessionSandboxPolicy } from '../../common import { readChatInputState, withChatInputState } from '../../common/meta/agentHostChatInputState.js'; import { createEditorInlineChatInstruction, createTerminalChatInstruction, readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; import { readAgentCustomizationMeta, toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js'; -import { readMcpServerSource, withMcpServerSourceMeta } from '../../common/meta/mcpCustomizationMeta.js'; +import { readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from '../../common/meta/mcpCustomizationMeta.js'; import { getCommandArgumentHint, getCompletionAction, readCompletionAttachmentMeta, toCommandCompletionAttachmentMeta, toSkillCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; import { CustomizationType, MessageAttachmentKind, ToolCallStatus, hasReportedUsage, readSessionComparisonMetadata, readUsageInfoMeta, withSessionComparisonMetadata, type AgentCustomization, type ClientPluginCustomization, type ToolCallState, type UsageInfo } from '../../common/state/sessionState.js'; import { McpServerStatus, type McpServerCustomization, type SessionModelInfo, type SimpleMessageAttachment } from '../../common/state/protocol/state.js'; @@ -137,6 +137,37 @@ suite('Agent host _meta readers', () => { }); }); + test('validates MCP display names and source plugins and removes them once they no longer apply', () => { + const customization = (meta: Record | undefined): McpServerCustomization => ({ + type: CustomizationType.McpServer, + id: 'server', + uri: 'mcp-top-level:server', + name: 'github-copilot-connector-1', + state: { kind: McpServerStatus.Ready }, + _meta: meta, + }); + const opaque = { 'test.opaque': 'kept' }; + const recorded = withMcpServerSourcePluginMeta(withMcpServerDisplayNameMeta(opaque, 'Linear'), 'computer-use'); + + assert.deepStrictEqual({ + read: [readMcpServerDisplayName(customization(recorded)), readMcpServerSourcePlugin(customization(recorded))], + invalidDisplayNames: [undefined, '', ' ', 1, {}, ['Linear']].map(value => readMcpServerDisplayName(customization({ 'agentHost.mcpServerDisplayName': value }))), + invalidPlugins: [undefined, '', 1].map(value => readMcpServerSourcePlugin(customization({ 'agentHost.mcpServerSourcePlugin': value }))), + recorded, + cleared: withMcpServerSourcePluginMeta(withMcpServerDisplayNameMeta(recorded, undefined), undefined), + emptied: withMcpServerDisplayNameMeta({ 'agentHost.mcpServerDisplayName': 'Linear' }, undefined), + unchanged: withMcpServerDisplayNameMeta(opaque, undefined) === opaque && withMcpServerDisplayNameMeta(recorded, 'Linear') === recorded, + }, { + read: ['Linear', 'computer-use'], + invalidDisplayNames: [undefined, undefined, undefined, undefined, undefined, undefined], + invalidPlugins: [undefined, undefined, undefined], + recorded: { 'test.opaque': 'kept', 'agentHost.mcpServerDisplayName': 'Linear', 'agentHost.mcpServerSourcePlugin': 'computer-use' }, + cleared: { 'test.opaque': 'kept' }, + emptied: undefined, + unchanged: true, + }); + }); + suite('readToolCallMeta', () => { test('returns empty when no _meta', () => { assert.deepStrictEqual(readToolCallMeta(toolCall(undefined)), {}); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 7fa460940c5d..3fa0fbabb608 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -39,7 +39,7 @@ import type { ChatInputRequestWithPlanReview } from '../../common/agentHostPlanR import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; import { ChatInputRequestPurpose, readChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; -import { readMcpServerSource } from '../../common/meta/mcpCustomizationMeta.js'; +import { readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin } from '../../common/meta/mcpCustomizationMeta.js'; import { agentModelCallMetaKey, readAgentModelCallDiagnostics } from '../../common/meta/agentModelCallMeta.js'; import { readAgentRuntimeModelConfiguration } from '../../common/meta/agentModelConfigurationMeta.js'; import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, readAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; @@ -20910,7 +20910,7 @@ Use the attached image as context. assert.deepStrictEqual(names, ['alpha', 'beta']); }); - test('publishes SDK configuration sources for host-only MCP servers and retains them across status updates', async () => { + test('publishes SDK configuration sources, display names and plugins for host-only MCP servers and retains them across status updates', async () => { const { session, mockSession, waitForSignal } = await createAgentSession(disposables, { configureMockSession: m => { m.mcpListResult = { @@ -20918,26 +20918,32 @@ Use the attached image as context. { name: 'local-memory', status: 'connected', source: 'user' }, { name: 'github', status: 'failed', source: 'user' }, { name: 'workspace-server', status: 'connected', source: 'workspace' }, - { name: 'plugin-server', status: 'connected', source: 'plugin' }, + { name: 'plugin-server', status: 'connected', source: 'plugin', sourcePlugin: 'acme' }, { name: 'github-mcp-server', status: 'connected', source: 'builtin' }, + { name: 'computer-use', status: 'connected', source: 'builtin', sourcePlugin: 'computer-use' }, + { name: 'github-copilot-connector-1', status: 'connected', source: 'managed', displayName: 'Linear' }, ] }; }, }); await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)); const snapshot = () => session.topLevelMcpCustomizations().map(server => ({ - name: server.name, source: readMcpServerSource(server), + name: server.name, source: readMcpServerSource(server), displayName: readMcpServerDisplayName(server), sourcePlugin: readMcpServerSourcePlugin(server), })); const initial = snapshot(); mockSession.fire('session.mcp_server_status_changed', { serverName: 'local-memory', status: 'stopped' }); + mockSession.fire('session.mcp_server_status_changed', { serverName: 'computer-use', status: 'stopped' }); + mockSession.fire('session.mcp_server_status_changed', { serverName: 'github-copilot-connector-1', status: 'stopped' }); const expected = [ - { name: 'local-memory', source: 'user' }, - { name: 'github', source: 'user' }, - { name: 'workspace-server', source: 'workspace' }, - { name: 'plugin-server', source: 'plugin' }, - { name: 'github-mcp-server', source: 'builtin' }, + { name: 'local-memory', source: 'user', displayName: undefined, sourcePlugin: undefined }, + { name: 'github', source: 'user', displayName: undefined, sourcePlugin: undefined }, + { name: 'workspace-server', source: 'workspace', displayName: undefined, sourcePlugin: undefined }, + { name: 'plugin-server', source: 'plugin', displayName: undefined, sourcePlugin: 'acme' }, + { name: 'github-mcp-server', source: 'builtin', displayName: undefined, sourcePlugin: undefined }, + { name: 'computer-use', source: 'builtin', displayName: undefined, sourcePlugin: 'computer-use' }, + { name: 'github-copilot-connector-1', source: 'managed', displayName: 'Linear', sourcePlugin: undefined }, ]; assert.deepStrictEqual({ initial, afterStatusChange: snapshot() }, { initial: expected, afterStatusChange: expected }); }); diff --git a/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts b/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts index 026a5538d3f2..e42f27a39729 100644 --- a/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { NullLogService } from '../../../../../platform/log/common/log.js'; import { AgentSession } from '../../../common/agent.js'; import { isCustomizationEnabled } from '../../../common/customizationEnablement.js'; -import { readMcpServerSource, withMcpServerSourceMeta } from '../../../common/meta/mcpCustomizationMeta.js'; +import { readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from '../../../common/meta/mcpCustomizationMeta.js'; import { ActionType } from '../../../common/state/protocol/common/actions.js'; import { CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpAuthRequiredReason, McpServerStatus, SessionStatus, type Customization, type CustomizationEnablement, type McpServerCustomization, type McpServerState, type PluginCustomization } from '../../../common/state/protocol/channels-session/state.js'; import { buildChatUri } from '../../../common/state/sessionState.js'; @@ -276,6 +276,54 @@ suite('McpCustomizationController', () => { ]); }); + test('publishes display names and source plugins, keeps them through lifecycle updates, and clears them with the inventory', () => { + const { controller } = harness(store, { + customizations: [{ + type: CustomizationType.McpServer, + id: 'restored-connector', + uri: 'mcp-top-level:copilot:session-1:connector', + name: 'connector', + state: stopped(), + _meta: withMcpServerDisplayNameMeta({ 'test.opaque': 'kept' }, 'Linear'), + }], + }); + store.add(controller); + const snapshot = () => controller.topLevelCustomizations().map(item => ({ + name: item.name, displayName: readMcpServerDisplayName(item), sourcePlugin: readMcpServerSourcePlugin(item), + })); + + controller.applyOne(server('connector', ready())); + const restored = snapshot(); + controller.applyAll([ + { ...server('connector', ready()), source: 'managed', displayName: 'Linear (Work)', pluginName: null }, + { ...server('computer-use', ready()), source: 'builtin', displayName: null, pluginName: 'computer-use' }, + ]); + const inventory = snapshot(); + controller.applyOne(server('computer-use', stopped())); + const lifecycle = snapshot(); + controller.applyAll([ + { ...server('connector', ready()), source: 'user', displayName: null, pluginName: null }, + { ...server('computer-use', stopped()), source: 'builtin', displayName: null, pluginName: null }, + ]); + + assert.deepStrictEqual({ restored, inventory, lifecycle, cleared: snapshot(), connectorMeta: controller.topLevelCustomizations()[0]._meta }, { + restored: [{ name: 'connector', displayName: 'Linear', sourcePlugin: undefined }], + inventory: [ + { name: 'connector', displayName: 'Linear (Work)', sourcePlugin: undefined }, + { name: 'computer-use', displayName: undefined, sourcePlugin: 'computer-use' }, + ], + lifecycle: [ + { name: 'connector', displayName: 'Linear (Work)', sourcePlugin: undefined }, + { name: 'computer-use', displayName: undefined, sourcePlugin: 'computer-use' }, + ], + cleared: [ + { name: 'connector', displayName: undefined, sourcePlugin: undefined }, + { name: 'computer-use', displayName: undefined, sourcePlugin: undefined }, + ], + connectorMeta: { 'test.opaque': 'kept', 'agentHost.mcpServerSource': 'user' }, + }); + }); + test('reapplying an unchanged inventory dispatches nothing', () => { const { controller, actions } = harness(store, { customizations: PLUGIN_CUSTOMIZATIONS }); store.add(controller); diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 75f014d00e2f..fcc79ec48f2e 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -15,6 +15,7 @@ import { ResolveSessionConfigResult, SessionConfigValueItem } from '../../platfo import { AgentCustomization, Customization, McpServerStatus, RootConfigState, type CustomizationEnablement, type McpServerState, type RootState, type TextRange } from '../../platform/agentHost/common/state/protocol/state.js'; import { type CustomizationDisabledReason } from '../../platform/agentHost/common/customizationEnablement.js'; import { type McpServerSource } from '../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; +import type { IMcpServerConfiguration } from '../../platform/mcp/common/mcpPlatformTypes.js'; import { ISessionsProvider } from '../services/sessions/common/sessionsProvider.js'; import { ISessionAgentRef } from '../services/sessions/common/session.js'; import type { AgentMergeSessionOverrides, AgentMergeSessionState } from '../../platform/agentHost/common/agentMerge.js'; @@ -98,7 +99,23 @@ export interface IAgentHostGroup { export interface IAgentHostMcpServer { readonly id: string; readonly name: string; + /** + * Human-readable name the host reports: a configured display name, a managed catalog's name + * for an opaque {@link name}, or the server's own advertised title. + */ + readonly displayName?: string; readonly source?: McpServerSource; + /** + * Plugin the host reports as the source of this server's configuration. Unlike + * {@link isPluginProvided}, the plugin may be one the client never published, + * such as a plugin bundled with the agent. + */ + readonly sourcePluginName?: string; + /** + * Definition held by the agent host's own MCP server configuration, for a server configured there + * (for example with MCP: Add Server > Add to Current Agent Session) rather than in a file. + */ + readonly hostConfiguration?: IMcpServerConfiguration; readonly enabled: boolean; readonly enablement?: readonly CustomizationEnablement[]; readonly isPluginProvided?: boolean; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index 8d84ec8aebf0..5b48bdc9b1ed 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -16,7 +16,7 @@ import { IAgentConnection } from '../../../../../../platform/agentHost/common/ag import { IAgentHostResourceUriMapper } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { getEffectiveAgents } from '../../../../../../platform/agentHost/common/customAgents.js'; -import { readMcpServerSource } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; +import { readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; import { getCustomizationDisabledReason, isCustomizationEnabled, withCustomizationEnablement } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; import { type IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; @@ -245,12 +245,15 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i return []; } return getPresentableMcpServerCustomizations(target.customizations) - .map(({ server, plugin }): IAgentHostMcpServer => { + .map(({ server, plugin, isTopLevel }): IAgentHostMcpServer => { const source = URI.parse(server.uri); return { id: this._scopedMcpServerId(sessionResource, server.id), name: server.name, + displayName: readMcpServerDisplayName(server), source: readMcpServerSource(server), + sourcePluginName: readMcpServerSourcePlugin(server), + hostConfiguration: isTopLevel ? getHostMcpServerConfiguration(target.rootConfig, server.name) : undefined, enabled: isCustomizationEnabled(server) && (!plugin || isCustomizationEnabled(plugin)), enablement: server.enablement, isPluginProvided: plugin !== undefined, @@ -478,6 +481,19 @@ export function getPresentableMcpServerCustomizations(customizations: readonly C return entries.filter(entry => entry.isTopLevel || !topLevelNames.has(entry.server.name)); } +/** + * Reads the definition the agent host's own MCP server configuration holds for `serverName`. Such + * servers have no configuration file, so the runtime reports no source for them. + */ +function getHostMcpServerConfiguration(rootConfig: RootConfigState | undefined, serverName: string): IMcpServerConfiguration | undefined { + const servers = rootConfig?.values[AgentHostMcpServersConfigKey]; + if (!servers || typeof servers !== 'object' || Array.isArray(servers) || !Object.hasOwn(servers, serverName)) { + return undefined; + } + const configuration: unknown = (servers as Record)[serverName]; + return configuration && typeof configuration === 'object' && !Array.isArray(configuration) ? configuration as IMcpServerConfiguration : undefined; +} + /** * Upper bound on how long {@link WorkbenchAgentHostCustomizationService.whenCustomizationsReady} * waits for a session's first state snapshot. diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts index c24954b82718..19eb48f57ee8 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts @@ -20,6 +20,7 @@ import { ILanguageService } from '../../../../../editor/common/languages/languag import { ITextModel } from '../../../../../editor/common/model.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; import { localize } from '../../../../../nls.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -55,6 +56,28 @@ export interface IMcpServerDetailInput { readonly uri: URI; readonly range?: IRange; }; + /** Where the server comes from when it has no {@link source} file, such as an extension or the agent itself. */ + readonly provenance?: IMcpServerProvenance; + /** Explains why there is no definition to show, for a server whose definition the client cannot read. */ + readonly definitionUnavailable?: IMcpServerDefinitionUnavailable; +} + +/** Why an MCP server has no definition to show, and where it can be controlled instead. */ +export interface IMcpServerDefinitionUnavailable { + /** Localized explanation shown in place of the definition. */ + readonly message: string; + /** Setting that controls the server, offered as a link to the Settings editor. */ + readonly settingId?: string; +} + +/** Describes where an MCP server comes from when there is no configuration file to open. */ +export interface IMcpServerProvenance { + /** Short localized label, for example "Built-in: Copilot" or "Extension: GitHub Copilot Chat". */ + readonly label: string; + /** Localized accessible name of the link that reveals the provider; present with {@link open}. */ + readonly ariaLabel?: string; + /** Reveals the provider, for example its extension details. */ + open?(): void; } export interface IMcpServerDetailOptions { @@ -113,6 +136,7 @@ export class EmbeddedMcpServerDetail extends Disposable { private readonly diagnosticDisposables = this._register(new DisposableStore()); private readonly iconDisposables = this._register(new DisposableStore()); private readonly migrationLinkListener = this._register(new MutableDisposable()); + private readonly definitionSettingsLinkListener = this._register(new MutableDisposable()); private readonly emptyEl: HTMLElement; private current: IMcpServerDetailInput | undefined; @@ -135,6 +159,7 @@ export class EmbeddedMcpServerDetail extends Disposable { @ICustomizationHarnessService private readonly customizationHarnessService: ICustomizationHarnessService, @INotificationService private readonly notificationService: INotificationService, @IThemeService private readonly themeService: IThemeService, + @ICommandService private readonly commandService: ICommandService, ) { super(); @@ -156,15 +181,17 @@ export class EmbeddedMcpServerDetail extends Disposable { this.editConfigurationButton.label = editConfigurationLabel; this._register(this.editConfigurationButton.onDidClick(() => void this.editConfiguration())); this._register(DOM.addDisposableListener(this.pathEl, DOM.EventType.CLICK, event => { - const source = this.current?.source; - if (!source) { - return; + const server = this.current; + if (server?.source) { + event.preventDefault(); + void this.editorService.openEditor({ + resource: server.source.uri, + options: { selection: server.source.range, pinned: true }, + }); + } else if (server?.provenance?.open) { + event.preventDefault(); + server.provenance.open(); } - event.preventDefault(); - void this.editorService.openEditor({ - resource: source.uri, - options: { selection: source.range, pinned: true }, - }); })); this.bodyEl = DOM.append(this.root, $('.mcp-detail-body')); @@ -260,29 +287,14 @@ export class EmbeddedMcpServerDetail extends Disposable { this.renderIcon(); if (!server) { this.nameEl.textContent = ''; - this.pathEl.textContent = ''; - this.pathEl.removeAttribute('href'); - this.pathEl.removeAttribute('aria-label'); - this.pathEl.classList.remove('source-link'); - this.editConfigurationButton.element.style.display = 'none'; + this.renderSource(undefined); this.setDefinition(undefined); this.definitionEmptyEl.style.display = 'none'; return; } this.nameEl.textContent = server.label || server.name; - const sourceLabel = server.source ? basename(server.source.uri) : 'mcp.json'; - this.pathEl.textContent = sourceLabel; - this.pathEl.classList.toggle('source-link', !!server.source); - if (server.source) { - this.pathEl.href = '#'; - this.pathEl.setAttribute('aria-label', localize('openMcpServerSource', "Open {0}", sourceLabel)); - this.editConfigurationButton.element.style.display = ''; - } else { - this.pathEl.removeAttribute('href'); - this.pathEl.removeAttribute('aria-label'); - this.editConfigurationButton.element.style.display = 'none'; - } + this.renderSource(server); if (server.installState !== McpServerInstallState.Installed) { this.setDefinition(undefined, localize('mcpDefinitionAvailableAfterInstall', "Details are available after install when the MCP server can be inspected locally.")); } else if (server.config) { @@ -291,7 +303,7 @@ export class EmbeddedMcpServerDetail extends Disposable { this.setDefinition(undefined, localize('mcpDefinitionLoading', "Loading MCP server definition...")); void this.loadSourceDefinition(server, server.source, renderGeneration); } else { - this.setDefinition(undefined); + this.setDefinition(undefined, server.definitionUnavailable?.message, server.definitionUnavailable?.settingId); } } @@ -304,6 +316,28 @@ export class EmbeddedMcpServerDetail extends Disposable { } } + /** Shows the configuration file, or where the server comes from when it has none. */ + private renderSource(server: IMcpServerDetailInput | undefined): void { + const source = server?.source; + const provenance = source ? undefined : server?.provenance; + const label = source ? basename(source.uri) : provenance?.label ?? ''; + const linkLabel = source + ? localize('openMcpServerSource', "Open {0}", label) + : provenance?.open ? provenance.ariaLabel ?? label : undefined; + this.pathEl.textContent = label; + this.pathEl.style.display = label ? '' : 'none'; + this.pathEl.classList.toggle('provenance', provenance !== undefined); + this.pathEl.classList.toggle('source-link', linkLabel !== undefined); + if (linkLabel !== undefined) { + this.pathEl.href = '#'; + this.pathEl.setAttribute('aria-label', linkLabel); + } else { + this.pathEl.removeAttribute('href'); + this.pathEl.removeAttribute('aria-label'); + } + this.editConfigurationButton.element.style.display = source ? '' : 'none'; + } + private async editConfiguration(): Promise { const server = this.current; const source = server?.source; @@ -538,11 +572,11 @@ export class EmbeddedMcpServerDetail extends Disposable { } } - private setDefinition(definition: string | undefined, emptyMessage = localize('mcpDefinitionUnavailable', "No definition is available for this MCP server.")): void { + private setDefinition(definition: string | undefined, emptyMessage = localize('mcpDefinitionUnavailable', "No definition is available for this MCP server."), settingId?: string): void { const hasDefinition = definition !== undefined; this.definitionEditorContainer.style.display = hasDefinition ? '' : 'none'; this.definitionEmptyEl.style.display = hasDefinition ? 'none' : ''; - this.definitionEmptyEl.textContent = emptyMessage; + this.renderDefinitionEmpty(emptyMessage, hasDefinition ? undefined : settingId); if (this.currentDefinition === definition) { this._onDidChangeContent.fire(); @@ -568,6 +602,24 @@ export class EmbeddedMcpServerDetail extends Disposable { this._onDidChangeContent.fire(); } + /** Shows why there is no definition, with a link to the setting that controls the server when one exists. */ + private renderDefinitionEmpty(message: string, settingId: string | undefined): void { + this.definitionSettingsLinkListener.clear(); + DOM.clearNode(this.definitionEmptyEl); + DOM.append(this.definitionEmptyEl, $('span.mcp-detail-definition-message')).textContent = message; + if (!settingId) { + return; + } + const link = DOM.append(this.definitionEmptyEl, $('a.mcp-detail-definition-settings-link')) as HTMLAnchorElement; + link.href = '#'; + link.textContent = localize('mcpOpenServerSettings', "Open Settings"); + link.setAttribute('aria-label', localize('mcpOpenServerSettingsAria', "Open Settings for {0}", this.current?.label || this.current?.name || '')); + this.definitionSettingsLinkListener.value = DOM.addDisposableListener(link, DOM.EventType.CLICK, event => { + event.preventDefault(); + void this.commandService.executeCommand('workbench.action.openSettings', `@id:${settingId}`); + }); + } + private ensureDefinitionEditor(): CodeEditorWidget { if (!this.definitionEditor) { this.definitionEditor = this._register(this.instantiationService.createInstance( diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 125dcc50826a..76d290304649 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -59,6 +59,8 @@ import { AgentPluginItemKind, IAgentPluginItem } from '../agentPluginEditor/agen import { CustomizationMcpServerCompatibilityKind, getCustomizationDisabledLabel, ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { IAgentHostCustomizationService } from '../agentSessions/agentHost/agentHostCustomizationService.js'; import { CustomizationEnablementKind, McpServerStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { AgentHostGitHubMcpServerEnabledSettingId } from '../../../../../platform/agentHost/common/agentService.js'; +import { GITHUB_MCP_SERVER_NAME } from '../../../../../platform/agentHost/common/githubEndpoints.js'; import { IOutputService } from '../../../../services/output/common/output.js'; import { ChatConfiguration } from '../../common/constants.js'; import { getAvailableCustomizationMarketplaceInstallTelemetryContext, runCustomizationMarketplaceInstallWithTelemetry } from '../../common/customizationMarketplaceInstallTelemetry.js'; @@ -69,14 +71,14 @@ import { getErrorMessage, isCancellationError } from '../../../../../base/common import { status } from '../../../../../base/browser/ui/aria/aria.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { IMcpServerConfiguration, McpServerType } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; -import { createWorkbenchMcpServerDetailInput, IMcpServerDetailInput } from './embeddedMcpServerDetail.js'; +import { createWorkbenchMcpServerDetailInput, IMcpServerDefinitionUnavailable, IMcpServerDetailInput, IMcpServerProvenance } from './embeddedMcpServerDetail.js'; import { createCustomizationCardPrimaryAction, CustomizationCardListController, getVirtualizedSectionMinimumHeight, layoutVirtualizedSections, setVirtualizedRowActionsTabbable, trackCustomizationCardPrimaryActionFocus } from './customizationCardList.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; import { WorkbenchList, WorkbenchObjectTree } from '../../../../../platform/list/browser/listService.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ExtensionEditorTab, IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; -import { ActiveSessionMcpServerMatcher, type AgentHostMcpServer, getRuntimeServerMatchKeys, getUniqueMcpMatchKeys, isMcpServerInUse } from './mcpServerCount.js'; +import { ActiveSessionMcpServerMatcher, type AgentHostMcpServer, getActiveSessionServerLabel, getRuntimeServerMatchKeys, getUniqueMcpMatchKeys, isMcpServerInUse } from './mcpServerCount.js'; import { getConnectorActionLabel } from './connectorPresentation.js'; import { ICopilotConnector, ICopilotConnectorsService, IConnectedCopilotConnectorMcpServer } from './copilotConnectorsService.js'; import { CustomizationGroupHeaderRenderer, CUSTOMIZATION_GROUP_HEADER_HEIGHT, CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR, ICustomizationGroupHeaderEntry } from './customizationGroupHeaderRenderer.js'; @@ -471,7 +473,7 @@ export class McpServerItemRenderer extends Disposable implements IListRenderer ExtensionIdentifier.equals(extension.identifier.id, extensionId)); const extensionName = extension?.displayName || extensionId.value; return { - label: localize('fromExtension', "Extension: {0}", extensionName), + label: isCopilotExtension(extensionId) + ? localize('mcpSourceBuiltin', "Built-in: {0}", extensionName) + : localize('fromExtension', "Extension: {0}", extensionName), hover: localize('openExtensionDetails', "Open extension details for {0}", extensionName), ariaLabel: localize('openExtensionDetails', "Open extension details for {0}", extensionName), open: () => extensionsWorkbenchService.open(extensionId.value, { tab: ExtensionEditorTab.Features, feature: 'mcp' }), @@ -1166,6 +1181,55 @@ function getMcpEntryLabelWithSource(element: IMcpInstalledEntry, labelService: I : label; } +/** + * Describes where a host-reported server comes from when the host names no configuration file: + * bundled with the agent, supplied by the agent's managed catalog, contributed by a plugin the + * client may not know, configured in the agent host's own settings, or configured at a user or + * workspace level the host does not locate. + */ +export function getActiveSessionServerProvenance(server: AgentHostMcpServer, agentLabel: string): IMcpServerProvenance | undefined { + if (server.hostConfiguration && server.source !== 'builtin' && server.source !== 'managed') { + return { label: localize('mcpSourceAgentHost', "Agent host configuration") }; + } + switch (server.source) { + case 'builtin': + return { + label: server.sourcePluginName + ? localize('mcpSourceBuiltinPlugin', "Built-in plugin: {0}", server.sourcePluginName) + : localize('mcpSourceBuiltin', "Built-in: {0}", agentLabel), + }; + case 'managed': + return { label: localize('mcpSourceManaged', "Managed by {0}", agentLabel) }; + case 'plugin': + return server.sourcePluginName ? { label: localize('fromPlugin', "Plugin: {0}", server.sourcePluginName) } : undefined; + case 'workspace': + return { label: localize('mcpSourceWorkspace', "Workspace configuration") }; + case 'user': + return { label: localize('mcpSourceUser', "User configuration") }; + default: + return undefined; + } +} + +/** + * Explains why a host-reported server has no definition to show: the agent sets it up or manages it + * itself and never shares the definition. The official GitHub server links to the setting that + * includes it in agent sessions. + */ +export function getActiveSessionServerDefinitionUnavailable(server: AgentHostMcpServer, agentLabel: string): IMcpServerDefinitionUnavailable | undefined { + switch (server.source) { + case 'builtin': + return { + message: localize('mcpDefinitionBuiltin', "{0} configures this server automatically, so its definition can't be viewed or edited.", agentLabel), + ...(server.name === GITHUB_MCP_SERVER_NAME && !server.sourcePluginName ? { settingId: AgentHostGitHubMcpServerEnabledSettingId } : {}), + }; + case 'managed': + return { message: localize('mcpDefinitionManaged', "{0} manages this server, so its definition can't be viewed or edited.", agentLabel) }; + default: + return undefined; + } +} + function getMcpServerCompatibilityId(element: IMcpInstalledEntry): string | undefined { if (element.type === 'session-server-item') { return undefined; @@ -1597,7 +1661,11 @@ function isConnectorMcpEntry(entry: IMcpInstalledEntry): entry is IMcpBuiltinIte return entry.type === 'builtin-item' && entry.connector !== undefined; } -export function createInstalledMcpServerDetailInput(entry: IMcpInstalledEntry, error?: IObservable): IMcpServerDetailInput { +/** + * Creates the detail view input for an installed row. `presentation` describes where the server comes + * from and why it has no definition; the detail shows it only when the row has no configuration file. + */ +export function createInstalledMcpServerDetailInput(entry: IMcpInstalledEntry, error?: IObservable, presentation?: Pick): IMcpServerDetailInput { if (entry.type === 'server-item') { const input = createWorkbenchMcpServerDetailInput(entry.server); return { @@ -1630,13 +1698,15 @@ export function createInstalledMcpServerDetailInput(entry: IMcpInstalledEntry, e return { id: getMcpRowKey(entry), - name: getMcpEntryLabel(entry), + name: getMcpEntryName(entry), label: getMcpEntryLabel(entry), installState: McpServerInstallState.Installed, - config: localDefinition ? getMcpServerConfiguration(localDefinition) : undefined, + config: localDefinition ? getMcpServerConfiguration(localDefinition) : activeSessionServer?.hostConfiguration, compatibilityId: localDefinition?.id, error, source: localSource ?? activeSessionSource, + ...(presentation?.provenance ? { provenance: presentation.provenance } : {}), + ...(presentation?.definitionUnavailable ? { definitionUnavailable: presentation.definitionUnavailable } : {}), }; } @@ -2360,7 +2430,23 @@ export class McpListWidget extends Disposable { } const connectionState = localServer.connectionState.read(reader); return getMcpErrorMessage(connectionState.state, connectionState.state === McpConnectionState.Kind.Error ? connectionState.message : undefined); - })); + }), this.getMcpEntryDetailPresentation(entry)); + } + + /** + * Where a row's server comes from when it has no configuration file (its extension, its plugin, + * or the agent), and why the agent's own servers have no definition to show. + */ + private getMcpEntryDetailPresentation(entry: IMcpInstalledEntry): Pick { + const activeSessionServer = getActiveSessionServer(entry); + const agentLabel = this.customizationHarnessService.getActiveDescriptor().label; + const definitionUnavailable = activeSessionServer && getActiveSessionServerDefinitionUnavailable(activeSessionServer, agentLabel); + const source = getMcpEntrySource(entry, this.labelService, this.agentPluginService, this.extensionsWorkbenchService, plugin => this._onDidRequestShowPlugin.fire(createInstalledPluginItem(plugin))); + const open = source?.open; + const provenance = source && open + ? { label: source.label, ariaLabel: source.ariaLabel, open } + : activeSessionServer && getActiveSessionServerProvenance(activeSessionServer, agentLabel); + return { provenance, definitionUnavailable }; } private getMcpEntryAriaLabel(entry: IMcpInstalledEntry): IObservable { @@ -3038,7 +3124,7 @@ export class McpListWidget extends Disposable { const collectionSources = new Map(this.mcpRegistry.collections.get().map(c => [c.id, c.source])); const pluginServers: Array<{ server: IMcpServer; activeSessionServer?: AgentHostMcpServer }> = []; const extensionServers: Array<{ server: IMcpServer; activeSessionServer?: AgentHostMcpServer; extensionId: ExtensionIdentifier }> = []; - const otherBuiltinServers: Array<{ server: IMcpServer; activeSessionServer?: AgentHostMcpServer }> = []; + const otherBuiltinServers: Array<{ server: IMcpServer; activeSessionServer?: AgentHostMcpServer; extensionId?: ExtensionIdentifier }> = []; for (const server of builtinServers) { const entry = { server, activeSessionServer: activeSessionMatcher.take(getRuntimeServerMatchKeys(server)) }; const source = collectionSources.get(server.collection.id); @@ -3047,7 +3133,8 @@ export class McpListWidget extends Disposable { } else if (source instanceof ExtensionIdentifier && !isCopilotExtension(source)) { extensionServers.push({ ...entry, extensionId: source }); } else { - otherBuiltinServers.push(entry); + // Copilot's servers stay built-in, but keep the extension so the UI can show where they come from. + otherBuiltinServers.push({ ...entry, extensionId: source instanceof ExtensionIdentifier ? source : undefined }); } } const regularMatchKeys = new Set([ @@ -3070,7 +3157,7 @@ export class McpListWidget extends Disposable { ...groups.flatMap(group => group.entries.map(entry => ({ entry }))), ...pluginServers.map(({ server, activeSessionServer }) => ({ entry: createBuiltinEntry(server, activeSessionServer) })), ...extensionServers.map(({ server, activeSessionServer, extensionId }) => ({ entry: createBuiltinEntry(server, activeSessionServer, extensionId) })), - ...otherBuiltinServers.map(({ server, activeSessionServer }) => ({ entry: createBuiltinEntry(server, activeSessionServer) })), + ...otherBuiltinServers.map(({ server, activeSessionServer, extensionId }) => ({ entry: createBuiltinEntry(server, activeSessionServer, extensionId) })), ...connectorMcpEntries.map(entry => ({ entry })), ...activeSessionBuiltinEntries.map(entry => ({ entry })), ]; @@ -3098,6 +3185,8 @@ export class McpListWidget extends Disposable { getActiveSessionServer(entry) ? 'session' : '', entry.type !== 'session-server-item' && entry.localServer ? 'local' : '', getMcpEntrySourceUri(entry)?.toString() ?? '', + // A host can report a display name after the row first renders. + getMcpEntryLabel(entry), ].join(':')).join('|'); } @@ -3352,6 +3441,14 @@ export class McpListWidget extends Disposable { } } + const sourceAction = plugin ? undefined : this.getBuiltinMcpServerSourceAction(entry); + if (sourceAction) { + if (actions.length > 0) { + actions.push(new Separator()); + } + actions.push(disposables.add(sourceAction)); + } + if (plugin) { if (actions.length > 0) { actions.push(new Separator()); @@ -3414,4 +3511,22 @@ export class McpListWidget extends Disposable { } return getServerItemContextMenuActions(groups, activeSessionServer, activeSessionLifecycleAction, agentHostEnablementActions); } + + /** Reveals where a built-in row comes from: its contributing extension, or its configuration file. */ + private getBuiltinMcpServerSourceAction(entry: IMcpBuiltinItemEntry): Action | undefined { + const extensionId = entry.extensionId; + if (extensionId) { + return new Action('mcpServer.showExtension', localize('showMcpServerExtension', "Show Extension"), undefined, true, async () => { + await this.extensionsWorkbenchService.open(extensionId.value, { tab: ExtensionEditorTab.Features, feature: 'mcp' }); + }); + } + const localServer = entry.localServer; + const definitions = localServer?.readDefinitions().get(); + if (!localServer || !(definitions?.server?.presentation?.origin ?? definitions?.collection?.presentation?.origin)) { + return undefined; + } + return new Action('mcpServer.showConfiguration', localize('showMcpServerConfiguration', "Show Configuration"), undefined, true, async () => { + await this.commandService.executeCommand(McpCommandIds.ShowConfiguration, localServer.collection.id, localServer.definition.id); + }); + } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpServerCount.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpServerCount.ts index a67530b4bf1e..2c612553454b 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpServerCount.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpServerCount.ts @@ -11,6 +11,15 @@ import { IMcpServer } from '../../../mcp/common/mcpTypes.js'; export type AgentHostMcpServer = ReturnType[number]; +/** + * The name to show for a host-reported server. Managed catalog entries have opaque names, so they + * use the host's display name; every other server keeps its configuration key, because the host's + * display name can be the server's own advertised title, which the user never configured. + */ +export function getActiveSessionServerLabel(server: AgentHostMcpServer): string { + return server.source === 'managed' && server.displayName ? server.displayName : server.name; +} + export function getUniqueMcpMatchKeys(values: readonly (string | undefined)[]): string[] { const keys = new Set(); for (const value of values) { @@ -52,7 +61,9 @@ export class ActiveSessionMcpServerMatcher { } unmatched(query: string): AgentHostMcpServer[] { - return this.servers.filter(server => !this.matchedIds.has(server.id) && (!query || server.name.toLowerCase().includes(query))); + return this.servers.filter(server => !this.matchedIds.has(server.id) && (!query + || server.name.toLowerCase().includes(query) + || getActiveSessionServerLabel(server).toLowerCase().includes(query))); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index b174297d0376..167f77c6c874 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -1936,6 +1936,23 @@ per-word capitalization does not survive translation. */ border-radius: var(--vscode-cornerRadius-small); } +.ai-customization-management-editor .embedded-mcp-detail .mcp-detail-definition-settings-link { + margin-inline-start: var(--vscode-spacing-size40); + color: var(--vscode-textLink-foreground); + text-decoration: none; +} + +.ai-customization-management-editor .embedded-mcp-detail .mcp-detail-definition-settings-link:hover, +.ai-customization-management-editor .embedded-mcp-detail .mcp-detail-definition-settings-link:active { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; +} + +.ai-customization-management-editor .embedded-mcp-detail .mcp-detail-definition-settings-link:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-strokeThickness); +} + .ai-customization-management-editor .embedded-mcp-detail .embedded-detail-leading-slot:empty { display: none; } @@ -3867,6 +3884,11 @@ per-word capitalization does not survive translation. */ outline-offset: var(--vscode-strokeThickness); } +/* A provider name reads as prose rather than a file path, so it keeps the proportional description font. */ +.ai-customization-management-editor .embedded-mcp-detail .editor-item-path.provenance { + font-family: inherit; +} + .ai-customization-management-editor .editor-save-indicator { flex-shrink: 0; width: 16px; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts index dcc4ae707e9c..02b1065fd235 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts @@ -14,7 +14,7 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; -import { withMcpServerSourceMeta } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; +import { withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType, type ActionEnvelope } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerCustomization, McpServerStatus, type Customization, type CustomizationEnablement } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -307,6 +307,50 @@ suite('AbstractAgentHostCustomizationService', () => { })), sources.map(source => ({ source, sourceUri: undefined }))); }); + test('exposes host-reported display names and source plugins only when the host publishes them', () => { + const sut = createSut(); + const session = URI.parse('vscode-agent-session:///session-1'); + sut.setTarget(session, new FakeTarget([ + { ...mcpServer('host-id-7f3a', 'catalog-entry-7f3a'), _meta: withMcpServerDisplayNameMeta(withMcpServerSourceMeta(undefined, 'managed'), 'Linear') }, + { ...mcpServer('host-id-9c1e', 'computer-use'), _meta: withMcpServerSourcePluginMeta(withMcpServerSourceMeta(undefined, 'builtin'), 'computer-use') }, + mcpServer('host-id-2b4d', 'without-extension'), + ])); + + assert.deepStrictEqual(sut.getMcpServers(session).map(server => ({ + name: server.name, displayName: server.displayName, sourcePluginName: server.sourcePluginName, + })), [ + { name: 'catalog-entry-7f3a', displayName: 'Linear', sourcePluginName: undefined }, + { name: 'computer-use', displayName: undefined, sourcePluginName: 'computer-use' }, + { name: 'without-extension', displayName: undefined, sourcePluginName: undefined }, + ]); + }); + + test('exposes the agent host configuration only for top-level servers configured there', () => { + const sut = createSut(); + const session = URI.parse('vscode-agent-session:///session-1'); + const configured = { type: 'http', url: 'http://localhost:2134' }; + const pluginServerConfiguration = { type: 'stdio', command: 'plugin-server' }; + const target = new class extends FakeTarget { + readonly rootConfig = { + schema: { type: 'object' as const, properties: {} }, + values: { mcpServers: { 'my-mcp-server-618d857f': configured, 'plugin-server': pluginServerConfiguration, 'not-an-object': 'http://localhost:2134' } }, + }; + }([ + { ...mcpServer('added', 'my-mcp-server-618d857f'), uri: 'mcp-top-level:copilotcli:session-1:my-mcp-server-618d857f' }, + { ...mcpServer('inherited', 'constructor'), uri: 'mcp-top-level:copilotcli:session-1:constructor' }, + { ...mcpServer('malformed', 'not-an-object'), uri: 'mcp-top-level:copilotcli:session-1:not-an-object' }, + { type: CustomizationType.Plugin, id: 'plugin-1', uri: 'file:///plugin-1', name: 'Plugin One', children: [mcpServer('plugin-child', 'plugin-server')] } as unknown as Customization, + ]); + sut.setTarget(session, target); + + assert.deepStrictEqual(sut.getMcpServers(session).map(server => ({ name: server.name, hostConfiguration: server.hostConfiguration })), [ + { name: 'my-mcp-server-618d857f', hostConfiguration: configured }, + { name: 'constructor', hostConfiguration: undefined }, + { name: 'not-an-object', hostConfiguration: undefined }, + { name: 'plugin-server', hostConfiguration: undefined }, + ]); + }); + test('preserves global and session decisions when re-enabling workspace enablement', () => { const sut = createSut(); const session = URI.parse('vscode-agent-session:///session-1'); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts index 9843d3445747..126842925e9b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts @@ -16,7 +16,9 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { CodeEditorWidget } from '../../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { ITextModel } from '../../../../../../editor/common/model.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IFileContent, IFileService } from '../../../../../../platform/files/common/files.js'; +import { McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { IMcpWorkbenchService, McpServerInstallState } from '../../../../mcp/common/mcpTypes.js'; import { EmbeddedMcpServerDetail, IMcpServerDetailInput } from '../../../browser/aiCustomization/embeddedMcpServerDetail.js'; @@ -31,7 +33,11 @@ suite('EmbeddedMcpServerDetail', () => { function createDetail() { const instantiationService = workbenchInstantiationService(undefined, store); const reads: { uri: URI; result: DeferredPromise }[] = []; + const executedCommands: unknown[][] = []; let model: ITextModel | null = null; + instantiationService.stub(ICommandService, { + executeCommand: async (...args: unknown[]) => { executedCommands.push(args); return undefined; }, + }); instantiationService.stub(IFileService, { readFile: resource => { const result = new DeferredPromise(); @@ -69,7 +75,7 @@ suite('EmbeddedMcpServerDetail', () => { const complete = (index: number, text = content) => reads[index].result.complete(new class extends mock() { override readonly value = VSBuffer.fromString(text); }()); - return { detail, input, reads, snapshot, complete }; + return { detail, input, reads, snapshot, complete, executedCommands }; } test('loads only the selected definition after migration metadata changes during the read', async () => { @@ -152,4 +158,65 @@ suite('EmbeddedMcpServerDetail', () => { editorVisible: false, }); }); + + test('shows where a server comes from when it has no configuration file', async () => { + const { detail, input, complete } = createDetail(); + const opened: string[] = []; + const path = detail.element.querySelector('.editor-item-path')!; + const editConfiguration = detail.element.querySelector('.embedded-detail-title-actions .monaco-button')!; + const header = () => ({ + label: path.style.display === 'none' ? undefined : path.textContent, + link: path.hasAttribute('href'), + ariaLabel: path.getAttribute('aria-label'), + provenance: path.classList.contains('provenance'), + editConfiguration: editConfiguration.style.display !== 'none', + }); + const builtin = { label: 'Built-in: Copilot' }; + const extension = { label: 'Built-in: GitHub Copilot Chat', ariaLabel: 'Open extension details for GitHub Copilot Chat', open: () => opened.push('extension') }; + + detail.setInput({ ...input, source: undefined, provenance: builtin }); + const agent = header(); + detail.setInput({ ...input, source: undefined, provenance: extension }); + const contributed = header(); + path.click(); + detail.setInput({ ...input, source: undefined }); + const unknown = header(); + detail.setInput({ ...input, provenance: builtin }); + const file = header(); + await complete(0); + + assert.deepStrictEqual({ agent, contributed, unknown, file, opened }, { + agent: { label: 'Built-in: Copilot', link: false, ariaLabel: null, provenance: true, editConfiguration: false }, + contributed: { label: 'Built-in: GitHub Copilot Chat', link: true, ariaLabel: 'Open extension details for GitHub Copilot Chat', provenance: true, editConfiguration: false }, + unknown: { label: undefined, link: false, ariaLabel: null, provenance: false, editConfiguration: false }, + file: { label: 'mcp-config.json', link: true, ariaLabel: 'Open mcp-config.json', provenance: false, editConfiguration: true }, + opened: ['extension'], + }); + }); + + test('explains a missing definition and links to the setting that controls the server', () => { + const { detail, input, snapshot, executedCommands } = createDetail(); + const empty = detail.element.querySelector('.mcp-detail-definition-empty')!; + const settingsLink = () => empty.querySelector('.mcp-detail-definition-settings-link'); + const read = () => ({ message: empty.querySelector('.mcp-detail-definition-message')?.textContent, link: settingsLink()?.textContent, ariaLabel: settingsLink()?.getAttribute('aria-label'), editorVisible: snapshot().editorVisible }); + const builtin = { id: 'github', name: 'github-mcp-server', label: 'github-mcp-server', installState: McpServerInstallState.Installed }; + const message = 'Copilot configures this server automatically, so its definition can\'t be viewed or edited.'; + + detail.setInput({ ...builtin, definitionUnavailable: { message, settingId: 'chat.agentHost.githubMcpServer.enabled' } }); + const withSetting = read(); + settingsLink()!.click(); + detail.setInput({ ...builtin, definitionUnavailable: { message } }); + const withoutSetting = read(); + detail.setInput({ ...builtin }); + const unexplained = read(); + detail.setInput({ ...input, source: undefined, config: { type: McpServerType.LOCAL, command: 'memory-server' }, definitionUnavailable: { message, settingId: 'chat.agentHost.githubMcpServer.enabled' } }); + + assert.deepStrictEqual({ withSetting, withoutSetting, unexplained, withDefinition: read(), executedCommands }, { + withSetting: { message, link: 'Open Settings', ariaLabel: 'Open Settings for github-mcp-server', editorVisible: false }, + withoutSetting: { message, link: undefined, ariaLabel: undefined, editorVisible: false }, + unexplained: { message: 'No definition is available for this MCP server.', link: undefined, ariaLabel: undefined, editorVisible: false }, + withDefinition: { message: 'No definition is available for this MCP server.', link: undefined, ariaLabel: undefined, editorVisible: true }, + executedCommands: [['workbench.action.openSettings', '@id:chat.agentHost.githubMcpServer.enabled']], + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts index d0e0a6a370c4..4d00681a4ee2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts @@ -30,6 +30,7 @@ import { IThemeService } from '../../../../../../platform/theme/common/themeServ import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { mcpAccessConfig, McpAccessValue } from '../../../../../../platform/mcp/common/mcpManagement.js'; +import { McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { IExtensionsWorkbenchService } from '../../../../extensions/common/extensions.js'; import { IAuthenticationQueryService } from '../../../../../services/authentication/common/authenticationQuery.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; @@ -42,6 +43,7 @@ import { IAgentHostCustomizationService } from '../../../browser/agentSessions/a import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { IMcpServer, IMcpService, IMcpWorkbenchService, IMcpSamplingService, IWorkbenchMcpServer, MCP_PLUGIN_COLLECTION_ID_PREFIX, McpCollectionDefinition, McpCollectionProvenance, McpConnectionState, McpServerDefinition, McpServerInstallState, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; import { DisableMcpServerForWorkspaceAction, DisableMcpServerGloballyAction, EnableMcpServerForWorkspaceAction, EnableMcpServerGloballyAction } from '../../../../mcp/browser/mcpServerActions.js'; +import { McpCommandIds } from '../../../../mcp/common/mcpCommandIds.js'; import { AgentHostMcpServer, authenticateMcpServer, @@ -49,6 +51,8 @@ import { createInstalledMcpServerDetailInput, getActiveSessionServerLifecycleAction, getActiveSessionServerPresentation, + getActiveSessionServerDefinitionUnavailable, + getActiveSessionServerProvenance, getBuiltinMcpServerEnablementActions, getActiveSessionServerOptionsActions, getAgentHostMcpServerEnablementActions, @@ -77,7 +81,7 @@ import { shouldLoadMcpGallerySnapshot, setPrimaryMcpServerEnablement, } from '../../../browser/aiCustomization/mcpListWidget.js'; -import { ActiveSessionMcpServerMatcher, getEffectiveMcpServerCount, getRuntimeServerMatchKeys } from '../../../browser/aiCustomization/mcpServerCount.js'; +import { ActiveSessionMcpServerMatcher, getActiveSessionServerLabel, getEffectiveMcpServerCount, getRuntimeServerMatchKeys } from '../../../browser/aiCustomization/mcpServerCount.js'; import { ICopilotConnector, ICopilotConnectorsService } from '../../../browser/aiCustomization/copilotConnectorsService.js'; import { CustomizationCardListController } from '../../../browser/aiCustomization/customizationCardList.js'; @@ -461,6 +465,7 @@ suite('mcpListWidget', () => { getMcpEntryGroup(localEntry(LocalMcpServerScope.Workspace)), getMcpEntryGroup({ type: 'builtin-item', id: 'plugin', label: 'Plugin', description: '', collectionId: `${MCP_PLUGIN_COLLECTION_ID_PREFIX}plugin` }), getMcpEntryGroup({ type: 'builtin-item', id: 'extension', label: 'Extension', description: '', extensionId: new ExtensionIdentifier('publisher.extension') }), + getMcpEntryGroup({ type: 'builtin-item', id: 'copilot', label: 'GitHub', description: '', extensionId: new ExtensionIdentifier('GitHub.copilot-chat') }), getMcpEntryGroup(createBuiltinActiveSessionMcpEntries([createAgentHostServer()])[0]), ], [ 'user', @@ -468,6 +473,7 @@ suite('mcpListWidget', () => { 'plugins', 'extensions', 'builtin', + 'builtin', ]); }); @@ -556,6 +562,7 @@ suite('mcpListWidget', () => { createAgentHostServer({ name: 'plugin-server', source: 'plugin' }), createAgentHostServer({ name: 'github-mcp-server', source: 'builtin' }), createAgentHostServer({ name: 'managed-server', source: 'managed' }), + createAgentHostServer({ name: 'my-mcp-server-618d857f', hostConfiguration: { type: McpServerType.REMOTE, url: 'http://localhost:2134' } }), createAgentHostServer({ name: 'legacy-server' }), ]; @@ -568,6 +575,7 @@ suite('mcpListWidget', () => { { name: 'plugin-server', group: 'plugins' }, { name: 'github-mcp-server', group: 'builtin' }, { name: 'managed-server', group: 'builtin' }, + { name: 'my-mcp-server-618d857f', group: 'user' }, { name: 'legacy-server', group: 'builtin' }, ]); }); @@ -768,6 +776,103 @@ suite('mcpListWidget', () => { }); }); + test('describes host-only MCP servers without a configuration file by their runtime source', () => { + const provenance = (overrides: Partial) => getActiveSessionServerProvenance(createAgentHostServer(overrides), 'Copilot')?.label; + const hostConfiguration = { type: McpServerType.REMOTE, url: 'http://localhost:2134' } as const; + + assert.deepStrictEqual([ + provenance({ source: 'builtin' }), + provenance({ source: 'builtin', sourcePluginName: 'computer-use' }), + provenance({ source: 'managed', displayName: 'Linear' }), + provenance({ source: 'plugin', sourcePluginName: 'acme' }), + provenance({ source: 'plugin' }), + provenance({ source: 'user' }), + provenance({ source: 'workspace' }), + provenance({ hostConfiguration }), + provenance({ source: 'managed', hostConfiguration }), + provenance({}), + ], [ + 'Built-in: Copilot', + 'Built-in plugin: computer-use', + 'Managed by Copilot', + 'Plugin: acme', + undefined, + 'User configuration', + 'Workspace configuration', + 'Agent host configuration', + 'Managed by Copilot', + undefined, + ]); + }); + + test('shows the agent host definition of servers configured in the agent host', () => { + const hostConfiguration = { type: McpServerType.REMOTE, url: 'http://localhost:2134' } as const; + const detail = createInstalledMcpServerDetailInput(createBuiltinActiveSessionMcpEntries([ + createAgentHostServer({ name: 'my-mcp-server-618d857f', hostConfiguration }), + ])[0]); + + assert.deepStrictEqual({ name: detail.name, config: detail.config, source: detail.source }, { + name: 'my-mcp-server-618d857f', + config: hostConfiguration, + source: undefined, + }); + }); + + test('labels host-only MCP servers by display name only when their name is an opaque managed identifier', () => { + assert.deepStrictEqual([ + getActiveSessionServerLabel(createAgentHostServer({ name: 'github-copilot-connector-1', source: 'managed', displayName: 'Linear' })), + getActiveSessionServerLabel(createAgentHostServer({ name: 'github-copilot-connector-2', source: 'managed' })), + getActiveSessionServerLabel(createAgentHostServer({ name: 'migratable', source: 'workspace', displayName: 'Everything Reference Server' })), + getActiveSessionServerLabel(createAgentHostServer({ name: 'github-mcp-server', source: 'builtin', displayName: 'GitHub MCP Server' })), + ], [ + 'Linear', + 'github-copilot-connector-2', + 'migratable', + 'github-mcp-server', + ]); + }); + + test('keeps the configuration key as the detail name when the host reports a display name', () => { + const provenance = { label: 'Managed by Copilot' }; + const definitionUnavailable = { message: 'Copilot manages this server, so its definition can\'t be viewed or edited.' }; + const detail = createInstalledMcpServerDetailInput({ + type: 'session-server-item', + server: createAgentHostServer({ name: 'github-copilot-connector-1', displayName: 'Linear', source: 'managed' }), + }, undefined, { provenance, definitionUnavailable }); + + assert.deepStrictEqual({ name: detail.name, label: detail.label, source: detail.source, provenance: detail.provenance, definitionUnavailable: detail.definitionUnavailable }, { + name: 'github-copilot-connector-1', + label: 'Linear', + source: undefined, + provenance, + definitionUnavailable, + }); + }); + + test('explains why the agent\'s own MCP servers have no definition and links the GitHub server to its setting', () => { + const explain = (overrides: Partial) => getActiveSessionServerDefinitionUnavailable(createAgentHostServer(overrides), 'Copilot'); + + assert.deepStrictEqual([ + explain({ name: 'github-mcp-server', source: 'builtin' }), + explain({ name: 'github-mcp-server', source: 'builtin', sourcePluginName: 'acme' }), + explain({ name: 'computer-use', source: 'builtin', sourcePluginName: 'computer-use' }), + explain({ name: 'github-copilot-connector-1', source: 'managed', displayName: 'Linear' }), + explain({ name: 'github-mcp-server', source: 'user' }), + explain({ name: 'workspace-server', source: 'workspace' }), + explain({ name: 'plugin-server', source: 'plugin', sourcePluginName: 'acme' }), + explain({ name: 'github-mcp-server' }), + ], [ + { message: 'Copilot configures this server automatically, so its definition can\'t be viewed or edited.', settingId: 'chat.agentHost.githubMcpServer.enabled' }, + { message: 'Copilot configures this server automatically, so its definition can\'t be viewed or edited.' }, + { message: 'Copilot configures this server automatically, so its definition can\'t be viewed or edited.' }, + { message: 'Copilot manages this server, so its definition can\'t be viewed or edited.' }, + undefined, + undefined, + undefined, + undefined, + ]); + }); + test('toggles MCP enablement without changing its scope', () => { assert.deepStrictEqual([ getToggledMcpEnablementState(ContributionEnablementState.EnabledProfile), @@ -1061,14 +1166,14 @@ suite('mcpListWidget', () => { }); test('distinguishes membership changes from state-only changes', () => { - const getMembershipSignature = (sourceUri: URI, source?: AgentHostMcpServer['source']) => { + const getMembershipSignature = (sourceUri: URI | undefined, source?: AgentHostMcpServer['source'], displayName?: string) => { const widget = Object.create(McpListWidget.prototype); Object.assign(widget, { installedEntries: [{ entry: { type: 'session-server-item', id: 'server-1', - server: createAgentHostServer({ sourceUri, source }), + server: createAgentHostServer({ sourceUri, source, displayName }), }, }], }); @@ -1079,7 +1184,8 @@ suite('mcpListWidget', () => { hasSameMcpMembership('server:one:session', 'server:one:session|server:two:session'), hasSameMcpMembership(getMembershipSignature(URI.file('/workspace/old.json')), getMembershipSignature(URI.file('/workspace/new.json'))), hasSameMcpMembership(getMembershipSignature(URI.file('/config.json')), getMembershipSignature(URI.file('/config.json'), 'user')), - ], [true, false, false, false]); + hasSameMcpMembership(getMembershipSignature(undefined, 'managed'), getMembershipSignature(undefined, 'managed', 'Linear')), + ], [true, false, false, false, false]); }); test('renders host-published disabled reasons without changing legacy rows', () => { @@ -1562,6 +1668,7 @@ suite('mcpListWidget', () => { const managementClicks: string[] = []; const openedPlugins: string[] = []; const openedExtensions: string[] = []; + const executedCommands: unknown[][] = []; let migrationRequests = 0; let inlineOutputRequests = 0; const hostEnablementCalls: Parameters[] = []; @@ -1582,6 +1689,7 @@ suite('mcpListWidget', () => { } as unknown as IAgentHostCustomizationService; const customizationHarnessService = { activeSessionResource, + getActiveDescriptor: () => ({ label: 'Copilot' }), } as unknown as ICustomizationHarnessService; const marketplaceInstallService = new class extends mock() { override readonly onDidChange = Event.None; @@ -1608,6 +1716,9 @@ suite('mcpListWidget', () => { local: [{ identifier: { id: 'publisher.extension' }, displayName: 'Example Extension', + }, { + identifier: { id: 'GitHub.copilot-chat' }, + displayName: 'GitHub Copilot Chat', }], open: async (extensionId: string) => { openedExtensions.push(extensionId); }, } as unknown as IExtensionsWorkbenchService; @@ -1691,6 +1802,7 @@ suite('mcpListWidget', () => { managementClicks, openedPlugins, openedExtensions, + executedCommands, migrationRequests: () => migrationRequests, inlineOutputRequests: () => inlineOutputRequests, hostEnablementCalls, @@ -1724,7 +1836,7 @@ suite('mcpListWidget', () => { }); Object.assign(widget, { instantiationService, mcpService, mcpWorkbenchService, agentPluginService, - commandService: { executeCommand: async () => undefined }, + commandService: { executeCommand: async (...args: unknown[]) => { executedCommands.push(args); } }, workspaceService: { isSessionsWindow, getActiveProjectRoot: () => undefined }, outputService: { showChannel: async () => { } }, }); @@ -1986,6 +2098,101 @@ suite('mcpListWidget', () => { }); }); + function builtinLocalServer(origin?: URI): IMcpServer { + const detailServer = createMcpDetailTestServer(origin); + return { + ...detailServer, + definition: detailServer.readDefinitions().get().server, + collection: { id: 'collection-1', label: 'Collection', order: 0 }, + enablement: observableValue('enablement', ContributionEnablementState.EnabledProfile), + connectionState: observableValue('connectionState', { state: McpConnectionState.Kind.Running }), + } as IMcpServer; + } + + test('shows Copilot as the built-in source of its MCP servers in the row and the detail', () => { + const ctx = createRenderer(createAgentHostServer(), false); + disposables.add(ctx.store); + const entry: Entry = { + type: 'builtin-item', + id: 'github', + label: 'GitHub', + description: '', + extensionId: new ExtensionIdentifier('GitHub.copilot-chat'), + localServer: builtinLocalServer(), + }; + ctx.render(entry); + ctx.templateData.sourcePath.click(); + const detail = ctx.detailInput(entry); + detail.provenance?.open?.(); + + assert.deepStrictEqual({ + group: getMcpEntryGroup(entry), + source: ctx.readSource(), + ariaLabel: ctx.read().ariaLabel, + detail: { source: detail.source, label: detail.provenance?.label, ariaLabel: detail.provenance?.ariaLabel }, + openedExtensions: ctx.openedExtensions, + }, { + group: 'builtin', + source: { + label: 'Built-in: GitHub Copilot Chat', + hover: 'Open extension details for GitHub Copilot Chat', + tagName: 'A', + ariaLabel: 'Open extension details for GitHub Copilot Chat', + tabIndex: 0, + }, + ariaLabel: 'GitHub, configured in Built-in: GitHub Copilot Chat', + detail: { source: undefined, label: 'Built-in: GitHub Copilot Chat', ariaLabel: 'Open extension details for GitHub Copilot Chat' }, + openedExtensions: ['GitHub.copilot-chat', 'GitHub.copilot-chat'], + }); + }); + + test('shows host display names on runtime-only rows and their runtime source in the detail', () => { + const server = createAgentHostServer({ name: 'github-copilot-connector-1', displayName: 'Linear', source: 'managed' }); + const ctx = createRenderer(server, false); + disposables.add(ctx.store); + const entry = createBuiltinActiveSessionMcpEntries([server])[0]; + ctx.render(entry); + const detail = ctx.detailInput(entry); + + assert.deepStrictEqual({ + name: ctx.templateData.name.textContent, + source: ctx.readSource().label, + ariaLabel: ctx.read().ariaLabel, + detail: { name: detail.name, label: detail.label, provenance: detail.provenance, definitionUnavailable: detail.definitionUnavailable }, + }, { + name: 'Linear', + source: '', + ariaLabel: 'Linear, Running', + detail: { + name: 'github-copilot-connector-1', + label: 'Linear', + provenance: { label: 'Managed by Copilot' }, + definitionUnavailable: { message: 'Copilot manages this server, so its definition can\'t be viewed or edited.' }, + }, + }); + }); + + test('built-in row menus reveal the extension or configuration file the server comes from', () => { + const ctx = createRenderer(createAgentHostServer(), false); + disposables.add(ctx.store); + const sourceActions = (entry: Extract) => ctx.menu(entry, entry.localServer) + .filter(action => action.id === 'mcpServer.showExtension' || action.id === 'mcpServer.showConfiguration'); + const extension = sourceActions({ type: 'builtin-item', id: 'github', label: 'GitHub', description: '', extensionId: new ExtensionIdentifier('GitHub.copilot-chat'), localServer: builtinLocalServer() }); + const file = sourceActions({ type: 'builtin-item', id: 'claude', label: 'Claude', description: '', localServer: builtinLocalServer(URI.file('/Users/test/Library/Application Support/Claude/claude_desktop_config.json')) }); + const none = sourceActions({ type: 'builtin-item', id: 'other', label: 'Other', description: '', localServer: builtinLocalServer() }); + [...extension, ...file].forEach(runAction); + + assert.deepStrictEqual({ + labels: [extension, file, none].map(actions => actions.map(action => action.label)), + openedExtensions: ctx.openedExtensions, + executedCommands: ctx.executedCommands, + }, { + labels: [['Show Extension'], ['Show Configuration'], []], + openedExtensions: ['GitHub.copilot-chat'], + executedCommands: [[McpCommandIds.ShowConfiguration, 'collection-1', 'server-1']], + }); + }); + function nativeServer() { const outputCalls: string[] = []; const startCalls: string[] = []; diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 288bf7f46861..bb153c947b7d 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -2643,6 +2643,9 @@ function renderEmbeddedMcpDetail( readonly error?: string; readonly migratable?: boolean; readonly source?: IMcpServerDetailInput['source']; + /** Replaces the server's configuration file with a non-file provenance. */ + readonly provenance?: IMcpServerDetailInput['provenance']; + readonly definitionUnavailable?: IMcpServerDetailInput['definitionUnavailable']; } = {}, ): void { const width = options.width ?? 480; @@ -2699,7 +2702,9 @@ function renderEmbeddedMcpDetail( ...input, error: options.error ? constObservable(options.error) : undefined, migratable: options.migratable, - source: options.source ?? input.source, + source: options.provenance ? undefined : options.source ?? input.source, + provenance: options.provenance, + definitionUnavailable: options.definitionUnavailable, }); } } @@ -3868,6 +3873,35 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { })), }), + EmbeddedMcpDetailBuiltinAgent: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + expectedVisualDescriptions: ['The MCP detail header shows Built-in: Copilot as plain description text in the proportional font below the server name. There is no Edit Configuration button. Configuration explains that Copilot configures this server automatically so its definition can\'t be viewed or edited, followed by an Open Settings link.'], + render: ctx => renderEmbeddedMcpDetail( + ctx, + makeLocalMcpServer('github-mcp-server', 'github-mcp-server', LocalMcpServerScope.User), + { + provenance: { label: 'Built-in: Copilot' }, + definitionUnavailable: { + message: 'Copilot configures this server automatically, so its definition can\'t be viewed or edited.', + settingId: 'chat.agentHost.githubMcpServer.enabled', + }, + }, + ), + }), + + EmbeddedMcpDetailBuiltinExtension: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + expectedVisualDescriptions: ['The MCP detail header shows Built-in: GitHub Copilot Chat as a themed link in the proportional font below the server name. There is no Edit Configuration button, and the HTTP configuration is shown below.'], + render: ctx => renderEmbeddedMcpDetail( + ctx, + makeLocalMcpServer('github', 'GitHub', LocalMcpServerScope.User, 'GitHub tools from Copilot', { + type: McpServerType.REMOTE, + url: 'https://api.githubcopilot.com/mcp/', + }), + { provenance: { label: 'Built-in: GitHub Copilot Chat', ariaLabel: 'Open extension details for GitHub Copilot Chat', open: () => { } } }, + ), + }), + EmbeddedMcpDetailErrorUnsupported: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: false }, additionalThemes: ['darkHighContrast', 'lightHighContrast'], From a6e9db7ea08bfea26bb1082f20373a17e4b3d326 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Sat, 3 Oct 2026 00:37:18 -0700 Subject: [PATCH 2/2] mcp: take MCP settings links from the host and refresh open details Address review feedback on the MCP provenance change: - The agent host now declares which VS Code setting controls a server it adds itself, in provider-owned metadata, instead of the UI inferring it from the server's name. Copilot declares the GitHub MCP server setting only for the runtime's own GitHub server, so a server with the same name from another host, a plugin or user configuration gets no settings link it cannot control. - An open MCP details page now refreshes when the agent host reports new metadata, so a restored server picks up its display name, provenance, settings link and host configuration once the runtime inventory arrives, without reopening it. Fixes #338719 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/githubEndpoints.ts | 3 - .../common/meta/mcpCustomizationMeta.ts | 2 +- .../meta/vscode/mcpCustomizationMeta.ts | 18 +++++ .../node/copilot/copilotAgentSession.ts | 6 ++ .../agentHost/node/shared/githubMcpServer.ts | 2 +- .../node/shared/mcpCustomizationController.ts | 24 ++++--- .../test/common/agentMetaReaders.test.ts | 16 +++-- .../test/node/copilotAgentSession.test.ts | 39 ++++++++++- .../common/agentHostSessionsProvider.ts | 5 ++ .../agentHostCustomizationService.ts | 3 +- .../aiCustomizationManagementEditor.ts | 16 +++++ .../embeddedMcpServerDetail.ts | 43 +++++++++++- .../browser/aiCustomization/mcpListWidget.ts | 33 +++++++-- .../agentHostCustomizationService.test.ts | 17 +++-- .../embeddedMcpServerDetail.test.ts | 70 +++++++++++++++++++ .../aiCustomization/mcpListWidget.test.ts | 46 ++++++++++-- 16 files changed, 301 insertions(+), 42 deletions(-) diff --git a/src/vs/platform/agentHost/common/githubEndpoints.ts b/src/vs/platform/agentHost/common/githubEndpoints.ts index 149896f861b4..75284a17da87 100644 --- a/src/vs/platform/agentHost/common/githubEndpoints.ts +++ b/src/vs/platform/agentHost/common/githubEndpoints.ts @@ -6,9 +6,6 @@ import { IGitHubEndpoints } from '../../github/common/githubEndpoints.js'; import { ProtectedResourceMetadata } from './state/protocol/state.js'; -/** Name under which agent hosts register the official GitHub MCP server. */ -export const GITHUB_MCP_SERVER_NAME = 'github-mcp-server'; - /** * The GitHub Copilot protected resource for the given endpoints. Shared by the * endpoint service and tests so the resource identity is defined once. diff --git a/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts b/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts index 37eb09dbdfe7..2db68df127f8 100644 --- a/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/mcpCustomizationMeta.ts @@ -4,4 +4,4 @@ *--------------------------------------------------------------------------------------------*/ export type { McpServerSource } from './vscode/mcpCustomizationMeta.js'; -export { readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from './vscode/mcpCustomizationMeta.js'; +export { readMcpServerControllingSetting, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerControllingSettingMeta, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from './vscode/mcpCustomizationMeta.js'; diff --git a/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts b/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts index 4d2fa0517cfb..2868d8f51e83 100644 --- a/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts +++ b/src/vs/platform/agentHost/common/meta/vscode/mcpCustomizationMeta.ts @@ -8,6 +8,7 @@ import type { McpServerCustomization } from '../../state/protocol/state.js'; const sourceKey = 'agentHost.mcpServerSource'; const displayNameKey = 'agentHost.mcpServerDisplayName'; const sourcePluginKey = 'agentHost.mcpServerSourcePlugin'; +const controllingSettingKey = 'vscode.mcpServerControllingSetting'; export type McpServerSource = | 'user' // Defined in user-level configuration. @@ -71,6 +72,23 @@ export function withMcpServerSourcePluginMeta(meta: Record | un return withMetaEntry(meta, sourcePluginKey, sourcePlugin); } +/** + * Reads the VS Code setting a VS Code agent host declares as controlling whether it includes a server. + * Only the host that owns the server can declare this; clients must not infer it from the server's name, + * because another host can publish a server with the same name that the setting cannot control. + */ +export function readMcpServerControllingSetting(customization: McpServerCustomization | undefined): string | undefined { + return readNonEmptyString(customization?._meta?.[controllingSettingKey]); +} + +/** + * Records the VS Code setting that controls whether the host includes a server. An absent setting removes a + * previously recorded one; every other entry is preserved. + */ +export function withMcpServerControllingSettingMeta(meta: Record | undefined, settingId: string | undefined): Record | undefined { + return withMetaEntry(meta, controllingSettingKey, settingId); +} + function readNonEmptyString(value: unknown): string | undefined { return typeof value === 'string' && value.trim().length > 0 ? value : undefined; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index d50590b45d75..fde816ab5253 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -89,6 +89,7 @@ import { CopilotSandboxDiagnostics } from './copilotSandboxDiagnostics.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { AGENT_MERGE_GITHUB_TOOL_RESTRICTION, getAgentMergeGitHubToolRestriction, isAgentMergeRestrictedMcpServer, isCopilotMcpToolName } from '../shared/agentMergeToolRestrictions.js'; import { GITHUB_MCP_SERVER_NAME } from '../shared/githubMcpServer.js'; +import { AgentHostGitHubMcpServerEnabledSettingId } from '../../common/agentService.js'; import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getStreamingInvocationMessage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isAgentCoordinationTool, isCopilotSdkToolOutputFile, isEditTool, isHiddenTool, isShellHelperTool, isShellTool, isTaskCompleteTool, parseCopilotStreamingToolInput, synthesizeSkillToolCall, tryStringify } from './copilotToolDisplay.js'; import { FileEditTracker } from '../shared/fileEditTracker.js'; import { ICopilotApiService, type IRestrictedTelemetryContext } from '../shared/copilotApiService.js'; @@ -1508,6 +1509,11 @@ export class CopilotAgentSession extends Disposable { const resolution = this._customizationEnablementService.resolve(this._ownerSessionUri.toString(), targetForMcpServer(server, owningPluginUri, false)); return resolution.kind === 'resolved' ? resolution.enablement : undefined; }, + // The runtime adds its own GitHub server only while the setting is on; a server merely named the same + // (user, workspace or plugin configured) is not the runtime's, and the setting cannot control it. + controllingSetting: (serverName, provenance) => serverName === GITHUB_MCP_SERVER_NAME && provenance.source === 'builtin' && !provenance.sourcePlugin + ? AgentHostGitHubMcpServerEnabledSettingId + : undefined, })); this._register(toDisposable(() => this._cancelAllPendingInteractions())); diff --git a/src/vs/platform/agentHost/node/shared/githubMcpServer.ts b/src/vs/platform/agentHost/node/shared/githubMcpServer.ts index 26f1ec95f507..beea9ff8df71 100644 --- a/src/vs/platform/agentHost/node/shared/githubMcpServer.ts +++ b/src/vs/platform/agentHost/node/shared/githubMcpServer.ts @@ -8,7 +8,7 @@ import { McpServerType, type IMcpServerConfiguration } from '../../../mcp/common import type { ICopilotApiService } from './copilotApiService.js'; import { findExecutable } from '../../../../base/node/processes.js'; -export { GITHUB_MCP_SERVER_NAME } from '../../common/githubEndpoints.js'; +export const GITHUB_MCP_SERVER_NAME = 'github-mcp-server'; export const GITHUB_MCP_FEATURES_HEADER = 'X-MCP-Features'; export const GITHUB_MCP_FEATURES = 'remote_mcp_ui_apps,mcp_apps_disable_form_deferral'; export const GITHUB_MCP_TOOLS_HEADER = 'X-MCP-Tools'; diff --git a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts index 0fdbcd5df820..b207ddc8f676 100644 --- a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts +++ b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../base/common/uri.js'; import { AgentSession } from '../../common/agent.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { isCustomizationEnabled } from '../../common/customizationEnablement.js'; -import { McpServerSource, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from '../../common/meta/mcpCustomizationMeta.js'; +import { McpServerSource, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerControllingSettingMeta, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from '../../common/meta/mcpCustomizationMeta.js'; import { CustomizationLoadStatus, CustomizationType, McpServerStatus, type AhpMcpUiHostCapabilities, type Customization, type CustomizationEnablement, type McpServerCustomization, type McpServerState } from '../../common/state/protocol/channels-session/state.js'; import { DEFAULT_MCP_APP, DEFAULT_MCP_APP_CAPABILITIES } from '../../common/state/protocol/mcpAppDefaults.js'; import { parseChatUri } from '../../common/state/sessionState.js'; @@ -49,13 +49,13 @@ export interface ISdkMcpServer { } /** Where a top-level server comes from, as last reported by its provider. */ -interface ITopLevelProvenance { +export interface ISdkMcpServerProvenance { readonly source: McpServerSource | undefined; readonly displayName: string | undefined; readonly sourcePlugin: string | undefined; } -function readTopLevelProvenance(customization: McpServerCustomization | undefined): ITopLevelProvenance { +function readTopLevelProvenance(customization: McpServerCustomization | undefined): ISdkMcpServerProvenance { return { source: readMcpServerSource(customization), displayName: readMcpServerDisplayName(customization), @@ -142,6 +142,11 @@ export interface IMcpCustomizationControllerOptions { readonly pluginMcpServerSources?: () => ReadonlyMap | undefined; /** Resolves the scoped enablement to publish for a temporarily top-level server. */ readonly resolveEnablement?: (server: McpServerCustomization, owningPluginUri: string | undefined) => readonly CustomizationEnablement[] | undefined; + /** + * Returns the VS Code setting that controls whether this host includes a server it adds itself, given the + * server's current provenance. Published so clients can offer the setting without guessing from the name. + */ + readonly controllingSetting?: (serverName: string, provenance: ISdkMcpServerProvenance) => string | undefined; /** * MCP App capabilities to advertise on every ready server. Defaults * to {@link DEFAULT_MCP_APP_CAPABILITIES}. @@ -410,7 +415,7 @@ export class McpCustomizationController extends Disposable { } // Lifecycle updates carry no provenance, so keep what was last reported or restored. const known = readTopLevelProvenance(previous?.topLevelCustomization ?? this._findPublishedTopLevel(topLevelId)); - const provenance: ITopLevelProvenance = { + const provenance: ISdkMcpServerProvenance = { source: server.source ?? known.source, displayName: server.displayName !== undefined ? server.displayName ?? undefined : known.displayName, sourcePlugin: server.pluginName !== undefined ? server.pluginName ?? undefined : known.sourcePlugin, @@ -542,7 +547,7 @@ export class McpCustomizationController extends Disposable { return buildMcpChannel(this._chatUri, serverName); } - private _buildTopLevel(id: string, serverName: string, state: McpServerState, enabled: boolean, provenance: ITopLevelProvenance, sourceUri?: string | null): McpServerCustomization { + private _buildTopLevel(id: string, serverName: string, state: McpServerState, enabled: boolean, provenance: ISdkMcpServerProvenance, sourceUri?: string | null): McpServerCustomization { const channel = this._buildChannel(serverName, state); const owningPluginUri = this.pluginMcpServerSources?.get(serverName); // Per AHP spec, `mcpApp` is a static capability declaration — @@ -555,9 +560,12 @@ export class McpCustomizationController extends Disposable { : DEFAULT_MCP_APP; const existing = this._findPublishedTopLevel(id); // `SessionCustomizationUpdated` replaces the whole customization, so keep opaque entries owned by others. - const meta = withMcpServerSourcePluginMeta( - withMcpServerDisplayNameMeta(withMcpServerSourceMeta(existing?._meta, provenance.source), provenance.displayName), - provenance.sourcePlugin, + const meta = withMcpServerControllingSettingMeta( + withMcpServerSourcePluginMeta( + withMcpServerDisplayNameMeta(withMcpServerSourceMeta(existing?._meta, provenance.source), provenance.displayName), + provenance.sourcePlugin, + ), + this._options.controllingSetting?.(serverName, provenance), ); const uri = (sourceUri === undefined ? existing?.uri : sourceUri) ?? this._mintTopLevelId(serverName); const customization: McpServerCustomization = { diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index 60aafeaaab4c..ee76228a96b9 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -12,7 +12,7 @@ import { readSessionSandboxPolicy, withSessionSandboxPolicy } from '../../common import { readChatInputState, withChatInputState } from '../../common/meta/agentHostChatInputState.js'; import { createEditorInlineChatInstruction, createTerminalChatInstruction, readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; import { readAgentCustomizationMeta, toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js'; -import { readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from '../../common/meta/mcpCustomizationMeta.js'; +import { readMcpServerControllingSetting, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerControllingSettingMeta, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from '../../common/meta/mcpCustomizationMeta.js'; import { getCommandArgumentHint, getCompletionAction, readCompletionAttachmentMeta, toCommandCompletionAttachmentMeta, toSkillCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; import { CustomizationType, MessageAttachmentKind, ToolCallStatus, hasReportedUsage, readSessionComparisonMetadata, readUsageInfoMeta, withSessionComparisonMetadata, type AgentCustomization, type ClientPluginCustomization, type ToolCallState, type UsageInfo } from '../../common/state/sessionState.js'; import { McpServerStatus, type McpServerCustomization, type SessionModelInfo, type SimpleMessageAttachment } from '../../common/state/protocol/state.js'; @@ -137,7 +137,7 @@ suite('Agent host _meta readers', () => { }); }); - test('validates MCP display names and source plugins and removes them once they no longer apply', () => { + test('validates MCP display names, source plugins and controlling settings and removes them once they no longer apply', () => { const customization = (meta: Record | undefined): McpServerCustomization => ({ type: CustomizationType.McpServer, id: 'server', @@ -147,21 +147,23 @@ suite('Agent host _meta readers', () => { _meta: meta, }); const opaque = { 'test.opaque': 'kept' }; - const recorded = withMcpServerSourcePluginMeta(withMcpServerDisplayNameMeta(opaque, 'Linear'), 'computer-use'); + const recorded = withMcpServerControllingSettingMeta(withMcpServerSourcePluginMeta(withMcpServerDisplayNameMeta(opaque, 'Linear'), 'computer-use'), 'chat.example.enabled'); assert.deepStrictEqual({ - read: [readMcpServerDisplayName(customization(recorded)), readMcpServerSourcePlugin(customization(recorded))], + read: [readMcpServerDisplayName(customization(recorded)), readMcpServerSourcePlugin(customization(recorded)), readMcpServerControllingSetting(customization(recorded))], invalidDisplayNames: [undefined, '', ' ', 1, {}, ['Linear']].map(value => readMcpServerDisplayName(customization({ 'agentHost.mcpServerDisplayName': value }))), invalidPlugins: [undefined, '', 1].map(value => readMcpServerSourcePlugin(customization({ 'agentHost.mcpServerSourcePlugin': value }))), + invalidSettings: [undefined, '', 1, {}].map(value => readMcpServerControllingSetting(customization({ 'vscode.mcpServerControllingSetting': value }))), recorded, - cleared: withMcpServerSourcePluginMeta(withMcpServerDisplayNameMeta(recorded, undefined), undefined), + cleared: withMcpServerControllingSettingMeta(withMcpServerSourcePluginMeta(withMcpServerDisplayNameMeta(recorded, undefined), undefined), undefined), emptied: withMcpServerDisplayNameMeta({ 'agentHost.mcpServerDisplayName': 'Linear' }, undefined), unchanged: withMcpServerDisplayNameMeta(opaque, undefined) === opaque && withMcpServerDisplayNameMeta(recorded, 'Linear') === recorded, }, { - read: ['Linear', 'computer-use'], + read: ['Linear', 'computer-use', 'chat.example.enabled'], invalidDisplayNames: [undefined, undefined, undefined, undefined, undefined, undefined], invalidPlugins: [undefined, undefined, undefined], - recorded: { 'test.opaque': 'kept', 'agentHost.mcpServerDisplayName': 'Linear', 'agentHost.mcpServerSourcePlugin': 'computer-use' }, + invalidSettings: [undefined, undefined, undefined, undefined], + recorded: { 'test.opaque': 'kept', 'agentHost.mcpServerDisplayName': 'Linear', 'agentHost.mcpServerSourcePlugin': 'computer-use', 'vscode.mcpServerControllingSetting': 'chat.example.enabled' }, cleared: { 'test.opaque': 'kept' }, emptied: undefined, unchanged: true, diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 3fa0fbabb608..681e653f8a88 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -39,7 +39,7 @@ import type { ChatInputRequestWithPlanReview } from '../../common/agentHostPlanR import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; import { ChatInputRequestPurpose, readChatInputRequestPurpose } from '../../common/meta/agentChatInputRequestMeta.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; -import { readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin } from '../../common/meta/mcpCustomizationMeta.js'; +import { readMcpServerControllingSetting, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin } from '../../common/meta/mcpCustomizationMeta.js'; import { agentModelCallMetaKey, readAgentModelCallDiagnostics } from '../../common/meta/agentModelCallMeta.js'; import { readAgentRuntimeModelConfiguration } from '../../common/meta/agentModelConfigurationMeta.js'; import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, readAgentSystemNotificationMeta } from '../../common/meta/agentSystemNotificationMeta.js'; @@ -20935,6 +20935,7 @@ Use the attached image as context. mockSession.fire('session.mcp_server_status_changed', { serverName: 'local-memory', status: 'stopped' }); mockSession.fire('session.mcp_server_status_changed', { serverName: 'computer-use', status: 'stopped' }); mockSession.fire('session.mcp_server_status_changed', { serverName: 'github-copilot-connector-1', status: 'stopped' }); + mockSession.fire('session.mcp_server_status_changed', { serverName: 'github-mcp-server', status: 'stopped' }); const expected = [ { name: 'local-memory', source: 'user', displayName: undefined, sourcePlugin: undefined }, @@ -20948,6 +20949,42 @@ Use the attached image as context. assert.deepStrictEqual({ initial, afterStatusChange: snapshot() }, { initial: expected, afterStatusChange: expected }); }); + test('declares the GitHub MCP setting only for the runtime\'s own GitHub server, not a configured server of the same name', async () => { + const { session, mockSession, waitForSignal } = await createAgentSession(disposables, { + configureMockSession: m => { + m.mcpListResult = { servers: [{ name: 'github-mcp-server', status: 'connected', source: 'builtin' }] }; + }, + }); + await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)); + const setting = () => readMcpServerControllingSetting(session.topLevelMcpCustomizations().find(server => server.name === 'github-mcp-server')); + const builtin = setting(); + mockSession.fire('session.mcp_server_status_changed', { serverName: 'github-mcp-server', status: 'stopped' }); + const afterStatusChange = setting(); + + const configured: Record = {}; + for (const [kind, server] of [ + ['user', { name: 'github-mcp-server', status: 'connected', source: 'user', sourcePlugin: undefined }], + ['plugin', { name: 'github-mcp-server', status: 'connected', source: 'plugin', sourcePlugin: 'acme' }], + ['builtinPlugin', { name: 'github-mcp-server', status: 'connected', source: 'builtin', sourcePlugin: 'acme' }], + ] as const) { + const published = (signal: AgentSignal) => isAction(signal, ActionType.SessionCustomizationUpdated) + && signal.action.type === ActionType.SessionCustomizationUpdated + && signal.action.customization.type === CustomizationType.McpServer + && readMcpServerSource(signal.action.customization) === server.source + && readMcpServerSourcePlugin(signal.action.customization) === server.sourcePlugin; + mockSession.mcpListResult = { servers: [server] }; + mockSession.fire('session.mcp_servers_loaded', { servers: [] }); + await waitForSignal(published); + configured[kind] = setting(); + } + + assert.deepStrictEqual({ builtin, afterStatusChange, configured }, { + builtin: 'chat.agentHost.githubMcpServer.enabled', + afterStatusChange: 'chat.agentHost.githubMcpServer.enabled', + configured: { user: undefined, plugin: undefined, builtinPlugin: undefined }, + }); + }); + test('resolves omitted SDK sources from user configuration without reclassifying explicit or unknown sources', async () => { const { session, mockSession, waitForSignal } = await createAgentSession(disposables, { getUserMcpServerNames: async () => new Set(['local-memory', 'github', 'workspace-server', 'plugin-server', 'github-mcp-server']), diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index fcc79ec48f2e..c4e988966e3d 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -116,6 +116,11 @@ export interface IAgentHostMcpServer { * (for example with MCP: Add Server > Add to Current Agent Session) rather than in a file. */ readonly hostConfiguration?: IMcpServerConfiguration; + /** + * VS Code setting the host declares as controlling whether it includes this server. Present only when + * the providing host says so; never inferred from the server's name. + */ + readonly controllingSettingId?: string; readonly enabled: boolean; readonly enablement?: readonly CustomizationEnablement[]; readonly isPluginProvided?: boolean; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index 5b48bdc9b1ed..db4a6136e84f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -16,7 +16,7 @@ import { IAgentConnection } from '../../../../../../platform/agentHost/common/ag import { IAgentHostResourceUriMapper } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { AMBIENT_AGENT_HOST_AUTHORITY, IAgentHostConnectionsService, IAgentHostSessionResolution } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { getEffectiveAgents } from '../../../../../../platform/agentHost/common/customAgents.js'; -import { readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; +import { readMcpServerControllingSetting, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; import { getCustomizationDisabledReason, isCustomizationEnabled, withCustomizationEnablement } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; import { type IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; @@ -254,6 +254,7 @@ export abstract class AbstractAgentHostCustomizationService extends Disposable i source: readMcpServerSource(server), sourcePluginName: readMcpServerSourcePlugin(server), hostConfiguration: isTopLevel ? getHostMcpServerConfiguration(target.rootConfig, server.name) : undefined, + controllingSettingId: readMcpServerControllingSetting(server), enabled: isCustomizationEnabled(server) && (!plugin || isCustomizationEnabled(plugin)), enablement: server.enablement, isPluginProvided: plugin !== undefined, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index f29000d9bb3b..9c57d77757fd 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -1177,6 +1177,8 @@ export class AICustomizationManagementEditor extends EditorPane { this.showEmbeddedMcpDetail(server); })); + this.editorDisposables.add(this.mcpListWidget.onDidUpdateServerDetail(() => this.refreshEmbeddedMcpDetail())); + this.editorDisposables.add(this.mcpListWidget.onDidRequestShowPlugin(item => { this.showPluginDetail(item); })); @@ -4899,6 +4901,20 @@ export class AICustomizationManagementEditor extends EditorPane { void this.restoreDetailOrigin(origin); } + /** Keeps an open MCP detail in sync when the agent host reports new metadata for its server. */ + private refreshEmbeddedMcpDetail(): void { + const current = this.mcpDetailInput; + if (!current || !this.embeddedMcpDetail || this.viewMode !== 'mcpDetail') { + return; + } + const updated = this.mcpListWidget?.getUpdatedServerDetail(current.id); + if (!updated) { + return; + } + this.mcpDetailInput = updated; + this.embeddedMcpDetail.updateInput(updated); + } + private refreshMcpDetailMigrationState(): void { if (!this.mcpDetailInput || !this.embeddedMcpDetail || this.viewMode !== 'mcpDetail') { return; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts index 19eb48f57ee8..91480cd52528 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMcpServerDetail.ts @@ -11,7 +11,8 @@ import { Emitter } from '../../../../../base/common/event.js'; import { findNodeAtLocation, parseTree } from '../../../../../base/common/json.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../../../base/common/observable.js'; -import { basename } from '../../../../../base/common/resources.js'; +import { basename, isEqual } from '../../../../../base/common/resources.js'; +import { equals } from '../../../../../base/common/objects.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; @@ -26,7 +27,7 @@ import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IMcpServerConfiguration } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; -import { CustomizationMarketplaceIcon } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; +import { CustomizationMarketplaceIcon, isCustomizationMarketplaceIconEqual } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { getSimpleEditorOptions } from '../../../codeEditor/browser/simpleEditorOptions.js'; @@ -254,6 +255,24 @@ export class EmbeddedMcpServerDetail extends Disposable { this.renderItem(); } + /** + * Replaces the current server's presentation (label, source, provenance, definition) after the host + * reports new metadata, keeping migration state and leaving the view untouched when nothing changed. + */ + updateInput(server: IMcpServerDetailInput): void { + const current = this.current; + if (!current || current.id !== server.id) { + return; + } + const next: IMcpServerDetailInput = { ...server, migratable: current.migratable }; + if (isSameMcpServerDetailPresentation(current, next)) { + return; + } + this.current = next; + this.bindDiagnostics(); + this.renderItem(); + } + setMigratable(migratable: boolean): void { if (!this.current || this.current.migratable === migratable) { return; @@ -646,6 +665,26 @@ export class EmbeddedMcpServerDetail extends Disposable { } } +/** + * Whether two inputs for the same server present the same thing. Ignores the error observable and link + * callbacks, which are recreated on every rebuild without changing what the detail shows. + */ +function isSameMcpServerDetailPresentation(a: IMcpServerDetailInput, b: IMcpServerDetailInput): boolean { + return a.name === b.name + && a.label === b.label + && a.installState === b.installState + && a.compatibilityId === b.compatibilityId + && a.migratable === b.migratable + && equals(a.config, b.config) + && isEqual(a.source?.uri, b.source?.uri) + && equals(a.source?.range, b.source?.range) + && a.provenance?.label === b.provenance?.label + && a.provenance?.ariaLabel === b.provenance?.ariaLabel + && !a.provenance?.open === !b.provenance?.open + && equals(a.definitionUnavailable, b.definitionUnavailable) + && isCustomizationMarketplaceIconEqual(a.icon, b.icon); +} + function resolveCompatibilityState(resolved: boolean, compatibility: ICustomizationMcpServerCompatibility | undefined): McpDetailCompatibilityState { if (!resolved) { return { kind: 'checking', details: [] }; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index b8aa78bbc88f..03e6c328f803 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -59,8 +59,6 @@ import { AgentPluginItemKind, IAgentPluginItem } from '../agentPluginEditor/agen import { CustomizationMcpServerCompatibilityKind, getCustomizationDisabledLabel, ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { IAgentHostCustomizationService } from '../agentSessions/agentHost/agentHostCustomizationService.js'; import { CustomizationEnablementKind, McpServerStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; -import { AgentHostGitHubMcpServerEnabledSettingId } from '../../../../../platform/agentHost/common/agentService.js'; -import { GITHUB_MCP_SERVER_NAME } from '../../../../../platform/agentHost/common/githubEndpoints.js'; import { IOutputService } from '../../../../services/output/common/output.js'; import { ChatConfiguration } from '../../common/constants.js'; import { getAvailableCustomizationMarketplaceInstallTelemetryContext, runCustomizationMarketplaceInstallWithTelemetry } from '../../common/customizationMarketplaceInstallTelemetry.js'; @@ -1203,18 +1201,22 @@ export function getActiveSessionServerProvenance(server: AgentHostMcpServer, age /** * Explains why a host-reported server has no definition to show: the agent sets it up or manages it - * itself and never shares the definition. The official GitHub server links to the setting that - * includes it in agent sessions. + * itself and never shares the definition. Links to the setting that controls the server only when the + * providing host declares one; the server's name alone says nothing about which host added it. */ export function getActiveSessionServerDefinitionUnavailable(server: AgentHostMcpServer, agentLabel: string): IMcpServerDefinitionUnavailable | undefined { + const settingId = server.controllingSettingId; switch (server.source) { case 'builtin': return { message: localize('mcpDefinitionBuiltin', "{0} configures this server automatically, so its definition can't be viewed or edited.", agentLabel), - ...(server.name === GITHUB_MCP_SERVER_NAME && !server.sourcePluginName ? { settingId: AgentHostGitHubMcpServerEnabledSettingId } : {}), + ...(settingId ? { settingId } : {}), }; case 'managed': - return { message: localize('mcpDefinitionManaged', "{0} manages this server, so its definition can't be viewed or edited.", agentLabel) }; + return { + message: localize('mcpDefinitionManaged', "{0} manages this server, so its definition can't be viewed or edited.", agentLabel), + ...(settingId ? { settingId } : {}), + }; default: return undefined; } @@ -1733,6 +1735,10 @@ export class McpListWidget extends Disposable { private readonly _onDidSelectServer = this._register(new Emitter()); readonly onDidSelectServer = this._onDidSelectServer.event; + private readonly _onDidUpdateServerDetail = this._register(new Emitter()); + /** Fires when host-reported presentation may have changed; read updates with {@link getUpdatedServerDetail}. */ + readonly onDidUpdateServerDetail = this._onDidUpdateServerDetail.event; + private readonly _onDidChangeItemCount = this._register(new Emitter()); readonly onDidChangeItemCount = this._onDidChangeItemCount.event; @@ -2124,6 +2130,8 @@ export class McpListWidget extends Disposable { if (!hasSameMcpMembership(previousMembership, this.getInstalledEntryMembershipSignature())) { this.renderFilteredServers(); } + // A restored server is enriched by the host's inventory later, so an open detail must be refreshed. + this._onDidUpdateServerDetail.fire(); })); } @@ -2398,6 +2406,19 @@ export class McpListWidget extends Disposable { return id ? (reader ? this.mcpServerCompatibility.read(reader) : this.mcpServerCompatibility.get()).get(id) : undefined; } + /** + * Rebuilds the detail input of the installed server whose detail id is `id`, reflecting the latest + * host-reported presentation, or returns `undefined` when that server is no longer listed. + */ + getUpdatedServerDetail(id: string): IMcpServerDetailInput | undefined { + for (const { entry } of this.installedEntries) { + if (entry.type === 'server-item' ? entry.server.id === id : getMcpRowKey(entry) === id) { + return this.createInstalledMcpServerDetailInput(entry); + } + } + return undefined; + } + private createInstalledMcpServerDetailInput(entry: IMcpInstalledEntry): IMcpServerDetailInput { return createInstalledMcpServerDetailInput(entry, derived(this, reader => { const activeSessionServer = getActiveSessionServer(entry); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts index 02b1065fd235..0a6e8d6620c9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts @@ -14,7 +14,7 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; -import { withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; +import { withMcpServerControllingSettingMeta, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from '../../../../../../platform/agentHost/common/meta/mcpCustomizationMeta.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType, type ActionEnvelope } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerCustomization, McpServerStatus, type Customization, type CustomizationEnablement } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -307,21 +307,26 @@ suite('AbstractAgentHostCustomizationService', () => { })), sources.map(source => ({ source, sourceUri: undefined }))); }); - test('exposes host-reported display names and source plugins only when the host publishes them', () => { + test('exposes host-reported display names, source plugins and controlling settings only when the host publishes them', () => { const sut = createSut(); const session = URI.parse('vscode-agent-session:///session-1'); sut.setTarget(session, new FakeTarget([ { ...mcpServer('host-id-7f3a', 'catalog-entry-7f3a'), _meta: withMcpServerDisplayNameMeta(withMcpServerSourceMeta(undefined, 'managed'), 'Linear') }, { ...mcpServer('host-id-9c1e', 'computer-use'), _meta: withMcpServerSourcePluginMeta(withMcpServerSourceMeta(undefined, 'builtin'), 'computer-use') }, mcpServer('host-id-2b4d', 'without-extension'), + { ...mcpServer('vscode-host-github', 'github-mcp-server'), _meta: withMcpServerControllingSettingMeta(withMcpServerSourceMeta(undefined, 'builtin'), 'chat.agentHost.githubMcpServer.enabled') }, + // A conforming non-VS Code host may bundle its own server under the same name without declaring a setting. + { ...mcpServer('urn:other-host:servers/0', 'github-mcp-server'), uri: 'https://other-host.example/servers/github', _meta: withMcpServerSourceMeta(undefined, 'builtin') }, ])); assert.deepStrictEqual(sut.getMcpServers(session).map(server => ({ - name: server.name, displayName: server.displayName, sourcePluginName: server.sourcePluginName, + name: server.name, displayName: server.displayName, sourcePluginName: server.sourcePluginName, controllingSettingId: server.controllingSettingId, })), [ - { name: 'catalog-entry-7f3a', displayName: 'Linear', sourcePluginName: undefined }, - { name: 'computer-use', displayName: undefined, sourcePluginName: 'computer-use' }, - { name: 'without-extension', displayName: undefined, sourcePluginName: undefined }, + { name: 'catalog-entry-7f3a', displayName: 'Linear', sourcePluginName: undefined, controllingSettingId: undefined }, + { name: 'computer-use', displayName: undefined, sourcePluginName: 'computer-use', controllingSettingId: undefined }, + { name: 'without-extension', displayName: undefined, sourcePluginName: undefined, controllingSettingId: undefined }, + { name: 'github-mcp-server', displayName: undefined, sourcePluginName: undefined, controllingSettingId: 'chat.agentHost.githubMcpServer.enabled' }, + { name: 'github-mcp-server', displayName: undefined, sourcePluginName: undefined, controllingSettingId: undefined }, ]); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts index 126842925e9b..88e83c1fbd66 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts @@ -219,4 +219,74 @@ suite('EmbeddedMcpServerDetail', () => { executedCommands: [['workbench.action.openSettings', '@id:chat.agentHost.githubMcpServer.enabled']], }); }); + + test('updates an open detail when the host enriches a restored server, keeping migration state', () => { + const { detail } = createDetail(); + const name = () => detail.element.querySelector('.editor-item-name')?.textContent; + const path = () => detail.element.querySelector('.editor-item-path')?.textContent; + const message = () => detail.element.querySelector('.mcp-detail-definition-message')?.textContent; + const settingsLink = () => detail.element.querySelector('.mcp-detail-definition-settings-link')?.textContent; + const read = () => ({ name: name(), path: path(), message: message(), settingsLink: settingsLink() }); + const restored: IMcpServerDetailInput = { + id: 'session:server-7', name: 'github-copilot-connector-1', label: 'github-copilot-connector-1', + installState: McpServerInstallState.Installed, + }; + const enriched: IMcpServerDetailInput = { + ...restored, + label: 'Linear', + provenance: { label: 'Managed by Copilot' }, + definitionUnavailable: { message: 'Copilot manages this server, so its definition can\'t be viewed or edited.', settingId: 'chat.example.enabled' }, + }; + + detail.setInput(restored); + detail.setMigratable(true); + const before = read(); + const definitionEmpty = detail.element.querySelector('.mcp-detail-definition-message'); + detail.updateInput({ ...enriched, id: 'session:other-server', label: 'Other' }); + const otherServer = read(); + detail.updateInput(enriched); + const after = read(); + const migration = detail.element.querySelector('.mcp-detail-diagnostic-card.migration')?.closest('section')?.style.display; + const renderedMessage = detail.element.querySelector('.mcp-detail-definition-message'); + detail.updateInput({ ...enriched, provenance: { label: 'Managed by Copilot' }, error: undefined }); + + assert.deepStrictEqual({ + before, + otherServer, + after, + migrationKept: migration !== 'none', + rerendered: definitionEmpty !== renderedMessage, + unchangedKeepsNodes: detail.element.querySelector('.mcp-detail-definition-message') === renderedMessage, + }, { + before: { name: 'github-copilot-connector-1', path: '', message: 'No definition is available for this MCP server.', settingsLink: undefined }, + otherServer: { name: 'github-copilot-connector-1', path: '', message: 'No definition is available for this MCP server.', settingsLink: undefined }, + after: { name: 'Linear', path: 'Managed by Copilot', message: 'Copilot manages this server, so its definition can\'t be viewed or edited.', settingsLink: 'Open Settings' }, + migrationKept: true, + rerendered: true, + unchangedKeepsNodes: true, + }); + }); + + test('shows a host configuration that arrives after the detail opened', () => { + const { detail, snapshot } = createDetail(); + const read = () => ({ ...snapshot(), path: detail.element.querySelector('.editor-item-path')?.textContent }); + const restored: IMcpServerDetailInput = { + id: 'session:server-8', name: 'my-mcp-server', label: 'my-mcp-server', + installState: McpServerInstallState.Installed, + }; + + detail.setInput(restored); + const before = read(); + detail.updateInput({ ...restored, provenance: { label: 'Agent host configuration' }, config: { type: McpServerType.LOCAL, command: 'my-mcp-server' } }); + + assert.deepStrictEqual({ before, after: read() }, { + before: { definition: undefined, emptyMessage: 'No definition is available for this MCP server.', editorVisible: false, path: '' }, + after: { + definition: '{\n\t"servers": {\n\t\t"my-mcp-server": {\n\t\t\t"type": "stdio",\n\t\t\t"command": "my-mcp-server"\n\t\t}\n\t}\n}\n', + emptyMessage: 'No definition is available for this MCP server.', + editorVisible: true, + path: 'Agent host configuration', + }, + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts index ef451895cbf6..292f2283329b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts @@ -849,22 +849,24 @@ suite('mcpListWidget', () => { }); }); - test('explains why the agent\'s own MCP servers have no definition and links the GitHub server to its setting', () => { + test('explains why the agent\'s own MCP servers have no definition and links only a setting the host declares', () => { const explain = (overrides: Partial) => getActiveSessionServerDefinitionUnavailable(createAgentHostServer(overrides), 'Copilot'); + const builtinMessage = 'Copilot configures this server automatically, so its definition can\'t be viewed or edited.'; assert.deepStrictEqual([ + explain({ name: 'github-mcp-server', source: 'builtin', controllingSettingId: 'chat.agentHost.githubMcpServer.enabled' }), + // Another conforming host can publish a built-in server with the same name that VS Code's setting cannot control. explain({ name: 'github-mcp-server', source: 'builtin' }), - explain({ name: 'github-mcp-server', source: 'builtin', sourcePluginName: 'acme' }), explain({ name: 'computer-use', source: 'builtin', sourcePluginName: 'computer-use' }), explain({ name: 'github-copilot-connector-1', source: 'managed', displayName: 'Linear' }), - explain({ name: 'github-mcp-server', source: 'user' }), + explain({ name: 'github-mcp-server', source: 'user', controllingSettingId: 'chat.agentHost.githubMcpServer.enabled' }), explain({ name: 'workspace-server', source: 'workspace' }), explain({ name: 'plugin-server', source: 'plugin', sourcePluginName: 'acme' }), explain({ name: 'github-mcp-server' }), ], [ - { message: 'Copilot configures this server automatically, so its definition can\'t be viewed or edited.', settingId: 'chat.agentHost.githubMcpServer.enabled' }, - { message: 'Copilot configures this server automatically, so its definition can\'t be viewed or edited.' }, - { message: 'Copilot configures this server automatically, so its definition can\'t be viewed or edited.' }, + { message: builtinMessage, settingId: 'chat.agentHost.githubMcpServer.enabled' }, + { message: builtinMessage }, + { message: builtinMessage }, { message: 'Copilot manages this server, so its definition can\'t be viewed or edited.' }, undefined, undefined, @@ -1776,6 +1778,8 @@ suite('mcpListWidget', () => { cardListControllers: Map; appendInstalledServerRow(parent: HTMLElement, presentation: { entry: Entry }): void; createInstalledMcpServerDetailInput(entry: Entry): ReturnType; + getUpdatedServerDetail(id: string): ReturnType | undefined; + installedEntries: { entry: Entry }[]; getMcpEntryAriaLabel(entry: Entry): IObservable; getMcpServerActions(entry: Entry, store: DisposableStore): IAction[]; renderMcpListActions(getEntry: () => Entry | undefined, actions: HTMLElement, store: DisposableStore, updateTabbability: () => void): void; @@ -1815,6 +1819,10 @@ suite('mcpListWidget', () => { menuActions: () => menuActions, activeSessionResource, detailInput: (entry: Entry) => widget.createInstalledMcpServerDetailInput(entry), + updatedDetail: (entries: readonly Entry[], id: string) => { + widget.installedEntries = entries.map(entry => ({ entry })); + return widget.getUpdatedServerDetail(id); + }, setAriaProvider: (provider: (entry: Entry) => IObservable) => { widget.getMcpEntryAriaLabel = provider; }, setRuntimeServers: (next: readonly IMcpServer[]) => runtimeServers.set(next, undefined), menu: (entry: Entry, localServer?: IMcpServer) => { @@ -2151,6 +2159,32 @@ suite('mcpListWidget', () => { }); }); + test('rebuilds an open detail from the latest host metadata after the inventory enriches a restored server', () => { + const restored = createAgentHostServer({ id: 'server-7', name: 'github-copilot-connector-1' }); + const ctx = createRenderer(restored, false); + disposables.add(ctx.store); + const opened = ctx.detailInput(createBuiltinActiveSessionMcpEntries([restored])[0]); + const enrichedServer = createAgentHostServer({ id: 'server-7', name: 'github-copilot-connector-1', displayName: 'Linear', source: 'managed', controllingSettingId: 'chat.example.enabled' }); + const updated = ctx.updatedDetail(createBuiltinActiveSessionMcpEntries([enrichedServer]), opened.id); + const removed = ctx.updatedDetail([], opened.id); + + assert.deepStrictEqual({ + opened: { id: opened.id, label: opened.label, provenance: opened.provenance, definitionUnavailable: opened.definitionUnavailable }, + updated: updated && { id: updated.id, name: updated.name, label: updated.label, provenance: updated.provenance, definitionUnavailable: updated.definitionUnavailable }, + removed, + }, { + opened: { id: 'session:server-7', label: 'github-copilot-connector-1', provenance: undefined, definitionUnavailable: undefined }, + updated: { + id: 'session:server-7', + name: 'github-copilot-connector-1', + label: 'Linear', + provenance: { label: 'Managed by Copilot' }, + definitionUnavailable: { message: 'Copilot manages this server, so its definition can\'t be viewed or edited.', settingId: 'chat.example.enabled' }, + }, + removed: undefined, + }); + }); + test('shows host display names on runtime-only rows and their runtime source in the detail', () => { const server = createAgentHostServer({ name: 'github-copilot-connector-1', displayName: 'Linear', source: 'managed' }); const ctx = createRenderer(server, false);