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..6abfcc4eea 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -1,144 +1,80 @@ -import type { EventEmitter } from "events" -import type { Socket } from "net" +/** + * API for programmatic MCP server operations + */ +import type { EventEmitter } from "events" 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" +import type { RooCodeSettings, ProviderSettings, ProviderSettingsEntry } from "./index.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, - }: { + refreshMcpServers(): Promise +} + +/** + * Main API interface for Roo Code extension + */ +export interface RooCodeAPI extends EventEmitter, McpApi { + // Task Management + startNewTask(options: { 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. - */ + getCurrentTaskStack(): unknown 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. - */ + cancelTask(taskId: string): Promise + + // Messaging + sendMessage(text?: string, images?: string[]): Promise pressPrimaryButton(): Promise - /** - * Simulates pressing the secondary button in the chat interface. - */ pressSecondaryButton(): Promise - /** - * Returns true if the API is ready to use. - */ + + // State isReady(): boolean - /** - * Returns the current configuration. - * @returns The current configuration. - */ + + // 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 - */ + + // Profile Management 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 } -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/ipc.ts b/packages/types/src/ipc.ts index ace39c3f2b..28386c857a 100644 --- a/packages/types/src/ipc.ts +++ b/packages/types/src/ipc.ts @@ -125,3 +125,15 @@ export type IpcServerEvents = { [IpcMessageType.TaskCommand]: [clientId: string, data: TaskCommand] [IpcMessageType.TaskEvent]: [relayClientId: string | undefined, data: TaskEvent] } + +/** + * RooCodeIpcServer + */ + +export interface RooCodeIpcServer { + socketPath: string + isListening: boolean + listen(): void + broadcast(message: IpcMessage): void + send(client: string | unknown, message: IpcMessage): void +} 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..1b7466f138 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -6,18 +6,20 @@ import * as os from "os" import * as vscode from "vscode" import { - type RooCodeAPI, type RooCodeSettings, type RooCodeEvents, + type RooCodeAPI, 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" @@ -94,6 +96,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( @@ -111,7 +126,7 @@ export class API extends EventEmitter implements RooCodeAPI { images, newTab, }: { - configuration: RooCodeSettings + configuration?: RooCodeSettings text?: string images?: string[] newTab?: boolean @@ -139,7 +154,13 @@ export class API extends EventEmitter implements RooCodeAPI { consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER, } - const task = await provider.createTask(text, images, undefined, options, configuration) + const task = await provider.createTask( + text, + images, + undefined, + options, + configuration || this.getConfiguration(), + ) if (!task) { throw new Error("Failed to create task due to policy restrictions") 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": {