Skip to content

Commit e835658

Browse files
committed
fix: address critical issues in MCP server restart handling
- Add pendingRestarts processing after tool execution completes - Add cleanup for activeToolExecutions with timeout mechanism to prevent memory leaks - Refactor updateServerToolList to reduce code duplication using updateServerToolListInternal - Fix race condition in watcher re-enable logic by checking disposal state - Add comprehensive test coverage for all fixes
1 parent 6155283 commit e835658

File tree

2 files changed

+381
-113
lines changed

2 files changed

+381
-113
lines changed

src/services/mcp/McpHub.ts

Lines changed: 143 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ export class McpHub {
152152
private refCount: number = 0 // Reference counter for active clients
153153
private configChangeDebounceTimers: Map<string, NodeJS.Timeout> = new Map()
154154
private activeToolExecutions: Map<string, Set<string>> = new Map() // Track active tool executions per server
155+
private pendingRestarts: Set<string> = new Set() // Track servers that need restart after tool execution
156+
private toolExecutionTimeouts: Map<string, NodeJS.Timeout> = new Map() // Track timeouts for tool execution cleanup
155157

156158
constructor(provider: ClineProvider) {
157159
this.providerRef = new WeakRef(provider)
@@ -1172,10 +1174,13 @@ export class McpHub {
11721174
async restartConnection(serverName: string, source?: "global" | "project"): Promise<void> {
11731175
// Check if there are active tool executions for this server
11741176
if (this.hasActiveToolExecutions(serverName, source)) {
1175-
console.log(`Skipping restart for ${serverName} - tools are currently executing`)
1176-
vscode.window.showWarningMessage(
1177-
t("mcp:errors.cannot_restart_tools_running", { serverName }) ||
1178-
`Cannot restart server "${serverName}" while tools are running. Please wait for tool execution to complete.`,
1177+
console.log(`Deferring restart for ${serverName} - tools are currently executing`)
1178+
// Add to pending restarts
1179+
const serverKey = `${serverName}:${source || "global"}`
1180+
this.pendingRestarts.add(serverKey)
1181+
vscode.window.showInformationMessage(
1182+
t("mcp:info.restart_deferred", { serverName }) ||
1183+
`Server "${serverName}" will be restarted after tool execution completes.`,
11791184
)
11801185
return
11811186
}
@@ -1636,6 +1641,15 @@ export class McpHub {
16361641
const executionId = `${toolName}:${Date.now()}`
16371642
this.activeToolExecutions.get(serverKey)!.add(executionId)
16381643

1644+
// Set up a timeout to clean up the execution tracking in case of hangs
1645+
const cleanupTimeout = setTimeout(() => {
1646+
this.cleanupToolExecution(serverKey, executionId)
1647+
}, timeout + 5000) // Add 5 seconds buffer after the tool timeout
1648+
1649+
// Store the timeout so we can clear it if the tool completes normally
1650+
const timeoutKey = `${serverKey}:${executionId}`
1651+
this.toolExecutionTimeouts.set(timeoutKey, cleanupTimeout)
1652+
16391653
try {
16401654
const result = await connection.client.request(
16411655
{
@@ -1651,23 +1665,60 @@ export class McpHub {
16511665
},
16521666
)
16531667

1668+
// Clear the cleanup timeout since the tool completed successfully
1669+
this.clearToolExecutionTimeout(timeoutKey)
1670+
16541671
// Remove from active executions on success
1655-
this.activeToolExecutions.get(serverKey)?.delete(executionId)
1656-
if (this.activeToolExecutions.get(serverKey)?.size === 0) {
1657-
this.activeToolExecutions.delete(serverKey)
1658-
}
1672+
this.cleanupToolExecution(serverKey, executionId)
16591673

16601674
return result
16611675
} catch (error) {
1676+
// Clear the cleanup timeout
1677+
this.clearToolExecutionTimeout(timeoutKey)
1678+
16621679
// Remove from active executions on error
1663-
this.activeToolExecutions.get(serverKey)?.delete(executionId)
1664-
if (this.activeToolExecutions.get(serverKey)?.size === 0) {
1665-
this.activeToolExecutions.delete(serverKey)
1666-
}
1680+
this.cleanupToolExecution(serverKey, executionId)
1681+
16671682
throw error
16681683
}
16691684
}
16701685

1686+
/**
1687+
* Clean up tool execution tracking and process pending restarts if needed
1688+
* @param serverKey The server key
1689+
* @param executionId The execution ID to clean up
1690+
*/
1691+
private cleanupToolExecution(serverKey: string, executionId: string): void {
1692+
// Remove from active executions
1693+
this.activeToolExecutions.get(serverKey)?.delete(executionId)
1694+
if (this.activeToolExecutions.get(serverKey)?.size === 0) {
1695+
this.activeToolExecutions.delete(serverKey)
1696+
1697+
// Check if this server has pending restarts
1698+
if (this.pendingRestarts.has(serverKey)) {
1699+
this.pendingRestarts.delete(serverKey)
1700+
// Extract server name and source from the key
1701+
const [serverName, source] = serverKey.split(":")
1702+
// Process the pending restart
1703+
this.restartConnection(serverName, source as "global" | "project").catch((error) => {
1704+
console.error(`Failed to process pending restart for ${serverName}:`, error)
1705+
})
1706+
}
1707+
}
1708+
}
1709+
1710+
/**
1711+
* Clear a tool execution timeout
1712+
* @param timeoutKey The timeout key to clear
1713+
*/
1714+
private clearToolExecutionTimeout(timeoutKey: string): void {
1715+
const timeout = this.toolExecutionTimeouts.get(timeoutKey)
1716+
if (timeout) {
1717+
clearTimeout(timeout)
1718+
this.toolExecutionTimeouts.delete(timeoutKey)
1719+
}
1720+
}
1721+
16711722
/**
16721723
* Check if any tools are currently executing for a specific server
16731724
* @param serverName The name of the server to check
@@ -1708,6 +1759,27 @@ export class McpHub {
17081759
toolName: string,
17091760
listName: "alwaysAllow" | "disabledTools",
17101761
addTool: boolean,
1762+
): Promise<void> {
1763+
// Use the common implementation with restart enabled
1764+
await this.updateServerToolListInternal(serverName, source, toolName, listName, addTool, true)
1765+
}
1766+
1767+
/**
1768+
* Internal implementation for updating server tool lists
1769+
* @param serverName The name of the server to update
1770+
* @param source Whether to update the global or project config
1771+
* @param toolName The name of the tool to add or remove
1772+
* @param listName The name of the list to modify ("alwaysAllow" or "disabledTools")
1773+
* @param addTool Whether to add (true) or remove (false) the tool from the list
1774+
* @param allowRestart Whether to allow file watchers to trigger restart
1775+
*/
1776+
private async updateServerToolListInternal(
1777+
serverName: string,
1778+
source: "global" | "project",
1779+
toolName: string,
1780+
listName: "alwaysAllow" | "disabledTools",
1781+
addTool: boolean,
1782+
allowRestart: boolean,
17111783
): Promise<void> {
17121784
// Find the connection with matching name and source
17131785
const connection = this.findConnection(serverName, source)
@@ -1731,7 +1803,6 @@ export class McpHub {
17311803
}
17321804

17331805
// Normalize path for cross-platform compatibility
1734-
// Use a consistent path format for both reading and writing
17351806
const normalizedPath = process.platform === "win32" ? configPath.replace(/\\/g, "/") : configPath
17361807

17371808
// Read the appropriate config file
@@ -1763,12 +1834,57 @@ export class McpHub {
17631834
targetList.splice(toolIndex, 1)
17641835
}
17651836

1837+
// Handle file watcher based on allowRestart flag
1838+
let watcherToRestore: vscode.FileSystemWatcher | undefined
1839+
if (!allowRestart) {
1840+
// Temporarily disable the watcher to prevent restart
1841+
if (source === "project" && this.projectMcpWatcher) {
1842+
watcherToRestore = this.projectMcpWatcher
1843+
this.projectMcpWatcher.dispose()
1844+
this.projectMcpWatcher = undefined
1845+
} else if (source === "global" && this.settingsWatcher) {
1846+
watcherToRestore = this.settingsWatcher
1847+
this.settingsWatcher.dispose()
1848+
this.settingsWatcher = undefined
1849+
}
1850+
}
1851+
17661852
await fs.writeFile(normalizedPath, JSON.stringify(config, null, 2))
17671853

17681854
if (connection) {
1769-
connection.server.tools = await this.fetchToolsList(serverName, source)
1855+
if (allowRestart) {
1856+
// Normal flow - fetch fresh tool list
1857+
connection.server.tools = await this.fetchToolsList(serverName, source)
1858+
} else {
1859+
// Update the in-memory tool state without restarting
1860+
const tools = connection.server.tools
1861+
if (tools) {
1862+
const tool = tools.find((t) => t.name === toolName)
1863+
if (tool) {
1864+
if (listName === "alwaysAllow") {
1865+
tool.alwaysAllow = addTool
1866+
} else if (listName === "disabledTools") {
1867+
tool.enabledForPrompt = !addTool
1868+
}
1869+
}
1870+
}
1871+
}
17701872
await this.notifyWebviewOfServerChanges()
17711873
}
1874+
1875+
// Re-enable the watcher after a short delay if it was disabled
1876+
if (!allowRestart && watcherToRestore) {
1877+
setTimeout(async () => {
1878+
// Check if not disposed during the timeout
1879+
if (!this.isDisposed) {
1880+
if (source === "project" && !this.projectMcpWatcher) {
1881+
await this.watchProjectMcpFile()
1882+
} else if (source === "global" && !this.settingsWatcher) {
1883+
await this.watchMcpSettingsFile()
1884+
}
1885+
}
1886+
}, 1000)
1887+
}
17721888
}
17731889

17741890
async toggleToolAlwaysAllow(
@@ -1780,7 +1896,7 @@ export class McpHub {
17801896
try {
17811897
// Check if there are active tool executions for this server
17821898
if (this.hasActiveToolExecutions(serverName, source)) {
1783-
console.log(`Skipping server restart for ${serverName} - tools are currently executing`)
1899+
console.log(`Deferring config update for ${serverName} - tools are currently executing`)
17841900
// Update the config file without triggering a restart
17851901
await this.updateServerToolListWithoutRestart(serverName, source, toolName, "alwaysAllow", shouldAllow)
17861902
} else {
@@ -1807,101 +1923,8 @@ export class McpHub {
18071923
listName: "alwaysAllow" | "disabledTools",
18081924
addTool: boolean,
18091925
): Promise<void> {
1810-
// Find the connection with matching name and source
1811-
const connection = this.findConnection(serverName, source)
1812-
1813-
if (!connection) {
1814-
throw new Error(`Server ${serverName} with source ${source} not found`)
1815-
}
1816-
1817-
// Determine the correct config path based on the source
1818-
let configPath: string
1819-
if (source === "project") {
1820-
// Get project MCP config path
1821-
const projectMcpPath = await this.getProjectMcpPath()
1822-
if (!projectMcpPath) {
1823-
throw new Error("Project MCP configuration file not found")
1824-
}
1825-
configPath = projectMcpPath
1826-
} else {
1827-
// Get global MCP settings path
1828-
configPath = await this.getMcpSettingsFilePath()
1829-
}
1830-
1831-
// Normalize path for cross-platform compatibility
1832-
const normalizedPath = process.platform === "win32" ? configPath.replace(/\\/g, "/") : configPath
1833-
1834-
// Read the appropriate config file
1835-
const content = await fs.readFile(normalizedPath, "utf-8")
1836-
const config = JSON.parse(content)
1837-
1838-
if (!config.mcpServers) {
1839-
config.mcpServers = {}
1840-
}
1841-
1842-
if (!config.mcpServers[serverName]) {
1843-
config.mcpServers[serverName] = {
1844-
type: "stdio",
1845-
command: "node",
1846-
args: [], // Default to an empty array; can be set later if needed
1847-
}
1848-
}
1849-
1850-
if (!config.mcpServers[serverName][listName]) {
1851-
config.mcpServers[serverName][listName] = []
1852-
}
1853-
1854-
const targetList = config.mcpServers[serverName][listName]
1855-
const toolIndex = targetList.indexOf(toolName)
1856-
1857-
if (addTool && toolIndex === -1) {
1858-
targetList.push(toolName)
1859-
} else if (!addTool && toolIndex !== -1) {
1860-
targetList.splice(toolIndex, 1)
1861-
}
1862-
1863-
// Write the config file directly without triggering file watcher
1864-
// We'll temporarily disable the watcher to prevent restart
1865-
const watcherKey = source === "project" ? this.projectMcpWatcher : this.settingsWatcher
1866-
const wasWatcherActive = !!watcherKey
1867-
1868-
if (wasWatcherActive && source === "project" && this.projectMcpWatcher) {
1869-
this.projectMcpWatcher.dispose()
1870-
this.projectMcpWatcher = undefined
1871-
} else if (wasWatcherActive && source === "global" && this.settingsWatcher) {
1872-
this.settingsWatcher.dispose()
1873-
this.settingsWatcher = undefined
1874-
}
1875-
1876-
await fs.writeFile(normalizedPath, JSON.stringify(config, null, 2))
1877-
1878-
// Update the in-memory tool state without restarting
1879-
if (connection) {
1880-
// Update the tool's alwaysAllow or enabledForPrompt status in memory
1881-
const tools = connection.server.tools
1882-
if (tools) {
1883-
const tool = tools.find((t) => t.name === toolName)
1884-
if (tool) {
1885-
if (listName === "alwaysAllow") {
1886-
tool.alwaysAllow = addTool
1887-
} else if (listName === "disabledTools") {
1888-
tool.enabledForPrompt = !addTool
1889-
}
1890-
}
1891-
}
1892-
await this.notifyWebviewOfServerChanges()
1893-
}
1894-
1895-
// Re-enable the watcher after a short delay
1896-
if (wasWatcherActive) {
1897-
setTimeout(async () => {
1898-
if (source === "project") {
1899-
await this.watchProjectMcpFile()
1900-
} else {
1901-
await this.watchMcpSettingsFile()
1902-
}
1903-
}, 1000)
1904-
}
1926+
// Use the common implementation
1927+
await this.updateServerToolListInternal(serverName, source, toolName, listName, addTool, false)
19051928
}
19061929

19071930
async toggleToolEnabledForPrompt(
@@ -1917,7 +1940,7 @@ export class McpHub {
19171940

19181941
// Check if there are active tool executions for this server
19191942
if (this.hasActiveToolExecutions(serverName, source)) {
1920-
console.log(`Skipping server restart for ${serverName} - tools are currently executing`)
1943+
console.log(`Deferring config update for ${serverName} - tools are currently executing`)
19211944
// Update the config file without triggering a restart
19221945
await this.updateServerToolListWithoutRestart(
19231946
serverName,
@@ -2004,6 +2027,15 @@ export class McpHub {
20042027
}
20052028
this.configChangeDebounceTimers.clear()
20062029

2030+
// Clear all tool execution timeouts
2031+
for (const timeout of this.toolExecutionTimeouts.values()) {
2032+
clearTimeout(timeout)
2033+
}
2034+
this.toolExecutionTimeouts.clear()
2035+
2036+
// Clear pending restarts
2037+
this.pendingRestarts.clear()
2038+
20072039
this.removeAllFileWatchers()
20082040
for (const connection of this.connections) {
20092041
try {

0 commit comments

Comments
 (0)