Compare commits

...
Author SHA1 Message Date
Elephant Lumps d2fa53cd89 fix type 2025-05-18 14:52:26 -07:00
Elephant Lumps fd01132786 use stringRequest 2025-05-18 11:06:27 -07:00
Elephant Lumps bcc0a0e6ee remove markdown file 2025-05-17 22:45:38 -07:00
Elephant Lumps 6424f1251b changeset 2025-05-17 22:42:31 -07:00
Elephant Lumps f55d794a1a migrate restartMcpServer 2025-05-17 22:41:39 -07:00
11 changed files with 222 additions and 28 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Migrate restartMcpServer to protobus
+1
View File
@@ -11,6 +11,7 @@ service McpService {
rpc updateMcpTimeout(UpdateMcpTimeoutRequest) returns (McpServers);
rpc addRemoteMcpServer(AddRemoteMcpServerRequest) returns (McpServers);
rpc downloadMcp(StringRequest) returns (Empty);
rpc restartMcpServer(StringRequest) returns (McpServers);
}
message ToggleMcpServerRequest {
-8
View File
@@ -499,14 +499,6 @@ export class Controller {
this.refreshTotalTasksSize()
break
}
case "restartMcpServer": {
try {
await this.mcpHub?.restartConnection(message.text!)
} catch (error) {
console.error(`Failed to retry connection for ${message.text}:`, error)
}
break
}
case "deleteMcpServer": {
if (message.serverName) {
this.mcpHub?.deleteServer(message.serverName)
+2
View File
@@ -5,6 +5,7 @@
import { registerMethod } from "./index"
import { addRemoteMcpServer } from "./addRemoteMcpServer"
import { downloadMcp } from "./downloadMcp"
import { restartMcpServer } from "./restartMcpServer"
import { toggleMcpServer } from "./toggleMcpServer"
import { updateMcpTimeout } from "./updateMcpTimeout"
@@ -13,6 +14,7 @@ export function registerAllMethods(): void {
// Register each method with the registry
registerMethod("addRemoteMcpServer", addRemoteMcpServer)
registerMethod("downloadMcp", downloadMcp)
registerMethod("restartMcpServer", restartMcpServer)
registerMethod("toggleMcpServer", toggleMcpServer)
registerMethod("updateMcpTimeout", updateMcpTimeout)
}
@@ -0,0 +1,24 @@
import type { McpServers } from "@shared/proto/mcp"
import type { Controller } from "../index"
import { convertMcpServersToProtoMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
import { StringRequest } from "@/shared/proto/common"
/**
* Restarts an MCP server connection
* @param controller The controller instance
* @param request The request containing the server name
* @returns The updated list of MCP servers
*/
export async function restartMcpServer(controller: Controller, request: StringRequest): Promise<McpServers> {
try {
const mcpServers = await controller.mcpHub?.restartConnectionRPC(request.value)
// Convert from McpServer[] to ProtoMcpServer[] ensuring all required fields are set
const protoServers = convertMcpServersToProtoMcpServers(mcpServers)
return { mcpServers: protoServers }
} catch (error) {
console.error(`Failed to restart MCP server ${request.value}:`, error)
throw error
}
}
+145
View File
@@ -103,6 +103,7 @@ export class McpHub {
getServers(): McpServer[] {
// Only return enabled servers
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
}
@@ -180,6 +181,120 @@ export class McpHub {
}
}
private async connectToServerRPC(
name: string,
config: z.infer<typeof StdioConfigSchema> | z.infer<typeof SseConfigSchema>,
): Promise<void> {
// Remove existing connection if it exists (should never happen, the connection should be deleted beforehand)
this.connections = this.connections.filter((conn) => conn.server.name !== name)
try {
// Each MCP server requires its own transport connection and has unique capabilities, configurations, and error handling. Having separate clients also allows proper scoping of resources/tools and independent server management like reconnection.
const client = new Client(
{
name: "Cline",
version: this.clientVersion,
},
{
capabilities: {},
},
)
let transport: StdioClientTransport | SSEClientTransport
if (config.transportType === "sse") {
transport = new SSEClientTransport(new URL(config.url), {})
} else {
transport = new StdioClientTransport({
command: config.command,
args: config.args,
env: {
...config.env,
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
// ...(process.env.NODE_PATH ? { NODE_PATH: process.env.NODE_PATH } : {}),
},
stderr: "pipe", // necessary for stderr to be available
})
}
transport.onerror = async (error) => {
console.error(`Transport error for "${name}":`, error)
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error.message)
}
}
transport.onclose = async () => {
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
}
}
const connection: McpConnection = {
server: {
name,
config: JSON.stringify(config),
status: "connecting",
disabled: config.disabled,
},
client,
transport,
}
this.connections.push(connection)
if (config.transportType === "stdio") {
// transport.stderr is only available after the process has been started. However we can't start it separately from the .connect() call because it also starts the transport. And we can't place this after the connect call since we need to capture the stderr stream before the connection is established, in order to capture errors during the connection process.
// As a workaround, we start the transport ourselves, and then monkey-patch the start method to no-op so that .connect() doesn't try to start it again.
await transport.start()
const stderrStream = (transport as StdioClientTransport).stderr
if (stderrStream) {
stderrStream.on("data", async (data: Buffer) => {
const output = data.toString()
// Check if output contains INFO level log
const isInfoLog = !/\berror\b/i.test(output)
if (isInfoLog) {
// Log normal informational messages
console.info(`Server "${name}" info:`, output)
} else {
// Treat as error log
console.error(`Server "${name}" stderr:`, output)
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
this.appendErrorMessage(connection, output)
}
}
})
} else {
console.error(`No stderr stream for ${name}`)
}
transport.start = async () => {} // No-op now, .connect() won't fail
}
// Connect
await client.connect(transport)
connection.server.status = "connected"
connection.server.error = ""
// Initial fetch of tools and resources
connection.server.tools = await this.fetchToolsList(name)
connection.server.resources = await this.fetchResourcesList(name)
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name)
} catch (error) {
// Update status with error
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error instanceof Error ? error.message : String(error))
}
throw error
}
}
private async connectToServer(
name: string,
config: z.infer<typeof StdioConfigSchema> | z.infer<typeof SseConfigSchema>,
@@ -494,6 +609,36 @@ export class McpHub {
this.fileWatchers.clear()
}
async restartConnectionRPC(serverName: string): Promise<McpServer[]> {
this.isConnecting = true
// Get existing connection and update its status
const connection = this.connections.find((conn) => conn.server.name === serverName)
const inMemoryConfig = connection?.server.config
if (inMemoryConfig) {
connection.server.status = "connecting"
connection.server.error = ""
await setTimeoutPromise(500) // artificial delay to show user that server is restarting
try {
await this.deleteConnection(serverName)
// Try to connect again using existing config
await this.connectToServerRPC(serverName, JSON.parse(inMemoryConfig))
} catch (error) {
console.error(`Failed to restart connection for ${serverName}:`, error)
}
}
this.isConnecting = false
const config = await this.readAndValidateMcpSettingsFile()
if (!config) {
throw new Error("Failed to read or validate MCP settings")
}
const serverOrder = Object.keys(config.mcpServers || {})
return this.getSortedMcpServers(serverOrder)
}
async restartConnection(serverName: string): Promise<void> {
this.isConnecting = true
-1
View File
@@ -21,7 +21,6 @@ export interface WebviewMessage {
| "showChatView"
| "refreshClineRules"
| "openMcpSettings"
| "restartMcpServer"
| "deleteMcpServer"
| "autoApprovalSettings"
| "browserRelaunchResult"
+9 -9
View File
@@ -915,15 +915,6 @@ export const FileServiceDefinition = {
responseStream: false,
options: {},
},
/** Select images from the file system and return as data URLs */
selectImages: {
name: "selectImages",
requestType: EmptyRequest,
requestStream: false,
responseType: StringArray,
responseStream: false,
options: {},
},
/** Opens an image in the system viewer */
openImage: {
name: "openImage",
@@ -960,6 +951,15 @@ export const FileServiceDefinition = {
responseStream: false,
options: {},
},
/** Select images from the file system and return as data URLs */
selectImages: {
name: "selectImages",
requestType: EmptyRequest,
requestStream: false,
responseType: StringArray,
responseStream: false,
options: {},
},
/** Convert URIs to workspace-relative paths */
getRelativePaths: {
name: "getRelativePaths",
+8
View File
@@ -1012,6 +1012,14 @@ export const McpServiceDefinition = {
responseStream: false,
options: {},
},
restartMcpServer: {
name: "restartMcpServer",
requestType: StringRequest,
requestStream: false,
responseType: McpServers,
responseStream: false,
options: {},
},
},
} as const
+4 -2
View File
@@ -20,11 +20,11 @@ import { checkpointRestore } from "../core/controller/checkpoints/checkpointRest
// File Service
import { openFile } from "../core/controller/file/openFile"
import { selectImages } from "../core/controller/file/selectImages"
import { openImage } from "../core/controller/file/openImage"
import { deleteRuleFile } from "../core/controller/file/deleteRuleFile"
import { createRuleFile } from "../core/controller/file/createRuleFile"
import { searchCommits } from "../core/controller/file/searchCommits"
import { selectImages } from "../core/controller/file/selectImages"
import { getRelativePaths } from "../core/controller/file/getRelativePaths"
import { searchFiles } from "../core/controller/file/searchFiles"
@@ -33,6 +33,7 @@ import { toggleMcpServer } from "../core/controller/mcp/toggleMcpServer"
import { updateMcpTimeout } from "../core/controller/mcp/updateMcpTimeout"
import { addRemoteMcpServer } from "../core/controller/mcp/addRemoteMcpServer"
import { downloadMcp } from "../core/controller/mcp/downloadMcp"
import { restartMcpServer } from "../core/controller/mcp/restartMcpServer"
// Models Service
import { getOllamaModels } from "../core/controller/models/getOllamaModels"
@@ -96,11 +97,11 @@ export function addServices(
// File Service
server.addService(proto.cline.FileService.service, {
openFile: wrapper(openFile, controller),
selectImages: wrapper(selectImages, controller),
openImage: wrapper(openImage, controller),
deleteRuleFile: wrapper(deleteRuleFile, controller),
createRuleFile: wrapper(createRuleFile, controller),
searchCommits: wrapper(searchCommits, controller),
selectImages: wrapper(selectImages, controller),
getRelativePaths: wrapper(getRelativePaths, controller),
searchFiles: wrapper(searchFiles, controller),
})
@@ -111,6 +112,7 @@ export function addServices(
updateMcpTimeout: wrapper(updateMcpTimeout, controller),
addRemoteMcpServer: wrapper(addRemoteMcpServer, controller),
downloadMcp: wrapper(downloadMcp, controller),
restartMcpServer: wrapper(restartMcpServer, controller),
})
// Models Service
@@ -19,6 +19,7 @@ import { useExtensionState } from "@/context/ExtensionStateContext"
import { McpServiceClient } from "@/services/grpc-client"
import { convertProtoMcpServersToMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
import { McpServers, UpdateMcpTimeoutRequest } from "@shared/proto/mcp"
import { StringRequest } from "@shared/proto/common"
// constant JSX.Elements
const TimeoutOptions = [
{ value: "30", label: "30 seconds" },
@@ -46,6 +47,7 @@ const ServerRow = ({
const [isExpanded, setIsExpanded] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isRestarting, setIsRestarting] = useState(false)
const getStatusColor = useCallback((status: McpServer["status"]) => {
switch (status) {
@@ -93,10 +95,24 @@ const ServerRow = ({
}
const handleRestart = () => {
vscode.postMessage({
type: "restartMcpServer",
text: server.name,
})
// Set local state to show "connecting" status
setIsRestarting(true)
// Make the gRPC call
McpServiceClient.restartMcpServer({
value: server.name,
} as StringRequest)
.then((response: McpServers) => {
// Update with the final state from the server
const mcpServers = convertProtoMcpServersToMcpServers(response.mcpServers)
setMcpServers(mcpServers)
setIsRestarting(false)
})
.catch((error) => {
// Reset the restarting state
setIsRestarting(false)
console.error("Error restarting MCP server", error)
})
}
const handleDelete = () => {
@@ -171,7 +187,7 @@ const ServerRow = ({
e.stopPropagation()
handleRestart()
}}
disabled={server.status === "connecting"}>
disabled={server.status === "connecting" || isRestarting}>
<span className="codicon codicon-sync"></span>
</VSCodeButton>
{hasTrashIcon && (
@@ -267,7 +283,7 @@ const ServerRow = ({
width: "calc(100% - 20px)",
margin: "0 10px 10px 10px",
}}>
{server.status === "connecting" ? "Retrying..." : "Retry Connection"}
{server.status === "connecting" || isRestarting ? "Retrying..." : "Retry Connection"}
</VSCodeButton>
<DangerButton
@@ -363,12 +379,12 @@ const ServerRow = ({
<VSCodeButton
appearance="secondary"
onClick={handleRestart}
disabled={server.status === "connecting"}
disabled={server.status === "connecting" || isRestarting}
style={{
width: "calc(100% - 14px)",
margin: "0 7px 3px 7px",
}}>
{server.status === "connecting" ? "Restarting..." : "Restart Server"}
{server.status === "connecting" || isRestarting ? "Restarting..." : "Restart Server"}
</VSCodeButton>
<DangerButton