mcpCustomizationController.ts ×29

Frontier kind: Code frontier

unlabeled · c_019315d4531a

974 tests · 23413 LOC · 122 files · introduces 0 tests · 280 LOC · 1 file

Introduces — evidence that enters the hierarchy at this concept

Code
29 ranges280 lines · 1 files
Tests
0 tests

Contains — complete concept membership

All code (extent)
2095 ranges23413 lines · 122 files · Browse complete extent
All tests (intent)
974 testsBrowse complete intent

Neighbourhood graph

The orange circle is the focus. Violet and green circles are every ancestor and descendant, broader and narrower, at any distance; blue squares and pink diamonds are the introduced files and exact introduced tests of every visible concept, not only the focus's. Arrows point from broader to narrower concepts and bridge only concepts omitted from this view. Undirected links show source or test introduction. Concept and file size follows LOC; exact test nodes use test-count units.

Introduced files, introduced tests, and structurally relevant concept specialization

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 native relationship evidence on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the native relationship evidence on this page while the interactive map is unavailable.

Native relationship evidence

Every exact file and test below is linked only from the concept that introduces it.

Introduced tests

Every collected test enters the hierarchy at exactly one concept.

No tests are introduced at this concept. Its intent tests are introduced by other concepts.

Introduced code

Every collected source range enters the hierarchy at exactly one concept.

1 file ranked by introduced lines: 280 introduced LOC across 29 ranges. Expand a file to inspect source; the > gutter marks introduced lines.

