diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39ac271dfc..44a1e9acd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -589,8 +589,8 @@ importers: specifier: ^1.3.6 version: 1.6.1(zod@3.24.4) '@modelcontextprotocol/sdk': - specifier: ^1.9.0 - version: 1.12.0 + specifier: ^1.12.1 + version: 1.12.1 '@qdrant/js-client-rest': specifier: ^1.14.0 version: 1.14.0(typescript@5.8.3) @@ -2499,8 +2499,8 @@ packages: '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} - '@modelcontextprotocol/sdk@1.12.0': - resolution: {integrity: sha512-m//7RlINx1F3sz3KqwY1WWzVgTcYX52HYk4bJ1hkBXV3zccAEth+jRvG8DBRrdaQuRsPAJOx2MH3zaHNCKL7Zg==} + '@modelcontextprotocol/sdk@1.12.1': + resolution: {integrity: sha512-KG1CZhZfWg+u8pxeM/mByJDScJSrjjxLc8fwQqbsS8xCjBmQfMNEBTotYdNanKekepnfRI85GtgQlctLFpcYPw==} engines: {node: '>=18'} '@mswjs/interceptors@0.38.6': @@ -7672,7 +7672,6 @@ packages: libsql@0.5.12: resolution: {integrity: sha512-TikiQZ1j4TwFEqVdJdTM9ZTti28is/ytGEvn0S2MocOj69UKQetWACe/qd8KAD5VeNnQSVd6Nlm2AJx0DFW9Ag==} - cpu: [x64, arm64, wasm32, arm] os: [darwin, linux, win32] lie@3.3.0: @@ -12615,7 +12614,7 @@ snapshots: '@mixmark-io/domino@2.2.0': {} - '@modelcontextprotocol/sdk@1.12.0': + '@modelcontextprotocol/sdk@1.12.1': dependencies: ajv: 6.12.6 content-type: 1.0.5 @@ -12626,8 +12625,8 @@ snapshots: express-rate-limit: 7.5.0(express@5.1.0) pkce-challenge: 5.0.0 raw-body: 3.0.0 - zod: 3.25.49 - zod-to-json-schema: 3.24.5(zod@3.25.49) + zod: 3.25.51 + zod-to-json-schema: 3.24.5(zod@3.25.51) transitivePeerDependencies: - supports-color @@ -22402,6 +22401,10 @@ snapshots: dependencies: zod: 3.25.49 + zod-to-json-schema@3.24.5(zod@3.25.51): + dependencies: + zod: 3.25.51 + zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.24.4): dependencies: typescript: 5.8.3 diff --git a/src/package.json b/src/package.json index 93aa51a8c9..006953e89f 100644 --- a/src/package.json +++ b/src/package.json @@ -360,7 +360,7 @@ "@aws-sdk/credential-providers": "^3.806.0", "@google/genai": "^0.13.0", "@mistralai/mistralai": "^1.3.6", - "@modelcontextprotocol/sdk": "^1.9.0", + "@modelcontextprotocol/sdk": "^1.12.1", "@roo-code/cloud": "workspace:^", "@roo-code/ipc": "workspace:^", "@roo-code/telemetry": "workspace:^", diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index e7d681df36..69ce9245b0 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -32,6 +32,7 @@ import { import { fileExistsAtPath } from "../../utils/fs" import { arePathsEqual } from "../../utils/path" import { injectEnv } from "../../utils/config" +import { createOutputChannelLogger, type LogFunction } from "../../utils/outputChannelLogger" export type McpConnection = { server: McpServer @@ -132,11 +133,30 @@ export class McpHub { connections: McpConnection[] = [] isConnecting: boolean = false private refCount: number = 0 // Reference counter for active clients + private log: LogFunction + private configChangeDebounceTimers: Map = new Map() constructor(provider: ClineProvider) { this.providerRef = new WeakRef(provider) + // Initialize logging using the provider's log method + this.log = (...args: unknown[]) => { + const provider = this.providerRef.deref() + if (provider) { + provider.log( + args + .map((arg) => + typeof arg === "string" + ? arg + : arg instanceof Error + ? `${arg.message}\n${arg.stack || ""}` + : JSON.stringify(arg), + ) + .join(" "), + ) + } + } this.watchMcpSettingsFile() - this.watchProjectMcpFile() + this.watchProjectMcpFile().catch(console.error) this.setupWorkspaceFoldersWatcher() this.initializeGlobalMcpServers() this.initializeProjectMcpServers() @@ -147,7 +167,7 @@ export class McpHub { */ public registerClient(): void { this.refCount++ - console.log(`McpHub: Client registered. Ref count: ${this.refCount}`) + this.log(`McpHub: Client registered. Ref count: ${this.refCount}`) } /** @@ -156,9 +176,9 @@ export class McpHub { */ public async unregisterClient(): Promise { this.refCount-- - console.log(`McpHub: Client unregistered. Ref count: ${this.refCount}`) + this.log(`McpHub: Client unregistered. Ref count: ${this.refCount}`) if (this.refCount <= 0) { - console.log("McpHub: Last client unregistered. Disposing hub.") + this.log("McpHub: Last client unregistered. Disposing hub.") await this.dispose() } } @@ -236,6 +256,7 @@ export class McpHub { * @param error The error object */ private showErrorMessage(message: string, error: unknown): void { + this.log(`${message}: ${error instanceof Error ? error.message : String(error)}`) console.error(`${message}:`, error) } @@ -247,43 +268,116 @@ export class McpHub { this.disposables.push( vscode.workspace.onDidChangeWorkspaceFolders(async () => { await this.updateProjectMcpServers() - this.watchProjectMcpFile() + await this.watchProjectMcpFile() }), ) } + /** + * Debounced wrapper for handling config file changes + */ + private debounceConfigChange(filePath: string, source: "global" | "project"): void { + const key = `${source}-${filePath}` + + // Clear existing timer if any + const existingTimer = this.configChangeDebounceTimers.get(key) + if (existingTimer) { + clearTimeout(existingTimer) + } + + // Set new timer + const timer = setTimeout(async () => { + this.configChangeDebounceTimers.delete(key) + await this.handleConfigFileChange(filePath, source) + }, 500) // 500ms debounce + + this.configChangeDebounceTimers.set(key, timer) + } + private async handleConfigFileChange(filePath: string, source: "global" | "project"): Promise { try { const content = await fs.readFile(filePath, "utf-8") - const config = JSON.parse(content) + let config: any + + try { + config = JSON.parse(content) + } catch (parseError) { + const errorMessage = t("mcp:errors.invalid_settings_syntax") + console.error(errorMessage, parseError) + vscode.window.showErrorMessage(errorMessage) + return + } + const result = McpSettingsSchema.safeParse(config) if (!result.success) { const errorMessages = result.error.errors .map((err) => `${err.path.join(".")}: ${err.message}`) .join("\n") - vscode.window.showErrorMessage(t("common:errors.invalid_mcp_settings_validation", { errorMessages })) + vscode.window.showErrorMessage(t("mcp:errors.invalid_settings_validation", { errorMessages })) return } await this.updateServerConnections(result.data.mcpServers || {}, source) } catch (error) { - if (error instanceof SyntaxError) { - vscode.window.showErrorMessage(t("common:errors.invalid_mcp_settings_format")) + // Check if the error is because the file doesn't exist + if (error.code === "ENOENT" && source === "project") { + // File was deleted, clean up project MCP servers + await this.cleanupProjectMcpServers() + await this.notifyWebviewOfServerChanges() + vscode.window.showInformationMessage(t("mcp:info.project_config_deleted")) } else { - this.showErrorMessage(`Failed to process ${source} MCP settings change`, error) + this.showErrorMessage(t("mcp:errors.failed_update_project"), error) } } } - private watchProjectMcpFile(): void { + private async watchProjectMcpFile(): Promise { + // Skip if test environment is detected or VSCode APIs are not available + if ( + process.env.NODE_ENV === "test" || + process.env.JEST_WORKER_ID !== undefined || + !vscode.workspace.createFileSystemWatcher + ) { + return + } + + // Clean up existing project MCP watcher if it exists + if (this.projectMcpWatcher) { + this.projectMcpWatcher.dispose() + this.projectMcpWatcher = undefined + } + + if (!vscode.workspace.workspaceFolders?.length) { + return + } + + const workspaceFolder = vscode.workspace.workspaceFolders[0] + const projectMcpPattern = new vscode.RelativePattern(workspaceFolder, ".roo/mcp.json") + + // Create a file system watcher for the project MCP file pattern + this.projectMcpWatcher = vscode.workspace.createFileSystemWatcher(projectMcpPattern) + + // Watch for file changes + const changeDisposable = this.projectMcpWatcher.onDidChange((uri) => { + this.debounceConfigChange(uri.fsPath, "project") + }) + + // Watch for file creation + const createDisposable = this.projectMcpWatcher.onDidCreate((uri) => { + this.debounceConfigChange(uri.fsPath, "project") + }) + + // Watch for file deletion + const deleteDisposable = this.projectMcpWatcher.onDidDelete(async () => { + // Clean up all project MCP servers when the file is deleted + await this.cleanupProjectMcpServers() + await this.notifyWebviewOfServerChanges() + vscode.window.showInformationMessage(t("mcp:info.project_config_deleted")) + }) + this.disposables.push( - vscode.workspace.onDidSaveTextDocument(async (document) => { - const projectMcpPath = await this.getProjectMcpPath() - if (projectMcpPath && arePathsEqual(document.uri.fsPath, projectMcpPath)) { - await this.handleConfigFileChange(projectMcpPath, "project") - } - }), + vscode.Disposable.from(changeDisposable, createDisposable, deleteDisposable, this.projectMcpWatcher), ) } @@ -298,8 +392,8 @@ export class McpHub { try { config = JSON.parse(content) } catch (parseError) { - const errorMessage = t("common:errors.invalid_mcp_settings_syntax") - console.error(errorMessage, parseError) + const errorMessage = t("mcp:errors.invalid_settings_syntax") + this.log(`${errorMessage}: ${parseError instanceof Error ? parseError.message : String(parseError)}`) vscode.window.showErrorMessage(errorMessage) return } @@ -313,22 +407,24 @@ export class McpHub { const errorMessages = result.error.errors .map((err) => `${err.path.join(".")}: ${err.message}`) .join("\n") - console.error("Invalid project MCP settings format:", errorMessages) - vscode.window.showErrorMessage(t("common:errors.invalid_mcp_settings_validation", { errorMessages })) + this.log(`Invalid project MCP settings format: ${errorMessages}`) + vscode.window.showErrorMessage(t("mcp:errors.invalid_settings_validation", { errorMessages })) } } catch (error) { - this.showErrorMessage(t("common:errors.failed_update_project_mcp"), error) + this.showErrorMessage(t("mcp:errors.failed_update_project"), error) } } private async cleanupProjectMcpServers(): Promise { - const projectServers = this.connections.filter((conn) => conn.server.source === "project") + // Disconnect and remove all project MCP servers + const projectConnections = this.connections.filter((conn) => conn.server.source === "project") - for (const conn of projectServers) { + for (const conn of projectConnections) { await this.deleteConnection(conn.server.name, "project") } - await this.notifyWebviewOfServerChanges() + // Clear project servers from the connections list + await this.updateServerConnections({}, "project", false) } getServers(): McpServer[] { @@ -374,14 +470,43 @@ export class McpHub { } private async watchMcpSettingsFile(): Promise { + // Skip if test environment is detected or VSCode APIs are not available + if ( + process.env.NODE_ENV === "test" || + process.env.JEST_WORKER_ID !== undefined || + !vscode.workspace.createFileSystemWatcher + ) { + return + } + + // Clean up existing settings watcher if it exists + if (this.settingsWatcher) { + this.settingsWatcher.dispose() + this.settingsWatcher = undefined + } + const settingsPath = await this.getMcpSettingsFilePath() - this.disposables.push( - vscode.workspace.onDidSaveTextDocument(async (document) => { - if (arePathsEqual(document.uri.fsPath, settingsPath)) { - await this.handleConfigFileChange(settingsPath, "global") - } - }), - ) + const settingsUri = vscode.Uri.file(settingsPath) + const settingsPattern = new vscode.RelativePattern(path.dirname(settingsPath), path.basename(settingsPath)) + + // Create a file system watcher for the global MCP settings file + this.settingsWatcher = vscode.workspace.createFileSystemWatcher(settingsPattern) + + // Watch for file changes + const changeDisposable = this.settingsWatcher.onDidChange((uri) => { + if (arePathsEqual(uri.fsPath, settingsPath)) { + this.debounceConfigChange(settingsPath, "global") + } + }) + + // Watch for file creation + const createDisposable = this.settingsWatcher.onDidCreate((uri) => { + if (arePathsEqual(uri.fsPath, settingsPath)) { + this.debounceConfigChange(settingsPath, "global") + } + }) + + this.disposables.push(vscode.Disposable.from(changeDisposable, createDisposable, this.settingsWatcher)) } private async initializeMcpServers(source: "global" | "project"): Promise { @@ -398,18 +523,18 @@ export class McpHub { const result = McpSettingsSchema.safeParse(config) if (result.success) { - await this.updateServerConnections(result.data.mcpServers || {}, source) + await this.updateServerConnections(result.data.mcpServers || {}, source, false) } else { const errorMessages = result.error.errors .map((err) => `${err.path.join(".")}: ${err.message}`) .join("\n") - console.error(`Invalid ${source} MCP settings format:`, errorMessages) - vscode.window.showErrorMessage(t("common:errors.invalid_mcp_settings_validation", { errorMessages })) + this.log(`Invalid ${source} MCP settings format: ${errorMessages}`) + vscode.window.showErrorMessage(t("mcp:errors.invalid_settings_validation", { errorMessages })) if (source === "global") { // Still try to connect with the raw config, but show warnings try { - await this.updateServerConnections(config.mcpServers || {}, source) + await this.updateServerConnections(config.mcpServers || {}, source, false) } catch (error) { this.showErrorMessage(`Failed to initialize ${source} MCP servers with raw config`, error) } @@ -417,8 +542,8 @@ export class McpHub { } } catch (error) { if (error instanceof SyntaxError) { - const errorMessage = t("common:errors.invalid_mcp_settings_syntax") - console.error(errorMessage, error) + const errorMessage = t("mcp:errors.invalid_settings_syntax") + this.log(`${errorMessage}: ${error instanceof Error ? error.message : String(error)}`) vscode.window.showErrorMessage(errorMessage) } else { this.showErrorMessage(`Failed to initialize ${source} MCP servers`, error) @@ -475,139 +600,165 @@ export class McpHub { let transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport // Inject environment variables to the config - const configInjected = (await injectEnv(config)) as typeof config - - if (configInjected.type === "stdio") { - transport = new StdioClientTransport({ - command: configInjected.command, - args: configInjected.args, - cwd: configInjected.cwd, - env: { - ...getDefaultEnvironment(), - ...(configInjected.env || {}), - }, - stderr: "pipe", - }) + const configInjected = (await injectEnv(config)) as z.infer + + switch (configInjected.type) { + case "stdio": { + transport = new StdioClientTransport({ + command: configInjected.command, + args: configInjected.args, + cwd: configInjected.cwd, + env: { + ...getDefaultEnvironment(), + ...(configInjected.env || {}), + }, + stderr: "pipe", + }) - // Set up stdio specific error handling - transport.onerror = async (error) => { - console.error(`Transport error for "${name}":`, error) - const connection = this.findConnection(name, source) - if (connection) { - connection.server.status = "disconnected" - this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`) + // Set up stdio specific error handling + transport.onerror = async (error) => { + this.log( + `Transport error for "${name}": ${error instanceof Error ? error.message : String(error)}`, + ) + const connection = this.findConnection(name, source) + if (connection) { + connection.server.status = "disconnected" + this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`) + } + await this.notifyWebviewOfServerChanges() } - await this.notifyWebviewOfServerChanges() - } - transport.onclose = async () => { - const connection = this.findConnection(name, source) - if (connection) { - connection.server.status = "disconnected" + transport.onclose = async () => { + const connection = this.findConnection(name, source) + if (connection) { + connection.server.status = "disconnected" + } + await this.notifyWebviewOfServerChanges() } - await this.notifyWebviewOfServerChanges() - } - // transport.stderr is only available after the process has been started. However we can't start it separately from the .connect() call because it also starts the transport. And we can't place this after the connect call since we need to capture the stderr stream before the connection is established, in order to capture errors during the connection process. - // As a workaround, we start the transport ourselves, and then monkey-patch the start method to no-op so that .connect() doesn't try to start it again. - await transport.start() - const stderrStream = transport.stderr - if (stderrStream) { - stderrStream.on("data", async (data: Buffer) => { - const output = data.toString() - // Check if output contains INFO level log - const isInfoLog = /INFO/i.test(output) - - if (isInfoLog) { - // Log normal informational messages - console.log(`Server "${name}" info:`, output) - } else { - // Treat as error log - console.error(`Server "${name}" stderr:`, output) - const connection = this.findConnection(name, source) - if (connection) { - this.appendErrorMessage(connection, output) - if (connection.server.status === "disconnected") { - await this.notifyWebviewOfServerChanges() + // transport.stderr is only available after the process has been started. However we can't start it separately from the .connect() call because it also starts the transport. And we can't place this after the connect call since we need to capture the stderr stream before the connection is established, in order to capture errors during the connection process. + // As a workaround, we start the transport ourselves, and then monkey-patch the start method to no-op so that .connect() doesn't try to start it again. + await transport.start() + const stderrStream = transport.stderr + if (stderrStream) { + stderrStream.on("data", async (data: Buffer) => { + const output = data.toString() + // Check if output contains INFO level log + const isInfoLog = /INFO/i.test(output) + + if (isInfoLog) { + // Log normal informational messages + this.log(`Server "${name}" info: ${output}`) + } else { + // Treat as error log + this.log(`Server "${name}" stderr: ${output}`) + const connection = this.findConnection(name, source) + if (connection) { + this.appendErrorMessage(connection, output) + if (connection.server.status === "disconnected") { + await this.notifyWebviewOfServerChanges() + } } } - } - }) - } else { - console.error(`No stderr stream for ${name}`) + }) + } else { + this.log(`No stderr stream for ${name}`) + } + break } - } else if (configInjected.type === "streamable-http") { - // Streamable HTTP connection - transport = new StreamableHTTPClientTransport(new URL(configInjected.url), { - requestInit: { - headers: configInjected.headers, - }, - }) + case "streamable-http": + case "sse": { + // For both sse and streamable-http, we try streamable-http first and fallback to sse. + let transportCreated = false + let lastError: Error | null = null + let createdTransport: StreamableHTTPClientTransport | SSEClientTransport | null = null + + // First, try StreamableHTTP + try { + createdTransport = new StreamableHTTPClientTransport(new URL(configInjected.url), { + requestInit: { + headers: configInjected.headers, + }, + }) + this.log(`Attempting to connect to "${name}" using Streamable HTTP transport.`) + transportCreated = true + } catch (streamableError) { + lastError = + streamableError instanceof Error ? streamableError : new Error(String(streamableError)) + this.log(`Failed to create StreamableHTTP transport for "${name}": ${lastError.message}`) + } - // Set up Streamable HTTP specific error handling - transport.onerror = async (error) => { - console.error(`Transport error for "${name}" (streamable-http):`, error) - const connection = this.findConnection(name, source) - if (connection) { - connection.server.status = "disconnected" - this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`) + // If StreamableHTTP transport creation failed and this is an SSE type, try SSE + if (!transportCreated && configInjected.type === "sse") { + try { + this.log(`Falling back to SSE transport for "${name}"`) + const sseOptions = { + requestInit: { + headers: configInjected.headers, + }, + } + const reconnectingEventSourceOptions = { + max_retry_time: 5000, + withCredentials: configInjected.headers?.["Authorization"] ? true : false, + fetch: (url: string | URL, init: RequestInit) => { + const headers = new Headers({ + ...(init?.headers || {}), + ...(configInjected.headers || {}), + }) + return fetch(url, { + ...init, + headers, + }) + }, + } + global.EventSource = ReconnectingEventSource + createdTransport = new SSEClientTransport(new URL(configInjected.url), { + ...sseOptions, + eventSourceInit: reconnectingEventSourceOptions, + }) + transportCreated = true + } catch (sseError) { + lastError = sseError instanceof Error ? sseError : new Error(String(sseError)) + this.log(`Failed to create SSE transport for "${name}": ${lastError.message}`) + } } - await this.notifyWebviewOfServerChanges() - } - transport.onclose = async () => { - const connection = this.findConnection(name, source) - if (connection) { - connection.server.status = "disconnected" + // If transport creation failed entirely, throw the last error + if (!transportCreated || !createdTransport) { + throw lastError || new Error("Failed to create transport") } - await this.notifyWebviewOfServerChanges() - } - } else if (configInjected.type === "sse") { - // SSE connection - const sseOptions = { - requestInit: { - headers: configInjected.headers, - }, - } - // Configure ReconnectingEventSource options - const reconnectingEventSourceOptions = { - max_retry_time: 5000, // Maximum retry time in milliseconds - withCredentials: configInjected.headers?.["Authorization"] ? true : false, // Enable credentials if Authorization header exists - fetch: (url: string | URL, init: RequestInit) => { - const headers = new Headers({ ...(init?.headers || {}), ...(configInjected.headers || {}) }) - return fetch(url, { - ...init, - headers, - }) - }, - } - global.EventSource = ReconnectingEventSource - transport = new SSEClientTransport(new URL(configInjected.url), { - ...sseOptions, - eventSourceInit: reconnectingEventSourceOptions, - }) - // Set up SSE specific error handling - transport.onerror = async (error) => { - console.error(`Transport error for "${name}":`, error) - const connection = this.findConnection(name, source) - if (connection) { - connection.server.status = "disconnected" - this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`) + // Assign the successfully created transport + transport = createdTransport + + // Set up common error and close handling for both SSE and Streamable HTTP + transport.onerror = async (error) => { + this.log( + `Transport error for "${name}": ${error instanceof Error ? error.message : String(error)}`, + ) + const connection = this.findConnection(name, source) + if (connection) { + connection.server.status = "disconnected" + this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`) + } + await this.notifyWebviewOfServerChanges() } - await this.notifyWebviewOfServerChanges() - } - transport.onclose = async () => { - const connection = this.findConnection(name, source) - if (connection) { - connection.server.status = "disconnected" + transport.onclose = async () => { + const connection = this.findConnection(name, source) + if (connection) { + connection.server.status = "disconnected" + } + await this.notifyWebviewOfServerChanges() } - await this.notifyWebviewOfServerChanges() + break + } + default: { + // This should be unreachable if the config is validated correctly. + // The `never` type helps enforce this at compile time. + const exhaustiveCheck: never = configInjected + throw new Error(`Unsupported MCP server type: ${exhaustiveCheck}`) } - } else { - // Should not happen if validateServerConfig is correct - throw new Error(`Unsupported MCP server type: ${(configInjected as any).type}`) } // Only override transport.start for stdio transports that have already been started @@ -615,11 +766,12 @@ export class McpHub { transport.start = async () => {} } + // Create connection object with connecting status const connection: McpConnection = { server: { name, config: JSON.stringify(configInjected), - status: "connecting", + status: "connecting", // Set to connecting until connection is established disabled: configInjected.disabled, source, projectPath: source === "project" ? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath : undefined, @@ -630,16 +782,116 @@ export class McpHub { } this.connections.push(connection) - // Connect (this will automatically start the transport) - await client.connect(transport) - connection.server.status = "connected" - connection.server.error = "" - connection.server.instructions = client.getInstructions() + // Now actually establish the connection + try { + this.log(`Establishing connection to "${name}"...`) + await client.connect(transport) + this.log(`Successfully connected to "${name}"`) + + // Only mark as connected after successful connection + connection.server.status = "connected" + connection.server.error = "" + connection.server.instructions = client.getInstructions() + + // Initial fetch of tools and resources + connection.server.tools = await this.fetchToolsList(name, source) + connection.server.resources = await this.fetchResourcesList(name, source) + connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name, source) + } catch (connectionError) { + const connectionErrorMessage = + connectionError instanceof Error ? connectionError.message : String(connectionError) + this.log(`Connection failed for "${name}": ${connectionErrorMessage}`) + + // If this is an SSE-type server and connection failed, try fallback to SSE + if (configInjected.type === "sse" && transport instanceof StreamableHTTPClientTransport) { + this.log(`Attempting SSE fallback for "${name}" after connection failure`) + + try { + // Close the failed StreamableHTTP transport + await transport.close() + + // Create SSE transport + const sseOptions = { + requestInit: { + headers: configInjected.headers, + }, + } + const reconnectingEventSourceOptions = { + max_retry_time: 5000, + withCredentials: configInjected.headers?.["Authorization"] ? true : false, + fetch: (url: string | URL, init: RequestInit) => { + const headers = new Headers({ + ...(init?.headers || {}), + ...(configInjected.headers || {}), + }) + return fetch(url, { + ...init, + headers, + }) + }, + } + global.EventSource = ReconnectingEventSource + const sseTransport = new SSEClientTransport(new URL(configInjected.url), { + ...sseOptions, + eventSourceInit: reconnectingEventSourceOptions, + }) - // Initial fetch of tools and resources - connection.server.tools = await this.fetchToolsList(name, source) - connection.server.resources = await this.fetchResourcesList(name, source) - connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name, source) + // Set up error and close handlers for SSE transport + sseTransport.onerror = async (error) => { + this.log( + `SSE Transport error for "${name}": ${error instanceof Error ? error.message : String(error)}`, + ) + const conn = this.findConnection(name, source) + if (conn) { + conn.server.status = "disconnected" + this.appendErrorMessage(conn, error instanceof Error ? error.message : `${error}`) + } + await this.notifyWebviewOfServerChanges() + } + + sseTransport.onclose = async () => { + const conn = this.findConnection(name, source) + if (conn) { + conn.server.status = "disconnected" + } + await this.notifyWebviewOfServerChanges() + } + + // Update connection with new transport + connection.transport = sseTransport + + // Try to connect with SSE transport + await client.connect(sseTransport) + this.log(`Successfully connected to "${name}" using SSE fallback`) + + // Mark as connected after successful SSE connection + connection.server.status = "connected" + connection.server.error = "" + connection.server.instructions = client.getInstructions() + + // Initial fetch of tools and resources + connection.server.tools = await this.fetchToolsList(name, source) + connection.server.resources = await this.fetchResourcesList(name, source) + connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name, source) + } catch (sseError) { + const sseErrorMessage = sseError instanceof Error ? sseError.message : String(sseError) + this.log(`SSE fallback also failed for "${name}": ${sseErrorMessage}`) + + // Mark connection as failed + connection.server.status = "disconnected" + this.appendErrorMessage( + connection, + `Connection failed: ${connectionErrorMessage}. SSE fallback failed: ${sseErrorMessage}`, + ) + throw connectionError // Re-throw the original error + } + } else { + // Mark connection as failed + connection.server.status = "disconnected" + this.appendErrorMessage(connection, connectionErrorMessage) + throw connectionError + } + } } catch (error) { // Update status with error const connection = this.findConnection(name, source) @@ -738,7 +990,9 @@ export class McpHub { alwaysAllowConfig = config.mcpServers?.[serverName]?.alwaysAllow || [] } } catch (error) { - console.error(`Failed to read alwaysAllow config for ${serverName}:`, error) + this.log( + `Failed to read alwaysAllow config for ${serverName}: ${error instanceof Error ? error.message : String(error)}`, + ) // Continue with empty alwaysAllowConfig } @@ -750,7 +1004,9 @@ export class McpHub { return tools } catch (error) { - console.error(`Failed to fetch tools for ${serverName}:`, error) + this.log( + `Failed to fetch tools for ${serverName}: ${error instanceof Error ? error.message : String(error)}`, + ) return [] } } @@ -800,7 +1056,9 @@ export class McpHub { await connection.transport.close() await connection.client.close() } catch (error) { - console.error(`Failed to close transport for ${name}:`, error) + this.log( + `Failed to close transport for ${name}: ${error instanceof Error ? error.message : String(error)}`, + ) } } @@ -815,8 +1073,11 @@ export class McpHub { async updateServerConnections( newServers: Record, source: "global" | "project" = "global", + manageConnectingState: boolean = true, ): Promise { - this.isConnecting = true + if (manageConnectingState) { + this.isConnecting = true + } this.removeAllFileWatchers() // Filter connections by source const currentConnections = this.connections.filter( @@ -867,7 +1128,9 @@ export class McpHub { // If server exists with same config, do nothing } await this.notifyWebviewOfServerChanges() - this.isConnecting = false + if (manageConnectingState) { + this.isConnecting = false + } } private setupFileWatcher( @@ -897,7 +1160,9 @@ export class McpHub { // Pass the source from the config to restartConnection await this.restartConnection(name, source) } catch (error) { - console.error(`Failed to restart server ${name} after change in ${changedPath}:`, error) + this.log( + `Failed to restart server ${name} after change in ${changedPath}: ${error instanceof Error ? error.message : String(error)}`, + ) } }) @@ -919,7 +1184,9 @@ export class McpHub { // Pass the source from the config to restartConnection await this.restartConnection(name, source) } catch (error) { - console.error(`Failed to restart server ${name} after change in ${filePath}:`, error) + this.log( + `Failed to restart server ${name} after change in ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ) } }) @@ -949,7 +1216,7 @@ export class McpHub { const connection = this.findConnection(serverName, source) const config = connection?.server.config if (config) { - vscode.window.showInformationMessage(t("common:info.mcp_server_restarting", { serverName })) + vscode.window.showInformationMessage(t("mcp:info.server_restarting", { serverName })) connection.server.status = "connecting" connection.server.error = "" await this.notifyWebviewOfServerChanges() @@ -964,7 +1231,7 @@ export class McpHub { // Try to connect again using validated config await this.connectToServer(serverName, validatedConfig, connection.server.source || "global") - vscode.window.showInformationMessage(t("common:info.mcp_server_connected", { serverName })) + vscode.window.showInformationMessage(t("mcp:info.server_connected", { serverName })) } catch (validationError) { this.showErrorMessage(`Invalid configuration for MCP server "${serverName}"`, validationError) } @@ -977,6 +1244,73 @@ export class McpHub { this.isConnecting = false } + public async refreshAllConnections(): Promise { + if (this.isConnecting) { + vscode.window.showInformationMessage(t("mcp:info.already_refreshing")) + return + } + + this.isConnecting = true + vscode.window.showInformationMessage(t("mcp:info.refreshing_all")) + + try { + const globalPath = await this.getMcpSettingsFilePath() + let globalServers: Record = {} + try { + const globalContent = await fs.readFile(globalPath, "utf-8") + const globalConfig = JSON.parse(globalContent) + globalServers = globalConfig.mcpServers || {} + const globalServerNames = Object.keys(globalServers) + vscode.window.showInformationMessage( + t("mcp:info.global_servers_active", { + mcpServers: `${globalServerNames.join(", ") || "none"}`, + }), + ) + } catch (error) { + console.log("Error reading global MCP config:", error) + } + + const projectPath = await this.getProjectMcpPath() + let projectServers: Record = {} + if (projectPath) { + try { + const projectContent = await fs.readFile(projectPath, "utf-8") + const projectConfig = JSON.parse(projectContent) + projectServers = projectConfig.mcpServers || {} + const projectServerNames = Object.keys(projectServers) + vscode.window.showInformationMessage( + t("mcp:info.project_servers_active", { + mcpServers: `${projectServerNames.join(", ") || "none"}`, + }), + ) + } catch (error) { + console.log("Error reading project MCP config:", error) + } + } + + // Clear all existing connections first + const existingConnections = [...this.connections] + for (const conn of existingConnections) { + await this.deleteConnection(conn.server.name, conn.server.source) + } + + // Re-initialize all servers from scratch + // This ensures proper initialization including fetching tools, resources, etc. + await this.initializeMcpServers("global") + await this.initializeMcpServers("project") + + await delay(100) + + await this.notifyWebviewOfServerChanges() + + vscode.window.showInformationMessage(t("mcp:info.all_refreshed")) + } catch (error) { + this.showErrorMessage("Failed to refresh MCP servers", error) + } finally { + this.isConnecting = false + } + } + private async notifyWebviewOfServerChanges(): Promise { // Get global server order from settings file const settingsPath = await this.getMcpSettingsFilePath() @@ -1019,10 +1353,26 @@ export class McpHub { }) // Send sorted servers to webview - await this.providerRef.deref()?.postMessageToWebview({ - type: "mcpServers", - mcpServers: sortedConnections.map((connection) => connection.server), - }) + const targetProvider: ClineProvider | undefined = this.providerRef.deref() + + if (targetProvider) { + const serversToSend = sortedConnections.map((connection) => connection.server) + + const message = { + type: "mcpServers" as const, + mcpServers: serversToSend, + } + + try { + await targetProvider.postMessageToWebview(message) + } catch (error) { + console.error("[McpHub] Error calling targetProvider.postMessageToWebview:", error) + } + } else { + console.error( + "[McpHub] No target provider available (neither from getInstance nor providerRef) - cannot send mcpServers message to webview", + ) + } } public async toggleServerDisabled( @@ -1056,7 +1406,9 @@ export class McpHub { ) } } catch (error) { - console.error(`Failed to refresh capabilities for ${serverName}:`, error) + this.log( + `Failed to refresh capabilities for ${serverName}: ${error instanceof Error ? error.message : String(error)}`, + ) } } @@ -1094,7 +1446,7 @@ export class McpHub { try { await fs.access(configPath) } catch (error) { - console.error("Settings file not accessible:", error) + this.log(`Settings file not accessible: ${error instanceof Error ? error.message : String(error)}`) throw new Error("Settings file not accessible") } @@ -1216,9 +1568,9 @@ export class McpHub { // Update server connections with the correct source await this.updateServerConnections(config.mcpServers, serverSource) - vscode.window.showInformationMessage(t("common:info.mcp_server_deleted", { serverName })) + vscode.window.showInformationMessage(t("mcp:info.server_deleted", { serverName })) } else { - vscode.window.showWarningMessage(t("common:info.mcp_server_not_found", { serverName })) + vscode.window.showWarningMessage(t("mcp:info.server_not_found", { serverName })) } } catch (error) { this.showErrorMessage(`Failed to delete MCP server ${serverName}`, error) @@ -1266,7 +1618,9 @@ export class McpHub { const parsedConfig = ServerConfigSchema.parse(JSON.parse(connection.server.config)) timeout = (parsedConfig.timeout ?? 60) * 1000 } catch (error) { - console.error("Failed to parse server config for timeout:", error) + this.log( + `Failed to parse server config for timeout: ${error instanceof Error ? error.message : String(error)}`, + ) // Default to 60 seconds if parsing fails timeout = 60 * 1000 } @@ -1370,22 +1724,36 @@ export class McpHub { async dispose(): Promise { // Prevent multiple disposals if (this.isDisposed) { - console.log("McpHub: Already disposed.") + this.log("McpHub: Already disposed.") return } - console.log("McpHub: Disposing...") + this.log("McpHub: Disposing...") this.isDisposed = true + + // Clear all debounce timers + for (const timer of this.configChangeDebounceTimers.values()) { + clearTimeout(timer) + } + this.configChangeDebounceTimers.clear() + this.removeAllFileWatchers() for (const connection of this.connections) { try { await this.deleteConnection(connection.server.name, connection.server.source) } catch (error) { - console.error(`Failed to close connection for ${connection.server.name}:`, error) + this.log( + `Failed to close connection for ${connection.server.name}: ${error instanceof Error ? error.message : String(error)}`, + ) } } this.connections = [] if (this.settingsWatcher) { this.settingsWatcher.dispose() + this.settingsWatcher = undefined + } + if (this.projectMcpWatcher) { + this.projectMcpWatcher.dispose() + this.projectMcpWatcher = undefined } this.disposables.forEach((d) => d.dispose()) }