mcp: show where built-in MCP servers come from (#339427)

<!-- Thank you for submitting a Pull Request. Please:
* Read our Pull Request guidelines:

https://github.com/microsoft/vscode/wiki/How-to-Contribute#pull-requests
* Associate an issue with the Pull Request.
* Ensure that the code is up-to-date with the `main` branch.
* Include a description of the proposed changes and how to test them.
-->

Fixes #338719

The MCP server details in Chat Customizations didn't say where built-in
servers come from. The header showed a made-up `mcp.json`, and
Configuration only said "No definition is available". This PR uses what
the agent runtime already reports about each server (its source, display
name and contributing plugin) to show the real origin, and explains when
there's nothing to show.

### What you'll see

| MCP server | Before | Now |
|---|---|---|
| `github-mcp-server` (built into Copilot CLI) | Made-up `mcp.json`
label; "No definition is available" | **Built-in: Copilot**.
Configuration explains that Copilot sets it up, with an **Open
Settings** link to `chat.agentHost.githubMcpServer.enabled` |
| Copilot connector, e.g. "Linear (Connector)" | Made-up `mcp.json`
label; "No definition is available" | **Managed by Copilot**, and an
explanation of why there's no definition |
| GitHub server from the Copilot Chat extension (local chat) | No source
| **Built-in: GitHub Copilot Chat**, linking to the extension; the `...`
menu gains **Show Extension** |
| Added via **MCP: Add Server → Add to Current Agent Session** | Could
land under Built-In; no definition | Listed under **User**, labelled
**Agent host configuration**, with its definition |
| Reported as user or workspace config, but no file is known | Made-up
`mcp.json` label | **User configuration** / **Workspace configuration**
|
| Configured in an `mcp.json` or another app's config file | File link
and Edit Configuration | Unchanged. Built-in rows backed by another
app's file (e.g. Claude Desktop) also get **Show Configuration** in the
`...` menu |

### How it works

- The Copilot agent host forwards the runtime's `sourcePlugin` (from
`session.mcp.list`) as optional, namespaced `_meta` on MCP server
customizations. Other agent hosts can leave it out.
- The agent host also declares, in `_meta`, the setting that controls a
server it adds itself. The UI only offers **Open Settings** when the
host declares one.
- The details page shows where a server comes from when there's no file
to open, and an explanation when the agent owns the definition.
- An open details page updates when the agent host reports new metadata.
For example, a server in a restored session gets its origin and settings
link once the runtime reports it, without reopening the page.
- `getMcpServers()` also exposes the definition the agent host's own
configuration holds for a server, so servers added there show their
definition.

### Notes for reviewers

