mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
support project-level mcp config
This commit is contained in:
parent
de85cc558d
commit
4106ca4a19
6 changed files with 293 additions and 12 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 配置格式无效")
|
||||
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)
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export interface WebviewMessage {
|
|||
| "screenshotQuality"
|
||||
| "remoteBrowserHost"
|
||||
| "openMcpSettings"
|
||||
| "openProjectMcpSettings"
|
||||
| "restartMcpServer"
|
||||
| "toggleToolAlwaysAllow"
|
||||
| "toggleMcpServer"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export type McpServer = {
|
|||
resourceTemplates?: McpResourceTemplate[]
|
||||
disabled?: boolean
|
||||
timeout?: number
|
||||
source?: "global" | "project"
|
||||
projectPath?: string
|
||||
}
|
||||
|
||||
export type McpTool = {
|
||||
|
|
|
|||
|
|
@ -94,16 +94,25 @@ const McpView = ({ onDone }: McpViewProps) => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Settings Button */}
|
||||
<div style={{ marginTop: "10px", width: "100%" }}>
|
||||
{/* Edit Settings Buttons */}
|
||||
<div style={{ marginTop: "10px", width: "100%", display: "flex", gap: "10px" }}>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
style={{ width: "100%" }}
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => {
|
||||
vscode.postMessage({ type: "openMcpSettings" })
|
||||
}}>
|
||||
<span className="codicon codicon-edit" style={{ marginRight: "6px" }}></span>
|
||||
Edit MCP Settings
|
||||
Edit Global MCP
|
||||
</VSCodeButton>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => {
|
||||
vscode.postMessage({ type: "openProjectMcpSettings" })
|
||||
}}>
|
||||
<span className="codicon codicon-edit" style={{ marginRight: "6px" }}></span>
|
||||
Edit Project MCP
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -193,7 +202,22 @@ const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer; alwaysAllowM
|
|||
style={{ marginRight: "8px" }}
|
||||
/>
|
||||
)}
|
||||
<span style={{ flex: 1 }}>{server.name}</span>
|
||||
<span style={{ flex: 1 }}>
|
||||
{server.name}
|
||||
{server.source && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "8px",
|
||||
padding: "1px 6px",
|
||||
fontSize: "11px",
|
||||
borderRadius: "4px",
|
||||
background: "var(--vscode-badge-background)",
|
||||
color: "var(--vscode-badge-foreground)",
|
||||
}}>
|
||||
{server.source}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", marginRight: "8px" }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue