Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 101 additions & 6 deletions src/services/mcp/McpHub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import { fileExistsAtPath } from "../../utils/fs"
import { arePathsEqual, getWorkspacePath } from "../../utils/path"
import { injectVariables } from "../../utils/config"
import { safeWriteJson } from "../../utils/safeWriteJson"

// Discriminated union for connection states
export type ConnectedMcpConnection = {
Expand Down Expand Up @@ -151,6 +152,8 @@ export class McpHub {
isConnecting: boolean = false
private refCount: number = 0 // Reference counter for active clients
private configChangeDebounceTimers: Map<string, NodeJS.Timeout> = new Map()
private isProgrammaticUpdate: boolean = false
private flagResetTimer?: NodeJS.Timeout

constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
Expand Down Expand Up @@ -278,6 +281,11 @@ export class McpHub {
* Debounced wrapper for handling config file changes
*/
private debounceConfigChange(filePath: string, source: "global" | "project"): void {
// Skip processing if this is a programmatic update to prevent unnecessary server restarts
if (this.isProgrammaticUpdate) {
return
}

const key = `${source}-${filePath}`

// Clear existing timer if any
Expand Down Expand Up @@ -1369,13 +1377,16 @@ export class McpHub {
this.removeFileWatchersForServer(serverName)
await this.deleteConnection(serverName, serverSource)
// Re-add as a disabled connection
await this.connectToServer(serverName, JSON.parse(connection.server.config), serverSource)
// Re-read config from file to get updated disabled state
const updatedConfig = await this.readServerConfigFromFile(serverName, serverSource)
await this.connectToServer(serverName, updatedConfig, serverSource)
} else if (!disabled && connection.server.status === "disconnected") {
// If enabling a disabled server, connect it
const config = JSON.parse(connection.server.config)
// Re-read config from file to get updated disabled state
const updatedConfig = await this.readServerConfigFromFile(serverName, serverSource)
await this.deleteConnection(serverName, serverSource)
// When re-enabling, file watchers will be set up in connectToServer
await this.connectToServer(serverName, config, serverSource)
await this.connectToServer(serverName, updatedConfig, serverSource)
} else if (connection.server.status === "connected") {
// Only refresh capabilities if connected
connection.server.tools = await this.fetchToolsList(serverName, serverSource)
Expand All @@ -1397,6 +1408,57 @@ export class McpHub {
}
}

/**
* Helper method to read a server's configuration from the appropriate settings file
* @param serverName The name of the server to read
* @param source Whether to read from the global or project config
* @returns The validated server configuration
*/
private async readServerConfigFromFile(
serverName: string,
source: "global" | "project" = "global",
): Promise<z.infer<typeof ServerConfigSchema>> {
// Determine which config file to read
let configPath: string
if (source === "project") {
const projectMcpPath = await this.getProjectMcpPath()
if (!projectMcpPath) {
throw new Error("Project MCP configuration file not found")
}
configPath = projectMcpPath
} else {
configPath = await this.getMcpSettingsFilePath()
}

// Ensure the settings file exists and is accessible
try {
await fs.access(configPath)
} catch (error) {
console.error("Settings file not accessible:", error)
throw new Error("Settings file not accessible")
}

// Read and parse the config file
const content = await fs.readFile(configPath, "utf-8")
const config = JSON.parse(content)

// Validate the config structure
if (!config || typeof config !== "object") {
throw new Error("Invalid config structure")
}

if (!config.mcpServers || typeof config.mcpServers !== "object") {
throw new Error("No mcpServers section in config")
}

if (!config.mcpServers[serverName]) {
throw new Error(`Server ${serverName} not found in config`)
}

// Validate and return the server config
return this.validateServerConfig(config.mcpServers[serverName], serverName)
}

/**
* Helper method to update a server's configuration in the appropriate settings file
* @param serverName The name of the server to update
Expand Down Expand Up @@ -1463,7 +1525,20 @@ export class McpHub {
mcpServers: config.mcpServers,
}

await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2))
// Set flag to prevent file watcher from triggering server restart
if (this.flagResetTimer) {
clearTimeout(this.flagResetTimer)
}
this.isProgrammaticUpdate = true
try {
await safeWriteJson(configPath, updatedConfig)
} finally {
// Reset flag after watcher debounce period (non-blocking)
this.flagResetTimer = setTimeout(() => {
this.isProgrammaticUpdate = false
this.flagResetTimer = undefined
}, 600)
}
}

public async updateServerTimeout(
Expand Down Expand Up @@ -1541,7 +1616,7 @@ export class McpHub {
mcpServers: config.mcpServers,
}

await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2))
await safeWriteJson(configPath, updatedConfig)

// Update server connections with the correct source
await this.updateServerConnections(config.mcpServers, serverSource)
Expand Down Expand Up @@ -1686,7 +1761,20 @@ export class McpHub {
targetList.splice(toolIndex, 1)
}

await fs.writeFile(normalizedPath, JSON.stringify(config, null, 2))
// Set flag to prevent file watcher from triggering server restart
if (this.flagResetTimer) {
clearTimeout(this.flagResetTimer)
}
this.isProgrammaticUpdate = true
try {
await safeWriteJson(normalizedPath, config)
} finally {
// Reset flag after watcher debounce period (non-blocking)
this.flagResetTimer = setTimeout(() => {
this.isProgrammaticUpdate = false
this.flagResetTimer = undefined
}, 600)
}

if (connection) {
connection.server.tools = await this.fetchToolsList(serverName, source)
Expand Down Expand Up @@ -1796,6 +1884,13 @@ export class McpHub {
}
this.configChangeDebounceTimers.clear()

// Clear flag reset timer and reset programmatic update flag
if (this.flagResetTimer) {
clearTimeout(this.flagResetTimer)
this.flagResetTimer = undefined
}
this.isProgrammaticUpdate = false

this.removeAllFileWatchers()
for (const connection of this.connections) {
try {
Expand Down