Skip to content

Commit 519eb61

Browse files
authored
mcp: support sep-1036 for URL mode elicitation (#277253)
* mcp: support sep-1036 for URL mode elicitation Refs modelcontextprotocol/modelcontextprotocol#887 * up
1 parent 51e7b22 commit 519eb61

6 files changed

Lines changed: 339 additions & 77 deletions

File tree

src/vs/base/common/assert.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ export function assertNever(value: never, message = 'Unreachable'): never {
2929
throw new Error(message);
3030
}
3131

32+
export function softAssertNever(value: never): void {
33+
// no-op
34+
}
35+
3236
/**
3337
* Asserts that a condition is `truthy`.
3438
*

src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts

Lines changed: 122 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,36 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { Action } from '../../../../base/common/actions.js';
7-
import { assertNever } from '../../../../base/common/assert.js';
7+
import { assertNever, softAssertNever } from '../../../../base/common/assert.js';
88
import { CancellationToken } from '../../../../base/common/cancellation.js';
9+
import { CancellationError } from '../../../../base/common/errors.js';
10+
import { MarkdownString } from '../../../../base/common/htmlContent.js';
911
import { DisposableStore } from '../../../../base/common/lifecycle.js';
12+
import { autorun } from '../../../../base/common/observable.js';
1013
import { isDefined } from '../../../../base/common/types.js';
14+
import { URI } from '../../../../base/common/uri.js';
1115
import { localize } from '../../../../nls.js';
1216
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
17+
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
1318
import { IQuickInputService, IQuickPick, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
1419
import { ChatElicitationRequestPart } from '../../chat/browser/chatElicitationRequestPart.js';
1520
import { ChatModel } from '../../chat/common/chatModel.js';
1621
import { IChatService } from '../../chat/common/chatService.js';
1722
import { LocalChatSessionUri } from '../../chat/common/chatUri.js';
18-
import { IMcpElicitationService, IMcpServer, IMcpToolCallContext } from '../common/mcpTypes.js';
23+
import { ElicitationKind, ElicitResult, IFormModeElicitResult, IMcpElicitationService, IMcpServer, IMcpToolCallContext, IUrlModeElicitResult, McpConnectionState, MpcResponseError } from '../common/mcpTypes.js';
1924
import { mcpServerToSourceData } from '../common/mcpTypesUtils.js';
2025
import { MCP } from '../common/modelContextProtocol.js';
2126

2227
const noneItem: IQuickPickItem = { id: undefined, label: localize('mcp.elicit.enum.none', 'None'), description: localize('mcp.elicit.enum.none.description', 'No selection'), alwaysShow: true };
2328

29+
function isFormElicitation(params: MCP.ElicitRequest['params']): params is MCP.ElicitRequestFormParams {
30+
return params.mode === 'form';
31+
}
32+
33+
function isUrlElicitation(params: MCP.ElicitRequest['params']): params is MCP.ElicitRequestURLParams {
34+
return params.mode === 'url';
35+
}
36+
2437
function isLegacyTitledEnumSchema(schema: MCP.PrimitiveSchemaDefinition): schema is MCP.LegacyTitledEnumSchema & { enumNames: string[] } {
2538
const cast = schema as MCP.LegacyTitledEnumSchema;
2639
return cast.type === 'string' && Array.isArray(cast.enum) && Array.isArray(cast.enumNames);
@@ -53,11 +66,23 @@ export class McpElicitationService implements IMcpElicitationService {
5366
@INotificationService private readonly _notificationService: INotificationService,
5467
@IQuickInputService private readonly _quickInputService: IQuickInputService,
5568
@IChatService private readonly _chatService: IChatService,
69+
@IOpenerService private readonly _openerService: IOpenerService,
5670
) { }
5771

58-
public elicit(server: IMcpServer, context: IMcpToolCallContext | undefined, elicitation: MCP.ElicitRequest['params'], token: CancellationToken): Promise<MCP.ElicitResult> {
72+
public elicit(server: IMcpServer, context: IMcpToolCallContext | undefined, elicitation: MCP.ElicitRequest['params'], token: CancellationToken): Promise<ElicitResult> {
73+
if (isFormElicitation(elicitation)) {
74+
return this._elicitForm(server, context, elicitation, token);
75+
} else if (isUrlElicitation(elicitation)) {
76+
return this._elicitUrl(server, context, elicitation, token);
77+
} else {
78+
softAssertNever(elicitation);
79+
return Promise.reject(new MpcResponseError('Unsupported elicitation type', MCP.INVALID_PARAMS, undefined));
80+
}
81+
}
82+
83+
private async _elicitForm(server: IMcpServer, context: IMcpToolCallContext | undefined, elicitation: MCP.ElicitRequestFormParams, token: CancellationToken): Promise<IFormModeElicitResult> {
5984
const store = new DisposableStore();
60-
return new Promise<MCP.ElicitResult>(resolve => {
85+
const value = await new Promise<MCP.ElicitResult>(resolve => {
6186
const chatModel = context?.chatSessionId && this._chatService.getSession(LocalChatSessionUri.forSession(context.chatSessionId));
6287
if (chatModel instanceof ChatModel) {
6388
const request = chatModel.getRequests().at(-1);
@@ -69,7 +94,7 @@ export class McpElicitationService implements IMcpElicitationService {
6994
localize('mcp.elicit.accept', 'Respond'),
7095
localize('mcp.elicit.reject', 'Cancel'),
7196
async () => {
72-
const p = this._doElicit(elicitation, token);
97+
const p = this._doElicitForm(elicitation, token);
7398
resolve(p);
7499
const result = await p;
75100
part.state = result.action === 'accept' ? 'accepted' : 'rejected';
@@ -90,7 +115,7 @@ export class McpElicitationService implements IMcpElicitationService {
90115
source: localize('mcp.elicit.source', 'MCP Server ({0})', server.definition.label),
91116
severity: Severity.Info,
92117
actions: {
93-
primary: [store.add(new Action('mcp.elicit.give', localize('mcp.elicit.give', 'Respond'), undefined, true, () => resolve(this._doElicit(elicitation, token))))],
118+
primary: [store.add(new Action('mcp.elicit.give', localize('mcp.elicit.give', 'Respond'), undefined, true, () => resolve(this._doElicitForm(elicitation, token))))],
94119
secondary: [store.add(new Action('mcp.elicit.cancel', localize('mcp.elicit.cancel', 'Cancel'), undefined, true, () => resolve({ action: 'decline' })))],
95120
}
96121
});
@@ -99,9 +124,99 @@ export class McpElicitationService implements IMcpElicitationService {
99124
}
100125

101126
}).finally(() => store.dispose());
127+
128+
return { kind: ElicitationKind.Form, value, dispose: () => { } };
129+
}
130+
131+
private async _elicitUrl(server: IMcpServer, context: IMcpToolCallContext | undefined, elicitation: MCP.ElicitRequestURLParams, token: CancellationToken): Promise<IUrlModeElicitResult> {
132+
const promiseStore = new DisposableStore();
133+
134+
// We create this ahead of time in case e.g. a user manually opens the URL beforehand
135+
const completePromise = new Promise<void>((resolve, reject) => {
136+
promiseStore.add(token.onCancellationRequested(() => reject(new CancellationError())));
137+
promiseStore.add(autorun(reader => {
138+
const cnx = server.connection.read(reader);
139+
const handler = cnx?.handler.read(reader);
140+
if (handler) {
141+
reader.store.add(handler.onDidReceiveElicitationCompleteNotification(e => {
142+
if (e.params.elicitationId === elicitation.elicitationId) {
143+
resolve();
144+
}
145+
}));
146+
} else if (!McpConnectionState.isRunning(server.connectionState.read(reader))) {
147+
reject(new CancellationError());
148+
}
149+
}));
150+
}).finally(() => promiseStore.dispose());
151+
152+
const store = new DisposableStore();
153+
const value = await new Promise<MCP.ElicitResult>(resolve => {
154+
const chatModel = context?.chatSessionId && this._chatService.getSession(LocalChatSessionUri.forSession(context.chatSessionId));
155+
if (chatModel instanceof ChatModel) {
156+
const request = chatModel.getRequests().at(-1);
157+
if (request) {
158+
const part = new ChatElicitationRequestPart(
159+
localize('mcp.elicit.url.title', 'Authorization Required'),
160+
new MarkdownString().appendText(elicitation.message)
161+
.appendMarkdown('\n\n' + localize('mcp.elicit.url.instruction', 'Open this URL?'))
162+
.appendCodeblock('', elicitation.url),
163+
localize('msg.subtitle', "{0} (MCP Server)", server.definition.label),
164+
localize('mcp.elicit.url.open', 'Open {0}', URI.parse(elicitation.url).authority),
165+
localize('mcp.elicit.reject', 'Cancel'),
166+
async () => {
167+
const result = await this._doElicitUrl(elicitation, token);
168+
resolve(result);
169+
part.state = result.action === 'accept' ? 'accepted' : 'rejected';
170+
},
171+
() => {
172+
resolve({ action: 'decline' });
173+
part.state = 'rejected';
174+
return Promise.resolve();
175+
},
176+
mcpServerToSourceData(server),
177+
);
178+
chatModel.acceptResponseProgress(request, part);
179+
}
180+
} else {
181+
const handle = this._notificationService.notify({
182+
message: elicitation.message + ' ' + localize('mcp.elicit.url.instruction2', 'This will open {0}', elicitation.url),
183+
source: localize('mcp.elicit.source', 'MCP Server ({0})', server.definition.label),
184+
severity: Severity.Info,
185+
actions: {
186+
primary: [store.add(new Action('mcp.elicit.url.open2', localize('mcp.elicit.url.open2', 'Open URL'), undefined, true, () => resolve(this._doElicitUrl(elicitation, token))))],
187+
secondary: [store.add(new Action('mcp.elicit.cancel', localize('mcp.elicit.cancel', 'Cancel'), undefined, true, () => resolve({ action: 'decline' })))],
188+
}
189+
});
190+
store.add(handle.onDidClose(() => resolve({ action: 'cancel' })));
191+
store.add(token.onCancellationRequested(() => resolve({ action: 'cancel' })));
192+
}
193+
}).finally(() => store.dispose());
194+
195+
return {
196+
kind: ElicitationKind.URL,
197+
value,
198+
wait: completePromise,
199+
dispose: () => promiseStore.dispose(),
200+
};
201+
}
202+
203+
private async _doElicitUrl(elicitation: MCP.ElicitRequestURLParams, token: CancellationToken): Promise<MCP.ElicitResult> {
204+
if (token.isCancellationRequested) {
205+
return { action: 'cancel' };
206+
}
207+
208+
try {
209+
if (await this._openerService.open(elicitation.url, { allowCommands: false })) {
210+
return { action: 'accept' };
211+
}
212+
} catch {
213+
// ignored
214+
}
215+
216+
return { action: 'decline' };
102217
}
103218

104-
private async _doElicit(elicitation: MCP.ElicitRequest['params'], token: CancellationToken): Promise<MCP.ElicitResult> {
219+
private async _doElicitForm(elicitation: MCP.ElicitRequestFormParams, token: CancellationToken): Promise<MCP.ElicitResult> {
105220
const quickPick = this._quickInputService.createQuickPick<IQuickPickItem>();
106221
const store = new DisposableStore();
107222

src/vs/workbench/contrib/mcp/common/mcpServer.ts

Lines changed: 49 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { McpDevModeServerAttache } from './mcpDevMode.js';
3434
import { McpIcons, parseAndValidateMcpIcon, StoredMcpIcons } from './mcpIcons.js';
3535
import { IMcpRegistry } from './mcpRegistryTypes.js';
3636
import { McpServerRequestHandler } from './mcpServerRequestHandler.js';
37-
import { extensionMcpCollectionPrefix, IMcpElicitationService, IMcpIcons, IMcpPrompt, IMcpPromptMessage, IMcpResource, IMcpResourceTemplate, IMcpSamplingService, IMcpServer, IMcpServerConnection, IMcpServerStartOpts, IMcpTool, IMcpToolCallContext, McpCapability, McpCollectionDefinition, McpCollectionReference, McpConnectionFailedError, McpConnectionState, McpDefinitionReference, mcpPromptReplaceSpecialChars, McpResourceURI, McpServerCacheState, McpServerDefinition, McpServerStaticToolAvailability, McpServerTransportType, McpToolName, UserInteractionRequiredError } from './mcpTypes.js';
37+
import { ElicitationKind, extensionMcpCollectionPrefix, IMcpElicitationService, IMcpIcons, IMcpPrompt, IMcpPromptMessage, IMcpResource, IMcpResourceTemplate, IMcpSamplingService, IMcpServer, IMcpServerConnection, IMcpServerStartOpts, IMcpTool, IMcpToolCallContext, McpCapability, McpCollectionDefinition, McpCollectionReference, McpConnectionFailedError, McpConnectionState, McpDefinitionReference, mcpPromptReplaceSpecialChars, McpResourceURI, McpServerCacheState, McpServerDefinition, McpServerStaticToolAvailability, McpServerTransportType, McpToolName, MpcResponseError, UserInteractionRequiredError } from './mcpTypes.js';
3838
import { MCP } from './modelContextProtocol.js';
3939
import { UriTemplate } from './uriTemplate.js';
4040

@@ -481,7 +481,7 @@ export class McpServer extends Disposable implements IMcpServer {
481481
})
482482
.map((o, reader) => o?.promiseResult.read(reader)?.data),
483483
(entry) => entry.tools,
484-
(entry) => entry.map(def => new McpTool(this, toolPrefix, def)).sort((a, b) => a.compare(b)),
484+
(entry) => entry.map(def => this._instantiationService.createInstance(McpTool, this, toolPrefix, def)).sort((a, b) => a.compare(b)),
485485
[],
486486
);
487487

@@ -607,7 +607,7 @@ export class McpServer extends Disposable implements IMcpServer {
607607
server: this,
608608
params,
609609
}).then(r => r.sample),
610-
elicitationRequestHandler: req => {
610+
elicitationRequestHandler: async req => {
611611
const serverInfo = connection.handler.get()?.serverInfo;
612612
if (serverInfo) {
613613
this._telemetryService.publicLog2<ElicitationTelemetryData, ElicitationTelemetryClassification>('mcp.elicitationRequested', {
@@ -616,7 +616,9 @@ export class McpServer extends Disposable implements IMcpServer {
616616
});
617617
}
618618

619-
return this._elicitationService.elicit(this, Iterable.first(this.runningToolCalls), req, CancellationToken.None);
619+
const r = await this._elicitationService.elicit(this, Iterable.first(this.runningToolCalls), req, CancellationToken.None);
620+
r.dispose();
621+
return r.value;
620622
}
621623
});
622624

@@ -974,35 +976,17 @@ export class McpTool implements IMcpTool {
974976
private readonly _server: McpServer,
975977
idPrefix: string,
976978
private readonly _definition: ValidatedMcpTool,
979+
@IMcpElicitationService private readonly _elicitationService: IMcpElicitationService,
977980
) {
978981
this.referenceName = _definition.name.replaceAll('.', '_');
979982
this.id = (idPrefix + _definition.name).replaceAll('.', '_').slice(0, McpToolName.MaxLength);
980983
this.icons = McpIcons.fromStored(this._definition._icons);
981984
}
982985

983986
async call(params: Record<string, unknown>, context?: IMcpToolCallContext, token?: CancellationToken): Promise<MCP.CallToolResult> {
984-
// serverToolName is always set now, but older cache entries (from 1.99-Insiders) may not have it.
985-
const name = this._definition.serverToolName ?? this._definition.name;
986987
if (context) { this._server.runningToolCalls.add(context); }
987988
try {
988-
const meta: Record<string, unknown> = {};
989-
if (context?.chatSessionId) {
990-
meta['vscode.conversationId'] = context.chatSessionId;
991-
}
992-
if (context?.chatRequestId) {
993-
meta['vscode.requestId'] = context.chatRequestId;
994-
}
995-
996-
const result = await McpServer.callOn(this._server, h => h.callTool({
997-
name,
998-
arguments: params,
999-
_meta: Object.keys(meta).length > 0 ? meta : undefined
1000-
}, token), token);
1001-
1002-
// Wait for tools to refresh for dynamic servers (#261611)
1003-
await this._server.awaitToolRefresh();
1004-
1005-
return result;
989+
return await this._callWithProgress(params, undefined, context, token);
1006990
} finally {
1007991
if (context) { this._server.runningToolCalls.delete(context); }
1008992
}
@@ -1017,20 +1001,23 @@ export class McpTool implements IMcpTool {
10171001
}
10181002
}
10191003

1020-
_callWithProgress(params: Record<string, unknown>, progress: ToolProgress, context?: IMcpToolCallContext, token?: CancellationToken, allowRetry = true): Promise<MCP.CallToolResult> {
1004+
_callWithProgress(params: Record<string, unknown>, progress: ToolProgress | undefined, context?: IMcpToolCallContext, token = CancellationToken.None, allowRetry = true): Promise<MCP.CallToolResult> {
10211005
// serverToolName is always set now, but older cache entries (from 1.99-Insiders) may not have it.
10221006
const name = this._definition.serverToolName ?? this._definition.name;
1023-
const progressToken = generateUuid();
1007+
const progressToken = progress ? generateUuid() : undefined;
1008+
const store = new DisposableStore();
10241009

10251010
return McpServer.callOn(this._server, async h => {
1026-
const listener = h.onDidReceiveProgressNotification((e) => {
1027-
if (e.params.progressToken === progressToken) {
1028-
progress.report({
1029-
message: e.params.message,
1030-
progress: e.params.total !== undefined && e.params.progress !== undefined ? e.params.progress / e.params.total : undefined,
1031-
});
1032-
}
1033-
});
1011+
if (progress) {
1012+
store.add(h.onDidReceiveProgressNotification((e) => {
1013+
if (e.params.progressToken === progressToken) {
1014+
progress.report({
1015+
message: e.params.message,
1016+
progress: e.params.total !== undefined && e.params.progress !== undefined ? e.params.progress / e.params.total : undefined,
1017+
});
1018+
}
1019+
}));
1020+
}
10341021

10351022
const meta: Record<string, unknown> = { progressToken };
10361023
if (context?.chatSessionId) {
@@ -1047,18 +1034,45 @@ export class McpTool implements IMcpTool {
10471034

10481035
return result;
10491036
} catch (err) {
1037+
// Handle URL elicitation required error
1038+
if (err instanceof MpcResponseError && err.code === MCP.URL_ELICITATION_REQUIRED && allowRetry) {
1039+
await this._handleElicitationErr(err, context, token);
1040+
return this._callWithProgress(params, progress, context, token, false);
1041+
}
1042+
10501043
const state = this._server.connectionState.get();
10511044
if (allowRetry && state.state === McpConnectionState.Kind.Error && state.shouldRetry) {
10521045
return this._callWithProgress(params, progress, context, token, false);
10531046
} else {
10541047
throw err;
10551048
}
10561049
} finally {
1057-
listener.dispose();
1050+
store.dispose();
10581051
}
10591052
}, token);
10601053
}
10611054

1055+
private async _handleElicitationErr(err: MpcResponseError, context: IMcpToolCallContext | undefined, token: CancellationToken) {
1056+
const elicitations = (err.data as MCP.URLElicitationRequiredError['error']['data'])?.elicitations;
1057+
if (Array.isArray(elicitations) && elicitations.length > 0) {
1058+
for (const elicitation of elicitations) {
1059+
const elicitResult = await this._elicitationService.elicit(this._server, context, elicitation, token);
1060+
1061+
try {
1062+
if (elicitResult.value.action !== 'accept') {
1063+
throw err;
1064+
}
1065+
1066+
if (elicitResult.kind === ElicitationKind.URL) {
1067+
await elicitResult.wait;
1068+
}
1069+
} finally {
1070+
elicitResult.dispose();
1071+
}
1072+
}
1073+
}
1074+
}
1075+
10621076
compare(other: IMcpTool): number {
10631077
return this._definition.name.localeCompare(other.definition.name);
10641078
}

src/vs/workbench/contrib/mcp/common/mcpServerRequestHandler.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { equals } from '../../../../base/common/arrays.js';
7-
import { assertNever } from '../../../../base/common/assert.js';
7+
import { assertNever, softAssertNever } from '../../../../base/common/assert.js';
88
import { DeferredPromise, IntervalTimer } from '../../../../base/common/async.js';
99
import { CancellationToken } from '../../../../base/common/cancellation.js';
1010
import { CancellationError } from '../../../../base/common/errors.js';
@@ -83,6 +83,9 @@ export class McpServerRequestHandler extends Disposable {
8383
private readonly _onDidReceiveProgressNotification = this._register(new Emitter<MCP.ProgressNotification>());
8484
readonly onDidReceiveProgressNotification = this._onDidReceiveProgressNotification.event;
8585

86+
private readonly _onDidReceiveElicitationCompleteNotification = this._register(new Emitter<MCP.ElicitationCompleteNotification>());
87+
readonly onDidReceiveElicitationCompleteNotification = this._onDidReceiveElicitationCompleteNotification.event;
88+
8689
private readonly _onDidChangeResourceList = this._register(new Emitter<void>());
8790
readonly onDidChangeResourceList = this._onDidChangeResourceList.event;
8891

@@ -117,7 +120,7 @@ export class McpServerRequestHandler extends Disposable {
117120
capabilities: {
118121
roots: { listChanged: true },
119122
sampling: opts.createMessageRequestHandler ? {} : undefined,
120-
elicitation: opts.elicitationRequestHandler ? {} : undefined,
123+
elicitation: opts.elicitationRequestHandler ? { form: {}, url: {} } : undefined,
121124
},
122125
clientInfo: {
123126
name: productService.nameLong,
@@ -374,6 +377,11 @@ export class McpServerRequestHandler extends Disposable {
374377
case 'notifications/prompts/list_changed':
375378
this._onDidChangePromptList.fire();
376379
return;
380+
case 'notifications/elicitation/complete':
381+
this._onDidReceiveElicitationCompleteNotification.fire(request);
382+
return;
383+
default:
384+
softAssertNever(request);
377385
}
378386
}
379387

0 commit comments

Comments
 (0)