Skip to content

Commit 4106ca4

Browse files
author
aheizi
committed
support project-level mcp config
1 parent de85cc5 commit 4106ca4

File tree

6 files changed

+293
-12
lines changed

6 files changed

+293
-12
lines changed

src/core/webview/ClineProvider.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,6 +1114,28 @@ export class ClineProvider implements vscode.WebviewViewProvider {
11141114
}
11151115
break
11161116
}
1117+
case "openProjectMcpSettings": {
1118+
if (!vscode.workspace.workspaceFolders?.length) {
1119+
vscode.window.showErrorMessage("Please open a project folder first")
1120+
return
1121+
}
1122+
1123+
const workspaceFolder = vscode.workspace.workspaceFolders[0]
1124+
const rooDir = path.join(workspaceFolder.uri.fsPath, ".roo")
1125+
const mcpPath = path.join(rooDir, "mcp.json")
1126+
1127+
try {
1128+
await fs.mkdir(rooDir, { recursive: true })
1129+
const exists = await fileExistsAtPath(mcpPath)
1130+
if (!exists) {
1131+
await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: {} }, null, 2))
1132+
}
1133+
await openFile(mcpPath)
1134+
} catch (error) {
1135+
vscode.window.showErrorMessage(`Failed to create or open .roo/mcp.json: ${error}`)
1136+
}
1137+
break
1138+
}
11171139
case "openCustomModesSettings": {
11181140
const customModesFilePath = await this.customModesManager.getCustomModesFilePath()
11191141
if (customModesFilePath) {

src/core/webview/__tests__/ClineProvider.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1950,6 +1950,130 @@ describe("ClineProvider", () => {
19501950
})
19511951
})
19521952

