Merge pull request #8142 from Kilo-Org/mark/reimplement-mcp-removal

feat(vscode): reimplement MCP removal in agent behaviour settings
This commit is contained in:
Mark IJbema
2026-04-08 13:52:30 +02:00
committed by GitHub
+89 -53
View File
@@ -35,7 +35,7 @@ import {
import { GitOps } from "./agent-manager/GitOps"
import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller"
import { getWorkspaceRoot } from "./review-utils"
import { MarketplaceService } from "./services/marketplace"
import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./services/marketplace"
import { resolveProjectDirectory } from "./project-directory"
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
import { retry } from "./services/cli-backend/retry"
@@ -970,12 +970,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
break
}
case "removeInstalledMarketplaceItem": {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const scope = message.mpInstallOptions?.target ?? "project"
const result = await this.getMarketplace().remove(message.mpItem, scope, workspace)
if (result.success) {
await this.invalidateAfterMarketplaceChange(scope)
}
const result = await this.removeMarketplaceItem(message.mpItem, scope)
this.postMessage({
type: "marketplaceRemoveResult",
success: result.success,
@@ -1747,59 +1743,87 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
*/
private async handleRemoveMode(name: string): Promise<void> {
if (!this.client) return
let removed = false
// 1. Try CLI removal (handles .md files and legacy .kilocodemodes)
try {
const dir = this.getWorkspaceDirectory()
const result = await this.client.kilocode.removeAgent({ name, directory: dir })
if (!result.error) removed = true
if (!result.error) {
this.cachedAgentsMessage = null
await this.fetchAndSendAgents()
return
}
} catch {
// CLI removal failed — agent may be in kilo.json instead
}
// 2. Try removing from kilo.json (handles marketplace-installed modes)
if (!removed) {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const mp = this.getMarketplace()
const stub = { id: name, type: "mode" as const, name, description: "", content: "" }
const project = await mp.remove(stub, "project", workspace)
const global = await mp.remove(stub, "global", workspace)
if (project.success || global.success) {
await this.disposeCliInstance("global")
removed = true
}
}
const stub = { id: name, type: "mode" as const, name, description: "", content: "" }
const removed = await this.removeMarketplaceItemFromAllScopes(stub)
if (!removed) {
console.error("[Kilo New] KiloProvider: Failed to remove mode:", name)
}
this.cachedAgentsMessage = null
await this.fetchAndSendAgents()
}
private async handleRemoveMcp(name: string): Promise<void> {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const mp = this.getMarketplace()
// Remove from legacy files first so that the subsequent invalidation
// causes the CLI to re-read config without the legacy entry.
await this.removeLegacyMcp(name)
const stub = { id: name, type: "mcp" as const, name, description: "", url: "", content: "" }
// Remove from both scopes — an MCP could exist in project, global, or both
const project = await mp.remove(stub, "project", workspace)
const global = await mp.remove(stub, "global", workspace)
if (project.success || global.success) {
// Use global scope when removed from global (or both) so the global
// config cache is also invalidated; project scope is a subset.
const scope = global.success ? "global" : "project"
await this.disposeCliInstance(scope)
this.cachedConfigMessage = null
await this.fetchAndSendConfig()
} else {
const removed = await this.removeMarketplaceItemFromAllScopes(stub)
if (!removed) {
console.error("[Kilo New] KiloProvider: Failed to remove MCP server:", name)
}
}
/**
* Remove an MCP server from legacy config files (.kilo/mcp.json, .kilocode/mcp.json,
* and the VS Code global storage mcp_settings.json). These files are read by the
* CLI-side McpMigrator and merged into config at the lowest precedence level.
* Returns true if the entry was found and removed from at least one file.
*/
private async removeLegacyMcp(name: string): Promise<boolean> {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const files: vscode.Uri[] = []
// Project-level legacy files
if (workspace) {
files.push(vscode.Uri.file(path.join(workspace, ".kilo", "mcp.json")))
files.push(vscode.Uri.file(path.join(workspace, ".kilocode", "mcp.json")))
}
// Global legacy file (VS Code extension global storage)
const storage = this.extensionContext?.globalStorageUri
if (storage) {
files.push(vscode.Uri.joinPath(storage, "settings", "mcp_settings.json"))
}
let removed = false
for (const uri of files) {
const bytes = await vscode.workspace.fs.readFile(uri).then(
(b) => b,
() => null,
)
if (!bytes) continue
try {
const parsed = JSON.parse(Buffer.from(bytes).toString("utf8")) as Record<string, unknown>
const servers = parsed.mcpServers as Record<string, unknown> | undefined
if (!servers?.[name]) continue
delete servers[name]
const content = Buffer.from(JSON.stringify(parsed, null, 2), "utf8")
await vscode.workspace.fs.writeFile(uri, content)
removed = true
} catch (err) {
console.warn("[Kilo New] KiloProvider: Failed to remove legacy MCP from", uri.fsPath, err)
}
}
return removed
}
private async fetchAndSendMcpStatus(): Promise<void> {
if (!this.client) {
if (this.cachedMcpStatusMessage) {
@@ -1846,23 +1870,35 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
/**
* Dispose the CLI backend instance so it re-reads config from disk.
* Call after any marketplace install/remove that writes config files directly.
* Global-scope changes need global.dispose() to also reset the global config cache.
* Remove a marketplace item from a single scope and invalidate CLI caches.
*/
private async disposeCliInstance(scope: "project" | "global"): Promise<void> {
if (!this.client) return
if (scope === "global") {
await this.client.global.dispose().catch((e: unknown) => {
console.warn("[Kilo New] global.dispose() after marketplace change failed:", e)
})
private async removeMarketplaceItem(item: MarketplaceItem, scope: "project" | "global"): Promise<RemoveResult> {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const result = await this.getMarketplace().remove(item, scope, workspace)
if (result.success) {
await this.invalidateAfterMarketplaceChange(scope)
}
// Always dispose the per-project instance so it rebuilds state from
// the (possibly updated) global + project config on the next request.
const dir = this.getWorkspaceDirectory()
await this.client.instance.dispose({ directory: dir }).catch((e: unknown) => {
console.warn("[Kilo New] instance.dispose() after marketplace change failed:", e)
})
return result
}
/**
* Remove a marketplace item from both project and global scopes.
* mp.remove returns success even when the entry doesn't exist (no-op),
* so we must attempt both scopes to cover dual-scope installations.
* Returns true if at least one scope removal succeeded.
*/
private async removeMarketplaceItemFromAllScopes(item: MarketplaceItem): Promise<boolean> {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const mp = this.getMarketplace()
const project = await mp.remove(item, "project", workspace)
const global = await mp.remove(item, "global", workspace)
if (project.success || global.success) {
const scope = global.success ? "global" : "project"
await this.invalidateAfterMarketplaceChange(scope)
return true
}
return false
}
/**