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.

1 > /*--------------------------------------------------------------------------------------------- mcpCustomizationController.ts ×29
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}`; mcpCustomizationController.ts ×1
95 > }
97 > export function buildMcpChannel(providerId: string, sessionId: string, serverName: string): string {
98 > return `mcp://${providerId}/${encodeURIComponent(sessionId)}/${encodeURIComponent(serverName)}`; mcpCustomizationController.ts ×1
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, mcpCustomizationController.ts ×2
142 > @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager,
143 > ) {
144 > super();
145 > this.runtimeStates = derived(this, reader => {
146 > const out = new Map<string, IMcpServerRuntimeState>(); mcpCustomizationController.ts ×2
147 > for (const entry of this._live.read(reader).values()) {
148 > const id = entry.topLevelId ?? this._options.resolveChildId(entry.serverName); mcpCustomizationController.ts ×2
149 > if (id === undefined) {
150 continue;
151 }
152 > out.set(id, { state: entry.state, channel: this._buildChannel(entry.serverName, entry.state) }); mcpCustomizationController.ts ×2
153 > }
156 > }
158 > /** Snapshot for inclusion in `getSessionCustomizations()` results. */
159 > topLevelCustomizations(): readonly McpServerCustomization[] {
160 > const out: McpServerCustomization[] = []; mcpCustomizationController.ts ×2
161 > for (const entry of this._live.get().values()) {
162 > if (entry.topLevelId === undefined) { mcpCustomizationController.ts ×1
164 > }
165 > out.push(this._buildTopLevel(entry.topLevelId, entry.serverName, entry.state, entry.enabled)); mcpCustomizationController.ts ×1
166 > }
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()) {
180 if (entry.state.kind !== McpServerStatus.Ready) {
181 continue;
182 }
183 const channel = this._buildChannel(entry.serverName, entry.state);
184 if (channel !== undefined) {
185 out.push({ serverName: entry.serverName, channel });
186 }
187 }
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); copilotAgentSession.ts ×3
203 > if (live?.topLevelId !== undefined) {
204 > return live.topLevelId; copilotAgentSession.ts ×2
205 > }
206 > return this._options.resolveChildId(serverName); mcpCustomizationController.ts ×1
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()) { mcpCustomizationController.ts ×2
212 > const entryId = entry.topLevelId ?? this._options.resolveChildId(entry.serverName);
213 > if (entryId === id) {
214 > return entry.serverName;
215 > }
216 > }
217 return undefined;
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; copilotAgentSession.ts ×2
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 }[] = []; copilotAgentSession.ts ×3
228 > for (const entry of this._live.get().values()) {
229 > const customizationId = entry.topLevelId ?? this._options.resolveChildId(entry.serverName);
230 > if (customizationId !== undefined) {
231 > result.push({ serverName: entry.serverName, customizationId, enabled: entry.enabled });
232 > }
233 > }
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); copilotAgentSession.ts ×2
247 > if (!live || live.state.kind !== McpServerStatus.Ready) {
248 > return undefined; mcpCustomizationController.ts ×1
249 > }
250 > return this._buildChannel(serverName, live.state); copilotAgentSession.ts ×2
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 => { mcpCustomizationController.ts ×3
261 > const seen = new Set<string>();
262 > for (const server of servers) {
263 > seen.add(server.name); mcpCustomizationController.ts ×3
264 > this._applyOne(server, tx);
265 > }
266 > for (const name of [...this._live.get().keys()]) { mcpCustomizationController.ts ×3
267 > if (!seen.has(name)) { mcpCustomizationController.ts ×3
268 > this._remove(name, tx); mcpCustomizationController.ts ×1
269 > }
272 > }
274 > /** Upserts a single server. */
275 > applyOne(server: ISdkMcpServer): void {
276 > transaction(tx => this._applyOne(server, tx)); mcpCustomizationController.ts ×1
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 => { mcpCustomizationController.ts ×2
296 > for (const name of serverNames) {
297 > const previous = this._live.get().get(name)?.state.kind;
298 > if (previous === McpServerStatus.Ready || previous === McpServerStatus.AuthRequired || previous === McpServerStatus.Starting) {
300 > }
301 > this._applyOne({ name, state: { kind: McpServerStatus.Starting } }, tx); mcpCustomizationController.ts ×2
302 > }
303 > });
304 > }
306 > private _applyOne(server: ISdkMcpServer, tx: ITransaction): void {
307 > const previous = this._live.get().get(server.name); mcpCustomizationController.ts ×7
308 > const state = this._stateForUpdate(previous?.state, server.state);
309 > const enabled = server.enabled ?? previous?.enabled ?? true;
310 > // Once promoted to a top-level entry, stay top-level for the
311 > // session — flipping back to a child mid-stream would orphan the
312 > // previously-published top-level id.
313 > let topLevelId = previous?.topLevelId;
314 > if (topLevelId === undefined) {
315 > const childId = this._options.resolveChildId(server.name);
316 > if (childId !== undefined) {
317 > this._setLiveEntry(server.name, { serverName: server.name, state, enabled, topLevelId: undefined }, tx); mcpCustomizationController.ts ×1
318 > this._options.emit({
319 > type: ActionType.SessionMcpServerStateChanged,
320 > id: childId,
321 > state,
322 > channel: this._buildChannel(server.name, state),
323 > });
324 > return;
325 > }
326 > topLevelId = this._mintTopLevelId(server.name); mcpCustomizationController.ts ×4
327 > }
328 > this._setLiveEntry(server.name, { serverName: server.name, state, enabled, topLevelId }, tx);
329 > this._options.emit({
330 > type: ActionType.SessionCustomizationUpdated,
331 > customization: this._buildTopLevel(topLevelId, server.name, state, enabled),
332 > });
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)); mcpCustomizationController.ts ×1
349 > }
351 > private _remove(serverName: string, tx: ITransaction): void {
352 > const entry = this._live.get().get(serverName); mcpCustomizationController.ts ×5
353 > if (!entry) {
354 return;
355 }
356 > this._deleteLiveEntry(serverName, tx); mcpCustomizationController.ts ×5
357 > if (entry.topLevelId !== undefined) {
358 > this._options.emit({ mcpCustomizationController.ts ×1
359 > type: ActionType.SessionCustomizationRemoved,
360 > id: entry.topLevelId,
361 > });
362 > return;
363 > }
364 > const childId = this._options.resolveChildId(serverName); mcpCustomizationController.ts ×2
365 > if (childId === undefined) {
366 return;
367 }
368 > this._options.emit({ mcpCustomizationController.ts ×2
369 > type: ActionType.SessionMcpServerStateChanged,
370 > id: childId,
371 > state: { kind: McpServerStatus.Stopped },
372 > });
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()); mcpCustomizationController.ts ×7
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(); mcpCustomizationController.ts ×5
387 > if (!current.has(serverName)) {
388 return;
389 }
390 > const next = new Map(current); mcpCustomizationController.ts ×5
391 > next.delete(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) { mcpCustomizationController.ts ×7
397 > return previous; mcpCustomizationController.ts ×1
398 > }
400 > }
402 > private _mintTopLevelId(serverName: string): string {
403 > return buildMcpTopLevelCustomizationId(this._options.providerId, this._options.sessionId, serverName); mcpCustomizationController.ts ×4
404 > }
406 > private _buildChannel(serverName: string, state: McpServerState): string | undefined {
407 > if (state.kind !== McpServerStatus.Ready) { mcpCustomizationController.ts ×7
408 > return undefined; mcpCustomizationController.ts ×1
409 > }
410 > return buildMcpChannel(this._options.providerId, this._options.sessionId, serverName); mcpCustomizationController.ts ×1
413 > private _buildTopLevel(id: string, serverName: string, state: McpServerState, enabled: boolean): McpServerCustomization {
414 > const channel = this._buildChannel(serverName, state); mcpCustomizationController.ts ×4
415 > // Per AHP spec, `mcpApp` is a static capability declaration —
416 > // "SHOULD be present whenever the server can host Apps". We
417 > // proxy every MCP server uniformly, so advertise the host's
418 > // capability set regardless of runtime `state`. Clients gate
419 > // rendering on `state.kind === Ready` + `channel` themselves.
420 > const mcpApp = this._options.capabilities
421 ? { capabilities: this._options.capabilities }
422 > : DEFAULT_MCP_APP; mcpCustomizationController.ts ×4
423 > return {
424 > type: CustomizationType.McpServer,
425 > id,
426 > uri: this._mintTopLevelId(serverName),
427 > name: serverName,
428 > enabled: getEffectiveMcpServerCustomizations(this._stateManager.getSessionState(this._options.sessionUri.toString())?.customizations ?? [])
429 > .find(customization => customization.id === id)?.enabled ?? enabled,
430 > state,
431 > channel,
432 > mcpApp,
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; mcpCustomizationController.ts ×1
446 > }
448 > export function getMcpServerCustomizations(customizations: readonly Customization[]): readonly McpServerCustomization[] {
449 > const result: McpServerCustomization[] = []; mcpCustomizationController.ts ×2
450 > for (const top of customizations) {
451 > if (top.type === CustomizationType.McpServer) { mcpCustomizationController.ts ×2
452 > result.push(top); mcpCustomizationController.ts ×1
454 > for (const child of top.children ?? []) {
455 > if (child.type === CustomizationType.McpServer) {
456 > result.push(child);
457 > }
458 > }
459 > }
460 > }
461 > return result; mcpCustomizationController.ts ×2
462 > }
464 > export function getEffectiveMcpServerCustomizations(customizations: readonly Customization[]): readonly McpServerCustomization[] {
465 > const result: McpServerCustomization[] = []; mcpCustomizationController.ts ×2
466 > for (const top of customizations) {
467 > if (top.type === CustomizationType.McpServer) { mcpCustomizationController.ts ×3
468 > result.push(top); mcpCustomizationController.ts ×1
470 > for (const child of top.children ?? []) { mcpCustomizationController.ts ×2
471 > if (child.type === CustomizationType.McpServer) { mcpCustomizationController.ts ×2
472 > result.push(top.enabled ? child : { ...child, enabled: false }); mcpCustomizationController.ts ×1
473 > }
477 > return result; mcpCustomizationController.ts ×2
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])); mcpCustomizationController.ts ×3
482 > return customizations.map(customization => {
483 > if (customization.type === CustomizationType.McpServer) {
484 > return applyMcpEnablement(customization, desiredById); claudeSdkPipeline.ts ×5
485 > }
486 > let changed = false; mcpCustomizationController.ts ×3
487 > const children = customization.children?.map(child => {
488 > const next = child.type === CustomizationType.McpServer ? applyMcpEnablement(child, desiredById) : child; mcpCustomizationController.ts ×1
489 > changed ||= next !== child;
490 > return next;
492 > return changed ? { ...customization, children } : customization;
493 > });
494 > }
496 > function applyMcpEnablement<T extends McpServerCustomization | Extract<ChildCustomization, { type: CustomizationType.McpServer }>>(customization: T, desiredById: ReadonlyMap<string, boolean>): T { claudeSdkPipeline.ts ×5
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; mcpCustomizationController.ts ×1
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://'; mcpCustomizationController.ts ×5
525 > if (!uri.startsWith(prefix)) {
526 > return undefined; mcpCustomizationController.ts ×4
527 > }
528 > const rest = uri.slice(prefix.length); mcpCustomizationController.ts ×5
529 > const slash = rest.indexOf('/');
530 > if (slash <= 0) {
531 > return undefined; mcpCustomizationController.ts ×4
532 > }
533 > const providerId = rest.slice(0, slash); mcpCustomizationController.ts ×5
534 > const tail = rest.slice(slash + 1);
535 > const sep = tail.indexOf('/');
536 > if (sep <= 0 || sep === tail.length - 1) {
537 > return undefined; mcpCustomizationController.ts ×4
538 > }
539 > let sessionId: string; mcpCustomizationController.ts ×5
540 > let serverName: string;
541 > try {
542 > // `decodeURIComponent` throws `URIError` on malformed percent
543 > // escapes (e.g. a lone `%`). Treat any decode failure as a
544 > // malformed channel rather than letting it escape — the caller
545 > // translates `undefined` into a clean `Method not found`.
546 > sessionId = decodeURIComponent(tail.slice(0, sep));
547 > serverName = decodeURIComponent(tail.slice(sep + 1));
548 > } catch {
549 > return undefined; mcpCustomizationController.ts ×4
550 > }
551 > if (!providerId || !sessionId || !serverName) { mcpCustomizationController.ts ×5
552 return undefined;
553 }
554 > return { providerId, sessionId, serverName }; mcpCustomizationController.ts ×1
555 > }