diff --git a/.voidrules b/.voidrules index 0c9e6204..59586971 100644 --- a/.voidrules +++ b/.voidrules @@ -8,3 +8,5 @@ Look for services and built-in functions that you might need to use to solve the In typescript, do NOT cast to types if not neccessary. NEVER lazily cast to 'any'. Find the correct type to apply and use it. Do not add or remove semicolons to any of my files. Just go with convention and make the least number of changes. + +Never modify files outside src/vs/workbench/contrib/void without consulting with the user first. diff --git a/eslint.config.js b/eslint.config.js index 7acc50f2..4f014565 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -842,7 +842,9 @@ export default tseslint.config( '@xterm/xterm', 'yauzl', 'yazl', - 'zlib' + 'zlib', + // Void added this + '@modelcontextprotocol/sdk/**' ] }, { diff --git a/package-lock.json b/package-lock.json index 5158d409..9fc60e62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@mistralai/mistralai": "^1.6.0", - "@modelcontextprotocol/sdk": "^1.10.2", + "@modelcontextprotocol/sdk": "^1.11.2", "@parcel/watcher": "2.5.1", "@types/semver": "^7.5.8", "@vscode/deviceid": "^0.1.1", @@ -2616,9 +2616,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.10.2.tgz", - "integrity": "sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.2.tgz", + "integrity": "sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==", "license": "MIT", "dependencies": { "content-type": "^1.0.5", diff --git a/package.json b/package.json index 7a5bc366..e6341c09 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@mistralai/mistralai": "^1.6.0", - "@modelcontextprotocol/sdk": "^1.10.2", + "@modelcontextprotocol/sdk": "^1.11.2", "@parcel/watcher": "2.5.1", "@types/semver": "^7.5.8", "@vscode/deviceid": "^0.1.1", diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 4feafb34..1c103b1f 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -130,6 +130,7 @@ import { IVoidUpdateService } from '../../workbench/contrib/void/common/voidUpda import { MetricsMainService } from '../../workbench/contrib/void/electron-main/metricsMainService.js'; import { VoidMainUpdateService } from '../../workbench/contrib/void/electron-main/voidUpdateMainService.js'; import { LLMMessageChannel } from '../../workbench/contrib/void/electron-main/sendLLMMessageChannel.js'; +import { MCPChannel } from '../../workbench/contrib/void/electron-main/mcpChannel.js'; /** * The main VS Code application. There will only ever be one instance, @@ -1243,6 +1244,9 @@ export class CodeApplication extends Disposable { const sendLLMMessageChannel = new LLMMessageChannel(accessor.get(IMetricsService)); mainProcessElectronServer.registerChannel('void-channel-llmMessage', sendLLMMessageChannel); + const mcpChannel = new MCPChannel(); + mainProcessElectronServer.registerChannel('void-channel-mcp', mcpChannel); + // Extension Host Debug Broadcasting const electronExtensionHostDebugBroadcastChannel = new ElectronExtensionHostDebugBroadcastChannel(accessor.get(IWindowsMainService)); mainProcessElectronServer.registerChannel('extensionhostdebugservice', electronExtensionHostDebugBroadcastChannel); diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 40a87ce8..eb1a6883 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -11,12 +11,12 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { URI } from '../../../../base/common/uri.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { ILLMMessageService } from '../common/sendLLMMessageService.js'; -import { chat_userMessageContent, ToolName, } from '../common/prompt/prompts.js'; +import { chat_userMessageContent, isABuiltinToolName } from '../common/prompt/prompts.js'; import { AnthropicReasoning, getErrorMessage, RawToolCallObj, RawToolParamsObj } from '../common/sendLLMMessageTypes.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { FeatureName, ModelSelection, ModelSelectionOptions } from '../common/voidSettingsTypes.js'; import { IVoidSettingsService } from '../common/voidSettingsService.js'; -import { approvalTypeOfToolName, ToolCallParams, ToolResultType } from '../common/toolsServiceTypes.js'; +import { approvalTypeOfBuiltinToolName, BuiltinToolCallParams, ToolCallParams, ToolName, ToolResult } from '../common/toolsServiceTypes.js'; import { IToolsService } from './toolsService.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; @@ -37,6 +37,8 @@ import { deepClone } from '../../../../base/common/objects.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IDirectoryStrService } from '../common/directoryStrService.js'; import { IFileService } from '../../../../platform/files/common/files.js'; +import { IMCPService } from '../common/mcpService.js'; +import { RawMCPToolCall } from '../common/mcpServiceTypes.js'; // related to retrying when LLM message has error @@ -181,10 +183,11 @@ export type ThreadStreamState = { llmInfo?: undefined; toolInfo: { toolName: ToolName; - toolParams: ToolCallParams[ToolName]; + toolParams: ToolCallParams; id: string; content: string; rawParams: RawToolParamsObj; + mcpServerName: string | undefined; }; interrupt: Promise<() => void>; } | { @@ -323,6 +326,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IDirectoryStrService private readonly _directoryStringService: IDirectoryStrService, @IFileService private readonly _fileService: IFileService, + @IMCPService private readonly _mcpService: IMCPService, ) { super() this.state = { allThreads: {}, currentThreadId: null as unknown as string } // default state @@ -445,7 +449,8 @@ class ChatThreadService extends Disposable implements IChatThreadService { // if running now but stream state doesn't indicate it (happens if restart Void), cancel that last tool if (lastMessage && lastMessage.role === 'tool' && lastMessage.type === 'running_now') { - this._updateLatestTool(threadId, { role: 'tool', type: 'rejected', content: lastMessage.content, id: lastMessage.id, rawParams: lastMessage.rawParams, result: null, name: lastMessage.name, params: lastMessage.params }) + + this._updateLatestTool(threadId, { role: 'tool', type: 'rejected', content: lastMessage.content, id: lastMessage.id, rawParams: lastMessage.rawParams, result: null, name: lastMessage.name, params: lastMessage.params, mcpServerName: lastMessage.mcpServerName }) } } @@ -532,19 +537,23 @@ class ChatThreadService extends Disposable implements IChatThreadService { const lastMsg = thread.messages[thread.messages.length - 1] - let params: ToolCallParams[ToolName] + let params: ToolCallParams if (lastMsg.role === 'tool' && lastMsg.type !== 'invalid_params') { params = lastMsg.params } else return - const { name, id, rawParams } = lastMsg + const { name, id, rawParams, mcpServerName } = lastMsg const errorMessage = this.toolErrMsgs.rejected - this._updateLatestTool(threadId, { role: 'tool', type: 'rejected', params: params, name: name, content: errorMessage, result: null, id, rawParams }) + this._updateLatestTool(threadId, { role: 'tool', type: 'rejected', params: params, name: name, content: errorMessage, result: null, id, rawParams, mcpServerName }) this._setStreamState(threadId, undefined) } + private _computeMCPServerOfToolName = (toolName: string) => { + return this._mcpService.getMCPTools()?.find(t => t.name === toolName)?.mcpServerName + } + async abortRunning(threadId: string) { const thread = this.state.allThreads[threadId] if (!thread) return // should never happen @@ -553,13 +562,13 @@ class ChatThreadService extends Disposable implements IChatThreadService { if (this.streamState[threadId]?.isRunning === 'LLM') { const { displayContentSoFar, reasoningSoFar, toolCallSoFar } = this.streamState[threadId].llmInfo this._addMessageToThread(threadId, { role: 'assistant', displayContent: displayContentSoFar, reasoning: reasoningSoFar, anthropicReasoning: null }) - if (toolCallSoFar) this._addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: toolCallSoFar.name }) + if (toolCallSoFar) this._addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: toolCallSoFar.name, mcpServerName: this._computeMCPServerOfToolName(toolCallSoFar.name) }) } // add tool that's running else if (this.streamState[threadId]?.isRunning === 'tool') { - const { toolName, toolParams, id, content: content_, rawParams } = this.streamState[threadId].toolInfo + const { toolName, toolParams, id, content: content_, rawParams, mcpServerName } = this.streamState[threadId].toolInfo const content = content_ || this.toolErrMsgs.interrupted - this._updateLatestTool(threadId, { role: 'tool', name: toolName, params: toolParams, id, content, rawParams, type: 'rejected', result: null }) + this._updateLatestTool(threadId, { role: 'tool', name: toolName, params: toolParams, id, content, rawParams, type: 'rejected', result: null, mcpServerName }) } // reject the tool for the user if relevant else if (this.streamState[threadId]?.isRunning === 'awaiting_user') { @@ -597,36 +606,46 @@ class ChatThreadService extends Disposable implements IChatThreadService { threadId: string, toolName: ToolName, toolId: string, - opts: { preapproved: true, unvalidatedToolParams: RawToolParamsObj, validatedParams: ToolCallParams[ToolName] } | { preapproved: false, unvalidatedToolParams: RawToolParamsObj }, + mcpServerName: string | undefined, + opts: { preapproved: true, unvalidatedToolParams: RawToolParamsObj, validatedParams: ToolCallParams } | { preapproved: false, unvalidatedToolParams: RawToolParamsObj }, ): Promise<{ awaitingUserApproval?: boolean, interrupted?: boolean }> => { // compute these below - let toolParams: ToolCallParams[ToolName] - let toolResult: Awaited + let toolParams: ToolCallParams + let toolResult: ToolResult let toolResultStr: string + // Check if it's a built-in tool + const isBuiltInTool = isABuiltinToolName(toolName) + + if (!opts.preapproved) { // skip this if pre-approved // 1. validate tool params try { - const params = this._toolsService.validateParams[toolName](opts.unvalidatedToolParams) - toolParams = params - } catch (error) { + if (isBuiltInTool) { + const params = this._toolsService.validateParams[toolName](opts.unvalidatedToolParams) + toolParams = params + } + else { + toolParams = opts.unvalidatedToolParams + } + } + catch (error) { const errorMessage = getErrorMessage(error) - this._addMessageToThread(threadId, { role: 'tool', type: 'invalid_params', rawParams: opts.unvalidatedToolParams, result: null, name: toolName, content: errorMessage, id: toolId, }) + this._addMessageToThread(threadId, { role: 'tool', type: 'invalid_params', rawParams: opts.unvalidatedToolParams, result: null, name: toolName, content: errorMessage, id: toolId, mcpServerName }) return {} } // once validated, add checkpoint for edit - if (toolName === 'edit_file') { this._addToolEditCheckpoint({ threadId, uri: (toolParams as ToolCallParams['edit_file']).uri }) } - if (toolName === 'rewrite_file') { this._addToolEditCheckpoint({ threadId, uri: (toolParams as ToolCallParams['rewrite_file']).uri }) } + if (toolName === 'edit_file') { this._addToolEditCheckpoint({ threadId, uri: (toolParams as BuiltinToolCallParams['edit_file']).uri }) } + if (toolName === 'rewrite_file') { this._addToolEditCheckpoint({ threadId, uri: (toolParams as BuiltinToolCallParams['rewrite_file']).uri }) } // 2. if tool requires approval, break from the loop, awaiting approval - - const approvalType = approvalTypeOfToolName[toolName] + const approvalType = isBuiltInTool ? approvalTypeOfBuiltinToolName[toolName] : 'mcp-tools' if (approvalType) { const autoApprove = this._settingsService.state.globalSettings.autoApprove[approvalType] // add a tool_request because we use it for UI if a tool is loading (this should be improved in the future) - this._addMessageToThread(threadId, { role: 'tool', type: 'tool_request', content: '(Awaiting user permission...)', result: null, name: toolName, params: toolParams, id: toolId, rawParams: opts.unvalidatedToolParams }) + this._addMessageToThread(threadId, { role: 'tool', type: 'tool_request', content: '(Awaiting user permission...)', result: null, name: toolName, params: toolParams, id: toolId, rawParams: opts.unvalidatedToolParams, mcpServerName }) if (!autoApprove) { return { awaitingUserApproval: true } } @@ -638,9 +657,12 @@ class ChatThreadService extends Disposable implements IChatThreadService { + + + // 3. call the tool // this._setStreamState(threadId, { isRunning: 'tool' }, 'merge') - const runningTool = { role: 'tool', type: 'running_now', name: toolName, params: toolParams, content: '(value not received yet...)', result: null, id: toolId, rawParams: opts.unvalidatedToolParams } as const + const runningTool = { role: 'tool', type: 'running_now', name: toolName, params: toolParams, content: '(value not received yet...)', result: null, id: toolId, rawParams: opts.unvalidatedToolParams, mcpServerName } as const this._updateLatestTool(threadId, runningTool) @@ -650,13 +672,28 @@ class ChatThreadService extends Disposable implements IChatThreadService { try { // set stream state - this._setStreamState(threadId, { isRunning: 'tool', interrupt: interruptorPromise, toolInfo: { toolName, toolParams, id: toolId, content: 'interrupted...', rawParams: opts.unvalidatedToolParams } }) + this._setStreamState(threadId, { isRunning: 'tool', interrupt: interruptorPromise, toolInfo: { toolName, toolParams, id: toolId, content: 'interrupted...', rawParams: opts.unvalidatedToolParams, mcpServerName } }) - const { result, interruptTool } = await this._toolsService.callTool[toolName](toolParams as any) - const interruptor = () => { interrupted = true; interruptTool?.() } - resolveInterruptor(interruptor) + if (isBuiltInTool) { + const { result, interruptTool } = await this._toolsService.callTool[toolName](toolParams as any) + const interruptor = () => { interrupted = true; interruptTool?.() } + resolveInterruptor(interruptor) - toolResult = await result + toolResult = await result + } + else { + const mcpTools = this._mcpService.getMCPTools() + const mcpTool = mcpTools?.find(t => t.name === toolName) + if (!mcpTool) { throw new Error(`MCP tool ${toolName} not found`) } + + resolveInterruptor(() => { }) + + toolResult = (await this._mcpService.callMCPTool({ + serverName: mcpTool.mcpServerName ?? 'unknown_mcp_server', + toolName: toolName, + params: toolParams + })).result + } if (interrupted) { return { interrupted: true } } // the tool result is added where we interrupt, not here } @@ -665,21 +702,27 @@ class ChatThreadService extends Disposable implements IChatThreadService { if (interrupted) { return { interrupted: true } } // the tool result is added where we interrupt, not here const errorMessage = getErrorMessage(error) - this._updateLatestTool(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, id: toolId, rawParams: opts.unvalidatedToolParams }) + this._updateLatestTool(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, id: toolId, rawParams: opts.unvalidatedToolParams, mcpServerName }) return {} } // 4. stringify the result to give to the LLM try { - toolResultStr = this._toolsService.stringOfResult[toolName](toolParams as any, toolResult as any) + if (isBuiltInTool) { + toolResultStr = this._toolsService.stringOfResult[toolName](toolParams as any, toolResult as any) + } + // For MCP tools, handle the result based on its type + else { + toolResultStr = this._mcpService.stringifyResult(toolResult as RawMCPToolCall) + } } catch (error) { const errorMessage = this.toolErrMsgs.errWhenStringifying(error) - this._updateLatestTool(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, id: toolId, rawParams: opts.unvalidatedToolParams }) + this._updateLatestTool(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, id: toolId, rawParams: opts.unvalidatedToolParams, mcpServerName }) return {} } // 5. add to history and keep going - this._updateLatestTool(threadId, { role: 'tool', type: 'success', params: toolParams, result: toolResult, name: toolName, content: toolResultStr, id: toolId, rawParams: opts.unvalidatedToolParams }) + this._updateLatestTool(threadId, { role: 'tool', type: 'success', params: toolParams, result: toolResult, name: toolName, content: toolResultStr, id: toolId, rawParams: opts.unvalidatedToolParams, mcpServerName }) return {} }; @@ -714,7 +757,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { // before enter loop, call tool if (callThisToolFirst) { - const { interrupted } = await this._runToolCall(threadId, callThisToolFirst.name, callThisToolFirst.id, { preapproved: true, unvalidatedToolParams: callThisToolFirst.rawParams, validatedParams: callThisToolFirst.params }) + const { interrupted } = await this._runToolCall(threadId, callThisToolFirst.name, callThisToolFirst.id, callThisToolFirst.mcpServerName, { preapproved: true, unvalidatedToolParams: callThisToolFirst.rawParams, validatedParams: callThisToolFirst.params }) if (interrupted) { this._setStreamState(threadId, undefined) this._addUserCheckpoint({ threadId }) @@ -823,7 +866,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { const { error } = llmRes const { displayContentSoFar, reasoningSoFar, toolCallSoFar } = this.streamState[threadId].llmInfo this._addMessageToThread(threadId, { role: 'assistant', displayContent: displayContentSoFar, reasoning: reasoningSoFar, anthropicReasoning: null }) - if (toolCallSoFar) this._addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: toolCallSoFar.name }) + if (toolCallSoFar) this._addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: toolCallSoFar.name, mcpServerName: this._computeMCPServerOfToolName(toolCallSoFar.name) }) this._setStreamState(threadId, { isRunning: undefined, error }) this._addUserCheckpoint({ threadId }) @@ -840,7 +883,10 @@ class ChatThreadService extends Disposable implements IChatThreadService { // call tool if there is one if (toolCall) { - const { awaitingUserApproval, interrupted } = await this._runToolCall(threadId, toolCall.name, toolCall.id, { preapproved: false, unvalidatedToolParams: toolCall.rawParams }) + const mcpTools = this._mcpService.getMCPTools() + const mcpTool = mcpTools?.find(t => t.name === toolCall.name) + + const { awaitingUserApproval, interrupted } = await this._runToolCall(threadId, toolCall.name, toolCall.id, mcpTool?.mcpServerName, { preapproved: false, unvalidatedToolParams: toolCall.rawParams }) if (interrupted) { this._setStreamState(threadId, undefined) return @@ -1300,7 +1346,7 @@ We only need to do it for files that were edited since `from`, ie files between } // URIs of files that have been read else if (m.role === 'tool' && m.type === 'success' && m.name === 'read_file') { - const params = m.params as ToolCallParams['read_file'] + const params = m.params as BuiltinToolCallParams['read_file'] addURI(params.uri) } } diff --git a/src/vs/workbench/contrib/void/browser/convertToLLMMessageService.ts b/src/vs/workbench/contrib/void/browser/convertToLLMMessageService.ts index 8ebd5e76..471e0c24 100644 --- a/src/vs/workbench/contrib/void/browser/convertToLLMMessageService.ts +++ b/src/vs/workbench/contrib/void/browser/convertToLLMMessageService.ts @@ -7,7 +7,7 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/ import { IEditorService } from '../../../services/editor/common/editorService.js'; import { ChatMessage } from '../common/chatThreadServiceTypes.js'; import { getIsReasoningEnabledState, getReservedOutputTokenSpace, getModelCapabilities } from '../common/modelCapabilities.js'; -import { reParsedToolXMLString, chat_systemMessage, ToolName } from '../common/prompt/prompts.js'; +import { reParsedToolXMLString, chat_systemMessage } from '../common/prompt/prompts.js'; import { AnthropicLLMChatMessage, AnthropicReasoning, GeminiLLMChatMessage, LLMChatMessage, LLMFIMMessage, OpenAILLMChatMessage, RawToolParamsObj } from '../common/sendLLMMessageTypes.js'; import { IVoidSettingsService } from '../common/voidSettingsService.js'; import { ChatMode, FeatureName, ModelSelection, ProviderName } from '../common/voidSettingsTypes.js'; @@ -16,6 +16,8 @@ import { ITerminalToolService } from './terminalToolService.js'; import { IVoidModelService } from '../common/voidModelService.js'; import { URI } from '../../../../base/common/uri.js'; import { EndOfLinePreference } from '../../../../editor/common/model.js'; +import { ToolName } from '../common/toolsServiceTypes.js'; +import { IMCPService } from '../common/mcpService.js'; export const EMPTY_MESSAGE = '(empty message)' @@ -455,8 +457,8 @@ const prepareGeminiMessages = (messages: AnthropicLLMChatMessage[]) => { return { text: c.text } } else if (c.type === 'tool_use') { - latestToolName = c.name as ToolName - return { functionCall: { id: c.id, name: c.name as ToolName, args: c.input } } + latestToolName = c.name + return { functionCall: { id: c.id, name: c.name, args: c.input } } } else return null }).filter(m => !!m) @@ -538,6 +540,7 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess @ITerminalToolService private readonly terminalToolService: ITerminalToolService, @IVoidSettingsService private readonly voidSettingsService: IVoidSettingsService, @IVoidModelService private readonly voidModelService: IVoidModelService, + @IMCPService private readonly mcpService: IMCPService, ) { super() } @@ -587,8 +590,10 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess const includeXMLToolDefinitions = !specialToolFormat + const mcpTools = this.mcpService.getMCPTools() + const persistentTerminalIDs = this.terminalToolService.listPersistentTerminalIds() - const systemMessage = chat_systemMessage({ workspaceFolders, openedURIs, directoryStr, activeURI, persistentTerminalIDs, chatMode, includeXMLToolDefinitions }) + const systemMessage = chat_systemMessage({ workspaceFolders, openedURIs, directoryStr, activeURI, persistentTerminalIDs, chatMode, mcpTools, includeXMLToolDefinitions }) return systemMessage } diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index 7e60cac8..26b12cc8 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -13,7 +13,7 @@ import { ChatMarkdownRender, ChatMessageLocation, getApplyBoxId } from '../markd import { URI } from '../../../../../../../base/common/uri.js'; import { IDisposable } from '../../../../../../../base/common/lifecycle.js'; import { ErrorDisplay } from './ErrorDisplay.js'; -import { BlockCode, TextAreaFns, VoidCustomDropdownBox, VoidInputBox2, VoidSlider, VoidSwitch } from '../util/inputs.js'; +import { BlockCode, TextAreaFns, VoidCustomDropdownBox, VoidInputBox2, VoidSlider, VoidSwitch, VoidDiffEditor } from '../util/inputs.js'; import { ModelDropdown, } from '../void-settings-tsx/ModelDropdown.js'; import { PastThreadsList } from './SidebarThreadSelector.js'; import { VOID_CTRL_L_ACTION_ID } from '../../../actionIDs.js'; @@ -24,11 +24,11 @@ import { WarningBox } from '../void-settings-tsx/WarningBox.js'; import { getModelCapabilities, getIsReasoningEnabledState } from '../../../../common/modelCapabilities.js'; import { AlertTriangle, File, Ban, Check, ChevronRight, Dot, FileIcon, Pencil, Undo, Undo2, X, Flag, Copy as CopyIcon, Info, CirclePlus, Ellipsis, CircleEllipsis, Folder, ALargeSmall, TypeOutline, Text } from 'lucide-react'; import { ChatMessage, CheckpointEntry, StagingSelectionItem, ToolMessage } from '../../../../common/chatThreadServiceTypes.js'; -import { approvalTypeOfToolName, LintErrorItem, ToolApprovalType, toolApprovalTypes, ToolCallParams } from '../../../../common/toolsServiceTypes.js'; +import { approvalTypeOfBuiltinToolName, BuiltinToolCallParams, BuiltinToolName, ToolName, LintErrorItem, ToolApprovalType, toolApprovalTypes } from '../../../../common/toolsServiceTypes.js'; import { CopyButton, EditToolAcceptRejectButtonsHTML, IconShell1, JumpToFileButton, JumpToTerminalButton, StatusIndicator, StatusIndicatorForApplyButton, useApplyStreamState, useEditToolStreamState } from '../markdown/ApplyBlockHoverButtons.js'; import { IsRunningType } from '../../../chatThreadService.js'; import { acceptAllBg, acceptBorder, buttonFontSize, buttonTextColor, rejectAllBg, rejectBg, rejectBorder } from '../../../../common/helpers/colors.js'; -import { MAX_FILE_CHARS_PAGE, MAX_TERMINAL_INACTIVE_TIME, ToolName, toolNames } from '../../../../common/prompt/prompts.js'; +import { builtinToolNames, isABuiltinToolName, MAX_FILE_CHARS_PAGE, MAX_TERMINAL_INACTIVE_TIME } from '../../../../common/prompt/prompts.js'; import { RawToolCallObj } from '../../../../common/sendLLMMessageTypes.js'; import ErrorBoundary from './ErrorBoundary.js'; import { ToolApprovalTypeSwitch } from '../void-settings-tsx/Settings.js'; @@ -36,6 +36,7 @@ import { ToolApprovalTypeSwitch } from '../void-settings-tsx/Settings.js'; import { persistentTerminalNameOfId } from '../../../terminalToolService.js'; + export const IconX = ({ size, className = '', ...props }: { size: number, className?: string } & React.SVGProps) => { return ( voidOpenFileFn(params.uri, accessor) const componentParams: ToolHeaderParams = { title, desc1, desc1OnClick, desc1Info, isError, icon, isRejected, } + + const editToolType = toolMessage.name === 'edit_file' ? 'diff' : 'rewrite' if (toolMessage.type === 'running_now' || toolMessage.type === 'tool_request') { componentParams.children = // JumpToFileButton removed in favor of FileLinkText @@ -936,6 +940,7 @@ const EditTool = ({ toolMessage, threadId, messageIdx, content }: Parameters @@ -1388,7 +1393,7 @@ const loadingTitleWrapper = (item: React.ReactNode): React.ReactNode => { } -const titleOfToolName = { +const titleOfBuiltinToolName = { 'read_file': { done: 'Read file', proposed: 'Read file', running: loadingTitleWrapper('Reading file') }, 'ls_dir': { done: 'Inspected folder', proposed: 'Inspect folder', running: loadingTitleWrapper('Inspecting folder') }, 'get_dir_tree': { done: 'Inspected folder tree', proposed: 'Inspect folder tree', running: loadingTitleWrapper('Inspecting folder tree') }, @@ -1406,21 +1411,42 @@ const titleOfToolName = { 'read_lint_errors': { done: `Read lint errors`, proposed: 'Read lint errors', running: loadingTitleWrapper('Reading lint errors') }, 'search_in_file': { done: 'Searched in file', proposed: 'Search in file', running: loadingTitleWrapper('Searching in file') }, -} as const satisfies Record +} as const satisfies Record -const getTitle = (toolMessage: Pick): React.ReactNode => { +const getTitle = (toolMessage: Pick): React.ReactNode => { const t = toolMessage - if (!toolNames.includes(t.name as ToolName)) return t.name // good measure - const toolName = t.name as ToolName - if (t.type === 'success') return titleOfToolName[toolName].done - if (t.type === 'running_now') return titleOfToolName[toolName].running - return titleOfToolName[toolName].proposed + // non-built-in title + if (!builtinToolNames.includes(t.name as BuiltinToolName)) { + // descriptor of Running or Ran etc + const descriptor = + t.type === 'success' ? 'Called' + : t.type === 'running_now' ? 'Calling' + : t.type === 'tool_request' ? 'Call' + : t.type === 'rejected' ? 'Call' + : t.type === 'invalid_params' ? 'Call' + : t.type === 'tool_error' ? 'Call' + : 'Call' + + + const title = `${descriptor} ${toolMessage.mcpServerName || 'MCP'}` + if (t.type === 'running_now' || t.type === 'tool_request') + return loadingTitleWrapper(title) + return title + } + + // built-in title + else { + const toolName = t.name as BuiltinToolName + if (t.type === 'success') return titleOfBuiltinToolName[toolName].done + if (t.type === 'running_now') return titleOfBuiltinToolName[toolName].running + return titleOfBuiltinToolName[toolName].proposed + } } -const toolNameToDesc = (toolName: ToolName, _toolParams: ToolCallParams[ToolName] | undefined, accessor: ReturnType): { +const toolNameToDesc = (toolName: BuiltinToolName, _toolParams: BuiltinToolCallParams[BuiltinToolName] | undefined, accessor: ReturnType): { desc1: React.ReactNode, desc1Info?: string, } => { @@ -1431,95 +1457,95 @@ const toolNameToDesc = (toolName: ToolName, _toolParams: ToolCallParams[ToolName const x = { 'read_file': () => { - const toolParams = _toolParams as ToolCallParams['read_file'] + const toolParams = _toolParams as BuiltinToolCallParams['read_file'] return { desc1: getBasename(toolParams.uri.fsPath), desc1Info: getRelative(toolParams.uri, accessor), }; }, 'ls_dir': () => { - const toolParams = _toolParams as ToolCallParams['ls_dir'] + const toolParams = _toolParams as BuiltinToolCallParams['ls_dir'] return { desc1: getFolderName(toolParams.uri.fsPath), desc1Info: getRelative(toolParams.uri, accessor), }; }, 'search_pathnames_only': () => { - const toolParams = _toolParams as ToolCallParams['search_pathnames_only'] + const toolParams = _toolParams as BuiltinToolCallParams['search_pathnames_only'] return { desc1: `"${toolParams.query}"`, } }, 'search_for_files': () => { - const toolParams = _toolParams as ToolCallParams['search_for_files'] + const toolParams = _toolParams as BuiltinToolCallParams['search_for_files'] return { desc1: `"${toolParams.query}"`, } }, 'search_in_file': () => { - const toolParams = _toolParams as ToolCallParams['search_in_file']; + const toolParams = _toolParams as BuiltinToolCallParams['search_in_file']; return { desc1: `"${toolParams.query}"`, desc1Info: getRelative(toolParams.uri, accessor), }; }, 'create_file_or_folder': () => { - const toolParams = _toolParams as ToolCallParams['create_file_or_folder'] + const toolParams = _toolParams as BuiltinToolCallParams['create_file_or_folder'] return { desc1: toolParams.isFolder ? getFolderName(toolParams.uri.fsPath) ?? '/' : getBasename(toolParams.uri.fsPath), desc1Info: getRelative(toolParams.uri, accessor), } }, 'delete_file_or_folder': () => { - const toolParams = _toolParams as ToolCallParams['delete_file_or_folder'] + const toolParams = _toolParams as BuiltinToolCallParams['delete_file_or_folder'] return { desc1: toolParams.isFolder ? getFolderName(toolParams.uri.fsPath) ?? '/' : getBasename(toolParams.uri.fsPath), desc1Info: getRelative(toolParams.uri, accessor), } }, 'rewrite_file': () => { - const toolParams = _toolParams as ToolCallParams['rewrite_file'] + const toolParams = _toolParams as BuiltinToolCallParams['rewrite_file'] return { desc1: getBasename(toolParams.uri.fsPath), desc1Info: getRelative(toolParams.uri, accessor), } }, 'edit_file': () => { - const toolParams = _toolParams as ToolCallParams['edit_file'] + const toolParams = _toolParams as BuiltinToolCallParams['edit_file'] return { desc1: getBasename(toolParams.uri.fsPath), desc1Info: getRelative(toolParams.uri, accessor), } }, 'run_command': () => { - const toolParams = _toolParams as ToolCallParams['run_command'] + const toolParams = _toolParams as BuiltinToolCallParams['run_command'] return { desc1: `"${toolParams.command}"`, } }, 'run_persistent_command': () => { - const toolParams = _toolParams as ToolCallParams['run_persistent_command'] + const toolParams = _toolParams as BuiltinToolCallParams['run_persistent_command'] return { desc1: `"${toolParams.command}"`, } }, 'open_persistent_terminal': () => { - const toolParams = _toolParams as ToolCallParams['open_persistent_terminal'] + const toolParams = _toolParams as BuiltinToolCallParams['open_persistent_terminal'] return { desc1: '' } }, 'kill_persistent_terminal': () => { - const toolParams = _toolParams as ToolCallParams['kill_persistent_terminal'] + const toolParams = _toolParams as BuiltinToolCallParams['kill_persistent_terminal'] return { desc1: toolParams.persistentTerminalId } }, 'get_dir_tree': () => { - const toolParams = _toolParams as ToolCallParams['get_dir_tree'] + const toolParams = _toolParams as BuiltinToolCallParams['get_dir_tree'] return { desc1: getFolderName(toolParams.uri.fsPath) ?? '/', desc1Info: getRelative(toolParams.uri, accessor), } }, 'read_lint_errors': () => { - const toolParams = _toolParams as ToolCallParams['read_lint_errors'] + const toolParams = _toolParams as BuiltinToolCallParams['read_lint_errors'] return { desc1: getBasename(toolParams.uri.fsPath), desc1Info: getRelative(toolParams.uri, accessor), @@ -1590,9 +1616,9 @@ const ToolRequestAcceptRejectButtons = ({ toolName }: { toolName: ToolName }) => ) - const approvalType = approvalTypeOfToolName[toolName] + const approvalType = isABuiltinToolName(toolName) ? approvalTypeOfBuiltinToolName[toolName] : 'mcp-tools' const approvalToggle = approvalType ?
- +
: null return
@@ -1604,7 +1630,7 @@ const ToolRequestAcceptRejectButtons = ({ toolName }: { toolName: ToolName }) => export const ToolChildrenWrapper = ({ children, className }: { children: React.ReactNode, className?: string }) => { return
-
+
{children}
@@ -1633,12 +1659,18 @@ export const ListableToolItem = ({ name, onClick, isSmall, className, showDot }: -const EditToolChildren = ({ uri, code }: { uri: URI | undefined, code: string }) => { +const EditToolChildren = ({ uri, code, type }: { uri: URI | undefined, code: string, type: 'diff' | 'rewrite' }) => { + + const content = type === 'diff' ? + + : + return
- + {content}
+ } @@ -1689,9 +1721,9 @@ const EditToolHeaderButtons = ({ applyBoxId, uri, codeStr, toolName, threadId }: -const InvalidTool = ({ toolName, message }: { toolName: ToolName, message: string }) => { +const InvalidTool = ({ toolName, message, mcpServerName }: { toolName: ToolName, message: string, mcpServerName: string | undefined }) => { const accessor = useAccessor() - const title = getTitle({ name: toolName, type: 'invalid_params' }) + const title = getTitle({ name: toolName, type: 'invalid_params', mcpServerName }) const desc1 = 'Invalid parameters' const icon = null const isError = true @@ -1705,9 +1737,9 @@ const InvalidTool = ({ toolName, message }: { toolName: ToolName, message: strin return } -const CanceledTool = ({ toolName }: { toolName: ToolName }) => { +const CanceledTool = ({ toolName, mcpServerName }: { toolName: ToolName, mcpServerName: string | undefined }) => { const accessor = useAccessor() - const title = getTitle({ name: toolName, type: 'rejected' }) + const title = getTitle({ name: toolName, type: 'rejected', mcpServerName }) const desc1 = '' const icon = null const isRejected = true @@ -1819,9 +1851,61 @@ const CommandTool = ({ toolMessage, type, threadId }: { threadId: string } & ({ } +type WrapperProps = { toolMessage: Exclude, { type: 'invalid_params' }>, messageIdx: number, threadId: string } +const MCPToolWrapper = ({ toolMessage }: WrapperProps) => { + const accessor = useAccessor() + const mcpService = accessor.get('IMCPService') -type ResultWrapper = (props: { toolMessage: Exclude, { type: 'invalid_params' }>, messageIdx: number, threadId: string }) => React.ReactNode -const toolNameToComponent: { [T in ToolName]: { resultWrapper: ResultWrapper, } } = { + const title = getTitle(toolMessage) + const desc1 = toolMessage.name + const icon = null + + + if (toolMessage.type === 'running_now') return null // do not show running + + const isError = false + const isRejected = toolMessage.type === 'rejected' + const { rawParams, params } = toolMessage + const componentParams: ToolHeaderParams = { title, desc1, isError, icon, isRejected, } + + const paramsStr = JSON.stringify(params, null, 2) + componentParams.desc2 = + + componentParams.info = !toolMessage.mcpServerName ? 'MCP tool not found' : undefined + + // Add copy inputs button in desc2 + + + if (toolMessage.type === 'success' || toolMessage.type === 'tool_request') { + const { result } = toolMessage + const resultStr = result ? mcpService.stringifyResult(result) : 'null' + componentParams.children = + + + + + } + else if (toolMessage.type === 'tool_error') { + const { result } = toolMessage + componentParams.bottomChildren = + + {result} + + + } + + return + +} + +type ResultWrapper = (props: WrapperProps) => React.ReactNode + +const builtinToolNameToComponent: { [T in BuiltinToolName]: { resultWrapper: ResultWrapper, } } = { 'read_file': { resultWrapper: ({ toolMessage }) => { const accessor = useAccessor() @@ -2257,12 +2341,12 @@ const toolNameToComponent: { [T in ToolName]: { resultWrapper: ResultWrapper, }, 'rewrite_file': { resultWrapper: (params) => { - return + return } }, 'edit_file': { resultWrapper: (params) => { - return + return } }, @@ -2442,11 +2526,15 @@ const _ChatBubble = ({ threadId, chatMessage, currCheckpointIdx, isCommitted, me if (chatMessage.type === 'invalid_params') { return
- +
} - const ToolResultWrapper = toolNameToComponent[chatMessage.name]?.resultWrapper as ResultWrapper + const toolName = chatMessage.name + const isBuiltInTool = isABuiltinToolName(toolName) + const ToolResultWrapper = isBuiltInTool ? builtinToolNameToComponent[toolName]?.resultWrapper as ResultWrapper + : MCPToolWrapper as ResultWrapper + if (ToolResultWrapper) return <>
@@ -2466,7 +2554,7 @@ const _ChatBubble = ({ threadId, chatMessage, currCheckpointIdx, isCommitted, me else if (role === 'interrupted_streaming_tool') { return
- +
} @@ -2746,12 +2834,13 @@ const CommandBarInChat = () => { const EditToolSoFar = ({ toolCallSoFar, }: { toolCallSoFar: RawToolCallObj }) => { + if (!isABuiltinToolName(toolCallSoFar.name)) return null const accessor = useAccessor() const uri = toolCallSoFar.rawParams.uri ? URI.file(toolCallSoFar.rawParams.uri) : undefined - const title = titleOfToolName[toolCallSoFar.name].proposed + const title = titleOfBuiltinToolName[toolCallSoFar.name].proposed const uriDone = toolCallSoFar.doneParams.includes('uri') const desc1 = @@ -2772,12 +2861,11 @@ const EditToolSoFar = ({ toolCallSoFar, }: { toolCallSoFar: RawToolCallObj }) => - - } diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx index d2b838e1..d877e110 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx @@ -20,6 +20,11 @@ import { URI } from '../../../../../../../base/common/uri.js'; import { getBasename, getFolderName } from '../sidebar-tsx/SidebarChat.js'; import { ChevronRight, File, Folder, FolderClosed, LucideProps } from 'lucide-react'; import { StagingSelectionItem } from '../../../../common/chatThreadServiceTypes.js'; +import { DiffEditorWidget } from '../../../../../../../editor/browser/widget/diffEditor/diffEditorWidget.js'; +import { extractSearchReplaceBlocks, ExtractedSearchReplaceBlock } from '../../../../common/helpers/extractCodeFromResult.js'; +import { IAccessibilitySignalService } from '../../../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { IEditorProgressService } from '../../../../../../../platform/progress/common/progress.js'; +import { detectLanguage } from '../../../../common/helpers/languageHelpers.js'; // type guard @@ -951,11 +956,11 @@ export const VoidInputBox = ({ onChangeText, onCreateInstance, inputBoxRef, plac const contextViewProvider = accessor.get('IContextViewService') return [ container, contextViewProvider, @@ -991,8 +996,7 @@ export const VoidInputBox = ({ onChangeText, onCreateInstance, inputBoxRef, plac inputBoxRef.current = instance; return disposables - }, [onChangeText, onCreateInstance, inputBoxRef]) - } + }, [onChangeText, onCreateInstance, inputBoxRef])} /> }; @@ -1841,3 +1845,154 @@ export const VoidButtonBgDarken = ({ children, disabled, onClick, className }: { // }; + + +const SingleDiffEditor = ({ block, lang }: { block: ExtractedSearchReplaceBlock, lang: string | undefined }) => { + const accessor = useAccessor(); + const modelService = accessor.get('IModelService'); + const instantiationService = accessor.get('IInstantiationService'); + const languageService = accessor.get('ILanguageService'); + + const languageSelection = useMemo(() => languageService.createById(lang), [lang, languageService]); + + // Create models for original and modified + const originalModel = useMemo(() => + modelService.createModel(block.orig, languageSelection), + [block.orig, languageSelection, modelService] + ); + const modifiedModel = useMemo(() => + modelService.createModel(block.final, languageSelection), + [block.final, languageSelection, modelService] + ); + + // Clean up models on unmount + useEffect(() => { + return () => { + originalModel.dispose(); + modifiedModel.dispose(); + }; + }, [originalModel, modifiedModel]); + + // Imperatively mount the DiffEditorWidget + const divRef = useRef(null); + const editorRef = useRef(null); + + useEffect(() => { + if (!divRef.current) return; + // Create the diff editor instance + const editor = instantiationService.createInstance( + DiffEditorWidget, + divRef.current, + { + automaticLayout: true, + readOnly: true, + renderSideBySide: true, + minimap: { enabled: false }, + lineNumbers: 'off', + scrollbar: { + vertical: 'hidden', + horizontal: 'auto', + verticalScrollbarSize: 0, + horizontalScrollbarSize: 8, + alwaysConsumeMouseWheel: false, + ignoreHorizontalScrollbarInContentHeight: true, + }, + hover: { enabled: false }, + folding: false, + selectionHighlight: false, + renderLineHighlight: 'none', + overviewRulerLanes: 0, + hideCursorInOverviewRuler: true, + overviewRulerBorder: false, + glyphMargin: false, + stickyScroll: { enabled: false }, + scrollBeyondLastLine: false, + renderGutterMenu: false, + renderIndicators: false, + }, + { originalEditor: { isSimpleWidget: true }, modifiedEditor: { isSimpleWidget: true } } + ); + editor.setModel({ original: originalModel, modified: modifiedModel }); + + // Calculate the height based on content + const updateHeight = () => { + const contentHeight = Math.max( + originalModel.getLineCount() * 19, // approximate line height + modifiedModel.getLineCount() * 19 + ) + 19 * 2 + 1; // add padding + + // Set reasonable min/max heights + const height = Math.min(Math.max(contentHeight, 100), 300); + if (divRef.current) { + divRef.current.style.height = `${height}px`; + editor.layout(); + } + }; + + updateHeight(); + editorRef.current = editor; + + // Update height when content changes + const disposable1 = originalModel.onDidChangeContent(() => updateHeight()); + const disposable2 = modifiedModel.onDidChangeContent(() => updateHeight()); + + return () => { + disposable1.dispose(); + disposable2.dispose(); + editor.dispose(); + editorRef.current = null; + }; + }, [originalModel, modifiedModel, instantiationService]); + + return ( +
+ ); +}; + + + + + +/** + * ToolDiffEditor mounts a native VSCode DiffEditorWidget to show a diff between original and modified code blocks. + * Props: + * - uri: URI of the file (for language detection, etc) + * - searchReplaceBlocks: string in search/replace format (from LLM) + * - language?: string (optional, fallback to 'plaintext') + */ +export const VoidDiffEditor = ({ uri, searchReplaceBlocks, language }: { uri?: any, searchReplaceBlocks: string, language?: string }) => { + const accessor = useAccessor(); + const languageService = accessor.get('ILanguageService'); + + // Extract all blocks + const blocks = extractSearchReplaceBlocks(searchReplaceBlocks); + + // Use detectLanguage for language detection if not provided + let lang = language; + if (!lang && blocks.length > 0) { + lang = detectLanguage(languageService, { uri: uri ?? null, fileContents: blocks[0].orig }); + } + + // If no blocks, show empty state + if (blocks.length === 0) { + return
No changes found
; + } + + // Display all blocks + return ( +
+ {blocks.map((block, index) => ( +
+ {blocks.length > 1 && ( +
+ Change {index + 1} of {blocks.length} +
+ )} + +
+ ))} +
+ ); +}; + + diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx index 82f47954..dc67784c 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/services.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------*/ import React, { useState, useEffect, useCallback } from 'react' -import { RefreshableProviderName, SettingsOfProvider } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js' +import { MCPUserState, RefreshableProviderName, SettingsOfProvider } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js' import { IDisposable } from '../../../../../../../base/common/lifecycle.js' import { VoidSettingsState } from '../../../../../../../workbench/contrib/void/common/voidSettingsService.js' import { ColorScheme } from '../../../../../../../platform/theme/common/theme.js' @@ -51,6 +51,7 @@ import { IConvertToLLMMessageService } from '../../../convertToLLMMessageService import { ITerminalService } from '../../../../../terminal/browser/terminal.js' import { ISearchService } from '../../../../../../services/search/common/search.js' import { IExtensionManagementService } from '../../../../../../../platform/extensionManagement/common/extensionManagement.js' +import { IMCPService } from '../../../../common/mcpService.js'; // normally to do this you'd use a useEffect that calls .onDidChangeState(), but useEffect mounts too late and misses initial state changes @@ -78,6 +79,8 @@ const ctrlKZoneStreamingStateListeners: Set<(diffareaid: number, s: boolean) => const commandBarURIStateListeners: Set<(uri: URI) => void> = new Set(); const activeURIListeners: Set<(uri: URI | null) => void> = new Set(); +const mcpListeners: Set<() => void> = new Set() + // must call this before you can use any of the hooks below // this should only be called ONCE! this is the only place you don't need to dispose onDidChange. If you use state.onDidChange anywhere else, make sure to dispose it! @@ -95,9 +98,10 @@ export const _registerServices = (accessor: ServicesAccessor) => { editCodeService: accessor.get(IEditCodeService), voidCommandBarService: accessor.get(IVoidCommandBarService), modelService: accessor.get(IModelService), + mcpService: accessor.get(IMCPService), } - const { settingsStateService, chatThreadsStateService, refreshModelService, themeService, editCodeService, voidCommandBarService, modelService } = stateServices + const { settingsStateService, chatThreadsStateService, refreshModelService, themeService, editCodeService, voidCommandBarService, modelService, mcpService } = stateServices @@ -164,6 +168,11 @@ export const _registerServices = (accessor: ServicesAccessor) => { }) ) + disposables.push( + mcpService.onDidChangeState(() => { + mcpListeners.forEach(l => l()) + }) + ) return disposables @@ -215,6 +224,7 @@ const getReactAccessor = (accessor: ServicesAccessor) => { ITerminalService: accessor.get(ITerminalService), IExtensionManagementService: accessor.get(IExtensionManagementService), IExtensionTransferService: accessor.get(IExtensionTransferService), + IMCPService: accessor.get(IMCPService), } as const return reactAccessor @@ -376,3 +386,16 @@ export const useActiveURI = () => { + +export const useMCPServiceState = () => { + const accessor = useAccessor() + const mcpService = accessor.get('IMCPService') + const [s, ss] = useState(mcpService.state) + useEffect(() => { + const listener = () => { ss(mcpService.state) } + mcpListeners.add(listener); + return () => { mcpListeners.delete(listener) }; + }, []); + return s +} + diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx index 3cc67ded..d0f2529d 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx @@ -19,6 +19,8 @@ import { ToolApprovalType, toolApprovalTypes } from '../../../../common/toolsSer import Severity from '../../../../../../../base/common/severity.js' import { getModelCapabilities, modelOverrideKeys, ModelOverrides } from '../../../../common/modelCapabilities.js'; import { TransferEditorType, TransferFilesInfo } from '../../../extensionTransferTypes.js'; +import { MCPServer } from '../../../../common/mcpServiceTypes.js'; +import { useMCPServiceState } from '../util/services.js'; const ButtonLeftTextRightOption = ({ text, leftButton }: { text: string, leftButton?: React.ReactNode }) => { @@ -454,7 +456,7 @@ export const ModelDump = ({ filteredProviders }: { filteredProviders?: ProviderN {/* left part is width:full */}
{isNewProviderName ? providerTitle : ''} - {modelName} + {modelName}
{/* right part is anything that fits */} @@ -493,7 +495,15 @@ export const ModelDump = ({ filteredProviders }: { filteredProviders?: ProviderN {/* X button */}
- {type === 'default' || type === 'autodetected' ? null : } + {type === 'default' || type === 'autodetected' ? null : }
@@ -898,6 +908,110 @@ export const OneClickSwitchButton = ({ fromEditor = 'VS Code', className = '' }: // full settings +// MCP Server component +const MCPServerComponent = ({ name, server }: { name: string, server: MCPServer }) => { + const accessor = useAccessor(); + const mcpService = accessor.get('IMCPService'); + + const voidSettings = useSettingsState() + const isOn = voidSettings.mcpUserStateOfName[name]?.isOn + + const removeUniquePrefix = (name: string) => name.split('_').slice(1).join('_') + + return ( +
+
+ {/* Status indicator */} +
+ + {/* Server name */} +
{name}
+ + {/* Power toggle switch */} +
+ mcpService.toggleServerIsOn(name, !isOn)} + /> +
+
+ + {/* Tools section */} +
+
+ {isOn && (server.tools ?? []).length > 0 ? ( + (server.tools ?? []).map((tool: { name: string; description?: string }) => ( + + {removeUniquePrefix(tool.name)} + + )) + ) : ( + No tools available + )} +
+
+ + {/* Command badge */} + {isOn && server.command && ( +
+
Command:
+
+ {server.command} +
+
+ )} + + {/* Error message if present */} + {server.error && ()} +
+ ); +}; + +// Main component that renders the list of servers +const MCPServersList = () => { + const mcpServiceState = useMCPServiceState() + + let content: React.ReactNode + if (mcpServiceState.error) { + content =
+ {mcpServiceState.error} +
+ } + else { + const entries = Object.entries(mcpServiceState.mcpServerOfName) + if (entries.length === 0) { + content =
+ No servers found +
+ } + else { + content = entries.map(([name, server]) => ( +
+ +
+ )) + } + } + + return content +}; + export const Settings = () => { const isDark = useIsDark() const accessor = useAccessor() @@ -908,6 +1022,7 @@ export const Settings = () => { const voidSettingsService = accessor.get('IVoidSettingsService') const chatThreadsService = accessor.get('IChatThreadService') const notificationService = accessor.get('INotificationService') + const mcpService = accessor.get('IMCPService') const onDownload = (t: 'Chats' | 'Settings') => { let dataStr: string @@ -1033,7 +1148,7 @@ export const Settings = () => { className='hover:brightness-110' data-tooltip-id='void-tooltip' data-tooltip-content='We recommend using the largest qwen2.5-coder model you can with Ollama (try qwen2.5-coder:3b).' - data-tooltip-class-name='void-max-w-[20px]' + data-tooltip-class-name='void-max-w-[300px]' > Only works with FIM models.* @@ -1233,6 +1348,7 @@ export const Settings = () => {

AI Instructions

+

+ +
+

MCP

+

+ +

+
+ { await mcpService.revealMCPConfigFile() }}> + Add MCP Server + +
+
+ + + +
diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-tooltip/VoidTooltip.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-tooltip/VoidTooltip.tsx index ad80dfb5..7128adf4 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-tooltip/VoidTooltip.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-tooltip/VoidTooltip.tsx @@ -50,6 +50,8 @@ export const VoidTooltip = () => { padding: 0px 8px; border-radius: 6px; z-index: 999999; + max-width: 300px; + word-wrap: break-word; } #void-tooltip { diff --git a/src/vs/workbench/contrib/void/browser/sidebarActions.ts b/src/vs/workbench/contrib/void/browser/sidebarActions.ts index 4112745c..ed2b97c9 100644 --- a/src/vs/workbench/contrib/void/browser/sidebarActions.ts +++ b/src/vs/workbench/contrib/void/browser/sidebarActions.ts @@ -172,7 +172,7 @@ registerAction2(class extends Action2 { const oldUI = await oldThread?.state.mountedInfo?.whenMounted const oldSelns = oldThread?.state.stagingSelections - const oldVal = oldUI?.textAreaRef.current?.value + const oldVal = oldUI?.textAreaRef?.current?.value // open and focus new thread chatThreadsService.openNewThread() diff --git a/src/vs/workbench/contrib/void/browser/terminalToolService.ts b/src/vs/workbench/contrib/void/browser/terminalToolService.ts index 2e18511b..334bfa54 100644 --- a/src/vs/workbench/contrib/void/browser/terminalToolService.ts +++ b/src/vs/workbench/contrib/void/browser/terminalToolService.ts @@ -346,20 +346,20 @@ export class TerminalToolService extends Disposable implements ITerminalToolServ await Promise.any([waitUntilDone, waitUntilInterrupt]) .finally(() => disposables.forEach(d => d.dispose())) + + + // read result if timed out, since we didn't get it (could clean this code up but it's ok) + if (resolveReason?.type === 'timeout') { + const terminalId = isPersistent ? params.persistentTerminalId : params.terminalId + result = await this.readTerminal(terminalId) + } + if (!isPersistent) { interrupt() } if (!resolveReason) throw new Error('Unexpected internal error: Promise.any should have resolved with a reason.') - // read result if timed out, since we didn't get it (could clean this code up but it's ok) - if (resolveReason.type === 'timeout') { - const terminalId = isPersistent ? params.persistentTerminalId : params.terminalId - result = await this.readTerminal(terminalId) - } - - - if (!isPersistent) result = `$ ${command}\n${result}` result = removeAnsiEscapeCodes(result) // trim diff --git a/src/vs/workbench/contrib/void/browser/toolsService.ts b/src/vs/workbench/contrib/void/browser/toolsService.ts index 02edf047..6d56bd46 100644 --- a/src/vs/workbench/contrib/void/browser/toolsService.ts +++ b/src/vs/workbench/contrib/void/browser/toolsService.ts @@ -8,7 +8,7 @@ import { QueryBuilder } from '../../../services/search/common/queryBuilder.js' import { ISearchService } from '../../../services/search/common/search.js' import { IEditCodeService } from './editCodeServiceInterface.js' import { ITerminalToolService } from './terminalToolService.js' -import { LintErrorItem, ToolCallParams, ToolResultType } from '../common/toolsServiceTypes.js' +import { LintErrorItem, BuiltinToolCallParams, BuiltinToolResultType, BuiltinToolName } from '../common/toolsServiceTypes.js' import { IVoidModelService } from '../common/voidModelService.js' import { EndOfLinePreference } from '../../../../editor/common/model.js' import { IVoidCommandBarService } from './voidCommandBarService.js' @@ -16,20 +16,15 @@ import { computeDirectoryTree1Deep, IDirectoryStrService, stringifyDirectoryTree import { IMarkerService, MarkerSeverity } from '../../../../platform/markers/common/markers.js' import { timeout } from '../../../../base/common/async.js' import { RawToolParamsObj } from '../common/sendLLMMessageTypes.js' -import { MAX_CHILDREN_URIs_PAGE, MAX_FILE_CHARS_PAGE, MAX_TERMINAL_BG_COMMAND_TIME, MAX_TERMINAL_INACTIVE_TIME, ToolName } from '../common/prompt/prompts.js' +import { MAX_CHILDREN_URIs_PAGE, MAX_FILE_CHARS_PAGE, MAX_TERMINAL_BG_COMMAND_TIME, MAX_TERMINAL_INACTIVE_TIME } from '../common/prompt/prompts.js' import { IVoidSettingsService } from '../common/voidSettingsService.js' import { generateUuid } from '../../../../base/common/uuid.js' // tool use for AI - - - - -type ValidateParams = { [T in ToolName]: (p: RawToolParamsObj) => ToolCallParams[T] } -type CallTool = { [T in ToolName]: (p: ToolCallParams[T]) => Promise<{ result: ToolResultType[T] | Promise, interruptTool?: () => void }> } -type ToolResultToString = { [T in ToolName]: (p: ToolCallParams[T], result: Awaited) => string } - +type ValidateBuiltinParams = { [T in BuiltinToolName]: (p: RawToolParamsObj) => BuiltinToolCallParams[T] } +type CallBuiltinTool = { [T in BuiltinToolName]: (p: BuiltinToolCallParams[T]) => Promise<{ result: BuiltinToolResultType[T] | Promise, interruptTool?: () => void }> } +type BuiltinToolResultToString = { [T in BuiltinToolName]: (p: BuiltinToolCallParams[T], result: Awaited) => string } const isFalsy = (u: unknown) => { @@ -110,9 +105,9 @@ const checkIfIsFolder = (uriStr: string) => { export interface IToolsService { readonly _serviceBrand: undefined; - validateParams: ValidateParams; - callTool: CallTool; - stringOfResult: ToolResultToString; + validateParams: ValidateBuiltinParams; + callTool: CallBuiltinTool; + stringOfResult: BuiltinToolResultToString; } export const IToolsService = createDecorator('ToolsService'); @@ -121,9 +116,9 @@ export class ToolsService implements IToolsService { readonly _serviceBrand: undefined; - public validateParams: ValidateParams; - public callTool: CallTool; - public stringOfResult: ToolResultToString; + public validateParams: ValidateBuiltinParams; + public callTool: CallBuiltinTool; + public stringOfResult: BuiltinToolResultToString; constructor( @IFileService fileService: IFileService, @@ -446,7 +441,6 @@ export class ToolsService implements IToolsService { await this.terminalToolService.killPersistentTerminal(persistentTerminalId) return { result: {} } }, - } @@ -550,7 +544,6 @@ export class ToolsService implements IToolsService { kill_persistent_terminal: (params, _result) => { return `Successfully closed terminal "${params.persistentTerminalId}".`; }, - } diff --git a/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts b/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts index fbf8e18e..44dc307e 100644 --- a/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts +++ b/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts @@ -5,31 +5,32 @@ import { URI } from '../../../../base/common/uri.js'; import { VoidFileSnapshot } from './editCodeServiceTypes.js'; -import { ToolName } from './prompt/prompts.js'; import { AnthropicReasoning, RawToolParamsObj } from './sendLLMMessageTypes.js'; -import { ToolCallParams, ToolResultType } from './toolsServiceTypes.js'; +import { ToolCallParams, ToolName, ToolResult } from './toolsServiceTypes.js'; export type ToolMessage = { role: 'tool'; content: string; // give this result to LLM (string of value) id: string; rawParams: RawToolParamsObj; + mcpServerName: string | undefined; // the server name at the time of the call } & ( // in order of events: | { type: 'invalid_params', result: null, name: T, } - | { type: 'tool_request', result: null, name: T, params: ToolCallParams[T], } // params were validated, awaiting user + | { type: 'tool_request', result: null, name: T, params: ToolCallParams, } // params were validated, awaiting user - | { type: 'running_now', result: null, name: T, params: ToolCallParams[T], } + | { type: 'running_now', result: null, name: T, params: ToolCallParams, } - | { type: 'tool_error', result: string, name: T, params: ToolCallParams[T], } // error when tool was running - | { type: 'success', result: Awaited, name: T, params: ToolCallParams[T], } - | { type: 'rejected', result: null, name: T, params: ToolCallParams[T] } + | { type: 'tool_error', result: string, name: T, params: ToolCallParams, } // error when tool was running + | { type: 'success', result: Awaited>, name: T, params: ToolCallParams, } + | { type: 'rejected', result: null, name: T, params: ToolCallParams } ) // user rejected export type DecorativeCanceledTool = { role: 'interrupted_streaming_tool'; name: ToolName; + mcpServerName: string | undefined; // the server name at the time of the call } diff --git a/src/vs/workbench/contrib/void/common/directoryStrService.ts b/src/vs/workbench/contrib/void/common/directoryStrService.ts index f661eca8..d9d0a319 100644 --- a/src/vs/workbench/contrib/void/common/directoryStrService.ts +++ b/src/vs/workbench/contrib/void/common/directoryStrService.ts @@ -9,7 +9,7 @@ import { registerSingleton, InstantiationType } from '../../../../platform/insta import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IFileService, IFileStat } from '../../../../platform/files/common/files.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { ShallowDirectoryItem, ToolCallParams, ToolResultType } from './toolsServiceTypes.js'; +import { ShallowDirectoryItem, BuiltinToolCallParams, BuiltinToolResultType } from './toolsServiceTypes.js'; import { MAX_CHILDREN_URIs_PAGE, MAX_DIRSTR_CHARS_TOTAL_BEGINNING, MAX_DIRSTR_CHARS_TOTAL_TOOL } from './prompt/prompts.js'; @@ -76,7 +76,7 @@ export const computeDirectoryTree1Deep = async ( fileService: IFileService, rootURI: URI, pageNumber: number = 1, -): Promise => { +): Promise => { const stat = await fileService.resolve(rootURI, { resolveMetadata: false }); if (!stat.isDirectory) { return { children: null, hasNextPage: false, hasPrevPage: false, itemsRemaining: 0 }; @@ -107,7 +107,7 @@ export const computeDirectoryTree1Deep = async ( }; }; -export const stringifyDirectoryTree1Deep = (params: ToolCallParams['ls_dir'], result: ToolResultType['ls_dir']): string => { +export const stringifyDirectoryTree1Deep = (params: BuiltinToolCallParams['ls_dir'], result: BuiltinToolResultType['ls_dir']): string => { if (!result.children) { return `Error: ${params.uri} is not a directory`; } diff --git a/src/vs/workbench/contrib/void/common/mcpService.ts b/src/vs/workbench/contrib/void/common/mcpService.ts new file mode 100644 index 00000000..c40afb43 --- /dev/null +++ b/src/vs/workbench/contrib/void/common/mcpService.ts @@ -0,0 +1,360 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IPathService } from '../../../services/path/common/pathService.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; +import { MCPServerOfName, MCPConfigFileJSON, MCPServer, MCPToolCallParams, RawMCPToolCall, MCPServerEventResponse } from './mcpServiceTypes.js'; +import { Event, Emitter } from '../../../../base/common/event.js'; +import { InternalToolInfo } from './prompt/prompts.js'; +import { IVoidSettingsService } from './voidSettingsService.js'; +import { MCPUserStateOfName } from './voidSettingsTypes.js'; + + +type MCPServiceState = { + mcpServerOfName: MCPServerOfName, + error: string | undefined, // global parsing error +} + +export interface IMCPService { + readonly _serviceBrand: undefined; + revealMCPConfigFile(): Promise; + toggleServerIsOn(serverName: string, isOn: boolean): Promise; + + readonly state: MCPServiceState; // NOT persisted + onDidChangeState: Event; + + getMCPTools(): InternalToolInfo[] | undefined; + callMCPTool(toolData: MCPToolCallParams): Promise<{ result: RawMCPToolCall }>; + stringifyResult(result: RawMCPToolCall): string +} + +export const IMCPService = createDecorator('mcpConfigService'); + + + +const MCP_CONFIG_FILE_NAME = 'mcp.json'; +const MCP_CONFIG_SAMPLE = { mcpServers: {} } +const MCP_CONFIG_SAMPLE_STRING = JSON.stringify(MCP_CONFIG_SAMPLE, null, 2); + + +// export interface MCPCallToolOfToolName { +// [toolName: string]: (params: any) => Promise<{ +// result: any | Promise, +// interruptTool?: () => void +// }>; +// } + + +class MCPService extends Disposable implements IMCPService { + _serviceBrand: undefined; + + + private readonly channel: IChannel // MCPChannel + + // list of MCP servers pulled from mcpChannel + state: MCPServiceState = { + mcpServerOfName: {}, + error: undefined, + } + + // Emitters for server events + private readonly _onDidChangeState = new Emitter(); + public readonly onDidChangeState = this._onDidChangeState.event; + + // private readonly _onLoadingServersChange = new Emitter(); + // public readonly onLoadingServersChange = this._onLoadingServersChange.event; + + constructor( + @IFileService private readonly fileService: IFileService, + @IPathService private readonly pathService: IPathService, + @IProductService private readonly productService: IProductService, + @IEditorService private readonly editorService: IEditorService, + @IMainProcessService private readonly mainProcessService: IMainProcessService, + @IVoidSettingsService private readonly voidSettingsService: IVoidSettingsService, + ) { + super(); + this.channel = this.mainProcessService.getChannel('void-channel-mcp') + + + const onEvent = (e: MCPServerEventResponse) => { + console.log('GOT EVENT', e) + this._setMCPServerState(e.response.name, e.response.newServer) + } + this._register((this.channel.listen('onAdd_server') satisfies Event)(onEvent)); + this._register((this.channel.listen('onUpdate_server') satisfies Event)(onEvent)); + this._register((this.channel.listen('onDelete_server') satisfies Event)(onEvent)); + + this._initialize(); + } + + + private async _initialize() { + try { + await this.voidSettingsService.waitForInitState; + + // Create .mcpConfig if it doesn't exist + const mcpConfigUri = await this._getMCPConfigFilePath(); + const fileExists = await this._configFileExists(mcpConfigUri); + if (!fileExists) { + await this._createMCPConfigFile(mcpConfigUri); + console.log('MCP Config file created:', mcpConfigUri.toString()); + } + await this._addMCPConfigFileWatcher(); + await this._refreshMCPServers(); + } catch (error) { + console.error('Error initializing MCPService:', error); + } + } + + private readonly _setMCPServerState = async (serverName: string, newServer: MCPServer | undefined) => { + if (newServer === undefined) { + // Remove the server from the state + const { [serverName]: removed, ...remainingServers } = this.state.mcpServerOfName; + this.state = { + ...this.state, + mcpServerOfName: remainingServers + } + } else { + // Add or update the server + this.state = { + ...this.state, + mcpServerOfName: { + ...this.state.mcpServerOfName, + [serverName]: newServer + } + } + } + this._onDidChangeState.fire(); + } + + private readonly _setHasError = async (errMsg: string | undefined) => { + this.state = { + ...this.state, + error: errMsg, + } + this._onDidChangeState.fire(); + } + + // Create the file/directory if it doesn't exist + private async _createMCPConfigFile(mcpConfigUri: URI): Promise { + await this.fileService.createFile(mcpConfigUri.with({ path: mcpConfigUri.path })); + const buffer = VSBuffer.fromString(MCP_CONFIG_SAMPLE_STRING); + await this.fileService.writeFile(mcpConfigUri, buffer); + } + + + private async _addMCPConfigFileWatcher(): Promise { + const mcpConfigUri = await this._getMCPConfigFilePath(); + this._register( + this.fileService.watch(mcpConfigUri) + ) + + this._register(this.fileService.onDidFilesChange(async e => { + if (!e.contains(mcpConfigUri)) return + await this._refreshMCPServers(); + })); + } + + // Client-side functions + + public async revealMCPConfigFile(): Promise { + try { + const mcpConfigUri = await this._getMCPConfigFilePath(); + await this.editorService.openEditor({ + resource: mcpConfigUri, + options: { + pinned: true, + revealIfOpened: true, + } + }); + } catch (error) { + console.error('Error opening MCP config file:', error); + } + } + + public getMCPTools(): InternalToolInfo[] | undefined { + const allTools: InternalToolInfo[] = [] + for (const serverName in this.state.mcpServerOfName) { + const server = this.state.mcpServerOfName[serverName]; + server.tools?.forEach(tool => { + allTools.push({ + description: tool.description || '', + params: this._transformInputSchemaToParams(tool.inputSchema), + name: tool.name, + mcpServerName: serverName, + }) + }) + } + if (allTools.length === 0) return undefined + return allTools + } + + private _transformInputSchemaToParams(inputSchema?: Record): { [paramName: string]: { description: string } } { + + // Check if inputSchema is valid + if (!inputSchema || !inputSchema.properties) return {}; + + const params: { [paramName: string]: { description: string } } = {}; + Object.keys(inputSchema.properties).forEach(paramName => { + const propertyValues = inputSchema.properties[paramName]; + + // Check if propertyValues is not an object + if (typeof propertyValues !== 'object') { + console.warn(`Invalid property value for ${paramName}: expected object, got ${typeof propertyValues}`); + return; // in forEach the return is equivalent to continue + } + + // Add the parameter to the params object + params[paramName] = { + description: JSON.stringify(propertyValues.description || '', null, 2) || '', + } + }); + return params; + } + + private async _getMCPConfigFilePath(): Promise { + const appName = this.productService.dataFolderName + const userHome = await this.pathService.userHome(); + const uri = URI.joinPath(userHome, appName, MCP_CONFIG_FILE_NAME) + return uri + } + + private async _configFileExists(mcpConfigUri: URI): Promise { + try { + await this.fileService.stat(mcpConfigUri); + return true; + } catch (error) { + return false; + } + } + + + private async _parseMCPConfigFile(): Promise { + const mcpConfigUri = await this._getMCPConfigFilePath(); + try { + const fileContent = await this.fileService.readFile(mcpConfigUri); + const contentString = fileContent.value.toString(); + const configFileJson = JSON.parse(contentString); + if (!configFileJson.mcpServers) { + throw new Error('Missing mcpServers property'); + } + return configFileJson as MCPConfigFileJSON; + } catch (error) { + const fullError = `Error parsing MCP config file: ${error}`; + this._setHasError(fullError) + return null; + } + } + + + // Handle server state changes + private async _refreshMCPServers(): Promise { + + this._setHasError(undefined) + + const newConfigFileJSON = await this._parseMCPConfigFile(); + if (!newConfigFileJSON) { console.log(`Not setting state: MCP config file not found`); return } + if (!newConfigFileJSON?.mcpServers) { console.log(`Not setting state: MCP config file did not have an 'mcpServers' field`); return } + + + const oldConfigFileNames = Object.keys(this.state.mcpServerOfName) + const newConfigFileNames = Object.keys(newConfigFileJSON.mcpServers) + + const addedServerNames = newConfigFileNames.filter(serverName => !oldConfigFileNames.includes(serverName)); // in new and not in old + const removedServerNames = oldConfigFileNames.filter(serverName => !newConfigFileNames.includes(serverName)); // in old and not in new + + // set isOn to any new servers in the config + const addedUserStateOfName: MCPUserStateOfName = {} + for (const name of addedServerNames) { addedUserStateOfName[name] = { isOn: true } } + await this.voidSettingsService.addMCPUserStateOfNames(addedUserStateOfName); + + // delete isOn for any servers that no longer show up in the config + await this.voidSettingsService.removeMCPUserStateOfNames(removedServerNames); + + // set all servers to loading + for (const serverName in newConfigFileJSON.mcpServers) { + this._setMCPServerState(serverName, { status: 'loading', tools: [] }) + } + const updatedServerNames = Object.keys(newConfigFileJSON.mcpServers).filter(serverName => !addedServerNames.includes(serverName) && !removedServerNames.includes(serverName)) + + this.channel.call('refreshMCPServers', { + mcpConfigFileJSON: newConfigFileJSON, + addedServerNames, + removedServerNames, + updatedServerNames, + userStateOfName: this.voidSettingsService.state.mcpUserStateOfName, + }) + } + + stringifyResult(result: RawMCPToolCall): string { + let toolResultStr: string + if (result.event === 'text') { + toolResultStr = result.text + } else if (result.event === 'image') { + toolResultStr = `[Image: ${result.image.mimeType}]` + } else if (result.event === 'audio') { + toolResultStr = `[Audio content]` + } else if (result.event === 'resource') { + toolResultStr = `[Resource content]` + } else { + toolResultStr = JSON.stringify(result) + } + return toolResultStr + } + + // toggle MCP server and update isOn in void settings + public async toggleServerIsOn(serverName: string, isOn: boolean): Promise { + this._setMCPServerState(serverName, { status: 'loading', tools: [] }) + + await this.voidSettingsService.setMCPServerState(serverName, { isOn }); + this.channel.call('toggleMCPServer', { serverName, isOn }) + } + + + public async callMCPTool(toolData: MCPToolCallParams): Promise<{ result: RawMCPToolCall }> { + const result = await this.channel.call('callTool', toolData); + if (result.event === 'error') { + throw new Error(`Error: ${result.text}`) + } + return { result }; + } + + // public getMCPToolFns(): MCPToolResultType { + // const tools = this.getMCPTools(); + // const toolFns: MCPToolResultType = {}; + + // tools.forEach((tool) => { + // const name = tool.name; + // // Define the tool call function + // const toolFn = async (params: { + // serverName: string, + // toolName: string, + // args: any + // }) => { + // const { serverName, toolName, args } = params; + // const response = await this.callMCPTool({ + // serverName, + // toolName, + // params: args, + // }); + // return { result: response } + // }; + // toolFns[name] = toolFn; + // }); + + // return toolFns + // } +} + +registerSingleton(IMCPService, MCPService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/common/mcpServiceTypes.ts b/src/vs/workbench/contrib/void/common/mcpServiceTypes.ts new file mode 100644 index 00000000..170cb843 --- /dev/null +++ b/src/vs/workbench/contrib/void/common/mcpServiceTypes.ts @@ -0,0 +1,236 @@ +/** + * mcp-response-types.ts + * -------------------------------------------------- + * **Pure** TypeScript interfaces (no external imports) + * describing the JSON-RPC response shapes for: + * + * 1. tools/list -> ToolsListResponse + * 2. prompts/list -> PromptsListResponse + * 3. tools/call -> ToolCallResponse + * + * They are distilled directly from the official MCP + * 2025‑03‑26 specification: + * • Tools list response examples + * • Prompts list response examples + * • Tool call response examples + * + * Use them to get full IntelliSense when working with + * @modelcontextprotocol/inspector‑cli responses. + */ + + +/* -------------------------------------------------- */ +/* Core JSON‑RPC envelope */ +/* -------------------------------------------------- */ + +// export interface JsonRpcSuccess { +// /** JSON‑RPC version – always '2.0' */ +// jsonrpc: '2.0'; +// /** Request identifier echoed back by the server */ +// id: string | number | null; +// /** The successful result payload */ +// result: T; +// } + +/* -------------------------------------------------- */ +/* Utility: pagination */ +/* -------------------------------------------------- */ + +// export interface Paginated { +// /** Opaque cursor for fetching the next page */ +// nextCursor?: string; +// } + +/* -------------------------------------------------- */ +/* 1. tools/list */ +/* -------------------------------------------------- */ + +export interface MCPTool { + /** Unique tool identifier */ + name: string; + /** Human‑readable description */ + description?: string; + /** JSON schema describing expected arguments */ + inputSchema?: Record; + /** Free‑form annotations describing behaviour, security, etc. */ + annotations?: Record; +} + +// export interface ToolsListResult extends Paginated { +// tools: MCPTool[]; +// } + +// export type ToolsListResponse = JsonRpcSuccess; + +/* -------------------------------------------------- */ +/* 2. prompts/list */ +/* -------------------------------------------------- */ + +// export interface PromptArgument { +// name: string; +// description?: string; +// /** Whether the argument is required */ +// required?: boolean; +// } + +// export interface Prompt { +// name: string; +// description?: string; +// arguments?: PromptArgument[]; +// } + +// export interface PromptsListResult extends Paginated { +// prompts: Prompt[]; +// } + +// export type PromptsListResponse = JsonRpcSuccess; + +/* -------------------------------------------------- */ +/* 3. tools/call */ +/* -------------------------------------------------- */ + +/** Additional resource structure that can be embedded in tool results */ +// export interface Resource { +// uri: string; +// mimeType: string; +// /** Either plain‑text or base64‑encoded binary data */ +// text?: string; +// data?: string; +// } + +/** Individual content items returned by a tool */ +// export type ToolContent = +// | { type: 'text'; text: string } +// | { type: 'image'; data: string; mimeType: string } +// | { type: 'audio'; data: string; mimeType: string } +// | { type: 'resource'; resource: Resource }; + +// export interface ToolCallResult { +// /** List of content parts (text, images, resources, etc.) */ +// content: ToolContent[]; +// /** True if the tool itself encountered a domain‑level error */ +// isError?: boolean; +// } + +// export type ToolCallResponse = JsonRpcSuccess; + +// MCP SERVER CONFIG FILE TYPES ----------------------------- + +export interface MCPConfigFileEntryJSON { + // Command-based server properties + command?: string; + args?: string[]; + env?: Record; + + // URL-based server properties + url?: URL; + headers?: Record; +} + +export interface MCPConfigFileJSON { + mcpServers: Record; +} + + +// SERVER EVENT TYPES ------------------------------------------ + +export type MCPServer = { + // Command-based server properties + tools: MCPTool[], + status: 'loading' | 'success' | 'offline', + command?: string, + error?: string, +} | { + tools?: undefined, + status: 'error', + command?: string, + error: string, +} + +export interface MCPServerOfName { + [serverName: string]: MCPServer; +} + +export type MCPServerEvent = { + name: string; + prevServer?: MCPServer; + newServer?: MCPServer; +} +export type MCPServerEventResponse = { response: MCPServerEvent } + +export interface MCPConfigFileParseErrorResponse { + response: { + type: 'config-file-error'; + error: string | null; + } +} + + +// export type MCPServerResponse = MCPAddResponse | MCPUpdateResponse | MCPDeleteResponse | MCPLoadingResponse; + +// Event parameter types +// export type MCPServerEventAddParam = { response: MCPAddResponse }; +// export type MCPServerEventUpdateParam = { response: MCPUpdateResponse }; +// export type MCPServerEventDeleteParam = { response: MCPDeleteResponse }; +// export type MCPServerEventLoadingParam = { response: MCPLoadingResponse }; + +// Event Param union type +// export type MCPServerEventParam = MCPServerEventAddParam | MCPServerEventUpdateParam | MCPServerEventDeleteParam | MCPServerEventLoadingParam; + +// TOOL CALL EVENT TYPES ------------------------------------------ + +type MCPToolResponseType = 'text' | 'image' | 'audio' | 'resource' | 'error'; + +type ResponseImageTypes = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' | 'image/svg+xml' | 'image/bmp' | 'image/tiff' | 'image/vnd.microsoft.icon'; + +interface ImageData { + data: string; + mimeType: ResponseImageTypes; +} + +interface MCPToolResponseBase { + toolName: string; + serverName?: string; + event: MCPToolResponseType; + text?: string; + image?: ImageData; +} + +type MCPToolResponseConstraints = { + 'text': { + image?: never; + text: string; + }; + 'error': { + image?: never; + text: string; + }; + 'image': { + text?: never; + image: ImageData; + }; + 'audio': { + text?: never; + image?: never; + }; + 'resource': { + text?: never; + image?: never; + } +} + +type MCPToolEventResponse = Omit & MCPToolResponseConstraints[T] & { event: T }; + +// Response types +export type MCPToolTextResponse = MCPToolEventResponse<'text'>; +export type MCPToolErrorResponse = MCPToolEventResponse<'error'>; +export type MCPToolImageResponse = MCPToolEventResponse<'image'>; +export type MCPToolAudioResponse = MCPToolEventResponse<'audio'>; +export type MCPToolResourceResponse = MCPToolEventResponse<'resource'>; +export type RawMCPToolCall = MCPToolTextResponse | MCPToolErrorResponse | MCPToolImageResponse | MCPToolAudioResponse | MCPToolResourceResponse; + +export interface MCPToolCallParams { + serverName: string; + toolName: string; + params: Record; +} diff --git a/src/vs/workbench/contrib/void/common/modelCapabilities.ts b/src/vs/workbench/contrib/void/common/modelCapabilities.ts index 623afed8..dcdbc940 100644 --- a/src/vs/workbench/contrib/void/common/modelCapabilities.ts +++ b/src/vs/workbench/contrib/void/common/modelCapabilities.ts @@ -1375,7 +1375,6 @@ const openRouterModelOptions_assumingOpenAICompat = { const openRouterSettings: VoidStaticProviderInfo = { modelOptions: openRouterModelOptions_assumingOpenAICompat, - // TODO!!! send a query to openrouter to get the price, etc. modelOptionsFallback: (modelName) => { const res = extensiveModelOptionsFallback(modelName) // openRouter does not support gemini-style, use openai-style instead diff --git a/src/vs/workbench/contrib/void/common/prompt/prompts.ts b/src/vs/workbench/contrib/void/common/prompt/prompts.ts index 37f16c84..9e9c3b57 100644 --- a/src/vs/workbench/contrib/void/common/prompt/prompts.ts +++ b/src/vs/workbench/contrib/void/common/prompt/prompts.ts @@ -9,7 +9,7 @@ import { IDirectoryStrService } from '../directoryStrService.js'; import { StagingSelectionItem } from '../chatThreadServiceTypes.js'; import { os } from '../helpers/systemInfo.js'; import { RawToolParamsObj } from '../sendLLMMessageTypes.js'; -import { approvalTypeOfToolName, ToolCallParams, ToolResultType } from '../toolsServiceTypes.js'; +import { approvalTypeOfBuiltinToolName, BuiltinToolCallParams, BuiltinToolName, BuiltinToolResultType, ToolName } from '../toolsServiceTypes.js'; import { ChatMode } from '../voidSettingsTypes.js'; // Triple backtick wrapper used throughout the prompts for code blocks @@ -147,6 +147,8 @@ export type InternalToolInfo = { params: { [paramName: string]: { description: string } }, + // Only if the tool is from an MCP server + mcpServerName?: string, } @@ -181,191 +183,197 @@ export type SnakeCaseKeys> = { -// export const voidTools = { -export const voidTools - : { - [T in keyof ToolCallParams]: { - name: string; - description: string; - // more params can be generated than exist here, but these params must be a subset of them - params: Partial<{ [paramName in keyof SnakeCaseKeys]: { description: string } }> - } +export const builtinTools: { + [T in keyof BuiltinToolCallParams]: { + name: string; + description: string; + // more params can be generated than exist here, but these params must be a subset of them + params: Partial<{ [paramName in keyof SnakeCaseKeys]: { description: string } }> } - = { - // --- context-gathering (read/search/list) --- +} = { + // --- context-gathering (read/search/list) --- - read_file: { - name: 'read_file', - description: `Returns full contents of a given file.`, - params: { - ...uriParam('file'), - start_line: { description: 'Optional. Do NOT fill this field in unless you were specifically given exact line numbers to search. Defaults to the beginning of the file.' }, - end_line: { description: 'Optional. Do NOT fill this field in unless you were specifically given exact line numbers to search. Defaults to the end of the file.' }, - ...paginationParam, - }, + read_file: { + name: 'read_file', + description: `Returns full contents of a given file.`, + params: { + ...uriParam('file'), + start_line: { description: 'Optional. Do NOT fill this field in unless you were specifically given exact line numbers to search. Defaults to the beginning of the file.' }, + end_line: { description: 'Optional. Do NOT fill this field in unless you were specifically given exact line numbers to search. Defaults to the end of the file.' }, + ...paginationParam, }, + }, - ls_dir: { - name: 'ls_dir', - description: `Lists all files and folders in the given URI.`, - params: { - uri: { description: `Optional. The FULL path to the ${'folder'}. Leave this as empty or "" to search all folders.` }, - ...paginationParam, - }, + ls_dir: { + name: 'ls_dir', + description: `Lists all files and folders in the given URI.`, + params: { + uri: { description: `Optional. The FULL path to the ${'folder'}. Leave this as empty or "" to search all folders.` }, + ...paginationParam, }, + }, - get_dir_tree: { - name: 'get_dir_tree', - description: `This is a very effective way to learn about the user's codebase. Returns a tree diagram of all the files and folders in the given folder. `, - params: { - ...uriParam('folder') - } - }, - - // pathname_search: { - // name: 'pathname_search', - // description: `Returns all pathnames that match a given \`find\`-style query over the entire workspace. ONLY searches file names. ONLY searches the current workspace. You should use this when looking for a file with a specific name or path. ${paginationHelper.desc}`, - - search_pathnames_only: { - name: 'search_pathnames_only', - description: `Returns all pathnames that match a given query (searches ONLY file names). You should use this when looking for a file with a specific name or path.`, - params: { - query: { description: `Your query for the search.` }, - include_pattern: { description: 'Optional. Only fill this in if you need to limit your search because there were too many results.' }, - ...paginationParam, - }, - }, - - - - search_for_files: { - name: 'search_for_files', - description: `Returns a list of file names whose content matches the given query. The query can be any substring or regex.`, - params: { - query: { description: `Your query for the search.` }, - search_in_folder: { description: 'Optional. Leave as blank by default. ONLY fill this in if your previous search with the same query was truncated. Searches descendants of this folder only.' }, - is_regex: { description: 'Optional. Default is false. Whether the query is a regex.' }, - ...paginationParam, - }, - }, - - // add new search_in_file tool - search_in_file: { - name: 'search_in_file', - description: `Returns an array of all the start line numbers where the content appears in the file.`, - params: { - ...uriParam('file'), - query: { description: 'The string or regex to search for in the file.' }, - is_regex: { description: 'Optional. Default is false. Whether the query is a regex.' } - } - }, - - read_lint_errors: { - name: 'read_lint_errors', - description: `Use this tool to view all the lint errors on a file.`, - params: { - ...uriParam('file'), - }, - }, - - // --- editing (create/delete) --- - - create_file_or_folder: { - name: 'create_file_or_folder', - description: `Create a file or folder at the given path. To create a folder, the path MUST end with a trailing slash.`, - params: { - ...uriParam('file or folder'), - }, - }, - - delete_file_or_folder: { - name: 'delete_file_or_folder', - description: `Delete a file or folder at the given path.`, - params: { - ...uriParam('file or folder'), - is_recursive: { description: 'Optional. Return true to delete recursively.' } - }, - }, - - edit_file: { - name: 'edit_file', - description: `Edit the contents of a file. You must provide the file's URI as well as a SINGLE string of SEARCH/REPLACE block(s) that will be used to apply the edit.`, - params: { - ...uriParam('file'), - search_replace_blocks: { description: replaceTool_description } - }, - }, - - rewrite_file: { - name: 'rewrite_file', - description: `Edits a file, deleting all the old contents and replacing them with your new contents. Use this tool if you want to edit a file you just created.`, - params: { - ...uriParam('file'), - new_content: { description: `The new contents of the file. Must be a string.` } - }, - }, - run_command: { - name: 'run_command', - description: `Runs a terminal command and waits for the result (times out after ${MAX_TERMINAL_INACTIVE_TIME}s of inactivity). ${terminalDescHelper}`, - params: { - command: { description: 'The terminal command to run.' }, - cwd: { description: cwdHelper }, - }, - }, - - run_persistent_command: { - name: 'run_persistent_command', - description: `Runs a terminal command in the persistent terminal that you created with open_persistent_terminal (results after ${MAX_TERMINAL_BG_COMMAND_TIME} are returned, and command continues running in background). ${terminalDescHelper}`, - params: { - command: { description: 'The terminal command to run.' }, - persistent_terminal_id: { description: 'The ID of the terminal created using open_persistent_terminal.' }, - }, - }, - - - - open_persistent_terminal: { - name: 'open_persistent_terminal', - description: `Use this tool when you want to run a terminal command indefinitely, like a dev server (eg \`npm run dev\`), a background listener, etc. Opens a new terminal in the user's environment which will not awaited for or killed.`, - params: { - cwd: { description: cwdHelper }, - } - }, - - - kill_persistent_terminal: { - name: 'kill_persistent_terminal', - description: `Interrupts and closes a persistent terminal that you opened with open_persistent_terminal.`, - params: { persistent_terminal_id: { description: `The ID of the persistent terminal.` } } + get_dir_tree: { + name: 'get_dir_tree', + description: `This is a very effective way to learn about the user's codebase. Returns a tree diagram of all the files and folders in the given folder. `, + params: { + ...uriParam('folder') } + }, + + // pathname_search: { + // name: 'pathname_search', + // description: `Returns all pathnames that match a given \`find\`-style query over the entire workspace. ONLY searches file names. ONLY searches the current workspace. You should use this when looking for a file with a specific name or path. ${paginationHelper.desc}`, + + search_pathnames_only: { + name: 'search_pathnames_only', + description: `Returns all pathnames that match a given query (searches ONLY file names). You should use this when looking for a file with a specific name or path.`, + params: { + query: { description: `Your query for the search.` }, + include_pattern: { description: 'Optional. Only fill this in if you need to limit your search because there were too many results.' }, + ...paginationParam, + }, + }, - // go_to_definition - // go_to_usages - } satisfies { [T in keyof ToolResultType]: InternalToolInfo } + search_for_files: { + name: 'search_for_files', + description: `Returns a list of file names whose content matches the given query. The query can be any substring or regex.`, + params: { + query: { description: `Your query for the search.` }, + search_in_folder: { description: 'Optional. Leave as blank by default. ONLY fill this in if your previous search with the same query was truncated. Searches descendants of this folder only.' }, + is_regex: { description: 'Optional. Default is false. Whether the query is a regex.' }, + ...paginationParam, + }, + }, + + // add new search_in_file tool + search_in_file: { + name: 'search_in_file', + description: `Returns an array of all the start line numbers where the content appears in the file.`, + params: { + ...uriParam('file'), + query: { description: 'The string or regex to search for in the file.' }, + is_regex: { description: 'Optional. Default is false. Whether the query is a regex.' } + } + }, + + read_lint_errors: { + name: 'read_lint_errors', + description: `Use this tool to view all the lint errors on a file.`, + params: { + ...uriParam('file'), + }, + }, + + // --- editing (create/delete) --- + + create_file_or_folder: { + name: 'create_file_or_folder', + description: `Create a file or folder at the given path. To create a folder, the path MUST end with a trailing slash.`, + params: { + ...uriParam('file or folder'), + }, + }, + + delete_file_or_folder: { + name: 'delete_file_or_folder', + description: `Delete a file or folder at the given path.`, + params: { + ...uriParam('file or folder'), + is_recursive: { description: 'Optional. Return true to delete recursively.' } + }, + }, + + edit_file: { + name: 'edit_file', + description: `Edit the contents of a file. You must provide the file's URI as well as a SINGLE string of SEARCH/REPLACE block(s) that will be used to apply the edit.`, + params: { + ...uriParam('file'), + search_replace_blocks: { description: replaceTool_description } + }, + }, + + rewrite_file: { + name: 'rewrite_file', + description: `Edits a file, deleting all the old contents and replacing them with your new contents. Use this tool if you want to edit a file you just created.`, + params: { + ...uriParam('file'), + new_content: { description: `The new contents of the file. Must be a string.` } + }, + }, + run_command: { + name: 'run_command', + description: `Runs a terminal command and waits for the result (times out after ${MAX_TERMINAL_INACTIVE_TIME}s of inactivity). ${terminalDescHelper}`, + params: { + command: { description: 'The terminal command to run.' }, + cwd: { description: cwdHelper }, + }, + }, + + run_persistent_command: { + name: 'run_persistent_command', + description: `Runs a terminal command in the persistent terminal that you created with open_persistent_terminal (results after ${MAX_TERMINAL_BG_COMMAND_TIME} are returned, and command continues running in background). ${terminalDescHelper}`, + params: { + command: { description: 'The terminal command to run.' }, + persistent_terminal_id: { description: 'The ID of the terminal created using open_persistent_terminal.' }, + }, + }, -export type ToolName = keyof ToolResultType -export const toolNames = Object.keys(voidTools) as ToolName[] -type ToolParamNameOfTool = keyof (typeof voidTools)[T]['params'] -export type ToolParamName = { [T in ToolName]: ToolParamNameOfTool }[ToolName] + open_persistent_terminal: { + name: 'open_persistent_terminal', + description: `Use this tool when you want to run a terminal command indefinitely, like a dev server (eg \`npm run dev\`), a background listener, etc. Opens a new terminal in the user's environment which will not awaited for or killed.`, + params: { + cwd: { description: cwdHelper }, + } + }, -const toolNamesSet = new Set(toolNames) -export const isAToolName = (toolName: string): toolName is ToolName => { + kill_persistent_terminal: { + name: 'kill_persistent_terminal', + description: `Interrupts and closes a persistent terminal that you opened with open_persistent_terminal.`, + params: { persistent_terminal_id: { description: `The ID of the persistent terminal.` } } + } + + + // go_to_definition + // go_to_usages + +} satisfies { [T in keyof BuiltinToolResultType]: InternalToolInfo } + + + + +export const builtinToolNames = Object.keys(builtinTools) as BuiltinToolName[] +const toolNamesSet = new Set(builtinToolNames) +export const isABuiltinToolName = (toolName: string): toolName is BuiltinToolName => { const isAToolName = toolNamesSet.has(toolName) return isAToolName } -export const availableTools = (chatMode: ChatMode) => { - const toolNames: ToolName[] | undefined = chatMode === 'normal' ? undefined - : chatMode === 'gather' ? (Object.keys(voidTools) as ToolName[]).filter(toolName => !(toolName in approvalTypeOfToolName)) - : chatMode === 'agent' ? Object.keys(voidTools) as ToolName[] + + + + +export const availableTools = (chatMode: ChatMode | null, mcpTools: InternalToolInfo[] | undefined) => { + + const builtinToolNames: BuiltinToolName[] | undefined = chatMode === 'normal' ? undefined + : chatMode === 'gather' ? (Object.keys(builtinTools) as BuiltinToolName[]).filter(toolName => !(toolName in approvalTypeOfBuiltinToolName)) + : chatMode === 'agent' ? Object.keys(builtinTools) as BuiltinToolName[] : undefined - const tools: InternalToolInfo[] | undefined = toolNames?.map(toolName => voidTools[toolName]) + const effectiveBuiltinTools = builtinToolNames?.map(toolName => builtinTools[toolName]) ?? undefined + const effectiveMCPTools = chatMode === 'agent' ? mcpTools : undefined + + const tools: InternalToolInfo[] | undefined = !(builtinToolNames || mcpTools) ? undefined + : [ + ...effectiveBuiltinTools ?? [], + ...effectiveMCPTools ?? [], + ] + return tools } @@ -382,7 +390,7 @@ const toolCallDefinitionsXMLString = (tools: InternalToolInfo[]) => { } export const reParsedToolXMLString = (toolName: ToolName, toolParams: RawToolParamsObj) => { - const params = Object.keys(toolParams).map(paramName => `<${paramName}>${toolParams[paramName as ToolParamName]}`).join('\n') + const params = Object.keys(toolParams).map(paramName => `<${paramName}>${toolParams[paramName]}`).join('\n') return `\ <${toolName}>${!params ? '' : `\n${params}`} ` @@ -391,8 +399,8 @@ export const reParsedToolXMLString = (toolName: ToolName, toolParams: RawToolPar /* We expect tools to come at the end - not a hard limit, but that's just how we process them, and the flow makes more sense that way. */ // - You are allowed to call multiple tools by specifying them consecutively. However, there should be NO text or writing between tool calls or after them. -const systemToolsXMLPrompt = (chatMode: ChatMode) => { - const tools = availableTools(chatMode) +const systemToolsXMLPrompt = (chatMode: ChatMode, mcpTools: InternalToolInfo[] | undefined) => { + const tools = availableTools(chatMode, mcpTools) if (!tools || tools.length === 0) return null const toolXMLDefinitions = (`\ @@ -417,7 +425,7 @@ const systemToolsXMLPrompt = (chatMode: ChatMode) => { // ======================================================== chat (normal, gather, agent) ======================================================== -export const chat_systemMessage = ({ workspaceFolders, openedURIs, activeURI, persistentTerminalIDs, directoryStr, chatMode: mode, includeXMLToolDefinitions }: { workspaceFolders: string[], directoryStr: string, openedURIs: string[], activeURI: string | undefined, persistentTerminalIDs: string[], chatMode: ChatMode, includeXMLToolDefinitions: boolean }) => { +export const chat_systemMessage = ({ workspaceFolders, openedURIs, activeURI, persistentTerminalIDs, directoryStr, chatMode: mode, mcpTools, includeXMLToolDefinitions }: { workspaceFolders: string[], directoryStr: string, openedURIs: string[], activeURI: string | undefined, persistentTerminalIDs: string[], chatMode: ChatMode, mcpTools: InternalToolInfo[] | undefined, includeXMLToolDefinitions: boolean }) => { const header = (`You are an expert coding ${mode === 'agent' ? 'agent' : 'assistant'} whose job is \ ${mode === 'agent' ? `to help the user develop, run, and make changes to their codebase.` : mode === 'gather' ? `to search, understand, and reference files in the user's codebase.` @@ -451,7 +459,7 @@ ${directoryStr} `) - const toolDefinitions = includeXMLToolDefinitions ? systemToolsXMLPrompt(mode) : null + const toolDefinitions = includeXMLToolDefinitions ? systemToolsXMLPrompt(mode, mcpTools) : null const details: string[] = [] diff --git a/src/vs/workbench/contrib/void/common/sendLLMMessageService.ts b/src/vs/workbench/contrib/void/common/sendLLMMessageService.ts index 7579cfec..7618e736 100644 --- a/src/vs/workbench/contrib/void/common/sendLLMMessageService.ts +++ b/src/vs/workbench/contrib/void/common/sendLLMMessageService.ts @@ -13,6 +13,7 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { IVoidSettingsService } from './voidSettingsService.js'; +import { IMCPService } from './mcpService.js'; // calls channel to implement features export const ILLMMessageService = createDecorator('llmMessageService'); @@ -61,6 +62,7 @@ export class LLMMessageService extends Disposable implements ILLMMessageService @IMainProcessService private readonly mainProcessService: IMainProcessService, // used as a renderer (only usable on client side) @IVoidSettingsService private readonly voidSettingsService: IVoidSettingsService, // @INotificationService private readonly notificationService: INotificationService, + @IMCPService private readonly mcpService: IMCPService, ) { super() @@ -116,6 +118,8 @@ export class LLMMessageService extends Disposable implements ILLMMessageService const { settingsOfProvider, } = this.voidSettingsService.state + const mcpTools = this.mcpService.getMCPTools() + // add state for request id const requestId = generateUuid(); this.llmMessageHooks.onText[requestId] = onText @@ -129,6 +133,7 @@ export class LLMMessageService extends Disposable implements ILLMMessageService requestId, settingsOfProvider, modelSelection, + mcpTools, } satisfies MainSendLLMMessageParams); return requestId diff --git a/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts b/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts index f6e634ee..f476b851 100644 --- a/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts +++ b/src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts @@ -3,7 +3,8 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { ToolName, ToolParamName } from './prompt/prompts.js' +import { InternalToolInfo } from './prompt/prompts.js' +import { ToolName, ToolParamName } from './toolsServiceTypes.js' import { ChatMode, ModelSelection, ModelSelectionOptions, OverridesOfModel, ProviderName, RefreshableProviderName, SettingsOfProvider } from './voidSettingsTypes.js' @@ -78,12 +79,12 @@ export type LLMFIMMessage = { export type RawToolParamsObj = { - [paramName in ToolParamName]?: string; + [paramName in ToolParamName]?: string; } export type RawToolCallObj = { name: ToolName; rawParams: RawToolParamsObj; - doneParams: ToolParamName[]; + doneParams: ToolParamName[]; id: string; isDone: boolean; }; @@ -133,6 +134,7 @@ export type SendLLMMessageParams = { overridesOfModel: OverridesOfModel | undefined; settingsOfProvider: SettingsOfProvider; + mcpTools: InternalToolInfo[] | undefined; } & SendLLMType diff --git a/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts b/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts index 2eb3a784..d930f5de 100644 --- a/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts +++ b/src/vs/workbench/contrib/void/common/toolsServiceTypes.ts @@ -1,5 +1,7 @@ import { URI } from '../../../../base/common/uri.js' -import { ToolName } from './prompt/prompts.js'; +import { RawMCPToolCall } from './mcpServiceTypes.js'; +import { builtinTools } from './prompt/prompts.js'; +import { RawToolParamsObj } from './sendLLMMessageTypes.js'; @@ -16,7 +18,7 @@ export type ShallowDirectoryItem = { } -export const approvalTypeOfToolName: Partial<{ [T in ToolName]?: 'edits' | 'terminal' }> = { +export const approvalTypeOfBuiltinToolName: Partial<{ [T in BuiltinToolName]?: 'edits' | 'terminal' | 'mcp-tools' }> = { 'create_file_or_folder': 'edits', 'delete_file_or_folder': 'edits', 'rewrite_file': 'edits', @@ -28,16 +30,19 @@ export const approvalTypeOfToolName: Partial<{ [T in ToolName]?: 'edits' | 'term } +export type ToolApprovalType = NonNullable<(typeof approvalTypeOfBuiltinToolName)[keyof typeof approvalTypeOfBuiltinToolName]>; + + +export const toolApprovalTypes = new Set([ + ...Object.values(approvalTypeOfBuiltinToolName), + 'mcp-tools', +]) + -// {{add: define new type for approval types}} -export type ToolApprovalType = NonNullable<(typeof approvalTypeOfToolName)[keyof typeof approvalTypeOfToolName]>; -export const toolApprovalTypes = new Set( - Object.values(approvalTypeOfToolName).filter((v): v is ToolApprovalType => v !== undefined) -) // PARAMS OF TOOL CALL -export type ToolCallParams = { +export type BuiltinToolCallParams = { 'read_file': { uri: URI, startLine: number | null, endLine: number | null, pageNumber: number }, 'ls_dir': { uri: URI, pageNumber: number }, 'get_dir_tree': { uri: URI }, @@ -58,7 +63,7 @@ export type ToolCallParams = { } // RESULT OF TOOL CALL -export type ToolResultType = { +export type BuiltinToolResultType = { 'read_file': { fileContents: string, totalFileLen: number, totalNumLines: number, hasNextPage: boolean }, 'ls_dir': { children: ShallowDirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number }, 'get_dir_tree': { str: string, }, @@ -78,3 +83,15 @@ export type ToolResultType = { 'kill_persistent_terminal': {}, } + +export type ToolCallParams = T extends BuiltinToolName ? BuiltinToolCallParams[T] : RawToolParamsObj +export type ToolResult = T extends BuiltinToolName ? BuiltinToolResultType[T] : RawMCPToolCall + +export type BuiltinToolName = keyof BuiltinToolResultType + +type BuiltinToolParamNameOfTool = keyof (typeof builtinTools)[T]['params'] +export type BuiltinToolParamName = { [T in BuiltinToolName]: BuiltinToolParamNameOfTool }[BuiltinToolName] + + +export type ToolName = BuiltinToolName | (string & {}) +export type ToolParamName = T extends BuiltinToolName ? BuiltinToolParamNameOfTool : string diff --git a/src/vs/workbench/contrib/void/common/voidSettingsService.ts b/src/vs/workbench/contrib/void/common/voidSettingsService.ts index b46e3f36..e448627a 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsService.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsService.ts @@ -13,7 +13,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IMetricsService } from './metricsService.js'; import { defaultProviderSettings, getModelCapabilities, ModelOverrides } from './modelCapabilities.js'; import { VOID_SETTINGS_STORAGE_KEY } from './storageKeys.js'; -import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidStatefulModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, ModelSelectionOptions, OptionsOfModelSelection, ChatMode, OverridesOfModel, defaultOverridesOfModel, } from './voidSettingsTypes.js'; +import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidStatefulModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, ModelSelectionOptions, OptionsOfModelSelection, ChatMode, OverridesOfModel, defaultOverridesOfModel, MCPUserStateOfName as MCPUserStateOfName, MCPUserState } from './voidSettingsTypes.js'; // name is the name in the dropdown @@ -43,6 +43,7 @@ export type VoidSettingsState = { readonly optionsOfModelSelection: OptionsOfModelSelection; readonly overridesOfModel: OverridesOfModel; readonly globalSettings: GlobalSettings; + readonly mcpUserStateOfName: MCPUserStateOfName; // user-controlled state of MCP servers readonly _modelOptions: ModelOption[] // computed based on the two above items } @@ -62,6 +63,7 @@ export interface IVoidSettingsService { setModelSelectionOfFeature: SetModelSelectionOfFeatureFn; setOptionsOfModelSelection: SetOptionsOfModelSelection; setGlobalSetting: SetGlobalSettingFn; + // setMCPServerStates: (newStates: MCPServerStates) => Promise; // setting to undefined CLEARS it, unlike others: setOverridesOfModel(providerName: ProviderName, modelName: string, overrides: Partial | undefined): Promise; @@ -73,6 +75,10 @@ export interface IVoidSettingsService { toggleModelHidden(providerName: ProviderName, modelName: string): void; addModel(providerName: ProviderName, modelName: string): void; deleteModel(providerName: ProviderName, modelName: string): boolean; + + addMCPUserStateOfNames(userStateOfName: MCPUserStateOfName): Promise; + removeMCPUserStateOfNames(serverNames: string[]): Promise; + setMCPServerState(serverName: string, state: MCPUserState): Promise; } @@ -212,6 +218,7 @@ const defaultState = () => { optionsOfModelSelection: { 'Chat': {}, 'Ctrl+K': {}, 'Autocomplete': {}, 'Apply': {} }, overridesOfModel: deepClone(defaultOverridesOfModel), _modelOptions: [], // computed later + mcpUserStateOfName: {}, } return d } @@ -363,6 +370,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { const newGlobalSettings = this.state.globalSettings const newOverridesOfModel = this.state.overridesOfModel + const newMCPUserStateOfName = this.state.mcpUserStateOfName const newState = { modelSelectionOfFeature: newModelSelectionOfFeature, @@ -370,6 +378,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { settingsOfProvider: newSettingsOfProvider, globalSettings: newGlobalSettings, overridesOfModel: newOverridesOfModel, + mcpUserStateOfName: newMCPUserStateOfName, } this.state = _validatedModelState(newState) @@ -533,6 +542,55 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { return true } + // MCP Server State + private _setMCPUserStateOfName = async (newStates: MCPUserStateOfName) => { + const newState: VoidSettingsState = { + ...this.state, + mcpUserStateOfName: { + ...this.state.mcpUserStateOfName, + ...newStates + } + }; + this.state = _validatedModelState(newState); + await this._storeState(); + this._onDidChangeState.fire(); + this._metricsService.capture('Set MCP Server States', { newStates }); + } + + addMCPUserStateOfNames = async (newMCPStates: MCPUserStateOfName) => { + const { mcpUserStateOfName: mcpServerStates } = this.state + const newMCPServerStates = { + ...mcpServerStates, + ...newMCPStates, + } + await this._setMCPUserStateOfName(newMCPServerStates) + this._metricsService.capture('Add MCP Servers', { servers: Object.keys(newMCPStates).join(', ') }); + } + + removeMCPUserStateOfNames = async (serverNames: string[]) => { + const { mcpUserStateOfName: mcpServerStates } = this.state + const newMCPServerStates = { + ...mcpServerStates, + } + serverNames.forEach(serverName => { + if (serverName in newMCPServerStates) { + delete newMCPServerStates[serverName] + } + }) + await this._setMCPUserStateOfName(newMCPServerStates) + this._metricsService.capture('Remove MCP Servers', { servers: serverNames.join(', ') }); + } + + setMCPServerState = async (serverName: string, state: MCPUserState) => { + const { mcpUserStateOfName } = this.state + const newMCPServerStates = { + ...mcpUserStateOfName, + [serverName]: state, + } + await this._setMCPUserStateOfName(newMCPServerStates) + this._metricsService.capture('Update MCP Server State', { serverName, state }); + } + } diff --git a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts index f63abec5..4755eb22 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts +++ b/src/vs/workbench/contrib/void/common/voidSettingsTypes.ts @@ -33,7 +33,7 @@ export type VoidStatefulModelInfo = { // <-- STATEFUL modelName: string, type: 'default' | 'autodetected' | 'custom'; isHidden: boolean, // whether or not the user is hiding it (switched off) -} // TODO!!! eventually we'd want to let the user change supportsFIM, etc on the model themselves +} @@ -493,3 +493,13 @@ export type OverridesOfModel = { const overridesOfModel = {} as OverridesOfModel for (const providerName of providerNames) { overridesOfModel[providerName] = {} } export const defaultOverridesOfModel = overridesOfModel + + + +export interface MCPUserStateOfName { + [serverName: string]: MCPUserState | undefined; +} + +export interface MCPUserState { + isOn: boolean; +} diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts index e96117b2..66e16791 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts @@ -5,8 +5,9 @@ import { generateUuid } from '../../../../../base/common/uuid.js' import { endsWithAnyPrefixOf, SurroundingsRemover } from '../../common/helpers/extractCodeFromResult.js' -import { availableTools, InternalToolInfo, ToolName, ToolParamName } from '../../common/prompt/prompts.js' +import { availableTools, InternalToolInfo } from '../../common/prompt/prompts.js' import { OnFinalMessage, OnText, RawToolCallObj, RawToolParamsObj } from '../../common/sendLLMMessageTypes.js' +import { ToolName, ToolParamName } from '../../common/toolsServiceTypes.js' import { ChatMode } from '../../common/voidSettingsTypes.js' @@ -164,15 +165,15 @@ const findIndexOfAny = (fullText: string, matches: string[]) => { type ToolOfToolName = { [toolName: string]: InternalToolInfo | undefined } -const parseXMLPrefixToToolCall = (toolName: ToolName, toolId: string, str: string, toolOfToolName: ToolOfToolName): RawToolCallObj => { +const parseXMLPrefixToToolCall = (toolName: T, toolId: string, str: string, toolOfToolName: ToolOfToolName): RawToolCallObj => { const paramsObj: RawToolParamsObj = {} - const doneParams: ToolParamName[] = [] + const doneParams: ToolParamName[] = [] let isDone = false const getAnswer = (): RawToolCallObj => { // trim off all whitespace at and before first \n and after last \n for each param for (const p in paramsObj) { - const paramName = p as ToolParamName + const paramName = p as ToolParamName const orig = paramsObj[paramName] if (orig === undefined) continue paramsObj[paramName] = trimBeforeAndAfterNewLines(orig) @@ -202,16 +203,16 @@ const parseXMLPrefixToToolCall = (toolName: ToolName, toolId: string, str: strin const pm = new SurroundingsRemover(str) - const allowedParams = Object.keys(toolOfToolName[toolName]?.params ?? {}) as ToolParamName[] + const allowedParams = Object.keys(toolOfToolName[toolName]?.params ?? {}) as ToolParamName[] if (allowedParams.length === 0) return getAnswer() - let latestMatchedOpenParam: null | ToolParamName = null + let latestMatchedOpenParam: null | ToolParamName = null let n = 0 while (true) { n += 1 if (n > 10) return getAnswer() // just for good measure as this code is early // find the param name opening tag - let matchedOpenParam: null | ToolParamName = null + let matchedOpenParam: null | ToolParamName = null for (const paramName of allowedParams) { const removed = pm.removeFromStartUntilFullMatch(`<${paramName}>`, true) if (removed) { @@ -260,11 +261,14 @@ const parseXMLPrefixToToolCall = (toolName: ToolName, toolId: string, str: strin } export const extractXMLToolsWrapper = ( - onText: OnText, onFinalMessage: OnFinalMessage, chatMode: ChatMode | null + onText: OnText, + onFinalMessage: OnFinalMessage, + chatMode: ChatMode | null, + mcpTools: InternalToolInfo[] | undefined, ): { newOnText: OnText, newOnFinalMessage: OnFinalMessage } => { if (!chatMode) return { newOnText: onText, newOnFinalMessage: onFinalMessage } - const tools = availableTools(chatMode) + const tools = availableTools(chatMode, mcpTools) if (!tools) return { newOnText: onText, newOnFinalMessage: onFinalMessage } const toolOfToolName: ToolOfToolName = {} diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts index 1190b283..5d6b43fc 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts @@ -18,7 +18,7 @@ import { AnthropicLLMChatMessage, GeminiLLMChatMessage, LLMChatMessage, LLMFIMMe import { ChatMode, displayInfoOfProviderName, ModelSelectionOptions, OverridesOfModel, ProviderName, SettingsOfProvider } from '../../common/voidSettingsTypes.js'; import { getSendableReasoningInfo, getModelCapabilities, getProviderCapabilities, defaultProviderSettings, getReservedOutputTokenSpace } from '../../common/modelCapabilities.js'; import { extractReasoningWrapper, extractXMLToolsWrapper } from './extractGrammar.js'; -import { availableTools, InternalToolInfo, isAToolName, ToolParamName, voidTools } from '../../common/prompt/prompts.js'; +import { availableTools, InternalToolInfo } from '../../common/prompt/prompts.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; const getGoogleApiKey = async () => { @@ -48,6 +48,7 @@ type SendChatParams_Internal = InternalCommonMessageParams & { messages: LLMChatMessage[]; separateSystemMessage: string | undefined; chatMode: ChatMode | null; + mcpTools: InternalToolInfo[] | undefined; } type SendFIMParams_Internal = InternalCommonMessageParams & { messages: LLMFIMMessage; separateSystemMessage: string | undefined; } export type ListParams_Internal = ModelListParams @@ -206,8 +207,8 @@ const toOpenAICompatibleTool = (toolInfo: InternalToolInfo) => { } satisfies OpenAI.Chat.Completions.ChatCompletionTool } -const openAITools = (chatMode: ChatMode) => { - const allowedTools = availableTools(chatMode) +const openAITools = (chatMode: ChatMode | null, mcpTools: InternalToolInfo[] | undefined) => { + const allowedTools = availableTools(chatMode, mcpTools) if (!allowedTools || Object.keys(allowedTools).length === 0) return null const openAITools: OpenAI.Chat.Completions.ChatCompletionTool[] = [] @@ -217,29 +218,36 @@ const openAITools = (chatMode: ChatMode) => { return openAITools } -const rawToolCallObjOf = (name: string, toolParamsStr: string, id: string): RawToolCallObj | null => { - if (!isAToolName(name)) return null - const rawParams: RawToolParamsObj = {} + +// convert LLM tool call to our tool format +const rawToolCallObjOfParamsStr = (name: string, toolParamsStr: string, id: string): RawToolCallObj | null => { let input: unknown - try { - input = JSON.parse(toolParamsStr) - } - catch (e) { - return null - } + try { input = JSON.parse(toolParamsStr) } + catch (e) { return null } + if (input === null) return null if (typeof input !== 'object') return null - for (const paramName in voidTools[name].params) { - rawParams[paramName as ToolParamName] = (input as any)[paramName] - } - return { id, name, rawParams, doneParams: Object.keys(rawParams) as ToolParamName[], isDone: true } + + const rawParams: RawToolParamsObj = input + return { id, name, rawParams, doneParams: Object.keys(rawParams), isDone: true } +} + + +const rawToolCallObjOfAnthropicParams = (toolBlock: Anthropic.Messages.ToolUseBlock): RawToolCallObj | null => { + const { id, name, input } = toolBlock + + if (input === null) return null + if (typeof input !== 'object') return null + + const rawParams: RawToolParamsObj = input + return { id, name, rawParams, doneParams: Object.keys(rawParams), isDone: true } } // ------------ OPENAI-COMPATIBLE ------------ -const _sendOpenAICompatibleChat = async ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, providerName, chatMode, separateSystemMessage, overridesOfModel }: SendChatParams_Internal) => { +const _sendOpenAICompatibleChat = async ({ messages, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, modelName: modelName_, _setAborter, providerName, chatMode, separateSystemMessage, overridesOfModel, mcpTools }: SendChatParams_Internal) => { const { modelName, specialToolFormat, @@ -259,7 +267,7 @@ const _sendOpenAICompatibleChat = async ({ messages, onText, onFinalMessage, onE } // tools - const potentialTools = chatMode !== null ? openAITools(chatMode) : null + const potentialTools = openAITools(chatMode, mcpTools) const nativeToolsObj = potentialTools && specialToolFormat === 'openai-style' ? { tools: potentialTools } as const : {} @@ -290,7 +298,7 @@ const _sendOpenAICompatibleChat = async ({ messages, onText, onFinalMessage, onE // manually parse out tool results if XML if (!specialToolFormat) { - const { newOnText, newOnFinalMessage } = extractXMLToolsWrapper(onText, onFinalMessage, chatMode) + const { newOnText, newOnFinalMessage } = extractXMLToolsWrapper(onText, onFinalMessage, chatMode, mcpTools) onText = newOnText onFinalMessage = newOnFinalMessage } @@ -335,7 +343,7 @@ const _sendOpenAICompatibleChat = async ({ messages, onText, onFinalMessage, onE onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, - toolCall: isAToolName(toolName) ? { name: toolName, rawParams: {}, isDone: false, doneParams: [], id: toolId } : undefined, + toolCall: { name: toolName, rawParams: {}, isDone: false, doneParams: [], id: toolId }, }) } @@ -344,7 +352,7 @@ const _sendOpenAICompatibleChat = async ({ messages, onText, onFinalMessage, onE onError({ message: 'Void: Response from model was empty.', fullError: null }) } else { - const toolCall = rawToolCallObjOf(toolName, toolParamsStr, toolId) + const toolCall = rawToolCallObjOfParamsStr(toolName, toolParamsStr, toolId) const toolCallObj = toolCall ? { toolCall } : {} onFinalMessage({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, anthropicReasoning: null, ...toolCallObj }); } @@ -410,8 +418,8 @@ const toAnthropicTool = (toolInfo: InternalToolInfo) => { } satisfies Anthropic.Messages.Tool } -const anthropicTools = (chatMode: ChatMode) => { - const allowedTools = availableTools(chatMode) +const anthropicTools = (chatMode: ChatMode | null, mcpTools: InternalToolInfo[] | undefined) => { + const allowedTools = availableTools(chatMode, mcpTools) if (!allowedTools || Object.keys(allowedTools).length === 0) return null const anthropicTools: Anthropic.Messages.ToolUnion[] = [] @@ -421,20 +429,10 @@ const anthropicTools = (chatMode: ChatMode) => { return anthropicTools } -const anthropicToolToRawToolCallObj = (toolBlock: Anthropic.Messages.ToolUseBlock): RawToolCallObj | null => { - const { id, name, input } = toolBlock - if (!isAToolName(name)) return null - const rawParams: RawToolParamsObj = {} - if (input === null) return null - if (typeof input !== 'object') return null - for (const paramName in voidTools[name].params) { - rawParams[paramName as ToolParamName] = (input as any)[paramName] - } - return { id, name, rawParams, doneParams: Object.keys(rawParams) as ToolParamName[], isDone: true } -} + // ------------ ANTHROPIC ------------ -const sendAnthropicChat = async ({ messages, providerName, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, overridesOfModel, modelName: modelName_, _setAborter, separateSystemMessage, chatMode }: SendChatParams_Internal) => { +const sendAnthropicChat = async ({ messages, providerName, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, overridesOfModel, modelName: modelName_, _setAborter, separateSystemMessage, chatMode, mcpTools }: SendChatParams_Internal) => { const { modelName, specialToolFormat, @@ -451,7 +449,7 @@ const sendAnthropicChat = async ({ messages, providerName, onText, onFinalMessag const maxTokens = getReservedOutputTokenSpace(providerName, modelName_, { isReasoningEnabled: !!reasoningInfo?.isReasoningEnabled, overridesOfModel }) // tools - const potentialTools = chatMode !== null ? anthropicTools(chatMode) : null + const potentialTools = anthropicTools(chatMode, mcpTools) const nativeToolsObj = potentialTools && specialToolFormat === 'anthropic-style' ? { tools: potentialTools, tool_choice: { type: 'auto' } } as const : {} @@ -475,7 +473,7 @@ const sendAnthropicChat = async ({ messages, providerName, onText, onFinalMessag // manually parse out tool results if XML if (!specialToolFormat) { - const { newOnText, newOnFinalMessage } = extractXMLToolsWrapper(onText, onFinalMessage, chatMode) + const { newOnText, newOnFinalMessage } = extractXMLToolsWrapper(onText, onFinalMessage, chatMode, mcpTools) onText = newOnText onFinalMessage = newOnFinalMessage } @@ -492,7 +490,7 @@ const sendAnthropicChat = async ({ messages, providerName, onText, onFinalMessag onText({ fullText, fullReasoning, - toolCall: isAToolName(fullToolName) ? { name: fullToolName, rawParams: {}, isDone: false, doneParams: [], id: 'dummy' } : undefined, + toolCall: { name: fullToolName, rawParams: {}, isDone: false, doneParams: [], id: 'dummy' }, }) } // there are no events for tool_use, it comes in at the end @@ -542,7 +540,7 @@ const sendAnthropicChat = async ({ messages, providerName, onText, onFinalMessag stream.on('finalMessage', (response) => { const anthropicReasoning = response.content.filter(c => c.type === 'thinking' || c.type === 'redacted_thinking') const tools = response.content.filter(c => c.type === 'tool_use') - const toolCall = tools[0] && anthropicToolToRawToolCallObj(tools[0]) + const toolCall = tools[0] && rawToolCallObjOfAnthropicParams(tools[0]) const toolCallObj = toolCall ? { toolCall } : {} onFinalMessage({ fullText, fullReasoning, anthropicReasoning, ...toolCallObj }) }) @@ -676,8 +674,8 @@ const toGeminiFunctionDecl = (toolInfo: InternalToolInfo) => { } satisfies FunctionDeclaration } -const geminiTools = (chatMode: ChatMode): GeminiTool[] | null => { - const allowedTools = availableTools(chatMode) +const geminiTools = (chatMode: ChatMode | null, mcpTools: InternalToolInfo[] | undefined): GeminiTool[] | null => { + const allowedTools = availableTools(chatMode, mcpTools) if (!allowedTools || Object.keys(allowedTools).length === 0) return null const functionDecls: FunctionDeclaration[] = [] for (const t in allowedTools ?? {}) { @@ -703,6 +701,7 @@ const sendGeminiChat = async ({ providerName, modelSelectionOptions, chatMode, + mcpTools, }: SendChatParams_Internal) => { if (providerName !== 'gemini') throw new Error(`Sending Gemini chat, but provider was ${providerName}`) @@ -728,7 +727,7 @@ const sendGeminiChat = async ({ : undefined // tools - const potentialTools = chatMode !== null ? geminiTools(chatMode) : undefined + const potentialTools = geminiTools(chatMode, mcpTools) const toolConfig = potentialTools && specialToolFormat === 'gemini-style' ? potentialTools : undefined @@ -739,7 +738,7 @@ const sendGeminiChat = async ({ // manually parse out tool results if XML if (!specialToolFormat) { - const { newOnText, newOnFinalMessage } = extractXMLToolsWrapper(onText, onFinalMessage, chatMode) + const { newOnText, newOnFinalMessage } = extractXMLToolsWrapper(onText, onFinalMessage, chatMode, mcpTools) onText = newOnText onFinalMessage = newOnFinalMessage } @@ -786,7 +785,7 @@ const sendGeminiChat = async ({ onText({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, - toolCall: isAToolName(toolName) ? { name: toolName, rawParams: {}, isDone: false, doneParams: [], id: toolId } : undefined, + toolCall: { name: toolName, rawParams: {}, isDone: false, doneParams: [], id: toolId }, }) } @@ -795,7 +794,7 @@ const sendGeminiChat = async ({ onError({ message: 'Void: Response from model was empty.', fullError: null }) } else { if (!toolId) toolId = generateUuid() // ids are empty, but other providers might expect an id - const toolCall = rawToolCallObjOf(toolName, toolParamsStr, toolId) + const toolCall = rawToolCallObjOfParamsStr(toolName, toolParamsStr, toolId) const toolCallObj = toolCall ? { toolCall } : {} onFinalMessage({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, anthropicReasoning: null, ...toolCallObj }); } diff --git a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts index 969dec8f..27f35ad5 100644 --- a/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts +++ b/src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts @@ -23,6 +23,7 @@ export const sendLLMMessage = async ({ overridesOfModel, chatMode, separateSystemMessage, + mcpTools, }: SendLLMMessageParams, metricsService: IMetricsService @@ -107,7 +108,7 @@ export const sendLLMMessage = async ({ } const { sendFIM, sendChat } = implementation if (messagesType === 'chatMessages') { - await sendChat({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, overridesOfModel, modelName, _setAborter, providerName, separateSystemMessage, chatMode }) + await sendChat({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, overridesOfModel, modelName, _setAborter, providerName, separateSystemMessage, chatMode, mcpTools }) return } if (messagesType === 'FIMMessage') { diff --git a/src/vs/workbench/contrib/void/electron-main/mcpChannel.ts b/src/vs/workbench/contrib/void/electron-main/mcpChannel.ts new file mode 100644 index 00000000..7e1be86b --- /dev/null +++ b/src/vs/workbench/contrib/void/electron-main/mcpChannel.ts @@ -0,0 +1,411 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +// registered in app.ts +// can't make a service responsible for this, because it needs +// to be connected to the main process and node dependencies + +import { IServerChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { MCPConfigFileJSON, MCPConfigFileEntryJSON, MCPServer, RawMCPToolCall, MCPToolErrorResponse, MCPServerEventResponse, MCPToolCallParams } from '../common/mcpServiceTypes.js'; +import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { MCPUserStateOfName } from '../common/voidSettingsTypes.js'; + +const getClientConfig = (serverName: string) => { + return { + name: `${serverName}-client`, + version: '0.1.0', + // debug: true, + } +} + +type MCPServerNonError = MCPServer & { status: Omit } +type MCPServerError = MCPServer & { status: 'error' } + + + +type ClientInfo = { + _client: Client, // _client is the client that connects with an mcp client. We're calling mcp clients "server" everywhere except here for naming consistency. + mcpServerEntryJSON: MCPConfigFileEntryJSON, + mcpServer: MCPServerNonError, +} | { + _client?: undefined, + mcpServerEntryJSON: MCPConfigFileEntryJSON, + mcpServer: MCPServerError, +} + +type InfoOfClientId = { + [clientId: string]: ClientInfo +} + +export class MCPChannel implements IServerChannel { + + private readonly infoOfClientId: InfoOfClientId = {} + private readonly _refreshingServerNames: Set = new Set() + + // mcp emitters + private readonly mcpEmitters = { + serverEvent: { + onAdd: new Emitter(), + onUpdate: new Emitter(), + onDelete: new Emitter(), + } + } satisfies { + serverEvent: { + onAdd: Emitter, + onUpdate: Emitter, + onDelete: Emitter, + } + } + + constructor( + ) { } + + // browser uses this to listen for changes + listen(_: unknown, event: string): Event { + + // server events + if (event === 'onAdd_server') return this.mcpEmitters.serverEvent.onAdd.event; + else if (event === 'onUpdate_server') return this.mcpEmitters.serverEvent.onUpdate.event; + else if (event === 'onDelete_server') return this.mcpEmitters.serverEvent.onDelete.event; + // else if (event === 'onLoading_server') return this.mcpEmitters.serverEvent.onChangeLoading.event; + + // tool call events + + // handle unknown events + else throw new Error(`Event not found: ${event}`); + } + + // browser uses this to call (see this.channel.call() in mcpConfigService.ts for all usages) + async call(_: unknown, command: string, params: any): Promise { + try { + if (command === 'refreshMCPServers') { + await this._refreshMCPServers(params) + } + else if (command === 'closeAllMCPServers') { + await this._closeAllMCPServers() + } + else if (command === 'toggleMCPServer') { + await this._toggleMCPServer(params.serverName, params.isOn) + } + else if (command === 'callTool') { + const p: MCPToolCallParams = params + const response = await this._safeCallTool(p.serverName, p.toolName, p.params) + return response + } + else { + throw new Error(`Void sendLLM: command "${command}" not recognized.`) + } + } + catch (e) { + console.error('mcp channel: Call Error:', e) + } + } + + // server functions + + + private async _refreshMCPServers(params: { mcpConfigFileJSON: MCPConfigFileJSON, userStateOfName: MCPUserStateOfName, addedServerNames: string[], removedServerNames: string[], updatedServerNames: string[] }) { + + const { + mcpConfigFileJSON, + userStateOfName, + addedServerNames, + removedServerNames, + updatedServerNames, + } = params + + const { mcpServers: mcpServersJSON } = mcpConfigFileJSON + + const allChanges: { type: 'added' | 'removed' | 'updated', serverName: string }[] = [ + ...addedServerNames.map(n => ({ serverName: n, type: 'added' }) as const), + ...removedServerNames.map(n => ({ serverName: n, type: 'removed' }) as const), + ...updatedServerNames.map(n => ({ serverName: n, type: 'updated' }) as const), + ] + + await Promise.all( + allChanges.map(async ({ serverName, type }) => { + + // check if already refreshing + if (this._refreshingServerNames.has(serverName)) return + this._refreshingServerNames.add(serverName) + + const prevServer = this.infoOfClientId[serverName]?.mcpServer; + + // close and delete the old client + if (type === 'removed' || type === 'updated') { + await this._closeClient(serverName) + delete this.infoOfClientId[serverName] + this.mcpEmitters.serverEvent.onDelete.fire({ response: { prevServer, name: serverName, } }) + } + + // create a new client + if (type === 'added' || type === 'updated') { + const clientInfo = await this._createClient(mcpServersJSON[serverName], serverName, userStateOfName[serverName]?.isOn) + this.infoOfClientId[serverName] = clientInfo + this.mcpEmitters.serverEvent.onAdd.fire({ response: { newServer: clientInfo.mcpServer, name: serverName, } }) + } + }) + ) + + allChanges.forEach(({ serverName, type }) => { + this._refreshingServerNames.delete(serverName) + }) + + } + + private async _createClientUnsafe(server: MCPConfigFileEntryJSON, serverName: string, isOn: boolean): Promise { + + const clientConfig = getClientConfig(serverName) + const client = new Client(clientConfig) + let transport: Transport; + let info: MCPServerNonError; + + if (server.url) { + // first try HTTP, fall back to SSE + try { + transport = new StreamableHTTPClientTransport(server.url); + await client.connect(transport); + console.log(`Connected via HTTP to ${serverName}`); + const { tools } = await client.listTools() + const toolsWithUniqueName = tools.map(({ name, ...rest }) => ({ name: this._addUniquePrefix(name), ...rest })) + info = { + status: isOn ? 'success' : 'offline', + tools: toolsWithUniqueName, + command: server.url.toString(), + } + } catch (httpErr) { + console.warn(`HTTP failed for ${serverName}, trying SSE…`, httpErr); + transport = new SSEClientTransport(server.url); + await client.connect(transport); + const { tools } = await client.listTools() + const toolsWithUniqueName = tools.map(({ name, ...rest }) => ({ name: this._addUniquePrefix(name), ...rest })) + console.log(`Connected via SSE to ${serverName}`); + info = { + status: isOn ? 'success' : 'offline', + tools: toolsWithUniqueName, + command: server.url.toString(), + } + } + } else if (server.command) { + console.log('ENV DATA: ', server.env) + transport = new StdioClientTransport({ + command: server.command, + args: server.args, + env: { + ...server.env, + ...process.env + } as Record, + }); + + await client.connect(transport) + + // Get the tools from the server + const { tools } = await client.listTools() + const toolsWithUniqueName = tools.map(({ name, ...rest }) => ({ name: this._addUniquePrefix(name), ...rest })) + + // Create a full command string for display + const fullCommand = `${server.command} ${server.args?.join(' ') || ''}` + + // Format server object + info = { + status: isOn ? 'success' : 'offline', + tools: toolsWithUniqueName, + command: fullCommand, + } + + } else { + throw new Error(`No url or command for server ${serverName}`); + } + + + return { _client: client, mcpServerEntryJSON: server, mcpServer: info } + } + + private _addUniquePrefix(base: string) { + return `${Math.random().toString(36).slice(2, 8)}_${base}`; + } + + private async _createClient(serverConfig: MCPConfigFileEntryJSON, serverName: string, isOn = true): Promise { + try { + const c: ClientInfo = await this._createClientUnsafe(serverConfig, serverName, isOn) + return c + } catch (err) { + console.error(`❌ Failed to connect to server "${serverName}":`, err) + const fullCommand = !serverConfig.command ? '' : `${serverConfig.command} ${serverConfig.args?.join(' ') || ''}` + const c: MCPServerError = { status: 'error', error: err + '', command: fullCommand, } + return { mcpServerEntryJSON: serverConfig, mcpServer: c, } + } + } + + private async _closeAllMCPServers() { + for (const serverName in this.infoOfClientId) { + await this._closeClient(serverName) + delete this.infoOfClientId[serverName] + } + console.log('Closed all MCP servers'); + } + + private async _closeClient(serverName: string) { + const info = this.infoOfClientId[serverName] + if (!info) return + const { _client: client } = info + if (client) { + await client.close() + } + console.log(`Closed MCP server ${serverName}`); + } + + + private async _toggleMCPServer(serverName: string, isOn: boolean) { + const prevServer = this.infoOfClientId[serverName]?.mcpServer + // Handle turning on the server + if (isOn) { + // this.mcpEmitters.serverEvent.onChangeLoading.fire(getLoadingServerObject(serverName, isOn)) + const clientInfo = await this._createClientUnsafe(this.infoOfClientId[serverName].mcpServerEntryJSON, serverName, isOn) + this.mcpEmitters.serverEvent.onUpdate.fire({ + response: { + name: serverName, + newServer: clientInfo.mcpServer, + prevServer: prevServer, + } + }) + } + // Handle turning off the server + else { + // this.mcpEmitters.serverEvent.onChangeLoading.fire(getLoadingServerObject(serverName, isOn)) + this._closeClient(serverName) + delete this.infoOfClientId[serverName]._client + + this.mcpEmitters.serverEvent.onUpdate.fire({ + response: { + name: serverName, + newServer: { + status: 'offline', + tools: [], + command: '', + // Explicitly set error to undefined to reset the error state + error: undefined, + }, + prevServer: prevServer, + } + }) + } + } + + // tool call functions + + private async _callTool(serverName: string, toolName: string, params: any): Promise { + const server = this.infoOfClientId[serverName] + if (!server) throw new Error(`Server ${serverName} not found`) + const { _client: client } = server + if (!client) throw new Error(`Client for server ${serverName} not found`) + + // Call the tool with the provided parameters + const response = await client.callTool({ + name: toolName, + arguments: params + }) + const { content } = response as CallToolResult + const returnValue = content[0] + + if (returnValue.type === 'text') { + // handle text response + + if (response.isError) { + throw new Error(`Tool call error: ${response.content}`) + // handle error + } + + // handle success + return { + event: 'text', + text: returnValue.text, + toolName, + serverName, + } + } + + // if (returnValue.type === 'audio') { + // // handle audio response + // } + + // if (returnValue.type === 'image') { + // // handle image response + // } + + // if (returnValue.type === 'resource') { + // // handle resource response + // } + + throw new Error(`Tool call error: We don\'t support ${returnValue.type} tool response yet for tool ${toolName} on server ${serverName}`) + } + + // tool call error wrapper + private async _safeCallTool(serverName: string, toolName: string, params: any): Promise { + try { + const response = await this._callTool(serverName, toolName, params) + return response + } catch (err) { + let errorMessage: string; + + // Check if it's an MCP error with a code + if (err && typeof err === 'object' && 'code' in err) { + const errorCode = err.code; + const errorName = err.name || 'Unknown Error'; + const errorMsg = err.message || ''; + + // Map common JSON-RPC error codes to user-friendly messages + let codeDescription = ''; + switch (errorCode) { + case -32700: + codeDescription = 'Parse Error'; + break; + case -32600: + codeDescription = 'Invalid Request'; + break; + case -32601: + codeDescription = 'Method Not Found'; + break; + case -32602: + codeDescription = 'Invalid Parameters'; + break; + case -32603: + codeDescription = 'Internal Error'; + break; + default: + codeDescription = `Error Code ${errorCode}`; + } + + errorMessage = `${errorName} (${codeDescription})${errorMsg ? ': ' + errorMsg : ''}`; + } else if (err && typeof err === 'object' && 'message' in err) { + // Standard error with message + errorMessage = err.message; + } else if (typeof err === 'string') { + // String error + errorMessage = err; + } else { + // Unknown error format + errorMessage = JSON.stringify(err, null, 2); + } + + const fullErrorMessage = `❌ Failed to call tool "${toolName}" on server "${serverName}": ${errorMessage}`; + const errorResponse: MCPToolErrorResponse = { + event: 'error', + text: fullErrorMessage, + toolName, + serverName, + } + return errorResponse + } + } +} + +