Everyone Gets a Timeout (#1889)

* timeouts for individual servers

* changeset

* remove logger

* use const and descriptive function for time settings
This commit is contained in:
Evan 2025-02-24 13:53:29 -08:00 committed by GitHub
parent 0f1240063c
commit 87670a37b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 135 additions and 31 deletions

View file

@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Added timeout configuration for individual MCP servers.

View file

@ -759,8 +759,9 @@ IMPORTANT: Regardless of what else you see in the MCP settings file, you must de
"args": ["/path/to/weather-server/build/index.js"],
"env": {
"OPENWEATHER_API_KEY": "user-provided-api-key"
}
},
},
"timeout": 60, // 60 seconds should be set as the default timeout for all MCP servers
}
}
}
\`\`\`

View file

@ -821,6 +821,16 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
break
}
case "updateMcpTimeout": {
try {
if (message.serverName && message.timeout) {
await this.mcpHub?.updateServerTimeout(message.serverName, message.timeout)
}
} catch (error) {
console.error(`Failed to update timeout for server ${message.serverName}:`, error)
}
break
}
case "openExtensionSettings": {
const settingsFilter = message.text || ""
await vscode.commands.executeCommand(

View file

@ -16,6 +16,7 @@ import * as vscode from "vscode"
import { z } from "zod"
import { ClineProvider, GlobalFileNames } from "../../core/webview/ClineProvider"
import {
DEFAULT_MCP_TIMEOUT_SECONDS,
McpMode,
McpResource,
McpResourceResponse,
@ -26,7 +27,7 @@ import {
} from "../../shared/mcp"
import { fileExistsAtPath } from "../../utils/fs"
import { arePathsEqual } from "../../utils/path"
import { secondsToMs } from "../../utils/time"
export type McpConnection = {
server: McpServer
client: Client
@ -42,6 +43,7 @@ const StdioConfigSchema = z.object({
env: z.record(z.string()).optional(),
autoApprove: AutoApproveSchema.optional(),
disabled: z.boolean().optional(),
timeout: z.number().min(1).max(3600).optional().default(DEFAULT_MCP_TIMEOUT_SECONDS),
})
const McpSettingsSchema = z.object({
@ -242,28 +244,6 @@ export class McpHub {
}
transport.start = async () => {} // No-op now, .connect() won't fail
// // Set up notification handlers
// client.setNotificationHandler(
// // @ts-ignore-next-line
// { method: "notifications/tools/list_changed" },
// async () => {
// console.log(`Tools changed for server: ${name}`)
// connection.server.tools = await this.fetchTools(name)
// await this.notifyWebviewOfServerChanges()
// },
// )
// client.setNotificationHandler(
// // @ts-ignore-next-line
// { method: "notifications/resources/list_changed" },
// async () => {
// console.log(`Resources changed for server: ${name}`)
// connection.server.resources = await this.fetchResources(name)
// connection.server.resourceTemplates = await this.fetchResourceTemplates(name)
// await this.notifyWebviewOfServerChanges()
// },
// )
// Connect
await client.connect(transport)
connection.server.status = "connected"
@ -343,10 +323,6 @@ export class McpHub {
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
try {
// connection.client.removeNotificationHandler("notifications/tools/list_changed")
// connection.client.removeNotificationHandler("notifications/resources/list_changed")
// connection.client.removeNotificationHandler("notifications/stderr")
// connection.client.removeNotificationHandler("notifications/stderr")
await connection.transport.close()
await connection.client.close()
} catch (error) {
@ -563,6 +539,7 @@ export class McpHub {
if (connection.server.disabled) {
throw new Error(`Server "${serverName}" is disabled`)
}
return await connection.client.request(
{
method: "resources/read",
@ -586,6 +563,17 @@ export class McpHub {
throw new Error(`Server "${serverName}" is disabled and cannot be used`)
}
let timeout = secondsToMs(DEFAULT_MCP_TIMEOUT_SECONDS) // sdk expects ms
try {
const config = JSON.parse(connection.server.config)
const parsedConfig = StdioConfigSchema.parse(config)
timeout = secondsToMs(parsedConfig.timeout)
} catch (error) {
console.error(`Failed to parse timeout configuration for server ${serverName}: ${error}`)
// Continue with default timeout
}
return await connection.client.request(
{
method: "tools/call",
@ -595,6 +583,9 @@ export class McpHub {
},
},
CallToolResultSchema,
{
timeout,
},
)
}
@ -663,6 +654,47 @@ export class McpHub {
}
}
public async updateServerTimeout(serverName: string, timeout: number): Promise<void> {
try {
// Validate timeout against schema
const setConfigResult = StdioConfigSchema.shape.timeout.safeParse(timeout)
if (!setConfigResult.success) {
throw new Error(`Invalid timeout value: ${timeout}. Must be between 1 and 3600 seconds.`)
}
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
if (!config.mcpServers?.[serverName]) {
throw new Error(`Server "${serverName}" not found in settings`)
}
// Update the timeout in the config
config.mcpServers[serverName] = {
...config.mcpServers[serverName],
timeout,
}
// Write updated config back to file
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
// Update server connections to apply the new timeout
await this.updateServerConnections(config.mcpServers)
vscode.window.showInformationMessage(`Updated timeout to ${timeout} seconds`)
} catch (error) {
console.error("Failed to update server timeout:", error)
if (error instanceof Error) {
console.error("Error details:", error.message, error.stack)
}
vscode.window.showErrorMessage(
`Failed to update server timeout: ${error instanceof Error ? error.message : String(error)}`,
)
throw error
}
}
async dispose(): Promise<void> {
this.removeAllFileWatchers()
for (const connection of this.connections) {

View file

@ -50,6 +50,7 @@ export interface WebviewMessage {
| "searchCommits"
| "showMcpView"
| "fetchLatestMcpServersFromHub"
| "updateMcpTimeout"
// | "relaunchChromeDebugMode"
text?: string
disabled?: boolean
@ -63,6 +64,7 @@ export interface WebviewMessage {
chatSettings?: ChatSettings
chatContent?: ChatContent
mcpId?: string
timeout?: number // For updateMcpTimeout
// For toggleToolAutoApprove
serverName?: string

View file

@ -1,3 +1,5 @@
export const DEFAULT_MCP_TIMEOUT_SECONDS = 60 // matches Anthropic's default timeout in their MCP SDK
export type McpMode = "full" | "server-use-only" | "off"
export type McpServer = {
@ -9,6 +11,7 @@ export type McpServer = {
resources?: McpResource[]
resourceTemplates?: McpResourceTemplate[]
disabled?: boolean
timeout?: number
}
export type McpTool = {

3
src/utils/time.ts Normal file
View file

@ -0,0 +1,3 @@
export function secondsToMs(seconds: number): number {
return seconds * 1000
}

View file

@ -1,7 +1,15 @@
import { VSCodeButton, VSCodeLink, VSCodePanels, VSCodePanelTab, VSCodePanelView } from "@vscode/webview-ui-toolkit/react"
import {
VSCodeButton,
VSCodeLink,
VSCodePanels,
VSCodePanelTab,
VSCodePanelView,
VSCodeDropdown,
VSCodeOption,
} from "@vscode/webview-ui-toolkit/react"
import { useEffect, useState } from "react"
import styled from "styled-components"
import { McpServer } from "../../../../src/shared/mcp"
import { DEFAULT_MCP_TIMEOUT_SECONDS, McpServer } from "../../../../src/shared/mcp"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { getMcpServerDisplayName } from "../../utils/mcp"
import { vscode } from "../../utils/vscode"
@ -210,6 +218,36 @@ const ServerRow = ({ server }: { server: McpServer }) => {
}
}
const [timeout, setTimeout] = useState<string>(() => {
try {
const config = JSON.parse(server.config)
return config.timeout?.toString() || DEFAULT_MCP_TIMEOUT_SECONDS.toString()
} catch {
return DEFAULT_MCP_TIMEOUT_SECONDS.toString()
}
})
const timeoutOptions = [
{ value: "30", label: "30 seconds" },
{ value: "60", label: "1 minute" },
{ value: "300", label: "5 minutes" },
{ value: "600", label: "10 minutes" },
{ value: "1800", label: "30 minutes" },
{ value: "3600", label: "1 hour" },
]
const handleTimeoutChange = (e: any) => {
const select = e.target as HTMLSelectElement
const value = select.value
const num = parseInt(value)
setTimeout(value)
vscode.postMessage({
type: "updateMcpTimeout",
serverName: server.name,
timeout: num,
})
}
const handleRestart = () => {
vscode.postMessage({
type: "restartMcpServer",
@ -410,6 +448,16 @@ const ServerRow = ({ server }: { server: McpServer }) => {
</VSCodePanelView>
</VSCodePanels>
<div style={{ margin: "10px 7px" }}>
<label style={{ display: "block", marginBottom: "4px", fontSize: "13px" }}>Request Timeout</label>
<VSCodeDropdown style={{ width: "100%" }} value={timeout} onChange={handleTimeoutChange}>
{timeoutOptions.map((option) => (
<VSCodeOption key={option.value} value={option.value}>
{option.label}
</VSCodeOption>
))}
</VSCodeDropdown>
</div>
<VSCodeButton
appearance="secondary"
onClick={handleRestart}