Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.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 { computeLevenshteinDistance } from '../../../../base/common/diff/diff.js';
7
import { Emitter, Event } from '../../../../base/common/event.js';
8
import { markdownCommandLink, MarkdownString } from '../../../../base/common/htmlContent.js';
9
import { findNodeAtLocation, Node, parseTree } from '../../../../base/common/json.js';
10
import { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';
11
import { IObservable } from '../../../../base/common/observable.js';
12
import { isEqual } from '../../../../base/common/resources.js';
13
import { Range } from '../../../../editor/common/core/range.js';
14
import { CodeLens, CodeLensList, CodeLensProvider, InlayHint, InlayHintList } from '../../../../editor/common/languages.js';
15
import { ITextModel } from '../../../../editor/common/model.js';
16
import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';
17
import { localize } from '../../../../nls.js';
18
import { IMarkerData, IMarkerService, MarkerSeverity } from '../../../../platform/markers/common/markers.js';
19
import { IWorkbenchContribution } from '../../../common/contributions.js';
20
import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js';
21
import { ConfigurationResolverExpression, IResolvedValue } from '../../../services/configurationResolver/common/configurationResolverExpression.js';
22
import { McpCommandIds } from '../common/mcpCommandIds.js';
23
import { mcpConfigurationSection } from '../common/mcpConfiguration.js';
24
import { IMcpRegistry } from '../common/mcpRegistryTypes.js';
25
import { IMcpConfigPath, IMcpServerStartOpts, IMcpService, IMcpWorkbenchService, McpConnectionState } from '../common/mcpTypes.js';
26
27
const diagnosticOwner = 'vscode.mcp';
28
29
export class McpLanguageFeatures extends Disposable implements IWorkbenchContribution {
30
private readonly _cachedMcpSection = this._register(new MutableDisposable<{ model: ITextModel; inConfig: IMcpConfigPath; tree: Node } & IDisposable>());
31
32
constructor(
33
@ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService,
34
@IMcpRegistry private readonly _mcpRegistry: IMcpRegistry,
35
@IMcpWorkbenchService private readonly _mcpWorkbenchService: IMcpWorkbenchService,
36
@IMcpService private readonly _mcpService: IMcpService,
37
@IMarkerService private readonly _markerService: IMarkerService,
38
@IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService,
39
) {
40
super();
41
42
const patterns = [
43
{ pattern: '**/mcp.json' },
44
{ pattern: '**/workspace.json' },
45
];
46
47
const onDidChangeCodeLens = this._register(new Emitter<CodeLensProvider>());
48
const codeLensProvider: CodeLensProvider = {
49
onDidChange: onDidChangeCodeLens.event,
50
provideCodeLenses: (model, range) => this._provideCodeLenses(model, () => onDidChangeCodeLens.fire(codeLensProvider)),
51
};
52
this._register(languageFeaturesService.codeLensProvider.register(patterns, codeLensProvider));
53
54
this._register(languageFeaturesService.inlayHintsProvider.register(patterns, {
55
onDidChangeInlayHints: _mcpRegistry.onDidChangeInputs,
56
provideInlayHints: (model, range) => this._provideInlayHints(model, range),
57
}));
58
}
59
60
/** Simple mechanism to avoid extra json parsing for hints+lenses */
61
private async _parseModel(model: ITextModel) {
62
if (this._cachedMcpSection.value?.model === model) {
63
return this._cachedMcpSection.value;
64
}
65
66
const uri = model.uri;
67
const inConfig = await this._mcpWorkbenchService.getMcpConfigPath(model.uri);
68
if (!inConfig) {
69
return undefined;
70
}
71
72
const value = model.getValue();
73
const tree = parseTree(value);
74
const listeners = [
75
model.onDidChangeContent(() => this._cachedMcpSection.clear()),
76
model.onWillDispose(() => this._cachedMcpSection.clear()),
77
];
78
this._addDiagnostics(model, value, tree, inConfig);
79
80
return this._cachedMcpSection.value = {
81
model,
82
tree,
83
inConfig,
84
dispose: () => {
85
this._markerService.remove(diagnosticOwner, [uri]);
86
dispose(listeners);
87
}
88
};
89
}
90
91
private _addDiagnostics(tm: ITextModel, value: string, tree: Node, inConfig: IMcpConfigPath) {
92
const serversNode = findNodeAtLocation(tree, inConfig.section ? [...inConfig.section, 'servers'] : ['servers']);
93
if (!serversNode) {
94
return;
95
}
96
97
const getClosestMatchingVariable = (name: string) => {
98
let bestValue = '';
99
let bestDistance = Infinity;
100
for (const variable of this._configurationResolverService.resolvableVariables) {
101
const distance = computeLevenshteinDistance(name, variable);
102
if (distance < bestDistance) {
103
bestDistance = distance;
104
bestValue = variable;
105
}
106
}
107
return bestValue;
108
};
109
110
const diagnostics: IMarkerData[] = [];
111
forEachPropertyWithReplacement(serversNode, node => {
112
const expr = ConfigurationResolverExpression.parse(node.value);
113
114
for (const { id, name, arg } of expr.unresolved()) {
115
if (!this._configurationResolverService.resolvableVariables.has(name)) {
116
const position = value.indexOf(id, node.offset);
117
if (position === -1) { continue; } // unreachable?
118
119
const start = tm.getPositionAt(position);
120
const end = tm.getPositionAt(position + id.length);
121
diagnostics.push({
122
severity: MarkerSeverity.Warning,
123
message: localize('mcp.variableNotFound', 'Variable `{0}` not found, did you mean ${{1}}?', name, getClosestMatchingVariable(name) + (arg ? `:${arg}` : '')),
124
startLineNumber: start.lineNumber,
125
startColumn: start.column,
126
endLineNumber: end.lineNumber,
127
endColumn: end.column,
128
modelVersionId: tm.getVersionId(),
129
});
130
}
131
}
132
});
133
134
if (diagnostics.length) {
135
this._markerService.changeOne(diagnosticOwner, tm.uri, diagnostics);
136
} else {
137
this._markerService.remove(diagnosticOwner, [tm.uri]);
138
}
139
}
140
141
private async _provideCodeLenses(model: ITextModel, onDidChangeCodeLens: () => void): Promise<CodeLensList | undefined> {
142
const parsed = await this._parseModel(model);
143
if (!parsed) {
144
return undefined;
145
}
146
147
const { tree, inConfig } = parsed;
148
const serversNode = findNodeAtLocation(tree, inConfig.section ? [...inConfig.section, 'servers'] : ['servers']);
149
if (!serversNode) {
150
return undefined;
151
}
152
153
const store = new DisposableStore();
154
const lenses: CodeLens[] = [];
155
const lensList: CodeLensList = { lenses, dispose: () => store.dispose() };
156
const read = <T>(observable: IObservable<T>): T => {
157
store.add(Event.fromObservableLight(observable)(onDidChangeCodeLens));
158
return observable.get();
159
};
160
161
const collection = read(this._mcpRegistry.collections).find(c => isEqual(c.presentation?.origin, model.uri));
162
if (!collection) {
163
return lensList;
164
}
165
166
const mcpServers = read(this._mcpService.servers).filter(s => s.collection.id === collection.id);
167
for (const node of serversNode.children || []) {
168
if (node.type !== 'property' || node.children?.[0]?.type !== 'string') {
169
continue;
170
}
171
172
const name = node.children[0].value as string;
173
const server = mcpServers.find(s => s.definition.label === name);
174
if (!server) {
175
continue;
176
}
177
178
const range = Range.fromPositions(model.getPositionAt(node.children[0].offset));
179
const canDebug = !!server.readDefinitions().get().server?.devMode?.debug;
180
const state = read(server.connectionState).state;
181
switch (state) {
182
case McpConnectionState.Kind.Error:
183
lenses.push({
184
range,
185
command: {
186
id: McpCommandIds.ShowOutput,
187
title: '$(error) ' + localize('server.error', 'Error'),
188
arguments: [server.definition.id],
189
},
190
}, {
191
range,
192
command: {
193
id: McpCommandIds.RestartServer,
194
title: localize('mcp.restart', "Restart"),
195
arguments: [server.definition.id, { autoTrustChanges: true } satisfies IMcpServerStartOpts],
196
},
197
});
198
if (canDebug) {
199
lenses.push({
200
range,
201
command: {
202
id: McpCommandIds.RestartServer,
203
title: localize('mcp.debug', "Debug"),
204
arguments: [server.definition.id, { debug: true, autoTrustChanges: true } satisfies IMcpServerStartOpts],
205
},
206
});
207
}
208
break;
209
case McpConnectionState.Kind.Starting:
210
lenses.push({
211
range,
212
command: {
213
id: McpCommandIds.ShowOutput,
214
title: '$(loading~spin) ' + localize('server.starting', 'Starting'),
215
arguments: [server.definition.id],
216
},
217
}, {
218
range,
219
command: {
220
id: McpCommandIds.StopServer,
221
title: localize('cancel', "Cancel"),
222
arguments: [server.definition.id],
223
},
224
});
225
break;
226
case McpConnectionState.Kind.Running:
227
lenses.push({
228
range,
229
command: {
230
id: McpCommandIds.ShowOutput,
231
title: '$(check) ' + localize('server.running', 'Running'),
232
arguments: [server.definition.id],
233
},
234
}, {
235
range,
236
command: {
237
id: McpCommandIds.StopServer,
238
title: localize('mcp.stop', "Stop"),
239
arguments: [server.definition.id],
240
},
241
}, {
242
range,
243
command: {
244
id: McpCommandIds.RestartServer,
245
title: localize('mcp.restart', "Restart"),
246
arguments: [server.definition.id, { autoTrustChanges: true } satisfies IMcpServerStartOpts],
247
},
248
});
249
if (canDebug) {
250
lenses.push({
251
range,
252
command: {
253
id: McpCommandIds.RestartServer,
254
title: localize('mcp.debug', "Debug"),
255
arguments: [server.definition.id, { autoTrustChanges: true, debug: true } satisfies IMcpServerStartOpts],
256
},
257
});
258
}
259
break;
260
case McpConnectionState.Kind.Stopped:
261
lenses.push({
262
range,
263
command: {
264
id: McpCommandIds.StartServer,
265
title: '$(debug-start) ' + localize('mcp.start', "Start"),
266
arguments: [server.definition.id, { autoTrustChanges: true } satisfies IMcpServerStartOpts],
267
},
268
});
269
if (canDebug) {
270
lenses.push({
271
range,
272
command: {
273
id: McpCommandIds.StartServer,
274
title: localize('mcp.debug', "Debug"),
275
arguments: [server.definition.id, { autoTrustChanges: true, debug: true } satisfies IMcpServerStartOpts],
276
},
277
});
278
}
279
}
280
281
282
if (state !== McpConnectionState.Kind.Error) {
283
const toolCount = read(server.tools).length;
284
if (toolCount) {
285
lenses.push({
286
range,
287
command: {
288
id: '',
289
title: localize('server.toolCount', '{0} tools', toolCount),
290
}
291
});
292
}
293
294
295
const promptCount = read(server.prompts).length;
296
if (promptCount) {
297
lenses.push({
298
range,
299
command: {
300
id: McpCommandIds.StartPromptForServer,
301
title: localize('server.promptcount', '{0} prompts', promptCount),
302
arguments: [server],
303
}
304
});
305
}
306
307
lenses.push({
308
range,
309
command: {
310
id: McpCommandIds.ServerOptions,
311
title: localize('mcp.server.more', 'More...'),
312
arguments: [server.definition.id],
313
}
314
});
315
}
316
}
317
318
return lensList;
319
}
320
321
private async _provideInlayHints(model: ITextModel, range: Range): Promise<InlayHintList | undefined> {
322
const parsed = await this._parseModel(model);
323
if (!parsed) {
324
return undefined;
325
}
326
327
const { tree, inConfig } = parsed;
328
const mcpSection = inConfig.section ? findNodeAtLocation(tree, [...inConfig.section]) : tree;
329
if (!mcpSection) {
330
return undefined;
331
}
332
333
const inputsNode = findNodeAtLocation(mcpSection, ['inputs']);
334
if (!inputsNode) {
335
return undefined;
336
}
337
338
const inputs = await this._mcpRegistry.getSavedInputs(inConfig.scope);
339
const hints: InlayHint[] = [];
340
341
const serversNode = findNodeAtLocation(mcpSection, ['servers']);
342
if (serversNode) {
343
annotateServers(serversNode);
344
}
345
annotateInputs(inputsNode);
346
347
return { hints, dispose: () => { } };
348
349
function annotateServers(servers: Node) {
350
forEachPropertyWithReplacement(servers, node => {
351
const expr = ConfigurationResolverExpression.parse(node.value);
352
for (const { id } of expr.unresolved()) {
353
const saved = inputs[id];
354
if (saved) {
355
pushAnnotation(id, node.offset + node.value.indexOf(id) + id.length, saved);
356
}
357
}
358
});
359
}
360
361
function annotateInputs(node: Node) {
362
if (node.type !== 'array' || !node.children) {
363
return;
364
}
365
366
for (const input of node.children) {
367
if (input.type !== 'object' || !input.children) {
368
continue;
369
}
370
371
const idProp = input.children.find(c => c.type === 'property' && c.children?.[0].value === 'id');
372
if (!idProp) {
373
continue;
374
}
375
376
const id = idProp.children![1];
377
if (!id || id.type !== 'string' || !id.value) {
378
continue;
379
}
380
381
const savedId = '${input:' + id.value + '}';
382
const saved = inputs[savedId];
383
if (saved) {
384
pushAnnotation(savedId, id.offset + 1 + id.length, saved);
385
}
386
}
387
}
388
389
function pushAnnotation(savedId: string, offset: number, saved: IResolvedValue): InlayHint {
390
const tooltip = new MarkdownString([
391
markdownCommandLink({ id: McpCommandIds.EditStoredInput, title: localize('edit', 'Edit'), arguments: [savedId, model.uri, mcpConfigurationSection, inConfig!.target] }),
392
markdownCommandLink({ id: McpCommandIds.RemoveStoredInput, title: localize('clear', 'Clear'), arguments: [inConfig!.scope, savedId] }),
393
markdownCommandLink({ id: McpCommandIds.RemoveStoredInput, title: localize('clearAll', 'Clear All'), arguments: [inConfig!.scope] }),
394
].join(' | '), { isTrusted: true });
395
396
const hint: InlayHint = {
397
label: '= ' + (saved.input?.type === 'promptString' && saved.input.password ? '*'.repeat(10) : (saved.value || '')),
398
position: model.getPositionAt(offset),
399
tooltip,
400
paddingLeft: true,
401
};
402
403
hints.push(hint);
404
return hint;
405
}
406
}
407
}
408
409
410
411
function forEachPropertyWithReplacement(node: Node, callback: (node: Node) => void) {
412
if (node.type === 'string' && typeof node.value === 'string' && node.value.includes(ConfigurationResolverExpression.VARIABLE_LHS)) {
413
callback(node);
414
} else if (node.type === 'property') {
415
// skip the property name
416
node.children?.slice(1).forEach(n => forEachPropertyWithReplacement(n, callback));
417
} else {
418
node.children?.forEach(n => forEachPropertyWithReplacement(n, callback));
419
}
420
}
421
422
423
424