Skip to content

Commit f47dd2d

Browse files
authored
Merge pull request #584 from RooVetGit/mcp_timeouts
Add per-server MCP network timeout configuration
2 parents 3deeb0c + 57518e1 commit f47dd2d

File tree

7 files changed

+341
-91
lines changed

7 files changed

+341
-91
lines changed

.changeset/flat-items-buy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
Add per-server MCP network timeout configuration

src/core/webview/ClineProvider.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,6 +1177,16 @@ export class ClineProvider implements vscode.WebviewViewProvider {
11771177
}
11781178
await this.postStateToWebview()
11791179
break
1180+
case "updateMcpTimeout":
1181+
if (message.serverName && typeof message.timeout === "number") {
1182+
try {
1183+
await this.mcpHub?.updateServerTimeout(message.serverName, message.timeout)
1184+
} catch (error) {
1185+
console.error(`Failed to update timeout for ${message.serverName}:`, error)
1186+
vscode.window.showErrorMessage(`Failed to update server timeout`)
1187+
}
1188+
}
1189+
break
11801190
case "updateCustomMode":
11811191
if (message.modeConfig) {
11821192
await this.customModesManager.updateCustomMode(message.modeConfig.slug, message.modeConfig)

src/services/mcp/McpHub.ts

Lines changed: 68 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,13 @@ export type McpConnection = {
3535
// StdioServerParameters
3636
const AlwaysAllowSchema = z.array(z.string()).default([])
3737

38-
const StdioConfigSchema = z.object({
38+
export const StdioConfigSchema = z.object({
3939
command: z.string(),
4040
args: z.array(z.string()).optional(),
4141
env: z.record(z.string()).optional(),
4242
alwaysAllow: AlwaysAllowSchema.optional(),
4343
disabled: z.boolean().optional(),
44+
timeout: z.number().min(1).max(3600).optional().default(60),
4445
})
4546

4647
const McpSettingsSchema = z.object({
@@ -238,28 +239,6 @@ export class McpHub {
238239
}
239240
transport.start = async () => {} // No-op now, .connect() won't fail
240241

241-
// // Set up notification handlers
242-
// client.setNotificationHandler(
243-
// // @ts-ignore-next-line
244-
// { method: "notifications/tools/list_changed" },
245-
// async () => {
246-
// console.log(`Tools changed for server: ${name}`)
247-
// connection.server.tools = await this.fetchTools(name)
248-
// await this.notifyWebviewOfServerChanges()
249-
// },
250-
// )
251-
252-
// client.setNotificationHandler(
253-
// // @ts-ignore-next-line
254-
// { method: "notifications/resources/list_changed" },
255-
// async () => {
256-
// console.log(`Resources changed for server: ${name}`)
257-
// connection.server.resources = await this.fetchResources(name)
258-
// connection.server.resourceTemplates = await this.fetchResourceTemplates(name)
259-
// await this.notifyWebviewOfServerChanges()
260-
// },
261-
// )
262-
263242
// Connect
264243
await client.connect(transport)
265244
connection.server.status = "connected"
@@ -339,10 +318,6 @@ export class McpHub {
339318
const connection = this.connections.find((conn) => conn.server.name === name)
340319
if (connection) {
341320
try {
342-
// connection.client.removeNotificationHandler("notifications/tools/list_changed")
343-
// connection.client.removeNotificationHandler("notifications/resources/list_changed")
344-
// connection.client.removeNotificationHandler("notifications/stderr")
345-
// connection.client.removeNotificationHandler("notifications/stderr")
346321
await connection.transport.close()
347322
await connection.client.close()
348323
} catch (error) {
@@ -468,8 +443,6 @@ export class McpHub {
468443
})
469444
}
470445

471-
// Public methods for server management
472-
473446
public async toggleServerDisabled(serverName: string, disabled: boolean): Promise<void> {
474447
let settingsPath: string
475448
try {
@@ -545,6 +518,59 @@ export class McpHub {
545518
}
546519
}
547520

521+
public async updateServerTimeout(serverName: string, timeout: number): Promise<void> {
522+
let settingsPath: string
523+
try {
524+
settingsPath = await this.getMcpSettingsFilePath()
525+
526+
// Ensure the settings file exists and is accessible
527+
try {
528+
await fs.access(settingsPath)
529+
} catch (error) {
530+
console.error("Settings file not accessible:", error)
531+
throw new Error("Settings file not accessible")
532+
}
533+
const content = await fs.readFile(settingsPath, "utf-8")
534+
const config = JSON.parse(content)
535+
536+
// Validate the config structure
537+
if (!config || typeof config !== "object") {
538+
throw new Error("Invalid config structure")
539+
}
540+
541+
if (!config.mcpServers || typeof config.mcpServers !== "object") {
542+
config.mcpServers = {}
543+
}
544+
545+
if (config.mcpServers[serverName]) {
546+
// Create a new server config object to ensure clean structure
547+
const serverConfig = {
548+
...config.mcpServers[serverName],
549+
timeout,
550+
}
551+
552+
config.mcpServers[serverName] = serverConfig
553+
554+
// Write the entire config back
555+
const updatedConfig = {
556+
mcpServers: config.mcpServers,
557+
}
558+
559+
await fs.writeFile(settingsPath, JSON.stringify(updatedConfig, null, 2))
560+
await this.notifyWebviewOfServerChanges()
561+
}
562+
} catch (error) {
563+
console.error("Failed to update server timeout:", error)
564+
if (error instanceof Error) {
565+
console.error("Error details:", error.message, error.stack)
566+
}
567+
vscode.window.showErrorMessage(
568+
`Failed to update server timeout: ${error instanceof Error ? error.message : String(error)}`,
569+
)
570+
throw error
571+
}
572+
}
573+
548574
async readResource(serverName: string, uri: string): Promise<McpResourceResponse> {
549575
const connection = this.connections.find((conn) => conn.server.name === serverName)
550576
if (!connection) {
@@ -579,6 +605,16 @@ export class McpHub {
579605
throw new Error(`Server "${serverName}" is disabled and cannot be used`)
580606
}
581607

608+
let timeout: number
609+
try {
610+
const parsedConfig = StdioConfigSchema.parse(JSON.parse(connection.server.config))
611+
timeout = (parsedConfig.timeout ?? 60) * 1000
612+
} catch (error) {
613+
console.error("Failed to parse server config for timeout:", error)
614+
// Default to 60 seconds if parsing fails
615+
timeout = 60 * 1000
616+
}
617+
582618
return await connection.client.request(
583619
{
584620
method: "tools/call",
@@ -588,6 +624,9 @@ export class McpHub {
588624
},
589625
},
590626
CallToolResultSchema,
627+
{
628+
timeout,
629+
},
591630
)
592631
}
593632

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

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import type { McpHub as McpHubType } from "../McpHub"
22
import type { ClineProvider } from "../../../core/webview/ClineProvider"
33
import type { ExtensionContext, Uri } from "vscode"
44
import type { McpConnection } from "../McpHub"
5+
import { StdioConfigSchema } from "../McpHub"
56

6-
const vscode = require("vscode")
77
const fs = require("fs/promises")
88
const { McpHub } = require("../McpHub")
99

@@ -280,6 +280,7 @@ describe("McpHub", () => {
280280
},
281281
},
282282
expect.any(Object),
283+
expect.objectContaining({ timeout: 60000 }), // Default 60 second timeout
283284
)
284285
})
285286

@@ -288,5 +289,192 @@ describe("McpHub", () => {
288289
"No connection found for server: non-existent-server",
289290
)
290291
})
292+
293+
describe("timeout configuration", () => {
294+
it("should validate timeout values", () => {
295+
// Test valid timeout values
296+
const validConfig = {
297+
command: "test",
298+
timeout: 60,
299+
}
300+
expect(() => StdioConfigSchema.parse(validConfig)).not.toThrow()
301+
302+
// Test invalid timeout values
303+
const invalidConfigs = [
304+
{ command: "test", timeout: 0 }, // Too low
305+
{ command: "test", timeout: 3601 }, // Too high
306+
{ command: "test", timeout: -1 }, // Negative
307+
]
308+
309+
invalidConfigs.forEach((config) => {
310+
expect(() => StdioConfigSchema.parse(config)).toThrow()
311+
})
312+
})
313+
314+
it("should use default timeout of 60 seconds if not specified", async () => {
315+
const mockConnection: McpConnection = {
316+
server: {
317+
name: "test-server",
318+
config: JSON.stringify({ command: "test" }), // No timeout specified
319+
status: "connected",
320+
},
321+
client: {
322+
request: jest.fn().mockResolvedValue({ content: [] }),
323+
} as any,
324+
transport: {} as any,
325+
}
326+
327+
mcpHub.connections = [mockConnection]
328+
await mcpHub.callTool("test-server", "test-tool")
329+
330+
expect(mockConnection.client.request).toHaveBeenCalledWith(
331+
expect.anything(),
332+
expect.anything(),
333+
expect.objectContaining({ timeout: 60000 }), // 60 seconds in milliseconds
334+
)
335+
})
336+
337+
it("should apply configured timeout to tool calls", async () => {
338+
const mockConnection: McpConnection = {
339+
server: {
340+
name: "test-server",
341+
config: JSON.stringify({ command: "test", timeout: 120 }), // 2 minutes
342+
status: "connected",
343+
},
344+
client: {
345+
request: jest.fn().mockResolvedValue({ content: [] }),
346+
} as any,
347+
transport: {} as any,
348+
}
349+
350+
mcpHub.connections = [mockConnection]
351+
await mcpHub.callTool("test-server", "test-tool")
352+
353+
expect(mockConnection.client.request).toHaveBeenCalledWith(
354+
expect.anything(),
355+
expect.anything(),
356+
expect.objectContaining({ timeout: 120000 }), // 120 seconds in milliseconds
357+
)
358+
})
359+
})
360+
361+
describe("updateServerTimeout", () => {
362+
it("should update server timeout in settings file", async () => {
363+
const mockConfig = {
364+
mcpServers: {
365+
"test-server": {
366+
command: "node",
367+
args: ["test.js"],
368+
timeout: 60,
369+
},
370+
},
371+
}
372+
373+
// Mock reading initial config
374+
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
375+
376+
await mcpHub.updateServerTimeout("test-server", 120)
377+
378+
// Verify the config was updated correctly
379+
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
380+
const writtenConfig = JSON.parse(writeCall[1])
381+
expect(writtenConfig.mcpServers["test-server"].timeout).toBe(120)
382+
})
383+
384+
it("should fallback to default timeout when config has invalid timeout", async () => {
385+
const mockConfig = {
386+
mcpServers: {
387+
"test-server": {
388+
command: "node",
389+
args: ["test.js"],
390+
timeout: 60,
391+
},
392+
},
393+
}
394+
395+
// Mock initial read
396+
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
397+
398+
// Update with invalid timeout
399+
await mcpHub.updateServerTimeout("test-server", 3601)
400+
401+
// Config is written
402+
expect(fs.writeFile).toHaveBeenCalled()
403+
404+
// Setup connection with invalid timeout
405+
const mockConnection: McpConnection = {
406+
server: {
407+
name: "test-server",
408+
config: JSON.stringify({
409+
command: "node",
410+
args: ["test.js"],
411+
timeout: 3601, // Invalid timeout
412+
}),
413+
status: "connected",
414+
},
415+
client: {
416+
request: jest.fn().mockResolvedValue({ content: [] }),
417+
} as any,
418+
transport: {} as any,
419+
}
420+
421+
mcpHub.connections = [mockConnection]
422+
423+
// Call tool - should use default timeout
424+
await mcpHub.callTool("test-server", "test-tool")
425+
426+
// Verify default timeout was used
427+
expect(mockConnection.client.request).toHaveBeenCalledWith(
428+
expect.anything(),
429+
expect.anything(),
430+
expect.objectContaining({ timeout: 60000 }), // Default 60 seconds
431+
)
432+
})
433+
434+
it("should accept valid timeout values", async () => {
435+
const mockConfig = {
436+
mcpServers: {
437+
"test-server": {
438+
command: "node",
439+
args: ["test.js"],
440+
timeout: 60,
441+
},
442+
},
443+
}
444+
445+
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
446+
447+
// Test valid timeout values
448+
const validTimeouts = [1, 60, 3600]
449+
for (const timeout of validTimeouts) {
450+
await mcpHub.updateServerTimeout("test-server", timeout)
451+
expect(fs.writeFile).toHaveBeenCalled()
452+
jest.clearAllMocks() // Reset for next iteration
453+
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
454+
}
455+
})
456+
457+
it("should notify webview after updating timeout", async () => {
458+
const mockConfig = {
459+
mcpServers: {
460+
"test-server": {
461+
command: "node",
462+
args: ["test.js"],
463+
timeout: 60,
464+
},
465+
},
466+
}
467+
468+
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
469+
470+
await mcpHub.updateServerTimeout("test-server", 120)
471+
472+
expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith(
473+
expect.objectContaining({
474+
type: "mcpServers",
475+
}),
476+
)
477+
})
478+
})
291479
})
292480
})

0 commit comments

Comments
 (0)