Skip to content

Commit 15216b2

Browse files
committed
fix: address PR review feedback for disabled MCP servers
- Centralize disabled state validation in connectToServer() method - Add protection against race conditions in toggleServerDisabled() - Remove redundant grey color indicator (keep only opacity) - Remove unnecessary status checks in toggle logic - Add comprehensive test coverage for edge cases - Fix failing test by ensuring correct disabled state in config
1 parent cd0bc93 commit 15216b2

File tree

3 files changed

+181
-20
lines changed

3 files changed

+181
-20
lines changed

src/services/mcp/McpHub.ts

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export class McpHub {
134134
isConnecting: boolean = false
135135
private refCount: number = 0 // Reference counter for active clients
136136
private configChangeDebounceTimers: Map<string, NodeJS.Timeout> = new Map()
137+
private toggleOperations: Map<string, Promise<void>> = new Map() // Track ongoing toggle operations
137138

138139
constructor(provider: ClineProvider) {
139140
this.providerRef = new WeakRef(provider)
@@ -557,6 +558,12 @@ export class McpHub {
557558
config: z.infer<typeof ServerConfigSchema>,
558559
source: "global" | "project" = "global",
559560
): Promise<void> {
561+
// Check if server is disabled before attempting to connect
562+
if (config.disabled) {
563+
console.log(`Skipping connection to disabled server: ${name}`)
564+
return
565+
}
566+
560567
// Remove existing connection if it exists with the same source
561568
await this.deleteConnection(name, source)
562569

@@ -973,24 +980,19 @@ export class McpHub {
973980
}
974981

975982
if (!currentConnection) {
976-
// New server - only connect if not disabled
977-
if (!validatedConfig.disabled) {
978-
try {
979-
this.setupFileWatcher(name, validatedConfig, source)
980-
await this.connectToServer(name, validatedConfig, source)
981-
} catch (error) {
982-
this.showErrorMessage(`Failed to connect to new MCP server ${name}`, error)
983-
}
983+
// New server
984+
try {
985+
this.setupFileWatcher(name, validatedConfig, source)
986+
await this.connectToServer(name, validatedConfig, source)
987+
} catch (error) {
988+
this.showErrorMessage(`Failed to connect to new MCP server ${name}`, error)
984989
}
985990
} else if (!deepEqual(JSON.parse(currentConnection.server.config), config)) {
986991
// Existing server with changed config
987992
try {
988993
this.setupFileWatcher(name, validatedConfig, source)
989994
await this.deleteConnection(name, source)
990-
// Only reconnect if not disabled
991-
if (!validatedConfig.disabled) {
992-
await this.connectToServer(name, validatedConfig, source)
993-
}
995+
await this.connectToServer(name, validatedConfig, source)
994996
} catch (error) {
995997
this.showErrorMessage(`Failed to reconnect MCP server ${name}`, error)
996998
}
@@ -1245,6 +1247,33 @@ export class McpHub {
12451247
serverName: string,
12461248
disabled: boolean,
12471249
source?: "global" | "project",
1250+
): Promise<void> {
1251+
// Check if there's already a toggle operation in progress for this server
1252+
const operationKey = `${serverName}-${source || "global"}`
1253+
const existingOperation = this.toggleOperations.get(operationKey)
1254+
1255+
if (existingOperation) {
1256+
console.log(`Toggle operation already in progress for ${operationKey}, waiting...`)
1257+
await existingOperation
1258+
return
1259+
}
1260+
1261+
// Create a new toggle operation
1262+
const toggleOperation = this._performToggleServerDisabled(serverName, disabled, source)
1263+
this.toggleOperations.set(operationKey, toggleOperation)
1264+
1265+
try {
1266+
await toggleOperation
1267+
} finally {
1268+
// Clean up the operation from the map
1269+
this.toggleOperations.delete(operationKey)
1270+
}
1271+
}
1272+
1273+
private async _performToggleServerDisabled(
1274+
serverName: string,
1275+
disabled: boolean,
1276+
source?: "global" | "project",
12481277
): Promise<void> {
12491278
try {
12501279
// Find the connection to determine if it's a global or project server
@@ -1263,11 +1292,13 @@ export class McpHub {
12631292
connection.server.disabled = disabled
12641293

12651294
// If disabling the server, disconnect it
1266-
if (disabled && connection.server.status === "connected") {
1295+
if (disabled) {
12671296
await this.deleteConnection(serverName, serverSource)
1268-
} else if (!disabled && connection.server.status !== "connected") {
1269-
// If enabling the server and it's not connected, connect it
1297+
} else if (!disabled) {
1298+
// If enabling the server, connect it
12701299
const config = JSON.parse(connection.server.config)
1300+
// Ensure the config has the updated disabled state
1301+
config.disabled = false
12711302
await this.connectToServer(serverName, config, serverSource)
12721303
}
12731304

src/services/mcp/__tests__/McpHub.spec.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,141 @@ describe("McpHub", () => {
766766
// Verify the server was connected
767767
expect(mockClient.connect).toHaveBeenCalled()
768768
})
769+
770+
it("should handle toggling a server that's already in the target state", async () => {
771+
const mockConfig = {
772+
mcpServers: {
773+
"test-server": {
774+
type: "stdio",
775+
command: "node",
776+
args: ["test.js"],
777+
disabled: true,
778+
},
779+
},
780+
}
781+
782+
// Mock reading initial config
783+
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
784+
785+
// Set up mock connection for already disabled server
786+
const mockConnection: McpConnection = {
787+
server: {
788+
name: "test-server",
789+
config: JSON.stringify({
790+
type: "stdio",
791+
command: "node",
792+
args: ["test.js"],
793+
disabled: true,
794+
}),
795+
status: "disconnected",
796+
disabled: true,
797+
source: "global",
798+
},
799+
client: {} as any,
800+
transport: {} as any,
801+
}
802+
mcpHub.connections = [mockConnection]
803+
804+
// Try to disable an already disabled server
805+
await mcpHub.toggleServerDisabled("test-server", true)
806+
807+
// Verify the config was still updated (idempotent operation)
808+
expect(fs.writeFile).toHaveBeenCalled()
809+
})
810+
811+
it("should handle errors during toggle operations gracefully", async () => {
812+
const mockConfig = {
813+
mcpServers: {
814+
"test-server": {
815+
type: "stdio",
816+
command: "node",
817+
args: ["test.js"],
818+
disabled: false,
819+
},
820+
},
821+
}
822+
823+
// Mock reading initial config
824+
vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig))
825+
826+
// Mock writeFile to throw an error
827+
vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error("Permission denied"))
828+
829+
// Set up mock connection
830+
const mockConnection: McpConnection = {
831+
server: {
832+
name: "test-server",
833+
config: JSON.stringify({
834+
type: "stdio",
835+
command: "node",
836+
args: ["test.js"],
837+
disabled: false,
838+
}),
839+
status: "connected",
840+
disabled: false,
841+
source: "global",
842+
},
843+
client: {} as any,
844+
transport: {} as any,
845+
}
846+
mcpHub.connections = [mockConnection]
847+
848+
// Try to toggle and expect it to throw
849+
await expect(mcpHub.toggleServerDisabled("test-server", true)).rejects.toThrow()
850+
})
851+
852+
it("should handle concurrent toggle operations", async () => {
853+
const mockConfig = {
854+
mcpServers: {
855+
"test-server": {
856+
type: "stdio",
857+
command: "node",
858+
args: ["test.js"],
859+
disabled: false,
860+
},
861+
},
862+
}
863+
864+
// Mock reading initial config
865+
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockConfig))
866+
867+
// Mock writeFile with a delay to simulate slow operation
868+
let writeCallCount = 0
869+
vi.mocked(fs.writeFile).mockImplementation(async () => {
870+
writeCallCount++
871+
await new Promise((resolve) => setTimeout(resolve, 100))
872+
})
873+
874+
// Set up mock connection
875+
const mockConnection: McpConnection = {
876+
server: {
877+
name: "test-server",
878+
config: JSON.stringify({
879+
type: "stdio",
880+
command: "node",
881+
args: ["test.js"],
882+
disabled: false,
883+
}),
884+
status: "connected",
885+
disabled: false,
886+
source: "global",
887+
},
888+
client: {} as any,
889+
transport: {} as any,
890+
}
891+
mcpHub.connections = [mockConnection]
892+
893+
// Start multiple concurrent toggle operations
894+
const promise1 = mcpHub.toggleServerDisabled("test-server", true)
895+
const promise2 = mcpHub.toggleServerDisabled("test-server", true)
896+
const promise3 = mcpHub.toggleServerDisabled("test-server", true)
897+
898+
// Wait for all to complete
899+
await Promise.all([promise1, promise2, promise3])
900+
901+
// Only the first operation should have executed
902+
expect(writeCallCount).toBe(1)
903+
})
769904
})
770905

771906
describe("callTool", () => {

webview-ui/src/components/mcp/McpView.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -218,11 +218,6 @@ const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer; alwaysAllowM
218218
]
219219

220220
const getStatusColor = () => {
221-
// Show grey/disabled color for disabled servers regardless of connection status
222-
if (server.disabled) {
223-
return "var(--vscode-disabledForeground)"
224-
}
225-
226221
switch (server.status) {
227222
case "connected":
228223
return "var(--vscode-testing-iconPassed)"

0 commit comments

Comments
 (0)