Skip to content
Merged
22 changes: 22 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,28 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
break
}
case "openProjectMcpSettings": {
if (!vscode.workspace.workspaceFolders?.length) {
vscode.window.showErrorMessage("Please open a project folder first")
return
}

const workspaceFolder = vscode.workspace.workspaceFolders[0]
const rooDir = path.join(workspaceFolder.uri.fsPath, ".roo")
const mcpPath = path.join(rooDir, "mcp.json")

try {
await fs.mkdir(rooDir, { recursive: true })
const exists = await fileExistsAtPath(mcpPath)
if (!exists) {
await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: {} }, null, 2))
}
await openFile(mcpPath)
} catch (error) {
vscode.window.showErrorMessage(`Failed to create or open .roo/mcp.json: ${error}`)
}
break
}
case "openCustomModesSettings": {
const customModesFilePath = await this.customModesManager.getCustomModesFilePath()
if (customModesFilePath) {
Expand Down
124 changes: 124 additions & 0 deletions src/core/webview/__tests__/ClineProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1950,6 +1950,130 @@ describe("ClineProvider", () => {
})
})

describe("Project MCP Settings", () => {
let provider: ClineProvider
let mockContext: vscode.ExtensionContext
let mockOutputChannel: vscode.OutputChannel
let mockWebviewView: vscode.WebviewView
let mockPostMessage: jest.Mock

beforeEach(() => {
jest.clearAllMocks()

mockContext = {
extensionPath: "/test/path",
extensionUri: {} as vscode.Uri,
globalState: {
get: jest.fn(),
update: jest.fn(),
keys: jest.fn().mockReturnValue([]),
},
secrets: {
get: jest.fn(),
store: jest.fn(),
delete: jest.fn(),
},
subscriptions: [],
extension: {
packageJSON: { version: "1.0.0" },
},
globalStorageUri: {
fsPath: "/test/storage/path",
},
} as unknown as vscode.ExtensionContext

mockOutputChannel = {
appendLine: jest.fn(),
clear: jest.fn(),
dispose: jest.fn(),
} as unknown as vscode.OutputChannel

mockPostMessage = jest.fn()
mockWebviewView = {
webview: {
postMessage: mockPostMessage,
html: "",
options: {},
onDidReceiveMessage: jest.fn(),
asWebviewUri: jest.fn(),
},
visible: true,
onDidDispose: jest.fn(),
onDidChangeVisibility: jest.fn(),
} as unknown as vscode.WebviewView

provider = new ClineProvider(mockContext, mockOutputChannel)
})

test("handles openProjectMcpSettings message", async () => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]

// Mock workspace folders
;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }]

// Mock fs functions
const fs = require("fs/promises")
fs.mkdir.mockResolvedValue(undefined)
fs.writeFile.mockResolvedValue(undefined)

// Trigger openProjectMcpSettings
await messageHandler({
type: "openProjectMcpSettings",
})

// Verify directory was created
expect(fs.mkdir).toHaveBeenCalledWith(
expect.stringContaining(".roo"),
expect.objectContaining({ recursive: true }),
)

// Verify file was created with default content
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringContaining("mcp.json"),
JSON.stringify({ mcpServers: {} }, null, 2),
)
})

test("handles openProjectMcpSettings when workspace is not open", async () => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]

// Mock no workspace folders
;(vscode.workspace as any).workspaceFolders = []

// Trigger openProjectMcpSettings
await messageHandler({
type: "openProjectMcpSettings",
})

// Verify error message was shown
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Please open a project folder first")
})

test("handles openProjectMcpSettings file creation error", async () => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]

// Mock workspace folders
;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }]

// Mock fs functions to fail
const fs = require("fs/promises")
fs.mkdir.mockRejectedValue(new Error("Failed to create directory"))

// Trigger openProjectMcpSettings
await messageHandler({
type: "openProjectMcpSettings",
})

// Verify error message was shown
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
expect.stringContaining("Failed to create or open .roo/mcp.json"),
)
})
})

