Compare commits

...
Author SHA1 Message Date
shey 37875df8e7 Only write sentinel if migration succeeded 2026-03-16 13:55:18 -07:00
shey 743c41feb7 fix: decouple MCP settings migration version from general migration
Address Greptile review comments:
- Add CURRENT_MCP_SETTINGS_MIGRATION_VERSION constant for independent versioning
- Update MCP migration check to use new constant instead of CURRENT_MIGRATION_VERSION
- Update sentinel write to use new constant
- Fix test regressions by pre-setting MCP sentinel in 'skip everything' tests

This prevents MCP migration from re-running unnecessarily when general
migration version is bumped for unrelated changes.
2026-03-16 12:37:58 -07:00
shey-cline 329629c279 Merge branch 'main' into shey/mcp-settings-migration 2026-03-16 08:09:01 -07:00
shey 17e6151e2b move back to single migration file 2026-03-16 07:50:56 -07:00
shey 4673cbe55d invoke migration function in extension.ts 2026-03-02 09:21:21 -08:00
shey 0e9c451fcc init 2026-03-02 09:11:10 -08:00
2 changed files with 221 additions and 5 deletions
@@ -6,18 +6,21 @@ import fs from "fs"
import os from "os"
import path from "path"
import sinon from "sinon"
import { exportVSCodeStorageToSharedFiles } from "../vscode-to-file-migration"
import { exportVSCodeStorageToSharedFiles, MCP_SETTINGS_MIGRATION_VERSION_KEY } from "../vscode-to-file-migration"
/**
* Create a minimal mock of VSCode's ExtensionContext for migration testing.
* Provides in-memory implementations of globalState, secrets, and workspaceState.
*/
function createMockVSCodeContext() {
function createMockVSCodeContext(globalStoragePath?: string) {
const globalStateStore = new Map<string, any>()
const secretsStore = new Map<string, string>()
const workspaceStateStore = new Map<string, any>()
return {
globalStorageUri: {
fsPath: globalStoragePath ?? "/nonexistent-vscode-storage",
},
globalState: {
get<T>(key: string): T | undefined {
return globalStateStore.get(key) as T | undefined
@@ -110,9 +113,10 @@ describe("vscode-to-file-migration", () => {
})
it("should skip everything when both sentinels are current version", async () => {
// Pre-set BOTH sentinels
// Pre-set ALL sentinels
storageContext.globalState.update("__vscodeMigrationVersion", 1)
storageContext.workspaceState.set("__vscodeMigrationVersion", 1)
storageContext.globalState.update(MCP_SETTINGS_MIGRATION_VERSION_KEY, 1)
const mockCtx = createMockVSCodeContext()
mockCtx._globalStateStore.set("mode", "plan")
@@ -131,6 +135,7 @@ describe("vscode-to-file-migration", () => {
it("should skip everything when both sentinels are higher version", async () => {
storageContext.globalState.update("__vscodeMigrationVersion", 999)
storageContext.workspaceState.set("__vscodeMigrationVersion", 999)
storageContext.globalState.update(MCP_SETTINGS_MIGRATION_VERSION_KEY, 999)
const mockCtx = createMockVSCodeContext()
mockCtx._globalStateStore.set("mode", "act")
@@ -364,6 +369,159 @@ describe("vscode-to-file-migration", () => {
})
})
describe("MCP settings migration", () => {
let vscodeStorageDir: string
beforeEach(() => {
vscodeStorageDir = path.join(os.tmpdir(), `vscode-storage-${Date.now()}-${Math.random().toString(36).slice(2)}`)
fs.mkdirSync(vscodeStorageDir, { recursive: true })
})
afterEach(() => {
try {
fs.rmSync(vscodeStorageDir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
})
it("should copy MCP settings file from VSCode storage to shared data dir", async () => {
// Create source MCP settings file in VSCode storage
const srcDir = path.join(vscodeStorageDir, "settings")
fs.mkdirSync(srcDir, { recursive: true })
const mcpSettings = { mcpServers: { "test-server": { command: "node", args: ["server.js"] } } }
fs.writeFileSync(path.join(srcDir, "cline_mcp_settings.json"), JSON.stringify(mcpSettings))
const mockCtx = createMockVSCodeContext(vscodeStorageDir)
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
// Verify MCP settings were copied to shared data dir
const destPath = path.join(storageContext.dataDir, "settings", "cline_mcp_settings.json")
fs.existsSync(destPath).should.be.true()
const copied = JSON.parse(fs.readFileSync(destPath, "utf8"))
copied.should.deepEqual(mcpSettings)
// MCP sentinel should be written
storageContext.globalState.get(MCP_SETTINGS_MIGRATION_VERSION_KEY)!.should.equal(1)
})
it("should NOT overwrite destination if it already has servers configured", async () => {
// Create source MCP settings file
const srcDir = path.join(vscodeStorageDir, "settings")
fs.mkdirSync(srcDir, { recursive: true })
const srcSettings = { mcpServers: { "vscode-server": { command: "node", args: ["vscode.js"] } } }
fs.writeFileSync(path.join(srcDir, "cline_mcp_settings.json"), JSON.stringify(srcSettings))
// Pre-populate destination with existing servers
const destDir = path.join(storageContext.dataDir, "settings")
fs.mkdirSync(destDir, { recursive: true })
const destSettings = { mcpServers: { "existing-server": { command: "python", args: ["server.py"] } } }
fs.writeFileSync(path.join(destDir, "cline_mcp_settings.json"), JSON.stringify(destSettings))
const mockCtx = createMockVSCodeContext(vscodeStorageDir)
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
// Destination should still have the original servers, NOT the VSCode ones
const destPath = path.join(destDir, "cline_mcp_settings.json")
const existing = JSON.parse(fs.readFileSync(destPath, "utf8"))
existing.should.deepEqual(destSettings)
// Sentinel should still be written
storageContext.globalState.get(MCP_SETTINGS_MIGRATION_VERSION_KEY)!.should.equal(1)
})
it("should skip if source file does not exist", async () => {
// vscodeStorageDir exists but has no settings/cline_mcp_settings.json
const mockCtx = createMockVSCodeContext(vscodeStorageDir)
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
// No file should have been created at destination
const destPath = path.join(storageContext.dataDir, "settings", "cline_mcp_settings.json")
fs.existsSync(destPath).should.be.false()
// Sentinel should still be written (source simply didn't exist)
storageContext.globalState.get(MCP_SETTINGS_MIGRATION_VERSION_KEY)!.should.equal(1)
})
it("should skip if source and destination paths are the same", async () => {
// Use the shared data dir as both VSCode storage and destination
// This simulates the CLI case where they point to the same place
const sharedDir = storageContext.dataDir
const settingsDir = path.join(sharedDir, "settings")
fs.mkdirSync(settingsDir, { recursive: true })
const mcpSettings = { mcpServers: { "my-server": { command: "node" } } }
fs.writeFileSync(path.join(settingsDir, "cline_mcp_settings.json"), JSON.stringify(mcpSettings))
const mockCtx = createMockVSCodeContext(sharedDir)
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
// File should still be there unchanged (not deleted or corrupted)
const existing = JSON.parse(fs.readFileSync(path.join(settingsDir, "cline_mcp_settings.json"), "utf8"))
existing.should.deepEqual(mcpSettings)
// Sentinel should be written
storageContext.globalState.get(MCP_SETTINGS_MIGRATION_VERSION_KEY)!.should.equal(1)
})
it("should skip MCP migration if sentinel is already current", async () => {
// Pre-set MCP sentinel
storageContext.globalState.update(MCP_SETTINGS_MIGRATION_VERSION_KEY, 1)
// Create source MCP settings file
const srcDir = path.join(vscodeStorageDir, "settings")
fs.mkdirSync(srcDir, { recursive: true })
const mcpSettings = { mcpServers: { "test-server": { command: "node" } } }
fs.writeFileSync(path.join(srcDir, "cline_mcp_settings.json"), JSON.stringify(mcpSettings))
const mockCtx = createMockVSCodeContext(vscodeStorageDir)
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
// MCP settings should NOT have been copied (sentinel was already current)
const destPath = path.join(storageContext.dataDir, "settings", "cline_mcp_settings.json")
fs.existsSync(destPath).should.be.false()
})
it("should copy MCP settings when destination file exists but has no servers", async () => {
// Create source with servers
const srcDir = path.join(vscodeStorageDir, "settings")
fs.mkdirSync(srcDir, { recursive: true })
const srcSettings = { mcpServers: { "test-server": { command: "node" } } }
fs.writeFileSync(path.join(srcDir, "cline_mcp_settings.json"), JSON.stringify(srcSettings))
// Create destination with empty mcpServers
const destDir = path.join(storageContext.dataDir, "settings")
fs.mkdirSync(destDir, { recursive: true })
const destSettings = { mcpServers: {} }
fs.writeFileSync(path.join(destDir, "cline_mcp_settings.json"), JSON.stringify(destSettings))
const mockCtx = createMockVSCodeContext(vscodeStorageDir)
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
result.migrated.should.be.true()
// Destination should now have the source servers (empty dest doesn't count as "has servers")
const destPath = path.join(destDir, "cline_mcp_settings.json")
const copied = JSON.parse(fs.readFileSync(destPath, "utf8"))
copied.should.deepEqual(srcSettings)
})
})
describe("idempotency", () => {
it("should produce same result when run twice", async () => {
const mockCtx = createMockVSCodeContext()
+60 -2
View File
@@ -32,6 +32,9 @@
* and vice versa. See also: checkpoints at {globalStorageFsPath}/checkpoints/.
*/
import { fileExistsAtPath } from "@utils/fs"
import fs from "fs/promises"
import * as path from "path"
import type * as vscode from "vscode"
import { Logger } from "@/shared/services/Logger"
import { GlobalStateAndSettingKeys, LocalStateKeys, SecretKeys } from "@/shared/storage/state-keys"
@@ -43,6 +46,12 @@ const CURRENT_MIGRATION_VERSION = 1
/** Sentinel key written to both globalState and workspaceState to track migration independently. */
const MIGRATION_VERSION_KEY = "__vscodeMigrationVersion"
/** Bump this when MCP settings migration logic changes. Independent of general migration version. */
const CURRENT_MCP_SETTINGS_MIGRATION_VERSION = 1
/** Sentinel key for MCP settings migration */
export const MCP_SETTINGS_MIGRATION_VERSION_KEY = "__mcpSettingsMigrationVersion"
/**
* Keys that should NOT be migrated from VSCode storage.
* These are either:
@@ -90,13 +99,16 @@ export async function exportVSCodeStorageToSharedFiles(
// Check sentinels independently
const globalVersion = storage.globalState.get<number>(MIGRATION_VERSION_KEY)
const workspaceVersion = storage.workspaceState.get<number>(MIGRATION_VERSION_KEY)
const mcpSettingsVersion = storage.globalState.get<number>(MCP_SETTINGS_MIGRATION_VERSION_KEY)
const needGlobalMigration = globalVersion === undefined || globalVersion < CURRENT_MIGRATION_VERSION
const needWorkspaceMigration = workspaceVersion === undefined || workspaceVersion < CURRENT_MIGRATION_VERSION
const needMcpSettingsMigration =
mcpSettingsVersion === undefined || mcpSettingsVersion < CURRENT_MCP_SETTINGS_MIGRATION_VERSION
if (!needGlobalMigration && !needWorkspaceMigration) {
if (!needGlobalMigration && !needWorkspaceMigration && !needMcpSettingsMigration) {
Logger.info(
`[Migration] File-backed stores already current (global: v${globalVersion}, workspace: v${workspaceVersion}), skipping.`,
`[Migration] File-backed stores already current (global: v${globalVersion}, workspace: v${workspaceVersion}, mcpSettings: v${mcpSettingsVersion}), skipping.`,
)
return result
}
@@ -189,6 +201,38 @@ export async function exportVSCodeStorageToSharedFiles(
storage.workspaceState.setBatch(workspaceStateBatch)
}
// ─── 3. Migrate MCP settings file (if needed) ───────────────────
if (needMcpSettingsMigration) {
try {
const srcPath = path.join(vscodeContext.globalStorageUri.fsPath, "settings", "cline_mcp_settings.json")
const destDir = path.join(storage.dataDir, "settings")
const destPath = path.join(destDir, "cline_mcp_settings.json")
// Skip if source and destination are the same path (CLI case)
const isSamePath = path.resolve(srcPath) === path.resolve(destPath)
const sourceExists = !isSamePath && (await fileExistsAtPath(srcPath))
if (sourceExists) {
// Check if destination already has servers
const destHasServers = await mcpFileHasServers(destPath)
if (!destHasServers) {
await fs.mkdir(destDir, { recursive: true })
await fs.copyFile(srcPath, destPath)
Logger.info(`[McpMigration] Migrated MCP settings from ${srcPath} to ${destPath}`)
} else {
Logger.info("[McpMigration] Shared MCP settings already has servers configured, skipping.")
}
}
// Only write sentinel if migration succeeded
storage.globalState.update(MCP_SETTINGS_MIGRATION_VERSION_KEY, CURRENT_MCP_SETTINGS_MIGRATION_VERSION)
} catch (error) {
// Non-fatal — will retry on next startup
Logger.error("[McpMigration] Failed to migrate MCP settings file:", error)
}
}
result.migrated = true
Logger.info(
@@ -204,3 +248,17 @@ export async function exportVSCodeStorageToSharedFiles(
return result
}
/** Returns true if the file exists and contains at least one MCP server entry. */
async function mcpFileHasServers(filePath: string): Promise<boolean> {
try {
if (!(await fileExistsAtPath(filePath))) {
return false
}
const data = JSON.parse(await fs.readFile(filePath, "utf8"))
return !!(data?.mcpServers && Object.keys(data.mcpServers).length > 0)
} catch (error) {
Logger.error("[McpMigration] Failed to parse MCP settings file, treating as empty:", error)
return false
}
}