diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 130031f020e..45c555810eb 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -37,14 +37,40 @@ export namespace Config { export const state = Instance.state(async () => { const auth = await Auth.all() - let result = await global() - // Override with custom config if provided + // Load remote/well-known config first as the base layer (lowest precedence) + // This allows organizations to provide default configs that users can override + let result: Info = {} + for (const [key, value] of Object.entries(auth)) { + if (value.type === "wellknown") { + process.env[value.key] = value.token + log.debug("fetching remote config", { url: `${key}/.well-known/opencode` }) + const response = await fetch(`${key}/.well-known/opencode`) + if (!response.ok) { + throw new Error(`failed to fetch remote config from ${key}: ${response.status}`) + } + const wellknown = (await response.json()) as any + const remoteConfig = wellknown.config ?? {} + // Add $schema to prevent load() from trying to write back to a non-existent file + if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" + result = mergeConfigConcatArrays( + result, + await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`), + ) + log.debug("loaded remote config from well-known", { url: key }) + } + } + + // Global user config overrides remote config + result = mergeConfigConcatArrays(result, await global()) + + // Custom config path overrides global if (Flag.OPENCODE_CONFIG) { result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG)) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) } + // Project config has highest precedence (overrides global and remote) for (const file of ["opencode.jsonc", "opencode.json"]) { const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree) for (const resolved of found.toReversed()) { @@ -52,19 +78,12 @@ export namespace Config { } } + // Inline config content has highest precedence if (Flag.OPENCODE_CONFIG_CONTENT) { result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT)) log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } - for (const [key, value] of Object.entries(auth)) { - if (value.type === "wellknown") { - process.env[value.key] = value.token - const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any - result = mergeConfigConcatArrays(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd())) - } - } - result.agent = result.agent || {} result.mode = result.mode || {} result.plugin = result.plugin || [] diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 4efc4b74281..b52f3ef7f77 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,6 +1,7 @@ -import { test, expect } from "bun:test" +import { test, expect, mock, afterEach } from "bun:test" import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" +import { Auth } from "../../src/auth" import { tmpdir } from "../fixture/fixture" import path from "path" import fs from "fs/promises" @@ -913,3 +914,234 @@ test("permission config preserves key order", async () => { }, }) }) + +// MCP config merging tests + +test("project config can override MCP server enabled status", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Simulates a base config (like from remote .well-known) with disabled MCP + await Bun.write( + path.join(dir, "opencode.jsonc"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + jira: { + type: "remote", + url: "https://jira.example.com/mcp", + enabled: false, + }, + wiki: { + type: "remote", + url: "https://wiki.example.com/mcp", + enabled: false, + }, + }, + }), + ) + // Project config enables just jira + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + jira: { + type: "remote", + url: "https://jira.example.com/mcp", + enabled: true, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + // jira should be enabled (overridden by project config) + expect(config.mcp?.jira).toEqual({ + type: "remote", + url: "https://jira.example.com/mcp", + enabled: true, + }) + // wiki should still be disabled (not overridden) + expect(config.mcp?.wiki).toEqual({ + type: "remote", + url: "https://wiki.example.com/mcp", + enabled: false, + }) + }, + }) +}) + +test("MCP config deep merges preserving base config properties", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Base config with full MCP definition + await Bun.write( + path.join(dir, "opencode.jsonc"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + myserver: { + type: "remote", + url: "https://myserver.example.com/mcp", + enabled: false, + headers: { + "X-Custom-Header": "value", + }, + }, + }, + }), + ) + // Override just enables it, should preserve other properties + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + myserver: { + type: "remote", + url: "https://myserver.example.com/mcp", + enabled: true, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.mcp?.myserver).toEqual({ + type: "remote", + url: "https://myserver.example.com/mcp", + enabled: true, + headers: { + "X-Custom-Header": "value", + }, + }) + }, + }) +}) + +test("local .opencode config can override MCP from project config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Project config with disabled MCP + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + docs: { + type: "remote", + url: "https://docs.example.com/mcp", + enabled: false, + }, + }, + }), + ) + // Local .opencode directory config enables it + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await Bun.write( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + docs: { + type: "remote", + url: "https://docs.example.com/mcp", + enabled: true, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.mcp?.docs?.enabled).toBe(true) + }, + }) +}) + +test("project config overrides remote well-known config", async () => { + const originalFetch = globalThis.fetch + let fetchedUrl: string | undefined + const mockFetch = mock((url: string | URL | Request) => { + const urlStr = url.toString() + if (urlStr.includes(".well-known/opencode")) { + fetchedUrl = urlStr + return Promise.resolve( + new Response( + JSON.stringify({ + config: { + mcp: { + jira: { + type: "remote", + url: "https://jira.example.com/mcp", + enabled: false, + }, + }, + }, + }), + { status: 200 }, + ), + ) + } + return originalFetch(url) + }) + globalThis.fetch = mockFetch as unknown as typeof fetch + + const originalAuthAll = Auth.all + Auth.all = mock(() => + Promise.resolve({ + "https://example.com": { + type: "wellknown" as const, + key: "TEST_TOKEN", + token: "test-token", + }, + }), + ) + + try { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + // Project config enables jira (overriding remote default) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + jira: { + type: "remote", + url: "https://jira.example.com/mcp", + enabled: true, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + // Verify fetch was called for wellknown config + expect(fetchedUrl).toBe("https://example.com/.well-known/opencode") + // Project config (enabled: true) should override remote (enabled: false) + expect(config.mcp?.jira?.enabled).toBe(true) + }, + }) + } finally { + globalThis.fetch = originalFetch + Auth.all = originalAuthAll + } +}) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index d9076e13a36..a5931b6fcf5 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -32,21 +32,74 @@ different order of precedence. Configuration files are **merged together**, not replaced. ::: -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. +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. 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. --- +### Precedence order + +Config sources are loaded in this order (later sources override earlier ones): + +1. **Remote config** (from `.well-known/opencode`) - organizational defaults +2. **Global config** (`~/.config/opencode/opencode.json`) - user preferences +3. **Custom config** (`OPENCODE_CONFIG` env var) - custom overrides +4. **Project config** (`opencode.json` in project) - project-specific settings +5. **`.opencode` directories** - agents, commands, plugins +6. **Inline config** (`OPENCODE_CONFIG_CONTENT` env var) - runtime overrides + +This means project configs can override global defaults, and global configs can override remote organizational defaults. + +--- + +### Remote + +Organizations can provide default configuration via the `.well-known/opencode` endpoint. This is fetched automatically when you authenticate with a provider that supports it. + +Remote config is loaded first, serving as the base layer. All other config sources (global, project) can override these defaults. + +For example, if your organization provides MCP servers that are disabled by default: + +```json title="Remote config from .well-known/opencode" +{ + "mcp": { + "jira": { + "type": "remote", + "url": "https://jira.example.com/mcp", + "enabled": false + } + } +} +``` + +You can enable specific servers in your local config: + +```json title="opencode.json" +{ + "mcp": { + "jira": { + "type": "remote", + "url": "https://jira.example.com/mcp", + "enabled": true + } + } +} +``` + +--- + ### Global -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. +Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide preferences like themes, providers, or keybinds. + +Global config overrides remote organizational defaults. --- ### Per project -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. +Add `opencode.json` in your project root. Project config has the highest precedence among standard config files - it overrides both global and remote configs. :::tip 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 ### Custom path -You can also specify a custom config file path using the `OPENCODE_CONFIG` environment variable. +Specify a custom config file path using the `OPENCODE_CONFIG` environment variable. ```bash export OPENCODE_CONFIG=/path/to/my/custom-config.json opencode run "Hello world" ``` -Settings from this config are merged with and **can override** the global and project configs. +Custom config is loaded between global and project configs in the precedence order. --- ### Custom directory -You can specify a custom config directory using the `OPENCODE_CONFIG_DIR` +Specify a custom config directory using the `OPENCODE_CONFIG_DIR` environment variable. This directory will be searched for agents, commands, modes, and plugins just like the standard `.opencode` directory, and should follow the same structure. diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 9d8b3e052aa..cb9e6a79b13 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -44,6 +44,29 @@ You can also disable a server by setting `enabled` to `false`. This is useful if --- +### Overriding remote defaults + +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. + +To enable a specific server from your organization's remote config, add it to your local config with `enabled: true`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "jira": { + "type": "remote", + "url": "https://jira.example.com/mcp", + "enabled": true + } + } +} +``` + +Your local config values override the remote defaults. See [config precedence](/docs/config#precedence-order) for more details. + +--- + ## Local Add local MCP servers using `type` to `"local"` within the MCP object.