Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/mcp/vscode-node/commands.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
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 * as vscode from 'vscode';
7
import { ChatFetchResponseType } from '../../../platform/chat/common/commonTypes';
8
import { JsonSchema } from '../../../platform/configuration/common/jsonSchema';
9
import { ILogService } from '../../../platform/log/common/logService';
10
import { IFetcherService } from '../../../platform/networking/common/fetcherService';
11
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';
12
import { createSha256Hash } from '../../../util/common/crypto';
13
import { extractCodeBlocks } from '../../../util/common/markdown';
14
import { mapFindFirst } from '../../../util/vs/base/common/arraysFind';
15
import { DeferredPromise, raceCancellation } from '../../../util/vs/base/common/async';
16
import { CancellationTokenSource } from '../../../util/vs/base/common/cancellation';
17
import { Disposable, toDisposable } from '../../../util/vs/base/common/lifecycle';
18
import { cloneAndChange } from '../../../util/vs/base/common/objects';
19
import { StopWatch } from '../../../util/vs/base/common/stopwatch';
20
import { generateUuid } from '../../../util/vs/base/common/uuid';
21
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
22
import { ChatLocation as VsCodeChatLocation } from '../../../vscodeTypes';
23
import { Conversation, Turn } from '../../prompt/common/conversation';
24
import { McpToolCallingLoop } from './mcpToolCallingLoop';
25
import { McpPickRef } from './mcpToolCallingTools';
26
import { IInstallableMcpServer, IMcpServerVariable, IMcpStdioServerConfiguration, NuGetMcpSetup } from './nuget';
27
28
export type PackageType = 'npm' | 'pip' | 'docker' | 'nuget';
29
30
export interface IValidatePackageArgs {
31
type: PackageType;
32
name: string;
33
targetConfig: JsonSchema;
34
}
35
36
interface PromptStringInputInfo {
37
id: string;
38
type: 'promptString';
39
description: string;
40
default?: string;
41
password?: boolean;
42
}
43
44
export interface IPendingSetupArgs {
45
name: string;
46
version?: string;
47
readme?: string;
48
getMcpServer?(installConsent: Promise<void>): Promise<Omit<IInstallableMcpServer, 'name'> | undefined>;
49
}
50
51
export const enum ValidatePackageErrorType {
52
NotFound = 'NotFound',
53
UnknownPackageType = 'UnknownPackageType',
54
UnhandledError = 'UnhandledError',
55
MissingCommand = 'MissingCommand',
56
BadCommandVersion = 'BadCommandVersion',
57
}
58
59
const enum FlowFinalState {
60
Done = 'Done',
61
Failed = 'Failed',
62
NameMismatch = 'NameMismatch',
63
}
64
65
// contract with https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/mcp/browser/mcpCommandsAddConfiguration.ts
66
export type ValidatePackageResult =
67
{ state: 'ok'; publisher: string; name?: string; version?: string } & IPendingSetupArgs
68
| { state: 'error'; error: string; helpUri?: string; helpUriLabel?: string; errorType: ValidatePackageErrorType };
69
70
type AssistedServerConfiguration = {
71
type: 'assisted';
72
name?: string;
73
server: any;
74
inputs: PromptStringInputInfo[];
75
inputValues: Record<string, string> | undefined;
76
} | {
77
type: 'mapped';
78
name?: string;
79
server: Omit<IMcpStdioServerConfiguration, 'type'>;
80
inputs?: IMcpServerVariable[];
81
};
82
83
interface NpmPackageResponse {
84
maintainers?: Array<{ name: string }>;
85
readme?: string;
86
'dist-tags'?: { latest?: string };
87
}
88
89
interface PyPiPackageResponse {
90
info?: {
91
author?: string;
92
author_email?: string;
93
description?: string;
94
name?: string;
95
version?: string;
96
};
97
}
98
99
interface DockerHubResponse {
100
user?: string;
101
name?: string;
102
namespace?: string;
103
description?: string;
104
full_description?: string;
105
}
106
107
export class McpSetupCommands extends Disposable {
108
private pendingSetup?: {
109
cts: CancellationTokenSource;
110
canPrompt: DeferredPromise<void>;
111
done: Promise<AssistedServerConfiguration | undefined>;
112
stopwatch: StopWatch; // since the validation began, may include waiting for the user,
113
validateArgs: IValidatePackageArgs;
114
pendingArgs: IPendingSetupArgs;
115
};
116
117
constructor(
118
@ITelemetryService private readonly telemetryService: ITelemetryService,
119
@ILogService private readonly logService: ILogService,
120
@IFetcherService private readonly fetcherService: IFetcherService,
121
@IInstantiationService private readonly instantiationService: IInstantiationService,
122
) {
123
super();
124
this._register(toDisposable(() => this.pendingSetup?.cts.dispose(true)));
125
this._register(vscode.commands.registerCommand('github.copilot.chat.mcp.setup.flow', async (args: { name: string }) => {
126
let finalState = FlowFinalState.Failed;
127
let result;
128
try {
129
// allow case-insensitive comparison
130
if (this.pendingSetup?.pendingArgs.name.toUpperCase() !== args.name.toUpperCase()) {
131
finalState = FlowFinalState.NameMismatch;
132
vscode.window.showErrorMessage(vscode.l10n.t("Failed to generate MCP server configuration with a matching package name. Expected '{0}' but got '{1}' from generated configuration.", args.name, this.pendingSetup?.pendingArgs.name ?? ''));
133
return undefined;
134
}
135
136
this.pendingSetup.canPrompt.complete(undefined);
137
result = await this.pendingSetup.done;
138
finalState = FlowFinalState.Done;
139
return result;
140
} finally {
141
/* __GDPR__
142
"mcp.setup.flow" : {
143
"owner": "joelverhagen",
144
"comment": "Reports the result of the agent-assisted MCP server installation",
145
"finalState": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The final state of the installation (e.g., 'Done', 'Failed')" },
146
"configurationType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Generic configuration typed produced by the installation" },
147
"packageType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Package type (e.g., npm)" },
148
"packageName": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Package name used for installation" },
149
"packageVersion": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Package version" },
150
"durationMs": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Duration of the installation process in milliseconds" }
151
}
152
*/
153
this.telemetryService.sendMSFTTelemetryEvent('mcp.setup.flow', {
154
finalState: finalState,
155
configurationType: result?.type,
156
packageType: this.pendingSetup?.validateArgs.type,
157
packageName: await this.lowerHash(this.pendingSetup?.pendingArgs.name || args.name),
158
packageVersion: this.pendingSetup?.pendingArgs.version,
159
}, {
160
durationMs: this.pendingSetup?.stopwatch.elapsed() ?? -1
161
});
162
}
163
}));
164
this._register(vscode.commands.registerCommand('github.copilot.chat.mcp.setup.validatePackage', async (args: IValidatePackageArgs): Promise<ValidatePackageResult> => {
165
const sw = new StopWatch();
166
const result = await McpSetupCommands.validatePackageRegistry(args, this.logService, this.fetcherService);
167
if (result.state === 'ok') {
168
this.enqueuePendingSetup(args, result, sw);
169
}
170
171
/* __GDPR__
172
"mcp.setup.validatePackage" : {
173
"owner": "joelverhagen",
174
"comment": "Reports success or failure of agent-assisted MCP server validation step",
175
"state": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Validation state of the package" },
176
"packageType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Package type (e.g., npm)" },
177
"packageName": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Package name used for installation" },
178
"packageVersion": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Package version" },
179
"errorType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Generic type of error encountered during validation" },
180
"durationMs": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Duration of the validation process in milliseconds" }
181
}
182
*/
183
this.telemetryService.sendMSFTTelemetryEvent(
184
'mcp.setup.validatePackage',
185
result.state === 'ok' ?
186
{
187
state: result.state,
188
packageType: args.type,
189
packageName: await this.lowerHash(result.name || args.name),
190
packageVersion: result.version
191
} :
192
{
193
state: result.state,
194
packageType: args.type,
195
packageName: await this.lowerHash(args.name),
196
errorType: result.errorType
197
},
198
{ durationMs: sw.elapsed() });
199
200
// return the minimal result to avoid leaking implementation details
201
// not all package information is needed to request consent to install the package
202
return result.state === 'ok' ?
203
{ state: 'ok', publisher: result.publisher, name: result.name, version: result.version } :
204
{ state: 'error', error: result.error, helpUri: result.helpUri, helpUriLabel: result.helpUriLabel, errorType: result.errorType };
205
}));
206
this._register(vscode.commands.registerCommand('github.copilot.chat.mcp.setup.check', () => {
207
return 1;
208
}));
209
}
210
211
private async lowerHash(input: string | undefined) {
212
return input ? await createSha256Hash(input.toLowerCase()) : undefined;
213
}
214
215
private async enqueuePendingSetup(validateArgs: IValidatePackageArgs, pendingArgs: IPendingSetupArgs, sw: StopWatch) {
216
const cts = new CancellationTokenSource();
217
const canPrompt = new DeferredPromise<void>();
218
const pickRef = new McpPickRef(raceCancellation(canPrompt.p, cts.token));
219
220
// we start doing the prompt in the background so the first call is speedy
221
const done = (async () => {
222
223
// if the package has a server manifest, we can fetch it and use it instead of a tool loop
224
if (pendingArgs.getMcpServer) {
225
let mcpServer: Omit<IInstallableMcpServer, 'name'> | undefined;
226
try {
227
mcpServer = await pendingArgs.getMcpServer(canPrompt.p);
228
} catch (error) {
229
this.logService.warn(`Unable to fetch MCP server configuration for ${validateArgs.type} package ${pendingArgs.name}@${pendingArgs.version}. Configuration will be generated from the package README.
230
Error: ${error}`);
231
}
232
233
if (mcpServer) {
234
return {
235
type: 'mapped' as const,
236
name: pendingArgs.name,
237
server: mcpServer.config as Omit<IMcpStdioServerConfiguration, 'type'>,
238
inputs: mcpServer.inputs
239
};
240
}
241
}
242
243
const fakePrompt = `Generate an MCP configuration for ${validateArgs.name}`;
244
const mcpLoop = this.instantiationService.createInstance(McpToolCallingLoop, {
245
toolCallLimit: 100, // limited via `getAvailableTools` in the loop
246
conversation: new Conversation(generateUuid(), [new Turn(undefined, { type: 'user', message: fakePrompt })]),
247
request: {
248
attempt: 0,
249
enableCommandDetection: false,
250
isParticipantDetected: false,
251
location: VsCodeChatLocation.Panel,
252
command: undefined,
253
location2: undefined,
254
// note: this is not used, model is hardcoded in the McpToolCallingLoop
255
model: (await vscode.lm.selectChatModels())[0],
256
prompt: fakePrompt,
257
references: [],
258
toolInvocationToken: generateUuid() as never,
259
toolReferences: [],
260
tools: new Map(),
261
id: '1',
262
sessionId: '',
263
sessionResource: vscode.Uri.parse('chat:/1'),
264
hasHooksEnabled: false,
265
},
266
props: {
267
targetSchema: validateArgs.targetConfig,
268
packageName: pendingArgs.name, // prefer the resolved name, not the input
269
packageVersion: pendingArgs.version,
270
packageType: validateArgs.type,
271
pickRef,
272
packageReadme: pendingArgs.readme || '<empty>',
273
},
274
});
275
276
const toolCallLoopResult = await mcpLoop.run(undefined, cts.token);
277
if (toolCallLoopResult.response.type !== ChatFetchResponseType.Success) {
278
vscode.window.showErrorMessage(vscode.l10n.t("Failed to generate MCP configuration for {0}: {1}", validateArgs.name, toolCallLoopResult.response.reason));
279
return undefined;
280
}
281
282
const { name, ...server } = mapFindFirst(extractCodeBlocks(toolCallLoopResult.response.value), block => {
283
try {
284
const j = JSON.parse(block.code);
285
286
// Unwrap if the model returns `mcpServers` in a wrapper object
287
if (j && typeof j === 'object' && j.hasOwnProperty('mcpServers')) {
288
const [name, obj] = Object.entries(j.mcpServers)[0] as [string, object];
289
return { ...obj, name };
290
}
291
292
return j;
293
} catch {
294
return undefined;
295
}
296
});
297
298
const inputs: PromptStringInputInfo[] = [];
299
let inputValues: Record<string, string> | undefined;
300
const extracted = cloneAndChange(server, value => {
301
if (typeof value === 'string') {
302
const fromInput = pickRef.picks.find(p => p.choice === value);
303
if (fromInput) {
304
inputs.push({ id: fromInput.id, type: 'promptString', description: fromInput.title });
305
inputValues ??= {};
306
const replacement = '${input:' + fromInput.id + '}';
307
inputValues[replacement] = value;
308
return replacement;
309
}
310
}
311
});
312
313
return { type: 'assisted' as const, name, server: extracted, inputs, inputValues };
314
})().finally(() => {
315
cts.dispose();
316
pickRef.dispose();
317
});
318
319
this.pendingSetup?.cts.dispose(true);
320
this.pendingSetup = { cts, canPrompt, done, validateArgs, pendingArgs, stopwatch: sw };
321
}
322
323
public static async validatePackageRegistry(args: { type: PackageType; name: string }, logService: ILogService, fetcherService: IFetcherService): Promise<ValidatePackageResult> {
324
try {
325
if (args.type === 'npm') {
326
const response = await fetcherService.fetch(`https://registry.npmjs.org/${encodeURIComponent(args.name)}`, { method: 'GET', callSite: 'mcp-npm-registry' });
327
if (!response.ok) {
328
return { state: 'error', errorType: ValidatePackageErrorType.NotFound, error: vscode.l10n.t("Package {0} not found in npm registry", args.name) };
329
}
330
const data = await response.json() as NpmPackageResponse;
331
const version = data['dist-tags']?.latest;
332
return {
333
state: 'ok',
334
publisher: data.maintainers?.[0]?.name || 'unknown',
335
name: args.name,
336
version,
337
readme: data.readme,
338
};
339
} else if (args.type === 'pip') {
340
const response = await fetcherService.fetch(`https://pypi.org/pypi/${encodeURIComponent(args.name)}/json`, { method: 'GET', callSite: 'mcp-pypi-registry' });
341
if (!response.ok) {
342
return { state: 'error', errorType: ValidatePackageErrorType.NotFound, error: vscode.l10n.t("Package {0} not found in PyPI registry", args.name) };
343
}
344
const data = await response.json() as PyPiPackageResponse;
345
const publisher = data.info?.author || data.info?.author_email || 'unknown';
346
const name = data.info?.name || args.name;
347
const version = data.info?.version;
348
return {
349
state: 'ok',
350
publisher,
351
name,
352
version,
353
readme: data.info?.description
354
};
355
} else if (args.type === 'nuget') {
356
const nuGetMcpSetup = new NuGetMcpSetup(logService, fetcherService);
357
return await nuGetMcpSetup.getNuGetPackageMetadata(args.name);
358
} else if (args.type === 'docker') {
359
// Docker Hub API uses namespace/repository format
360
// Handle both formats: 'namespace/repository' or just 'repository' (assumes 'library/' namespace)
361
const [namespace, repository] = args.name.includes('/')
362
? args.name.split('/', 2)
363
: ['library', args.name];
364
365
const response = await fetcherService.fetch(`https://hub.docker.com/v2/repositories/${encodeURIComponent(namespace)}/${encodeURIComponent(repository)}`, { method: 'GET', callSite: 'mcp-docker-registry' });
366
if (!response.ok) {
367
return { state: 'error', errorType: ValidatePackageErrorType.NotFound, error: vscode.l10n.t("Docker image {0} not found in Docker Hub registry", args.name) };
368
}
369
const data = await response.json() as DockerHubResponse;
370
return {
371
state: 'ok',
372
publisher: data.namespace || data.user || 'unknown',
373
name: args.name,
374
readme: data.full_description || data.description,
375
};
376
}
377
return { state: 'error', error: vscode.l10n.t("Unsupported package type: {0}", args.type), errorType: ValidatePackageErrorType.UnknownPackageType };
378
} catch (error) {
379
return { state: 'error', error: vscode.l10n.t("Error querying package: {0}", (error as Error).message), errorType: ValidatePackageErrorType.UnhandledError };
380
}
381
}
382
}
383
384