feat: add programmatic MCP server refresh capability

- Add refreshMcpServers command to VS Code command palette
- Expose MCP refresh functionality through API for extensions
- Add McpApi interface to @roo-code/types package
- Initialize global MCP API instance on extension activation
- Add tests for new functionality

Fixes #8356
This commit is contained in:
Roo Code 2025-09-28 18:55:29 +00:00
parent a57528dc8e
commit c2cc9643e1
9 changed files with 324 additions and 140 deletions

1
.tmp/review/Roo-Code Submodule

@ -0,0 +1 @@
Subproject commit 8dbd8c4b1b72fb48be3990a8e78285a787a1828c

@ -0,0 +1 @@
Subproject commit ea8420be8c5386d867fe6aa7b1f9756a44a3b5b1

View file

@ -1,144 +1,35 @@
import type { EventEmitter } from "events"
import type { Socket } from "net"
/**
* API for programmatic MCP server operations
*/
import type { RooCodeEvents } from "./events.js"
import type { RooCodeSettings } from "./global-settings.js"
import type { ProviderSettingsEntry, ProviderSettings } from "./provider-settings.js"
import type { IpcMessage, IpcServerEvents } from "./ipc.js"
export type RooCodeAPIEvents = RooCodeEvents
export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
/**
* Interface for MCP operations that can be accessed programmatically
*/
export interface McpApi {
/**
* Starts a new task with an optional initial message and images.
* @param task Optional initial task message.
* @param images Optional array of image data URIs (e.g., "data:image/webp;base64,...").
* @returns The ID of the new task.
* Refresh all MCP server connections
* @returns Promise that resolves when refresh is complete
*/
startNewTask({
configuration,
text,
images,
newTab,
}: {
configuration?: RooCodeSettings
text?: string
images?: string[]
newTab?: boolean
}): Promise<string>
/**
* Resumes a task with the given ID.
* @param taskId The ID of the task to resume.
* @throws Error if the task is not found in the task history.
*/
resumeTask(taskId: string): Promise<void>
/**
* Checks if a task with the given ID is in the task history.
* @param taskId The ID of the task to check.
* @returns True if the task is in the task history, false otherwise.
*/
isTaskInHistory(taskId: string): Promise<boolean>
/**
* Returns the current task stack.
* @returns An array of task IDs.
*/
getCurrentTaskStack(): string[]
/**
* Clears the current task.
*/
clearCurrentTask(lastMessage?: string): Promise<void>
/**
* Cancels the current task.
*/
cancelCurrentTask(): Promise<void>
/**
* Sends a message to the current task.
* @param message Optional message to send.
* @param images Optional array of image data URIs (e.g., "data:image/webp;base64,...").
*/
sendMessage(message?: string, images?: string[]): Promise<void>
/**
* Simulates pressing the primary button in the chat interface.
*/
pressPrimaryButton(): Promise<void>
/**
* Simulates pressing the secondary button in the chat interface.
*/
pressSecondaryButton(): Promise<void>
/**
* Returns true if the API is ready to use.
*/
isReady(): boolean
/**
* Returns the current configuration.
* @returns The current configuration.
*/
getConfiguration(): RooCodeSettings
/**
* Sets the configuration for the current task.
* @param values An object containing key-value pairs to set.
*/
setConfiguration(values: RooCodeSettings): Promise<void>
/**
* Returns a list of all configured profile names
* @returns Array of profile names
*/
getProfiles(): string[]
/**
* Returns the profile entry for a given name
* @param name The name of the profile
* @returns The profile entry, or undefined if the profile does not exist
*/
getProfileEntry(name: string): ProviderSettingsEntry | undefined
/**
* Creates a new API configuration profile
* @param name The name of the profile
* @param profile The profile to create; defaults to an empty object
* @param activate Whether to activate the profile after creation; defaults to true
* @returns The ID of the created profile
* @throws Error if the profile already exists
*/
createProfile(name: string, profile?: ProviderSettings, activate?: boolean): Promise<string>
/**
* Updates an existing API configuration profile
* @param name The name of the profile
* @param profile The profile to update
* @param activate Whether to activate the profile after update; defaults to true
* @returns The ID of the updated profile
* @throws Error if the profile does not exist
*/
updateProfile(name: string, profile: ProviderSettings, activate?: boolean): Promise<string | undefined>
/**
* Creates a new API configuration profile or updates an existing one
* @param name The name of the profile
* @param profile The profile to create or update; defaults to an empty object
* @param activate Whether to activate the profile after upsert; defaults to true
* @returns The ID of the upserted profile
*/
upsertProfile(name: string, profile: ProviderSettings, activate?: boolean): Promise<string | undefined>
/**
* Deletes a profile by name
* @param name The name of the profile to delete
* @throws Error if the profile does not exist
*/
deleteProfile(name: string): Promise<void>
/**
* Returns the name of the currently active profile
* @returns The profile name, or undefined if no profile is active
*/
getActiveProfile(): string | undefined
/**
* Changes the active API configuration profile
* @param name The name of the profile to activate
* @throws Error if the profile does not exist
*/
setActiveProfile(name: string): Promise<string | undefined>
refreshMcpServers(): Promise<void>
}
export interface RooCodeIpcServer extends EventEmitter<IpcServerEvents> {
listen(): void
broadcast(message: IpcMessage): void
send(client: string | Socket, message: IpcMessage): void
get socketPath(): string
get isListening(): boolean
/**
* Global MCP API instance that will be set by the extension
*/
export let mcpApi: McpApi | undefined
/**
* Set the global MCP API instance
* @param api The MCP API implementation
*/
export function setMcpApi(api: McpApi): void {
mcpApi = api
}
/**
* Get the global MCP API instance
* @returns The MCP API instance or undefined if not set
*/
export function getMcpApi(): McpApi | undefined {
return mcpApi
}

View file

@ -54,6 +54,7 @@ export const commandIds = [
"acceptInput",
"focusPanel",
"toggleAutoApprove",
"refreshMcpServers",
] as const
export type CommandId = (typeof commandIds)[number]

View file

@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import * as vscode from "vscode"
import { registerCommands } from "../registerCommands"
import { ClineProvider } from "../../core/webview/ClineProvider"
// Mock vscode module
vi.mock("vscode", () => ({
window: {
showInformationMessage: vi.fn(),
showWarningMessage: vi.fn(),
},
commands: {
registerCommand: vi.fn(),
},
}))
describe("registerCommands", () => {
let mockContext: vscode.ExtensionContext
let mockOutputChannel: vscode.OutputChannel
let mockProvider: ClineProvider
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks()
// Create mock objects
mockContext = {
subscriptions: {
push: vi.fn(),
},
} as any
mockOutputChannel = {
appendLine: vi.fn(),
} as any
mockProvider = {
getMcpHub: vi.fn(),
} as any
})
describe("refreshMcpServers command", () => {
it("should refresh MCP servers when hub is available", async () => {
// Arrange
const mockMcpHub = {
refreshAllConnections: vi.fn().mockResolvedValue(undefined),
}
mockProvider.getMcpHub = vi.fn().mockReturnValue(mockMcpHub)
// Mock getVisibleProviderOrLog to return our mock provider
const getVisibleProviderOrLog = vi.fn().mockReturnValue(mockProvider)
// Register commands
const commands = registerCommands({
context: mockContext,
outputChannel: mockOutputChannel,
provider: mockProvider,
})
// Find and execute the refreshMcpServers command
const registerCommandCalls = vi.mocked(vscode.commands.registerCommand).mock.calls
const refreshCommand = registerCommandCalls.find(([cmd]) => cmd === "roo-cline.refreshMcpServers")
expect(refreshCommand).toBeDefined()
// Execute the command callback
const commandCallback = refreshCommand![1] as () => Promise<void>
await commandCallback()
// Assert
expect(mockProvider.getMcpHub).toHaveBeenCalled()
expect(mockMcpHub.refreshAllConnections).toHaveBeenCalled()
expect(vscode.window.showInformationMessage).toHaveBeenCalledWith("MCP servers refreshed successfully")
})
it("should show warning when MCP hub is not available", async () => {
// Arrange
mockProvider.getMcpHub = vi.fn().mockReturnValue(undefined)
// Mock getVisibleProviderOrLog to return our mock provider
const getVisibleProviderOrLog = vi.fn().mockReturnValue(mockProvider)
// Register commands
const commands = registerCommands({
context: mockContext,
outputChannel: mockOutputChannel,
provider: mockProvider,
})
// Find and execute the refreshMcpServers command
const registerCommandCalls = vi.mocked(vscode.commands.registerCommand).mock.calls
const refreshCommand = registerCommandCalls.find(([cmd]) => cmd === "roo-cline.refreshMcpServers")
expect(refreshCommand).toBeDefined()
// Execute the command callback
const commandCallback = refreshCommand![1] as () => Promise<void>
await commandCallback()
// Assert
expect(mockProvider.getMcpHub).toHaveBeenCalled()
expect(vscode.window.showWarningMessage).toHaveBeenCalledWith("MCP hub is not available")
})
it("should not execute when no visible provider is found", async () => {
// Arrange
// Mock getVisibleProviderOrLog to return undefined
const getVisibleProviderOrLog = vi.fn().mockReturnValue(undefined)
// Register commands
const commands = registerCommands({
context: mockContext,
outputChannel: mockOutputChannel,
provider: mockProvider,
})
// Find and execute the refreshMcpServers command
const registerCommandCalls = vi.mocked(vscode.commands.registerCommand).mock.calls
const refreshCommand = registerCommandCalls.find(([cmd]) => cmd === "roo-cline.refreshMcpServers")
expect(refreshCommand).toBeDefined()
// Execute the command callback
const commandCallback = refreshCommand![1] as () => Promise<void>
await commandCallback()
// Assert - should return early without calling getMcpHub
expect(mockProvider.getMcpHub).not.toHaveBeenCalled()
expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
expect(vscode.window.showWarningMessage).not.toHaveBeenCalled()
})
})
})

