mirror of
https://github.com/microsoft/vscode.git
synced 2026-10-07 09:50:12 +01:00
* Show connector names in MCP authentication Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address MCP connector review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Identify connector names in MCP authentication Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Publish connector labels during reconciliation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use connector labels across MCP surfaces Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refresh pending connector authentication labels Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
704 lines
29 KiB
TypeScript
704 lines
29 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import { Disposable } from '../../../../base/common/lifecycle.js';
|
|
import { equals } from '../../../../base/common/objects.js';
|
|
import { derived, observableValue, transaction, type IObservable, type ITransaction } from '../../../../base/common/observable.js';
|
|
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 { 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';
|
|
import type { SessionAction } from '../../common/state/sessionActions.js';
|
|
import { AgentHostStateManager, IAgentHostStateManager } from '../agentHostStateManager.js';
|
|
|
|
/**
|
|
* SDK-neutral description of a single MCP server, as the controller's
|
|
* caller sees it. Each provider adapts its own SDK events into this
|
|
* shape (Copilot, Claude, Codex, …) and feeds them to
|
|
* {@link McpCustomizationController}.
|
|
*/
|
|
export interface ISdkMcpServer {
|
|
/** Server name (used both as the customization name and the channel suffix). */
|
|
readonly name: string;
|
|
/** Optional Connector catalog name that does not participate in runtime identity. */
|
|
readonly displayName?: string;
|
|
readonly source?: McpServerSource;
|
|
/** Configuration file URI. Omitted on lifecycle updates; null clears a previously known source. */
|
|
readonly sourceUri?: string | null;
|
|
/** Current lifecycle state. */
|
|
readonly state: McpServerState;
|
|
/**
|
|
* Allows this exact starting update to replace an auth challenge. Absent by
|
|
* default so other providers retain the sticky auth-required behavior.
|
|
*/
|
|
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;
|
|
readonly pluginVersion?: string;
|
|
}
|
|
|
|
/**
|
|
* Runtime fields of an MCP server customization that this controller
|
|
* owns — the high-frequency `state`/`channel` pair. Consumers overlay
|
|
* these onto their published customizations (keyed by customization id)
|
|
* so a wholesale customization republish preserves live MCP status
|
|
* rather than resetting it to the `Stopped` default baked into
|
|
* `makeMcpServerCustomization`.
|
|
*/
|
|
export type IMcpServerRuntimeState = Pick<McpServerCustomization, 'state' | 'channel'>;
|
|
|
|
/** Joins discovered declarations with live top-level servers without losing declaration identity or enablement. */
|
|
export function mergeMcpServerCustomizations(declarations: readonly Customization[], liveServers: readonly Customization[]): Customization[] {
|
|
const byId = new Map(declarations.map(customization => [customization.id, customization]));
|
|
for (const live of liveServers) {
|
|
const declaration = byId.get(live.id);
|
|
byId.set(live.id, declaration?.type === CustomizationType.McpServer && live.type === CustomizationType.McpServer
|
|
? {
|
|
...live,
|
|
...declaration,
|
|
state: live.state,
|
|
channel: live.channel,
|
|
mcpApp: live.mcpApp ?? declaration.mcpApp,
|
|
enablement: declaration.enablement,
|
|
_meta: { ...live._meta, ...declaration._meta },
|
|
}
|
|
: declaration ?? live);
|
|
}
|
|
return [...byId.values()];
|
|
}
|
|
|
|
/** Applies live MCP runtime fields to matching top-level or child customizations. */
|
|
export function applyMcpServerRuntimeStates<T extends Customization>(customization: T, runtimeStates: ReadonlyMap<string, IMcpServerRuntimeState> | undefined): T {
|
|
if (!runtimeStates?.size) {
|
|
return customization;
|
|
}
|
|
if (customization.type === CustomizationType.McpServer) {
|
|
const runtime = runtimeStates.get(customization.id);
|
|
return runtime && (!equals(customization.state, runtime.state) || customization.channel !== runtime.channel)
|
|
? { ...customization, state: runtime.state, channel: runtime.channel }
|
|
: customization;
|
|
}
|
|
const children = customization.children;
|
|
if (!children?.length) {
|
|
return customization;
|
|
}
|
|
let changed = false;
|
|
const updatedChildren = children.map(child => {
|
|
if (child.type !== CustomizationType.McpServer) {
|
|
return child;
|
|
}
|
|
const runtime = runtimeStates.get(child.id);
|
|
if (!runtime || (equals(child.state, runtime.state) && child.channel === runtime.channel)) {
|
|
return child;
|
|
}
|
|
changed = true;
|
|
return { ...child, state: runtime.state, channel: runtime.channel };
|
|
});
|
|
return changed ? { ...customization, children: updatedChildren } : customization;
|
|
}
|
|
|
|
/**
|
|
* Re-export so existing imports of `DEFAULT_MCP_APP_CAPABILITIES` from
|
|
* the controller keep working — the canonical home is now
|
|
* `agentHost/common/state/protocol/mcpAppDefaults.ts`.
|
|
*/
|
|
export { DEFAULT_MCP_APP_CAPABILITIES, DEFAULT_MCP_APP };
|
|
|
|
/**
|
|
* Options for {@link McpCustomizationController}.
|
|
*/
|
|
export interface IMcpCustomizationControllerOptions {
|
|
/** Concrete chat URI used for MCP App routing. */
|
|
readonly chatUri: URI;
|
|
/** Emits a {@link SessionAction} into the session's action stream. */
|
|
readonly emit: (action: SessionAction) => void;
|
|
/** Returns durable plugin source URIs for plugin-provided MCP servers. */
|
|
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;
|
|
/**
|
|
* MCP App capabilities to advertise on every ready server. Defaults
|
|
* to {@link DEFAULT_MCP_APP_CAPABILITIES}.
|
|
*/
|
|
readonly capabilities?: AhpMcpUiHostCapabilities;
|
|
}
|
|
|
|
interface ILiveEntry {
|
|
readonly serverName: string;
|
|
readonly displayName?: string;
|
|
readonly state: McpServerState;
|
|
readonly enabled: boolean;
|
|
readonly publishedId: string;
|
|
/** Top-level customization id (when no child match was found). */
|
|
readonly topLevelId?: string;
|
|
/** Container that published this child, retained across its transient Loading snapshot. */
|
|
readonly childContainerId?: string;
|
|
readonly topLevelCustomization?: McpServerCustomization;
|
|
}
|
|
|
|
export function buildMcpTopLevelCustomizationId(providerId: string, sessionId: string, serverName: string): string {
|
|
return `mcp-top-level:${providerId}:${sessionId}:${serverName}`;
|
|
}
|
|
|
|
export function buildMcpChannel(chatUri: URI, serverName: string): string {
|
|
const providerId = getMcpChannelProviderId(chatUri);
|
|
return `mcp://${providerId}/${encodeURIComponent(chatUri.toString())}/${encodeURIComponent(serverName)}`;
|
|
}
|
|
|
|
/**
|
|
* Translates a stream of SDK-reported MCP server states into AHP
|
|
* customization actions:
|
|
*
|
|
* - For servers backed by an existing child customization (plugin- or
|
|
* directory-derived), the controller emits
|
|
* {@link ActionType.SessionMcpServerStateChanged} keyed on the
|
|
* resolved child id. The reducer narrowly updates `state` and
|
|
* `channel` on the matching child.
|
|
* - For servers with no matching child (typically globally-configured
|
|
* MCP servers the SDK reports), the controller emits a full
|
|
* {@link ActionType.SessionCustomizationUpdated} carrying a bare
|
|
* top-level {@link McpServerCustomization}. The same id is reused
|
|
* across updates, so the reducer's upsert keeps in-place.
|
|
*
|
|
* The controller is SDK-agnostic: providers translate their own events
|
|
* into {@link ISdkMcpServer} and call {@link applyAll} / {@link applyOne}.
|
|
* If a provider reports a coarse {@link McpServerStatus.Starting} update
|
|
* after a richer {@link McpServerStatus.AuthRequired} state, the controller
|
|
* preserves the auth-required state until a definitive
|
|
* {@link McpServerStatus.Ready}, {@link McpServerStatus.Error}, or
|
|
* {@link McpServerStatus.Stopped} update arrives. A provider can opt a
|
|
* specific SDK starting update out of that preservation.
|
|
*/
|
|
export class McpCustomizationController extends Disposable {
|
|
|
|
private readonly _chatUri: URI;
|
|
private readonly _providerId: string;
|
|
private readonly _sessionId: string;
|
|
private readonly _sessionUri: URI;
|
|
|
|
/** Per-server live entries, keyed by server name. */
|
|
private readonly _live = observableValue<ReadonlyMap<string, ILiveEntry>>(this, new Map());
|
|
|
|
/**
|
|
* Snapshot of every live server's runtime {@link IMcpServerRuntimeState},
|
|
* keyed by its retained published customization id. Child entries keep that
|
|
* id while their owning container is transiently Loading.
|
|
*/
|
|
readonly runtimeStates: IObservable<ReadonlyMap<string, IMcpServerRuntimeState>>;
|
|
|
|
constructor(
|
|
private readonly _options: IMcpCustomizationControllerOptions,
|
|
@IAgentHostStateManager private readonly _stateManager: AgentHostStateManager,
|
|
) {
|
|
super();
|
|
this._chatUri = this._options.chatUri;
|
|
const chat = parseChatUri(this._chatUri);
|
|
if (!chat) {
|
|
throw new Error(`Malformed AHP chat URI: ${this._chatUri.toString()}`);
|
|
}
|
|
this._sessionUri = URI.parse(chat.session);
|
|
this._providerId = AgentSession.provider(this._sessionUri) ?? '';
|
|
this._sessionId = AgentSession.id(this._sessionUri);
|
|
if (!this._providerId || !this._sessionId) {
|
|
throw new Error(`Malformed Agent Host session URI: ${chat.session}`);
|
|
}
|
|
this.runtimeStates = derived(this, reader => {
|
|
const out = new Map<string, IMcpServerRuntimeState>();
|
|
for (const entry of this._live.read(reader).values()) {
|
|
out.set(entry.publishedId, { state: entry.state, channel: this._buildChannel(entry.serverName, entry.state) });
|
|
}
|
|
return out;
|
|
});
|
|
}
|
|
|
|
/** Snapshot for inclusion in `getSessionCustomizations()` results. */
|
|
topLevelCustomizations(): readonly McpServerCustomization[] {
|
|
const out: McpServerCustomization[] = [];
|
|
for (const entry of this._live.get().values()) {
|
|
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));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
get pluginMcpServerSources(): ReadonlyMap<string, string> | undefined {
|
|
return this._options.pluginMcpServerSources?.();
|
|
}
|
|
|
|
/**
|
|
* Names of MCP servers currently in {@link McpServerStatus.Ready},
|
|
* paired with their channel URI. Used by providers to drive
|
|
* polling-based notification streams (e.g. re-fetch `tools/list`
|
|
* after a refresh hint and fire
|
|
* `notifications/tools/list_changed` if the result changed).
|
|
*/
|
|
readyChannels(): readonly { readonly serverName: string; readonly channel: string }[] {
|
|
const out: { serverName: string; channel: string }[] = [];
|
|
for (const entry of this._live.get().values()) {
|
|
if (entry.state.kind !== McpServerStatus.Ready) {
|
|
continue;
|
|
}
|
|
const channel = this._buildChannel(entry.serverName, entry.state);
|
|
if (channel !== undefined) {
|
|
out.push({ serverName: entry.serverName, channel });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Returns the customization id currently associated with the MCP
|
|
* server named `serverName`, or `undefined` when no customization
|
|
* exists. Top-level entries return the minted top-level id; child
|
|
* entries return the child id published in session state for that server.
|
|
* Used by providers to tag
|
|
* {@link ToolCallMcpContributor.customizationId | tool-call contributors}
|
|
* so clients can correlate MCP tool calls with the originating
|
|
* server customization.
|
|
*/
|
|
customizationIdForServer(serverName: string): string | undefined {
|
|
const live = this._live.get().get(serverName);
|
|
if (live?.topLevelId !== undefined) {
|
|
return live.topLevelId;
|
|
}
|
|
const published = this._findPublishedMcpCustomization(serverName);
|
|
return published?.topLevelId ?? published?.childId;
|
|
}
|
|
|
|
/** Returns the server name associated with a live or currently published customization id. */
|
|
serverNameForCustomizationId(id: string): string | undefined {
|
|
for (const entry of this._live.get().values()) {
|
|
const entryId = entry.topLevelId ?? this._resolveChildId(entry.serverName);
|
|
if (entryId === id) {
|
|
return entry.serverName;
|
|
}
|
|
}
|
|
const customizations = this._stateManager.getSessionState(this._sessionUri.toString())?.customizations ?? [];
|
|
return findMcpServerName(customizations, id);
|
|
}
|
|
|
|
/** Returns the currently published customization for an MCP server name. */
|
|
customizationForServer(serverName: string): McpServerCustomization | undefined {
|
|
const customizations = this._stateManager.getSessionState(this._sessionUri.toString())?.customizations ?? [];
|
|
return getMcpServerCustomizations(customizations).find(server => server.name === serverName);
|
|
}
|
|
|
|
/** Returns the last live state recorded for the MCP server named `serverName`. */
|
|
stateForServer(serverName: string): McpServerState | undefined {
|
|
return this._live.get().get(serverName)?.state;
|
|
}
|
|
|
|
/** Returns the last runtime enablement reported for the MCP server named `serverName`. */
|
|
enabledForServer(serverName: string): boolean | undefined {
|
|
return this._live.get().get(serverName)?.enabled;
|
|
}
|
|
|
|
/** Snapshot used by providers to reconcile desired and observed enablement. */
|
|
serverEnablement(): readonly { readonly serverName: string; readonly customizationId: string; readonly enabled: boolean }[] {
|
|
const result: { serverName: string; customizationId: string; enabled: boolean }[] = [];
|
|
for (const entry of this._live.get().values()) {
|
|
const customizationId = entry.topLevelId ?? this._resolveChildId(entry.serverName);
|
|
if (customizationId !== undefined) {
|
|
result.push({ serverName: entry.serverName, customizationId, enabled: entry.enabled });
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Returns the `mcp://` AHP channel URI currently advertised for the
|
|
* MCP server named `serverName`, or `undefined` when the server is
|
|
* not in {@link McpServerStatus.Ready}. Used by providers to attach
|
|
* the channel to MCP App `_meta.ui` so clients can route App
|
|
* sub-RPCs (tools/call, resources/read, sampling/createMessage)
|
|
* back through {@link IAgentHostService.handleMcpRequest}.
|
|
*/
|
|
channelForServer(serverName: string): string | undefined {
|
|
const live = this._live.get().get(serverName);
|
|
if (!live || live.state.kind !== McpServerStatus.Ready) {
|
|
return undefined;
|
|
}
|
|
return this._buildChannel(serverName, live.state);
|
|
}
|
|
|
|
/**
|
|
* Replaces the live inventory with `servers`. Servers no longer
|
|
* present are removed; new servers and changed servers are upserted.
|
|
* Batched in a single transaction so {@link runtimeStates} observers
|
|
* see one coalesced update. `force` republishes unchanged entries after
|
|
* publication ownership changes.
|
|
*/
|
|
applyAll(servers: readonly ISdkMcpServer[], force = false): void {
|
|
transaction(tx => {
|
|
const seen = new Set<string>();
|
|
for (const server of servers) {
|
|
seen.add(server.name);
|
|
this._applyOne(server, tx, force);
|
|
}
|
|
for (const name of [...this._live.get().keys()]) {
|
|
if (!seen.has(name)) {
|
|
this._remove(name, tx);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Upserts a single server; `force` republishes retained state after an optimistic client update. */
|
|
applyOne(server: ISdkMcpServer, force = false): void {
|
|
transaction(tx => this._applyOne(server, tx, force));
|
|
}
|
|
|
|
private _applyOne(server: ISdkMcpServer, tx: ITransaction, force = false): void {
|
|
const previous = this._live.get().get(server.name);
|
|
const state = this._stateForUpdate(previous?.state, server.state, server.allowAuthRequiredToStarting === true);
|
|
const enabled = server.enabled ?? previous?.enabled ?? true;
|
|
const displayName = server.displayName ?? previous?.displayName;
|
|
// Once promoted to a top-level entry, stay top-level for the
|
|
// session — flipping back to a child mid-stream would orphan the
|
|
// previously-published top-level id.
|
|
let topLevelId = previous?.topLevelId;
|
|
if (topLevelId === undefined) {
|
|
const published = this._findPublishedMcpCustomization(server.name);
|
|
const retainChildIdentity = published === undefined
|
|
&& previous?.childContainerId !== undefined
|
|
&& this._isContainerLoading(previous.childContainerId);
|
|
const childId = published?.childId ?? (retainChildIdentity ? previous?.publishedId : undefined);
|
|
const childContainerId = published?.childContainerId ?? (retainChildIdentity ? previous?.childContainerId : undefined);
|
|
if (childId !== undefined) {
|
|
const stateChanged = force || previous?.topLevelId !== undefined || previous?.publishedId !== childId || previous?.childContainerId !== childContainerId || !equals(previous?.state, state);
|
|
if (stateChanged || previous?.enabled !== enabled || previous?.displayName !== displayName) {
|
|
this._setLiveEntry(server.name, { serverName: server.name, displayName, state, enabled, publishedId: childId, topLevelId: undefined, childContainerId }, tx);
|
|
}
|
|
if (!stateChanged) {
|
|
return;
|
|
}
|
|
this._options.emit({
|
|
type: ActionType.SessionMcpServerStateChanged,
|
|
id: childId,
|
|
state,
|
|
channel: this._buildChannel(server.name, state),
|
|
});
|
|
return;
|
|
}
|
|
topLevelId = published?.topLevelId ?? this._mintTopLevelId(server.name);
|
|
}
|
|
const source = server.source ?? readMcpServerSource(previous?.topLevelCustomization);
|
|
const sourceUri = server.sourceUri !== undefined ? server.sourceUri : previous?.topLevelCustomization?.uri;
|
|
const customization = this._buildTopLevel(topLevelId, server.name, displayName, state, enabled, source, sourceUri);
|
|
const resolvedDisplayName = readMcpServerDisplayName(customization);
|
|
const customizationChanged = force || previous?.topLevelId !== topLevelId || !equals(previous?.topLevelCustomization, customization);
|
|
if (customizationChanged || previous?.enabled !== enabled) {
|
|
this._setLiveEntry(server.name, { serverName: server.name, displayName: resolvedDisplayName, state, enabled, publishedId: topLevelId, topLevelId, topLevelCustomization: customization }, tx);
|
|
}
|
|
if (!customizationChanged) {
|
|
return;
|
|
}
|
|
this._options.emit({
|
|
type: ActionType.SessionCustomizationUpdated,
|
|
customization,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Removes a server from the live inventory. For top-level entries
|
|
* (bare servers with no plugin-derived child) emits
|
|
* {@link ActionType.SessionCustomizationRemoved} so the entry is
|
|
* dropped from session state, not just from the in-memory live
|
|
* inventory.
|
|
*
|
|
* For child entries we emit a final {@link ActionType.SessionMcpServerStateChanged}
|
|
* carrying {@link McpServerStatus.Stopped} so the UI sees the
|
|
* server settle into a terminal state; the plugin layer owns the
|
|
* actual removal of the child container.
|
|
*/
|
|
remove(serverName: string): void {
|
|
transaction(tx => this._remove(serverName, tx));
|
|
}
|
|
|
|
private _remove(serverName: string, tx: ITransaction): void {
|
|
const entry = this._live.get().get(serverName);
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
this._deleteLiveEntry(serverName, tx);
|
|
if (entry.topLevelId !== undefined) {
|
|
this._options.emit({
|
|
type: ActionType.SessionCustomizationRemoved,
|
|
id: entry.topLevelId,
|
|
});
|
|
return;
|
|
}
|
|
const childId = this._resolveChildId(serverName);
|
|
if (childId === undefined) {
|
|
return;
|
|
}
|
|
this._options.emit({
|
|
type: ActionType.SessionMcpServerStateChanged,
|
|
id: childId,
|
|
state: { kind: McpServerStatus.Stopped },
|
|
});
|
|
}
|
|
|
|
// ---- internals ---------------------------------------------------------
|
|
|
|
/** Immutable upsert into the {@link _live} observable. */
|
|
private _setLiveEntry(serverName: string, entry: ILiveEntry, tx: ITransaction): void {
|
|
const next = new Map(this._live.get());
|
|
next.set(serverName, entry);
|
|
this._live.set(next, tx);
|
|
}
|
|
|
|
/** Immutable delete from the {@link _live} observable. */
|
|
private _deleteLiveEntry(serverName: string, tx: ITransaction): void {
|
|
const current = this._live.get();
|
|
if (!current.has(serverName)) {
|
|
return;
|
|
}
|
|
const next = new Map(current);
|
|
next.delete(serverName);
|
|
this._live.set(next, tx);
|
|
}
|
|
|
|
private _stateForUpdate(previous: McpServerState | undefined, next: McpServerState, allowAuthRequiredToStarting: boolean): McpServerState {
|
|
if (!allowAuthRequiredToStarting && previous?.kind === McpServerStatus.AuthRequired && next.kind === McpServerStatus.Starting) {
|
|
return previous;
|
|
}
|
|
return next;
|
|
}
|
|
|
|
private _mintTopLevelId(serverName: string): string {
|
|
return buildMcpTopLevelCustomizationId(this._providerId, this._sessionId, serverName);
|
|
}
|
|
|
|
private _resolveChildId(serverName: string): string | undefined {
|
|
return this._findPublishedMcpCustomization(serverName)?.childId;
|
|
}
|
|
|
|
private _findPublishedMcpCustomization(serverName: string): { readonly topLevelId?: string; readonly childId?: string; readonly childContainerId?: string } | undefined {
|
|
const customizations = this._stateManager.getSessionState(this._sessionUri.toString())?.customizations ?? [];
|
|
const topLevel = customizations.find(customization => customization.type === CustomizationType.McpServer && customization.name === serverName);
|
|
if (topLevel?.type === CustomizationType.McpServer) {
|
|
return { topLevelId: topLevel.id };
|
|
}
|
|
for (const customization of customizations) {
|
|
if (customization.type === CustomizationType.McpServer) {
|
|
continue;
|
|
}
|
|
const child = customization.children?.find(candidate => candidate.type === CustomizationType.McpServer && candidate.name === serverName);
|
|
if (child?.type === CustomizationType.McpServer) {
|
|
return { childId: child.id, childContainerId: customization.id };
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private _isContainerLoading(id: string): boolean {
|
|
const customizations = this._stateManager.getSessionState(this._sessionUri.toString())?.customizations ?? [];
|
|
return customizations.some(customization => customization.type !== CustomizationType.McpServer
|
|
&& customization.id === id
|
|
&& customization.load?.kind === CustomizationLoadStatus.Loading);
|
|
}
|
|
|
|
private _buildChannel(serverName: string, state: McpServerState): string | undefined {
|
|
if (state.kind !== McpServerStatus.Ready) {
|
|
return undefined;
|
|
}
|
|
return buildMcpChannel(this._chatUri, serverName);
|
|
}
|
|
|
|
private _buildTopLevel(id: string, serverName: string, displayName: string | undefined, state: McpServerState, enabled: boolean, source?: McpServerSource, 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 —
|
|
// "SHOULD be present whenever the server can host Apps". We
|
|
// proxy every MCP server uniformly, so advertise the host's
|
|
// capability set regardless of runtime `state`. Clients gate
|
|
// rendering on `state.kind === Ready` + `channel` themselves.
|
|
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);
|
|
// `SessionCustomizationUpdated` replaces the whole customization, so keep opaque entries owned by others.
|
|
const meta = withMcpServerDisplayNameMeta(withMcpServerSourceMeta(existing?._meta, source), displayName);
|
|
const uri = (sourceUri === undefined ? existing?.uri : sourceUri) ?? this._mintTopLevelId(serverName);
|
|
const customization: McpServerCustomization = {
|
|
type: CustomizationType.McpServer,
|
|
id,
|
|
uri,
|
|
...(uri === existing?.uri && existing.range ? { range: existing.range } : {}),
|
|
name: serverName,
|
|
state,
|
|
channel,
|
|
mcpApp,
|
|
...(meta ? { _meta: meta } : {}),
|
|
};
|
|
const enablement = this._options.resolveEnablement?.(customization, owningPluginUri) ?? existing?.enablement;
|
|
return enablement?.length ? { ...customization, enablement: [...enablement] } : customization;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convenience helper: given a flat list of {@link Customization}
|
|
* entries, returns the id of the first MCP child customization whose
|
|
* name matches `serverName`.
|
|
*/
|
|
export function findMcpChildId(customizations: readonly Customization[], serverName: string): string | undefined {
|
|
return getMcpServerCustomizations(customizations).find(server => server.name === serverName)?.id;
|
|
}
|
|
|
|
export function getMcpServerCustomizations(customizations: readonly Customization[]): readonly McpServerCustomization[] {
|
|
const result: McpServerCustomization[] = [];
|
|
for (const top of customizations) {
|
|
if (top.type === CustomizationType.McpServer) {
|
|
result.push(top);
|
|
} else {
|
|
for (const child of top.children ?? []) {
|
|
if (child.type === CustomizationType.McpServer) {
|
|
result.push(child);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Every MCP server with its effective enabled value, after applying the
|
|
* container gate. The gate is applied here, at the point of use — a child's
|
|
* own stored decisions are never overwritten, so re-enabling a container
|
|
* restores each child to the user's chosen value.
|
|
*/
|
|
export function getEffectiveMcpServerCustomizations(
|
|
customizations: readonly Customization[],
|
|
): readonly { readonly server: McpServerCustomization; readonly enabled: boolean }[] {
|
|
const result: { server: McpServerCustomization; enabled: boolean }[] = [];
|
|
for (const customization of customizations) {
|
|
if (customization.type === CustomizationType.McpServer) {
|
|
result.push({ server: customization, enabled: isCustomizationEnabled(customization) });
|
|
continue;
|
|
}
|
|
const containerEnabled = customization.type === CustomizationType.Plugin
|
|
? isCustomizationEnabled(customization)
|
|
: customization.enabled;
|
|
for (const child of customization.children ?? []) {
|
|
if (child.type === CustomizationType.McpServer) {
|
|
result.push({ server: child, enabled: containerEnabled && isCustomizationEnabled(child) });
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function applyMcpServerEnablement(customizations: readonly Customization[], desired: readonly Customization[]): readonly Customization[] {
|
|
const desiredById = new Map(getEffectiveMcpServerCustomizations(desired).map(({ server }) => [server.id, server.enablement]));
|
|
return customizations.map(customization => {
|
|
if (customization.type === CustomizationType.McpServer) {
|
|
return applyMcpEnablement(customization, desiredById);
|
|
}
|
|
let changed = false;
|
|
const children = customization.children?.map(child => {
|
|
const next = child.type === CustomizationType.McpServer ? applyMcpEnablement(child, desiredById) : child;
|
|
changed ||= next !== child;
|
|
return next;
|
|
});
|
|
return changed ? { ...customization, children } : customization;
|
|
});
|
|
}
|
|
|
|
function applyMcpEnablement(customization: McpServerCustomization, desiredById: ReadonlyMap<string, readonly CustomizationEnablement[] | undefined>): McpServerCustomization {
|
|
if (!desiredById.has(customization.id)) {
|
|
return customization;
|
|
}
|
|
const enablement = desiredById.get(customization.id);
|
|
if (enablement === undefined) {
|
|
return customization;
|
|
}
|
|
if (enablement?.length) {
|
|
return { ...customization, enablement: [...enablement] };
|
|
}
|
|
const { enablement: _enablement, ...withoutEnablement } = customization;
|
|
return withoutEnablement;
|
|
}
|
|
|
|
export function findMcpServerName(customizations: readonly Customization[], id: string): string | undefined {
|
|
return getMcpServerCustomizations(customizations).find(server => server.id === id)?.name;
|
|
}
|
|
|
|
/**
|
|
* Parsed `mcp://<providerId>/<chatUri>/<serverName>` URI as minted by
|
|
* {@link McpCustomizationController}. The path segments are
|
|
* URL-decoded.
|
|
*/
|
|
export interface IMcpChannelRoute {
|
|
readonly providerId: string;
|
|
readonly chatUri: URI;
|
|
readonly serverName: string;
|
|
}
|
|
|
|
function getMcpChannelProviderId(chatUri: URI): string {
|
|
const chat = parseChatUri(chatUri);
|
|
if (!chat) {
|
|
throw new Error(`Malformed AHP chat URI: ${chatUri.toString()}`);
|
|
}
|
|
const providerId = AgentSession.provider(chat.session);
|
|
if (!providerId) {
|
|
throw new Error(`Malformed Agent Host session URI: ${chat.session}`);
|
|
}
|
|
return providerId;
|
|
}
|
|
|
|
/**
|
|
* Decodes a channel URI string into a {@link IMcpChannelRoute}, or
|
|
* returns `undefined` when the URI is not an `mcp://` channel or the
|
|
* path is malformed.
|
|
*/
|
|
export function parseMcpChannelUri(uri: string): IMcpChannelRoute | undefined {
|
|
const prefix = 'mcp://';
|
|
if (!uri.startsWith(prefix)) {
|
|
return undefined;
|
|
}
|
|
const rest = uri.slice(prefix.length);
|
|
const slash = rest.indexOf('/');
|
|
if (slash <= 0) {
|
|
return undefined;
|
|
}
|
|
const providerId = rest.slice(0, slash);
|
|
const tail = rest.slice(slash + 1);
|
|
const segments = tail.split('/');
|
|
if (segments.length !== 2 || !segments[0] || !segments[1]) {
|
|
return undefined;
|
|
}
|
|
let chatUri: URI;
|
|
let serverName: string;
|
|
try {
|
|
chatUri = URI.parse(decodeURIComponent(segments[0]));
|
|
serverName = decodeURIComponent(segments[1]);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
if (!providerId || !serverName) {
|
|
return undefined;
|
|
}
|
|
let routedProviderId: string;
|
|
try {
|
|
routedProviderId = getMcpChannelProviderId(chatUri);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
if (routedProviderId !== providerId) {
|
|
return undefined;
|
|
}
|
|
return { providerId, chatUri, serverName };
|
|
}
|