From 3d34de490882cf083d3b80cf71dc40c8de42ae6b Mon Sep 17 00:00:00 2001 From: Evan <58194240+celestial-vault@users.noreply.github.com> Date: Sat, 1 Mar 2025 14:46:29 -0800 Subject: [PATCH] Everyone Gets a Timeout (#2018) * re-open clean PR; formatting changes * remove unnecessary comments * Revert system prompt changes * Remove vscode message after timeout * Create tidy-kings-ring.md --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> --- .changeset/tidy-kings-ring.md | 5 ++ src/core/webview/ClineProvider.ts | 11 ++- src/services/mcp/McpHub.ts | 83 +++++++++++++++-------- src/shared/WebviewMessage.ts | 2 +- src/shared/mcp.ts | 3 + src/utils/time.ts | 3 + webview-ui/src/components/mcp/McpView.tsx | 52 +++++++++++++- 7 files changed, 126 insertions(+), 33 deletions(-) create mode 100644 .changeset/tidy-kings-ring.md create mode 100644 src/utils/time.ts diff --git a/.changeset/tidy-kings-ring.md b/.changeset/tidy-kings-ring.md new file mode 100644 index 0000000000..8ae4f03934 --- /dev/null +++ b/.changeset/tidy-kings-ring.md @@ -0,0 +1,5 @@ +--- +"claude-dev": patch +--- + +Add timeout option to MCP servers diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c424ecbd2e..cabb80fb08 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -541,7 +541,6 @@ export class ClineProvider implements vscode.WebviewViewProvider { const isOptedIn = telemetrySetting === "enabled" telemetryService.updateTelemetryState(isOptedIn) }) - break case "newTask": // Code that should run in response to the hello message command @@ -945,6 +944,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( diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 556d6d8c47..c0b5904263 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -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, @@ -23,10 +24,11 @@ import { McpServer, McpTool, McpToolCallResponse, + MIN_MCP_TIMEOUT_SECONDS, } 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 @@ -35,13 +37,13 @@ export type McpConnection = { const AutoApproveSchema = z.array(z.string()).default([]) -// StdioServerParameters const StdioConfigSchema = z.object({ command: z.string(), args: z.array(z.string()).optional(), env: z.record(z.string()).optional(), autoApprove: AutoApproveSchema.optional(), disabled: z.boolean().optional(), + timeout: z.number().min(MIN_MCP_TIMEOUT_SECONDS).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,16 @@ 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}`) + } + return await connection.client.request( { method: "tools/call", @@ -595,6 +582,9 @@ export class McpHub { }, }, CallToolResultSchema, + { + timeout, + }, ) } @@ -620,7 +610,6 @@ export class McpHub { autoApprove.splice(toolIndex, 1) } - // Write updated config back to file await fs.writeFile(settingsPath, JSON.stringify(config, null, 2)) // Update the tools list to reflect the change @@ -663,6 +652,42 @@ export class McpHub { } } + public async updateServerTimeout(serverName: string, timeout: number): Promise { + try { + // Validate timeout against schema + const setConfigResult = StdioConfigSchema.shape.timeout.safeParse(timeout) + if (!setConfigResult.success) { + throw new Error(`Invalid timeout value: ${timeout}. Must be at minimum ${MIN_MCP_TIMEOUT_SECONDS} 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`) + } + + config.mcpServers[serverName] = { + ...config.mcpServers[serverName], + timeout, + } + + await fs.writeFile(settingsPath, JSON.stringify(config, null, 2)) + + await this.updateServerConnections(config.mcpServers) + } 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 { this.removeAllFileWatchers() for (const connection of this.connections) { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 873a7390f9..12cb26d2ed 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -69,7 +69,7 @@ export interface WebviewMessage { chatSettings?: ChatSettings chatContent?: ChatContent mcpId?: string - + timeout?: number // For toggleToolAutoApprove serverName?: string toolName?: string diff --git a/src/shared/mcp.ts b/src/shared/mcp.ts index f0a09afa2d..b078b5de82 100644 --- a/src/shared/mcp.ts +++ b/src/shared/mcp.ts @@ -1,3 +1,5 @@ +export const DEFAULT_MCP_TIMEOUT_SECONDS = 60 // matches Anthropic's default timeout in their MCP SDK +export const MIN_MCP_TIMEOUT_SECONDS = 1 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 = { diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 0000000000..316ea5a949 --- /dev/null +++ b/src/utils/time.ts @@ -0,0 +1,3 @@ +export function secondsToMs(seconds: number): number { + return seconds * 1000 +} diff --git a/webview-ui/src/components/mcp/McpView.tsx b/webview-ui/src/components/mcp/McpView.tsx index f40dec9ddd..25e6a5017a 100644 --- a/webview-ui/src/components/mcp/McpView.tsx +++ b/webview-ui/src/components/mcp/McpView.tsx @@ -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 [timeoutValue, setTimeoutValue] = useState(() => { + 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) + setTimeoutValue(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 }) => { +
+ + + {timeoutOptions.map((option) => ( + + {option.label} + + ))} + +