From c2cc9643e1fde02bb6f912dc6a03bb90f7a0aa2d Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 28 Sep 2025 18:55:29 +0000 Subject: [PATCH] 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 --- .tmp/review/Roo-Code | 1 + .work/reviews/Roo-Code | 1 + packages/types/src/api.ts | 167 +++--------------- packages/types/src/vscode.ts | 1 + .../__tests__/registerCommands.test.ts | 133 ++++++++++++++ src/activate/registerCommands.ts | 15 ++ src/extension/__tests__/api.test.ts | 123 +++++++++++++ src/extension/api.ts | 18 +- src/package.json | 5 + 9 files changed, 324 insertions(+), 140 deletions(-) create mode 160000 .tmp/review/Roo-Code create mode 160000 .work/reviews/Roo-Code create mode 100644 src/activate/__tests__/registerCommands.test.ts create mode 100644 src/extension/__tests__/api.test.ts diff --git a/.tmp/review/Roo-Code b/.tmp/review/Roo-Code new file mode 160000 index 0000000000..8dbd8c4b1b --- /dev/null +++ b/.tmp/review/Roo-Code @@ -0,0 +1 @@ +Subproject commit 8dbd8c4b1b72fb48be3990a8e78285a787a1828c diff --git a/.work/reviews/Roo-Code b/.work/reviews/Roo-Code new file mode 160000 index 0000000000..ea8420be8c --- /dev/null +++ b/.work/reviews/Roo-Code @@ -0,0 +1 @@ +Subproject commit ea8420be8c5386d867fe6aa7b1f9756a44a3b5b1 diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index e61e1e6106..440d1a4e30 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -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 { +/** + * 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 - /** - * 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 - /** - * 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 - /** - * Returns the current task stack. - * @returns An array of task IDs. - */ - getCurrentTaskStack(): string[] - /** - * Clears the current task. - */ - clearCurrentTask(lastMessage?: string): Promise - /** - * Cancels the current task. - */ - cancelCurrentTask(): Promise - /** - * 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 - /** - * Simulates pressing the primary button in the chat interface. - */ - pressPrimaryButton(): Promise - /** - * Simulates pressing the secondary button in the chat interface. - */ - pressSecondaryButton(): Promise - /** - * 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 - /** - * 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 - /** - * 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 - /** - * 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 - /** - * 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 - /** - * 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 + refreshMcpServers(): Promise } -export interface RooCodeIpcServer extends EventEmitter { - 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 } diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index d22ebdab22..5fc6daa962 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -54,6 +54,7 @@ export const commandIds = [ "acceptInput", "focusPanel", "toggleAutoApprove", + "refreshMcpServers", ] as const export type CommandId = (typeof commandIds)[number] diff --git a/src/activate/__tests__/registerCommands.test.ts b/src/activate/__tests__/registerCommands.test.ts new file mode 100644 index 0000000000..a5227d366c --- /dev/null +++ b/src/activate/__tests__/registerCommands.test.ts @@ -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 + 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 + 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 + 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() + }) + }) +}) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 41c127333d..983fff021a 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -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) => { diff --git a/src/extension/__tests__/api.test.ts b/src/extension/__tests__/api.test.ts new file mode 100644 index 0000000000..a603c34929 --- /dev/null +++ b/src/extension/__tests__/api.test.ts @@ -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") + }) + }) +}) diff --git a/src/extension/api.ts b/src/extension/api.ts index 1820ddee6c..5601a9453a 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -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 implements RooCodeAPI { +export class API extends EventEmitter 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 implements RooCodeAPI { } }) } + + // Initialize the global MCP API + setMcpApi(this) + } + + // McpApi implementation + public async refreshMcpServers(): Promise { + const mcpHub = this.sidebarProvider.getMcpHub() + if (mcpHub) { + await mcpHub.refreshAllConnections() + } else { + throw new Error("MCP hub is not available") + } } public override emit( diff --git a/src/package.json b/src/package.json index 8ed4d4e47a..878201f157 100644 --- a/src/package.json +++ b/src/package.json @@ -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": {