1953+
describe("Project MCP Settings", () => {
1954+
let provider: ClineProvider
1955+
let mockContext: vscode.ExtensionContext
1956+
let mockOutputChannel: vscode.OutputChannel
1957+
let mockWebviewView: vscode.WebviewView
1958+
let mockPostMessage: jest.Mock
1959+
1960+
beforeEach(() => {
1961+
jest.clearAllMocks()
1962+
1963+
mockContext = {
1964+
extensionPath: "/test/path",
1965+
extensionUri: {} as vscode.Uri,
1966+
globalState: {
1967+
get: jest.fn(),
1968+
update: jest.fn(),
1969+
keys: jest.fn().mockReturnValue([]),
1970+
},
1971+
secrets: {
1972+
get: jest.fn(),
1973+
store: jest.fn(),
1974+
delete: jest.fn(),
1975+
},
1976+
subscriptions: [],
1977+
extension: {
1978+
packageJSON: { version: "1.0.0" },
1979+
},
1980+
globalStorageUri: {
1981+
fsPath: "/test/storage/path",
1982+
},
1983+
} as unknown as vscode.ExtensionContext
1984+
1985+
mockOutputChannel = {
1986+
appendLine: jest.fn(),
1987+
clear: jest.fn(),
1988+
dispose: jest.fn(),
1989+
} as unknown as vscode.OutputChannel
1990+
1991+
mockPostMessage = jest.fn()
1992+
mockWebviewView = {
1993+
webview: {
1994+
postMessage: mockPostMessage,
1995+
html: "",
1996+
options: {},
1997+
onDidReceiveMessage: jest.fn(),
1998+
asWebviewUri: jest.fn(),
1999+
},
2000+
visible: true,
2001+
onDidDispose: jest.fn(),
2002+
onDidChangeVisibility: jest.fn(),
2003+
} as unknown as vscode.WebviewView
2004+
2005+
provider = new ClineProvider(mockContext, mockOutputChannel)
2006+
})
2007+
2008+
test("handles openProjectMcpSettings message", async () => {
2009+
await provider.resolveWebviewView(mockWebviewView)
2010+
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
2011+
2012+
// Mock workspace folders
2013+
;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }]
2014+
2015+
// Mock fs functions
2016+
const fs = require("fs/promises")
2017+
fs.mkdir.mockResolvedValue(undefined)
2018+
fs.writeFile.mockResolvedValue(undefined)
2019+
2020+
// Trigger openProjectMcpSettings
2021+
await messageHandler({
2022+
type: "openProjectMcpSettings",
2023+
})
2024+
2025+
// Verify directory was created
2026+
expect(fs.mkdir).toHaveBeenCalledWith(
2027+
expect.stringContaining(".roo"),
2028+
expect.objectContaining({ recursive: true }),
2029+
)
2030+
2031+
// Verify file was created with default content
2032+
expect(fs.writeFile).toHaveBeenCalledWith(
2033+
expect.stringContaining("mcp.json"),
2034+
JSON.stringify({ mcpServers: {} }, null, 2),
2035+
)
2036+
})
2037+
2038+
test("handles openProjectMcpSettings when workspace is not open", async () => {
2039+
await provider.resolveWebviewView(mockWebviewView)
2040+
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
2041+
2042+
// Mock no workspace folders
2043+
;(vscode.workspace as any).workspaceFolders = []
2044+
2045+
// Trigger openProjectMcpSettings
2046+
await messageHandler({
2047+
type: "openProjectMcpSettings",
2048+
})
2049+
2050+
// Verify error message was shown
2051+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Please open a project folder first")
2052+
})
2053+
2054+
test("handles openProjectMcpSettings file creation error", async () => {
2055+
await provider.resolveWebviewView(mockWebviewView)
2056+
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
2057+
2058+
// Mock workspace folders
2059+
;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }]
2060+
2061+
// Mock fs functions to fail
2062+
const fs = require("fs/promises")
2063+
fs.mkdir.mockRejectedValue(new Error("Failed to create directory"))
2064+
2065+
// Trigger openProjectMcpSettings
2066+
await messageHandler({
2067+
type: "openProjectMcpSettings",
2068+
})
2069+
2070+
// Verify error message was shown
2071+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
2072+
expect.stringContaining("Failed to create or open .roo/mcp.json"),
2073+
)
2074+
})
2075+
})
2076+
19532077
describe("ContextProxy integration", () => {
19542078
let provider: ClineProvider
19552079
let mockContext: vscode.ExtensionContext

src/services/mcp/McpHub.ts

Lines changed: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export class McpHub {
7373
private providerRef: WeakRef<ClineProvider>
7474
private disposables: vscode.Disposable[] = []
7575
private settingsWatcher?: vscode.FileSystemWatcher
76+
private projectMcpWatcher?: vscode.FileSystemWatcher
7677
private fileWatchers: Map<string, FSWatcher> = new Map()
7778
private isDisposed: boolean = false
7879
connections: McpConnection[] = []
@@ -81,9 +82,55 @@ export class McpHub {
8182
constructor(provider: ClineProvider) {
8283
this.providerRef = new WeakRef(provider)
8384
this.watchMcpSettingsFile()
85+
this.watchProjectMcpFile()
86+
this.setupWorkspaceFoldersWatcher()
8487
this.initializeMcpServers()
8588
}
8689

90+
private setupWorkspaceFoldersWatcher(): void {
91+
this.disposables.push(
92+
vscode.workspace.onDidChangeWorkspaceFolders(async () => {
93+
await this.updateProjectMcpServers()
94+
this.watchProjectMcpFile()
95+
}),
96+
)
97+
}
98+
99+
private watchProjectMcpFile(): void {
100+
this.projectMcpWatcher?.dispose()
101+
102+
this.projectMcpWatcher = vscode.workspace.createFileSystemWatcher("**/.roo/mcp.json", false, false, false)
103+
104+
this.disposables.push(
105+
this.projectMcpWatcher.onDidChange(async () => {
106+
await this.updateProjectMcpServers()
107+
}),
108+
this.projectMcpWatcher.onDidCreate(async () => {
109+
await this.updateProjectMcpServers()
110+
}),
111+
this.projectMcpWatcher.onDidDelete(async () => {
112+
await this.cleanupProjectMcpServers()
113+
}),
114+
)
115+
116+
this.disposables.push(this.projectMcpWatcher)
117+
}
118+
119+
private async updateProjectMcpServers(): Promise<void> {
120+
await this.cleanupProjectMcpServers()
121+
await this.initializeProjectMcpServers()
122+
}
123+
124+
private async cleanupProjectMcpServers(): Promise<void> {
125+
const projectServers = this.connections.filter((conn) => conn.server.source === "project")
126+
127+
for (const conn of projectServers) {
128+
await this.deleteConnection(conn.server.name)
129+
}
130+
131+
await this.notifyWebviewOfServerChanges()
132+
}
133+
87134
getServers(): McpServer[] {
88135
// Only return enabled servers
89136
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
@@ -158,16 +205,68 @@ export class McpHub {
158205

159206
private async initializeMcpServers(): Promise<void> {
160207
try {
208+
// 1. Initialize global MCP servers
161209
const settingsPath = await this.getMcpSettingsFilePath()
162210
const content = await fs.readFile(settingsPath, "utf-8")
163211
const config = JSON.parse(content)
164-
await this.updateServerConnections(config.mcpServers || {})
212+
await this.updateServerConnections(config.mcpServers || {}, "global")
213+
214+
// 2. Initialize project-level MCP servers
215+
await this.initializeProjectMcpServers()
165216
} catch (error) {
166217
console.error("Failed to initialize MCP servers:", error)
167218
}
168219
}
169220

170-
private async connectToServer(name: string, config: z.infer<typeof ServerConfigSchema>): Promise<void> {
221+
// Get project-level MCP configuration path
222+
private async getProjectMcpPath(): Promise<string | null> {
223+
if (!vscode.workspace.workspaceFolders?.length) {
224+
return null
225+
}
226+
227+
const workspaceFolder = vscode.workspace.workspaceFolders[0]
228+
const projectMcpDir = path.join(workspaceFolder.uri.fsPath, ".roo")
229+
const projectMcpPath = path.join(projectMcpDir, "mcp.json")
230+
231+
try {
232+
await fs.access(projectMcpPath)
233+
return projectMcpPath
234+
} catch {
235+
return null
236+
}
237+
}
238+
239+
// Initialize project-level MCP servers
240+
private async initializeProjectMcpServers(): Promise<void> {
241+
const projectMcpPath = await this.getProjectMcpPath()
242+
if (!projectMcpPath) {
243+
return
244+
}
245+
246+
try {
247+
const content = await fs.readFile(projectMcpPath, "utf-8")
248+
const config = JSON.parse(content)
249+
250+
// Validate configuration structure
251+
const result = McpSettingsSchema.safeParse(config)
252+
if (!result.success) {
253+
vscode.window.showErrorMessage("项目 MCP 配置格式无效")
254+
return
255+
}
256+
257+
// Update server connections
258+
await this.updateServerConnections(result.data.mcpServers || {}, "project")
259+
} catch (error) {
260+
console.error("Failed to initialize project MCP servers:", error)
261+
vscode.window.showErrorMessage(`初始化项目 MCP 服务器失败: ${error}`)
262+
}
263+
}
264+
265+
private async connectToServer(
266+
name: string,
267+
config: z.infer<typeof ServerConfigSchema>,
268+
source: "global" | "project" = "global",
269+
): Promise<void> {
171270
// Remove existing connection if it exists
172271
await this.deleteConnection(name)
173272

@@ -272,6 +371,8 @@ export class McpHub {
272371
config: JSON.stringify(config),
273372
status: "connecting",
274373
disabled: config.disabled,
374+
source,
375+
projectPath: source === "project" ? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath : undefined,
275376
},
276377
client,
277378
transport,
@@ -366,10 +467,17 @@ export class McpHub {
366467
}
367468
}
368469

369-
async updateServerConnections(newServers: Record<string, any>): Promise<void> {
470+
async updateServerConnections(
471+
newServers: Record<string, any>,
472+
source: "global" | "project" = "global",
473+
): Promise<void> {
370474
this.isConnecting = true
371475
this.removeAllFileWatchers()
372-
const currentNames = new Set(this.connections.map((conn) => conn.server.name))
476+
// Filter connections by source
477+
const currentConnections = this.connections.filter(
478+
(conn) => conn.server.source === source || (!conn.server.source && source === "global"),
479+
)
480+
const currentNames = new Set(currentConnections.map((conn) => conn.server.name))
373481
const newNames = new Set(Object.keys(newServers))
374482

375483
// Delete removed servers
@@ -388,7 +496,7 @@ export class McpHub {
388496
// New server
389497
try {
390498
this.setupFileWatcher(name, config)
391-
await this.connectToServer(name, config)
499+
await this.connectToServer(name, config, source)
392500
} catch (error) {
393501
console.error(`Failed to connect to new MCP server ${name}:`, error)
394502
}
@@ -397,8 +505,8 @@ export class McpHub {
397505
try {
398506
this.setupFileWatcher(name, config)
399507
await this.deleteConnection(name)
400-
await this.connectToServer(name, config)
401-
console.log(`Reconnected MCP server with updated config: ${name}`)
508+
await this.connectToServer(name, config, source)
509+
console.log(`Reconnected ${source} MCP server with updated config: ${name}`)
402510
} catch (error) {
403511
console.error(`Failed to reconnect MCP server ${name}:`, error)
404512
}

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export interface WebviewMessage {
5959
| "screenshotQuality"
6060
| "remoteBrowserHost"
6161
| "openMcpSettings"
62+
| "openProjectMcpSettings"
6263
| "restartMcpServer"
6364
| "toggleToolAlwaysAllow"
6465
| "toggleMcpServer"

src/shared/mcp.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export type McpServer = {
88
resourceTemplates?: McpResourceTemplate[]
99
disabled?: boolean
1010
timeout?: number
11+
source?: "global" | "project"
12+
projectPath?: string
1113
}
1214

1315
export type McpTool = {

0 commit comments

Comments
 (0)