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
5302 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, softAssertNever } from '../../../../base/common/assert.js';
8
import { CancellationToken } from '../../../../base/common/cancellation.js';
9
import { CancellationError } from '../../../../base/common/errors.js';
10
import { MarkdownString } from '../../../../base/common/htmlContent.js';
11
import { DisposableStore } from '../../../../base/common/lifecycle.js';
12
import { autorun } from '../../../../base/common/observable.js';
13
import { isDefined } from '../../../../base/common/types.js';
14
import { URI } from '../../../../base/common/uri.js';
15
import { localize } from '../../../../nls.js';
16
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
17
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
18
import { IQuickInputService, IQuickPick, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
19
import { ChatElicitationRequestPart } from '../../chat/common/model/chatProgressTypes/chatElicitationRequestPart.js';
20
import { ChatModel } from '../../chat/common/model/chatModel.js';
21
import { ElicitationState, IChatService } from '../../chat/common/chatService/chatService.js';
22
import { LocalChatSessionUri } from '../../chat/common/model/chatUri.js';
23
import { ElicitationKind, ElicitResult, IFormModeElicitResult, IMcpElicitationService, IMcpServer, IMcpToolCallContext, IUrlModeElicitResult, McpConnectionState, MpcResponseError } from '../common/mcpTypes.js';
24
import { mcpServerToSourceData } from '../common/mcpTypesUtils.js';
25
import { MCP } from '../common/modelContextProtocol.js';
26
27
const noneItem: IQuickPickItem = { id: undefined, label: localize('mcp.elicit.enum.none', 'None'), description: localize('mcp.elicit.enum.none.description', 'No selection'), alwaysShow: true };
28
29
type Pre20251125ElicitationParams = Omit<MCP.ElicitRequestFormParams, 'mode'> & { mode?: undefined };
30
31
function isFormElicitation(params: MCP.ElicitRequest['params'] | Pre20251125ElicitationParams): params is (MCP.ElicitRequestFormParams | Pre20251125ElicitationParams) {
32
return params.mode === 'form' || (params.mode === undefined && !!(params as Pre20251125ElicitationParams).requestedSchema);
33
}
34
35
function isUrlElicitation(params: MCP.ElicitRequest['params']): params is MCP.ElicitRequestURLParams {
36
return params.mode === 'url';
37
}
38
39
function isLegacyTitledEnumSchema(schema: MCP.PrimitiveSchemaDefinition): schema is MCP.LegacyTitledEnumSchema & { enumNames: string[] } {
40
const cast = schema as MCP.LegacyTitledEnumSchema;
41
return cast.type === 'string' && Array.isArray(cast.enum) && Array.isArray(cast.enumNames);
42
}
43
44
function isUntitledEnumSchema(schema: MCP.PrimitiveSchemaDefinition): schema is MCP.LegacyTitledEnumSchema | MCP.UntitledSingleSelectEnumSchema {
45
const cast = schema as MCP.LegacyTitledEnumSchema | MCP.UntitledSingleSelectEnumSchema;
46
return cast.type === 'string' && Array.isArray(cast.enum);
47
}
48
49
function isTitledSingleEnumSchema(schema: MCP.PrimitiveSchemaDefinition): schema is MCP.TitledSingleSelectEnumSchema {
50
const cast = schema as MCP.TitledSingleSelectEnumSchema;
51
return cast.type === 'string' && Array.isArray(cast.oneOf);
52
}
53
54
function isUntitledMultiEnumSchema(schema: MCP.PrimitiveSchemaDefinition): schema is MCP.UntitledMultiSelectEnumSchema {
55
const cast = schema as MCP.UntitledMultiSelectEnumSchema;
56
return cast.type === 'array' && !!cast.items?.enum;
57
}
58
59
function isTitledMultiEnumSchema(schema: MCP.PrimitiveSchemaDefinition): schema is MCP.TitledMultiSelectEnumSchema {
60
const cast = schema as MCP.TitledMultiSelectEnumSchema;
61
return cast.type === 'array' && !!cast.items?.anyOf;
62
}
63
64
export class McpElicitationService implements IMcpElicitationService {
65
declare readonly _serviceBrand: undefined;
66
67
constructor(
68
@INotificationService private readonly _notificationService: INotificationService,
69
@IQuickInputService private readonly _quickInputService: IQuickInputService,
70
@IChatService private readonly _chatService: IChatService,
71
@IOpenerService private readonly _openerService: IOpenerService,
72
) { }
73
74
public elicit(server: IMcpServer, context: IMcpToolCallContext | undefined, elicitation: MCP.ElicitRequest['params'], token: CancellationToken): Promise<ElicitResult> {
75
if (isFormElicitation(elicitation)) {
76
return this._elicitForm(server, context, elicitation, token);
77
} else if (isUrlElicitation(elicitation)) {
78
return this._elicitUrl(server, context, elicitation, token);
79
} else {
80
softAssertNever(elicitation);
81
return Promise.reject(new MpcResponseError('Unsupported elicitation type', MCP.INVALID_PARAMS, undefined));
82
}
83
}
84
85
private async _elicitForm(server: IMcpServer, context: IMcpToolCallContext | undefined, elicitation: MCP.ElicitRequestFormParams | Pre20251125ElicitationParams, token: CancellationToken): Promise<IFormModeElicitResult> {
86
const store = new DisposableStore();
87
const value = await new Promise<MCP.ElicitResult>(resolve => {
88
const chatModel = context?.chatSessionId && this._chatService.getSession(LocalChatSessionUri.forSession(context.chatSessionId));
89
if (chatModel instanceof ChatModel) {
90
const request = chatModel.getRequests().at(-1);
91
if (request) {
92
const part = new ChatElicitationRequestPart(
93
localize('mcp.elicit.title', 'Request for Input'),
94
elicitation.message,
95
localize('msg.subtitle', "{0} (MCP Server)", server.definition.label),
96
localize('mcp.elicit.accept', 'Respond'),
97
localize('mcp.elicit.reject', 'Cancel'),
98
async () => {
99
const p = this._doElicitForm(elicitation, token);
100
resolve(p);
101
const result = await p;
102
part.acceptedResult = result.content;
103
return result.action === 'accept' ? ElicitationState.Accepted : ElicitationState.Rejected;
104
},
105
() => {
106
resolve({ action: 'decline' });
107
return Promise.resolve(ElicitationState.Rejected);
108
},
109
mcpServerToSourceData(server),
110
);
111
chatModel.acceptResponseProgress(request, part);
112
}
113
} else {
114
const handle = this._notificationService.notify({
115
message: elicitation.message,
116
source: localize('mcp.elicit.source', 'MCP Server ({0})', server.definition.label),
117
severity: Severity.Info,
118
actions: {
119
primary: [store.add(new Action('mcp.elicit.give', localize('mcp.elicit.give', 'Respond'), undefined, true, () => resolve(this._doElicitForm(elicitation, token))))],
120
secondary: [store.add(new Action('mcp.elicit.cancel', localize('mcp.elicit.cancel', 'Cancel'), undefined, true, () => resolve({ action: 'decline' })))],
121
}
122
});
123
store.add(handle.onDidClose(() => resolve({ action: 'cancel' })));
124
store.add(token.onCancellationRequested(() => resolve({ action: 'cancel' })));
125
}
126
127
}).finally(() => store.dispose());
128
129
return { kind: ElicitationKind.Form, value, dispose: () => { } };
130
}
131
132
private async _elicitUrl(server: IMcpServer, context: IMcpToolCallContext | undefined, elicitation: MCP.ElicitRequestURLParams, token: CancellationToken): Promise<IUrlModeElicitResult> {
133
const promiseStore = new DisposableStore();
134
135
// We create this ahead of time in case e.g. a user manually opens the URL beforehand
136
const completePromise = new Promise<void>((resolve, reject) => {
137
promiseStore.add(token.onCancellationRequested(() => reject(new CancellationError())));
138
promiseStore.add(autorun(reader => {
139
const cnx = server.connection.read(reader);
140
const handler = cnx?.handler.read(reader);
141
if (handler) {
142
reader.store.add(handler.onDidReceiveElicitationCompleteNotification(e => {
143
if (e.params.elicitationId === elicitation.elicitationId) {
144
resolve();
145
}
146
}));
147
} else if (!McpConnectionState.isRunning(server.connectionState.read(reader))) {
148
reject(new CancellationError());
149
}
150
}));
151
}).finally(() => promiseStore.dispose());
152
153
const store = new DisposableStore();
154
const value = await new Promise<MCP.ElicitResult>(resolve => {
155
const chatModel = context?.chatSessionId && this._chatService.getSession(LocalChatSessionUri.forSession(context.chatSessionId));
156
if (chatModel instanceof ChatModel) {
157
const request = chatModel.getRequests().at(-1);
158
if (request) {
159
const part = new ChatElicitationRequestPart(
160
localize('mcp.elicit.url.title', 'Authorization Required'),
161
new MarkdownString().appendText(elicitation.message)
162
.appendMarkdown('\n\n' + localize('mcp.elicit.url.instruction', 'Open this URL?'))
163
.appendCodeblock('', elicitation.url),
164
localize('msg.subtitle', "{0} (MCP Server)", server.definition.label),
165
localize('mcp.elicit.url.open', 'Open {0}', URI.parse(elicitation.url).authority),
166
localize('mcp.elicit.reject', 'Cancel'),
167
async () => {
168
const result = await this._doElicitUrl(elicitation, token);
169
resolve(result);
170
completePromise.then(() => part.hide());
171
return result.action === 'accept' ? ElicitationState.Accepted : ElicitationState.Rejected;
172
},
173
() => {
174
resolve({ action: 'decline' });
175
return Promise.resolve(ElicitationState.Rejected);
176
},
177
mcpServerToSourceData(server),
178
);
179
chatModel.acceptResponseProgress(request, part);
180
}
181
} else {
182
const handle = this._notificationService.notify({
183
message: elicitation.message + ' ' + localize('mcp.elicit.url.instruction2', 'This will open {0}', elicitation.url),
184
source: localize('mcp.elicit.source', 'MCP Server ({0})', server.definition.label),
185
severity: Severity.Info,
186
actions: {
187
primary: [store.add(new Action('mcp.elicit.url.open2', localize('mcp.elicit.url.open2', 'Open URL'), undefined, true, () => resolve(this._doElicitUrl(elicitation, token))))],
188
secondary: [store.add(new Action('mcp.elicit.cancel', localize('mcp.elicit.cancel', 'Cancel'), undefined, true, () => resolve({ action: 'decline' })))],
189
}
190
});
191
store.add(handle.onDidClose(() => resolve({ action: 'cancel' })));
192
store.add(token.onCancellationRequested(() => resolve({ action: 'cancel' })));
193
}
194
}).finally(() => store.dispose());
195
196
return {
197
kind: ElicitationKind.URL,
198
value,
199
wait: completePromise,
200
dispose: () => promiseStore.dispose(),
201
};
202
}
203
204
private async _doElicitUrl(elicitation: MCP.ElicitRequestURLParams, token: CancellationToken): Promise<MCP.ElicitResult> {
205
if (token.isCancellationRequested) {
206
return { action: 'cancel' };
207
}
208
209
try {
210
if (await this._openerService.open(elicitation.url, { allowCommands: false })) {
211
return { action: 'accept' };
212
}
213
} catch {
214
// ignored
215
}
216
217
return { action: 'decline' };
218
}
219
220
private async _doElicitForm(elicitation: MCP.ElicitRequestFormParams | Pre20251125ElicitationParams, token: CancellationToken): Promise<MCP.ElicitResult> {
221
const quickPick = this._quickInputService.createQuickPick<IQuickPickItem>();
222
const store = new DisposableStore();
223
224
try {
225
const properties = Object.entries(elicitation.requestedSchema.properties);
226
const requiredFields = new Set(elicitation.requestedSchema.required || []);
227
const results: Record<string, string | number | boolean | string[]> = {};
228
const backSnapshots: { value: string; validationMessage?: string }[] = [];
229
230
quickPick.title = elicitation.message;
231
quickPick.totalSteps = properties.length;
232
quickPick.ignoreFocusOut = true;
233
234
for (let i = 0; i < properties.length; i++) {
235
const [propertyName, schema] = properties[i];
236
const isRequired = requiredFields.has(propertyName);
237
const restore = backSnapshots.at(i);
238
239
store.clear();
240
quickPick.step = i + 1;
241
quickPick.title = schema.title || propertyName;
242
quickPick.placeholder = this._getFieldPlaceholder(schema, isRequired);
243
quickPick.value = restore?.value ?? '';
244
quickPick.validationMessage = '';
245
quickPick.buttons = i > 0 ? [this._quickInputService.backButton] : [];
246
247
let result: { type: 'value'; value: string | number | boolean | undefined | string[] } | { type: 'back' } | { type: 'cancel' };
248
if (schema.type === 'boolean') {
249
result = await this._handleEnumField(quickPick, { enum: [{ const: 'true' }, { const: 'false' }], default: schema.default ? String(schema.default) : undefined }, isRequired, store, token);
250
if (result.type === 'value') { result.value = result.value === 'true' ? true : false; }
251
} else if (isLegacyTitledEnumSchema(schema)) {
252
result = await this._handleEnumField(quickPick, { enum: schema.enum.map((v, i) => ({ const: v, title: schema.enumNames[i] })), default: schema.default }, isRequired, store, token);
253
} else if (isUntitledEnumSchema(schema)) {
254
result = await this._handleEnumField(quickPick, { enum: schema.enum.map(v => ({ const: v })), default: schema.default }, isRequired, store, token);
255
} else if (isTitledSingleEnumSchema(schema)) {
256
result = await this._handleEnumField(quickPick, { enum: schema.oneOf, default: schema.default }, isRequired, store, token);
257
} else if (isTitledMultiEnumSchema(schema)) {
258
result = await this._handleMultiEnumField(quickPick, { enum: schema.items.anyOf, default: schema.default }, isRequired, store, token);
259
} else if (isUntitledMultiEnumSchema(schema)) {
260
result = await this._handleMultiEnumField(quickPick, { enum: schema.items.enum.map(v => ({ const: v })), default: schema.default }, isRequired, store, token);
261
} else {
262
result = await this._handleInputField(quickPick, schema, isRequired, store, token);
263
if (result.type === 'value' && (schema.type === 'number' || schema.type === 'integer')) {
264
result.value = Number(result.value);
265
}
266
}
267
268
if (result.type === 'back') {
269
i -= 2;
270
continue;
271
}
272
if (result.type === 'cancel') {
273
return { action: 'cancel' };
274
}
275
276
backSnapshots[i] = { value: quickPick.value };
277
278
if (result.value === undefined) {
279
delete results[propertyName];
280
} else {
281
results[propertyName] = result.value;
282
}
283
}
284
285
return {
286
action: 'accept',
287
content: results,
288
};
289
} finally {
290
store.dispose();
291
quickPick.dispose();
292
}
293
}
294
295
private _getFieldPlaceholder(schema: MCP.PrimitiveSchemaDefinition, required: boolean): string {
296
let placeholder = schema.description || '';
297
if (!required) {
298
placeholder = placeholder ? `${placeholder} (${localize('optional', 'Optional')})` : localize('optional', 'Optional');
299
}
300
return placeholder;
301
}
302
303
private async _handleEnumField(
304
quickPick: IQuickPick<IQuickPickItem>,
305
schema: { default?: string; enum: { const: string; title?: string }[] },
306
required: boolean,
307
store: DisposableStore,
308
token: CancellationToken
309
) {
310
const items: IQuickPickItem[] = schema.enum.map(({ const: value, title }) => ({
311
id: value,
312
label: value,
313
description: title,
314
}));
315
316
if (!required) {
317
items.push(noneItem);
318
}
319
320
quickPick.canSelectMany = false;
321
quickPick.items = items;
322
if (schema.default !== undefined) {
323
quickPick.activeItems = items.filter(item => item.id === schema.default);
324
}
325
326
return new Promise<{ type: 'value'; value: string | undefined } | { type: 'back' } | { type: 'cancel' }>(resolve => {
327
store.add(token.onCancellationRequested(() => resolve({ type: 'cancel' })));
328
store.add(quickPick.onDidAccept(() => {
329
const selected = quickPick.selectedItems[0];
330
if (selected) {
331
resolve({ type: 'value', value: selected.id });
332
}
333
}));
334
store.add(quickPick.onDidTriggerButton(() => resolve({ type: 'back' })));
335
store.add(quickPick.onDidHide(() => resolve({ type: 'cancel' })));
336
337
quickPick.show();
338
});
339
}
340
341
private async _handleMultiEnumField(
342
quickPick: IQuickPick<IQuickPickItem>,
343
schema: { default?: string[]; enum: { const: string; title?: string }[] },
344
required: boolean,
345
store: DisposableStore,
346
token: CancellationToken
347
) {
348
const items: IQuickPickItem[] = schema.enum.map(({ const: value, title }) => ({
349
id: value,
350
label: value,
351
description: title,
352
picked: !!schema.default?.includes(value),
353
pickable: true,
354
}));
355
356
if (!required) {
357
items.push(noneItem);
358
}
359
360
quickPick.canSelectMany = true;
361
quickPick.items = items;
362
363
return new Promise<{ type: 'value'; value: string[] | undefined } | { type: 'back' } | { type: 'cancel' }>(resolve => {
364
store.add(token.onCancellationRequested(() => resolve({ type: 'cancel' })));
365
store.add(quickPick.onDidAccept(() => {
366
const selected = quickPick.selectedItems[0];
367
if (selected.id === undefined) {
368
resolve({ type: 'value', value: undefined });
369
} else {
370
resolve({ type: 'value', value: quickPick.selectedItems.map(i => i.id).filter(isDefined) });
371
}
372
}));
373
store.add(quickPick.onDidTriggerButton(() => resolve({ type: 'back' })));
374
store.add(quickPick.onDidHide(() => resolve({ type: 'cancel' })));
375
376
quickPick.show();
377
});
378
}
379
380
private async _handleInputField(
381
quickPick: IQuickPick<IQuickPickItem>,
382
schema: MCP.NumberSchema | MCP.StringSchema,
383
required: boolean,
384
store: DisposableStore,
385
token: CancellationToken
386
) {
387
quickPick.canSelectMany = false;
388
389
const updateItems = () => {
390
const items: IQuickPickItem[] = [];
391
if (quickPick.value) {
392
const validation = this._validateInput(quickPick.value, schema);
393
quickPick.validationMessage = validation.message;
394
if (validation.isValid) {
395
items.push({ id: '$current', label: `\u27A4 ${quickPick.value}` });
396
}
397
} else {
398
quickPick.validationMessage = '';
399
400
if (schema.default) {
401
items.push({ id: '$default', label: `${schema.default}`, description: localize('mcp.elicit.useDefault', 'Default value') });
402
}
403
}
404
405
406
if (quickPick.validationMessage) {
407
quickPick.severity = Severity.Warning;
408
} else {
409
quickPick.severity = Severity.Ignore;
410
if (!required) {
411
items.push(noneItem);
412
}
413
}
414
415
quickPick.items = items;
416
};
417
418
updateItems();
419
420
return new Promise<{ type: 'value'; value: string | undefined } | { type: 'back' } | { type: 'cancel' }>(resolve => {
421
if (token.isCancellationRequested) {
422
resolve({ type: 'cancel' });
423
return;
424
}
425
426
store.add(token.onCancellationRequested(() => resolve({ type: 'cancel' })));
427
store.add(quickPick.onDidChangeValue(updateItems));
428
store.add(quickPick.onDidAccept(() => {
429
const id = quickPick.selectedItems[0].id;
430
if (!id) {
431
resolve({ type: 'value', value: undefined });
432
} else if (id === '$default') {
433
resolve({ type: 'value', value: String(schema.default) });
434
} else if (!quickPick.validationMessage) {
435
resolve({ type: 'value', value: quickPick.value });
436
}
437
}));
438
store.add(quickPick.onDidTriggerButton(() => resolve({ type: 'back' })));
439
store.add(quickPick.onDidHide(() => resolve({ type: 'cancel' })));
440
441
quickPick.show();
442
});
443
}
444
445
private _validateInput(value: string, schema: MCP.NumberSchema | MCP.StringSchema): { isValid: boolean; message?: string } {
446
switch (schema.type) {
447
case 'string':
448
return this._validateString(value, schema);
449
case 'number':
450
case 'integer':
451
return this._validateNumber(value, schema);
452
default:
453
assertNever(schema);
454
}
455
}
456
457
private _validateString(value: string, schema: MCP.StringSchema): { isValid: boolean; parsedValue?: string; message?: string } {
458
if (schema.minLength && value.length < schema.minLength) {
459
return { isValid: false, message: localize('mcp.elicit.validation.minLength', 'Minimum length is {0}', schema.minLength) };
460
}
461
if (schema.maxLength && value.length > schema.maxLength) {
462
return { isValid: false, message: localize('mcp.elicit.validation.maxLength', 'Maximum length is {0}', schema.maxLength) };
463
}
464
if (schema.format) {
465
const formatValid = this._validateStringFormat(value, schema.format);
466
if (!formatValid.isValid) {
467
return formatValid;
468
}
469
}
470
return { isValid: true, parsedValue: value };
471
}
472
473
private _validateStringFormat(value: string, format: string): { isValid: boolean; message?: string } {
474
switch (format) {
475
case 'email':
476
return value.includes('@')
477
? { isValid: true }
478
: { isValid: false, message: localize('mcp.elicit.validation.email', 'Please enter a valid email address') };
479
case 'uri':
480
if (URL.canParse(value)) {
481
return { isValid: true };
482
} else {
483
return { isValid: false, message: localize('mcp.elicit.validation.uri', 'Please enter a valid URI') };
484
}
485
case 'date': {
486
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
487
if (!dateRegex.test(value)) {
488
return { isValid: false, message: localize('mcp.elicit.validation.date', 'Please enter a valid date (YYYY-MM-DD)') };
489
}
490
const date = new Date(value);
491
return !isNaN(date.getTime())
492
? { isValid: true }
493
: { isValid: false, message: localize('mcp.elicit.validation.date', 'Please enter a valid date (YYYY-MM-DD)') };
494
}
495
case 'date-time': {
496
const dateTime = new Date(value);
497
return !isNaN(dateTime.getTime())
498
? { isValid: true }
499
: { isValid: false, message: localize('mcp.elicit.validation.dateTime', 'Please enter a valid date-time') };
500
}
501
default:
502
return { isValid: true };
503
}
504
}
505
506
private _validateNumber(value: string, schema: MCP.NumberSchema): { isValid: boolean; parsedValue?: number; message?: string } {
507
const parsed = Number(value);
508
if (isNaN(parsed)) {
509
return { isValid: false, message: localize('mcp.elicit.validation.number', 'Please enter a valid number') };
510
}
511
if (schema.type === 'integer' && !Number.isInteger(parsed)) {
512
return { isValid: false, message: localize('mcp.elicit.validation.integer', 'Please enter a valid integer') };
513
}
514
if (schema.minimum !== undefined && parsed < schema.minimum) {
515
return { isValid: false, message: localize('mcp.elicit.validation.minimum', 'Minimum value is {0}', schema.minimum) };
516
}
517
if (schema.maximum !== undefined && parsed > schema.maximum) {
518
return { isValid: false, message: localize('mcp.elicit.validation.maximum', 'Maximum value is {0}', schema.maximum) };
519
}
520
return { isValid: true, parsedValue: parsed };
521
}
522
}
523
524