Path: blob/main/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.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 { decodeBase64, VSBuffer } from '../../../../base/common/buffer.js';6import { CancellationToken } from '../../../../base/common/cancellation.js';7import { Codicon } from '../../../../base/common/codicons.js';8import { MarkdownString } from '../../../../base/common/htmlContent.js';9import { Lazy } from '../../../../base/common/lazy.js';10import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';11import { equals } from '../../../../base/common/objects.js';12import { autorun, autorunSelfDisposable } from '../../../../base/common/observable.js';13import { basename } from '../../../../base/common/resources.js';14import { URI } from '../../../../base/common/uri.js';15import { localize } from '../../../../nls.js';16import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';17import { IFileService } from '../../../../platform/files/common/files.js';18import { IImageResizeService } from '../../../../platform/imageResize/common/imageResizeService.js';19import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';20import { IProductService } from '../../../../platform/product/common/productService.js';21import { StorageScope } from '../../../../platform/storage/common/storage.js';22import { IWorkbenchContribution } from '../../../common/contributions.js';23import { ChatResponseResource, getAttachableImageExtension } from '../../chat/common/chatModel.js';24import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, IToolResultInputOutputDetails, ToolDataSource, ToolProgress, ToolSet } from '../../chat/common/languageModelToolsService.js';25import { IMcpRegistry } from './mcpRegistryTypes.js';26import { IMcpServer, IMcpService, IMcpTool, LazyCollectionState, McpResourceURI, McpServerCacheState } from './mcpTypes.js';27import { mcpServerToSourceData } from './mcpTypesUtils.js';2829interface ISyncedToolData {30toolData: IToolData;31store: DisposableStore;32}3334export class McpLanguageModelToolContribution extends Disposable implements IWorkbenchContribution {3536public static readonly ID = 'workbench.contrib.mcp.languageModelTools';3738constructor(39@ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService,40@IMcpService mcpService: IMcpService,41@IInstantiationService private readonly _instantiationService: IInstantiationService,42@IMcpRegistry private readonly _mcpRegistry: IMcpRegistry,43) {44super();4546// 1. Auto-discover extensions with new MCP servers47const lazyCollectionState = mcpService.lazyCollectionState.map(s => s.state);48this._register(autorun(reader => {49if (lazyCollectionState.read(reader) === LazyCollectionState.HasUnknown) {50mcpService.activateCollections();51}52}));5354// 2. Keep tools in sync with the tools service.55const previous = this._register(new DisposableMap<IMcpServer, DisposableStore>());56this._register(autorun(reader => {57const servers = mcpService.servers.read(reader);5859const toDelete = new Set(previous.keys());60for (const server of servers) {61if (previous.has(server)) {62toDelete.delete(server);63continue;64}6566const store = new DisposableStore();67const toolSet = new Lazy(() => {68const source = mcpServerToSourceData(server);69const toolSet = store.add(this._toolsService.createToolSet(70source,71server.definition.id, server.definition.label,72{73icon: Codicon.mcp,74description: localize('mcp.toolset', "{0}: All Tools", server.definition.label)75}76));7778return { toolSet, source };79});8081this._syncTools(server, toolSet, store);82previous.set(server, store);83}8485for (const key of toDelete) {86previous.deleteAndDispose(key);87}88}));89}9091private _syncTools(server: IMcpServer, collectionData: Lazy<{ toolSet: ToolSet; source: ToolDataSource }>, store: DisposableStore) {92const tools = new Map</* tool ID */string, ISyncedToolData>();9394const collectionObservable = this._mcpRegistry.collections.map(collections =>95collections.find(c => c.id === server.collection.id));9697// If the server is extension-provided and was marked outdated automatically start it98store.add(autorunSelfDisposable(reader => {99const collection = collectionObservable.read(reader);100if (!collection) {101return;102}103104if (!(collection.source instanceof ExtensionIdentifier)) {105reader.dispose();106return;107}108109const cacheState = server.cacheState.read(reader);110if (cacheState === McpServerCacheState.Unknown || cacheState === McpServerCacheState.Outdated) {111reader.dispose();112server.start();113}114}));115116store.add(autorun(reader => {117const toDelete = new Set(tools.keys());118119// toRegister is deferred until deleting tools that moving a tool between120// servers (or deleting one instance of a multi-instance server) doesn't cause an error.121const toRegister: (() => void)[] = [];122const registerTool = (tool: IMcpTool, toolData: IToolData, store: DisposableStore) => {123store.add(this._toolsService.registerTool(toolData, this._instantiationService.createInstance(McpToolImplementation, tool, server)));124store.add(collectionData.value.toolSet.addTool(toolData));125};126127const collection = collectionObservable.read(reader);128for (const tool of server.tools.read(reader)) {129const existing = tools.get(tool.id);130const toolData: IToolData = {131id: tool.id,132source: collectionData.value.source,133icon: Codicon.tools,134// duplicative: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/813135displayName: tool.definition.annotations?.title || tool.definition.title || tool.definition.name,136toolReferenceName: tool.referenceName,137modelDescription: tool.definition.description ?? '',138userDescription: tool.definition.description ?? '',139inputSchema: tool.definition.inputSchema,140canBeReferencedInPrompt: true,141alwaysDisplayInputOutput: true,142runsInWorkspace: collection?.scope === StorageScope.WORKSPACE || !!collection?.remoteAuthority,143tags: ['mcp'],144};145146if (existing) {147if (!equals(existing.toolData, toolData)) {148existing.toolData = toolData;149existing.store.clear();150// We need to re-register both the data and implementation, as the151// implementation is discarded when the data is removed (#245921)152registerTool(tool, toolData, existing.store);153}154toDelete.delete(tool.id);155} else {156const store = new DisposableStore();157toRegister.push(() => registerTool(tool, toolData, store));158tools.set(tool.id, { toolData, store });159}160}161162for (const id of toDelete) {163const tool = tools.get(id);164if (tool) {165tool.store.dispose();166tools.delete(id);167}168}169170for (const fn of toRegister) {171fn();172}173}));174175store.add(toDisposable(() => {176for (const tool of tools.values()) {177tool.store.dispose();178}179}));180}181}182183class McpToolImplementation implements IToolImpl {184constructor(185private readonly _tool: IMcpTool,186private readonly _server: IMcpServer,187@IProductService private readonly _productService: IProductService,188@IFileService private readonly _fileService: IFileService,189@IImageResizeService private readonly _imageResizeService: IImageResizeService,190) { }191192async prepareToolInvocation(context: IToolInvocationPreparationContext): Promise<IPreparedToolInvocation> {193const tool = this._tool;194const server = this._server;195196const mcpToolWarning = localize(197'mcp.tool.warning',198"Note that MCP servers or malicious conversation content may attempt to misuse '{0}' through tools.",199this._productService.nameShort200);201202const needsConfirmation = !tool.definition.annotations?.readOnlyHint;203// duplicative: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/813204const title = tool.definition.annotations?.title || tool.definition.title || ('`' + tool.definition.name + '`');205206return {207confirmationMessages: needsConfirmation ? {208title: new MarkdownString(localize('msg.title', "Run {0}", title)),209message: new MarkdownString(tool.definition.description, { supportThemeIcons: true }),210disclaimer: mcpToolWarning,211allowAutoConfirm: true,212} : undefined,213invocationMessage: new MarkdownString(localize('msg.run', "Running {0}", title)),214pastTenseMessage: new MarkdownString(localize('msg.ran', "Ran {0} ", title)),215originMessage: localize('msg.subtitle', "{0} (MCP Server)", server.definition.label),216toolSpecificData: {217kind: 'input',218rawInput: context.parameters219}220};221}222223async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, progress: ToolProgress, token: CancellationToken) {224225const result: IToolResult = {226content: []227};228229const callResult = await this._tool.callWithProgress(invocation.parameters as Record<string, any>, progress, { chatRequestId: invocation.chatRequestId, chatSessionId: invocation.context?.sessionId }, token);230const details: IToolResultInputOutputDetails = {231input: JSON.stringify(invocation.parameters, undefined, 2),232output: [],233isError: callResult.isError === true,234};235236for (const item of callResult.content) {237const audience = item.annotations?.audience || ['assistant'];238if (audience.includes('user')) {239if (item.type === 'text') {240progress.report({ message: item.text });241}242}243244// Rewrite image resources to images so they are inlined nicely245const addAsInlineData = async (mimeType: string, value: string, uri?: URI): Promise<VSBuffer | void> => {246details.output.push({ type: 'embed', mimeType, value, uri });247if (isForModel) {248let finalData: VSBuffer;249try {250const resized = await this._imageResizeService.resizeImage(decodeBase64(value).buffer, mimeType);251finalData = VSBuffer.wrap(resized);252} catch {253finalData = decodeBase64(value);254}255result.content.push({ kind: 'data', value: { mimeType, data: finalData } });256}257};258259const isForModel = audience.includes('assistant');260if (item.type === 'text') {261details.output.push({ type: 'embed', isText: true, value: item.text });262// structured content 'represents the result of the tool call', so take263// that in place of any textual description when present.264if (isForModel && !callResult.structuredContent) {265result.content.push({266kind: 'text',267value: item.text268});269}270} else if (item.type === 'image' || item.type === 'audio') {271// default to some image type if not given to hint272await addAsInlineData(item.mimeType || 'image/png', item.data);273} else if (item.type === 'resource_link') {274const uri = McpResourceURI.fromServer(this._server.definition, item.uri);275details.output.push({276type: 'ref',277uri,278mimeType: item.mimeType,279});280281if (isForModel) {282if (item.mimeType && getAttachableImageExtension(item.mimeType)) {283result.content.push({284kind: 'data',285value: {286mimeType: item.mimeType,287data: await this._fileService.readFile(uri).then(f => f.value).catch(() => VSBuffer.alloc(0)),288}289});290} else {291result.content.push({292kind: 'text',293value: `The tool returns a resource which can be read from the URI ${uri}\n`,294});295}296}297} else if (item.type === 'resource') {298const uri = McpResourceURI.fromServer(this._server.definition, item.resource.uri);299if (item.resource.mimeType && getAttachableImageExtension(item.resource.mimeType) && 'blob' in item.resource) {300await addAsInlineData(item.resource.mimeType, item.resource.blob, uri);301} else {302details.output.push({303type: 'embed',304uri,305isText: 'text' in item.resource,306mimeType: item.resource.mimeType,307value: 'blob' in item.resource ? item.resource.blob : item.resource.text,308asResource: true,309});310311if (isForModel) {312const permalink = invocation.chatRequestId && invocation.context && ChatResponseResource.createUri(invocation.context.sessionId, invocation.chatRequestId, invocation.callId, result.content.length, basename(uri));313314result.content.push({315kind: 'text',316value: 'text' in item.resource ? item.resource.text : `The tool returns a resource which can be read from the URI ${permalink || uri}\n`,317});318}319}320}321}322323if (callResult.structuredContent) {324details.output.push({ type: 'embed', isText: true, value: JSON.stringify(callResult.structuredContent, null, 2) });325result.content.push({ kind: 'text', value: JSON.stringify(callResult.structuredContent) });326}327328result.toolResultDetails = details;329return result;330}331332}333334335