Skip to content

Commit f839d4c

Browse files
authored
fix: prevent MCP server restart when toggling tool permissions (#8633)
* fix: prevent MCP server restart when toggling tool permissions Add isProgrammaticUpdate flag to distinguish between programmatic config updates and user-initiated file changes. Skip file watcher processing during programmatic updates to prevent unnecessary server restarts. * fix(mcp): prevent server reconnection when toggling disabled state Fixed bug where MCP servers would reconnect instead of staying disabled when toggled off. The issue was that toggleServerDisabled() used stale in-memory config instead of reading the fresh config from disk after writing the disabled flag. Changes: Added readServerConfigFromFile() helper to read and validate server config from disk Updated disable path to read fresh config before calling connectToServer() Updated enable path to read fresh config before calling connectToServer() This ensures the disabled: true flag is properly read, causing connectToServer() to create a disabled placeholder connection instead of actually connecting the server. + refactor(mcp): use safeWriteJson for atomic config writes Replace JSON.stringify + fs.writeFile with safeWriteJson in McpHub.ts to prevent data corruption through atomic writes with file locking. * fix(mcp): prevent race condition in isProgrammaticUpdate flag Replace multiple independent reset timers with a single timer that gets cleared and rescheduled on each programmatic config update. This prevents the flag from being reset prematurely when multiple rapid updates occur, which could cause unwanted server restarts during the file watcher's debounce period. + fix(mcp): ensure isProgrammaticUpdate flag cleanup with try-finally Wrap safeWriteJson() calls in try-finally blocks to guarantee the isProgrammaticUpdate flag is always reset, even if the write operation fails. This prevents the flag from being stuck at true indefinitely, which would cause subsequent user-initiated config changes to be silently ignored.
1 parent 82b0b04 commit f839d4c

File tree

1 file changed

+101
-6
lines changed

1 file changed

+101
-6
lines changed

src/services/mcp/McpHub.ts

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
import { fileExistsAtPath } from "../../utils/fs"
3333
import { arePathsEqual, getWorkspacePath } from "../../utils/path"
3434
import { injectVariables } from "../../utils/config"
35+
import { safeWriteJson } from "../../utils/safeWriteJson"
3536

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

155158
constructor(provider: ClineProvider) {
156159
this.providerRef = new WeakRef(provider)
@@ -278,6 +281,11 @@ export class McpHub {
278281
* Debounced wrapper for handling config file changes
279282
*/
280283
private debounceConfigChange(filePath: string, source: "global" | "project"): void {
284+
// Skip processing if this is a programmatic update to prevent unnecessary server restarts
285+
if (this.isProgrammaticUpdate) {
286+
return
287+
}
288+
281289
const key = `${source}-${filePath}`
282290

283291
// Clear existing timer if any
@@ -1369,13 +1377,16 @@ export class McpHub {
13691377
this.removeFileWatchersForServer(serverName)
13701378
await this.deleteConnection(serverName, serverSource)
13711379
// Re-add as a disabled connection
1372-
await this.connectToServer(serverName, JSON.parse(connection.server.config), serverSource)
1380+
// Re-read config from file to get updated disabled state
1381+
const updatedConfig = await this.readServerConfigFromFile(serverName, serverSource)
1382+
await this.connectToServer(serverName, updatedConfig, serverSource)
13731383
} else if (!disabled && connection.server.status === "disconnected") {
13741384
// If enabling a disabled server, connect it
1375-
const config = JSON.parse(connection.server.config)
1385+
// Re-read config from file to get updated disabled state
1386+
const updatedConfig = await this.readServerConfigFromFile(serverName, serverSource)
13761387
await this.deleteConnection(serverName, serverSource)
13771388
// When re-enabling, file watchers will be set up in connectToServer
1378-
await this.connectToServer(serverName, config, serverSource)
1389+
await this.connectToServer(serverName, updatedConfig, serverSource)
13791390
} else if (connection.server.status === "connected") {
13801391
// Only refresh capabilities if connected
13811392
connection.server.tools = await this.fetchToolsList(serverName, serverSource)
@@ -1397,6 +1408,57 @@ export class McpHub {
13971408
}
13981409
}
13991410

1411+
/**
1412+
* Helper method to read a server's configuration from the appropriate settings file
1413+
* @param serverName The name of the server to read
1414+
* @param source Whether to read from the global or project config
1415+
* @returns The validated server configuration
1416+
*/
1417+
private async readServerConfigFromFile(
1418+
serverName: string,
1419+
source: "global" | "project" = "global",
1420+
): Promise<z.infer<typeof ServerConfigSchema>> {
1421+
// Determine which config file to read
1422+
let configPath: string
1423+
if (source === "project") {
1424+
const projectMcpPath = await this.getProjectMcpPath()
1425+
if (!projectMcpPath) {
1426+
throw new Error("Project MCP configuration file not found")
1427+
}
1428+
configPath = projectMcpPath
1429+
} else {
1430+
configPath = await this.getMcpSettingsFilePath()
1431+
}
1432+
1433+
// Ensure the settings file exists and is accessible
1434+
try {
1435+
await fs.access(configPath)
1436+
} catch (error) {
1437+
console.error("Settings file not accessible:", error)
1438+
throw new Error("Settings file not accessible")
1439+
}
1440+
1441+
// Read and parse the config file
1442+
const content = await fs.readFile(configPath, "utf-8")
1443+
const config = JSON.parse(content)
1444+
1445+
// Validate the config structure
1446+
if (!config || typeof config !== "object") {
1447+
throw new Error("Invalid config structure")
1448+
}
1449+
1450+
if (!config.mcpServers || typeof config.mcpServers !== "object") {
1451+
throw new Error("No mcpServers section in config")
1452+
}
1453+
1454+
if (!config.mcpServers[serverName]) {
1455+
throw new Error(`Server ${serverName} not found in config`)
1456+
}
1457+
1458+
// Validate and return the server config
1459+
return this.validateServerConfig(config.mcpServers[serverName], serverName)
1460+
}
1461+
14001462
/**
14011463
* Helper method to update a server's configuration in the appropriate settings file
14021464
* @param serverName The name of the server to update
@@ -1463,7 +1525,20 @@ export class McpHub {
14631525
mcpServers: config.mcpServers,
14641526
}
14651527

1466-
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2))
1528+
// Set flag to prevent file watcher from triggering server restart
1529+
if (this.flagResetTimer) {
1530+
clearTimeout(this.flagResetTimer)
1531+
}
1532+
this.isProgrammaticUpdate = true
1533+
try {
1534+
await safeWriteJson(configPath, updatedConfig)
1535+
} finally {
1536+
// Reset flag after watcher debounce period (non-blocking)
1537+
this.flagResetTimer = setTimeout(() => {
1538+
this.isProgrammaticUpdate = false
1539+
this.flagResetTimer = undefined
1540+
}, 600)
1541+
}
14671542
}
14681543

14691544
public async updateServerTimeout(
@@ -1541,7 +1616,7 @@ export class McpHub {
15411616
mcpServers: config.mcpServers,
15421617
}
15431618

1544-
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2))
1619+
await safeWriteJson(configPath, updatedConfig)
15451620

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

1689-
await fs.writeFile(normalizedPath, JSON.stringify(config, null, 2))
1764+
// Set flag to prevent file watcher from triggering server restart
1765+
if (this.flagResetTimer) {
1766+
clearTimeout(this.flagResetTimer)
1767+
}
1768+
this.isProgrammaticUpdate = true
1769+
try {
1770+
await safeWriteJson(normalizedPath, config)
1771+
} finally {
1772+
// Reset flag after watcher debounce period (non-blocking)
1773+
this.flagResetTimer = setTimeout(() => {
1774+
this.isProgrammaticUpdate = false
1775+
this.flagResetTimer = undefined
1776+
}, 600)
1777+
}
16901778

16911779
if (connection) {
16921780
connection.server.tools = await this.fetchToolsList(serverName, source)
@@ -1796,6 +1884,13 @@ export class McpHub {
17961884
}
17971885
this.configChangeDebounceTimers.clear()
17981886

1887+
// Clear flag reset timer and reset programmatic update flag
1888+
if (this.flagResetTimer) {
1889+
clearTimeout(this.flagResetTimer)
1890+
this.flagResetTimer = undefined
1891+
}
1892+
this.isProgrammaticUpdate = false
1893+
17991894
this.removeAllFileWatchers()
18001895
for (const connection of this.connections) {
18011896
try {

0 commit comments

Comments
 (0)