View file

@ -233,6 +233,21 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
action: "toggleAutoApprove",
})
},
refreshMcpServers: async () => {
const visibleProvider = getVisibleProviderOrLog(outputChannel)
if (!visibleProvider) {
return
}
const mcpHub = visibleProvider.getMcpHub()
if (mcpHub) {
await mcpHub.refreshAllConnections()
vscode.window.showInformationMessage("MCP servers refreshed successfully")
} else {
vscode.window.showWarningMessage("MCP hub is not available")
}
},
})
export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {

View file

@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import * as vscode from "vscode"
import { API } from "../api"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { setMcpApi, getMcpApi } from "@roo-code/types"
// Mock the types module with partial mocking
vi.mock("@roo-code/types", async (importOriginal) => {
const actual = (await importOriginal()) as any
return {
...actual,
setMcpApi: vi.fn(),
getMcpApi: vi.fn(),
}
})
// Mock vscode module
vi.mock("vscode", () => ({
OutputChannel: vi.fn(),
ExtensionContext: vi.fn(),
}))
describe("API", () => {
let mockOutputChannel: vscode.OutputChannel
let mockProvider: ClineProvider
let api: API
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks()
// Create mock objects
mockOutputChannel = {
appendLine: vi.fn(),
} as any
mockProvider = {
context: {
subscriptions: [],
} as any,
getMcpHub: vi.fn(),
on: vi.fn(),
viewLaunched: true,
getCurrentTaskStack: vi.fn(),
finishSubTask: vi.fn(),
postStateToWebview: vi.fn(),
cancelTask: vi.fn(),
postMessageToWebview: vi.fn(),
removeClineFromStack: vi.fn(),
createTask: vi.fn(),
getTaskWithId: vi.fn(),
createTaskWithHistoryItem: vi.fn(),
getValues: vi.fn().mockReturnValue({}),
contextProxy: {
setValues: vi.fn(),
},
providerSettingsManager: {
saveConfig: vi.fn(),
},
getProviderProfileEntries: vi.fn().mockReturnValue([]),
getProviderProfileEntry: vi.fn(),
upsertProviderProfile: vi.fn(),
deleteProviderProfile: vi.fn(),
activateProviderProfile: vi.fn(),
} as any
// Create API instance
api = new API(mockOutputChannel, mockProvider)
})
describe("refreshMcpServers", () => {
it("should call refreshAllConnections on the MCP hub", async () => {
// Arrange
const mockMcpHub = {
refreshAllConnections: vi.fn().mockResolvedValue(undefined),
}
mockProvider.getMcpHub = vi.fn().mockReturnValue(mockMcpHub)
// Act
await api.refreshMcpServers()
// Assert
expect(mockProvider.getMcpHub).toHaveBeenCalled()
expect(mockMcpHub.refreshAllConnections).toHaveBeenCalled()
})
it("should throw an error when MCP hub is not available", async () => {
// Arrange
mockProvider.getMcpHub = vi.fn().mockReturnValue(undefined)
// Act & Assert
await expect(api.refreshMcpServers()).rejects.toThrow("MCP hub is not available")
expect(mockProvider.getMcpHub).toHaveBeenCalled()
})
it("should propagate errors from refreshAllConnections", async () => {
// Arrange
const testError = new Error("Connection failed")
const mockMcpHub = {
refreshAllConnections: vi.fn().mockRejectedValue(testError),
}
mockProvider.getMcpHub = vi.fn().mockReturnValue(mockMcpHub)
// Act & Assert
await expect(api.refreshMcpServers()).rejects.toThrow("Connection failed")
expect(mockProvider.getMcpHub).toHaveBeenCalled()
expect(mockMcpHub.refreshAllConnections).toHaveBeenCalled()
})
})
describe("API initialization", () => {
it("should register the API with setMcpApi", () => {
// Assert
expect(setMcpApi).toHaveBeenCalledWith(api)
})
it("should implement the McpApi interface", () => {
// Assert
expect(api.refreshMcpServers).toBeDefined()
expect(typeof api.refreshMcpServers).toBe("function")
})
})
})

