Path: blob/main/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts
5302 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 { Action } from '../../../../base/common/actions.js';6import { assertNever, softAssertNever } from '../../../../base/common/assert.js';7import { CancellationToken } from '../../../../base/common/cancellation.js';8import { CancellationError } from '../../../../base/common/errors.js';9import { MarkdownString } from '../../../../base/common/htmlContent.js';10import { DisposableStore } from '../../../../base/common/lifecycle.js';11import { autorun } from '../../../../base/common/observable.js';12import { isDefined } from '../../../../base/common/types.js';13import { URI } from '../../../../base/common/uri.js';14import { localize } from '../../../../nls.js';15import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';16import { IOpenerService } from '../../../../platform/opener/common/opener.js';17import { IQuickInputService, IQuickPick, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';18import { ChatElicitationRequestPart } from '../../chat/common/model/chatProgressTypes/chatElicitationRequestPart.js';19import { ChatModel } from '../../chat/common/model/chatModel.js';20import { ElicitationState, IChatService } from '../../chat/common/chatService/chatService.js';21import { LocalChatSessionUri } from '../../chat/common/model/chatUri.js';22import { ElicitationKind, ElicitResult, IFormModeElicitResult, IMcpElicitationService, IMcpServer, IMcpToolCallContext, IUrlModeElicitResult, McpConnectionState, MpcResponseError } from '../common/mcpTypes.js';23import { mcpServerToSourceData } from '../common/mcpTypesUtils.js';24import { MCP } from '../common/modelContextProtocol.js';2526const noneItem: IQuickPickItem = { id: undefined, label: localize('mcp.elicit.enum.none', 'None'), description: localize('mcp.elicit.enum.none.description', 'No selection'), alwaysShow: true };2728type Pre20251125ElicitationParams = Omit<MCP.ElicitRequestFormParams, 'mode'> & { mode?: undefined };2930function isFormElicitation(params: MCP.ElicitRequest['params'] | Pre20251125ElicitationParams): params is (MCP.ElicitRequestFormParams | Pre20251125ElicitationParams) {31return params.mode === 'form' || (params.mode === undefined && !!(params as Pre20251125ElicitationParams).requestedSchema);32}3334function isUrlElicitation(params: MCP.ElicitRequest['params']): params is MCP.ElicitRequestURLParams {35return params.mode === 'url';36}3738function isLegacyTitledEnumSchema(schema: MCP.PrimitiveSchemaDefinition): schema is MCP.LegacyTitledEnumSchema & { enumNames: string[] } {39const cast = schema as MCP.LegacyTitledEnumSchema;40return cast.type === 'string' && Array.isArray(cast.enum) && Array.isArray(cast.enumNames);41}4243function isUntitledEnumSchema(schema: MCP.PrimitiveSchemaDefinition): schema is MCP.LegacyTitledEnumSchema | MCP.UntitledSingleSelectEnumSchema {44const cast = schema as MCP.LegacyTitledEnumSchema | MCP.UntitledSingleSelectEnumSchema;45return cast.type === 'string' && Array.isArray(cast.enum);46}4748function isTitledSingleEnumSchema(schema: MCP.PrimitiveSchemaDefinition): schema is MCP.TitledSingleSelectEnumSchema {49const cast = schema as MCP.TitledSingleSelectEnumSchema;50return cast.type === 'string' && Array.isArray(cast.oneOf);51}5253function isUntitledMultiEnumSchema(schema: MCP.PrimitiveSchemaDefinition): schema is MCP.UntitledMultiSelectEnumSchema {54const cast = schema as MCP.UntitledMultiSelectEnumSchema;55return cast.type === 'array' && !!cast.items?.enum;56}5758function isTitledMultiEnumSchema(schema: MCP.PrimitiveSchemaDefinition): schema is MCP.TitledMultiSelectEnumSchema {59const cast = schema as MCP.TitledMultiSelectEnumSchema;60return cast.type === 'array' && !!cast.items?.anyOf;61}6263export class McpElicitationService implements IMcpElicitationService {64declare readonly _serviceBrand: undefined;6566constructor(67@INotificationService private readonly _notificationService: INotificationService,68@IQuickInputService private readonly _quickInputService: IQuickInputService,69@IChatService private readonly _chatService: IChatService,70@IOpenerService private readonly _openerService: IOpenerService,71) { }7273public elicit(server: IMcpServer, context: IMcpToolCallContext | undefined, elicitation: MCP.ElicitRequest['params'], token: CancellationToken): Promise<ElicitResult> {74if (isFormElicitation(elicitation)) {75return this._elicitForm(server, context, elicitation, token);76} else if (isUrlElicitation(elicitation)) {77return this._elicitUrl(server, context, elicitation, token);78} else {79softAssertNever(elicitation);80return Promise.reject(new MpcResponseError('Unsupported elicitation type', MCP.INVALID_PARAMS, undefined));81}82}8384private async _elicitForm(server: IMcpServer, context: IMcpToolCallContext | undefined, elicitation: MCP.ElicitRequestFormParams | Pre20251125ElicitationParams, token: CancellationToken): Promise<IFormModeElicitResult> {85const store = new DisposableStore();86const value = await new Promise<MCP.ElicitResult>(resolve => {87const chatModel = context?.chatSessionId && this._chatService.getSession(LocalChatSessionUri.forSession(context.chatSessionId));88if (chatModel instanceof ChatModel) {89const request = chatModel.getRequests().at(-1);90if (request) {91const part = new ChatElicitationRequestPart(92localize('mcp.elicit.title', 'Request for Input'),93elicitation.message,94localize('msg.subtitle', "{0} (MCP Server)", server.definition.label),95localize('mcp.elicit.accept', 'Respond'),96localize('mcp.elicit.reject', 'Cancel'),97async () => {98const p = this._doElicitForm(elicitation, token);99resolve(p);100const result = await p;101part.acceptedResult = result.content;102return result.action === 'accept' ? ElicitationState.Accepted : ElicitationState.Rejected;103},104() => {105resolve({ action: 'decline' });106return Promise.resolve(ElicitationState.Rejected);107},108mcpServerToSourceData(server),109);110chatModel.acceptResponseProgress(request, part);111}112} else {113const handle = this._notificationService.notify({114message: elicitation.message,115source: localize('mcp.elicit.source', 'MCP Server ({0})', server.definition.label),116severity: Severity.Info,117actions: {118primary: [store.add(new Action('mcp.elicit.give', localize('mcp.elicit.give', 'Respond'), undefined, true, () => resolve(this._doElicitForm(elicitation, token))))],119secondary: [store.add(new Action('mcp.elicit.cancel', localize('mcp.elicit.cancel', 'Cancel'), undefined, true, () => resolve({ action: 'decline' })))],120}121});122store.add(handle.onDidClose(() => resolve({ action: 'cancel' })));123store.add(token.onCancellationRequested(() => resolve({ action: 'cancel' })));124}125126}).finally(() => store.dispose());127128return { kind: ElicitationKind.Form, value, dispose: () => { } };129}130131private async _elicitUrl(server: IMcpServer, context: IMcpToolCallContext | undefined, elicitation: MCP.ElicitRequestURLParams, token: CancellationToken): Promise<IUrlModeElicitResult> {132const promiseStore = new DisposableStore();133134// We create this ahead of time in case e.g. a user manually opens the URL beforehand135const completePromise = new Promise<void>((resolve, reject) => {136promiseStore.add(token.onCancellationRequested(() => reject(new CancellationError())));137promiseStore.add(autorun(reader => {138const cnx = server.connection.read(reader);139const handler = cnx?.handler.read(reader);140if (handler) {141reader.store.add(handler.onDidReceiveElicitationCompleteNotification(e => {142if (e.params.elicitationId === elicitation.elicitationId) {143resolve();144}145}));146} else if (!McpConnectionState.isRunning(server.connectionState.read(reader))) {147reject(new CancellationError());148}149}));150}).finally(() => promiseStore.dispose());151152const store = new DisposableStore();153const value = await new Promise<MCP.ElicitResult>(resolve => {154const chatModel = context?.chatSessionId && this._chatService.getSession(LocalChatSessionUri.forSession(context.chatSessionId));155if (chatModel instanceof ChatModel) {156const request = chatModel.getRequests().at(-1);157if (request) {158const part = new ChatElicitationRequestPart(159localize('mcp.elicit.url.title', 'Authorization Required'),160new MarkdownString().appendText(elicitation.message)161.appendMarkdown('\n\n' + localize('mcp.elicit.url.instruction', 'Open this URL?'))162.appendCodeblock('', elicitation.url),163localize('msg.subtitle', "{0} (MCP Server)", server.definition.label),164localize('mcp.elicit.url.open', 'Open {0}', URI.parse(elicitation.url).authority),165localize('mcp.elicit.reject', 'Cancel'),166async () => {167const result = await this._doElicitUrl(elicitation, token);168resolve(result);169completePromise.then(() => part.hide());170return result.action === 'accept' ? ElicitationState.Accepted : ElicitationState.Rejected;171},172() => {173resolve({ action: 'decline' });174return Promise.resolve(ElicitationState.Rejected);175},176mcpServerToSourceData(server),177);178chatModel.acceptResponseProgress(request, part);179}180} else {181const handle = this._notificationService.notify({182message: elicitation.message + ' ' + localize('mcp.elicit.url.instruction2', 'This will open {0}', elicitation.url),183source: localize('mcp.elicit.source', 'MCP Server ({0})', server.definition.label),184severity: Severity.Info,185actions: {186primary: [store.add(new Action('mcp.elicit.url.open2', localize('mcp.elicit.url.open2', 'Open URL'), undefined, true, () => resolve(this._doElicitUrl(elicitation, token))))],187secondary: [store.add(new Action('mcp.elicit.cancel', localize('mcp.elicit.cancel', 'Cancel'), undefined, true, () => resolve({ action: 'decline' })))],188}189});190store.add(handle.onDidClose(() => resolve({ action: 'cancel' })));191store.add(token.onCancellationRequested(() => resolve({ action: 'cancel' })));192}193}).finally(() => store.dispose());194195return {196kind: ElicitationKind.URL,197value,198wait: completePromise,199dispose: () => promiseStore.dispose(),200};201}202203private async _doElicitUrl(elicitation: MCP.ElicitRequestURLParams, token: CancellationToken): Promise<MCP.ElicitResult> {204if (token.isCancellationRequested) {205return { action: 'cancel' };206}207208try {209if (await this._openerService.open(elicitation.url, { allowCommands: false })) {210return { action: 'accept' };211}212} catch {213// ignored214}215216return { action: 'decline' };217}218219private async _doElicitForm(elicitation: MCP.ElicitRequestFormParams | Pre20251125ElicitationParams, token: CancellationToken): Promise<MCP.ElicitResult> {220const quickPick = this._quickInputService.createQuickPick<IQuickPickItem>();221const store = new DisposableStore();222223try {224const properties = Object.entries(elicitation.requestedSchema.properties);225const requiredFields = new Set(elicitation.requestedSchema.required || []);226const results: Record<string, string | number | boolean | string[]> = {};227const backSnapshots: { value: string; validationMessage?: string }[] = [];228229quickPick.title = elicitation.message;230quickPick.totalSteps = properties.length;231quickPick.ignoreFocusOut = true;232233for (let i = 0; i < properties.length; i++) {234const [propertyName, schema] = properties[i];235const isRequired = requiredFields.has(propertyName);236const restore = backSnapshots.at(i);237238store.clear();239quickPick.step = i + 1;240quickPick.title = schema.title || propertyName;241quickPick.placeholder = this._getFieldPlaceholder(schema, isRequired);242quickPick.value = restore?.value ?? '';243quickPick.validationMessage = '';244quickPick.buttons = i > 0 ? [this._quickInputService.backButton] : [];245246let result: { type: 'value'; value: string | number | boolean | undefined | string[] } | { type: 'back' } | { type: 'cancel' };247if (schema.type === 'boolean') {248result = await this._handleEnumField(quickPick, { enum: [{ const: 'true' }, { const: 'false' }], default: schema.default ? String(schema.default) : undefined }, isRequired, store, token);249if (result.type === 'value') { result.value = result.value === 'true' ? true : false; }250} else if (isLegacyTitledEnumSchema(schema)) {251result = await this._handleEnumField(quickPick, { enum: schema.enum.map((v, i) => ({ const: v, title: schema.enumNames[i] })), default: schema.default }, isRequired, store, token);252} else if (isUntitledEnumSchema(schema)) {253result = await this._handleEnumField(quickPick, { enum: schema.enum.map(v => ({ const: v })), default: schema.default }, isRequired, store, token);254} else if (isTitledSingleEnumSchema(schema)) {255result = await this._handleEnumField(quickPick, { enum: schema.oneOf, default: schema.default }, isRequired, store, token);256} else if (isTitledMultiEnumSchema(schema)) {257result = await this._handleMultiEnumField(quickPick, { enum: schema.items.anyOf, default: schema.default }, isRequired, store, token);258} else if (isUntitledMultiEnumSchema(schema)) {259result = await this._handleMultiEnumField(quickPick, { enum: schema.items.enum.map(v => ({ const: v })), default: schema.default }, isRequired, store, token);260} else {261result = await this._handleInputField(quickPick, schema, isRequired, store, token);262if (result.type === 'value' && (schema.type === 'number' || schema.type === 'integer')) {263result.value = Number(result.value);264}265}266267if (result.type === 'back') {268i -= 2;269continue;270}271if (result.type === 'cancel') {272return { action: 'cancel' };273}274275backSnapshots[i] = { value: quickPick.value };276277if (result.value === undefined) {278delete results[propertyName];279} else {280results[propertyName] = result.value;281}282}283284return {285action: 'accept',286content: results,287};288} finally {289store.dispose();290quickPick.dispose();291}292}293294private _getFieldPlaceholder(schema: MCP.PrimitiveSchemaDefinition, required: boolean): string {295let placeholder = schema.description || '';296if (!required) {297placeholder = placeholder ? `${placeholder} (${localize('optional', 'Optional')})` : localize('optional', 'Optional');298}299return placeholder;300}301302private async _handleEnumField(303quickPick: IQuickPick<IQuickPickItem>,304schema: { default?: string; enum: { const: string; title?: string }[] },305required: boolean,306store: DisposableStore,307token: CancellationToken308) {309const items: IQuickPickItem[] = schema.enum.map(({ const: value, title }) => ({310id: value,311label: value,312description: title,313}));314315if (!required) {316items.push(noneItem);317}318319quickPick.canSelectMany = false;320quickPick.items = items;321if (schema.default !== undefined) {322quickPick.activeItems = items.filter(item => item.id === schema.default);323}324325return new Promise<{ type: 'value'; value: string | undefined } | { type: 'back' } | { type: 'cancel' }>(resolve => {326store.add(token.onCancellationRequested(() => resolve({ type: 'cancel' })));327store.add(quickPick.onDidAccept(() => {328const selected = quickPick.selectedItems[0];329if (selected) {330resolve({ type: 'value', value: selected.id });331}332}));333store.add(quickPick.onDidTriggerButton(() => resolve({ type: 'back' })));334store.add(quickPick.onDidHide(() => resolve({ type: 'cancel' })));335336quickPick.show();337});338}339340private async _handleMultiEnumField(341quickPick: IQuickPick<IQuickPickItem>,342schema: { default?: string[]; enum: { const: string; title?: string }[] },343required: boolean,344store: DisposableStore,345token: CancellationToken346) {347const items: IQuickPickItem[] = schema.enum.map(({ const: value, title }) => ({348id: value,349label: value,350description: title,351picked: !!schema.default?.includes(value),352pickable: true,353}));354355if (!required) {356items.push(noneItem);357}358359quickPick.canSelectMany = true;360quickPick.items = items;361362return new Promise<{ type: 'value'; value: string[] | undefined } | { type: 'back' } | { type: 'cancel' }>(resolve => {363store.add(token.onCancellationRequested(() => resolve({ type: 'cancel' })));364store.add(quickPick.onDidAccept(() => {365const selected = quickPick.selectedItems[0];366if (selected.id === undefined) {367resolve({ type: 'value', value: undefined });368} else {369resolve({ type: 'value', value: quickPick.selectedItems.map(i => i.id).filter(isDefined) });370}371}));372store.add(quickPick.onDidTriggerButton(() => resolve({ type: 'back' })));373store.add(quickPick.onDidHide(() => resolve({ type: 'cancel' })));374375quickPick.show();376});377}378379private async _handleInputField(380quickPick: IQuickPick<IQuickPickItem>,381schema: MCP.NumberSchema | MCP.StringSchema,382required: boolean,383store: DisposableStore,384token: CancellationToken385) {386quickPick.canSelectMany = false;387388const updateItems = () => {389const items: IQuickPickItem[] = [];390if (quickPick.value) {391const validation = this._validateInput(quickPick.value, schema);392quickPick.validationMessage = validation.message;393if (validation.isValid) {394items.push({ id: '$current', label: `\u27A4 ${quickPick.value}` });395}396} else {397quickPick.validationMessage = '';398399if (schema.default) {400items.push({ id: '$default', label: `${schema.default}`, description: localize('mcp.elicit.useDefault', 'Default value') });401}402}403404405if (quickPick.validationMessage) {406quickPick.severity = Severity.Warning;407} else {408quickPick.severity = Severity.Ignore;409if (!required) {410items.push(noneItem);411}412}413414quickPick.items = items;415};416417updateItems();418419return new Promise<{ type: 'value'; value: string | undefined } | { type: 'back' } | { type: 'cancel' }>(resolve => {420if (token.isCancellationRequested) {421resolve({ type: 'cancel' });422return;423}424425store.add(token.onCancellationRequested(() => resolve({ type: 'cancel' })));426store.add(quickPick.onDidChangeValue(updateItems));427store.add(quickPick.onDidAccept(() => {428const id = quickPick.selectedItems[0].id;429if (!id) {430resolve({ type: 'value', value: undefined });431} else if (id === '$default') {432resolve({ type: 'value', value: String(schema.default) });433} else if (!quickPick.validationMessage) {434resolve({ type: 'value', value: quickPick.value });435}436}));437store.add(quickPick.onDidTriggerButton(() => resolve({ type: 'back' })));438store.add(quickPick.onDidHide(() => resolve({ type: 'cancel' })));439440quickPick.show();441});442}443444private _validateInput(value: string, schema: MCP.NumberSchema | MCP.StringSchema): { isValid: boolean; message?: string } {445switch (schema.type) {446case 'string':447return this._validateString(value, schema);448case 'number':449case 'integer':450return this._validateNumber(value, schema);451default:452assertNever(schema);453}454}455456private _validateString(value: string, schema: MCP.StringSchema): { isValid: boolean; parsedValue?: string; message?: string } {457if (schema.minLength && value.length < schema.minLength) {458return { isValid: false, message: localize('mcp.elicit.validation.minLength', 'Minimum length is {0}', schema.minLength) };459}460if (schema.maxLength && value.length > schema.maxLength) {461return { isValid: false, message: localize('mcp.elicit.validation.maxLength', 'Maximum length is {0}', schema.maxLength) };462}463if (schema.format) {464const formatValid = this._validateStringFormat(value, schema.format);465if (!formatValid.isValid) {466return formatValid;467}468}469return { isValid: true, parsedValue: value };470}471472private _validateStringFormat(value: string, format: string): { isValid: boolean; message?: string } {473switch (format) {474case 'email':475return value.includes('@')476? { isValid: true }477: { isValid: false, message: localize('mcp.elicit.validation.email', 'Please enter a valid email address') };478case 'uri':479if (URL.canParse(value)) {480return { isValid: true };481} else {482return { isValid: false, message: localize('mcp.elicit.validation.uri', 'Please enter a valid URI') };483}484case 'date': {485const dateRegex = /^\d{4}-\d{2}-\d{2}$/;486if (!dateRegex.test(value)) {487return { isValid: false, message: localize('mcp.elicit.validation.date', 'Please enter a valid date (YYYY-MM-DD)') };488}489const date = new Date(value);490return !isNaN(date.getTime())491? { isValid: true }492: { isValid: false, message: localize('mcp.elicit.validation.date', 'Please enter a valid date (YYYY-MM-DD)') };493}494case 'date-time': {495const dateTime = new Date(value);496return !isNaN(dateTime.getTime())497? { isValid: true }498: { isValid: false, message: localize('mcp.elicit.validation.dateTime', 'Please enter a valid date-time') };499}500default:501return { isValid: true };502}503}504505private _validateNumber(value: string, schema: MCP.NumberSchema): { isValid: boolean; parsedValue?: number; message?: string } {506const parsed = Number(value);507if (isNaN(parsed)) {508return { isValid: false, message: localize('mcp.elicit.validation.number', 'Please enter a valid number') };509}510if (schema.type === 'integer' && !Number.isInteger(parsed)) {511return { isValid: false, message: localize('mcp.elicit.validation.integer', 'Please enter a valid integer') };512}513if (schema.minimum !== undefined && parsed < schema.minimum) {514return { isValid: false, message: localize('mcp.elicit.validation.minimum', 'Minimum value is {0}', schema.minimum) };515}516if (schema.maximum !== undefined && parsed > schema.maximum) {517return { isValid: false, message: localize('mcp.elicit.validation.maximum', 'Maximum value is {0}', schema.maximum) };518}519return { isValid: true, parsedValue: parsed };520}521}522523524