describe("ContextProxy integration", () => {
let provider: ClineProvider
let mockContext: vscode.ExtensionContext
Expand Down
122 changes: 115 additions & 7 deletions src/services/mcp/McpHub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export class McpHub {
private providerRef: WeakRef<ClineProvider>
private disposables: vscode.Disposable[] = []
private settingsWatcher?: vscode.FileSystemWatcher
private projectMcpWatcher?: vscode.FileSystemWatcher
private fileWatchers: Map<string, FSWatcher> = new Map()
private isDisposed: boolean = false
connections: McpConnection[] = []
Expand All @@ -81,9 +82,55 @@ export class McpHub {
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
this.watchMcpSettingsFile()
this.watchProjectMcpFile()
this.setupWorkspaceFoldersWatcher()
this.initializeMcpServers()
}

private setupWorkspaceFoldersWatcher(): void {
this.disposables.push(
vscode.workspace.onDidChangeWorkspaceFolders(async () => {
await this.updateProjectMcpServers()
this.watchProjectMcpFile()
}),
)
}

private watchProjectMcpFile(): void {
this.projectMcpWatcher?.dispose()

this.projectMcpWatcher = vscode.workspace.createFileSystemWatcher("**/.roo/mcp.json", false, false, false)

this.disposables.push(
this.projectMcpWatcher.onDidChange(async () => {
await this.updateProjectMcpServers()
}),
this.projectMcpWatcher.onDidCreate(async () => {
await this.updateProjectMcpServers()
}),
this.projectMcpWatcher.onDidDelete(async () => {
await this.cleanupProjectMcpServers()
}),
)

this.disposables.push(this.projectMcpWatcher)
}

private async updateProjectMcpServers(): Promise<void> {
await this.cleanupProjectMcpServers()
await this.initializeProjectMcpServers()
}

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)
}

await this.notifyWebviewOfServerChanges()
}

getServers(): McpServer[] {
// Only return enabled servers
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
Expand Down Expand Up @@ -158,16 +205,68 @@ export class McpHub {

private async initializeMcpServers(): Promise<void> {
try {
// 1. Initialize global MCP servers
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
await this.updateServerConnections(config.mcpServers || {})
await this.updateServerConnections(config.mcpServers || {}, "global")

// 2. Initialize project-level MCP servers
await this.initializeProjectMcpServers()
} catch (error) {
console.error("Failed to initialize MCP servers:", error)
}
}

private async connectToServer(name: string, config: z.infer<typeof ServerConfigSchema>): Promise<void> {
// 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> {
const projectMcpPath = await this.getProjectMcpPath()
if (!projectMcpPath) {
return
}

try {
const content = await fs.readFile(projectMcpPath, "utf-8")
const config = JSON.parse(content)

// Validate configuration structure
const result = McpSettingsSchema.safeParse(config)
if (!result.success) {
vscode.window.showErrorMessage("项目 MCP 配置格式无效")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mind if we make these English for now and then we can internationalize them soon?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mind if we make these English for now and then we can internationalize them soon?

Of course, I have already seen the international submission, that's really a great thing!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! I want to talk to you about that separately - will send a message in Discord.

return
}

// Update server connections
await this.updateServerConnections(result.data.mcpServers || {}, "project")
} catch (error) {
console.error("Failed to initialize project MCP servers:", error)
vscode.window.showErrorMessage(`初始化项目 MCP 服务器失败: ${error}`)
}
}

private async connectToServer(
name: string,
config: z.infer<typeof ServerConfigSchema>,
source: "global" | "project" = "global",
): Promise<void> {
// Remove existing connection if it exists
await this.deleteConnection(name)

Expand Down Expand Up @@ -272,6 +371,8 @@ export class McpHub {
config: JSON.stringify(config),
status: "connecting",
disabled: config.disabled,
source,
projectPath: source === "project" ? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath : undefined,
},
client,
transport,
Expand Down Expand Up @@ -366,10 +467,17 @@ export class McpHub {
}
}

async updateServerConnections(newServers: Record<string, any>): Promise<void> {
async updateServerConnections(
newServers: Record<string, any>,
source: "global" | "project" = "global",
): Promise<void> {
this.isConnecting = true
this.removeAllFileWatchers()
const currentNames = new Set(this.connections.map((conn) => conn.server.name))
// 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
Expand All @@ -388,7 +496,7 @@ export class McpHub {
// New server
try {
this.setupFileWatcher(name, config)
await this.connectToServer(name, config)
await this.connectToServer(name, config, source)
} catch (error) {
console.error(`Failed to connect to new MCP server ${name}:`, error)
}
Expand All @@ -397,8 +505,8 @@ export class McpHub {
try {
this.setupFileWatcher(name, config)
await this.deleteConnection(name)
await this.connectToServer(name, config)
console.log(`Reconnected MCP server with updated config: ${name}`)
await this.connectToServer(name, config, source)
console.log(`Reconnected ${source} MCP server with updated config: ${name}`)
} catch (error) {
console.error(`Failed to reconnect MCP server ${name}:`, error)
}
Expand Down
1 change: 1 addition & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface WebviewMessage {
| "screenshotQuality"
| "remoteBrowserHost"
| "openMcpSettings"
| "openProjectMcpSettings"
| "restartMcpServer"
| "toggleToolAlwaysAllow"
| "toggleMcpServer"
Expand Down
2 changes: 2 additions & 0 deletions src/shared/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export type McpServer = {
resourceTemplates?: McpResourceTemplate[]
disabled?: boolean
timeout?: number
source?: "global" | "project"
projectPath?: string
}

export type McpTool = {
Expand Down
Loading
Loading