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