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) { 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/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c2d2ee2f3b73..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" @@ -100,6 +101,7 @@ export const McpCommand = cmd({ .command(McpAddCommand) .command(McpListCommand) .command(McpAuthCommand) + .command(McpApproveCommand) .command(McpLogoutCommand) .command(McpDebugCommand) .demandCommand(), @@ -114,6 +116,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) @@ -147,6 +154,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" @@ -158,8 +169,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})`, ) } @@ -333,6 +345,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", @@ -391,22 +435,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 +461,59 @@ 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 key = await ProjectKey.key(projectRoot) + const storePath = localMcpStorePath() + if (await Filesystem.exists(storePath)) { + const store = (await Filesystem.readJson(storePath)) as Record> + if (store[key]?.[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 + const servers = (parsed.mcpServers ?? parsed.servers ?? parsed) as Record + if (servers[name]) return scope + } + } + return "config" +} + export const McpAddCommand = effectCmd({ command: "add [name]", describe: "add an MCP server", @@ -448,6 +536,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 +587,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 +605,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: "Global", - value: globalConfigPath, - hint: globalConfigPath, + label: "Project", + value: "project" as const, + hint: `${resolveMcpJsonPath((ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : ctx.directory))} (shared via git)`, + }, + { + 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 +674,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 +752,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 86238f1a844c..10937a0b4ea2 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" @@ -23,6 +24,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" @@ -31,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" @@ -112,6 +115,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 = { @@ -161,7 +168,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 } @@ -224,6 +231,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 @@ -243,6 +251,29 @@ 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. 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) + if (!text) return {} as Info + const expanded = yield* Effect.promise(() => + ConfigVariable.substitute({ text, type: "path", path: filepath, env }), + ) + 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 })])), + servers, + filepath, + ) + return { mcp: validated } 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 @@ -311,6 +342,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) @@ -353,6 +403,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(/\/+$/, "") @@ -405,7 +469,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) } } @@ -422,15 +488,24 @@ 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) + 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) + } } yield* ensureGitignore(dir).pipe(Effect.orDie) @@ -465,6 +540,25 @@ 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. + // 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* Effect.promise(() => ProjectKey.key(rawRoot)) + const localMcp = yield* loadLocalMcp(projectRoot, authEnv).pipe( + Effect.catch(() => Effect.succeed({})), + ) + 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" const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { 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/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 808aa3029625..cad3ea9be9bf 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,80 @@ 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)) + // 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 + }) + 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/index.ts b/packages/opencode/src/mcp/index.ts index 05f12fa2ee45..df9e7200aac0 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" @@ -29,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" @@ -96,6 +99,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 +109,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 +238,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 +414,38 @@ const layer = Layer.effect( ) }) + const cfgSvc = yield* Config.Service + const fsUtil = yield* FSUtil.Service + + // 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* Effect.promise(() => ProjectKey.key(raw)) + }) + 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 root = yield* canonicalRoot() + const approvals = yield* readApprovals() + if (approvals[root]?.[key] !== expected) { + yield* Effect.logWarning("project MCP server requires approval", { server: key, root }) + 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 +484,6 @@ const layer = Layer.effect( }) }), ) - const cfgSvc = yield* Config.Service const descendants = Effect.fnUntraced( function* (pid: number) { @@ -969,6 +1039,16 @@ const layer = Layer.effect( return "authenticated" }) + 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* canonicalRoot(), name, hash) + }) + + const revokeApproval = Effect.fn("MCP.revokeApproval")(function* (name: string) { + yield* writeApproval(yield* canonicalRoot(), name, undefined) + }) + return Service.of({ status, clients, @@ -989,6 +1069,8 @@ const layer = Layer.effect( supportsOAuth, hasStoredTokens, getAuthStatus, + approve, + revokeApproval, }) }), ) @@ -998,7 +1080,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 "." diff --git a/packages/opencode/src/mcp/keychain.ts b/packages/opencode/src/mcp/keychain.ts new file mode 100644 index 000000000000..516e949eb52b --- /dev/null +++ b/packages/opencode/src/mcp/keychain.ts @@ -0,0 +1,76 @@ +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) => { + // 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) + 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/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 => + stat(path.join(root, ".git")) + .then((info) => `${info.dev}:${info.ino}`) + .catch(() => root) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 8f72c0cb7f63..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" @@ -1477,6 +1478,214 @@ 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() + 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) + }), +) + +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() + 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) + }), +) + +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) + }), +) + +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", + ) + }), +) + +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 + 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) + // 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( + 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/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 c48c0f8ee188..d99495a25b90 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -6,12 +6,25 @@ 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; 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) + ) + } if (props.enabled) { return ✓ Enabled } @@ -22,9 +35,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 +61,22 @@ export function DialogMcp() { map(([name, status]) => ({ value: name, title: name, - description: status.status === "failed" ? "failed" : status.status, - footer: , + description: + status.status === "needs_approval" + ? "approval required" + : status.status === "needs_auth" + ? "authentication required" + : status.status === "failed" + ? "failed" + : status.status, + footer: ( + + ), category: undefined, })), ) @@ -55,13 +93,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 +101,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/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/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(() => { 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 ? [ { diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 318f013b4119..034e39e39eaa 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,38 @@ 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. 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. + +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 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..d79cebef02f9 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -42,6 +42,45 @@ 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. + +### 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 @@ -288,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