Skip to content

Commit 13f4e62

Browse files
committed
fix: config precedence now correctly allows local config to override remote
Remote config from .well-known/opencode is now loaded first as the base layer, allowing global and project configs to override remote defaults. This fixes the issue where users couldn't enable MCP servers that were disabled by default in their organization's remote config. Precedence order (later overrides earlier): 1. Remote config (.well-known/opencode) 2. Global config (~/.config/opencode/) 3. Custom config (OPENCODE_CONFIG) 4. Project config (opencode.json) 5. .opencode directories 6. Inline config (OPENCODE_CONFIG_CONTENT)
1 parent 01eadf3 commit 13f4e62

File tree

4 files changed

+261
-16
lines changed

4 files changed

+261
-16
lines changed

packages/opencode/src/config/config.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,34 +37,47 @@ export namespace Config {
3737

3838
export const state = Instance.state(async () => {
3939
const auth = await Auth.all()
40-
let result = await global()
4140

42-
// Override with custom config if provided
41+
// Load remote/well-known config first as the base layer (lowest precedence)
42+
// This allows organizations to provide default configs that users can override
43+
let result: Info = {}
44+
for (const [key, value] of Object.entries(auth)) {
45+
if (value.type === "wellknown") {
46+
process.env[value.key] = value.token
47+
log.debug("fetching remote config", { url: `${key}/.well-known/opencode` })
48+
const response = await fetch(`${key}/.well-known/opencode`)
49+
if (!response.ok) {
50+
throw new Error(`failed to fetch remote config from ${key}: ${response.status}`)
51+
}
52+
const wellknown = (await response.json()) as any
53+
result = mergeConfigConcatArrays(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
54+
log.debug("loaded remote config from well-known", { url: key })
55+
}
56+
}
57+
58+
// Global user config overrides remote config
59+
result = mergeConfigConcatArrays(result, await global())
60+
61+
// Custom config path overrides global
4362
if (Flag.OPENCODE_CONFIG) {
4463
result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
4564
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
4665
}
4766

67+
// Project config has highest precedence (overrides global and remote)
4868
for (const file of ["opencode.jsonc", "opencode.json"]) {
4969
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
5070
for (const resolved of found.toReversed()) {
5171
result = mergeConfigConcatArrays(result, await loadFile(resolved))
5272
}
5373
}
5474

75+
// Inline config content has highest precedence
5576
if (Flag.OPENCODE_CONFIG_CONTENT) {
5677
result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
5778
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
5879
}
5980

60-
for (const [key, value] of Object.entries(auth)) {
61-
if (value.type === "wellknown") {
62-
process.env[value.key] = value.token
63-
const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any
64-
result = mergeConfigConcatArrays(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
65-
}
66-
}
67-
6881
result.agent = result.agent || {}
6982
result.mode = result.mode || {}
7083
result.plugin = result.plugin || []

packages/opencode/test/config/config.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,3 +913,159 @@ test("permission config preserves key order", async () => {
913913
},
914914
})
915915
})
916+
917+
// MCP config merging tests
918+
919+
test("project config can override MCP server enabled status", async () => {
920+
await using tmp = await tmpdir({
921+
init: async (dir) => {
922+
// Simulates a base config (like from remote .well-known) with disabled MCP
923+
await Bun.write(
924+
path.join(dir, "opencode.jsonc"),
925+
JSON.stringify({
926+
$schema: "https://opencode.ai/config.json",
927+
mcp: {
928+
jira: {
929+
type: "remote",
930+
url: "https://jira.example.com/mcp",
931+
enabled: false,
932+
},
933+
wiki: {
934+
type: "remote",
935+
url: "https://wiki.example.com/mcp",
936+
enabled: false,
937+
},
938+
},
939+
}),
940+
)
941+
// Project config enables just jira
942+
await Bun.write(
943+
path.join(dir, "opencode.json"),
944+
JSON.stringify({
945+
$schema: "https://opencode.ai/config.json",
946+
mcp: {
947+
jira: {
948+
type: "remote",
949+
url: "https://jira.example.com/mcp",
950+
enabled: true,
951+
},
952+
},
953+
}),
954+
)
955+
},
956+
})
957+
await Instance.provide({
958+
directory: tmp.path,
959+
fn: async () => {
960+
const config = await Config.get()
961+
// jira should be enabled (overridden by project config)
962+
expect(config.mcp?.jira).toEqual({
963+
type: "remote",
964+
url: "https://jira.example.com/mcp",
965+
enabled: true,
966+
})
967+
// wiki should still be disabled (not overridden)
968+
expect(config.mcp?.wiki).toEqual({
969+
type: "remote",
970+
url: "https://wiki.example.com/mcp",
971+
enabled: false,
972+
})
973+
},
974+
})
975+
})
976+
977+
test("MCP config deep merges preserving base config properties", async () => {
978+
await using tmp = await tmpdir({
979+
init: async (dir) => {
980+
// Base config with full MCP definition
981+
await Bun.write(
982+
path.join(dir, "opencode.jsonc"),
983+
JSON.stringify({
984+
$schema: "https://opencode.ai/config.json",
985+
mcp: {
986+
myserver: {
987+
type: "remote",
988+
url: "https://myserver.example.com/mcp",
989+
enabled: false,
990+
headers: {
991+
"X-Custom-Header": "value",
992+
},
993+
},
994+
},
995+
}),
996+
)
997+
// Override just enables it, should preserve other properties
998+
await Bun.write(
999+
path.join(dir, "opencode.json"),
1000+
JSON.stringify({
1001+
$schema: "https://opencode.ai/config.json",
1002+
mcp: {
1003+
myserver: {
1004+
type: "remote",
1005+
url: "https://myserver.example.com/mcp",
1006+
enabled: true,
1007+
},
1008+
},
1009+
}),
1010+
)
1011+
},
1012+
})
1013+
await Instance.provide({
1014+
directory: tmp.path,
1015+
fn: async () => {
1016+
const config = await Config.get()
1017+
expect(config.mcp?.myserver).toEqual({
1018+
type: "remote",
1019+
url: "https://myserver.example.com/mcp",
1020+
enabled: true,
1021+
headers: {
1022+
"X-Custom-Header": "value",
1023+
},
1024+
})
1025+
},
1026+
})
1027+
})
1028+
1029+
test("local .opencode config can override MCP from project config", async () => {
1030+
await using tmp = await tmpdir({
1031+
init: async (dir) => {
1032+
// Project config with disabled MCP
1033+
await Bun.write(
1034+
path.join(dir, "opencode.json"),
1035+
JSON.stringify({
1036+
$schema: "https://opencode.ai/config.json",
1037+
mcp: {
1038+
docs: {
1039+
type: "remote",
1040+
url: "https://docs.example.com/mcp",
1041+
enabled: false,
1042+
},
1043+
},
1044+
}),
1045+
)
1046+
// Local .opencode directory config enables it
1047+
const opencodeDir = path.join(dir, ".opencode")
1048+
await fs.mkdir(opencodeDir, { recursive: true })
1049+
await Bun.write(
1050+
path.join(opencodeDir, "opencode.json"),
1051+
JSON.stringify({
1052+
$schema: "https://opencode.ai/config.json",
1053+
mcp: {
1054+
docs: {
1055+
type: "remote",
1056+
url: "https://docs.example.com/mcp",
1057+
enabled: true,
1058+
},
1059+
},
1060+
}),
1061+
)
1062+
},
1063+
})
1064+
await Instance.provide({
1065+
directory: tmp.path,
1066+
fn: async () => {
1067+
const config = await Config.get()
1068+
expect(config.mcp?.docs?.enabled).toBe(true)
1069+
},
1070+
})
1071+
})

packages/web/src/content/docs/config.mdx

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,74 @@ different order of precedence.
3232
Configuration files are **merged together**, not replaced.
3333
:::
3434

35-
Configuration files are merged together, not replaced. Settings from the following config locations are combined. Where later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
35+
Configuration files are merged together, not replaced. Settings from the following config locations are combined. Later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
3636

3737
For example, if your global config sets `theme: "opencode"` and `autoupdate: true`, and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include all three settings.
3838

3939
---
4040

41+
### Precedence order
42+
43+
Config sources are loaded in this order (later sources override earlier ones):
44+
45+
1. **Remote config** (from `.well-known/opencode`) - organizational defaults
46+
2. **Global config** (`~/.config/opencode/opencode.json`) - user preferences
47+
3. **Custom config** (`OPENCODE_CONFIG` env var) - custom overrides
48+
4. **Project config** (`opencode.json` in project) - project-specific settings
49+
5. **`.opencode` directories** - agents, commands, plugins
50+
6. **Inline config** (`OPENCODE_CONFIG_CONTENT` env var) - runtime overrides
51+
52+
This means project configs can override global defaults, and global configs can override remote organizational defaults.
53+
54+
---
55+
56+
### Remote
57+
58+
Organizations can provide default configuration via the `.well-known/opencode` endpoint. This is fetched automatically when you authenticate with a provider that supports it.
59+
60+
Remote config is loaded first, serving as the base layer. All other config sources (global, project) can override these defaults.
61+
62+
For example, if your organization provides MCP servers that are disabled by default:
63+
64+
```json title="Remote config from .well-known/opencode"
65+
{
66+
"mcp": {
67+
"jira": {
68+
"type": "remote",
69+
"url": "https://jira.example.com/mcp",
70+
"enabled": false
71+
}
72+
}
73+
}
74+
```
75+
76+
You can enable specific servers in your local config:
77+
78+
```json title="opencode.json"
79+
{
80+
"mcp": {
81+
"jira": {
82+
"type": "remote",
83+
"url": "https://jira.example.com/mcp",
84+
"enabled": true
85+
}
86+
}
87+
}
88+
```
89+
90+
---
91+
4192
### Global
4293

43-
Place your global OpenCode config in `~/.config/opencode/opencode.json`. You'll want to use the global config for things like themes, providers, or keybinds.
94+
Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide preferences like themes, providers, or keybinds.
95+
96+
Global config overrides remote organizational defaults.
4497

4598
---
4699

47100
### Per project
48101

49-
You can also add a `opencode.json` in your project. Settings from this config are merged with and can override the global config. This is useful for configuring providers or modes specific to your project.
102+
Add `opencode.json` in your project root. Project config has the highest precedence among standard config files - it overrides both global and remote configs.
50103

51104
:::tip
52105
Place project specific config in the root of your project.
@@ -60,20 +113,20 @@ This is also safe to be checked into Git and uses the same schema as the global
60113

61114
### Custom path
62115

63-
You can also specify a custom config file path using the `OPENCODE_CONFIG` environment variable.
116+
Specify a custom config file path using the `OPENCODE_CONFIG` environment variable.
64117

65118
```bash
66119
export OPENCODE_CONFIG=/path/to/my/custom-config.json
67120
opencode run "Hello world"
68121
```
69122

70-
Settings from this config are merged with and **can override** the global and project configs.
123+
Custom config is loaded between global and project configs in the precedence order.
71124

72125
---
73126

74127
### Custom directory
75128

76-
You can specify a custom config directory using the `OPENCODE_CONFIG_DIR`
129+
Specify a custom config directory using the `OPENCODE_CONFIG_DIR`
77130
environment variable. This directory will be searched for agents, commands,
78131
modes, and plugins just like the standard `.opencode` directory, and should
79132
follow the same structure.

packages/web/src/content/docs/mcp-servers.mdx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,29 @@ You can also disable a server by setting `enabled` to `false`. This is useful if
4444

4545
---
4646

47+
### Overriding remote defaults
48+
49+
Organizations can provide default MCP servers via their `.well-known/opencode` endpoint. These servers may be disabled by default, allowing users to opt-in to the ones they need.
50+
51+
To enable a specific server from your organization's remote config, add it to your local config with `enabled: true`:
52+
53+
```json title="opencode.json"
54+
{
55+
"$schema": "https://opencode.ai/config.json",
56+
"mcp": {
57+
"jira": {
58+
"type": "remote",
59+
"url": "https://jira.example.com/mcp",
60+
"enabled": true
61+
}
62+
}
63+
}
64+
```
65+
66+
Your local config values override the remote defaults. See [config precedence](/docs/config#precedence-order) for more details.
67+
68+
---
69+
4770
## Local
4871

4972
Add local MCP servers using `type` to `"local"` within the MCP object.

0 commit comments

Comments
 (0)