src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts
555 LOC · 531 covered · 24 uncovered · 123 ranges · 2022 concepts · 51 introducers · 974 tests
File neighbourhood
The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.
Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file
In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.
Graph controls are ready.
Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.
/*---------------------------------------------------------------------------------------------
mcpCustomizationController.ts ×29
* 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 { derived, observableValue, transaction, type IObservable, type ITransaction } from '../../../../base/common/observable.js';
import { URI } from '../../../../base/common/uri.js';
import { ActionType } from '../../common/state/protocol/common/actions.js';
import { CustomizationType, McpServerStatus, type AhpMcpUiHostCapabilities, type ChildCustomization, type Customization, 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 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;
/** Current lifecycle state. */
readonly state: McpServerState;
/** Explicit runtime enablement when the SDK distinguishes disabled from stopped. */
readonly enabled?: boolean;
}
/**
* 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'>;
/**
* 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 };
/**
* Lookup callback the controller uses to find an existing child MCP
* customization id by server name. The agent's plugin layer publishes
* MCP customizations with provider-defined ids
* (e.g. `pluginParsers.makeMcpServerCustomization` uses
* `buildChildId(definitionUri, 'mcp=' + encodeURIComponent(name))`), so
* we resolve them by name at action-dispatch time rather than trying to
* reconstruct the id.
*
* Returns `undefined` when no existing entry matches — in that case the
* controller surfaces a bare top-level customization for the server.
*/
export type IMcpChildIdResolver = (serverName: string) => string | undefined;
/**
* Options for {@link McpCustomizationController}.
*/
export interface IMcpCustomizationControllerOptions {
/** Provider id (e.g. `'copilotcli'`). Used as the channel URI authority. */
readonly providerId: string;
/** Session id (the raw id, not the full URI). Used as the channel path segment. */
readonly sessionId: string;
/** Canonical session URI used to resolve persisted customization state. */
readonly sessionUri: URI;
/**
* Resolves an existing child customization id for a given server
* name. See {@link IMcpChildIdResolver}.
*/
readonly resolveChildId: IMcpChildIdResolver;
/** Emits a {@link SessionAction} into the session's action stream. */
readonly emit: (action: SessionAction) => void;
/**
* 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 state: McpServerState;
readonly enabled: boolean;
/** Top-level customization id (when no child match was found). */
readonly topLevelId?: string;
}
export function buildMcpTopLevelCustomizationId(providerId: string, sessionId: string, serverName: string): string {
return `mcp-top-level:${providerId}:${sessionId}:${serverName}`;
mcpCustomizationController.ts ×1
}
export function buildMcpChannel(providerId: string, sessionId: string, serverName: string): string {
return `mcp://${providerId}/${encodeURIComponent(sessionId)}/${encodeURIComponent(serverName)}`;
mcpCustomizationController.ts ×1
}
/**
* 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.
*/
export class McpCustomizationController extends Disposable {
/** 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 the customization id under which it is published (the
* minted top-level id, or the plugin-derived child id resolved via
* {@link IMcpChildIdResolver}). Derived from {@link _live}. Callers mirror
* this into their own published customizations so a wholesale republish
* preserves live MCP status. Servers whose child id cannot currently be
* resolved are omitted.
*/
readonly runtimeStates: IObservable<ReadonlyMap<string, IMcpServerRuntimeState>>;
constructor(
private readonly _options: IMcpCustomizationControllerOptions,
mcpCustomizationController.ts ×2
@IAgentHostStateManager private readonly _stateManager: AgentHostStateManager,
) {
super();
this.runtimeStates = derived(this, reader => {
for (const entry of this._live.read(reader).values()) {
const id = entry.topLevelId ?? this._options.resolveChildId(entry.serverName);
mcpCustomizationController.ts ×2
if (id === undefined) {
continue;
}
out.set(id, { state: entry.state, channel: this._buildChannel(entry.serverName, entry.state) });
mcpCustomizationController.ts ×2
}
}
/** Snapshot for inclusion in `getSessionCustomizations()` results. */
topLevelCustomizations(): readonly McpServerCustomization[] {
for (const entry of this._live.get().values()) {
}
out.push(this._buildTopLevel(entry.topLevelId, entry.serverName, entry.state, entry.enabled));
mcpCustomizationController.ts ×1
}
}
/**
* 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 whatever {@link IMcpChildIdResolver} resolves to
* 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 {
if (live?.topLevelId !== undefined) {
}
/** Returns the live server name associated with a customization id. */
serverNameForCustomizationId(id: string): string | undefined {
const entryId = entry.topLevelId ?? this._options.resolveChildId(entry.serverName);
if (entryId === id) {
return entry.serverName;
}
}
return undefined;
/** Returns the last live state recorded for the MCP server named `serverName`. */
stateForServer(serverName: string): McpServerState | undefined {
}
/** 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 }[] = [];
copilotAgentSession.ts ×3
for (const entry of this._live.get().values()) {
const customizationId = entry.topLevelId ?? this._options.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 {
if (!live || live.state.kind !== McpServerStatus.Ready) {
}
/**
* 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.
*/
applyAll(servers: readonly ISdkMcpServer[]): void {
const seen = new Set<string>();
for (const server of servers) {
this._applyOne(server, tx);
}
}
}
/** Upserts a single server. */
applyOne(server: ISdkMcpServer): void {
}
/**
* Optimistically transitions the named servers to
* {@link McpServerStatus.Starting}, skipping any that are already
* {@link McpServerStatus.Ready} (nothing to (re)start), blocked on
* {@link McpServerStatus.AuthRequired} (needs the user, not a background
* start), or already {@link McpServerStatus.Starting}.
*
* The SDK connects enabled servers in the background — on an explicit
* start or when a turn begins — but emits no live "starting" event, so
* without this a connecting server would read as its last settled state
* (e.g. `Stopped`) until it resolves. Callers invoke this immediately
* before the (blocking) connect so clients see the transient `Starting`
* state; the subsequent SDK status settles each server. Batched in a
* single transaction so {@link runtimeStates} observers see one update.
*/
markStarting(serverNames: Iterable<string>): void {
for (const name of serverNames) {
const previous = this._live.get().get(name)?.state.kind;
if (previous === McpServerStatus.Ready || previous === McpServerStatus.AuthRequired || previous === McpServerStatus.Starting) {
}
this._applyOne({ name, state: { kind: McpServerStatus.Starting } }, tx);
mcpCustomizationController.ts ×2
}
});
}
private _applyOne(server: ISdkMcpServer, tx: ITransaction): void {
const state = this._stateForUpdate(previous?.state, server.state);
const enabled = server.enabled ?? previous?.enabled ?? true;
// 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 childId = this._options.resolveChildId(server.name);
if (childId !== undefined) {
this._setLiveEntry(server.name, { serverName: server.name, state, enabled, topLevelId: undefined }, tx);
mcpCustomizationController.ts ×1
this._options.emit({
type: ActionType.SessionMcpServerStateChanged,
id: childId,
state,
channel: this._buildChannel(server.name, state),
});
return;
}
}
this._setLiveEntry(server.name, { serverName: server.name, state, enabled, topLevelId }, tx);
this._options.emit({
type: ActionType.SessionCustomizationUpdated,
customization: this._buildTopLevel(topLevelId, server.name, state, enabled),
});
/**
* 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 {
}
private _remove(serverName: string, tx: ITransaction): void {
if (!entry) {
return;
}
if (entry.topLevelId !== undefined) {
type: ActionType.SessionCustomizationRemoved,
id: entry.topLevelId,
});
return;
}
if (childId === undefined) {
return;
}
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 {
next.set(serverName, entry);
this._live.set(next, tx);
}
/** Immutable delete from the {@link _live} observable. */
private _deleteLiveEntry(serverName: string, tx: ITransaction): void {
if (!current.has(serverName)) {
return;
}
next.delete(serverName);
this._live.set(next, tx);
}
private _stateForUpdate(previous: McpServerState | undefined, next: McpServerState): McpServerState {
if (previous?.kind === McpServerStatus.AuthRequired && next.kind === McpServerStatus.Starting) {
mcpCustomizationController.ts ×7
}
}
private _mintTopLevelId(serverName: string): string {
return buildMcpTopLevelCustomizationId(this._options.providerId, this._options.sessionId, serverName);
mcpCustomizationController.ts ×4
}
private _buildChannel(serverName: string, state: McpServerState): string | undefined {
}
return buildMcpChannel(this._options.providerId, this._options.sessionId, serverName);
mcpCustomizationController.ts ×1
private _buildTopLevel(id: string, serverName: string, state: McpServerState, enabled: boolean): McpServerCustomization {
// 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 }
return {
type: CustomizationType.McpServer,
id,
uri: this._mintTopLevelId(serverName),
name: serverName,
enabled: getEffectiveMcpServerCustomizations(this._stateManager.getSessionState(this._options.sessionUri.toString())?.customizations ?? [])
.find(customization => customization.id === id)?.enabled ?? enabled,
state,
channel,
mcpApp,
};
}
/**
* Convenience helper: given a flat list of {@link Customization}
* entries, returns the id of the first MCP child customization whose
* name matches `serverName`. Used by providers to wire up
* {@link IMcpCustomizationControllerOptions.resolveChildId} without
* each provider having to walk the customization tree itself.
*/
export function findMcpChildId(customizations: readonly Customization[], serverName: string): string | undefined {
return getMcpServerCustomizations(customizations).find(server => server.name === serverName)?.id;
mcpCustomizationController.ts ×1
}
export function getMcpServerCustomizations(customizations: readonly Customization[]): readonly McpServerCustomization[] {
for (const top of customizations) {
for (const child of top.children ?? []) {
if (child.type === CustomizationType.McpServer) {
result.push(child);
}
}
}
}
}
export function getEffectiveMcpServerCustomizations(customizations: readonly Customization[]): readonly McpServerCustomization[] {
for (const top of customizations) {
result.push(top.enabled ? child : { ...child, enabled: false });
mcpCustomizationController.ts ×1
}
}
export function applyMcpServerEnablement(customizations: readonly Customization[], desired: readonly Customization[]): readonly Customization[] {
const desiredById = new Map(getEffectiveMcpServerCustomizations(desired).map(server => [server.id, server.enabled]));
mcpCustomizationController.ts ×3
return customizations.map(customization => {
if (customization.type === CustomizationType.McpServer) {
}
const children = customization.children?.map(child => {
const next = child.type === CustomizationType.McpServer ? applyMcpEnablement(child, desiredById) : child;
mcpCustomizationController.ts ×1
changed ||= next !== child;
return next;
return changed ? { ...customization, children } : customization;
});
}
function applyMcpEnablement<T extends McpServerCustomization | Extract<ChildCustomization, { type: CustomizationType.McpServer }>>(customization: T, desiredById: ReadonlyMap<string, boolean>): T {
claudeSdkPipeline.ts ×5
const enabled = desiredById.get(customization.id);
return enabled === undefined || enabled === customization.enabled ? customization : { ...customization, enabled };
}
export function findMcpServerName(customizations: readonly Customization[], id: string): string | undefined {
return getMcpServerCustomizations(customizations).find(server => server.id === id)?.name;
mcpCustomizationController.ts ×1
}
/**
* Parsed `mcp://<providerId>/<sessionId>/<serverName>` URI as minted by
* {@link McpCustomizationController}. The path segments are
* URL-decoded.
*/
export interface IMcpChannelRoute {
readonly providerId: string;
readonly sessionId: string;
readonly serverName: string;
}
/**
* 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. Intentionally uses string parsing rather than
* `URI.parse` so the helper stays usable from layers (e.g. agentService
* test fixtures) without a full URI dependency.
*/
export function parseMcpChannelUri(uri: string): IMcpChannelRoute | undefined {
if (!uri.startsWith(prefix)) {
}
const slash = rest.indexOf('/');
if (slash <= 0) {
}
const tail = rest.slice(slash + 1);
const sep = tail.indexOf('/');
if (sep <= 0 || sep === tail.length - 1) {
}
let serverName: string;
try {
// `decodeURIComponent` throws `URIError` on malformed percent
// escapes (e.g. a lone `%`). Treat any decode failure as a
// malformed channel rather than letting it escape — the caller
// translates `undefined` into a clean `Method not found`.
sessionId = decodeURIComponent(tail.slice(0, sep));
serverName = decodeURIComponent(tail.slice(sep + 1));
} catch {
}
return undefined;
}
}