Skip to content
Closed
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
d6c38ea
feat(mcp): persist MCP enabled state to config file
mikij Feb 21, 2026
82a8290
fix(mcp): resolve config path consistently and handle persistence errors
mikij Feb 21, 2026
58407b0
refactor(config): improve config file resolution priority
mikij Feb 23, 2026
f061cb2
fix(config): preserve MCP connections on config reload
mikij Feb 23, 2026
db3cf55
fix(config): find correct config file for MCP toggle persistence
mikij Feb 23, 2026
0da79ae
fix(config): skip managed config paths when determining where to writ…
mikij Feb 23, 2026
ea8fd54
fix(config): handle MCP toggle in read-only managed config
mikij Feb 23, 2026
a7bcb7d
Merge branch 'main' into fix/#565-mcp-toggle
mikij Feb 23, 2026
4b8738f
fix(config): resolve config path order mismatch and add change event …
mikij Feb 23, 2026
83730ae
Merge branch 'main' into fix/#565-mcp-toggle
mikij Feb 23, 2026
838149e
fix(config): remive try/catch
mikij Feb 23, 2026
f8d2627
fix(config): config writes go to the same location hierarchy as confi…
mikij Feb 23, 2026
2d6cfc5
fix(config): eliminates wasteful operations when toggling an MCP serv…
mikij Feb 23, 2026
7d4e872
fix(config): PR comments
mikij Feb 23, 2026
85b5b3f
fix(config): Added GlobalBus.emit() to update() to notify UI/watchers…
mikij Feb 23, 2026
71c50d4
Merge branch 'main' into fix/#565-mcp-toggle
mikij Feb 26, 2026
2537889
fix(config): addressing comments after conflict resolution
mikij Feb 26, 2026
2d8b32a
Merge branch 'main' into fix/#565-mcp-toggle
mikij Feb 26, 2026
582bafe
fix(config): addressed new set of PR comments
mikij Feb 26, 2026
51d638f
Merge branch 'main' into fix/#565-mcp-toggle
mikij Feb 26, 2026
122fb73
fix(config): addressed new set of PR comments
mikij Feb 26, 2026
fbbff14
Merge branch 'main' into fix/#565-mcp-toggle
mikij Mar 4, 2026
0aff250
chore(sdk): removing generated changes from PR
mikij Mar 4, 2026
ea6c8b2
fix(mcp): improve toggle logic and config search
mikij Mar 4, 2026
e61db48
fix(config): add file locking and fix state disposal order
mikij Mar 4, 2026
5a83ec0
fix(config): improve config file loading order and MCP handling
mikij Mar 4, 2026
e4990d6
fix(config): use parseJsonc instead of JSON.parse for inline config
mikij Mar 4, 2026
d9cfca5
docs(config): reorganize config resolution order comments
mikij Mar 4, 2026
b5b5ec1
refactor(config): update mcp config search order precedence
mikij Mar 4, 2026
d2fd238
fix(config): reverse directory depth sort for config file resolution
mikij Mar 4, 2026
dcee5c1
Merge branch 'main' into fix/#565-mcp-toggle
mikij Mar 4, 2026
8834242
fix(config): simplify mcp config file existence check
mikij Mar 4, 2026
6386ba2
refactor(config): improve mcp toggle validation and config path resol…
mikij Mar 5, 2026
701f796
Merge branch 'main' into fix/#565-mcp-toggle
mikij Mar 5, 2026
12c1151
Merge remote-tracking branch 'origin/main' into fix/#565-mcp-toggle
mikij Mar 14, 2026
54aa765
fix(config): remove premature Instance.dispose() call
mikij Mar 14, 2026
584dae3
feat(config): enhance cache management and config file precedence
mikij Mar 14, 2026
8b03048
feat(config): add reactive configuration update handling
mikij Mar 14, 2026
6c8201a
refactor(sdk): reorganize event types and standardize json formatting
mikij Mar 14, 2026
913945c
fix(vscode): correct config change event directory comparison
mikij Mar 14, 2026
1355367
feat(config): add boolean return to persistMcpToggle for success indi…
mikij Mar 14, 2026
ead0d9f
fix(vscode): correct config change handler to use reloadAfterAuthChange
mikij Mar 14, 2026
f93fdbb
fix(vscode): fix MCP toggle config change handling for worktrees
mikij Mar 14, 2026
2e43b29
fix(config): refine shared config detection for accurate state disposal
mikij Mar 14, 2026
48592d8
fix(config): ensure all workspaces refresh after shared config change
mikij Mar 14, 2026
f7d52e3
Merge branch 'main' into fix/#565-mcp-toggle
mikij Mar 15, 2026
c257437
fix(config): ensure all state is disposed when shared config changes
mikij Mar 15, 2026
8124de8
fix(worktree): refine shared config detection and add directory param…
mikij Mar 15, 2026
2e1a399
feat: adjust config/state disposal and refresh behavior to preserve a…
mikij Mar 15, 2026
e74b27a
fix(config): ensure state disposal runs for current directory regardl…
mikij Mar 15, 2026
f6b0332
feat(config): add .kilocode directory support as legacy config fallback
mikij Mar 15, 2026
ddc4e9b
Merge branch 'main' into fix/#565-mcp-toggle
mikij Mar 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@ logs/
*.bun-build