src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts 280 introduced LOC · 29 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- mcpCustomizationController.ts
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import { Disposable } from '../../../../base/common/lifecycle.js';
7 > import { derived, observableValue, transaction, type IObservable, type ITransaction } from '../../../../base/common/observable.js';
8 > import { URI } from '../../../../base/common/uri.js';
9 > import { ActionType } from '../../common/state/protocol/common/actions.js';
10 > import { CustomizationType, McpServerStatus, type AhpMcpUiHostCapabilities, type ChildCustomization, type Customization, type McpServerCustomization, type McpServerState } from '../../common/state/protocol/channels-session/state.js';
11 > import { DEFAULT_MCP_APP, DEFAULT_MCP_APP_CAPABILITIES } from '../../common/state/protocol/mcpAppDefaults.js';
12 > import type { SessionAction } from '../../common/state/sessionActions.js';
13 > import { AgentHostStateManager, IAgentHostStateManager } from '../agentHostStateManager.js';
14 >
15 > /**
16 > * SDK-neutral description of a single MCP server, as the controller's
17 > * caller sees it. Each provider adapts its own SDK events into this
18 > * shape (Copilot, Claude, Codex, …) and feeds them to
19 > * {@link McpCustomizationController}.
20 > */
21 > export interface ISdkMcpServer {
22 > /** Server name (used both as the customization name and the channel suffix). */
23 > readonly name: string;
24 > /** Current lifecycle state. */
25 > readonly state: McpServerState;
26 > /** Explicit runtime enablement when the SDK distinguishes disabled from stopped. */
27 > readonly enabled?: boolean;
28 > }
29 >
30 > /**
31 > * Runtime fields of an MCP server customization that this controller
32 > * owns — the high-frequency `state`/`channel` pair. Consumers overlay
33 > * these onto their published customizations (keyed by customization id)
34 > * so a wholesale customization republish preserves live MCP status
35 > * rather than resetting it to the `Stopped` default baked into
36 > * `makeMcpServerCustomization`.
37 > */
38 > export type IMcpServerRuntimeState = Pick<McpServerCustomization, 'state' | 'channel'>;
39 >
40 > /**
41 > * Re-export so existing imports of `DEFAULT_MCP_APP_CAPABILITIES` from
42 > * the controller keep working — the canonical home is now
43 > * `agentHost/common/state/protocol/mcpAppDefaults.ts`.
44 > */
45 > export { DEFAULT_MCP_APP_CAPABILITIES, DEFAULT_MCP_APP };
46 >
47 > /**
48 > * Lookup callback the controller uses to find an existing child MCP
49 > * customization id by server name. The agent's plugin layer publishes
50 > * MCP customizations with provider-defined ids
51 > * (e.g. `pluginParsers.makeMcpServerCustomization` uses
52 > * `buildChildId(definitionUri, 'mcp=' + encodeURIComponent(name))`), so
53 > * we resolve them by name at action-dispatch time rather than trying to
54 > * reconstruct the id.
55 > *
56 > * Returns `undefined` when no existing entry matches — in that case the
57 > * controller surfaces a bare top-level customization for the server.
58 > */
59 > export type IMcpChildIdResolver = (serverName: string) => string | undefined;
60 >
61 > /**
62 > * Options for {@link McpCustomizationController}.
63 > */
64 > export interface IMcpCustomizationControllerOptions {
65 > /** Provider id (e.g. `'copilotcli'`). Used as the channel URI authority. */
66 > readonly providerId: string;
67 > /** Session id (the raw id, not the full URI). Used as the channel path segment. */
68 > readonly sessionId: string;
69 > /** Canonical session URI used to resolve persisted customization state. */
70 > readonly sessionUri: URI;
71 > /**
72 > * Resolves an existing child customization id for a given server
73 > * name. See {@link IMcpChildIdResolver}.
74 > */
75 > readonly resolveChildId: IMcpChildIdResolver;
76 > /** Emits a {@link SessionAction} into the session's action stream. */
77 > readonly emit: (action: SessionAction) => void;
78 > /**
79 > * MCP App capabilities to advertise on every ready server. Defaults
80 > * to {@link DEFAULT_MCP_APP_CAPABILITIES}.
81 > */
82 > readonly capabilities?: AhpMcpUiHostCapabilities;
83 > }
84 >
85 > interface ILiveEntry {
86 > readonly serverName: string;
87 > readonly state: McpServerState;
88 > readonly enabled: boolean;
89 > /** Top-level customization id (when no child match was found). */
90 > readonly topLevelId?: string;
91 > }
92 >
93 > export function buildMcpTopLevelCustomizationId(providerId: string, sessionId: string, serverName: string): string {
94 return `mcp-top-level:${providerId}:${sessionId}:${serverName}`;
95 }
97 > export function buildMcpChannel(providerId: string, sessionId: string, serverName: string): string {
98 return `mcp://${providerId}/${encodeURIComponent(sessionId)}/${encodeURIComponent(serverName)}`;
99 }
101 > /**
102 > * Translates a stream of SDK-reported MCP server states into AHP
103 > * customization actions:
104 > *
105 > * - For servers backed by an existing child customization (plugin- or
106 > * directory-derived), the controller emits
107 > * {@link ActionType.SessionMcpServerStateChanged} keyed on the
108 > * resolved child id. The reducer narrowly updates `state` and
109 > * `channel` on the matching child.
110 > * - For servers with no matching child (typically globally-configured
111 > * MCP servers the SDK reports), the controller emits a full
112 > * {@link ActionType.SessionCustomizationUpdated} carrying a bare
113 > * top-level {@link McpServerCustomization}. The same id is reused
114 > * across updates, so the reducer's upsert keeps in-place.
115 > *
116 > * The controller is SDK-agnostic: providers translate their own events
117 > * into {@link ISdkMcpServer} and call {@link applyAll} / {@link applyOne}.
118 > * If a provider reports a coarse {@link McpServerStatus.Starting} update
119 > * after a richer {@link McpServerStatus.AuthRequired} state, the controller
120 > * preserves the auth-required state until a definitive
121 > * {@link McpServerStatus.Ready}, {@link McpServerStatus.Error}, or
122 > * {@link McpServerStatus.Stopped} update arrives.
123 > */
124 > export class McpCustomizationController extends Disposable {
125 >
126 > /** Per-server live entries, keyed by server name. */
127 > private readonly _live = observableValue<ReadonlyMap<string, ILiveEntry>>(this, new Map());
128 >
129 > /**
130 > * Snapshot of every live server's runtime {@link IMcpServerRuntimeState},
131 > * keyed by the customization id under which it is published (the
132 > * minted top-level id, or the plugin-derived child id resolved via
133 > * {@link IMcpChildIdResolver}). Derived from {@link _live}. Callers mirror
134 > * this into their own published customizations so a wholesale republish
135 > * preserves live MCP status. Servers whose child id cannot currently be
136 > * resolved are omitted.
137 > */
138 > readonly runtimeStates: IObservable<ReadonlyMap<string, IMcpServerRuntimeState>>;
139 >
140 > constructor(
141 private readonly _options: IMcpCustomizationControllerOptions,
142 @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager,
155 });
156 }
158 > /** Snapshot for inclusion in `getSessionCustomizations()` results. */
159 > topLevelCustomizations(): readonly McpServerCustomization[] {
160 const out: McpServerCustomization[] = [];
161 for (const entry of this._live.get().values()) {
167 return out;
168 }
170 > /**
171 > * Names of MCP servers currently in {@link McpServerStatus.Ready},
172 > * paired with their channel URI. Used by providers to drive
173 > * polling-based notification streams (e.g. re-fetch `tools/list`
174 > * after a refresh hint and fire
175 > * `notifications/tools/list_changed` if the result changed).
176 > */
177 > readyChannels(): readonly { readonly serverName: string; readonly channel: string }[] {
178 const out: { serverName: string; channel: string }[] = [];
179 for (const entry of this._live.get().values()) {
188 return out;
189 }
191 > /**
192 > * Returns the customization id currently associated with the MCP
193 > * server named `serverName`, or `undefined` when no customization
194 > * exists. Top-level entries return the minted top-level id; child
195 > * entries return whatever {@link IMcpChildIdResolver} resolves to
196 > * for that server. Used by providers to tag
197 > * {@link ToolCallMcpContributor.customizationId | tool-call contributors}
198 > * so clients can correlate MCP tool calls with the originating
199 > * server customization.
200 > */
201 > customizationIdForServer(serverName: string): string | undefined {
202 const live = this._live.get().get(serverName);
203 if (live?.topLevelId !== undefined) {
206 return this._options.resolveChildId(serverName);
207 }
209 > /** Returns the live server name associated with a customization id. */
210 > serverNameForCustomizationId(id: string): string | undefined {
211 for (const entry of this._live.get().values()) {
212 const entryId = entry.topLevelId ?? this._options.resolveChildId(entry.serverName);
217 return undefined;
218 }
220 > /** Returns the last live state recorded for the MCP server named `serverName`. */
221 > stateForServer(serverName: string): McpServerState | undefined {
222 return this._live.get().get(serverName)?.state;
223 }
225 > /** Snapshot used by providers to reconcile desired and observed enablement. */
226 > serverEnablement(): readonly { readonly serverName: string; readonly customizationId: string; readonly enabled: boolean }[] {
227 const result: { serverName: string; customizationId: string; enabled: boolean }[] = [];
228 for (const entry of this._live.get().values()) {
234 return result;
235 }
237 > /**
238 > * Returns the `mcp://` AHP channel URI currently advertised for the
239 > * MCP server named `serverName`, or `undefined` when the server is
240 > * not in {@link McpServerStatus.Ready}. Used by providers to attach
241 > * the channel to MCP App `_meta.ui` so clients can route App
242 > * sub-RPCs (tools/call, resources/read, sampling/createMessage)
243 > * back through {@link IAgentHostService.handleMcpRequest}.
244 > */
245 > channelForServer(serverName: string): string | undefined {
246 const live = this._live.get().get(serverName);
247 if (!live || live.state.kind !== McpServerStatus.Ready) {
250 return this._buildChannel(serverName, live.state);
251 }
253 > /**
254 > * Replaces the live inventory with `servers`. Servers no longer
255 > * present are removed; new servers and changed servers are upserted.
256 > * Batched in a single transaction so {@link runtimeStates} observers
257 > * see one coalesced update.
258 > */
259 > applyAll(servers: readonly ISdkMcpServer[]): void {
260 transaction(tx => {
261 const seen = new Set<string>();
271 });
272 }
274 > /** Upserts a single server. */
275 > applyOne(server: ISdkMcpServer): void {
276 transaction(tx => this._applyOne(server, tx));
277 }
279 > /**
280 > * Optimistically transitions the named servers to
281 > * {@link McpServerStatus.Starting}, skipping any that are already
282 > * {@link McpServerStatus.Ready} (nothing to (re)start), blocked on
283 > * {@link McpServerStatus.AuthRequired} (needs the user, not a background
284 > * start), or already {@link McpServerStatus.Starting}.
285 > *
286 > * The SDK connects enabled servers in the background — on an explicit
287 > * start or when a turn begins — but emits no live "starting" event, so
288 > * without this a connecting server would read as its last settled state
289 > * (e.g. `Stopped`) until it resolves. Callers invoke this immediately
290 > * before the (blocking) connect so clients see the transient `Starting`
291 > * state; the subsequent SDK status settles each server. Batched in a
292 > * single transaction so {@link runtimeStates} observers see one update.
293 > */
294 > markStarting(serverNames: Iterable<string>): void {
295 transaction(tx => {
296 for (const name of serverNames) {
303 });
304 }
306 > private _applyOne(server: ISdkMcpServer, tx: ITransaction): void {
307 const previous = this._live.get().get(server.name);
308 const state = this._stateForUpdate(previous?.state, server.state);
332 });
333 }
335 > /**
336 > * Removes a server from the live inventory. For top-level entries
337 > * (bare servers with no plugin-derived child) emits
338 > * {@link ActionType.SessionCustomizationRemoved} so the entry is
339 > * dropped from session state, not just from the in-memory live
340 > * inventory.
341 > *
342 > * For child entries we emit a final {@link ActionType.SessionMcpServerStateChanged}
343 > * carrying {@link McpServerStatus.Stopped} so the UI sees the
344 > * server settle into a terminal state; the plugin layer owns the
345 > * actual removal of the child container.
346 > */
347 > remove(serverName: string): void {
348 transaction(tx => this._remove(serverName, tx));
349 }
351 > private _remove(serverName: string, tx: ITransaction): void {
352 const entry = this._live.get().get(serverName);
353 if (!entry) {
372 });
373 }
375 > // ---- internals ---------------------------------------------------------
376 >
377 > /** Immutable upsert into the {@link _live} observable. */
378 > private _setLiveEntry(serverName: string, entry: ILiveEntry, tx: ITransaction): void {
379 const next = new Map(this._live.get());
380 next.set(serverName, entry);
381 this._live.set(next, tx);
382 }
384 > /** Immutable delete from the {@link _live} observable. */
385 > private _deleteLiveEntry(serverName: string, tx: ITransaction): void {
386 const current = this._live.get();
387 if (!current.has(serverName)) {
392 this._live.set(next, tx);
393 }
395 > private _stateForUpdate(previous: McpServerState | undefined, next: McpServerState): McpServerState {
396 if (previous?.kind === McpServerStatus.AuthRequired && next.kind === McpServerStatus.Starting) {
397 return previous;
399 return next;
400 }
402 > private _mintTopLevelId(serverName: string): string {
403 return buildMcpTopLevelCustomizationId(this._options.providerId, this._options.sessionId, serverName);
404 }
406 > private _buildChannel(serverName: string, state: McpServerState): string | undefined {
407 if (state.kind !== McpServerStatus.Ready) {
408 return undefined;
410 return buildMcpChannel(this._options.providerId, this._options.sessionId, serverName);
411 }
413 > private _buildTopLevel(id: string, serverName: string, state: McpServerState, enabled: boolean): McpServerCustomization {
414 const channel = this._buildChannel(serverName, state);
415 // Per AHP spec, `mcpApp` is a static capability declaration —
433 };
434 }
436 >
437 > /**
438 > * Convenience helper: given a flat list of {@link Customization}
439 > * entries, returns the id of the first MCP child customization whose
440 > * name matches `serverName`. Used by providers to wire up
441 > * {@link IMcpCustomizationControllerOptions.resolveChildId} without
442 > * each provider having to walk the customization tree itself.
443 > */
444 > export function findMcpChildId(customizations: readonly Customization[], serverName: string): string | undefined {
445 return getMcpServerCustomizations(customizations).find(server => server.name === serverName)?.id;
446 }
448 > export function getMcpServerCustomizations(customizations: readonly Customization[]): readonly McpServerCustomization[] {
449 const result: McpServerCustomization[] = [];
450 for (const top of customizations) {
461 return result;
462 }
464 > export function getEffectiveMcpServerCustomizations(customizations: readonly Customization[]): readonly McpServerCustomization[] {
465 const result: McpServerCustomization[] = [];
466 for (const top of customizations) {
477 return result;
478 }
480 > export function applyMcpServerEnablement(customizations: readonly Customization[], desired: readonly Customization[]): readonly Customization[] {
481 const desiredById = new Map(getEffectiveMcpServerCustomizations(desired).map(server => [server.id, server.enabled]));
482 return customizations.map(customization => {
493 });
494 }
496 function applyMcpEnablement<T extends McpServerCustomization | Extract<ChildCustomization, { type: CustomizationType.McpServer }>>(customization: T, desiredById: ReadonlyMap<string, boolean>): T {
497 const enabled = desiredById.get(customization.id);
498 return enabled === undefined || enabled === customization.enabled ? customization : { ...customization, enabled };
499 }
501 > export function findMcpServerName(customizations: readonly Customization[], id: string): string | undefined {
502 return getMcpServerCustomizations(customizations).find(server => server.id === id)?.name;
503 }
505 > /**
506 > * Parsed `mcp://<providerId>/<sessionId>/<serverName>` URI as minted by
507 > * {@link McpCustomizationController}. The path segments are
508 > * URL-decoded.
509 > */
510 > export interface IMcpChannelRoute {
511 > readonly providerId: string;
512 > readonly sessionId: string;
513 > readonly serverName: string;
514 > }
515 >
516 > /**
517 > * Decodes a channel URI string into a {@link IMcpChannelRoute}, or
518 > * returns `undefined` when the URI is not an `mcp://` channel or the
519 > * path is malformed. Intentionally uses string parsing rather than
520 > * `URI.parse` so the helper stays usable from layers (e.g. agentService
521 > * test fixtures) without a full URI dependency.
522 > */
523 > export function parseMcpChannelUri(uri: string): IMcpChannelRoute | undefined {
524 const prefix = 'mcp://';
525 if (!uri.startsWith(prefix)) {