mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-11 03:38:15 +00:00
1314 lines
42 KiB
TypeScript
1314 lines
42 KiB
TypeScript
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
|
import ReconnectingEventSource from "reconnecting-eventsource"
|
|
import {
|
|
CallToolResultSchema,
|
|
ListResourcesResultSchema,
|
|
ListResourceTemplatesResultSchema,
|
|
ListToolsResultSchema,
|
|
ReadResourceResultSchema,
|
|
} from "@modelcontextprotocol/sdk/types.js"
|
|
import chokidar, { FSWatcher } from "chokidar"
|
|
import delay from "delay"
|
|
import deepEqual from "fast-deep-equal"
|
|
import * as fs from "fs/promises"
|
|
import * as path from "path"
|
|
import * as vscode from "vscode"
|
|
import { z } from "zod"
|
|
import { t } from "../../i18n"
|
|
|
|
import { ClineProvider } from "../../core/webview/ClineProvider"
|
|
import { GlobalFileNames } from "../../shared/globalFileNames"
|
|
import {
|
|
McpResource,
|
|
McpResourceResponse,
|
|
McpResourceTemplate,
|
|
McpServer,
|
|
McpTool,
|
|
McpToolCallResponse,
|
|
} from "../../shared/mcp"
|
|
import { fileExistsAtPath } from "../../utils/fs"
|
|
import { arePathsEqual } from "../../utils/path"
|
|
import { injectEnv } from "../../utils/config"
|
|
|
|
export type McpConnection = {
|
|
server: McpServer
|
|
client: Client
|
|
transport: StdioClientTransport | SSEClientTransport
|
|
}
|
|
|
|
// Base configuration schema for common settings
|
|
const BaseConfigSchema = z.object({
|
|
disabled: z.boolean().optional(),
|
|
timeout: z.number().min(1).max(3600).optional().default(60),
|
|
alwaysAllow: z.array(z.string()).default([]),
|
|
watchPaths: z.array(z.string()).optional(), // paths to watch for changes and restart server
|
|
})
|
|
|
|
// Custom error messages for better user feedback
|
|
const typeErrorMessage = "Server type must be either 'stdio' or 'sse'"
|
|
const stdioFieldsErrorMessage =
|
|
"For 'stdio' type servers, you must provide a 'command' field and can optionally include 'args' and 'env'"
|
|
const sseFieldsErrorMessage =
|
|
"For 'sse' type servers, you must provide a 'url' field and can optionally include 'headers'"
|
|
const mixedFieldsErrorMessage =
|
|
"Cannot mix 'stdio' and 'sse' fields. For 'stdio' use 'command', 'args', and 'env'. For 'sse' use 'url' and 'headers'"
|
|
const missingFieldsErrorMessage = "Server configuration must include either 'command' (for stdio) or 'url' (for sse)"
|
|
|
|
// Helper function to create a refined schema with better error messages
|
|
const createServerTypeSchema = () => {
|
|
return z.union([
|
|
// Stdio config (has command field)
|
|
BaseConfigSchema.extend({
|
|
type: z.enum(["stdio"]).optional(),
|
|
command: z.string().min(1, "Command cannot be empty"),
|
|
args: z.array(z.string()).optional(),
|
|
cwd: z.string().default(() => vscode.workspace.workspaceFolders?.at(0)?.uri.fsPath ?? process.cwd()),
|
|
env: z.record(z.string()).optional(),
|
|
// Ensure no SSE fields are present
|
|
url: z.undefined().optional(),
|
|
headers: z.undefined().optional(),
|
|
})
|
|
.transform((data) => ({
|
|
...data,
|
|
type: "stdio" as const,
|
|
}))
|
|
.refine((data) => data.type === undefined || data.type === "stdio", { message: typeErrorMessage }),
|
|
// SSE config (has url field)
|
|
BaseConfigSchema.extend({
|
|
type: z.enum(["sse"]).optional(),
|
|
url: z.string().url("URL must be a valid URL format"),
|
|
headers: z.record(z.string()).optional(),
|
|
// Ensure no stdio fields are present
|
|
command: z.undefined().optional(),
|
|
args: z.undefined().optional(),
|
|
env: z.undefined().optional(),
|
|
})
|
|
.transform((data) => ({
|
|
...data,
|
|
type: "sse" as const,
|
|
}))
|
|
.refine((data) => data.type === undefined || data.type === "sse", { message: typeErrorMessage }),
|
|
])
|
|
}
|
|
|
|
// Server configuration schema with automatic type inference and validation
|
|
export const ServerConfigSchema = createServerTypeSchema()
|
|
|
|
// Settings schema
|
|
const McpSettingsSchema = z.object({
|
|
mcpServers: z.record(ServerConfigSchema),
|
|
})
|
|
|
|
export class McpHub {
|
|
private providerRef: WeakRef<ClineProvider>
|
|
private disposables: vscode.Disposable[] = []
|
|
private settingsWatcher?: vscode.FileSystemWatcher
|
|
private fileWatchers: Map<string, FSWatcher[]> = new Map()
|
|
private projectMcpWatcher?: vscode.FileSystemWatcher
|
|
private isDisposed: boolean = false
|
|
connections: McpConnection[] = []
|
|
isConnecting: boolean = false
|
|
private refCount: number = 0 // Reference counter for active clients
|
|
|
|
constructor(provider: ClineProvider) {
|
|
this.providerRef = new WeakRef(provider)
|
|
this.watchMcpSettingsFile()
|
|
this.watchProjectMcpFile()
|
|
this.setupWorkspaceFoldersWatcher()
|
|
this.initializeGlobalMcpServers()
|
|
this.initializeProjectMcpServers()
|
|
}
|
|
/**
|
|
* Registers a client (e.g., ClineProvider) using this hub.
|
|
* Increments the reference count.
|
|
*/
|
|
public registerClient(): void {
|
|
this.refCount++
|
|
console.log(`McpHub: Client registered. Ref count: ${this.refCount}`)
|
|
}
|
|
|
|
/**
|
|
* Unregisters a client. Decrements the reference count.
|
|
* If the count reaches zero, disposes the hub.
|
|
*/
|
|
public async unregisterClient(): Promise<void> {
|
|
this.refCount--
|
|
console.log(`McpHub: Client unregistered. Ref count: ${this.refCount}`)
|
|
if (this.refCount <= 0) {
|
|
console.log("McpHub: Last client unregistered. Disposing hub.")
|
|
await this.dispose()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates and normalizes server configuration
|
|
* @param config The server configuration to validate
|
|
* @param serverName Optional server name for error messages
|
|
* @returns The validated configuration
|
|
* @throws Error if the configuration is invalid
|
|
*/
|
|
private validateServerConfig(config: any, serverName?: string): z.infer<typeof ServerConfigSchema> {
|
|
// Detect configuration issues before validation
|
|
const hasStdioFields = config.command !== undefined
|
|
const hasSseFields = config.url !== undefined
|
|
|
|
// Check for mixed fields
|
|
if (hasStdioFields && hasSseFields) {
|
|
throw new Error(mixedFieldsErrorMessage)
|
|
}
|
|
|
|
// Check if it's a stdio or SSE config and add type if missing
|
|
if (!config.type) {
|
|
if (hasStdioFields) {
|
|
config.type = "stdio"
|
|
} else if (hasSseFields) {
|
|
config.type = "sse"
|
|
} else {
|
|
throw new Error(missingFieldsErrorMessage)
|
|
}
|
|
} else if (config.type !== "stdio" && config.type !== "sse") {
|
|
throw new Error(typeErrorMessage)
|
|
}
|
|
|
|
// Check for type/field mismatch
|
|
if (config.type === "stdio" && !hasStdioFields) {
|
|
throw new Error(stdioFieldsErrorMessage)
|
|
}
|
|
if (config.type === "sse" && !hasSseFields) {
|
|
throw new Error(sseFieldsErrorMessage)
|
|
}
|
|
|
|
// Validate the config against the schema
|
|
try {
|
|
return ServerConfigSchema.parse(config)
|
|
} catch (validationError) {
|
|
if (validationError instanceof z.ZodError) {
|
|
// Extract and format validation errors
|
|
const errorMessages = validationError.errors
|
|
.map((err) => `${err.path.join(".")}: ${err.message}`)
|
|
.join("; ")
|
|
throw new Error(
|
|
serverName
|
|
? `Invalid configuration for server "${serverName}": ${errorMessages}`
|
|
: `Invalid server configuration: ${errorMessages}`,
|
|
)
|
|
}
|
|
throw validationError
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Formats and displays error messages to the user
|
|
* @param message The error message prefix
|
|
* @param error The error object
|
|
*/
|
|
private showErrorMessage(message: string, error: unknown): void {
|
|
console.error(`${message}:`, error)
|
|
}
|
|
|
|
public setupWorkspaceFoldersWatcher(): void {
|
|
// Skip if test environment is detected
|
|
if (process.env.NODE_ENV === "test" || process.env.JEST_WORKER_ID !== undefined) {
|
|
return
|
|
}
|
|
this.disposables.push(
|
|
vscode.workspace.onDidChangeWorkspaceFolders(async () => {
|
|
await this.updateProjectMcpServers()
|
|
this.watchProjectMcpFile()
|
|
}),
|
|
)
|
|
}
|
|
|
|
private async handleConfigFileChange(filePath: string, source: "global" | "project"): Promise<void> {
|
|
try {
|
|
const content = await fs.readFile(filePath, "utf-8")
|
|
const config = JSON.parse(content)
|
|
const result = McpSettingsSchema.safeParse(config)
|
|
|
|
if (!result.success) {
|
|
const errorMessages = result.error.errors
|
|
.map((err) => `${err.path.join(".")}: ${err.message}`)
|
|
.join("\n")
|
|
vscode.window.showErrorMessage(t("common:errors.invalid_mcp_settings_validation", { errorMessages }))
|
|
return
|
|
}
|
|
|
|
await this.updateServerConnections(result.data.mcpServers || {}, source)
|
|
} catch (error) {
|
|
if (error instanceof SyntaxError) {
|
|
vscode.window.showErrorMessage(t("common:errors.invalid_mcp_settings_format"))
|
|
} else {
|
|
this.showErrorMessage(`Failed to process ${source} MCP settings change`, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
private watchProjectMcpFile(): void {
|
|
this.disposables.push(
|
|
vscode.workspace.onDidSaveTextDocument(async (document) => {
|
|
const projectMcpPath = await this.getProjectMcpPath()
|
|
if (projectMcpPath && arePathsEqual(document.uri.fsPath, projectMcpPath)) {
|
|
await this.handleConfigFileChange(projectMcpPath, "project")
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
private async updateProjectMcpServers(): Promise<void> {
|
|
try {
|
|
const projectMcpPath = await this.getProjectMcpPath()
|
|
if (!projectMcpPath) return
|
|
|
|
const content = await fs.readFile(projectMcpPath, "utf-8")
|
|
let config: any
|
|
|
|
try {
|
|
config = JSON.parse(content)
|
|
} catch (parseError) {
|
|
const errorMessage = t("common:errors.invalid_mcp_settings_syntax")
|
|
console.error(errorMessage, parseError)
|
|
vscode.window.showErrorMessage(errorMessage)
|
|
return
|
|
}
|
|
|
|
// Validate configuration structure
|
|
const result = McpSettingsSchema.safeParse(config)
|
|
if (result.success) {
|
|
await this.updateServerConnections(result.data.mcpServers || {}, "project")
|
|
} else {
|
|
// Format validation errors for better user feedback
|
|
const errorMessages = result.error.errors
|
|
.map((err) => `${err.path.join(".")}: ${err.message}`)
|
|
.join("\n")
|
|
console.error("Invalid project MCP settings format:", errorMessages)
|
|
vscode.window.showErrorMessage(t("common:errors.invalid_mcp_settings_validation", { errorMessages }))
|
|
}
|
|
} catch (error) {
|
|
this.showErrorMessage(t("common:errors.failed_update_project_mcp"), error)
|
|
}
|
|
}
|
|
|
|
private async cleanupProjectMcpServers(): Promise<void> {
|
|
const projectServers = this.connections.filter((conn) => conn.server.source === "project")
|
|
|
|
for (const conn of projectServers) {
|
|
await this.deleteConnection(conn.server.name, "project")
|
|
}
|
|
|
|
await this.notifyWebviewOfServerChanges()
|
|
}
|
|
|
|
getServers(): McpServer[] {
|
|
// Only return enabled servers
|
|
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
|
|
}
|
|
|
|
getAllServers(): McpServer[] {
|
|
// Return all servers regardless of state
|
|
return this.connections.map((conn) => conn.server)
|
|
}
|
|
|
|
async getMcpServersPath(): Promise<string> {
|
|
const provider = this.providerRef.deref()
|
|
if (!provider) {
|
|
throw new Error("Provider not available")
|
|
}
|
|
const mcpServersPath = await provider.ensureMcpServersDirectoryExists()
|
|
return mcpServersPath
|
|
}
|
|
|
|
async getMcpSettingsFilePath(): Promise<string> {
|
|
const provider = this.providerRef.deref()
|
|
if (!provider) {
|
|
throw new Error("Provider not available")
|
|
}
|
|
const mcpSettingsFilePath = path.join(
|
|
await provider.ensureSettingsDirectoryExists(),
|
|
GlobalFileNames.mcpSettings,
|
|
)
|
|
const fileExists = await fileExistsAtPath(mcpSettingsFilePath)
|
|
if (!fileExists) {
|
|
await fs.writeFile(
|
|
mcpSettingsFilePath,
|
|
`{
|
|
"mcpServers": {
|
|
|
|
}
|
|
}`,
|
|
)
|
|
}
|
|
return mcpSettingsFilePath
|
|
}
|
|
|
|
private async watchMcpSettingsFile(): Promise<void> {
|
|
const settingsPath = await this.getMcpSettingsFilePath()
|
|
this.disposables.push(
|
|
vscode.workspace.onDidSaveTextDocument(async (document) => {
|
|
if (arePathsEqual(document.uri.fsPath, settingsPath)) {
|
|
await this.handleConfigFileChange(settingsPath, "global")
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
public async reloadMcpServers() {
|
|
await this.initializeProjectMcpServers()
|
|
await this.initializeGlobalMcpServers()
|
|
}
|
|
|
|
private async initializeMcpServers(source: "global" | "project"): Promise<void> {
|
|
try {
|
|
const configPath =
|
|
source === "global" ? await this.getMcpSettingsFilePath() : await this.getProjectMcpPath()
|
|
|
|
if (!configPath) {
|
|
return
|
|
}
|
|
|
|
const content = await fs.readFile(configPath, "utf-8")
|
|
const config = JSON.parse(content)
|
|
const result = McpSettingsSchema.safeParse(config)
|
|
|
|
if (result.success) {
|
|
await this.updateServerConnections(result.data.mcpServers || {}, source)
|
|
} else {
|
|
const errorMessages = result.error.errors
|
|
.map((err) => `${err.path.join(".")}: ${err.message}`)
|
|
.join("\n")
|
|
console.error(`Invalid ${source} MCP settings format:`, errorMessages)
|
|
vscode.window.showErrorMessage(t("common:errors.invalid_mcp_settings_validation", { errorMessages }))
|
|
|
|
if (source === "global") {
|
|
// Still try to connect with the raw config, but show warnings
|
|
try {
|
|
await this.updateServerConnections(config.mcpServers || {}, source)
|
|
} catch (error) {
|
|
this.showErrorMessage(`Failed to initialize ${source} MCP servers with raw config`, error)
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof SyntaxError) {
|
|
const errorMessage = t("common:errors.invalid_mcp_settings_syntax")
|
|
console.error(errorMessage, error)
|
|
vscode.window.showErrorMessage(errorMessage)
|
|
} else {
|
|
this.showErrorMessage(`Failed to initialize ${source} MCP servers`, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
private async initializeGlobalMcpServers(): Promise<void> {
|
|
await this.initializeMcpServers("global")
|
|
}
|
|
|
|
// Get project-level MCP configuration path
|
|
private async getProjectMcpPath(): Promise<string | null> {
|
|
if (!vscode.workspace.workspaceFolders?.length) {
|
|
return null
|
|
}
|
|
|
|
const workspaceFolder = vscode.workspace.workspaceFolders[0]
|
|
const projectMcpDir = path.join(workspaceFolder.uri.fsPath, ".roo")
|
|
const projectMcpPath = path.join(projectMcpDir, "mcp.json")
|
|
|
|
try {
|
|
await fs.access(projectMcpPath)
|
|
return projectMcpPath
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Initialize project-level MCP servers
|
|
private async initializeProjectMcpServers(): Promise<void> {
|
|
await this.initializeMcpServers("project")
|
|
}
|
|
|
|
private async connectToServer(
|
|
name: string,
|
|
config: z.infer<typeof ServerConfigSchema>,
|
|
source: "global" | "project" = "global",
|
|
): Promise<void> {
|
|
// Remove existing connection if it exists with the same source
|
|
await this.deleteConnection(name, source)
|
|
|
|
try {
|
|
const client = new Client(
|
|
{
|
|
name: "Roo Code",
|
|
version: this.providerRef.deref()?.context.extension?.packageJSON?.version ?? "1.0.0",
|
|
},
|
|
{
|
|
capabilities: {},
|
|
},
|
|
)
|
|
|
|
let transport: StdioClientTransport | SSEClientTransport
|
|
|
|
if (config.type === "stdio") {
|
|
transport = new StdioClientTransport({
|
|
command: config.command,
|
|
args: config.args,
|
|
cwd: config.cwd,
|
|
env: {
|
|
...(config.env ? await injectEnv(config.env) : {}),
|
|
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
|
},
|
|
stderr: "pipe",
|
|
})
|
|
|
|
// Set up stdio specific error handling
|
|
transport.onerror = async (error) => {
|
|
console.error(`Transport error for "${name}":`, error)
|
|
const connection = this.findConnection(name, source)
|
|
if (connection) {
|
|
connection.server.status = "disconnected"
|
|
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
|
|
}
|
|
await this.notifyWebviewOfServerChanges()
|
|
}
|
|
|
|
transport.onclose = async () => {
|
|
const connection = this.findConnection(name, source)
|
|
if (connection) {
|
|
connection.server.status = "disconnected"
|
|
}
|
|
await this.notifyWebviewOfServerChanges()
|
|
}
|
|
|
|
// 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.stderr
|
|
if (stderrStream) {
|
|
stderrStream.on("data", async (data: Buffer) => {
|
|
const output = data.toString()
|
|
// Check if output contains INFO level log
|
|
const isInfoLog = /INFO/i.test(output)
|
|
|
|
if (isInfoLog) {
|
|
// Log normal informational messages
|
|
console.log(`Server "${name}" info:`, output)
|
|
} else {
|
|
// Treat as error log
|
|
console.error(`Server "${name}" stderr:`, output)
|
|
const connection = this.findConnection(name, source)
|
|
if (connection) {
|
|
this.appendErrorMessage(connection, output)
|
|
if (connection.server.status === "disconnected") {
|
|
await this.notifyWebviewOfServerChanges()
|
|
}
|
|
}
|
|
}
|
|
})
|
|
} else {
|
|
console.error(`No stderr stream for ${name}`)
|
|
}
|
|
transport.start = async () => {} // No-op now, .connect() won't fail
|
|
} else {
|
|
// SSE connection
|
|
const sseOptions = {
|
|
requestInit: {
|
|
headers: config.headers,
|
|
},
|
|
}
|
|
// Configure ReconnectingEventSource options
|
|
const reconnectingEventSourceOptions = {
|
|
max_retry_time: 5000, // Maximum retry time in milliseconds
|
|
withCredentials: config.headers?.["Authorization"] ? true : false, // Enable credentials if Authorization header exists
|
|
}
|
|
global.EventSource = ReconnectingEventSource
|
|
transport = new SSEClientTransport(new URL(config.url), {
|
|
...sseOptions,
|
|
eventSourceInit: reconnectingEventSourceOptions,
|
|
})
|
|
|
|
// Set up SSE specific error handling
|
|
transport.onerror = async (error) => {
|
|
console.error(`Transport error for "${name}":`, error)
|
|
const connection = this.findConnection(name, source)
|
|
if (connection) {
|
|
connection.server.status = "disconnected"
|
|
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
|
|
}
|
|
await this.notifyWebviewOfServerChanges()
|
|
}
|
|
}
|
|
|
|
const connection: McpConnection = {
|
|
server: {
|
|
name,
|
|
config: JSON.stringify(config),
|
|
status: "connecting",
|
|
disabled: config.disabled,
|
|
source,
|
|
projectPath: source === "project" ? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath : undefined,
|
|
errorHistory: [],
|
|
},
|
|
client,
|
|
transport,
|
|
}
|
|
this.connections.push(connection)
|
|
|
|
// Connect (this will automatically start the transport)
|
|
await client.connect(transport)
|
|
connection.server.status = "connected"
|
|
connection.server.error = ""
|
|
|
|
// Initial fetch of tools and resources
|
|
connection.server.tools = await this.fetchToolsList(name, source)
|
|
connection.server.resources = await this.fetchResourcesList(name, source)
|
|
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name, source)
|
|
} catch (error) {
|
|
// Update status with error
|
|
const connection = this.findConnection(name, source)
|
|
if (connection) {
|
|
connection.server.status = "disconnected"
|
|
this.appendErrorMessage(connection, error instanceof Error ? error.message : `${error}`)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private appendErrorMessage(connection: McpConnection, error: string, level: "error" | "warn" | "info" = "error") {
|
|
const MAX_ERROR_LENGTH = 1000
|
|
const truncatedError =
|
|
error.length > MAX_ERROR_LENGTH
|
|
? `${error.substring(0, MAX_ERROR_LENGTH)}...(error message truncated)`
|
|
: error
|
|
|
|
// Add to error history
|
|
if (!connection.server.errorHistory) {
|
|
connection.server.errorHistory = []
|
|
}
|
|
|
|
connection.server.errorHistory.push({
|
|
message: truncatedError,
|
|
timestamp: Date.now(),
|
|
level,
|
|
})
|
|
|
|
// Keep only the last 100 errors
|
|
if (connection.server.errorHistory.length > 100) {
|
|
connection.server.errorHistory = connection.server.errorHistory.slice(-100)
|
|
}
|
|
|
|
// Update current error display
|
|
connection.server.error = truncatedError
|
|
}
|
|
|
|
/**
|
|
* Helper method to find a connection by server name and source
|
|
* @param serverName The name of the server to find
|
|
* @param source Optional source to filter by (global or project)
|
|
* @returns The matching connection or undefined if not found
|
|
*/
|
|
private findConnection(serverName: string, source?: "global" | "project"): McpConnection | undefined {
|
|
// If source is specified, only find servers with that source
|
|
if (source !== undefined) {
|
|
return this.connections.find((conn) => conn.server.name === serverName && conn.server.source === source)
|
|
}
|
|
|
|
// If no source is specified, first look for project servers, then global servers
|
|
// This ensures that when servers have the same name, project servers are prioritized
|
|
const projectConn = this.connections.find(
|
|
(conn) => conn.server.name === serverName && conn.server.source === "project",
|
|
)
|
|
if (projectConn) return projectConn
|
|
|
|
// If no project server is found, look for global servers
|
|
return this.connections.find(
|
|
(conn) => conn.server.name === serverName && (conn.server.source === "global" || !conn.server.source),
|
|
)
|
|
}
|
|
|
|
private async fetchToolsList(serverName: string, source?: "global" | "project"): Promise<McpTool[]> {
|
|
try {
|
|
// Use the helper method to find the connection
|
|
const connection = this.findConnection(serverName, source)
|
|
|
|
if (!connection) {
|
|
throw new Error(`Server ${serverName} not found`)
|
|
}
|
|
|
|
const response = await connection.client.request({ method: "tools/list" }, ListToolsResultSchema)
|
|
|
|
// Determine the actual source of the server
|
|
const actualSource = connection.server.source || "global"
|
|
let configPath: string
|
|
let alwaysAllowConfig: string[] = []
|
|
|
|
// Read from the appropriate config file based on the actual source
|
|
try {
|
|
if (actualSource === "project") {
|
|
// Get project MCP config path
|
|
const projectMcpPath = await this.getProjectMcpPath()
|
|
if (projectMcpPath) {
|
|
configPath = projectMcpPath
|
|
const content = await fs.readFile(configPath, "utf-8")
|
|
const config = JSON.parse(content)
|
|
alwaysAllowConfig = config.mcpServers?.[serverName]?.alwaysAllow || []
|
|
}
|
|
} else {
|
|
// Get global MCP settings path
|
|
configPath = await this.getMcpSettingsFilePath()
|
|
const content = await fs.readFile(configPath, "utf-8")
|
|
const config = JSON.parse(content)
|
|
alwaysAllowConfig = config.mcpServers?.[serverName]?.alwaysAllow || []
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to read alwaysAllow config for ${serverName}:`, error)
|
|
// Continue with empty alwaysAllowConfig
|
|
}
|
|
|
|
// Mark tools as always allowed based on settings
|
|
const tools = (response?.tools || []).map((tool) => ({
|
|
...tool,
|
|
alwaysAllow: alwaysAllowConfig.includes(tool.name),
|
|
}))
|
|
|
|
return tools
|
|
} catch (error) {
|
|
console.error(`Failed to fetch tools for ${serverName}:`, error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
private async fetchResourcesList(serverName: string, source?: "global" | "project"): Promise<McpResource[]> {
|
|
try {
|
|
const connection = this.findConnection(serverName, source)
|
|
if (!connection) {
|
|
return []
|
|
}
|
|
const response = await connection.client.request({ method: "resources/list" }, ListResourcesResultSchema)
|
|
return response?.resources || []
|
|
} catch (error) {
|
|
// console.error(`Failed to fetch resources for ${serverName}:`, error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
private async fetchResourceTemplatesList(
|
|
serverName: string,
|
|
source?: "global" | "project",
|
|
): Promise<McpResourceTemplate[]> {
|
|
try {
|
|
const connection = this.findConnection(serverName, source)
|
|
if (!connection) {
|
|
return []
|
|
}
|
|
const response = await connection.client.request(
|
|
{ method: "resources/templates/list" },
|
|
ListResourceTemplatesResultSchema,
|
|
)
|
|
return response?.resourceTemplates || []
|
|
} catch (error) {
|
|
// console.error(`Failed to fetch resource templates for ${serverName}:`, error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
async deleteConnection(name: string, source?: "global" | "project"): Promise<void> {
|
|
// If source is provided, only delete connections from that source
|
|
const connections = source
|
|
? this.connections.filter((conn) => conn.server.name === name && conn.server.source === source)
|
|
: this.connections.filter((conn) => conn.server.name === name)
|
|
|
|
for (const connection of connections) {
|
|
try {
|
|
await connection.transport.close()
|
|
await connection.client.close()
|
|
} catch (error) {
|
|
console.error(`Failed to close transport for ${name}:`, error)
|
|
}
|
|
}
|
|
|
|
// Remove the connections from the array
|
|
this.connections = this.connections.filter((conn) => {
|
|
if (conn.server.name !== name) return true
|
|
if (source && conn.server.source !== source) return true
|
|
return false
|
|
})
|
|
}
|
|
|
|
async updateServerConnections(
|
|
newServers: Record<string, any>,
|
|
source: "global" | "project" = "global",
|
|
): Promise<void> {
|
|
this.isConnecting = true
|
|
this.removeAllFileWatchers()
|
|
// Filter connections by source
|
|
const currentConnections = this.connections.filter(
|
|
(conn) => conn.server.source === source || (!conn.server.source && source === "global"),
|
|
)
|
|
const currentNames = new Set(currentConnections.map((conn) => conn.server.name))
|
|
const newNames = new Set(Object.keys(newServers))
|
|
|
|
// Delete removed servers
|
|
for (const name of currentNames) {
|
|
if (!newNames.has(name)) {
|
|
await this.deleteConnection(name, source)
|
|
}
|
|
}
|
|
|
|
// Update or add servers
|
|
for (const [name, config] of Object.entries(newServers)) {
|
|
// Only consider connections that match the current source
|
|
const currentConnection = this.findConnection(name, source)
|
|
|
|
// Validate and transform the config
|
|
let validatedConfig: z.infer<typeof ServerConfigSchema>
|
|
try {
|
|
validatedConfig = this.validateServerConfig(config, name)
|
|
} catch (error) {
|
|
this.showErrorMessage(`Invalid configuration for MCP server "${name}"`, error)
|
|
continue
|
|
}
|
|
|
|
if (!currentConnection) {
|
|
// New server
|
|
try {
|
|
this.setupFileWatcher(name, validatedConfig, source)
|
|
await this.connectToServer(name, validatedConfig, source)
|
|
} catch (error) {
|
|
this.showErrorMessage(`Failed to connect to new MCP server ${name}`, error)
|
|
}
|
|
} else if (!deepEqual(JSON.parse(currentConnection.server.config), config)) {
|
|
// Existing server with changed config
|
|
try {
|
|
this.setupFileWatcher(name, validatedConfig, source)
|
|
await this.deleteConnection(name, source)
|
|
await this.connectToServer(name, validatedConfig, source)
|
|
} catch (error) {
|
|
this.showErrorMessage(`Failed to reconnect MCP server ${name}`, error)
|
|
}
|
|
}
|
|
// If server exists with same config, do nothing
|
|
}
|
|
await this.notifyWebviewOfServerChanges()
|
|
this.isConnecting = false
|
|
}
|
|
|
|
private setupFileWatcher(
|
|
name: string,
|
|
config: z.infer<typeof ServerConfigSchema>,
|
|
source: "global" | "project" = "global",
|
|
) {
|
|
// Initialize an empty array for this server if it doesn't exist
|
|
if (!this.fileWatchers.has(name)) {
|
|
this.fileWatchers.set(name, [])
|
|
}
|
|
|
|
const watchers = this.fileWatchers.get(name) || []
|
|
|
|
// Only stdio type has args
|
|
if (config.type === "stdio") {
|
|
// Setup watchers for custom watchPaths if defined
|
|
if (config.watchPaths && config.watchPaths.length > 0) {
|
|
const watchPathsWatcher = chokidar.watch(config.watchPaths, {
|
|
// persistent: true,
|
|
// ignoreInitial: true,
|
|
// awaitWriteFinish: true,
|
|
})
|
|
|
|
watchPathsWatcher.on("change", async (changedPath) => {
|
|
try {
|
|
// Pass the source from the config to restartConnection
|
|
await this.restartConnection(name, source)
|
|
} catch (error) {
|
|
console.error(`Failed to restart server ${name} after change in ${changedPath}:`, error)
|
|
}
|
|
})
|
|
|
|
watchers.push(watchPathsWatcher)
|
|
}
|
|
|
|
// Also setup the fallback build/index.js watcher if applicable
|
|
const filePath = config.args?.find((arg: string) => arg.includes("build/index.js"))
|
|
if (filePath) {
|
|
// we use chokidar instead of onDidSaveTextDocument because it doesn't require the file to be open in the editor
|
|
const indexJsWatcher = chokidar.watch(filePath, {
|
|
// persistent: true,
|
|
// ignoreInitial: true,
|
|
// awaitWriteFinish: true, // This helps with atomic writes
|
|
})
|
|
|
|
indexJsWatcher.on("change", async () => {
|
|
try {
|
|
// Pass the source from the config to restartConnection
|
|
await this.restartConnection(name, source)
|
|
} catch (error) {
|
|
console.error(`Failed to restart server ${name} after change in ${filePath}:`, error)
|
|
}
|
|
})
|
|
|
|
watchers.push(indexJsWatcher)
|
|
}
|
|
|
|
// Update the fileWatchers map with all watchers for this server
|
|
if (watchers.length > 0) {
|
|
this.fileWatchers.set(name, watchers)
|
|
}
|
|
}
|
|
}
|
|
|
|
private removeAllFileWatchers() {
|
|
this.fileWatchers.forEach((watchers) => watchers.forEach((watcher) => watcher.close()))
|
|
this.fileWatchers.clear()
|
|
}
|
|
|
|
async restartConnection(serverName: string, source?: "global" | "project"): Promise<void> {
|
|
this.isConnecting = true
|
|
const provider = this.providerRef.deref()
|
|
if (!provider) {
|
|
return
|
|
}
|
|
|
|
// Get existing connection and update its status
|
|
const connection = this.findConnection(serverName, source)
|
|
const config = connection?.server.config
|
|
if (config) {
|
|
vscode.window.showInformationMessage(t("common:info.mcp_server_restarting", { serverName }))
|
|
connection.server.status = "connecting"
|
|
connection.server.error = ""
|
|
await this.notifyWebviewOfServerChanges()
|
|
await delay(500) // artificial delay to show user that server is restarting
|
|
try {
|
|
await this.deleteConnection(serverName, connection.server.source)
|
|
// Parse the config to validate it
|
|
const parsedConfig = JSON.parse(config)
|
|
try {
|
|
// Validate the config
|
|
const validatedConfig = this.validateServerConfig(parsedConfig, serverName)
|
|
|
|
// Try to connect again using validated config
|
|
await this.connectToServer(serverName, validatedConfig, connection.server.source || "global")
|
|
vscode.window.showInformationMessage(t("common:info.mcp_server_connected", { serverName }))
|
|
} catch (validationError) {
|
|
this.showErrorMessage(`Invalid configuration for MCP server "${serverName}"`, validationError)
|
|
}
|
|
} catch (error) {
|
|
this.showErrorMessage(`Failed to restart ${serverName} MCP server connection`, error)
|
|
}
|
|
}
|
|
|
|
await this.notifyWebviewOfServerChanges()
|
|
this.isConnecting = false
|
|
}
|
|
|
|
private async notifyWebviewOfServerChanges(): Promise<void> {
|
|
// Get global server order from settings file
|
|
const settingsPath = await this.getMcpSettingsFilePath()
|
|
const content = await fs.readFile(settingsPath, "utf-8")
|
|
const config = JSON.parse(content)
|
|
const globalServerOrder = Object.keys(config.mcpServers || {})
|
|
|
|
// Get project server order if available
|
|
const projectMcpPath = await this.getProjectMcpPath()
|
|
let projectServerOrder: string[] = []
|
|
if (projectMcpPath) {
|
|
try {
|
|
const projectContent = await fs.readFile(projectMcpPath, "utf-8")
|
|
const projectConfig = JSON.parse(projectContent)
|
|
projectServerOrder = Object.keys(projectConfig.mcpServers || {})
|
|
} catch (error) {
|
|
// Silently continue with empty project server order
|
|
}
|
|
}
|
|
|
|
// Sort connections: first project servers in their defined order, then global servers in their defined order
|
|
// This ensures that when servers have the same name, project servers are prioritized
|
|
const sortedConnections = [...this.connections].sort((a, b) => {
|
|
const aIsGlobal = a.server.source === "global" || !a.server.source
|
|
const bIsGlobal = b.server.source === "global" || !b.server.source
|
|
|
|
// If both are global or both are project, sort by their respective order
|
|
if (aIsGlobal && bIsGlobal) {
|
|
const indexA = globalServerOrder.indexOf(a.server.name)
|
|
const indexB = globalServerOrder.indexOf(b.server.name)
|
|
return indexA - indexB
|
|
} else if (!aIsGlobal && !bIsGlobal) {
|
|
const indexA = projectServerOrder.indexOf(a.server.name)
|
|
const indexB = projectServerOrder.indexOf(b.server.name)
|
|
return indexA - indexB
|
|
}
|
|
|
|
// Project servers come before global servers (reversed from original)
|
|
return aIsGlobal ? 1 : -1
|
|
})
|
|
|
|
// Send sorted servers to webview
|
|
await this.providerRef.deref()?.postMessageToWebview({
|
|
type: "mcpServers",
|
|
mcpServers: sortedConnections.map((connection) => connection.server),
|
|
})
|
|
}
|
|
|
|
public async toggleServerDisabled(
|
|
serverName: string,
|
|
disabled: boolean,
|
|
source?: "global" | "project",
|
|
): Promise<void> {
|
|
try {
|
|
// Find the connection to determine if it's a global or project server
|
|
const connection = this.findConnection(serverName, source)
|
|
if (!connection) {
|
|
throw new Error(`Server ${serverName}${source ? ` with source ${source}` : ""} not found`)
|
|
}
|
|
|
|
const serverSource = connection.server.source || "global"
|
|
// Update the server config in the appropriate file
|
|
await this.updateServerConfig(serverName, { disabled }, serverSource)
|
|
|
|
// Update the connection object
|
|
if (connection) {
|
|
try {
|
|
connection.server.disabled = disabled
|
|
|
|
// Only refresh capabilities if connected
|
|
if (connection.server.status === "connected") {
|
|
connection.server.tools = await this.fetchToolsList(serverName, serverSource)
|
|
connection.server.resources = await this.fetchResourcesList(serverName, serverSource)
|
|
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(
|
|
serverName,
|
|
serverSource,
|
|
)
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to refresh capabilities for ${serverName}:`, error)
|
|
}
|
|
}
|
|
|
|
await this.notifyWebviewOfServerChanges()
|
|
} catch (error) {
|
|
this.showErrorMessage(`Failed to update server ${serverName} state`, error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper method to update a server's configuration in the appropriate settings file
|
|
* @param serverName The name of the server to update
|
|
* @param configUpdate The configuration updates to apply
|
|
* @param source Whether to update the global or project config
|
|
*/
|
|
private async updateServerConfig(
|
|
serverName: string,
|
|
configUpdate: Record<string, any>,
|
|
source: "global" | "project" = "global",
|
|
): Promise<void> {
|
|
// Determine which config file to update
|
|
let configPath: string
|
|
if (source === "project") {
|
|
const projectMcpPath = await this.getProjectMcpPath()
|
|
if (!projectMcpPath) {
|
|
throw new Error("Project MCP configuration file not found")
|
|
}
|
|
configPath = projectMcpPath
|
|
} else {
|
|
configPath = await this.getMcpSettingsFilePath()
|
|
}
|
|
|
|
// Ensure the settings file exists and is accessible
|
|
try {
|
|
await fs.access(configPath)
|
|
} catch (error) {
|
|
console.error("Settings file not accessible:", error)
|
|
throw new Error("Settings file not accessible")
|
|
}
|
|
|
|
// Read and parse the config file
|
|
const content = await fs.readFile(configPath, "utf-8")
|
|
const config = JSON.parse(content)
|
|
|
|
// Validate the config structure
|
|
if (!config || typeof config !== "object") {
|
|
throw new Error("Invalid config structure")
|
|
}
|
|
|
|
if (!config.mcpServers || typeof config.mcpServers !== "object") {
|
|
config.mcpServers = {}
|
|
}
|
|
|
|
if (!config.mcpServers[serverName]) {
|
|
config.mcpServers[serverName] = {}
|
|
}
|
|
|
|
// Create a new server config object to ensure clean structure
|
|
const serverConfig = {
|
|
...config.mcpServers[serverName],
|
|
...configUpdate,
|
|
}
|
|
|
|
// Ensure required fields exist
|
|
if (!serverConfig.alwaysAllow) {
|
|
serverConfig.alwaysAllow = []
|
|
}
|
|
|
|
config.mcpServers[serverName] = serverConfig
|
|
|
|
// Write the entire config back
|
|
const updatedConfig = {
|
|
mcpServers: config.mcpServers,
|
|
}
|
|
|
|
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2))
|
|
}
|
|
|
|
public async updateServerTimeout(
|
|
serverName: string,
|
|
timeout: number,
|
|
source?: "global" | "project",
|
|
): Promise<void> {
|
|
try {
|
|
// Find the connection to determine if it's a global or project server
|
|
const connection = this.findConnection(serverName, source)
|
|
if (!connection) {
|
|
throw new Error(`Server ${serverName}${source ? ` with source ${source}` : ""} not found`)
|
|
}
|
|
|
|
// Update the server config in the appropriate file
|
|
await this.updateServerConfig(serverName, { timeout }, connection.server.source || "global")
|
|
|
|
await this.notifyWebviewOfServerChanges()
|
|
} catch (error) {
|
|
this.showErrorMessage(`Failed to update server ${serverName} timeout settings`, error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public async deleteServer(serverName: string, source?: "global" | "project"): Promise<void> {
|
|
try {
|
|
// Find the connection to determine if it's a global or project server
|
|
const connection = this.findConnection(serverName, source)
|
|
if (!connection) {
|
|
throw new Error(`Server ${serverName}${source ? ` with source ${source}` : ""} not found`)
|
|
}
|
|
|
|
const serverSource = connection.server.source || "global"
|
|
// Determine config file based on server source
|
|
const isProjectServer = serverSource === "project"
|
|
let configPath: string
|
|
|
|
if (isProjectServer) {
|
|
// Get project MCP config path
|
|
const projectMcpPath = await this.getProjectMcpPath()
|
|
if (!projectMcpPath) {
|
|
throw new Error("Project MCP configuration file not found")
|
|
}
|
|
configPath = projectMcpPath
|
|
} else {
|
|
// Get global MCP settings path
|
|
configPath = await this.getMcpSettingsFilePath()
|
|
}
|
|
|
|
// Ensure the settings file exists and is accessible
|
|
try {
|
|
await fs.access(configPath)
|
|
} catch (error) {
|
|
throw new Error("Settings file not accessible")
|
|
}
|
|
|
|
const content = await fs.readFile(configPath, "utf-8")
|
|
const config = JSON.parse(content)
|
|
|
|
// Validate the config structure
|
|
if (!config || typeof config !== "object") {
|
|
throw new Error("Invalid config structure")
|
|
}
|
|
|
|
if (!config.mcpServers || typeof config.mcpServers !== "object") {
|
|
config.mcpServers = {}
|
|
}
|
|
|
|
// Remove the server from the settings
|
|
if (config.mcpServers[serverName]) {
|
|
delete config.mcpServers[serverName]
|
|
|
|
// Write the entire config back
|
|
const updatedConfig = {
|
|
mcpServers: config.mcpServers,
|
|
}
|
|
|
|
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2))
|
|
|
|
// Update server connections with the correct source
|
|
await this.updateServerConnections(config.mcpServers, serverSource)
|
|
|
|
vscode.window.showInformationMessage(t("common:info.mcp_server_deleted", { serverName }))
|
|
} else {
|
|
vscode.window.showWarningMessage(t("common:info.mcp_server_not_found", { serverName }))
|
|
}
|
|
} catch (error) {
|
|
this.showErrorMessage(`Failed to delete MCP server ${serverName}`, error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async readResource(serverName: string, uri: string, source?: "global" | "project"): Promise<McpResourceResponse> {
|
|
const connection = this.findConnection(serverName, source)
|
|
if (!connection) {
|
|
throw new Error(`No connection found for server: ${serverName}${source ? ` with source ${source}` : ""}`)
|
|
}
|
|
if (connection.server.disabled) {
|
|
throw new Error(`Server "${serverName}" is disabled`)
|
|
}
|
|
return await connection.client.request(
|
|
{
|
|
method: "resources/read",
|
|
params: {
|
|
uri,
|
|
},
|
|
},
|
|
ReadResourceResultSchema,
|
|
)
|
|
}
|
|
|
|
async callTool(
|
|
serverName: string,
|
|
toolName: string,
|
|
toolArguments?: Record<string, unknown>,
|
|
source?: "global" | "project",
|
|
): Promise<McpToolCallResponse> {
|
|
const connection = this.findConnection(serverName, source)
|
|
if (!connection) {
|
|
throw new Error(
|
|
`No connection found for server: ${serverName}${source ? ` with source ${source}` : ""}. Please make sure to use MCP servers available under 'Connected MCP Servers'.`,
|
|
)
|
|
}
|
|
if (connection.server.disabled) {
|
|
throw new Error(`Server "${serverName}" is disabled and cannot be used`)
|
|
}
|
|
|
|
let timeout: number
|
|
try {
|
|
const parsedConfig = ServerConfigSchema.parse(JSON.parse(connection.server.config))
|
|
timeout = (parsedConfig.timeout ?? 60) * 1000
|
|
} catch (error) {
|
|
console.error("Failed to parse server config for timeout:", error)
|
|
// Default to 60 seconds if parsing fails
|
|
timeout = 60 * 1000
|
|
}
|
|
|
|
return await connection.client.request(
|
|
{
|
|
method: "tools/call",
|
|
params: {
|
|
name: toolName,
|
|
arguments: toolArguments,
|
|
},
|
|
},
|
|
CallToolResultSchema,
|
|
{
|
|
timeout,
|
|
},
|
|
)
|
|
}
|
|
|
|
async toggleToolAlwaysAllow(
|
|
serverName: string,
|
|
source: "global" | "project",
|
|
toolName: string,
|
|
shouldAllow: boolean,
|
|
): Promise<void> {
|
|
try {
|
|
// Find the connection with matching name and source
|
|
const connection = this.findConnection(serverName, source)
|
|
|
|
if (!connection) {
|
|
throw new Error(`Server ${serverName} with source ${source} not found`)
|
|
}
|
|
|
|
// Determine the correct config path based on the source
|
|
let configPath: string
|
|
if (source === "project") {
|
|
// Get project MCP config path
|
|
const projectMcpPath = await this.getProjectMcpPath()
|
|
if (!projectMcpPath) {
|
|
throw new Error("Project MCP configuration file not found")
|
|
}
|
|
configPath = projectMcpPath
|
|
} else {
|
|
// Get global MCP settings path
|
|
configPath = await this.getMcpSettingsFilePath()
|
|
}
|
|
|
|
// Normalize path for cross-platform compatibility
|
|
// Use a consistent path format for both reading and writing
|
|
const normalizedPath = process.platform === "win32" ? configPath.replace(/\\/g, "/") : configPath
|
|
|
|
// Read the appropriate config file
|
|
const content = await fs.readFile(normalizedPath, "utf-8")
|
|
const config = JSON.parse(content)
|
|
|
|
// Initialize mcpServers if it doesn't exist
|
|
if (!config.mcpServers) {
|
|
config.mcpServers = {}
|
|
}
|
|
|
|
// Initialize server config if it doesn't exist
|
|
if (!config.mcpServers[serverName]) {
|
|
config.mcpServers[serverName] = {
|
|
type: "stdio",
|
|
command: "node",
|
|
args: [], // Default to an empty array; can be set later if needed
|
|
}
|
|
}
|
|
|
|
// Initialize alwaysAllow if it doesn't exist
|
|
if (!config.mcpServers[serverName].alwaysAllow) {
|
|
config.mcpServers[serverName].alwaysAllow = []
|
|
}
|
|
|
|
const alwaysAllow = config.mcpServers[serverName].alwaysAllow
|
|
const toolIndex = alwaysAllow.indexOf(toolName)
|
|
|
|
if (shouldAllow && toolIndex === -1) {
|
|
// Add tool to always allow list
|
|
alwaysAllow.push(toolName)
|
|
} else if (!shouldAllow && toolIndex !== -1) {
|
|
// Remove tool from always allow list
|
|
alwaysAllow.splice(toolIndex, 1)
|
|
}
|
|
|
|
// Write updated config back to file
|
|
await fs.writeFile(normalizedPath, JSON.stringify(config, null, 2))
|
|
|
|
// Update the tools list to reflect the change
|
|
if (connection) {
|
|
// Explicitly pass the source to ensure we're updating the correct server's tools
|
|
connection.server.tools = await this.fetchToolsList(serverName, source)
|
|
await this.notifyWebviewOfServerChanges()
|
|
}
|
|
} catch (error) {
|
|
this.showErrorMessage(`Failed to update always allow settings for tool ${toolName}`, error)
|
|
throw error // Re-throw to ensure the error is properly handled
|
|
}
|
|
}
|
|
|
|
async dispose(): Promise<void> {
|
|
// Prevent multiple disposals
|
|
if (this.isDisposed) {
|
|
console.log("McpHub: Already disposed.")
|
|
return
|
|
}
|
|
console.log("McpHub: Disposing...")
|
|
this.isDisposed = true
|
|
this.removeAllFileWatchers()
|
|
for (const connection of this.connections) {
|
|
try {
|
|
await this.deleteConnection(connection.server.name, connection.server.source)
|
|
} catch (error) {
|
|
console.error(`Failed to close connection for ${connection.server.name}:`, error)
|
|
}
|
|
}
|
|
this.connections = []
|
|
if (this.settingsWatcher) {
|
|
this.settingsWatcher.dispose()
|
|
}
|
|
this.disposables.forEach((d) => d.dispose())
|
|
}
|
|
}
|