updates/fixes to mcp

This commit is contained in:
Andrew Pareles 2025-05-22 02:04:28 -07:00
parent f5b0f24445
commit 7aacbca580
11 changed files with 199 additions and 317 deletions

View file

@ -16,7 +16,7 @@ import { AnthropicReasoning, getErrorMessage, RawToolCallObj, RawToolParamsObj }
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 { approvalTypeOfToolName, BuiltinToolCallParams, BuiltinToolResultType } from '../common/toolsServiceTypes.js';
import { IToolsService } from './toolsService.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';
@ -181,7 +181,7 @@ export type ThreadStreamState = {
llmInfo?: undefined;
toolInfo: {
toolName: ToolName;
toolParams: ToolCallParams[ToolName];
toolParams: BuiltinToolCallParams[ToolName];
id: string;
content: string;
rawParams: RawToolParamsObj;
@ -532,7 +532,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
const lastMsg = thread.messages[thread.messages.length - 1]
let params: ToolCallParams[ToolName]
let params: BuiltinToolCallParams[ToolName]
if (lastMsg.role === 'tool' && lastMsg.type !== 'invalid_params') {
params = lastMsg.params
}
@ -597,12 +597,12 @@ class ChatThreadService extends Disposable implements IChatThreadService {
threadId: string,
toolName: ToolName,
toolId: string,
opts: { preapproved: true, unvalidatedToolParams: RawToolParamsObj, validatedParams: ToolCallParams[ToolName] } | { preapproved: false, unvalidatedToolParams: RawToolParamsObj },
opts: { preapproved: true, unvalidatedToolParams: RawToolParamsObj, validatedParams: BuiltinToolCallParams[ToolName] } | { preapproved: false, unvalidatedToolParams: RawToolParamsObj },
): Promise<{ awaitingUserApproval?: boolean, interrupted?: boolean }> => {
// compute these below
let toolParams: ToolCallParams[ToolName]
let toolResult: Awaited<ToolResultType[typeof toolName]>
let toolParams: BuiltinToolCallParams[ToolName]
let toolResult: Awaited<BuiltinToolResultType[typeof toolName]>
let toolResultStr: string
if (!opts.preapproved) { // skip this if pre-approved
@ -616,8 +616,8 @@ class ChatThreadService extends Disposable implements IChatThreadService {
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
@ -638,6 +638,33 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// TODO!!!!!!!!!
// const isBuiltInTool = (toolNames as string[]).includes(toolName)
// const callToolFn = (toolName: string, toolParams: BuiltinToolCallParams[ToolName]) => {
// if (isBuiltInTool) {
// }
// else {
// }
// }
// const stringifyToolFn = (toolName: string, toolResult: Awaited<BuiltinToolResultType[ToolName]>) => {
// if (isBuiltInTool) {
// }
// else {
// if (result.event === 'error' || result.event === 'text') {
// return result.text;
// }
// }
// }
// 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
@ -1300,7 +1327,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)
}
}

View file

@ -905,11 +905,6 @@ const MCPServerComponent = ({ name, server }: { name: string, server: MCPServer
const accessor = useAccessor();
const mcpService = accessor.get('IMCPService');
const handleChangeEvent = (e: boolean) => {
// Handle the change event
mcpService.toggleMCPServer(name, e);
}
const voidSettings = useSettingsState()
const isOn = voidSettings.mcpUserStateOfName[name]?.isOn
@ -934,7 +929,7 @@ const MCPServerComponent = ({ name, server }: { name: string, server: MCPServer
<VoidSwitch
value={isOn ?? false}
disabled={server.status === 'error'}
onChange={handleChangeEvent}
onChange={() => mcpService.toggleServerIsOn(name, !isOn)}
/>
</div>
</div>

View file

@ -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 } from '../common/toolsServiceTypes.js'
import { IVoidModelService } from '../common/voidModelService.js'
import { EndOfLinePreference } from '../../../../editor/common/model.js'
import { IVoidCommandBarService } from './voidCommandBarService.js'
@ -19,22 +19,12 @@ 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 { IVoidSettingsService } from '../common/voidSettingsService.js'
import { generateUuid } from '../../../../base/common/uuid.js'
import { IMCPService, MCPCallTool, MCPToolResultToString } from '../common/mcpService.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<ToolResultType[T]>, interruptTool?: () => void }> }
type ToolResultToString = { [T in ToolName]: (p: ToolCallParams[T], result: Awaited<ToolResultType[T]>) => string }
// Interfaces that accept both internal tools and MCP tools
export type ToolHandler = CallTool & MCPCallTool;
export type ToolResultToStringHandler = ToolResultToString & MCPToolResultToString
type ValidateBuiltinParams = { [T in ToolName]: (p: RawToolParamsObj) => BuiltinToolCallParams[T] }
type CallBuiltinTool = { [T in ToolName]: (p: BuiltinToolCallParams[T]) => Promise<{ result: BuiltinToolResultType[T] | Promise<BuiltinToolResultType[T]>, interruptTool?: () => void }> }
type BuiltinToolResultToString = { [T in ToolName]: (p: BuiltinToolCallParams[T], result: Awaited<BuiltinToolResultType[T]>) => string }
const isFalsy = (u: unknown) => {
@ -115,9 +105,9 @@ const checkIfIsFolder = (uriStr: string) => {
export interface IToolsService {
readonly _serviceBrand: undefined;
validateParams: ValidateParams;
callTool: ToolHandler;
stringOfResult: ToolResultToStringHandler;
validateParams: ValidateBuiltinParams;
callTool: CallBuiltinTool;
stringOfResult: BuiltinToolResultToString;
}
export const IToolsService = createDecorator<IToolsService>('ToolsService');
@ -126,9 +116,9 @@ export class ToolsService implements IToolsService {
readonly _serviceBrand: undefined;
public validateParams: ValidateParams;
public callTool: ToolHandler;
public stringOfResult: ToolResultToStringHandler;
public validateParams: ValidateBuiltinParams;
public callTool: CallBuiltinTool;
public stringOfResult: BuiltinToolResultToString;
constructor(
@IFileService fileService: IFileService,
@ -142,7 +132,6 @@ export class ToolsService implements IToolsService {
@IDirectoryStrService private readonly directoryStrService: IDirectoryStrService,
@IMarkerService private readonly markerService: IMarkerService,
@IVoidSettingsService private readonly voidSettingsService: IVoidSettingsService,
@IMCPService private readonly mcpService: IMCPService,
) {
const queryBuilder = instantiationService.createInstance(QueryBuilder);
@ -452,8 +441,6 @@ export class ToolsService implements IToolsService {
await this.terminalToolService.killPersistentTerminal(persistentTerminalId)
return { result: {} }
},
// Returns MCP server call tool functions
...this.mcpService.getMCPToolFns().callTool,
}
@ -557,8 +544,6 @@ export class ToolsService implements IToolsService {
kill_persistent_terminal: (params, _result) => {
return `Successfully closed terminal "${params.persistentTerminalId}".`;
},
// All MCP server result to string functions
...this.mcpService.getMCPToolFns().resultToString,
}

View file

@ -7,7 +7,7 @@ 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 { BuiltinToolCallParams, BuiltinToolResultType } from './toolsServiceTypes.js';
export type ToolMessage<T extends ToolName> = {
role: 'tool';
@ -18,13 +18,13 @@ export type ToolMessage<T extends ToolName> = {
// 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: BuiltinToolCallParams[T], } // params were validated, awaiting user
| { type: 'running_now', result: null, name: T, params: ToolCallParams[T], }
| { type: 'running_now', result: null, name: T, params: BuiltinToolCallParams[T], }
| { type: 'tool_error', result: string, name: T, params: ToolCallParams[T], } // error when tool was running
| { type: 'success', result: Awaited<ToolResultType[T]>, name: T, params: ToolCallParams[T], }
| { type: 'rejected', result: null, name: T, params: ToolCallParams[T] }
| { type: 'tool_error', result: string, name: T, params: BuiltinToolCallParams[T], } // error when tool was running
| { type: 'success', result: Awaited<BuiltinToolResultType[T]>, name: T, params: BuiltinToolCallParams[T], }
| { type: 'rejected', result: null, name: T, params: BuiltinToolCallParams[T] }
) // user rejected
export type DecorativeCanceledTool = {

View file

@ -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<ToolResultType['ls_dir']> => {
): Promise<BuiltinToolResultType['ls_dir']> => {
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`;
}

View file

@ -14,7 +14,7 @@ import { IProductService } from '../../../../platform/product/common/productServ
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, MCPAddServerResponse, MCPUpdateServerResponse, MCPDeleteServerResponse, MCPServer, MCPToolCallParams, MCPGenericToolResponse } from './mcpServiceTypes.js';
import { MCPServerOfName, MCPConfigFileJSON, MCPServer, MCPToolCallParams, MCPGenericToolResponse, MCPServerEventResponse } from './mcpServiceTypes.js';
import { Event, Emitter } from '../../../../base/common/event.js';
import { InternalToolInfo } from './prompt/prompts.js';
import { IVoidSettingsService } from './voidSettingsService.js';
@ -29,16 +29,16 @@ type MCPServiceState = {
export interface IMCPService {
readonly _serviceBrand: undefined;
revealMCPConfigFile(): Promise<void>;
toggleMCPServer(serverName: string, isOn: boolean): Promise<void>;
toggleServerIsOn(serverName: string, isOn: boolean): Promise<void>;
readonly state: MCPServiceState; // NOT persisted
onDidChangeState: Event<void>;
getCurrentMCPToolNames(): InternalToolInfo[];
getMCPToolFns(): {
callTool: MCPCallTool;
resultToString: MCPToolResultToString
};
// TODO!!!!!!!!! getMCPToolDescriptors (the equivalent of tools in prompts.ts)
// getMCPToolFns(): MCPCallToolOfToolName;
}
export const IMCPService = createDecorator<IMCPService>('mcpConfigService');
@ -50,21 +50,13 @@ const MCP_CONFIG_SAMPLE = { mcpServers: {} }
const MCP_CONFIG_SAMPLE_STRING = JSON.stringify(MCP_CONFIG_SAMPLE, null, 2);
export interface MCPCallTool {
export interface MCPCallToolOfToolName {
[toolName: string]: (params: any) => Promise<{
result: any | Promise<any>,
interruptTool?: () => void
}>;
}
export interface MCPToolResultToString {
[toolName: string]: (params: any, result: any) => string;
}
class MCPService extends Disposable implements IMCPService {
_serviceBrand: undefined;
@ -96,9 +88,9 @@ class MCPService extends Disposable implements IMCPService {
super();
this.channel = this.mainProcessService.getChannel('void-channel-mcp')
this._register((this.channel.listen('onAdd_server') satisfies Event<MCPAddServerResponse>)(e => { this._setMCPServerState(e.response.name, e.response.newServer) }));
this._register((this.channel.listen('onUpdate_server') satisfies Event<MCPUpdateServerResponse>)(e => { this._setMCPServerState(e.response.name, e.response.newServer) }));
this._register((this.channel.listen('onDelete_server') satisfies Event<MCPDeleteServerResponse>)(e => { this._setMCPServerState(e.response.name, e.response.newServer) }));
this._register((this.channel.listen('onAdd_server') satisfies Event<MCPServerEventResponse>)(e => { this._setMCPServerState(e.response.name, e.response.newServer) }));
this._register((this.channel.listen('onUpdate_server') satisfies Event<MCPServerEventResponse>)(e => { this._setMCPServerState(e.response.name, e.response.newServer) }));
this._register((this.channel.listen('onDelete_server') satisfies Event<MCPServerEventResponse>)(e => { this._setMCPServerState(e.response.name, e.response.newServer) }));
this._initialize();
}
@ -205,9 +197,9 @@ class MCPService extends Disposable implements IMCPService {
}
// toggle MCP server and update isOn in void settings
public async toggleMCPServer(serverName: string, isOn: boolean): Promise<void> {
this.channel.call('toggleMCPServer', { serverName, isOn })
public async toggleServerIsOn(serverName: string, isOn: boolean): Promise<void> {
await this.voidSettingsService.setMCPServerState(serverName, { isOn });
this.channel.call('toggleMCPServer', { serverName, isOn })
}
// utility functions
@ -272,66 +264,55 @@ class MCPService extends Disposable implements IMCPService {
await this.voidSettingsService.removeMCPUserStateOfNames(removedServerNames);
// set all servers to loading
const mcpConfigOfName = newConfigFileJSON.mcpServers
for (const serverName in mcpConfigOfName) {
for (const serverName in newConfigFileJSON.mcpServers) {
if (serverName in this.state.mcpServerOfName) continue
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, userStateOfName: this.voidSettingsService.state.mcpUserStateOfName })
this.channel.call('refreshMCPServers', {
mcpConfigFileJSON: newConfigFileJSON,
addedServerNames,
removedServerNames,
updatedServerNames,
userStateOfName: this.voidSettingsService.state.mcpUserStateOfName,
})
}
public async callMCPTool(toolData: MCPToolCallParams): Promise<MCPGenericToolResponse> {
const response = await this.channel.call<MCPGenericToolResponse>('callTool', toolData);
return response;
public async callMCPTool(toolData: MCPToolCallParams): Promise<{ result: MCPGenericToolResponse }> {
const result = await this.channel.call<MCPGenericToolResponse>('callTool', toolData);
return { result };
}
public getMCPToolFns(): { callTool: MCPCallTool; resultToString: MCPToolResultToString } {
const tools = this.getCurrentMCPToolNames();
const toolFns: MCPCallTool = {};
const toolResultToStringFns: MCPToolResultToString = {};
// public getMCPToolFns(): MCPToolResultType {
// const tools = this.getCurrentMCPToolNames();
// const toolFns: MCPToolResultType = {};
tools.forEach((tool) => {
const name = tool.name;
const serverName = tool.mcpServerName;
// 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;
// });
// 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,
};
};
// Define the result-to-string function
const resultToStringFn = (params: any, result: MCPGenericToolResponse): string => {
if (result.event === 'error' || result.event === 'text') {
return result.text;
}
throw new Error(`MCP Server ${serverName} and Tool ${name} returned an unexpected result: ${JSON.stringify(result)}`);
};
toolFns[name] = toolFn;
toolResultToStringFns[name] = resultToStringFn;
});
return {
callTool: toolFns,
resultToString: toolResultToStringFns
};
}
// return toolFns
// }
}
registerSingleton(IMCPService, MCPService, InstantiationType.Eager);

View file

@ -152,36 +152,12 @@ export interface MCPServerOfName {
}
export type MCPServerEvent = {
type: 'add';
name: string;
prevServer?: undefined;
newServer: MCPServer;
} | {
type: 'update';
name: string;
prevServer: MCPServer;
newServer: MCPServer;
} | {
type: 'delete';
name: string;
newServer?: undefined;
prevServer: MCPServer;
} | {
type: 'loading';
name: string;
prevServer?: undefined;
newServer: MCPServer;
prevServer?: MCPServer;
newServer?: MCPServer;
}
// Response types
export type MCPServerEventResponse = { response: MCPServerEvent }
export type MCPAddServerResponse = { response: MCPServerEvent & { type: 'add' } }
export type MCPUpdateServerResponse = { response: MCPServerEvent & { type: 'update' } }
export type MCPDeleteServerResponse = { response: MCPServerEvent & { type: 'delete' } }
export interface MCPConfigFileParseErrorResponse {
response: {
type: 'config-file-error';

View file

@ -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 { approvalTypeOfToolName, BuiltinToolCallParams, BuiltinToolResultType } from '../toolsServiceTypes.js';
import { ChatMode } from '../voidSettingsTypes.js';
// Triple backtick wrapper used throughout the prompts for code blocks
@ -186,11 +186,11 @@ export type SnakeCaseKeys<T extends Record<string, any>> = {
// export const voidTools = {
export const voidTools
: {
[T in keyof ToolCallParams]: {
[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<ToolCallParams[T]>]: { description: string } }>
params: Partial<{ [paramName in keyof SnakeCaseKeys<BuiltinToolCallParams[T]>]: { description: string } }>
}
}
= {
@ -345,10 +345,10 @@ export const voidTools
// go_to_definition
// go_to_usages
} satisfies { [T in keyof ToolResultType]: InternalToolInfo }
} satisfies { [T in keyof BuiltinToolResultType]: InternalToolInfo }
export type ToolName = keyof ToolResultType
export type ToolName = keyof BuiltinToolResultType
export const toolNames = Object.keys(voidTools) as ToolName[]
type ToolParamNameOfTool<T extends ToolName> = keyof (typeof voidTools)[T]['params']

View file

@ -37,7 +37,7 @@ export const toolApprovalTypes = new Set<ToolApprovalType>(
)
// 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 +58,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, },

View file

@ -580,10 +580,9 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService {
}
setMCPServerState = async (serverName: string, state: MCPUserState) => {
const { mcpUserStateOfName: mcpServerStates } = this.state
if (!(serverName in mcpServerStates)) return // if not in list, do nothing
const { mcpUserStateOfName } = this.state
const newMCPServerStates = {
...mcpServerStates,
...mcpUserStateOfName,
[serverName]: state,
}
await this._setMCPUserStateOfName(newMCPServerStates)

View file

@ -13,10 +13,9 @@ 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, MCPAddServerResponse, MCPUpdateServerResponse, MCPDeleteServerResponse, MCPGenericToolResponse, MCPToolErrorResponse } from '../common/mcpServiceTypes.js';
import { MCPConfigFileJSON, MCPConfigFileEntryJSON, MCPServer, MCPGenericToolResponse, MCPToolErrorResponse, MCPServerEventResponse } from '../common/mcpServiceTypes.js';
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { equals } from '../../../../base/common/objects.js';
import { MCPUserStateOfName } from '../common/voidSettingsTypes.js';
const getClientConfig = (serverName: string) => {
@ -27,53 +26,46 @@ const getClientConfig = (serverName: string) => {
}
}
type MCPServerNonError = MCPServer & { status: Omit<MCPServer['status'], 'error'> }
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 {
// connected clients
private infoOfClientId: {
[clientId: string]: {
_client: Client,
mcpConfig: MCPConfigFileEntryJSON,
mcpServer: MCPServerNonError,
} | {
_client?: undefined,
mcpConfig: MCPConfigFileEntryJSON,
mcpServer: MCPServerError,
}
} = {}
private readonly infoOfClientId: InfoOfClientId = {}
private readonly _refreshingServerNames: Set<string> = new Set()
// mcp emitters
private readonly mcpEmitters = {
serverEvent: {
onAdd: new Emitter<MCPAddServerResponse>(),
onUpdate: new Emitter<MCPUpdateServerResponse>(),
onDelete: new Emitter<MCPDeleteServerResponse>(),
// onResult: new Emitter<>(),
// onError: new Emitter<>(),
// onChangeLoading: new Emitter<MCPLoadingResponse>(), // really onStart
onAdd: new Emitter<MCPServerEventResponse>(),
onUpdate: new Emitter<MCPServerEventResponse>(),
onDelete: new Emitter<MCPServerEventResponse>(),
}
// toolCall: {
// success: new Emitter<void>(),
// error: new Emitter<void>(),
// },
} satisfies {
serverEvent: {
onAdd: Emitter<MCPAddServerResponse>,
onUpdate: Emitter<MCPUpdateServerResponse>,
onDelete: Emitter<MCPDeleteServerResponse>,
// onChangeLoading: Emitter<MCPLoadingResponse>,
onAdd: Emitter<MCPServerEventResponse>,
onUpdate: Emitter<MCPServerEventResponse>,
onDelete: Emitter<MCPServerEventResponse>,
}
}
constructor(
// private readonly metricsService: IMetricsService,
) { }
// browser uses this to listen for changes
@ -118,108 +110,62 @@ export class MCPChannel implements IServerChannel {
// server functions
private async _refreshMCPServers(params: { mcpConfigFileJSON: MCPConfigFileJSON, userStateOfName: MCPUserStateOfName }) {
const { mcpConfigFileJSON, userStateOfName } = params
private async _refreshMCPServers(params: { mcpConfigFileJSON: MCPConfigFileJSON, userStateOfName: MCPUserStateOfName, addedServerNames: string[], removedServerNames: string[], updatedServerNames: string[] }) {
// Get all prevServers
const prevServers = { ...this.infoOfClientId }
const {
mcpConfigFileJSON,
userStateOfName,
addedServerNames,
removedServerNames,
updatedServerNames,
} = params
// Handle config file setup and changes
const { mcpServers } = mcpConfigFileJSON
const serverNames = Object.keys(mcpServers)
const { mcpServers: mcpServersJSON } = mcpConfigFileJSON
const getPrevAndNewServerConfig = (serverName: string) => {
const prevMCPConfig = prevServers[serverName]?.mcpConfig
const newMCPConfig = mcpServers[serverName]
return { prevMCPConfig, newMCPConfig }
}
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),
]
// Divide the server based on event
const addedServers = serverNames.filter((serverName) => {
const { prevMCPConfig, newMCPConfig } = getPrevAndNewServerConfig(serverName)
const isNew = !prevMCPConfig && newMCPConfig
// if (isAdded) {
// this.mcpEmitters.serverEvent.onChangeLoading.fire(getLoadingServerObject(serverName, serverStates[serverName]?.isOn))
// }
return isNew
})
const updatedServers = serverNames.filter((serverName) => {
const { prevMCPConfig, newMCPConfig } = getPrevAndNewServerConfig(serverName)
const isNew = prevMCPConfig && newMCPConfig && !equals(prevMCPConfig, newMCPConfig)
// if (isUpdated) {
// this.mcpEmitters.serverEvent.onChangeLoading.fire(getLoadingServerObject(serverName, serverStates[serverName]?.isOn))
// }
return isNew
})
const deletedServers = Object.keys(prevServers).filter((serverName) => {
const { prevMCPConfig, newMCPConfig } = getPrevAndNewServerConfig(serverName)
const isNew = prevMCPConfig && !newMCPConfig
// if (isDeleted) {
// this.mcpEmitters.serverEvent.onChangeLoading.fire(getLoadingServerObject(serverName, serverStates[serverName]?.isOn))
// }
return isNew
})
await Promise.all(
allChanges.map(async ({ serverName, type }) => {
// Check if no changes were made
if (addedServers.length === 0 && updatedServers.length === 0 && deletedServers.length === 0) {
console.log('No changes to MCP servers found.')
return
}
// check if already refreshing
if (this._refreshingServerNames.has(serverName)) return
this._refreshingServerNames.add(serverName)
if (addedServers.length > 0) {
// emit added servers
const addPromises = addedServers.map(async (serverName) => {
const addedServer = await this._safeSetupServer(mcpServers[serverName], serverName, userStateOfName[serverName]?.isOn)
return {
type: 'add',
newServer: addedServer,
name: serverName,
} as const
});
const formattedAddedResponses = await Promise.all(addPromises);
formattedAddedResponses.forEach((formattedResponse) => (this.mcpEmitters.serverEvent.onAdd.fire({ response: formattedResponse })));
}
if (updatedServers.length > 0) {
// emit updated servers
const updatePromises = updatedServers.map(async (serverName) => {
const prevServer = this.infoOfClientId[serverName]?.mcpServer;
const newServer = await this._safeSetupServer(mcpServers[serverName], serverName, userStateOfName[serverName]?.isOn)
return {
type: 'update',
prevServer,
newServer: newServer,
name: serverName,
} as const
});
const formattedUpdatedResponses = await Promise.all(updatePromises);
formattedUpdatedResponses.forEach((formattedResponse) => (this.mcpEmitters.serverEvent.onUpdate.fire({ response: formattedResponse })));
}
if (deletedServers.length > 0) {
// emit deleted servers
const deletePromises = deletedServers.map(async (serverName) => {
const prevServer = this.infoOfClientId[serverName]?.mcpServer;
await this._closeClient(serverName)
this._removeClient(serverName)
return {
type: 'delete',
prevServer,
name: serverName,
} as const
});
const formattedDeletedResponses = await Promise.all(deletePromises);
formattedDeletedResponses.forEach((formattedResponse) => (this.mcpEmitters.serverEvent.onDelete.fire({ response: formattedResponse })));
}
// 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 _callSetupServer(server: MCPConfigFileEntryJSON, serverName: string, isOn = true) {
private async _createClientUnsafe(server: MCPConfigFileEntryJSON, serverName: string, isOn: boolean): Promise<ClientInfo> {
const clientConfig = getClientConfig(serverName)
const client = new Client(clientConfig)
let transport: Transport;
let formattedServer: MCPServer;
let info: MCPServerNonError;
if (server.url) {
// first try HTTP, fall back to SSE
@ -228,7 +174,7 @@ export class MCPChannel implements IServerChannel {
await client.connect(transport);
console.log(`Connected via HTTP to ${serverName}`);
const { tools } = await client.listTools()
formattedServer = {
info = {
status: isOn ? 'success' : 'offline',
tools: tools,
command: server.url.toString(),
@ -238,7 +184,7 @@ export class MCPChannel implements IServerChannel {
transport = new SSEClientTransport(server.url);
await client.connect(transport);
console.log(`Connected via SSE to ${serverName}`);
formattedServer = {
info = {
status: isOn ? 'success' : 'offline',
tools: [],
command: server.url.toString(),
@ -264,7 +210,7 @@ export class MCPChannel implements IServerChannel {
const fullCommand = `${server.command} ${server.args?.join(' ') || ''}`
// Format server object
formattedServer = {
info = {
status: isOn ? 'success' : 'offline',
tools: tools,
command: fullCommand,
@ -275,44 +221,25 @@ export class MCPChannel implements IServerChannel {
}
this.infoOfClientId[serverName] = { _client: client, mcpConfig: server, mcpServer: formattedServer }
return formattedServer;
return { _client: client, mcpServerEntryJSON: server, mcpServer: info }
}
// Helper function to safely setup a server
private async _safeSetupServer(serverConfig: MCPConfigFileEntryJSON, serverName: string, isOn = true) {
private async _createClient(serverConfig: MCPConfigFileEntryJSON, serverName: string, isOn = true): Promise<ClientInfo> {
try {
return await this._callSetupServer(serverConfig, serverName, isOn)
const c: ClientInfo = await this._createClientUnsafe(serverConfig, serverName, isOn)
return c
} catch (err) {
const typedErr = err as Error
console.error(`❌ Failed to connect to server "${serverName}":`, err)
let fullCommand = ''
if (serverConfig.command) {
fullCommand = `${serverConfig.command} ${serverConfig.args?.join(' ') || ''}`
}
const formattedError: MCPServerError = {
status: 'error',
// tools: [],
error: typedErr.message,
command: fullCommand,
}
// Add the error to the clients object
this.infoOfClientId[serverName] = {
mcpConfig: serverConfig,
mcpServer: formattedError,
}
return formattedError
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)
this._removeClient(serverName)
delete this.infoOfClientId[serverName]
}
console.log('Closed all MCP servers');
}
@ -324,46 +251,38 @@ export class MCPChannel implements IServerChannel {
if (client) {
await client.close()
}
// Remove the client from the clients object
delete this.infoOfClientId[serverName]._client
console.log(`Closed MCP server ${serverName}`);
}
private _removeClient(serverName: string) {
if (this.infoOfClientId[serverName]) {
delete this.infoOfClientId[serverName]
console.log(`Removed MCP server ${serverName}`);
}
}
private async _toggleMCPServer(serverName: string, isOn: boolean) {
const prevServer = this.infoOfClientId[serverName]?.mcpServer
// Handle turning on the server
if (isOn) {
// Handle turning on the server
// this.mcpEmitters.serverEvent.onChangeLoading.fire(getLoadingServerObject(serverName, isOn))
const formattedServer = await this._callSetupServer(this.infoOfClientId[serverName].mcpConfig, serverName)
const clientInfo = await this._createClientUnsafe(this.infoOfClientId[serverName].mcpServerEntryJSON, serverName, isOn)
this.mcpEmitters.serverEvent.onUpdate.fire({
response: {
type: 'update',
name: serverName,
newServer: formattedServer,
newServer: clientInfo.mcpServer,
prevServer: prevServer,
}
})
} else {
// Handle turning off the server
}
// 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: {
type: 'update',
name: serverName,
newServer: {
status: 'offline',
tools: [],
command: '',
// Explicitly set error to undefined
// to reset the error state
// Explicitly set error to undefined to reset the error state
error: undefined,
},
prevServer: prevServer,