Path: blob/main/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts
3296 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 } from '../../../../base/common/assert.js';7import { CancellationToken } from '../../../../base/common/cancellation.js';8import { DisposableStore } from '../../../../base/common/lifecycle.js';9import { localize } from '../../../../nls.js';10import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';11import { IQuickInputService, IQuickPick, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';12import { ChatElicitationRequestPart } from '../../chat/browser/chatElicitationRequestPart.js';13import { ChatModel } from '../../chat/common/chatModel.js';14import { IChatService } from '../../chat/common/chatService.js';15import { IMcpElicitationService, IMcpServer, IMcpToolCallContext } from '../common/mcpTypes.js';16import { mcpServerToSourceData } from '../common/mcpTypesUtils.js';17import { MCP } from '../common/modelContextProtocol.js';1819const noneItem: IQuickPickItem = { id: undefined, label: localize('mcp.elicit.enum.none', 'None'), description: localize('mcp.elicit.enum.none.description', 'No selection'), alwaysShow: true };2021export class McpElicitationService implements IMcpElicitationService {22declare readonly _serviceBrand: undefined;2324constructor(25@INotificationService private readonly _notificationService: INotificationService,26@IQuickInputService private readonly _quickInputService: IQuickInputService,27@IChatService private readonly _chatService: IChatService,28) { }2930public elicit(server: IMcpServer, context: IMcpToolCallContext | undefined, elicitation: MCP.ElicitRequest['params'], token: CancellationToken): Promise<MCP.ElicitResult> {31const store = new DisposableStore();32return new Promise<MCP.ElicitResult>(resolve => {33const chatModel = context?.chatSessionId && this._chatService.getSession(context.chatSessionId);34if (chatModel instanceof ChatModel) {35const request = chatModel.getRequests().at(-1);36if (request) {37const part = new ChatElicitationRequestPart(38localize('mcp.elicit.title', 'Request for Input'),39elicitation.message,40localize('msg.subtitle', "{0} (MCP Server)", server.definition.label),41localize('mcp.elicit.accept', 'Respond'),42localize('mcp.elicit.reject', 'Cancel'),43async () => {44const p = this._doElicit(elicitation, token);45resolve(p);46const result = await p;47part.state = result.action === 'accept' ? 'accepted' : 'rejected';48part.acceptedResult = result.content;49},50() => {51resolve({ action: 'decline' });52part.state = 'rejected';53return Promise.resolve();54},55mcpServerToSourceData(server),56);57chatModel.acceptResponseProgress(request, part);58}59} else {60const handle = this._notificationService.notify({61message: elicitation.message,62source: localize('mcp.elicit.source', 'MCP Server ({0})', server.definition.label),63severity: Severity.Info,64actions: {65primary: [store.add(new Action('mcp.elicit.give', localize('mcp.elicit.give', 'Respond'), undefined, true, () => resolve(this._doElicit(elicitation, token))))],66secondary: [store.add(new Action('mcp.elicit.cancel', localize('mcp.elicit.cancel', 'Cancel'), undefined, true, () => resolve({ action: 'decline' })))],67}68});69store.add(handle.onDidClose(() => resolve({ action: 'cancel' })));70store.add(token.onCancellationRequested(() => resolve({ action: 'cancel' })));71}7273}).finally(() => store.dispose());74}7576private async _doElicit(elicitation: MCP.ElicitRequest['params'], token: CancellationToken): Promise<MCP.ElicitResult> {77const quickPick = this._quickInputService.createQuickPick<IQuickPickItem>();78const store = new DisposableStore();7980try {81const properties = Object.entries(elicitation.requestedSchema.properties);82const requiredFields = new Set(elicitation.requestedSchema.required || []);83const results: Record<string, string | number | boolean> = {};84const backSnapshots: { value: string; validationMessage?: string }[] = [];8586quickPick.title = elicitation.message;87quickPick.totalSteps = properties.length;88quickPick.ignoreFocusOut = true;8990for (let i = 0; i < properties.length; i++) {91const [propertyName, schema] = properties[i];92const isRequired = requiredFields.has(propertyName);93const restore = backSnapshots.at(i);9495store.clear();96quickPick.step = i + 1;97quickPick.title = schema.title || propertyName;98quickPick.placeholder = this._getFieldPlaceholder(schema, isRequired);99quickPick.value = restore?.value ?? '';100quickPick.validationMessage = '';101quickPick.buttons = i > 0 ? [this._quickInputService.backButton] : [];102103let result: { type: 'value'; value: string | number | boolean | undefined } | { type: 'back' } | { type: 'cancel' };104if (schema.type === 'boolean') {105result = await this._handleEnumField(quickPick, { ...schema, type: 'string', enum: ['true', 'false'] }, isRequired, store, token);106if (result.type === 'value') { result.value = result.value === 'true' ? true : false; }107} else if (schema.type === 'string' && 'enum' in schema) {108result = await this._handleEnumField(quickPick, schema, isRequired, store, token);109} else {110result = await this._handleInputField(quickPick, schema, isRequired, store, token);111if (result.type === 'value' && (schema.type === 'number' || schema.type === 'integer')) {112result.value = Number(result.value);113}114}115116if (result.type === 'back') {117i -= 2;118continue;119}120if (result.type === 'cancel') {121return { action: 'cancel' };122}123124backSnapshots[i] = { value: quickPick.value };125126if (result.value === undefined) {127delete results[propertyName];128} else {129results[propertyName] = result.value;130}131}132133return {134action: 'accept',135content: results,136};137} finally {138store.dispose();139quickPick.dispose();140}141}142143private _getFieldPlaceholder(schema: MCP.PrimitiveSchemaDefinition, required: boolean): string {144let placeholder = schema.description || '';145if (!required) {146placeholder = placeholder ? `${placeholder} (${localize('optional', 'Optional')})` : localize('optional', 'Optional');147}148return placeholder;149}150151private async _handleEnumField(152quickPick: IQuickPick<IQuickPickItem>,153schema: MCP.EnumSchema,154required: boolean,155store: DisposableStore,156token: CancellationToken157) {158const items: IQuickPickItem[] = schema.enum.map((value, index) => ({159id: value,160label: value,161description: schema.enumNames?.[index],162}));163164if (!required) {165items.push(noneItem);166}167168quickPick.items = items;169quickPick.canSelectMany = false;170171return new Promise<{ type: 'value'; value: string | undefined } | { type: 'back' } | { type: 'cancel' }>(resolve => {172store.add(token.onCancellationRequested(() => resolve({ type: 'cancel' })));173store.add(quickPick.onDidAccept(() => {174const selected = quickPick.selectedItems[0];175if (selected) {176resolve({ type: 'value', value: selected.id });177}178}));179store.add(quickPick.onDidTriggerButton(() => resolve({ type: 'back' })));180store.add(quickPick.onDidHide(() => resolve({ type: 'cancel' })));181182quickPick.show();183});184}185186private async _handleInputField(187quickPick: IQuickPick<IQuickPickItem>,188schema: MCP.NumberSchema | MCP.StringSchema,189required: boolean,190store: DisposableStore,191token: CancellationToken192) {193quickPick.canSelectMany = false;194195const updateItems = () => {196const items: IQuickPickItem[] = [];197if (quickPick.value) {198const validation = this._validateInput(quickPick.value, schema);199quickPick.validationMessage = validation.message;200if (validation.isValid) {201items.push({ id: '$current', label: `\u27A4 ${quickPick.value}` });202}203} else {204quickPick.validationMessage = '';205}206207if (quickPick.validationMessage) {208quickPick.severity = Severity.Warning;209} else {210quickPick.severity = Severity.Ignore;211if (!required) {212items.push(noneItem);213}214}215216quickPick.items = items;217};218219updateItems();220221return new Promise<{ type: 'value'; value: string | undefined } | { type: 'back' } | { type: 'cancel' }>(resolve => {222if (token.isCancellationRequested) {223resolve({ type: 'cancel' });224return;225}226227store.add(token.onCancellationRequested(() => resolve({ type: 'cancel' })));228store.add(quickPick.onDidChangeValue(updateItems));229store.add(quickPick.onDidAccept(() => {230if (!quickPick.selectedItems[0].id) {231resolve({ type: 'value', value: undefined });232} else if (!quickPick.validationMessage) {233resolve({ type: 'value', value: quickPick.value });234}235}));236store.add(quickPick.onDidTriggerButton(() => resolve({ type: 'back' })));237store.add(quickPick.onDidHide(() => resolve({ type: 'cancel' })));238239quickPick.show();240});241}242243private _validateInput(value: string, schema: MCP.NumberSchema | MCP.StringSchema): { isValid: boolean; message?: string } {244switch (schema.type) {245case 'string':246return this._validateString(value, schema);247case 'number':248case 'integer':249return this._validateNumber(value, schema);250default:251assertNever(schema);252}253}254255private _validateString(value: string, schema: MCP.StringSchema): { isValid: boolean; parsedValue?: string; message?: string } {256if (schema.minLength && value.length < schema.minLength) {257return { isValid: false, message: localize('mcp.elicit.validation.minLength', 'Minimum length is {0}', schema.minLength) };258}259if (schema.maxLength && value.length > schema.maxLength) {260return { isValid: false, message: localize('mcp.elicit.validation.maxLength', 'Maximum length is {0}', schema.maxLength) };261}262if (schema.format) {263const formatValid = this._validateStringFormat(value, schema.format);264if (!formatValid.isValid) {265return formatValid;266}267}268return { isValid: true, parsedValue: value };269}270271private _validateStringFormat(value: string, format: string): { isValid: boolean; message?: string } {272switch (format) {273case 'email':274return value.includes('@')275? { isValid: true }276: { isValid: false, message: localize('mcp.elicit.validation.email', 'Please enter a valid email address') };277case 'uri':278if (URL.canParse(value)) {279return { isValid: true };280} else {281return { isValid: false, message: localize('mcp.elicit.validation.uri', 'Please enter a valid URI') };282}283case 'date': {284const dateRegex = /^\d{4}-\d{2}-\d{2}$/;285if (!dateRegex.test(value)) {286return { isValid: false, message: localize('mcp.elicit.validation.date', 'Please enter a valid date (YYYY-MM-DD)') };287}288const date = new Date(value);289return !isNaN(date.getTime())290? { isValid: true }291: { isValid: false, message: localize('mcp.elicit.validation.date', 'Please enter a valid date (YYYY-MM-DD)') };292}293case 'date-time': {294const dateTime = new Date(value);295return !isNaN(dateTime.getTime())296? { isValid: true }297: { isValid: false, message: localize('mcp.elicit.validation.dateTime', 'Please enter a valid date-time') };298}299default:300return { isValid: true };301}302}303304private _validateNumber(value: string, schema: MCP.NumberSchema): { isValid: boolean; parsedValue?: number; message?: string } {305const parsed = Number(value);306if (isNaN(parsed)) {307return { isValid: false, message: localize('mcp.elicit.validation.number', 'Please enter a valid number') };308}309if (schema.type === 'integer' && !Number.isInteger(parsed)) {310return { isValid: false, message: localize('mcp.elicit.validation.integer', 'Please enter a valid integer') };311}312if (schema.minimum !== undefined && parsed < schema.minimum) {313return { isValid: false, message: localize('mcp.elicit.validation.minimum', 'Minimum value is {0}', schema.minimum) };314}315if (schema.maximum !== undefined && parsed > schema.maximum) {316return { isValid: false, message: localize('mcp.elicit.validation.maximum', 'Maximum value is {0}', schema.maximum) };317}318return { isValid: true, parsedValue: parsed };319}320}321322323