- Connector names come from the connector catalog (#339431). This PR
doesn't forward the runtime's own `displayName`: for anything other than
a connector it falls back to the server's advertised title, which would
rename servers that users configured.
- Copilot Chat's own servers keep their extension ID (to link to it) but
stay under Built-In.
- The setting link is never inferred from a server's name. Another agent
host, a plugin, or a user's config can also publish a
`github-mcp-server` that `chat.agentHost.githubMcpServer.enabled` can't
control, so Copilot declares the setting only for the runtime's own
GitHub server.
- Not in this PR: showing a built-in server's tools or instructions, or
its actual definition (the Copilot SDK doesn't expose it).

### How to test

1. In a Copilot CLI agent session, open Chat Customizations → MCP
Servers and select `github-mcp-server`. The header says "Built-in:
Copilot", Configuration explains why there's no definition, and **Open
Settings** opens `chat.agentHost.githubMcpServer.enabled`.
2. Run **MCP: Add Server → Add to Current Agent Session**. The server is
listed under User with "Agent host configuration" and its definition.
3. In a local chat, select Copilot's GitHub server. The header links to
GitHub Copilot Chat, and `...` → **Show Extension** opens it.
4. A server from `.vscode/mcp.json` still shows its file link and Edit
Configuration.
5. Open a server's details while a resumed Copilot CLI session is still
starting. Once the runtime reports the server, the details update
(origin, settings link) without reopening.

The `EmbeddedMcpDetailBuiltinAgent` and
`EmbeddedMcpDetailBuiltinExtension` component fixtures show the new
details states.

Validation (run locally on top of current `main`):

- Unit tests: passed, 1236 passing and 0 failing.
  ```
  ./scripts/test.sh \
--run src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts \
--run
src/vs/platform/agentHost/test/common/metadataCompatibility.test.ts \
--run
src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts
\
--run src/vs/platform/agentHost/test/node/shared/githubMcpServer.test.ts
\
--run src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts \
--run
src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCustomizationService.test.ts
\
--run
src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMcpServerDetail.test.ts
\
--run
src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts
\
--run
src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationManagementEditor.test.ts
\
--run
src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.test.ts
\
--run
src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpServersStartingContentPart.test.ts
\
--run
src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolAuthenticationSubPart.test.ts
  ```
- Connector-name suites from #339431: passed.
`copilotSessionLauncher.test.ts` and `stateToProgressAdapter.test.ts`
had 301 passing; `mcpCommands.test.ts` had 2 passing.
- `npx tsc --project ./src/tsconfig.json --noEmit --skipLibCheck`: no
errors in code this PR changes. My locally installed dependencies are
older than `main`'s lockfile (Copilot SDK 1.0.15-preview.4 vs
1.0.17-preview.4). That causes 23 errors about newer APIs (`proxyCa`,
`managedSettings.resolve`, `FoundryLocalManager`), all in lines this PR
doesn't change.
- `npx eslint --max-warnings 0` on the changed files: passed.
- `node build/stylelint.ts
src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css`:
passed.
- `node build/checker/layersChecker.ts`: passed.
- Pre-commit hygiene: passed.
This commit is contained in:
Vritant Bhardwaj authored and GitHub committed 2026-10-05 13:39:24 -08:00
commit 205d0627ee
17 files changed
+1033 -76

No files matched your search

@@ -4,4 +4,4 @@
*--------------------------------------------------------------------------------------------*/
export type { McpServerSource } from './vscode/mcpCustomizationMeta.js';
export { readMcpServerDisplayName, readMcpServerSource, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } from './vscode/mcpCustomizationMeta.js';
export { readMcpServerControllingSetting, readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerControllingSettingMeta, withMcpServerDisplayNameMeta, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } from './vscode/mcpCustomizationMeta.js';
@@ -8,6 +8,8 @@ import type { McpServerCustomization } from '../../state/protocol/state.js';
const sourceKey = 'agentHost.mcpServerSource';
const displayNameKey = 'vscode.mcpServerDisplayName';
const maxDisplayNameLength = 512;
const sourcePluginKey = 'agentHost.mcpServerSourcePlugin';
const controllingSettingKey = 'vscode.mcpServerControllingSetting';
export type McpServerSource =
| 'user' // Defined in user-level configuration.
@@ -57,3 +59,52 @@ function normalizeMcpServerDisplayName(value: unknown): string | undefined {
const trimmed = value.trim();
return trimmed.length > 0 && trimmed.length <= maxDisplayNameLength ? trimmed : undefined;
}
/**
* 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<string, unknown> | undefined, sourcePlugin: string | undefined): Record<string, unknown> | undefined {
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<string, unknown> | undefined, settingId: string | undefined): Record<string, unknown> | undefined {
return withMetaEntry(meta, controllingSettingKey, settingId);
}
function readNonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
}
function withMetaEntry(meta: Record<string, unknown> | undefined, key: string, value: string | undefined): Record<string, unknown> | 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 };
}
@@ -90,6 +90,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';
@@ -1515,6 +1516,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()));
@@ -7955,12 +7961,14 @@ 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,
pluginName: server.sourcePlugin ?? null,
}
: {};
return {
@@ -7969,8 +7977,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,
};
}
@@ -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, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } 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';
@@ -40,11 +40,24 @@ 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;
/** 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. */
export interface ISdkMcpServerProvenance {
readonly source: McpServerSource | undefined;
readonly sourcePlugin: string | undefined;
}
function readTopLevelProvenance(customization: McpServerCustomization | undefined): ISdkMcpServerProvenance {
return {
source: readMcpServerSource(customization),
sourcePlugin: readMcpServerSourcePlugin(customization),
};
}
/**
* Runtime fields of an MCP server customization that this controller
* owns — the high-frequency `state`/`channel` pair. Consumers overlay
@@ -125,6 +138,11 @@ export interface IMcpCustomizationControllerOptions {
readonly pluginMcpServerSources?: () => ReadonlyMap<string, string> | 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}.
@@ -226,7 +244,7 @@ export class McpCustomizationController extends Disposable {
if (entry.topLevelId === undefined) {
continue;
}
out.push(this._buildTopLevel(entry.topLevelId, entry.serverName, entry.displayName, entry.state, entry.enabled, readMcpServerSource(entry.topLevelCustomization), entry.topLevelCustomization?.uri));
out.push(this._buildTopLevel(entry.topLevelId, entry.serverName, entry.displayName, entry.state, entry.enabled, readTopLevelProvenance(entry.topLevelCustomization), entry.topLevelCustomization?.uri));
}
return out;
}
@@ -392,9 +410,14 @@ 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: ISdkMcpServerProvenance = {
source: server.source ?? known.source,
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, displayName, state, enabled, source, sourceUri);
const customization = this._buildTopLevel(topLevelId, server.name, displayName, state, enabled, provenance, sourceUri);
const resolvedDisplayName = readMcpServerDisplayName(customization);
const customizationChanged = force || previous?.topLevelId !== topLevelId || !equals(previous?.topLevelCustomization, customization);
if (customizationChanged || previous?.enabled !== enabled) {
@@ -509,6 +532,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;
@@ -516,7 +544,7 @@ export class McpCustomizationController extends Disposable {
return buildMcpChannel(this._chatUri, serverName, this._providerId);
}
private _buildTopLevel(id: string, serverName: string, displayName: string | undefined, state: McpServerState, enabled: boolean, source?: McpServerSource, sourceUri?: string | null): McpServerCustomization {
private _buildTopLevel(id: string, serverName: string, displayName: string | undefined, 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 —
@@ -527,10 +555,15 @@ 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 = withMcpServerDisplayNameMeta(withMcpServerSourceMeta(existing?._meta, source), displayName);
const meta = withMcpServerControllingSettingMeta(
withMcpServerSourcePluginMeta(
withMcpServerDisplayNameMeta(withMcpServerSourceMeta(existing?._meta, provenance.source), displayName),
provenance.sourcePlugin,
),
this._options.controllingSetting?.(serverName, provenance),
);
const uri = (sourceUri === undefined ? existing?.uri : sourceUri) ?? this._mintTopLevelId(serverName);
const customization: McpServerCustomization = {
type: CustomizationType.McpServer,
@@ -13,7 +13,7 @@ import { readSlashCommandResource, toSlashCommandResourceMeta } from '../../comm
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, withMcpServerDisplayNameMeta, withMcpServerSourceMeta } 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';
@@ -166,6 +166,37 @@ suite('Agent host _meta readers', () => {
});
});
test('validates MCP source plugins and controlling settings and removes them once they no longer apply', () => {
const customization = (meta: Record<string, unknown> | undefined): McpServerCustomization => ({
type: CustomizationType.McpServer,
id: 'server',
uri: 'mcp-top-level:server',
name: 'computer-use',
state: { kind: McpServerStatus.Ready },
_meta: meta,
});
const opaque = { 'test.opaque': 'kept' };
const recorded = withMcpServerControllingSettingMeta(withMcpServerSourcePluginMeta(opaque, 'computer-use'), 'chat.example.enabled');
assert.deepStrictEqual({
read: [readMcpServerSourcePlugin(customization(recorded)), readMcpServerControllingSetting(customization(recorded))],
invalidPlugins: [undefined, '', ' ', 1, {}].map(value => readMcpServerSourcePlugin(customization({ 'agentHost.mcpServerSourcePlugin': value }))),
invalidSettings: [undefined, '', 1, {}].map(value => readMcpServerControllingSetting(customization({ 'vscode.mcpServerControllingSetting': value }))),
recorded,
cleared: withMcpServerControllingSettingMeta(withMcpServerSourcePluginMeta(recorded, undefined), undefined),
emptied: withMcpServerSourcePluginMeta({ 'agentHost.mcpServerSourcePlugin': 'computer-use' }, undefined),
unchanged: withMcpServerSourcePluginMeta(opaque, undefined) === opaque && withMcpServerSourcePluginMeta(recorded, 'computer-use') === recorded,
}, {
read: ['computer-use', 'chat.example.enabled'],
invalidPlugins: [undefined, undefined, undefined, undefined, undefined],
invalidSettings: [undefined, undefined, undefined, undefined],
recorded: { 'test.opaque': 'kept', 'agentHost.mcpServerSourcePlugin': 'computer-use', 'vscode.mcpServerControllingSetting': 'chat.example.enabled' },
cleared: { 'test.opaque': 'kept' },
emptied: undefined,
unchanged: true,
});
});
suite('readToolCallMeta', () => {
test('returns empty when no _meta', () => {
assert.deepStrictEqual(readToolCallMeta(toolCall(undefined)), {});
@@ -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 } 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';
@@ -21203,38 +21203,82 @@ 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 and plugins for host-only MCP servers, but not runtime display titles, and retains them across status updates', async () => {
const { session, mockSession, waitForSignal } = await createAgentSession(disposables, {
configureMockSession: m => {
m.mcpListResult = {
servers: [
{ 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' },
// The runtime falls back to a server's advertised title; only connector catalog names are display names.
{ name: 'workspace-server', status: 'connected', source: 'workspace', displayName: 'Everything Reference Server' },
{ 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' },
]
};
},
});
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' });
mockSession.fire('session.mcp_server_status_changed', { serverName: 'github-mcp-server', 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: undefined, sourcePlugin: undefined },
];
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<string, string | undefined> = {};
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']),
@@ -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 { readMcpServerDisplayName, readMcpServerSource, withMcpServerSourceMeta } from '../../../common/meta/mcpCustomizationMeta.js';
import { readMcpServerDisplayName, readMcpServerSource, readMcpServerSourcePlugin, withMcpServerSourceMeta, withMcpServerSourcePluginMeta } 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';
@@ -286,6 +286,52 @@ suite('McpCustomizationController', () => {
]);
});
test('publishes source plugins, keeps them through lifecycle updates, and clears them with the inventory', () => {
const { controller } = harness(store, {
customizations: [{
type: CustomizationType.McpServer,
id: 'restored-computer-use',
uri: 'mcp-top-level:copilot:session-1:computer-use',
name: 'computer-use',
state: stopped(),
_meta: withMcpServerSourcePluginMeta({ 'test.opaque': 'kept' }, 'computer-use'),
}],
});
store.add(controller);
const snapshot = () => controller.topLevelCustomizations().map(item => ({ name: item.name, sourcePlugin: readMcpServerSourcePlugin(item) }));
controller.applyOne(server('computer-use', ready()));
const restored = snapshot();
controller.applyAll([
{ ...server('computer-use', ready()), source: 'builtin', pluginName: 'computer-use' },
{ ...server('acme-server', ready()), source: 'plugin', pluginName: 'acme' },
]);
const inventory = snapshot();
controller.applyOne(server('acme-server', stopped()));
const lifecycle = snapshot();
controller.applyAll([
{ ...server('computer-use', ready()), source: 'builtin', pluginName: null },
{ ...server('acme-server', stopped()), source: 'user', pluginName: null },
]);
assert.deepStrictEqual({ restored, inventory, lifecycle, cleared: snapshot(), restoredMeta: controller.topLevelCustomizations()[0]._meta }, {
restored: [{ name: 'computer-use', sourcePlugin: 'computer-use' }],
inventory: [
{ name: 'computer-use', sourcePlugin: 'computer-use' },
{ name: 'acme-server', sourcePlugin: 'acme' },
],
lifecycle: [
{ name: 'computer-use', sourcePlugin: 'computer-use' },
{ name: 'acme-server', sourcePlugin: 'acme' },
],
cleared: [
{ name: 'computer-use', sourcePlugin: undefined },
{ name: 'acme-server', sourcePlugin: undefined },
],
restoredMeta: { 'test.opaque': 'kept', 'agentHost.mcpServerSource': 'builtin' },
});
});
test('reapplying an unchanged inventory dispatches nothing', () => {
const { controller, actions } = harness(store, { customizations: PLUGIN_CUSTOMIZATIONS });
store.add(controller);
@@ -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 { ISessionConfigProvider, 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';
@@ -101,6 +102,22 @@ export interface IAgentHostMcpServer {
/** Optional Connector catalog name for presentation only. */
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;
/**
* 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;
@@ -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 } 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';
@@ -251,13 +251,16 @@ 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,
controllingSettingId: readMcpServerControllingSetting(server),
enabled: isCustomizationEnabled(server) && (!plugin || isCustomizationEnabled(plugin)),
enablement: server.enablement,
isPluginProvided: plugin !== undefined,
@@ -486,6 +489,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<string, unknown>)[serverName];
return configuration && typeof configuration === 'object' && !Array.isArray(configuration) ? configuration as IMcpServerConfiguration : undefined;
}
function hasSessionSnapshot(subscription: IAgentSubscription<SessionState>): boolean {
return subscription.value !== undefined && !(subscription.value instanceof Error);
}
@@ -1185,6 +1185,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);
}));
@@ -4986,6 +4988,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;
@@ -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';
@@ -20,12 +21,13 @@ 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';
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';
@@ -55,6 +57,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 +137,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 +160,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 +182,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'));
@@ -227,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;
@@ -260,29 +306,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 +322,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 +335,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<void> {
const server = this.current;
const source = server?.source;
@@ -538,11 +591,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 +621,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(
@@ -594,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: [] };
@@ -69,7 +69,7 @@ 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';
@@ -1030,20 +1030,26 @@ export function getMcpEntryGroup(entry: IMcpInstalledEntry): 'user' | 'workspace
return 'plugins';
}
if (entry.extensionId) {
return 'extensions';
return isCopilotExtension(entry.extensionId) ? 'builtin' : 'extensions';
}
const collection = entry.localServer?.readDefinitions().get().collection;
if (collection?.provenance === McpCollectionProvenance.ExternalConfiguration) {
return McpCollectionDefinition.isWorkspaceDiscovered(collection) ? 'workspace' : 'user';
}
}
switch (getActiveSessionServer(entry)?.source) {
const activeSessionServer = getActiveSessionServer(entry);
switch (activeSessionServer?.source) {
case 'user':
return 'user';
case 'workspace':
return 'workspace';
case 'plugin':
return 'plugins';
case undefined:
// The agent host's own configuration applies to all of its sessions, like user configuration.
if (activeSessionServer?.hostConfiguration) {
return 'user';
}
}
return 'builtin';
}
@@ -1101,6 +1107,11 @@ function getMcpEntryLabel(element: IMcpServerItemEntry | IMcpSessionServerItemEn
: element.server.label;
}
/** The server's configuration key, which a managed catalog's display name can differ from. */
function getMcpEntryName(element: IMcpInstalledEntry): string {
return element.type === 'session-server-item' ? element.server.name : getMcpEntryLabel(element);
}
function getMcpEntrySourceUri(element: IMcpInstalledEntry): URI | undefined {
switch (element.type) {
case 'server-item':
@@ -1132,7 +1143,9 @@ function getMcpEntrySource(element: IMcpInstalledEntry, labelService: ILabelServ
const extension = extensionsWorkbenchService.local.find(extension => 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' }),
@@ -1156,6 +1169,59 @@ 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. 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),
...(settingId ? { settingId } : {}),
};
case 'managed':
return {
message: localize('mcpDefinitionManaged', "{0} manages this server, so its definition can't be viewed or edited.", agentLabel),
...(settingId ? { settingId } : {}),
};
default:
return undefined;
}
}
function getMcpServerCompatibilityId(element: IMcpInstalledEntry): string | undefined {
if (element.type === 'session-server-item') {
return undefined;
@@ -1587,7 +1653,11 @@ function isConnectorMcpEntry(entry: IMcpInstalledEntry): entry is IMcpBuiltinIte
return entry.type === 'builtin-item' && entry.connector !== undefined;
}
export function createInstalledMcpServerDetailInput(entry: IMcpInstalledEntry, error?: IObservable<string | undefined>): 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<string | undefined>, presentation?: Pick<IMcpServerDetailInput, 'provenance' | 'definitionUnavailable'>): IMcpServerDetailInput {
if (entry.type === 'server-item') {
const input = createWorkbenchMcpServerDetailInput(entry.server);
return {
@@ -1620,13 +1690,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 } : {}),
};
}
@@ -1663,6 +1735,10 @@ export class McpListWidget extends Disposable {
private readonly _onDidSelectServer = this._register(new Emitter<IMcpServerDetailInput>());
readonly onDidSelectServer = this._onDidSelectServer.event;
private readonly _onDidUpdateServerDetail = this._register(new Emitter<void>());
/** 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<number>());
readonly onDidChangeItemCount = this._onDidChangeItemCount.event;
@@ -2054,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();
}));
}
@@ -2328,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);
@@ -2354,7 +2445,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<IMcpServerDetailInput, 'provenance' | 'definitionUnavailable'> {
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<string> {
@@ -3032,7 +3139,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);
@@ -3041,7 +3148,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([
@@ -3064,7 +3172,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 })),
];
@@ -3092,6 +3200,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('|');
}
@@ -3342,6 +3452,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());
@@ -3404,4 +3522,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);
});
}
}
@@ -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;
@@ -15,7 +15,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 } 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';
@@ -310,6 +310,55 @@ suite('AbstractAgentHostCustomizationService', () => {
})), sources.map(source => ({ displayName: `${source} display`, source, sourceUri: undefined })));
});
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, controllingSettingId: server.controllingSettingId,
})), [
{ 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 },
]);
});
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');
@@ -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<IFileContent> }[] = [];
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<IFileContent>();
@@ -69,7 +75,7 @@ suite('EmbeddedMcpServerDetail', () => {
const complete = (index: number, text = content) => reads[index].result.complete(new class extends mock<IFileContent>() {
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,135 @@ 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<HTMLAnchorElement>('.editor-item-path')!;
const editConfiguration = detail.element.querySelector<HTMLElement>('.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<HTMLElement>('.mcp-detail-definition-empty')!;
const settingsLink = () => empty.querySelector<HTMLAnchorElement>('.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']],
});
});
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<HTMLElement>('.mcp-detail-diagnostic-card.migration')?.closest<HTMLElement>('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',
},
});
});
});
@@ -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,
@@ -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' },
]);
});
@@ -787,6 +795,91 @@ suite('mcpListWidget', () => {
});
});
test('describes host-only MCP servers without a configuration file by their runtime source', () => {
const provenance = (overrides: Partial<AgentHostMcpServer>) => 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('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 (Connector)',
source: undefined,
provenance,
definitionUnavailable,
});
});
test('explains why the agent\'s own MCP servers have no definition and links only a setting the host declares', () => {
const explain = (overrides: Partial<AgentHostMcpServer>) => 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: 'computer-use', source: 'builtin', sourcePluginName: 'computer-use' }),
explain({ name: 'github-copilot-connector-1', source: 'managed', displayName: 'Linear' }),
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: 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,
undefined,
undefined,
]);
});
test('toggles MCP enablement without changing its scope', () => {
assert.deepStrictEqual([
getToggledMcpEnablementState(ContributionEnablementState.EnabledProfile),
@@ -1080,14 +1173,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 }),
},
}],
});
@@ -1098,7 +1191,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', () => {
@@ -1582,6 +1676,7 @@ suite('mcpListWidget', () => {
const managementClicks: string[] = [];
const openedPlugins: string[] = [];
const openedExtensions: string[] = [];
const executedCommands: unknown[][] = [];
let migrationRequests = 0;
const hostEnablementCalls: Parameters<IAgentHostCustomizationService['setCustomizationEnablement']>[] = [];
const runtimeServers = observableValue<readonly IMcpServer[]>('runtimeServers', []);
@@ -1606,6 +1701,7 @@ suite('mcpListWidget', () => {
} as unknown as IAgentHostCustomizationService;
const customizationHarnessService = {
activeSessionResource,
getActiveDescriptor: () => ({ label: 'Copilot' }),
} as unknown as ICustomizationHarnessService;
const marketplaceInstallService = new class extends mock<ICustomizationMarketplaceInstallService>() {
override readonly onDidChange = Event.None;
@@ -1632,6 +1728,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;
@@ -1684,6 +1783,8 @@ suite('mcpListWidget', () => {
cardListControllers: Map<HTMLElement, CustomizationCardListController>;
appendInstalledServerRow(parent: HTMLElement, presentation: { entry: Entry }): void;
createInstalledMcpServerDetailInput(entry: Entry): ReturnType<typeof createInstalledMcpServerDetailInput>;
getUpdatedServerDetail(id: string): ReturnType<typeof createInstalledMcpServerDetailInput> | undefined;
installedEntries: { entry: Entry }[];
getMcpEntryAriaLabel(entry: Entry): IObservable<string>;
getMcpServerActions(entry: Entry, store: DisposableStore): IAction[];
renderMcpListActions(getEntry: () => Entry | undefined, actions: HTMLElement, store: DisposableStore, updateTabbability: () => void): void;
@@ -1716,12 +1817,17 @@ suite('mcpListWidget', () => {
managementClicks,
openedPlugins,
openedExtensions,
executedCommands,
migrationRequests: () => migrationRequests,
hostEnablementCalls,
localEnablementCalls: () => localEnablementCalls,
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<string>) => { widget.getMcpEntryAriaLabel = provider; },
setRuntimeServers: (next: readonly IMcpServer[]) => runtimeServers.set(next, undefined),
menu: (entry: Entry, localServer?: IMcpServer) => {
@@ -1748,7 +1854,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 () => { } },
});
@@ -2010,6 +2116,127 @@ 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<McpConnectionState>('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('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 (Connector)',
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 connector 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 (Connector)',
source: '',
ariaLabel: 'Linear (Connector), Running',
detail: {
name: 'github-copilot-connector-1',
label: 'Linear (Connector)',
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<Entry, { type: 'builtin-item' }>) => 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(onShowOutput?: () => void) {
const outputCalls: string[] = [];
const startCalls: string[] = [];
@@ -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'],