# Telemetry ID
telemetry-id
telemetry-id
.cocoindex_code/
300 changes: 268 additions & 32 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,61 @@
import { Log } from "../util/log"
import path from "path"
import { pathToFileURL } from "url"
import os from "os"
import z from "zod"
import { Filesystem } from "../util/filesystem"
import { ModelsDev } from "../provider/models"
import { mergeDeep, pipe, unique } from "remeda"
import { Global } from "../global"
import fs from "fs/promises"
import { lazy } from "../util/lazy"
import { BunProc } from "@/bun"
import { PackageRegistry } from "@/bun/registry"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { BusEvent } from "@/bus/bus-event"
import { Control } from "@/control"
import { Installation } from "@/installation"
import { iife } from "@/util/iife"
import { proxied } from "@/util/proxied"
import { NamedError } from "@opencode-ai/util/error"
import { Flag } from "../flag/flag"
import { Auth } from "../auth"
import { constants, existsSync } from "fs"
import fs from "fs/promises"
import {
type ParseError as JsoncParseError,
applyEdits,
modify,
parse as parseJsonc,
printParseErrorCode,
} from "jsonc-parser"
import { Instance } from "../project/instance"
import os from "os"
import path from "path"
import { mergeDeep, pipe, unique } from "remeda"
import { pathToFileURL } from "url"
import z from "zod"
import { Auth } from "../auth"
import { Flag } from "../flag/flag"
import { Global } from "../global"
import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
import { Instance } from "../project/instance"
import { ModelsDev } from "../provider/models"
import { Event as ServerEvent } from "../server/event"
import { Filesystem } from "../util/filesystem"
import { lazy } from "../util/lazy"
import { Log } from "../util/log"
import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
import { Control } from "@/control"

import { State } from "@/project/state" // kilocode_change
import { IgnoreMigrator } from "../kilocode/ignore-migrator" // kilocode_change
import { McpMigrator } from "../kilocode/mcp-migrator" // kilocode_change
import { ModesMigrator } from "../kilocode/modes-migrator" // kilocode_change
import { RulesMigrator } from "../kilocode/rules-migrator" // kilocode_change
import { WorkflowsMigrator } from "../kilocode/workflows-migrator" // kilocode_change
import { McpMigrator } from "../kilocode/mcp-migrator" // kilocode_change
import { IgnoreMigrator } from "../kilocode/ignore-migrator" // kilocode_change

export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })

const log = Log.create({ service: "config" })

// kilocode_change: Export Config.Event for config change notifications
export const Event = {
Changed: BusEvent.define(
"config.changed",
Comment thread
mikij marked this conversation as resolved.
Outdated
z.object({
directory: z.string(),
}),
),
}

// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
// These settings override all user and project settings
function getManagedConfigDir(): string {
Expand Down Expand Up @@ -84,7 +96,8 @@ export namespace Config {
return merged
}

export const state = Instance.state(async () => {
// kilocode_change: Export the init function so it can be used for targeted state disposal
Comment thread
mikij marked this conversation as resolved.
Outdated
async function initConfigState() {
const auth = await Auth.all()

// This ensures Opencode native configs always take precedence over legacy Kilocode configs
Expand Down Expand Up @@ -343,7 +356,9 @@ export namespace Config {
directories,
deps,
}
})
}

export const state = Instance.state(initConfigState)

export async function waitForDependencies() {
const deps = await state().then((x) => x.deps)
Expand Down Expand Up @@ -1418,15 +1433,19 @@ export namespace Config {
parsed.data.$schema = "https://kilo.ai/config.json" // kilocode_change
// Write the $schema to the original text to preserve variables like {env:VAR}
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://kilo.ai/config.json",') // kilocode_change
await Bun.write(configFilepath, updated).catch(() => {})
await Bun.write(configFilepath, updated).catch((err) => {
log.warn("failed to write $schema to config file", { configFilepath, error: err })
})
}
const data = parsed.data
if (data.plugin) {
for (let i = 0; i < data.plugin.length; i++) {
const plugin = data.plugin[i]
try {
data.plugin[i] = import.meta.resolve!(plugin, configFilepath)
} catch (err) {}
} catch (err) {
log.warn("failed to resolve plugin path", { plugin, configFilepath, error: err })
}
}
}
return data
Expand Down Expand Up @@ -1471,11 +1490,228 @@ export namespace Config {
return global()
}

// kilocode_change start - Add function to persist MCP enabled state to config
export async function persistMcpToggle(name: string, enabled: boolean) {
Comment thread
mikij marked this conversation as resolved.
Outdated
// kilocode_change: Find the file where this MCP server is actually defined
const configPath = await findMcpConfigPath(name)
const file = Bun.file(configPath)

let text = "{}"
Comment thread
mikij marked this conversation as resolved.
Outdated
if (await file.exists()) {
text = await file.text()
}

// kilocode_change: Check if value is already the same to avoid unnecessary writes
const data = parseJsonc(text, [], { allowTrailingComma: true })
if (data?.mcp?.[name]?.enabled === enabled) return

const edits = modify(text, ["mcp", name, "enabled"], enabled, {
formattingOptions: { tabSize: 2, insertSpaces: true },
})

if (!edits.length) return

const result = applyEdits(text, edits)
await Bun.write(configPath, result)
// kilocode_change: Dispose only the Config state, not the entire instance (preserves MCP connections)
await State.disposeEntry(Instance.directory, initConfigState)
Comment thread
mikij marked this conversation as resolved.
// kilocode_change: Emit config changed event to notify UI/watchers
GlobalBus.emit("event", {
directory: Instance.directory,
Comment thread
mikij marked this conversation as resolved.
Outdated
payload: {
type: Event.Changed.type,
properties: {
directory: Instance.directory,
},
},
})
}

async function findMcpConfigPath(mcpName: string): Promise<string> {
// kilocode_change: Find which config file contains the MCP server definition
// Search in reverse priority order (highest precedence first) to find where it's defined

// Check managed config first (highest priority) - but only to warn, don't return it
// since managed config directories are typically read-only for regular users.
// Continue searching to find a writable location for the toggle.
if (existsSync(managedConfigDir)) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
Comment thread
mikij marked this conversation as resolved.
Outdated
const filepath = path.join(managedConfigDir, file)
if (await hasMcpDefinition(filepath, mcpName)) {
log.warn("MCP server is defined in managed config (read-only), toggle will be written to user config", {
Comment thread
mikij marked this conversation as resolved.
Outdated
mcp: mcpName,
managedConfig: filepath,
})
// Continue searching - we need to find a writable location
}
}
}

// Check inline config content (can't write here, fall through)
Comment thread
mikij marked this conversation as resolved.
// Skip: Flag.KILO_CONFIG_CONTENT is read-only

// Check custom config path
if (Flag.KILO_CONFIG) {
if (await hasMcpDefinition(Flag.KILO_CONFIG, mcpName)) {
return Flag.KILO_CONFIG
}
}

// Check KILO_CONFIG_DIR
if (Flag.KILO_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
const filepath = path.join(Flag.KILO_CONFIG_DIR, file)
if (await hasMcpDefinition(filepath, mcpName)) {
return filepath
}
}
}

// Check ~/.opencode/
const homeOpencodeDir = path.join(Global.Path.home, ".opencode")
for (const file of ["opencode.jsonc", "opencode.json"]) {
const filepath = path.join(homeOpencodeDir, file)
if (await hasMcpDefinition(filepath, mcpName)) {
return filepath
}
}

// Check project .opencode directories (closest first)
if (!Flag.KILO_DISABLE_PROJECT_CONFIG) {
const opencodeDirs = await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
Comment thread
mikij marked this conversation as resolved.
start: Instance.directory,
stop: Instance.worktree,
}),
)
for (const dir of opencodeDirs.toReversed()) {
Comment thread
mikij marked this conversation as resolved.
Outdated
for (const file of ["opencode.jsonc", "opencode.json"]) {
const filepath = path.join(dir, file)
if (await hasMcpDefinition(filepath, mcpName)) {
return filepath
}
}
}
}

