From 56c2202de488a4878837e796f0b7a91fff7deb3d Mon Sep 17 00:00:00 2001 From: savagelysubtle Date: Mon, 24 Aug 2026 15:25:16 -0700 Subject: [PATCH 01/13] fix(core): route mode-restricted writeJson through a pre-chmoded temp file writeJson created the target with umask permissions before chmodding, briefly exposing secrets group/world-readable on multi-user systems. --- packages/core/src/fs-util.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/core/src/fs-util.ts b/packages/core/src/fs-util.ts index 93a551c9b0a2..2c3101b4db4c 100644 --- a/packages/core/src/fs-util.ts +++ b/packages/core/src/fs-util.ts @@ -109,8 +109,19 @@ export namespace FSUtil { const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) { const content = JSON.stringify(data, null, 2) - yield* fs.writeFileString(path, content) - if (mode) yield* fs.chmod(path, mode) + if (!mode) { + yield* fs.writeFileString(path, content) + return + } + // Route restricted writes through a pre-chmoded temp file so the target never briefly + // exists with umask-default permissions (which are typically group/world readable). + const tmp = `${path}.tmp` + yield* fs.writeFileString(tmp, content) + yield* fs.chmod(tmp, mode) + yield* fs.rename(tmp, path).pipe( + Effect.catch((error) => fs.remove(tmp).pipe(Effect.ignore, Effect.andThen(Effect.fail(error)))), + Effect.orDie, + ) }) const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) { From 5b7775e812286e713c2729b18856cd4711813212 Mon Sep 17 00:00:00 2001 From: savagelysubtle Date: Mon, 24 Aug 2026 12:59:26 -0700 Subject: [PATCH 02/13] feat(config): add .agents project directory with dedicated mcp.json Project-level configuration can now live in .agents/, discovered from cwd up to the nearest git root plus ~/.agents for user-wide config. The directory supports opencode.json(c) for general overrides and a dedicated mcp.json holding MCP server definitions without the outer mcp wrapper. .agents sources merge after root-level files so they win on conflicts; root-level opencode.json continues to load as legacy. --- packages/opencode/src/config/config.ts | 25 +++++- packages/opencode/src/config/paths.ts | 4 +- packages/opencode/test/config/config.test.ts | 90 +++++++++++++++++++ packages/web/src/content/docs/config.mdx | 29 +++++- packages/web/src/content/docs/mcp-servers.mdx | 16 ++++ 5 files changed, 159 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 86238f1a844c..d9d47797a7fc 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -23,6 +23,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath, type InstanceContext } from "../project/instance-context" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" import { RemoteAuthError } from "@opencode-ai/core/v1/config/error" import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission" import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" @@ -243,6 +244,23 @@ const layer = Layer.effect( return yield* loadConfig(text, { path: filepath }, env) }) + // .agents/mcp.json holds only MCP server definitions; contents are validated against the `mcp` record schema + // and merged as if they were declared under the "mcp" key of a config file. + const loadMcpFile = Effect.fnUntraced(function* (filepath: string, env?: Record) { + yield* Effect.logInfo("loading", { path: filepath }) + const text = yield* readConfigFile(filepath) + if (!text) return {} as Info + const expanded = yield* Effect.promise(() => + ConfigVariable.substitute({ text, type: "path", path: filepath, env }), + ) + const servers = ConfigParse.schema( + Schema.Record(Schema.String, Schema.Union([ConfigMCPV1.Info, Schema.Struct({ enabled: Schema.Boolean })])), + ConfigParse.jsonc(expanded, filepath), + filepath, + ) + return { mcp: servers } as Info + }) + const loadGlobal = Effect.fnUntraced(function* (env?: Record) { let result: Info = {} // Seed the default global config with the schema for editor completion, but avoid writing when the user @@ -422,7 +440,7 @@ const layer = Layer.effect( const deps: Fiber.Fiber[] = [] for (const dir of directories) { - if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { + if (dir.endsWith(".opencode") || dir.endsWith(".agents") || dir === Flag.OPENCODE_CONFIG_DIR) { for (const file of ["opencode.json", "opencode.jsonc"]) { const source = path.join(dir, file) yield* Effect.logDebug(`loading config from ${source}`) @@ -431,6 +449,11 @@ const layer = Layer.effect( result.mode ??= {} result.plugin ??= [] } + if (dir.endsWith(".agents")) { + const mcpSource = path.join(dir, "mcp.json") + yield* Effect.logDebug(`loading config from ${mcpSource}`) + yield* merge(mcpSource, yield* loadMcpFile(mcpSource, authEnv)) + } } yield* ensureGitignore(dir).pipe(Effect.orDie) diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 11d90f1292ab..7f6b895311e0 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -26,13 +26,13 @@ export const directories = Effect.fn("ConfigPaths.directories")(function* (direc Global.Path.config, ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? yield* afs.up({ - targets: [".opencode"], + targets: [".opencode", ".agents"], start: directory, stop: worktree, }) : []), ...(yield* afs.up({ - targets: [".opencode"], + targets: [".opencode", ".agents"], start: Global.Path.home, stop: Global.Path.home, })), diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 8f72c0cb7f63..cedaa0b34e9a 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1477,6 +1477,96 @@ it.instance("local .opencode config can override MCP from project config", () => }), ) +it.instance("loads MCP servers from .agents/mcp.json", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* FSUtil.use.ensureDir(path.join(test.directory, ".agents")) + yield* writeConfigEffect( + path.join(test.directory, ".agents"), + { + jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true }, + }, + "mcp.json", + ) + + const config = yield* Config.use.get() + expect(config.mcp?.jira?.type).toBe("remote") + expect(config.mcp?.jira?.url).toBe("https://jira.example.com/mcp") + expect(config.mcp?.jira?.enabled).toBe(true) + }), +) + +it.instance(".agents config overrides root project config", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeConfigEffect(test.directory, { + $schema: "https://opencode.ai/config.json", + mcp: { + docs: { + type: "remote", + url: "https://docs.example.com/mcp", + enabled: false, + }, + }, + }) + yield* FSUtil.use.ensureDir(path.join(test.directory, ".agents")) + yield* writeConfigEffect( + path.join(test.directory, ".agents"), + { + $schema: "https://opencode.ai/config.json", + mcp: { + docs: { + type: "remote", + url: "https://docs.example.com/mcp", + enabled: true, + }, + }, + }, + "opencode.json", + ) + yield* writeConfigEffect(path.join(test.directory, ".agents"), { slack: { type: "remote", url: "https://slack.example.com/mcp" } }, "mcp.json") + + const config = yield* Config.use.get() + expect(config.mcp?.docs?.enabled).toBe(true) + expect(config.mcp?.slack?.url).toBe("https://slack.example.com/mcp") + }), +) + +it.instance(".agents/mcp.json overrides mcp key from .agents/opencode.json", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* FSUtil.use.ensureDir(path.join(test.directory, ".agents")) + yield* writeConfigEffect( + path.join(test.directory, ".agents"), + { + $schema: "https://opencode.ai/config.json", + mcp: { + docs: { + type: "remote", + url: "https://docs.example.com/mcp", + enabled: false, + }, + }, + }, + "opencode.json", + ) + yield* writeConfigEffect( + path.join(test.directory, ".agents"), + { + docs: { + type: "remote", + url: "https://docs.example.com/mcp", + enabled: true, + }, + }, + "mcp.json", + ) + + const config = yield* Config.use.get() + expect(config.mcp?.docs?.enabled).toBe(true) + }), +) + const remoteProjectOverride = wellKnown({ config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } }, diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 318f013b4119..87c76ddd382f 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -47,7 +47,7 @@ Config sources are loaded in this order (later sources override earlier ones): 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 +5. **`.opencode` and `.agents` directories** - agents, commands, plugins, plus `.agents/mcp.json` 6. **Inline config** (`OPENCODE_CONFIG_CONTENT` env var) - runtime overrides 7. **Managed config files** (`/Library/Application Support/opencode/` on macOS) - admin-controlled 8. **macOS managed preferences** (`.mobileconfig` via MDM) - highest priority, not user-overridable @@ -55,7 +55,7 @@ Config sources are loaded in this order (later sources override earlier ones): This means project configs can override global defaults, and global configs can override remote organizational defaults. Managed settings override everything. :::note -The `.opencode` and `~/.config/opencode` directories use **plural names** for subdirectories: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, and `themes/`. Singular names (e.g., `agent/`) are also supported for backwards compatibility. +The `.opencode`, `.agents`, and `~/.config/opencode` directories use **plural names** for subdirectories: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, and `themes/`. Singular names (e.g., `agent/`) are also supported for backwards compatibility. ::: --- @@ -122,6 +122,31 @@ This is also safe to be checked into Git and uses the same schema as the global --- +### The `.agents` directory + +The recommended home for project-level configuration is a `.agents/` directory. Like `.opencode`, it is discovered from the current directory up to the nearest Git directory (plus `~/.agents` for user-wide agent config), and it can hold `agents/`, `commands/`, `plugins/`, `skills/`, and other subdirectories just like `.opencode`. + +It supports two config files, both merged after root-level `opencode.json` (so they override it): + +- `.agents/opencode.json` (or `.jsonc`) - any config keys, same schema as global config +- `.agents/mcp.json` - MCP server definitions only; equivalent to declaring them under the `"mcp"` key, but kept in a dedicated file so teams can manage servers independently from the rest of the config + +```json title=".agents/mcp.json" +{ + "jira": { + "type": "remote", + "url": "https://jira.example.com/mcp", + "enabled": true + } +} +``` + +Note that `mcp.json` contains the server map directly - there is no outer `"mcp"` wrapper. + +Root-level `opencode.json` files continue to work and are loaded first, with `.agents` config taking precedence over them. + +--- + ### Custom path Specify a custom config file path using the `OPENCODE_CONFIG` environment variable. diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 215938ec3b11..b6fb4b70b3e8 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -42,6 +42,22 @@ You can define MCP servers in your [OpenCode Config](https://opencode.ai/docs/co You can also disable a server by setting `enabled` to `false`. This is useful if you want to temporarily disable a server without removing it from your config. +### Project-level `.agents/mcp.json` + +For projects, you can keep MCP servers in a dedicated `.agents/mcp.json` file instead of nesting them inside `opencode.json`. The file contains the server map directly - no outer `"mcp"` wrapper: + +```json title=".agents/mcp.json" +{ + "jira": { + "type": "remote", + "url": "https://jira.example.com/mcp", + "enabled": true + } +} +``` + +This file merges after root-level `opencode.json`, so servers declared here win on conflicts. + --- ### Overriding remote defaults From 1fa34adb49598bd5b7c846f997debabed20c51ba Mon Sep 17 00:00:00 2001 From: savagelysubtle Date: Mon, 24 Aug 2026 13:20:37 -0700 Subject: [PATCH 03/13] feat(mcp): accept vendor shorthand server formats in config MCP snippets copied from vendor docs (Claude Desktop, VS Code, Gemini CLI) now work without hand-translating: bare url/httpUrl entries become remote servers, and command string + args + env entries become local servers with args appended to command and env mapped to environment. Explicit type fields still win. .agents/mcp.json also accepts mcpServers/servers wrappers. --- packages/core/src/v1/config/mcp.ts | 43 +++++++++++++++++ packages/opencode/src/config/config.ts | 15 ++++-- packages/opencode/test/config/config.test.ts | 50 ++++++++++++++++++++ packages/web/src/content/docs/config.mdx | 7 ++- 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/packages/core/src/v1/config/mcp.ts b/packages/core/src/v1/config/mcp.ts index 0a2aeff12fb0..ffd015ed88d9 100644 --- a/packages/core/src/v1/config/mcp.ts +++ b/packages/core/src/v1/config/mcp.ts @@ -61,3 +61,46 @@ export type Remote = Schema.Schema.Type export const Info = Schema.Union([Local, Remote]).annotate({ discriminator: "type" }) export type Info = Schema.Schema.Type + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +// Vendors publish MCP snippets in shorthand forms (Claude Desktop, VS Code, Gemini CLI): +// `{"command": "npx", "args": [...], "env": {...}}` or `{"httpUrl": "..."}`. Expand those +// into the canonical tagged form so users can paste vendor docs without hand-translating. +export function normalizeServer(input: unknown): unknown { + if (!isRecord(input) || "type" in input) return input + + if ("command" in input || "args" in input) { + const args = Array.isArray(input.args) ? input.args : [] + const command = typeof input.command === "string" ? [input.command, ...args] : Array.isArray(input.command) ? [...input.command, ...args] : undefined + if (!command) return input + return { + type: "local", + command, + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + ...(input.env !== undefined ? { environment: input.env } : {}), + ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), + ...(input.timeout !== undefined ? { timeout: input.timeout } : {}), + } + } + + const url = typeof input.url === "string" ? input.url : typeof input.httpUrl === "string" ? input.httpUrl : undefined + if (url) { + return { + type: "remote", + url, + ...(input.headers !== undefined ? { headers: input.headers } : {}), + ...(input.oauth !== undefined ? { oauth: input.oauth } : {}), + ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), + ...(input.timeout !== undefined ? { timeout: input.timeout } : {}), + } + } + + return input +} + +export function normalizeServers(servers: unknown): unknown { + if (!isRecord(servers)) return servers + return Object.fromEntries(Object.entries(servers).map(([name, spec]) => [name, normalizeServer(spec)])) +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d9d47797a7fc..8990edac7ad2 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -225,6 +225,7 @@ const layer = Layer.effect( ), ) const parsed = ConfigParse.jsonc(expanded, source) + if (isRecord(parsed) && isRecord(parsed.mcp)) parsed.mcp = ConfigMCPV1.normalizeServers(parsed.mcp) const data = ConfigParse.schema(ConfigV1.Info, normalizeLoadedConfig(parsed), source) if (!("path" in options)) return data @@ -245,7 +246,8 @@ const layer = Layer.effect( }) // .agents/mcp.json holds only MCP server definitions; contents are validated against the `mcp` record schema - // and merged as if they were declared under the "mcp" key of a config file. + // and merged as if they were declared under the "mcp" key of a config file. A "mcpServers" or "servers" + // wrapper (Claude Desktop / Gemini CLI style) is accepted and unwrapped. const loadMcpFile = Effect.fnUntraced(function* (filepath: string, env?: Record) { yield* Effect.logInfo("loading", { path: filepath }) const text = yield* readConfigFile(filepath) @@ -253,12 +255,17 @@ const layer = Layer.effect( const expanded = yield* Effect.promise(() => ConfigVariable.substitute({ text, type: "path", path: filepath, env }), ) - const servers = ConfigParse.schema( + const parsed = ConfigParse.jsonc(expanded, filepath) + let servers = parsed + if (isRecord(parsed) && isRecord(parsed.mcpServers)) servers = parsed.mcpServers + else if (isRecord(parsed) && isRecord(parsed.servers)) servers = parsed.servers + servers = ConfigMCPV1.normalizeServers(servers) + const validated = ConfigParse.schema( Schema.Record(Schema.String, Schema.Union([ConfigMCPV1.Info, Schema.Struct({ enabled: Schema.Boolean })])), - ConfigParse.jsonc(expanded, filepath), + servers, filepath, ) - return { mcp: servers } as Info + return { mcp: validated } as Info }) const loadGlobal = Effect.fnUntraced(function* (env?: Record) { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index cedaa0b34e9a..c987c1ce322a 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1567,6 +1567,56 @@ it.instance(".agents/mcp.json overrides mcp key from .agents/opencode.json", () }), ) +it.instance(".agents/mcp.json accepts vendor shorthand server formats", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* FSUtil.use.ensureDir(path.join(test.directory, ".agents")) + yield* writeConfigEffect( + path.join(test.directory, ".agents"), + { + stripe: { httpUrl: "https://mcp.stripe.com" }, + jira: { + command: "npx", + args: ["-y", "@nexus2520/jira-mcp-server"], + env: { JIRA_EMAIL: "test@example.com", JIRA_API_TOKEN: "{env:PATH}" }, + }, + supabase: { url: "https://mcp.supabase.com/mcp", enabled: false }, + }, + "mcp.json", + ) + + const config = yield* Config.use.get() + const stripe = config.mcp?.stripe + expect(stripe && "type" in stripe ? stripe.type : undefined).toBe("remote") + expect(stripe && "url" in stripe ? stripe.url : undefined).toBe("https://mcp.stripe.com") + const jira = config.mcp?.jira + expect(jira && "type" in jira ? jira.type : undefined).toBe("local") + expect(jira && "command" in jira ? jira.command : undefined).toEqual(["npx", "-y", "@nexus2520/jira-mcp-server"]) + expect(jira && "environment" in jira ? jira.environment : undefined).toEqual({ + JIRA_EMAIL: "test@example.com", + JIRA_API_TOKEN: process.env.PATH ?? "", + }) + expect(config.mcp?.supabase?.enabled).toBe(false) + }), +) + +it.instance(".agents/mcp.json accepts mcpServers and servers wrappers", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* FSUtil.use.ensureDir(path.join(test.directory, ".agents")) + yield* writeConfigEffect( + path.join(test.directory, ".agents"), + { servers: { stripe: { httpUrl: "https://mcp.stripe.com" } } }, + "mcp.json", + ) + + const config = yield* Config.use.get() + expect(config.mcp?.stripe && "url" in config.mcp.stripe ? config.mcp.stripe.url : undefined).toBe( + "https://mcp.stripe.com", + ) + }), +) + const remoteProjectOverride = wellKnown({ config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } }, diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 87c76ddd382f..a059d93f8ac0 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -141,7 +141,12 @@ It supports two config files, both merged after root-level `opencode.json` (so t } ``` -Note that `mcp.json` contains the server map directly - there is no outer `"mcp"` wrapper. +Note that `mcp.json` contains the server map directly - there is no outer `"mcp"` wrapper. A `"mcpServers"` or `"servers"` wrapper is also accepted. + +Shorthand server formats are expanded automatically, so snippets copied from vendor docs usually work as-is: + +- `{"httpUrl": "..."} ` or a bare `{"url": "..."}` is treated as a remote server +- `{"command": "npx", "args": ["-y", "pkg"], "env": {...}}` is treated as a local server (`args` appended to `command`, `env` mapped to `environment`) Root-level `opencode.json` files continue to work and are loaded first, with `.agents` config taking precedence over them. From d426cfe86d635dd8f5e61ccd5638ecffd2c22a68 Mon Sep 17 00:00:00 2001 From: savagelysubtle Date: Mon, 24 Aug 2026 13:09:05 -0700 Subject: [PATCH 04/13] fix(mcp): harden OAuth callback port handling and credential file writes - Listen on the OAuth callback port first and treat EADDRINUSE as another opencode instance owning it, removing the check-then-listen race and the silent five-minute hang when a foreign app squats the default port. The timeout error now points users at the callbackPort option. - Route mode-restricted writeJson calls through a pre-chmoded temp file so MCP OAuth credentials never briefly exist with umask-default permissions. --- packages/opencode/src/mcp/oauth-callback.ts | 31 +++++++++++++------- packages/opencode/test/config/config.test.ts | 10 ++++--- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index 84007902b8c0..c362376beffa 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -113,21 +113,26 @@ export async function ensureRunning(redirectUri?: string): Promise { if (server) return - const running = await isPortInUse(port) - if (running) { - return - } - currentPort = port currentPath = path server = createServer(handleRequest) - await new Promise((resolve, reject) => { - server!.listen(currentPort, OAUTH_CALLBACK_HOST, () => { - resolve() + try { + await new Promise((resolve, reject) => { + server!.listen(port, OAUTH_CALLBACK_HOST, () => { + resolve() + }) + server!.on("error", reject) }) - server!.on("error", reject) - }) + } catch (error) { + server = undefined + // Another opencode instance already owns the callback port; its server will + // receive the browser redirect. Anything else on the port is foreign and the + // flow will surface through the waitForCallback timeout. + const code = error && typeof error === "object" && "code" in error ? error.code : undefined + if (code === "EADDRINUSE") return + throw error + } } export function waitForCallback(oauthState: string, mcpName?: string): Promise { @@ -137,7 +142,11 @@ export function waitForCallback(oauthState: string, mcpName?: string): Promise ) const config = yield* Config.use.get() - expect(config.mcp?.jira?.type).toBe("remote") - expect(config.mcp?.jira?.url).toBe("https://jira.example.com/mcp") - expect(config.mcp?.jira?.enabled).toBe(true) + const jira = config.mcp?.jira + expect(jira && "url" in jira ? jira.url : undefined).toBe("https://jira.example.com/mcp") + expect(jira && "type" in jira ? jira.type : undefined).toBe("remote") + expect(jira?.enabled).toBe(true) }), ) @@ -1527,8 +1528,9 @@ it.instance(".agents config overrides root project config", () => yield* writeConfigEffect(path.join(test.directory, ".agents"), { slack: { type: "remote", url: "https://slack.example.com/mcp" } }, "mcp.json") const config = yield* Config.use.get() + const slack = config.mcp?.slack + expect(slack && "url" in slack ? slack.url : undefined).toBe("https://slack.example.com/mcp") expect(config.mcp?.docs?.enabled).toBe(true) - expect(config.mcp?.slack?.url).toBe("https://slack.example.com/mcp") }), ) From d143ca6d316b97400ad590f9b1ed135867f30a40 Mon Sep 17 00:00:00 2001 From: savagelysubtle Date: Mon, 24 Aug 2026 13:33:41 -0700 Subject: [PATCH 05/13] feat(tui): add MCP authenticate action to MCP dialog The MCP dialog only offered enable/disable, leaving servers stuck in needs_auth with no in-TUI path through the OAuth flow. Add an authenticate action that starts the OAuth flow (browser opens, callback awaited) and refreshes status on completion, and surface a warning footer for needs_auth servers. --- packages/tui/src/component/dialog-mcp.tsx | 61 +++++++++++++++++++---- packages/tui/src/context/local.tsx | 3 ++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index c48c0f8ee188..a148f180e2c2 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -6,12 +6,18 @@ import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "../ import { useTheme } from "../context/theme" import { TextAttributes } from "@opentui/core" import { useSDK } from "../context/sdk" +import { useToast } from "../ui/toast" -function Status(props: { enabled: boolean; loading: boolean }) { +function Status(props: { enabled: boolean; loading: boolean; needsAuth: boolean }) { const { theme } = useTheme() if (props.loading) { return ⋯ Loading } + if (props.needsAuth) { + return ( + ⚠ Auth required (authenticate) + ) + } if (props.enabled) { return ✓ Enabled } @@ -22,9 +28,20 @@ export function DialogMcp() { const local = useLocal() const sync = useSync() const sdk = useSDK() + const toast = useToast() const [, setRef] = createSignal>() const [loading, setLoading] = createSignal(null) + const refreshStatus = async () => { + // Refresh MCP status from server + const status = await sdk.client.mcp.status() + if (status.data) { + sync.set("mcp", status.data) + } else { + console.error("Failed to refresh MCP status: no data returned") + } + } + const options = createMemo(() => { // Track sync data and loading state to trigger re-render when they change const mcpData = sync.data.mcp @@ -37,8 +54,10 @@ export function DialogMcp() { map(([name, status]) => ({ value: name, title: name, - description: status.status === "failed" ? "failed" : status.status, - footer: , + description: status.status === "needs_auth" ? "authentication required" : status.status === "failed" ? "failed" : status.status, + footer: ( + + ), category: undefined, })), ) @@ -55,13 +74,7 @@ export function DialogMcp() { setLoading(option.value) try { await local.mcp.toggle(option.value) - // Refresh MCP status from server - const status = await sdk.client.mcp.status() - if (status.data) { - sync.set("mcp", status.data) - } else { - console.error("Failed to refresh MCP status: no data returned") - } + await refreshStatus() } catch (error) { console.error("Failed to toggle MCP:", error) } finally { @@ -69,6 +82,34 @@ export function DialogMcp() { } }, }, + { + command: "dialog.mcp.authenticate", + title: "authenticate", + onTrigger: async (option: DialogSelectOption) => { + if (loading() !== null) return + + setLoading(option.value) + toast.show({ + variant: "info", + message: `Opening your browser to authorize ${option.value}...`, + duration: 10000, + }) + try { + await local.mcp.authenticate(option.value) + await refreshStatus() + toast.show({ variant: "success", message: `${option.value} authenticated` }) + } catch (error) { + console.error("Failed to authenticate MCP:", error) + toast.show({ + variant: "error", + message: `Authentication failed for ${option.value}: ${error instanceof Error ? error.message : String(error)}`, + duration: 10000, + }) + } finally { + setLoading(null) + } + }, + }, ]) return ( diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index a05d315166ed..ade9782ed541 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -517,6 +517,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ await sdk.client.mcp.connect({ name }) } }, + async authenticate(name: string) { + await sdk.client.mcp.auth.authenticate({ name }) + }, } createEffect(() => { From f868678e580a7a49a02d977b0f62ee209384354a Mon Sep 17 00:00:00 2001 From: savagelysubtle Date: Mon, 24 Aug 2026 13:55:38 -0700 Subject: [PATCH 06/13] feat(tui): bind enter to MCP authenticate in MCP dialog Dialog actions could not be triggered from the footer when bound to return because dialog.select.submit matched first. Order action bindings before the generic select bindings so dialog-specific actions win, and bind MCP authenticate to enter (with a as mnemonic). --- packages/tui/src/config/keybind.ts | 1 + packages/tui/src/ui/dialog-select.tsx | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 5dd7e4b5aafe..47c8ba73dbdc 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -208,6 +208,7 @@ export const Definitions = { "dialog.select.submit": keybind("return", "Submit selected dialog item"), "dialog.prompt.submit": keybind("return", "Submit dialog prompt"), "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), + "dialog.mcp.authenticate": keybind("return,a", "Authenticate MCP in MCP dialog"), "dialog.move_session.new": keybind("ctrl+m", "New project copy"), "dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"), "dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"), diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index df2913ede08c..3d94b791219f 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -448,6 +448,9 @@ export function DialogSelect(props: DialogSelectProps) { })), ], bindings: [ + // Dialog actions take precedence over the generic select bindings so an action bound to + // "return" (e.g. MCP authenticate) wins over the no-op submit. + ...visible.flatMap((item) => tuiConfig.keybinds.get(item.command)), ...tuiConfig.keybinds.gather("dialog.select", [ "dialog.select.prev", "dialog.select.next", @@ -457,7 +460,6 @@ export function DialogSelect(props: DialogSelectProps) { "dialog.select.end", "dialog.select.submit", ]), - ...visible.flatMap((item) => tuiConfig.keybinds.get(item.command)), ...(visible.length ? [ { From 5df4bf2fa16a9b54b6f47e17cdee37e034cc4fbd Mon Sep 17 00:00:00 2001 From: savagelysubtle Date: Mon, 24 Aug 2026 14:32:49 -0700 Subject: [PATCH 07/13] feat(mcp): add local scope for per-project private servers Add Claude Code-style MCP scopes. opencode mcp add now accepts --scope local|project|user: local (default) stores the server in ~/.local/share/opencode/mcp-local.json keyed by project root so credentials never live in the repository; project writes .agents/mcp.json; user writes ~/.agents/mcp.json. Local entries replace same-named project/user definitions wholesale. mcp list shows each server's scope. Non-git projects key local scope by working directory since worktree is / there. --- packages/opencode/src/cli/cmd/mcp.ts | 141 +++++++++++++----- packages/opencode/src/config/config.ts | 30 ++++ packages/opencode/test/config/config.test.ts | 47 ++++++ packages/web/src/content/docs/config.mdx | 2 + packages/web/src/content/docs/mcp-servers.mdx | 23 +++ 5 files changed, 205 insertions(+), 38 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c2d2ee2f3b73..e1789d8faa76 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -114,6 +114,11 @@ export const McpListCommand = effectCmd({ UI.empty() prompts.intro("MCP Servers") + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + const projectRoot = (ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory) + const { config, statuses, stored } = yield* listState() const servers = configuredServers(config) @@ -158,8 +163,9 @@ export const McpListCommand = effectCmd({ } const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") + const scope = yield* Effect.promise(() => detectScope(name, projectRoot)) prompts.log.info( - `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, + `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint} (${scope})`, ) } @@ -391,22 +397,13 @@ export const McpLogoutCommand = effectCmd({ }), }) -async function resolveConfigPath(baseDir: string, global = false) { - // Check for existing config files (prefer .jsonc over .json, check .opencode/ subdirectory too) - const candidates = [path.join(baseDir, "opencode.json"), path.join(baseDir, "opencode.jsonc")] - - if (!global) { - candidates.push(path.join(baseDir, ".opencode", "opencode.json"), path.join(baseDir, ".opencode", "opencode.jsonc")) - } - - for (const candidate of candidates) { - if (await Filesystem.exists(candidate)) { - return candidate - } - } +// .agents/mcp.json holds the bare server map (no "mcp" wrapper) +function resolveMcpJsonPath(baseDir: string) { + return path.join(baseDir, ".agents", "mcp.json") +} - // Default to opencode.json if none exist - return candidates[0] +function localMcpStorePath() { + return path.join(Global.Path.data, "mcp-local.json") } async function addMcpToConfig(name: string, mcpConfig: ConfigMCPV1.Info, configPath: string) { @@ -426,6 +423,57 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCPV1.Info, configP return configPath } +// Project and user scope write .agents/mcp.json with the server map at the top level +async function addMcpToJson(name: string, mcpConfig: ConfigMCPV1.Info, configPath: string) { + let text = "{}" + if (await Filesystem.exists(configPath)) { + text = await Filesystem.readText(configPath) + } + + const edits = modify(text, [name], mcpConfig, { + formattingOptions: { tabSize: 2, insertSpaces: true }, + }) + const result = applyEdits(text, edits) + + await Filesystem.write(configPath, result) + + return configPath +} + +// Local scope stores per-project servers in the data directory, keyed by project root, +// so credentials never live inside the repository. File holds secrets, so 0o600. +async function addMcpToLocal(name: string, mcpConfig: ConfigMCPV1.Info, projectRoot: string) { + const storePath = localMcpStorePath() + let store: Record> = {} + if (await Filesystem.exists(storePath)) { + store = (await Filesystem.readJson(storePath)) as Record> + } + store[projectRoot] = { ...(store[projectRoot] ?? {}), [name]: mcpConfig } + await Filesystem.writeJson(storePath, store, 0o600) + return storePath +} + +type McpScope = "local" | "project" | "user" | "config" + +async function detectScope(name: string, projectRoot: string): Promise { + const storePath = localMcpStorePath() + if (await Filesystem.exists(storePath)) { + const store = (await Filesystem.readJson(storePath)) as Record> + if (store[projectRoot]?.[name]) return "local" + } + for (const [scope, base] of [ + ["project", projectRoot], + ["user", Global.Path.home], + ] as const) { + const mcpJson = path.join(base, ".agents", "mcp.json") + if (await Filesystem.exists(mcpJson)) { + const parsed = (await Filesystem.readJson(mcpJson)) as Record + if (parsed[name]) return scope + } + } + return "config" +} + export const McpAddCommand = effectCmd({ command: "add [name]", describe: "add an MCP server", @@ -448,6 +496,11 @@ export const McpAddCommand = effectCmd({ describe: "HTTP header for a remote MCP server (KEY=VALUE)", type: "string", array: true, + }) + .option("scope", { + describe: "where to store the server: local (private, this project), project (.agents/mcp.json, shared), user (all projects)", + type: "string", + choices: ["local", "project", "user"], }), handler: Effect.fn("Cli.mcp.add")(function* (args) { const maybeCtx = yield* InstanceRef @@ -494,9 +547,16 @@ export const McpAddCommand = effectCmd({ ...(Object.keys(environment).length ? { environment } : {}), } - const configPath = await resolveConfigPath(Global.Path.config, true) - await addMcpToConfig(args.name, mcpConfig, configPath) - prompts.log.success(`MCP server "${args.name}" added to ${configPath}`) + const scope = args.scope ?? "local" + let configPath: string + if (scope === "local") { + configPath = await addMcpToLocal(args.name, mcpConfig, (ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory)) + } else if (scope === "project") { + configPath = await addMcpToJson(args.name, mcpConfig, resolveMcpJsonPath((ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory))) + } else { + configPath = await addMcpToJson(args.name, mcpConfig, resolveMcpJsonPath(Global.Path.home)) + } + prompts.log.success(`MCP server "${args.name}" added to ${configPath} (${scope} scope)`) return } @@ -505,32 +565,37 @@ export const McpAddCommand = effectCmd({ const project = ctx.project - // Resolve config paths eagerly for hints - const [projectConfigPath, globalConfigPath] = await Promise.all([ - resolveConfigPath(ctx.worktree), - resolveConfigPath(Global.Path.config, true), - ]) - // Determine scope - let configPath = globalConfigPath + let scope: "local" | "project" | "user" = "local" if (project.vcs === "git") { const scopeResult = await prompts.select({ - message: "Location", + message: "Scope", options: [ { - label: "Current project", - value: projectConfigPath, - hint: projectConfigPath, + label: "Local", + value: "local" as const, + hint: "private to you, this project only (stored in home)", + }, + { + label: "Project", + value: "project" as const, + hint: `${resolveMcpJsonPath((ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory))} (shared via git)`, }, { - label: "Global", - value: globalConfigPath, - hint: globalConfigPath, + label: "User", + value: "user" as const, + hint: "available in all your projects (stored in home)", }, ], }) if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() - configPath = scopeResult + scope = scopeResult + } + + const writeScope = (name: string, mcpConfig: ConfigMCPV1.Info) => { + if (scope === "local") return addMcpToLocal(name, mcpConfig, (ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory)) + if (scope === "project") return addMcpToJson(name, mcpConfig, resolveMcpJsonPath((ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory))) + return addMcpToJson(name, mcpConfig, resolveMcpJsonPath(Global.Path.home)) } const name = await prompts.text({ @@ -569,8 +634,8 @@ export const McpAddCommand = effectCmd({ command: command.split(" "), } - await addMcpToConfig(name, mcpConfig, configPath) - prompts.log.success(`MCP server "${name}" added to ${configPath}`) + const configPath = await writeScope(name, mcpConfig) + prompts.log.success(`MCP server "${name}" added to ${configPath} (${scope} scope)`) prompts.outro("MCP server added successfully") return } @@ -647,8 +712,8 @@ export const McpAddCommand = effectCmd({ } } - await addMcpToConfig(name, mcpConfig, configPath) - prompts.log.success(`MCP server "${name}" added to ${configPath}`) + const configPath = await writeScope(name, mcpConfig) + prompts.log.success(`MCP server "${name}" added to ${configPath} (${scope} scope)`) } prompts.outro("MCP server added successfully") diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 8990edac7ad2..1cbf72b0c20a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -336,6 +336,25 @@ const layer = Layer.effect( } }) + // Local-scope MCP servers: per-project, private to the user, stored in the data directory so + // credentials never live in the project. Keyed by project root, Claude Code's ~/.claude.json pattern. + const loadLocalMcp = Effect.fnUntraced(function* (projectRoot: string, env?: Record) { + const filepath = path.join(Global.Path.data, "mcp-local.json") + if (!existsSync(filepath)) return {} + const text = yield* readConfigFile(filepath) + if (!text) return {} + const expanded = yield* Effect.promise(() => + ConfigVariable.substitute({ text, type: "path", path: filepath, env }), + ) + const parsed = ConfigParse.jsonc(expanded, filepath) + const projects = ConfigParse.schema( + Schema.Record(Schema.String, Schema.Record(Schema.String, ConfigMCPV1.Info)), + parsed, + filepath, + ) + return projects[projectRoot] ?? {} + }) + const loadInstanceState = Effect.fn("Config.loadInstanceState")( function* (ctx: InstanceContext) { const auth = yield* authSvc.all().pipe(Effect.orDie) @@ -495,6 +514,17 @@ const layer = Layer.effect( yield* mergePluginOrigins(dir, list) } + // Local-scope MCP entries replace same-named servers from project/user config wholesale, + // matching Claude Code's scope semantics instead of field-level merging. + // Non-git projects set worktree to "/", so fall back to the working directory for the key. + const projectRoot = ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory + const localMcp = yield* loadLocalMcp(projectRoot, authEnv).pipe( + Effect.catch(() => Effect.succeed({})), + ) + if (Object.keys(localMcp).length) { + result.mcp = { ...(result.mcp ?? {}), ...localMcp } + } + if (process.env.OPENCODE_CONFIG_CONTENT) { const source = "OPENCODE_CONFIG_CONTENT" const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index f13772c8af2e..6329a9ad6994 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1619,6 +1619,53 @@ it.instance(".agents/mcp.json accepts mcpServers and servers wrappers", () => }), ) +it.instance("local scope MCP overrides project config and ignores other projects", () => + Effect.gen(function* () { + const test = yield* TestInstance + const dataDir = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "opencode-data-"))) + const previousData = Global.Path.data + ;(Global.Path as { data: string }).data = dataDir + + yield* Effect.gen(function* () { + yield* FSUtil.use.ensureDir(path.join(test.directory, ".agents")) + yield* writeConfigEffect( + path.join(test.directory, ".agents"), + { + jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false }, + shared: { type: "remote", url: "https://shared.example.com/mcp" }, + }, + "mcp.json", + ) + // Local store keyed by project root; an entry under a different project must be ignored + yield* FSUtil.use.writeJson( + path.join(dataDir, "mcp-local.json"), + { + [test.directory]: { + jira: { type: "remote", url: "https://private.example.com/mcp", enabled: true }, + }, + ["/some/other/project"]: { + jira: { type: "remote", url: "https://wrong.example.com/mcp" }, + }, + }, + 0o600, + ) + + const config = yield* Config.use.get() + const jira = config.mcp?.jira + expect(jira && "url" in jira ? jira.url : undefined).toBe("https://private.example.com/mcp") + expect(jira?.enabled).toBe(true) + const shared = config.mcp?.shared + expect(shared && "url" in shared ? shared.url : undefined).toBe("https://shared.example.com/mcp") + }).pipe( + Effect.ensuring( + Effect.sync(() => { + ;(Global.Path as { data: string }).data = previousData + }), + ), + ) + }), +) + const remoteProjectOverride = wellKnown({ config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } }, diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index a059d93f8ac0..034e39e39eaa 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -150,6 +150,8 @@ Shorthand server formats are expanded automatically, so snippets copied from ven Root-level `opencode.json` files continue to work and are loaded first, with `.agents` config taking precedence over them. +MCP servers additionally support a per-project **local scope**: `opencode mcp add --scope local` stores the server in `~/.local/share/opencode/mcp-local.json`, keyed by project path. Local entries are private to you, never committed, and replace same-named servers from project and user config. + --- ### Custom path diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index b6fb4b70b3e8..40f7276f50e6 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -58,6 +58,29 @@ For projects, you can keep MCP servers in a dedicated `.agents/mcp.json` file in This file merges after root-level `opencode.json`, so servers declared here win on conflicts. +### Scopes + +Servers can be managed at three scopes with `opencode mcp add`: + +| Scope | Loads in | Shared with team | Stored in | +| --------- | --------------------- | ------------------------- | --------- | +| `local` | Current project only | No | `~/.local/share/opencode/mcp-local.json` (keyed by project path) | +| `project` | Current project only | Yes, via version control | `.agents/mcp.json` in the project | +| `user` | All your projects | No | `~/.agents/mcp.json` | + +```bash +# Private to this project - credentials stay out of the repository (default) +opencode mcp add jira --scope local -- npx -y @nexus2520/jira-mcp-server --env JIRA_API_TOKEN=... + +# Shared with the team via git +opencode mcp add stripe --scope project --url https://mcp.stripe.com + +# Available in all your projects +opencode mcp add supabase --scope user --url https://mcp.supabase.com/mcp +``` + +Use `local` scope for servers with credentials: the configuration is stored in your home directory, keyed by project path, and never committed. When the same server name exists at multiple scopes, the local entry replaces project and user definitions wholesale. + --- ### Overriding remote defaults From 8d1dbf821f751ed8da7db3f24a7433fe97ccb620 Mon Sep 17 00:00:00 2001 From: savagelysubtle Date: Mon, 24 Aug 2026 15:04:39 -0700 Subject: [PATCH 08/13] feat(mcp): require approval for project-scoped servers Project-owned MCP definitions (project opencode.json and .agents/mcp.json) are hashed at load time and will not connect until explicitly approved with 'opencode mcp approve '. Choices are stored in the data directory keyed by project root, never in the repository, so a cloned repo cannot approve its own servers. Editing a definition invalidates its approval. Local-scope entries are user-private and exempt. Adds a needs_approval status surfaced in the MCP dialog and mcp list. --- packages/opencode/src/cli/cmd/mcp.ts | 37 +++++++++ packages/opencode/src/config/config.ts | 32 +++++++- packages/opencode/src/mcp/index.ts | 78 ++++++++++++++++++- packages/opencode/test/config/config.test.ts | 20 +++++ packages/opencode/test/session/prompt.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 7 ++ packages/tui/src/component/dialog-mcp.tsx | 25 +++++- 8 files changed, 198 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index e1789d8faa76..6c010b045be1 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -100,6 +100,7 @@ export const McpCommand = cmd({ .command(McpAddCommand) .command(McpListCommand) .command(McpAuthCommand) + .command(McpApproveCommand) .command(McpLogoutCommand) .command(McpDebugCommand) .demandCommand(), @@ -152,6 +153,10 @@ export const McpListCommand = effectCmd({ } else if (status.status === "needs_auth") { statusIcon = "⚠" statusText = "needs authentication" + } else if (status.status === "needs_approval") { + statusIcon = "⚠" + statusText = "needs approval" + hint = "\n " + UI.Style.TEXT_DIM + "review the definition, then run: opencode mcp approve " + name } else if (status.status === "needs_client_registration") { statusIcon = "✗" statusText = "needs client registration" @@ -339,6 +344,38 @@ export const McpAuthListCommand = effectCmd({ }), }) +export const McpApproveCommand = effectCmd({ + command: "approve ", + describe: "approve a project-scoped MCP server for connection", + builder: (yargs) => + yargs + .positional("name", { + describe: "name of the MCP server", + type: "string", + demandOption: true, + }) + .option("revoke", { + describe: "revoke a previously granted approval", + type: "boolean", + default: false, + }), + handler: Effect.fn("Cli.mcp.approve")(function* (args) { + UI.empty() + if (args.revoke) { + yield* MCP.Service.use((mcp) => mcp.revokeApproval(args.name!)) + prompts.log.success(`Approval revoked for "${args.name}"`) + return + } + yield* MCP.Service.use((mcp) => mcp.approve(args.name!)).pipe( + Effect.orElseSucceed(() => { + prompts.log.error(`"${args.name}" is not a project-scoped MCP server`) + throw new UI.CancelledError() + }), + ) + prompts.log.success(`"${args.name}" approved for connection in this project`) + }), +}) + export const McpLogoutCommand = effectCmd({ command: "logout [name]", describe: "remove OAuth credentials for an MCP server", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1cbf72b0c20a..99e8640940bd 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,4 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { createHash } from "crypto" import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { serviceUse } from "@opencode-ai/core/effect/service-use" import path from "path" @@ -113,6 +114,10 @@ type Info = ConfigV1.Info & { // plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together // with the file and scope it came from so later runtime code can make location-sensitive decisions. plugin_origins?: ConfigPlugin.Origin[] + // mcp_project_scope maps server names defined in project-owned config files (checked-into-git territory) + // to a hash of their definition. Derived state like plugin_origins; the MCP service uses it to require + // approval before connecting project-supplied servers, so a cloned repo can't auto-connect its own servers. + mcp_project_scope?: Record } type State = { @@ -162,7 +167,7 @@ function patchJsonc(input: string, patch: unknown, path: string[] = []): string } function writable(info: Info) { - const { plugin_origins: _plugin_origins, ...next } = info + const { plugin_origins: _plugin_origins, mcp_project_scope: _mcp_project_scope, ...next } = info return next } @@ -397,6 +402,20 @@ const layer = Layer.effect( return mergePluginOrigins(source, next.plugin, kind) } + // Track MCP servers defined by project-owned config so the MCP service can require approval + // before connecting them. A hash of each winning definition is kept so edited entries + // re-trigger the approval prompt. + const projectMcp: Record = {} + const snapshotMcp = () => JSON.stringify(result.mcp ?? {}) + const markProjectMcp = (before: string) => { + const previous = JSON.parse(before) as Record + for (const [name, entry] of Object.entries(result.mcp ?? {})) { + if (JSON.stringify(previous[name]) !== JSON.stringify(entry)) { + projectMcp[name] = createHash("sha256").update(JSON.stringify(entry)).digest("hex") + } + } + } + for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { const url = key.replace(/\/+$/, "") @@ -449,7 +468,9 @@ const layer = Layer.effect( if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) { + const before = snapshotMcp() yield* merge(file, yield* loadFile(file, authEnv), "local") + markProjectMcp(before) } } @@ -469,16 +490,20 @@ const layer = Layer.effect( if (dir.endsWith(".opencode") || dir.endsWith(".agents") || dir === Flag.OPENCODE_CONFIG_DIR) { for (const file of ["opencode.json", "opencode.jsonc"]) { const source = path.join(dir, file) + const before = snapshotMcp() yield* Effect.logDebug(`loading config from ${source}`) yield* merge(source, yield* loadFile(source, authEnv)) + if (containsPath(dir, ctx)) markProjectMcp(before) result.agent ??= {} result.mode ??= {} result.plugin ??= [] } if (dir.endsWith(".agents")) { const mcpSource = path.join(dir, "mcp.json") + const before = snapshotMcp() yield* Effect.logDebug(`loading config from ${mcpSource}`) yield* merge(mcpSource, yield* loadMcpFile(mcpSource, authEnv)) + if (containsPath(dir, ctx)) markProjectMcp(before) } } @@ -523,7 +548,12 @@ const layer = Layer.effect( ) if (Object.keys(localMcp).length) { result.mcp = { ...(result.mcp ?? {}), ...localMcp } + // Local entries are user-private; they win over project definitions and need no approval. + for (const name of Object.keys(localMcp)) { + delete projectMcp[name] + } } + result.mcp_project_scope = projectMcp if (process.env.OPENCODE_CONFIG_CONTENT) { const source = "OPENCODE_CONFIG_CONTENT" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 05f12fa2ee45..7c01b7090a4d 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -1,5 +1,7 @@ import path from "node:path" +import fs from "node:fs/promises" import { pathToFileURL } from "node:url" +import { Global } from "@opencode-ai/core/global" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { serviceUse } from "@opencode-ai/core/effect/service-use" @@ -96,6 +98,9 @@ const StatusNeedsClientRegistration = Schema.Struct({ status: Schema.Literal("needs_client_registration"), error: Schema.String, }).annotate({ identifier: "MCPStatusNeedsClientRegistration" }) +const StatusNeedsApproval = Schema.Struct({ status: Schema.Literal("needs_approval") }).annotate({ + identifier: "MCPStatusNeedsApproval", +}) export const Status = Schema.Union([ StatusConnected, @@ -103,9 +108,46 @@ export const Status = Schema.Union([ StatusFailed, StatusNeedsAuth, StatusNeedsClientRegistration, + StatusNeedsApproval, ]).annotate({ identifier: "MCPStatus", discriminator: "status" }) export type Status = Schema.Schema.Type +// Project-scope servers require approval before first connect (and again whenever their definition +// changes), so a cloned repository can't silently connect its own MCP servers. Choices are stored +// in the data directory, never in the project. +const approvalsFilepath = () => path.join(Global.Path.data, "mcp-approvals.json") + +const readApprovals = Effect.fn("MCP.readApprovals")(function* () { + const text = yield* Effect.promise(() => + fs + .readFile(approvalsFilepath(), "utf8") + .then((text) => JSON.parse(text) as Record>) + .catch(() => ({}) as Record>), + ) + return text +}) + +const writeApproval = Effect.fn("MCP.writeApproval")(function* ( + root: string, + name: string, + hash: string | undefined, +) { + const data = { ...(yield* readApprovals()) } + if (hash) { + data[root] = { ...(data[root] ?? {}), [name]: hash } + } else { + const entry = {...(data[root] ?? {}) } + delete entry[name] + if (Object.keys(entry).length) data[root] = entry + else delete data[root] + } + yield* Effect.promise(() => + fs + .mkdir(Global.Path.data, { recursive: true }) + .then(() => fs.writeFile(approvalsFilepath(), JSON.stringify(data, null, 2), { mode: 0o600 })), + ) +}) + // Store transports for OAuth servers to allow finishing auth type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport const pendingOAuthTransports = new Map() @@ -195,6 +237,8 @@ export interface Interface { readonly supportsOAuth: (mcpName: string) => Effect.Effect readonly hasStoredTokens: (mcpName: string) => Effect.Effect readonly getAuthStatus: (mcpName: string) => Effect.Effect + readonly approve: (name: string) => Effect.Effect + readonly revokeApproval: (name: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/MCP") {} @@ -369,12 +413,28 @@ const layer = Layer.effect( ) }) + const cfgSvc = yield* Config.Service + const create = Effect.fn("MCP.create")( function* (key: string, mcp: ConfigMCPV1.Info) { if (mcp.enabled === false) { return DISABLED_RESULT } + // Project-supplied servers need explicit approval (and re-approval when edited) before + // they may connect. Local-scope and user config are exempt. + const projectScope = (yield* cfgSvc.get()).mcp_project_scope + const expected = projectScope?.[key] + if (expected) { + const ctx = yield* InstanceState.context + const root = ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory + const approvals = yield* readApprovals() + if (approvals[root]?.[key] !== expected) { + yield* Effect.logWarning("project MCP server requires approval", { server: key }) + return { status: { status: "needs_approval" } } satisfies CreateResult + } + } + const { client: mcpClient, status } = mcp.type === "remote" ? yield* connectRemote(key, mcp as ConfigMCPV1.Info & { type: "remote" }) @@ -413,7 +473,6 @@ const layer = Layer.effect( }) }), ) - const cfgSvc = yield* Config.Service const descendants = Effect.fnUntraced( function* (pid: number) { @@ -969,6 +1028,21 @@ const layer = Layer.effect( return "authenticated" }) + const projectRoot = Effect.fn("MCP.projectRoot")(function* () { + const ctx = yield* InstanceState.context + return ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory + }) + + const approve = Effect.fn("MCP.approve")(function* (name: string) { + const hash = (yield* cfgSvc.get()).mcp_project_scope?.[name] + if (!hash) return yield* new NotFoundError({ name }) + yield* writeApproval(yield* projectRoot(), name, hash) + }) + + const revokeApproval = Effect.fn("MCP.revokeApproval")(function* (name: string) { + yield* writeApproval(yield* projectRoot(), name, undefined) + }) + return Service.of({ status, clients, @@ -989,6 +1063,8 @@ const layer = Layer.effect( supportsOAuth, hasStoredTokens, getAuthStatus, + approve, + revokeApproval, }) }), ) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 6329a9ad6994..d21ede7167c7 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -29,6 +29,7 @@ import { import { InstanceRuntime } from "@/project/instance-runtime" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" +import { createHash } from "crypto" import path from "path" import fs from "fs/promises" import os from "os" @@ -1619,6 +1620,23 @@ it.instance(".agents/mcp.json accepts mcpServers and servers wrappers", () => }), ) +it.instance("project mcp servers are marked for approval", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* FSUtil.use.ensureDir(path.join(test.directory, ".agents")) + yield* writeConfigEffect( + path.join(test.directory, ".agents"), + { stripe: { httpUrl: "https://mcp.stripe.com" } }, + "mcp.json", + ) + + const config = yield* Config.use.get() + const hash = config.mcp_project_scope?.stripe + expect(hash).toBeDefined() + expect(hash).toBe(createHash("sha256").update(JSON.stringify(config.mcp?.stripe)).digest("hex")) + }), +) + it.instance("local scope MCP overrides project config and ignores other projects", () => Effect.gen(function* () { const test = yield* TestInstance @@ -1654,6 +1672,8 @@ it.instance("local scope MCP overrides project config and ignores other projects const jira = config.mcp?.jira expect(jira && "url" in jira ? jira.url : undefined).toBe("https://private.example.com/mcp") expect(jira?.enabled).toBe(true) + // Local scope replaced the project definition, so no approval should be required + expect(config.mcp_project_scope?.jira).toBeUndefined() const shared = config.mcp?.shared expect(shared && "url" in shared ? shared.url : undefined).toBe("https://shared.example.com/mcp") }).pipe( diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index da6e0f8d036f..5e59dd5af772 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -132,6 +132,8 @@ function makeMcp(instructions: MCP.ServerInstructions[] = []) { supportsOAuth: () => Effect.succeed(false), hasStoredTokens: () => Effect.succeed(false), getAuthStatus: () => Effect.succeed("not_authenticated" as const), + approve: () => Effect.void, + revokeApproval: () => Effect.void, }), ) } diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 1265237840f3..0e6428d26a23 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -54,6 +54,8 @@ const mcp = Layer.succeed( supportsOAuth: () => Effect.succeed(false), hasStoredTokens: () => Effect.succeed(false), getAuthStatus: () => Effect.succeed("not_authenticated" as const), + approve: () => Effect.void, + revokeApproval: () => Effect.void, }), ) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 72b5e6f30ace..9585da63bb27 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1922,6 +1922,7 @@ export type Config = { ] > share?: "manual" | "auto" | "disabled" + title_style?: "descriptive" | "funny" autoshare?: boolean /** * Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications @@ -2017,6 +2018,7 @@ export type Config = { tail_turns?: number preserve_recent_tokens?: number reserved?: number + threshold?: number } experimental?: { disable_paste_summary?: boolean @@ -2404,12 +2406,17 @@ export type McpStatusNeedsClientRegistration = { error: string } +export type McpStatusNeedsApproval = { + status: "needs_approval" +} + export type McpStatus = | McpStatusConnected | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth | McpStatusNeedsClientRegistration + | McpStatusNeedsApproval export type McpUnsupportedOAuthError = { error: string diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index a148f180e2c2..d99495a25b90 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -8,11 +8,18 @@ import { TextAttributes } from "@opentui/core" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" -function Status(props: { enabled: boolean; loading: boolean; needsAuth: boolean }) { +function Status(props: { enabled: boolean; loading: boolean; needsAuth: boolean; needsApproval: boolean }) { const { theme } = useTheme() if (props.loading) { return ⋯ Loading } + if (props.needsApproval) { + return ( + + ⚠ Approval required (opencode mcp approve) + + ) + } if (props.needsAuth) { return ( ⚠ Auth required (authenticate) @@ -54,9 +61,21 @@ export function DialogMcp() { map(([name, status]) => ({ value: name, title: name, - description: status.status === "needs_auth" ? "authentication required" : status.status === "failed" ? "failed" : status.status, + description: + status.status === "needs_approval" + ? "approval required" + : status.status === "needs_auth" + ? "authentication required" + : status.status === "failed" + ? "failed" + : status.status, footer: ( - + ), category: undefined, })), From 2f118ca18e1315dc973c570054ce306c76392017 Mon Sep 17 00:00:00 2001 From: savagelysubtle Date: Mon, 24 Aug 2026 15:17:00 -0700 Subject: [PATCH 09/13] feat(mcp): store OAuth secrets in the OS keychain MCP OAuth tokens and client secrets now go to the OS keychain (macOS security, Linux secret-service via secret-tool) when available, matching Gemini CLI's hybrid approach. The auth file keeps only non-secret metadata as an enumeration index; legacy plaintext entries remain readable and migrate into the keychain on their next save. If the keychain write fails, credentials stay in the 0600 file rather than being lost. OPENCODE_MCP_FORCE_FILE_STORAGE=1 forces file storage. --- packages/opencode/src/mcp/auth.ts | 74 ++++++++++++++++++- packages/opencode/src/mcp/keychain.ts | 73 ++++++++++++++++++ packages/web/src/content/docs/mcp-servers.mdx | 15 ++++ 3 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/mcp/keychain.ts diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 808aa3029625..f5134674c759 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -5,6 +5,7 @@ import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Context, Option, Schema } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Keychain } from "./keychain" export const Tokens = Schema.Struct({ accessToken: Schema.mutableKey(Schema.String), @@ -62,6 +63,10 @@ const layer = Layer.effect( const fs = yield* FSUtil.Service const flock = yield* EffectFlock.Service + // Secrets (tokens, client secrets) live in the OS keychain when available; the file keeps only + // non-secret metadata as an enumeration index. Legacy file entries with embedded secrets remain + // readable and migrate into the keychain on their next save. Locking note: the locked wrappers + // below must only call unlocked cores — the file lock is not reentrant. const read = Effect.fn("McpAuth.read")(function* () { return yield* fs.readJson(filepath).pipe( Effect.map((data): AuthData => Option.getOrElse(decodeAuthData(data), () => ({}) as AuthData) as AuthData), @@ -69,15 +74,78 @@ const layer = Layer.effect( ) }) + const keychainName = (mcpName: string) => mcpName.replace(/\s+/g, "_") + + const keychainGet = Effect.fn("McpAuth.keychainGet")(function* (mcpName: string) { + if (!(yield* Keychain.available())) return undefined + const value = yield* Keychain.get(keychainName(mcpName)) + if (value === undefined) return undefined + return Option.getOrElse(decodeAuthData({ [mcpName]: value }), () => ({}) as AuthData)[mcpName] + }) + + const readFileData = Effect.fn("McpAuth.readFileData")(function* () { + return yield* read() + }) + + const writeFileData = Effect.fn("McpAuth.writeFileData")(function* (update: (data: AuthData) => AuthData) { + const next = update(yield* read()) + yield* fs.writeJson(filepath, next, 0o600).pipe(Effect.orDie) + }) + + const persistUnlocked = Effect.fn("McpAuth.persistUnlocked")(function* ( + mcpName: string, + entry: Entry | undefined, + ) { + if (!entry) { + if (yield* Keychain.available()) yield* Keychain.remove(keychainName(mcpName)) + yield* writeFileData((data) => { + const next = { ...data } + delete next[mcpName] + return next + }) + return + } + const { tokens, clientInfo, ...metadata } = entry + if (yield* Keychain.available()) { + const stored = yield* Keychain.set(keychainName(mcpName), { tokens, clientInfo }) + // Only strip secrets from the file once the keychain round-trips; otherwise keep the + // legacy plaintext entry rather than risking the credentials. + if (stored) { + yield* writeFileData((data) => ({ ...data, [mcpName]: metadata })) + return + } + } + yield* writeFileData((data) => ({ ...data, [mcpName]: entry })) + }) + + const readMerged = Effect.fn("McpAuth.readMerged")(function* () { + const fileData = yield* readFileData() + const merged: AuthData = {} + for (const [name, entry] of Object.entries(fileData)) { + const fromKeychain = yield* keychainGet(name).pipe(Effect.orElseSucceed(() => undefined)) + merged[name] = fromKeychain ?? entry + } + return merged + }) + const all = Effect.fn("McpAuth.all")(function* () { - return yield* read().pipe(flock.withLock(lockKey), Effect.orDie) + return yield* readMerged().pipe(flock.withLock(lockKey), Effect.orDie) }) const mutate = Effect.fn("McpAuth.mutate")(function* (update: (data: AuthData) => AuthData | undefined) { yield* Effect.gen(function* () { - const next = update(yield* read()) + const current = yield* readMerged() + // Snapshot before running update: update functions may mutate entries in place, which + // would make a naive before/after diff compare the mutated object against itself. + const before = new Map(Object.entries(current).map(([name, entry]) => [name, JSON.stringify(entry)])) + const next = update(current) if (!next) return - yield* fs.writeJson(filepath, next, 0o600).pipe(Effect.orDie) + const names = new Set([...Object.keys(current), ...Object.keys(next)]) + for (const name of names) { + const after = JSON.stringify(next[name] ?? {}) + if ((before.get(name) ?? "{}") === after) continue + yield* persistUnlocked(name, next[name]) + } }).pipe(flock.withLock(lockKey), Effect.orDie) }) diff --git a/packages/opencode/src/mcp/keychain.ts b/packages/opencode/src/mcp/keychain.ts new file mode 100644 index 000000000000..a0c399d0ab24 --- /dev/null +++ b/packages/opencode/src/mcp/keychain.ts @@ -0,0 +1,73 @@ +import { Effect } from "effect" +import { spawn } from "child_process" + +// OS keychain access for MCP OAuth secrets via the platform CLI tools, mirroring Gemini CLI's +// hybrid approach: macOS `security`, Linux secret-service `secret-tool`. When no tool is +// available (or OPENCODE_MCP_FORCE_FILE_STORAGE=1) callers fall back to the file store. +const SERVICE = "opencode-mcp" + +const forcedFileStorage = () => process.env["OPENCODE_MCP_FORCE_FILE_STORAGE"] === "1" + +const run = (cmd: string, args: string[], input?: string) => + Effect.promise( + () => + new Promise<{ code: number; stdout: string }>((resolve) => { + const child = spawn(cmd, args, { stdio: input === undefined ? "ignore" : "pipe" }) + let stdout = "" + if (input !== undefined && child.stdin) { + child.stdin.write(input) + child.stdin.end() + } + child.stdout?.on("data", (chunk) => (stdout += chunk.toString())) + child.on("error", () => resolve({ code: 1, stdout: "" })) + child.on("close", (code) => resolve({ code: code ?? 1, stdout })) + }), + ) + +let availableCache: boolean | undefined + +const available = Effect.fn("Keychain.available")(function* () { + if (forcedFileStorage()) return false + if (availableCache !== undefined) return availableCache + const probe = + process.platform === "darwin" + ? yield* run("security", ["list-keychains"]) + : process.platform === "linux" + ? yield* run("sh", ["-c", "command -v secret-tool"]) + : { code: 1, stdout: "" } + availableCache = probe.code === 0 + return availableCache +}) + +const get = Effect.fn("Keychain.get")(function* (name: string) { + const result = + process.platform === "darwin" + ? yield* run("security", ["find-generic-password", "-s", SERVICE, "-a", name, "-w"]) + : yield* run("secret-tool", ["lookup", "service", SERVICE, "account", name]) + const text = result.stdout.trim() + if (result.code !== 0 || !text) return undefined + try { + return JSON.parse(text) as unknown + } catch { + return undefined + } +}) + +const set = Effect.fn("Keychain.set")(function* (name: string, value: unknown) { + const payload = JSON.stringify(value) + const result = + process.platform === "darwin" + ? yield* run("security", ["add-generic-password", "-s", SERVICE, "-a", name, "-w", payload, "-U"]) + : yield* run("secret-tool", ["store", "--label=opencode-mcp", "service", SERVICE, "account", name], payload) + return result.code === 0 +}) + +const remove = Effect.fn("Keychain.remove")(function* (name: string) { + const result = + process.platform === "darwin" + ? yield* run("security", ["delete-generic-password", "-s", SERVICE, "-a", name]) + : yield* run("secret-tool", ["clear", "service", SERVICE, "account", name]) + return result.code === 0 +}) + +export const Keychain = { available, get, set, remove } diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 40f7276f50e6..d79cebef02f9 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -327,6 +327,21 @@ opencode mcp debug my-oauth-server The `mcp debug` command shows the current auth status, tests HTTP connectivity, and attempts the OAuth discovery flow. +#### Token storage + +OAuth tokens and client secrets are stored in your OS keychain (macOS Keychain via `security`, Linux secret-service via `secret-tool`) when available, falling back to a permission-restricted file (`~/.local/share/opencode/mcp-auth.json`, mode 0600) otherwise. Set `OPENCODE_MCP_FORCE_FILE_STORAGE=1` to always use the file. + +#### Project server approval + +MCP servers defined in project-owned config files (project `opencode.json` or `.agents/mcp.json`) require explicit approval before they connect, and re-approval whenever their definition changes. This prevents a cloned repository from silently connecting its own servers. Approve or revoke with: + +```bash +opencode mcp approve my-server +opencode mcp approve my-server --revoke +``` + +Approval choices are stored per project in `~/.local/share/opencode/mcp-approvals.json`, never in the repository. Servers in user or local scope are your own and need no approval. + --- ## Manage From ab63ead3497fc6746642a9cf85b0d732e452b8dc Mon Sep 17 00:00:00 2001 From: savagelysubtle Date: Mon, 24 Aug 2026 15:21:09 -0700 Subject: [PATCH 10/13] fix(mcp): detect scope through mcpServers/servers wrappers --- packages/opencode/src/cli/cmd/mcp.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 6c010b045be1..e3ef3704e40c 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -505,7 +505,8 @@ async function detectScope(name: string, projectRoot: string): Promise const mcpJson = path.join(base, ".agents", "mcp.json") if (await Filesystem.exists(mcpJson)) { const parsed = (await Filesystem.readJson(mcpJson)) as Record - if (parsed[name]) return scope + const servers = (parsed.mcpServers ?? parsed.servers ?? parsed) as Record + if (servers[name]) return scope } } return "config" From 46f9d37478d6f3b5d0bbae6dffe9faa57e55a7df Mon Sep 17 00:00:00 2001 From: savagelysubtle Date: Mon, 24 Aug 2026 15:39:38 -0700 Subject: [PATCH 11/13] fix(mcp): keep keychain stdout piped and merge secret fields over file metadata secret-tool lookups returned empty because stdio ignore disabled stdout for input-less commands, so keychain reads never saw stored secrets. Keychain hits now layer secret fields over the file entry instead of replacing it, preserving serverUrl, codeVerifier, and oauthState. --- packages/opencode/src/mcp/auth.ts | 4 +++- packages/opencode/src/mcp/keychain.ts | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index f5134674c759..cad3ea9be9bf 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -123,7 +123,9 @@ const layer = Layer.effect( const merged: AuthData = {} for (const [name, entry] of Object.entries(fileData)) { const fromKeychain = yield* keychainGet(name).pipe(Effect.orElseSucceed(() => undefined)) - merged[name] = fromKeychain ?? entry + // The keychain holds only the secret fields; file metadata (serverUrl, codeVerifier, + // oauthState) layers underneath so a keychain hit doesn't erase it. + merged[name] = fromKeychain ? { ...entry, ...fromKeychain } : entry } return merged }) diff --git a/packages/opencode/src/mcp/keychain.ts b/packages/opencode/src/mcp/keychain.ts index a0c399d0ab24..516e949eb52b 100644 --- a/packages/opencode/src/mcp/keychain.ts +++ b/packages/opencode/src/mcp/keychain.ts @@ -12,7 +12,10 @@ const run = (cmd: string, args: string[], input?: string) => Effect.promise( () => new Promise<{ code: number; stdout: string }>((resolve) => { - const child = spawn(cmd, args, { stdio: input === undefined ? "ignore" : "pipe" }) + // stdout must stay piped even for write operations so lookups can read secrets back + const child = spawn(cmd, args, { + stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], + }) let stdout = "" if (input !== undefined && child.stdin) { child.stdin.write(input) From cf5cc3e2ac2262f0e1741717e7ce5fd4dc1c0520 Mon Sep 17 00:00:00 2001 From: savagelysubtle Date: Mon, 24 Aug 2026 21:51:53 -0700 Subject: [PATCH 12/13] fix(mcp): canonicalize project root for approval and local-scope keys The same project reached through a symlinked path produced a different approval/local-scope key than the realpath, so approvals granted from one launch path never matched sessions launched from the other (Cursor workspaces open the symlink form). Canonicalize with realpath before keying approvals, local-scope storage, and approve/revoke. --- packages/opencode/src/config/config.ts | 5 ++++- packages/opencode/src/mcp/index.ts | 23 +++++++++++++---------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 99e8640940bd..cbd1480a5c3f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -542,7 +542,10 @@ const layer = Layer.effect( // Local-scope MCP entries replace same-named servers from project/user config wholesale, // matching Claude Code's scope semantics instead of field-level merging. // Non-git projects set worktree to "/", so fall back to the working directory for the key. - const projectRoot = ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory + // The key is canonicalized (realpath) so reaching the project through a symlinked path + // resolves to the same local-scope entry. + const rawRoot = ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory + const projectRoot = yield* fs.resolve(rawRoot).pipe(Effect.orElseSucceed(() => rawRoot)) const localMcp = yield* loadLocalMcp(projectRoot, authEnv).pipe( Effect.catch(() => Effect.succeed({})), ) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 7c01b7090a4d..82536e9a2e6b 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -414,6 +414,15 @@ const layer = Layer.effect( }) const cfgSvc = yield* Config.Service + const fs = yield* FSUtil.Service + + // Approvals and local-scope storage must key on the canonical project root: the same project + // reached through a symlinked path must not produce a second identity (or a second approval). + const canonicalRoot = Effect.fn("MCP.canonicalRoot")(function* () { + const ctx = yield* InstanceState.context + const raw = ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory + return yield* fs.resolve(raw).pipe(Effect.orElseSucceed(() => raw)) + }) const create = Effect.fn("MCP.create")( function* (key: string, mcp: ConfigMCPV1.Info) { @@ -426,8 +435,7 @@ const layer = Layer.effect( const projectScope = (yield* cfgSvc.get()).mcp_project_scope const expected = projectScope?.[key] if (expected) { - const ctx = yield* InstanceState.context - const root = ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory + const root = yield* canonicalRoot() const approvals = yield* readApprovals() if (approvals[root]?.[key] !== expected) { yield* Effect.logWarning("project MCP server requires approval", { server: key }) @@ -1028,19 +1036,14 @@ const layer = Layer.effect( return "authenticated" }) - const projectRoot = Effect.fn("MCP.projectRoot")(function* () { - const ctx = yield* InstanceState.context - return ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory - }) - const approve = Effect.fn("MCP.approve")(function* (name: string) { const hash = (yield* cfgSvc.get()).mcp_project_scope?.[name] if (!hash) return yield* new NotFoundError({ name }) - yield* writeApproval(yield* projectRoot(), name, hash) + yield* writeApproval(yield* canonicalRoot(), name, hash) }) const revokeApproval = Effect.fn("MCP.revokeApproval")(function* (name: string) { - yield* writeApproval(yield* projectRoot(), name, undefined) + yield* writeApproval(yield* canonicalRoot(), name, undefined) }) return Service.of({ @@ -1074,7 +1077,7 @@ export type AuthStatus = "authenticated" | "expired" | "not_authenticated" export const node = LayerNode.make({ service: Service, layer: layer, - deps: [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node, McpBrowser.node], + deps: [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node, McpBrowser.node, FSUtil.node], }) export * as MCP from "." From c4bbbba4664505cbb9f9e798e0dd2b98a6b86e62 Mon Sep 17 00:00:00 2001 From: savagelysubtle Date: Mon, 24 Aug 2026 22:23:37 -0700 Subject: [PATCH 13/13] fix(mcp): key project state on git directory identity, not path strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One project can be reachable through several equally-real paths (symlinks, bind mounts) and realpath cannot unify bind mounts, so approval and local-scope keys derived from the raw worktree string made servers demand re-approval (or vanish) depending on launch path. Key approvals, local-scope storage, and scope detection on the .git directory's device+inode instead — identical across every path variant for a git repo, falling back to the path string for non-git directories. --- packages/opencode/src/cli/cmd/mcp.ts | 4 +++- packages/opencode/src/config/config.ts | 7 ++++--- packages/opencode/src/mcp/index.ts | 13 ++++++++----- packages/opencode/src/project/project-key.ts | 13 +++++++++++++ 4 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/src/project/project-key.ts diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index e3ef3704e40c..4c35f7b60647 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -17,6 +17,7 @@ import { InstanceRef } from "@/effect/instance-ref" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "path" import { Global } from "@opencode-ai/core/global" +import { ProjectKey } from "@/project/project-key" import { modify, applyEdits } from "jsonc-parser" import { Filesystem } from "@/util/filesystem" import { Effect } from "effect" @@ -493,10 +494,11 @@ async function addMcpToLocal(name: string, mcpConfig: ConfigMCPV1.Info, projectR type McpScope = "local" | "project" | "user" | "config" async function detectScope(name: string, projectRoot: string): Promise { + const key = await ProjectKey.key(projectRoot) const storePath = localMcpStorePath() if (await Filesystem.exists(storePath)) { const store = (await Filesystem.readJson(storePath)) as Record> - if (store[projectRoot]?.[name]) return "local" + if (store[key]?.[name]) return "local" } for (const [scope, base] of [ ["project", projectRoot], diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index cbd1480a5c3f..10937a0b4ea2 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -33,6 +33,7 @@ import { ConfigCommand } from "./command" import { ConfigManaged } from "./managed" import { ConfigParse } from "./parse" import { ConfigPaths } from "./paths" +import { ProjectKey } from "@/project/project-key" import { ConfigPlugin } from "./plugin" import { ConfigVariable } from "./variable" import { Npm } from "@opencode-ai/core/npm" @@ -542,10 +543,10 @@ const layer = Layer.effect( // Local-scope MCP entries replace same-named servers from project/user config wholesale, // matching Claude Code's scope semantics instead of field-level merging. // Non-git projects set worktree to "/", so fall back to the working directory for the key. - // The key is canonicalized (realpath) so reaching the project through a symlinked path - // resolves to the same local-scope entry. + // The key is the git directory's device+inode (ProjectKey) so reaching the project through + // a symlinked or bind-mounted path resolves to the same local-scope entry. const rawRoot = ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory - const projectRoot = yield* fs.resolve(rawRoot).pipe(Effect.orElseSucceed(() => rawRoot)) + const projectRoot = yield* Effect.promise(() => ProjectKey.key(rawRoot)) const localMcp = yield* loadLocalMcp(projectRoot, authEnv).pipe( Effect.catch(() => Effect.succeed({})), ) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 82536e9a2e6b..df9e7200aac0 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -31,6 +31,7 @@ import { TuiEvent } from "@/server/tui-event" import { Cause, Effect, Exit, Layer, Context, Schema, Stream } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" +import { ProjectKey } from "@/project/project-key" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { McpCatalog } from "./catalog" @@ -414,14 +415,16 @@ const layer = Layer.effect( }) const cfgSvc = yield* Config.Service - const fs = yield* FSUtil.Service + const fsUtil = yield* FSUtil.Service - // Approvals and local-scope storage must key on the canonical project root: the same project - // reached through a symlinked path must not produce a second identity (or a second approval). + // Approvals and local-scope storage must key on a canonical project identity: the same project + // reached through a symlinked or bind-mounted path must not produce a second identity (or a + // second approval). ProjectKey keys on the git directory's device+inode, which is identical + // across every path variant. const canonicalRoot = Effect.fn("MCP.canonicalRoot")(function* () { const ctx = yield* InstanceState.context const raw = ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory - return yield* fs.resolve(raw).pipe(Effect.orElseSucceed(() => raw)) + return yield* Effect.promise(() => ProjectKey.key(raw)) }) const create = Effect.fn("MCP.create")( @@ -438,7 +441,7 @@ const layer = Layer.effect( const root = yield* canonicalRoot() const approvals = yield* readApprovals() if (approvals[root]?.[key] !== expected) { - yield* Effect.logWarning("project MCP server requires approval", { server: key }) + yield* Effect.logWarning("project MCP server requires approval", { server: key, root }) return { status: { status: "needs_approval" } } satisfies CreateResult } } diff --git a/packages/opencode/src/project/project-key.ts b/packages/opencode/src/project/project-key.ts new file mode 100644 index 000000000000..e3796a4ed507 --- /dev/null +++ b/packages/opencode/src/project/project-key.ts @@ -0,0 +1,13 @@ +export * as ProjectKey from "./project-key" + +import { stat } from "node:fs/promises" +import path from "path" + +// One project can be reachable through several equally-real paths (symlinks, bind mounts) and +// realpath cannot unify bind mounts. The .git directory's device+inode is identical across every +// path variant, so project-scoped state keys on it when present, falling back to the path string +// for non-git directories. +export const key = (root: string): Promise => + stat(path.join(root, ".git")) + .then((info) => `${info.dev}:${info.ino}`) + .catch(() => root)