Compare commits

...

2 Commits

Author SHA1 Message Date
Igor Tceglevskii d1f26011aa local mcp config file 2025-11-13 19:25:49 -08:00
Igor Tceglevskii efb941a45f workspaceFoldef parameter for MCP servers 2025-11-08 08:42:07 -08:00
+170 -10
View File
@@ -24,6 +24,7 @@ import {
} from "@shared/mcp"
import { convertMcpServersToProtoMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
import { fileExistsAtPath } from "@utils/fs"
import { getCwd } from "@utils/path"
import { secondsToMs } from "@utils/time"
import chokidar, { FSWatcher } from "chokidar"
import deepEqual from "fast-deep-equal"
@@ -45,6 +46,7 @@ export class McpHub {
private telemetryService: TelemetryService
private settingsWatcher?: FSWatcher
private projectSettingsWatcher?: FSWatcher
private fileWatchers: Map<string, FSWatcher> = new Map()
connections: McpConnection[] = []
isConnecting: boolean = false
@@ -76,6 +78,7 @@ export class McpHub {
this.clientVersion = clientVersion
this.telemetryService = telemetryService
this.watchMcpSettingsFile()
this.watchProjectMcpSettingsFile()
this.initializeMcpServers()
}
@@ -127,26 +130,82 @@ export class McpHub {
return mcpSettingsFilePath
}
/**
* Get project MCP settings file path for current workspace
* Returns undefined if no workspace is open
*/
private async getProjectMcpSettingsFilePath(): Promise<string | undefined> {
const workspacePath = await getCwd()
if (!workspacePath) {
return undefined
}
return path.join(workspacePath, ".cline", "mcp_settings.json")
}
/**
* Read project MCP settings if they exist
* Returns empty object if file doesn't exist or is invalid
*/
private async readProjectMcpSettings(): Promise<Record<string, McpServerConfig>> {
try {
const projectPath = await this.getProjectMcpSettingsFilePath()
if (!projectPath) {
return {}
}
const exists = await fileExistsAtPath(projectPath)
if (!exists) {
return {}
}
const content = await fs.readFile(projectPath, "utf-8")
const config = JSON.parse(content)
// Validate
const result = McpSettingsSchema.safeParse(config)
if (!result.success) {
console.warn("Invalid project MCP settings, using global only:", result.error)
return {}
}
return result.data.mcpServers || {}
} catch (error) {
console.warn("Failed to read project MCP settings:", error)
return {}
}
}
private async readAndValidateMcpSettingsFile(): Promise<z.infer<typeof McpSettingsSchema> | undefined> {
try {
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
// Read global settings
const globalSettingsPath = await this.getMcpSettingsFilePath()
const globalContent = await fs.readFile(globalSettingsPath, "utf-8")
let globalConfig: any
let config: any
// Parse JSON file content
// Parse global JSON file content
try {
config = JSON.parse(content)
globalConfig = JSON.parse(globalContent)
} catch (_error) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Invalid MCP settings format. Please ensure your settings follow the correct JSON format.",
message: "Invalid global MCP settings format.",
})
return undefined
}
// Validate against schema
const result = McpSettingsSchema.safeParse(config)
// Read project settings (returns {} if not found/invalid)
const projectServers = await this.readProjectMcpSettings()
// Merge: project overrides global
const mergedConfig = {
mcpServers: {
...(globalConfig.mcpServers || {}),
...projectServers,
},
}
// Validate merged config
const result = McpSettingsSchema.safeParse(mergedConfig)
if (!result.success) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
@@ -192,6 +251,79 @@ export class McpHub {
})
}
private async watchProjectMcpSettingsFile(): Promise<void> {
const projectPath = await this.getProjectMcpSettingsFilePath()
if (!projectPath) {
// No workspace open, skip project watcher
return
}
// Watch both the file and .cline directory (to detect file creation)
const clineDir = path.dirname(projectPath)
const watchPaths = [projectPath]
// Also watch directory if it exists
if (await fileExistsAtPath(clineDir)) {
watchPaths.push(clineDir)
}
this.projectSettingsWatcher = chokidar.watch(watchPaths, {
persistent: true,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 100,
pollInterval: 100,
},
atomic: true,
})
this.projectSettingsWatcher.on("change", async (path) => {
// Only react to changes to mcp_settings.json
if (path.endsWith("mcp_settings.json")) {
const settings = await this.readAndValidateMcpSettingsFile()
if (settings) {
try {
await this.updateServerConnections(settings.mcpServers)
} catch (error) {
console.error("Failed to process project MCP settings change:", error)
}
}
}
})
this.projectSettingsWatcher.on("add", async (path) => {
// Detect when mcp_settings.json is created
if (path.endsWith("mcp_settings.json")) {
const settings = await this.readAndValidateMcpSettingsFile()
if (settings) {
try {
await this.updateServerConnections(settings.mcpServers)
} catch (error) {
console.error("Failed to process new project MCP settings:", error)
}
}
}
})
this.projectSettingsWatcher.on("unlink", async (path) => {
// Detect when mcp_settings.json is deleted
if (path.endsWith("mcp_settings.json")) {
const settings = await this.readAndValidateMcpSettingsFile()
if (settings) {
try {
await this.updateServerConnections(settings.mcpServers)
} catch (error) {
console.error("Failed to process project MCP settings deletion:", error)
}
}
}
})
this.projectSettingsWatcher.on("error", (error) => {
console.error("Error watching project MCP settings file:", error)
})
}
private async initializeMcpServers(): Promise<void> {
const settings = await this.readAndValidateMcpSettingsFile()
if (settings) {
@@ -203,6 +335,28 @@ export class McpHub {
return this.connections.find((conn) => conn.server.name === name)
}
/**
* Resolves variables in the cwd path
* Currently supports: ${workspaceFolder}
*/
private async resolveCwd(cwd: string | undefined): Promise<string | undefined> {
if (!cwd) {
return undefined
}
// Replace ${workspaceFolder} with the actual workspace path
if (cwd.includes("${workspaceFolder}")) {
const workspacePath = await getCwd()
if (!workspacePath) {
console.warn("Cannot resolve ${workspaceFolder}: no workspace folder open")
return cwd
}
return cwd.replace(/\$\{workspaceFolder\}/g, workspacePath)
}
return cwd
}
private async connectToServer(
name: string,
config: z.infer<typeof ServerConfigSchema>,
@@ -244,10 +398,13 @@ export class McpHub {
switch (config.type) {
case "stdio": {
// Resolve cwd variables like ${workspaceFolder}
const resolvedCwd = await this.resolveCwd(config.cwd)
transport = new StdioClientTransport({
command: config.command,
args: config.args,
cwd: config.cwd,
cwd: resolvedCwd,
env: {
// ...(config.env ? await injectEnv(config.env) : {}), // Commented out as injectEnv is not found
...getDefaultEnvironment(),
@@ -1188,5 +1345,8 @@ export class McpHub {
if (this.settingsWatcher) {
await this.settingsWatcher.close()
}
if (this.projectSettingsWatcher) {
await this.projectSettingsWatcher.close()
}
}
}