// Check project config files (opencode.jsonc/json in project root)
if (!Flag.KILO_DISABLE_PROJECT_CONFIG) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
for (const filepath of found) {
if (await hasMcpDefinition(filepath, mcpName)) {
return filepath
}
}
}
}

// Check global config
const globalPath = globalConfigFile()
if (await hasMcpDefinition(globalPath, mcpName)) {
return globalPath
}

// MCP not found in any existing file - fall back to default resolution.
Comment thread
mikij marked this conversation as resolved.
// This creates a new entry in the highest-priority writable location,
// which shadows any managed config definition (user config overrides system config).
return resolveConfigPath()
}

async function hasMcpDefinition(filepath: string, mcpName: string): Promise<boolean> {
if (!existsSync(filepath)) return false
const text = await Bun.file(filepath).text()
Comment thread
mikij marked this conversation as resolved.
Outdated
const data = parseJsonc(text, [], { allowTrailingComma: true })
return data?.mcp?.[mcpName] !== undefined
}

async function resolveConfigPath() {
// kilocode_change: Use same resolution logic as loading to ensure we write to the same file
// Resolution order follows loading precedence (low to high):
Comment thread
mikij marked this conversation as resolved.
Outdated
// 1. KILO_CONFIG (explicit flag, highest priority)
// 2. KILO_CONFIG_DIR (if set)
// 3. ~/.opencode/
// 4. Project .opencode/ directories (closest first)
// 5. Project config files (opencode.jsonc/json in project root)
// 6. Global config (fallback)

// Check if custom config path is set via flag
if (Flag.KILO_CONFIG) {
return Flag.KILO_CONFIG
}

// Check if project config is disabled
if (Flag.KILO_DISABLE_PROJECT_CONFIG) {
Comment thread
mikij marked this conversation as resolved.
Outdated
return globalConfigFile()
}

// Check KILO_CONFIG_DIR (higher precedence than ~/.opencode/)
if (Flag.KILO_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
const filepath = path.join(Flag.KILO_CONFIG_DIR, file)
if (existsSync(filepath)) {
return filepath
}
}
// Directory exists but no config file - create new file here
if (existsSync(Flag.KILO_CONFIG_DIR)) {
return path.join(Flag.KILO_CONFIG_DIR, "opencode.jsonc")
}
}

// Check ~/.opencode/ (user home directory config)
const homeOpencodeDir = path.join(Global.Path.home, ".opencode")
for (const file of ["opencode.jsonc", "opencode.json"]) {
const filepath = path.join(homeOpencodeDir, file)
if (existsSync(filepath)) {
return filepath
}
}
// ~/.opencode/ exists but no config file - create new file here
if (existsSync(homeOpencodeDir)) {
return path.join(homeOpencodeDir, "opencode.jsonc")
}

// Check .opencode directories (scanned from closest to project root)
const opencodeDirs = await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Instance.directory,
stop: Instance.worktree,
}),
)
for (const dir of opencodeDirs.toReversed()) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
const filepath = path.join(dir, file)
if (existsSync(filepath)) {
return filepath
}
}
// .opencode/ directory exists but no config file - create new file here
return path.join(dir, "opencode.jsonc")
}

// Find project config using same priority as loading (jsonc first, then json)
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
if (found.length > 0) {
// Use the closest file (first in array, which is highest priority)
return found[0]
}
}

// Fallback to global config
return globalConfigFile()
}
// kilocode_change end

export async function update(config: Info) {
const filepath = path.join(Instance.directory, "config.json")
Comment thread
mikij marked this conversation as resolved.
const existing = await loadFile(filepath)
await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2))
Comment thread
mikij marked this conversation as resolved.
Outdated
await Instance.dispose()
// kilocode_change: Dispose only the Config state, not the entire instance (preserves MCP connections)
await State.disposeEntry(Instance.directory, initConfigState)
Comment thread
mikij marked this conversation as resolved.
}

function globalConfigFile() {
Expand Down Expand Up @@ -1574,7 +1810,7 @@ export namespace Config {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
type: ServerEvent.Disposed.type,
properties: {},
},
})
Expand Down
Loading
Loading