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