View file

@ -6,18 +6,19 @@ import * as os from "os"
import * as vscode from "vscode"
import {
type RooCodeAPI,
type RooCodeSettings,
type RooCodeEvents,
type ProviderSettings,
type ProviderSettingsEntry,
type TaskEvent,
type CreateTaskOptions,
type McpApi,
RooCodeEventName,
TaskCommandName,
isSecretStateKey,
IpcOrigin,
IpcMessageType,
setMcpApi,
} from "@roo-code/types"
import { IpcServer } from "@roo-code/ipc"
@ -25,7 +26,7 @@ import { Package } from "../shared/package"
import { ClineProvider } from "../core/webview/ClineProvider"
import { openClineInNewTab } from "../activate/registerCommands"
export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
export class API extends EventEmitter<RooCodeEvents> implements McpApi {
private readonly outputChannel: vscode.OutputChannel
private readonly sidebarProvider: ClineProvider
private readonly context: vscode.ExtensionContext
@ -94,6 +95,19 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
}
})
}
// Initialize the global MCP API
setMcpApi(this)
}
// McpApi implementation
public async refreshMcpServers(): Promise<void> {
const mcpHub = this.sidebarProvider.getMcpHub()
if (mcpHub) {
await mcpHub.refreshAllConnections()
} else {
throw new Error("MCP hub is not available")
}
}
public override emit<K extends keyof RooCodeEvents>(

View file

@ -179,6 +179,11 @@
"command": "roo-cline.toggleAutoApprove",
"title": "%command.toggleAutoApprove.title%",
"category": "%configuration.title%"
},
{
"command": "roo-cline.refreshMcpServers",
"title": "Refresh MCP Servers",
"category": "%configuration.title%"
}
],
"menus": {