Path: blob/main/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts
5252 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, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';11import { equals } from '../../../../base/common/objects.js';12import { autorun } from '../../../../base/common/observable.js';13import { basename } from '../../../../base/common/resources.js';14import { isDefined, Mutable } from '../../../../base/common/types.js';15import { URI } from '../../../../base/common/uri.js';16import { localize } from '../../../../nls.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 { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';21import { mcpAppsEnabledConfig } from '../../../../platform/mcp/common/mcpManagement.js';22import { IProductService } from '../../../../platform/product/common/productService.js';23import { StorageScope } from '../../../../platform/storage/common/storage.js';24import { IWorkbenchContribution } from '../../../common/contributions.js';25import { ChatResponseResource, getAttachableImageExtension } from '../../chat/common/model/chatModel.js';26import { LanguageModelPartAudience } from '../../chat/common/languageModels.js';27import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolConfirmationMessages, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, IToolResultInputOutputDetails, ToolDataSource, ToolProgress, ToolSet } from '../../chat/common/tools/languageModelToolsService.js';28import { IMcpRegistry } from './mcpRegistryTypes.js';29import { IMcpServer, IMcpService, IMcpTool, IMcpToolResourceLinkContents, McpResourceURI, McpToolResourceLinkMimeType, McpToolVisibility } from './mcpTypes.js';30import { mcpServerToSourceData } from './mcpTypesUtils.js';31import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js';3233interface ISyncedToolData {34toolData: IToolData;35store: DisposableStore;36}3738export class McpLanguageModelToolContribution extends Disposable implements IWorkbenchContribution {3940public static readonly ID = 'workbench.contrib.mcp.languageModelTools';4142constructor(43@ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService,44@IMcpService mcpService: IMcpService,45@IInstantiationService private readonly _instantiationService: IInstantiationService,46@IMcpRegistry private readonly _mcpRegistry: IMcpRegistry,47@ILifecycleService private readonly lifecycleService: ILifecycleService,48) {49super();5051type Rec = { source?: ToolDataSource } & IDisposable;5253// Keep tools in sync with the tools service.54const previous = this._register(new DisposableMap<IMcpServer, Rec>());55this._register(autorun(reader => {56const servers = mcpService.servers.read(reader);5758const toDelete = new Set(previous.keys());59for (const server of servers) {60const previousRec = previous.get(server);61if (previousRec) {62toDelete.delete(server);63if (!previousRec.source || equals(previousRec.source, mcpServerToSourceData(server, reader))) {64continue; // same definition, no need to update65}6667previousRec.dispose();68}6970const store = new DisposableStore();71const rec: Rec = { dispose: () => store.dispose() };72const toolSet = new Lazy(() => {73const source = rec.source = mcpServerToSourceData(server);74const referenceName = server.definition.label.toLowerCase().replace(/\s+/g, '-'); // see issue https://github.com/microsoft/vscode/issues/27815275const toolSet = store.add(this._toolsService.createToolSet(76source,77server.definition.id,78referenceName,79{80icon: Codicon.mcp,81description: localize('mcp.toolset', "{0}: All Tools", server.definition.label)82}83));8485return { toolSet, source };86});8788this._syncTools(server, toolSet, store);89previous.set(server, rec);90}9192for (const key of toDelete) {93previous.deleteAndDispose(key);94}95}));96}9798private _syncTools(server: IMcpServer, collectionData: Lazy<{ toolSet: ToolSet; source: ToolDataSource }>, store: DisposableStore) {99const tools = new Map</* tool ID */string, ISyncedToolData>();100101const collectionObservable = this._mcpRegistry.collections.map(collections =>102collections.find(c => c.id === server.collection.id));103104store.add(autorun(reader => {105const toDelete = new Set(tools.keys());106107// toRegister is deferred until deleting tools that moving a tool between108// servers (or deleting one instance of a multi-instance server) doesn't cause an error.109const toRegister: (() => void)[] = [];110const registerTool = (tool: IMcpTool, toolData: IToolData, store: DisposableStore) => {111store.add(this._toolsService.registerTool(toolData, this._instantiationService.createInstance(McpToolImplementation, tool, server)));112store.add(collectionData.value.toolSet.addTool(toolData));113};114115// Don't bother cleaning up tools internally during shutdown. This just costs time for no benefit.116if (this.lifecycleService.willShutdown) {117return;118}119120const collection = collectionObservable.read(reader);121if (!collection) {122tools.forEach(t => t.store.dispose());123tools.clear();124return;125}126127for (const tool of server.tools.read(reader)) {128// Skip app-only tools - they should not be registered with the language model tools service129if (!(tool.visibility & McpToolVisibility.Model)) {130continue;131}132133const existing = tools.get(tool.id);134const icons = tool.icons.getUrl(22);135const toolData: IToolData = {136id: tool.id,137source: collectionData.value.source,138icon: icons || Codicon.tools,139// duplicative: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/813140displayName: tool.definition.annotations?.title || tool.definition.title || tool.definition.name,141toolReferenceName: tool.referenceName,142modelDescription: tool.definition.description ?? '',143userDescription: tool.definition.description ?? '',144inputSchema: tool.definition.inputSchema,145canBeReferencedInPrompt: true,146alwaysDisplayInputOutput: true,147canRequestPreApproval: !tool.definition.annotations?.readOnlyHint,148canRequestPostApproval: !!tool.definition.annotations?.openWorldHint,149runsInWorkspace: collection?.scope === StorageScope.WORKSPACE || !!collection?.remoteAuthority,150tags: ['mcp'],151};152153if (existing) {154if (!equals(existing.toolData, toolData)) {155existing.toolData = toolData;156existing.store.clear();157// We need to re-register both the data and implementation, as the158// implementation is discarded when the data is removed (#245921)159registerTool(tool, toolData, existing.store);160}161toDelete.delete(tool.id);162} else {163const store = new DisposableStore();164toRegister.push(() => registerTool(tool, toolData, store));165tools.set(tool.id, { toolData, store });166}167}168169for (const id of toDelete) {170const tool = tools.get(id);171if (tool) {172tool.store.dispose();173tools.delete(id);174}175}176177for (const fn of toRegister) {178fn();179}180181// Important: flush tool updates when the server is fully registered so that182// any consuming (e.g. autostarting) requests have the tools available immediately.183this._toolsService.flushToolUpdates();184}));185186store.add(toDisposable(() => {187for (const tool of tools.values()) {188tool.store.dispose();189}190}));191}192}193194class McpToolImplementation implements IToolImpl {195constructor(196private readonly _tool: IMcpTool,197private readonly _server: IMcpServer,198@IConfigurationService private readonly _configurationService: IConfigurationService,199@IProductService private readonly _productService: IProductService,200@IFileService private readonly _fileService: IFileService,201@IImageResizeService private readonly _imageResizeService: IImageResizeService,202) { }203204async prepareToolInvocation(context: IToolInvocationPreparationContext): Promise<IPreparedToolInvocation> {205const tool = this._tool;206const server = this._server;207208const mcpToolWarning = localize(209'mcp.tool.warning',210"Note that MCP servers or malicious conversation content may attempt to misuse '{0}' through tools.",211this._productService.nameShort212);213214// duplicative: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/813215const title = tool.definition.annotations?.title || tool.definition.title || ('`' + tool.definition.name + '`');216217const confirm: IToolConfirmationMessages = {};218if (!tool.definition.annotations?.readOnlyHint) {219confirm.title = new MarkdownString(localize('msg.title', "Run {0}", title));220confirm.message = new MarkdownString(tool.definition.description, { supportThemeIcons: true });221confirm.disclaimer = mcpToolWarning;222confirm.allowAutoConfirm = true;223}224if (tool.definition.annotations?.openWorldHint) {225confirm.confirmResults = true;226}227228const mcpUiEnabled = this._configurationService.getValue<boolean>(mcpAppsEnabledConfig);229230return {231confirmationMessages: confirm,232invocationMessage: new MarkdownString(localize('msg.run', "Running {0}", title)),233pastTenseMessage: new MarkdownString(localize('msg.ran', "Ran {0} ", title)),234originMessage: localize('msg.subtitle', "{0} (MCP Server)", server.definition.label),235toolSpecificData: {236kind: 'input',237rawInput: context.parameters,238mcpAppData: mcpUiEnabled && tool.uiResourceUri ? {239resourceUri: tool.uiResourceUri,240serverDefinitionId: server.definition.id,241collectionId: server.collection.id,242} : undefined,243}244};245}246247async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, progress: ToolProgress, token: CancellationToken) {248249const result: IToolResult = {250content: []251};252253const callResult = await this._tool.callWithProgress(invocation.parameters as Record<string, unknown>, progress, { chatRequestId: invocation.chatRequestId, chatSessionId: invocation.context?.sessionId }, token);254const details: Mutable<IToolResultInputOutputDetails> = {255input: JSON.stringify(invocation.parameters, undefined, 2),256output: [],257isError: callResult.isError === true,258};259260for (const item of callResult.content) {261const audience = item.annotations?.audience?.map(a => {262if (a === 'assistant') {263return LanguageModelPartAudience.Assistant;264} else if (a === 'user') {265return LanguageModelPartAudience.User;266} else {267return undefined;268}269}).filter(isDefined);270271// Explicit user parts get pushed to progress to show in the status UI272if (audience?.includes(LanguageModelPartAudience.User)) {273if (item.type === 'text') {274progress.report({ message: item.text });275}276}277278// Rewrite image resources to images so they are inlined nicely279const addAsInlineData = async (mimeType: string, value: string, uri?: URI): Promise<VSBuffer | void> => {280details.output.push({ type: 'embed', mimeType, value, uri, audience });281if (isForModel) {282let finalData: VSBuffer;283try {284const resized = await this._imageResizeService.resizeImage(decodeBase64(value).buffer, mimeType);285finalData = VSBuffer.wrap(resized);286} catch {287finalData = decodeBase64(value);288}289result.content.push({ kind: 'data', value: { mimeType, data: finalData }, audience });290}291};292293const addAsLinkedResource = (uri: URI, mimeType?: string) => {294const json: IMcpToolResourceLinkContents = { uri, underlyingMimeType: mimeType };295result.content.push({296kind: 'data',297audience,298value: {299mimeType: McpToolResourceLinkMimeType,300data: VSBuffer.fromString(JSON.stringify(json)),301},302});303};304305const isForModel = !audience || audience.includes(LanguageModelPartAudience.Assistant);306if (item.type === 'text') {307details.output.push({ type: 'embed', isText: true, value: item.text });308// structured content 'represents the result of the tool call', so take309// that in place of any textual description when present.310if (isForModel && !callResult.structuredContent) {311result.content.push({312kind: 'text',313audience,314value: item.text315});316}317} else if (item.type === 'image' || item.type === 'audio') {318// default to some image type if not given to hint319await addAsInlineData(item.mimeType || 'image/png', item.data);320} else if (item.type === 'resource_link') {321const uri = McpResourceURI.fromServer(this._server.definition, item.uri);322details.output.push({323type: 'ref',324uri,325audience,326mimeType: item.mimeType,327});328329if (isForModel) {330if (item.mimeType && getAttachableImageExtension(item.mimeType)) {331result.content.push({332kind: 'data',333audience,334value: {335mimeType: item.mimeType,336data: await this._fileService.readFile(uri).then(f => f.value).catch(() => VSBuffer.alloc(0)),337}338});339} else {340addAsLinkedResource(uri, item.mimeType);341}342}343} else if (item.type === 'resource') {344const uri = McpResourceURI.fromServer(this._server.definition, item.resource.uri);345if (item.resource.mimeType && getAttachableImageExtension(item.resource.mimeType) && 'blob' in item.resource) {346await addAsInlineData(item.resource.mimeType, item.resource.blob, uri);347} else {348details.output.push({349type: 'embed',350uri,351isText: 'text' in item.resource,352mimeType: item.resource.mimeType,353value: 'blob' in item.resource ? item.resource.blob : item.resource.text,354audience,355asResource: true,356});357358if (isForModel) {359const permalink = invocation.context && ChatResponseResource.createUri(invocation.context.sessionResource, invocation.callId, result.content.length, basename(uri));360addAsLinkedResource(permalink || uri, item.resource.mimeType);361}362}363}364}365366if (callResult.structuredContent) {367details.output.push({ type: 'embed', isText: true, value: JSON.stringify(callResult.structuredContent, null, 2), audience: [LanguageModelPartAudience.Assistant] });368result.content.push({ kind: 'text', value: JSON.stringify(callResult.structuredContent), audience: [LanguageModelPartAudience.Assistant] });369}370371// Add raw MCP output for MCP App UI rendering if this tool has UI372if (this._tool.uiResourceUri) {373details.mcpOutput = callResult;374}375376result.toolResultDetails = details;377return result;378}379380}381382383