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