From c1b3d117dc3d67118943cf5440732b17af63216a Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Wed, 3 Dec 2025 15:49:19 +1100 Subject: [PATCH 01/11] feat: add per-project MCP config overrides Allow project configs to override the 'enabled' field of MCP servers defined in global config without redefining the full configuration. This enables two patterns: - Define MCPs globally, disable per-project - Define MCPs globally disabled, enable per-project Added 'Missing configuration' error when an override references an MCP that has no base config defined. UI: Right-align MCP status labels in sidebar to match Modified Files. --- .../cli/cmd/tui/routes/session/sidebar.tsx | 4 +- packages/opencode/src/config/config.ts | 11 +- packages/opencode/src/mcp/index.ts | 10 + packages/opencode/test/config/config.test.ts | 206 ++++++++++++++++++ packages/web/src/content/docs/mcp-servers.mdx | 52 +++++ 5 files changed, 280 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index c1c29a73160..595a157c8e7 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -100,7 +100,7 @@ export function Sidebar(props: { sessionID: string }) { {([key, item]) => ( - + - + {key}{" "} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 42f6b11e9f5..49be4f34946 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -367,7 +367,16 @@ export namespace Config { ref: "McpRemoteConfig", }) - export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote]) + export const McpOverride = z + .object({ + enabled: z.boolean().describe("Enable or disable the MCP server on startup"), + }) + .strict() + .meta({ + ref: "McpOverrideConfig", + }) + + export const Mcp = z.union([z.discriminatedUnion("type", [McpLocal, McpRemote]), McpOverride]) export type Mcp = z.infer export const Permission = z.enum(["ask", "allow", "deny"]) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 7b5f816508b..092af79c96d 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -156,6 +156,16 @@ export namespace MCP { status: { status: "disabled" as const }, } } + if (!("type" in mcp)) { + log.warn("mcp override without base config", { key }) + return { + mcpClient: undefined, + status: { + status: "failed" as const, + error: "Missing configuration", + }, + } + } log.info("found", { key, type: mcp.type }) let mcpClient: MCPClient | undefined let status: Status | undefined = undefined diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 2ff8c01cdb0..fc2e6e9a311 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -501,3 +501,209 @@ test("deduplicates duplicate plugins from global and local configs", async () => }, }) }) + +test("accepts full MCP configuration", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + "my-mcp-foo": { + type: "remote", + url: "https://my-mcp-server-foo.com", + }, + "my-mcp-bar": { + type: "local", + command: ["npx", "-y", "my-mcp-command"], + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.mcp?.["my-mcp-foo"]).toEqual({ + type: "remote", + url: "https://my-mcp-server-foo.com", + }) + expect(config.mcp?.["my-mcp-bar"]).toEqual({ + type: "local", + command: ["npx", "-y", "my-mcp-command"], + }) + }, + }) +}) + +test("accepts MCP override with only enabled field", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + "my-mcp-foo": { enabled: false }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.mcp?.["my-mcp-foo"]).toEqual({ enabled: false }) + }, + }) +}) + +test("merges MCP override with full config from parent", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const projectDir = path.join(dir, "project") + const opencodeDir = path.join(projectDir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + + // Parent config with full MCP definitions + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + "my-mcp-foo": { + type: "remote", + url: "https://my-mcp-server-foo.com", + }, + "my-mcp-bar": { + type: "remote", + url: "https://my-mcp-server-bar.com", + }, + "my-mcp-baz": { + type: "local", + command: ["npx", "-y", "my-mcp-command"], + }, + }, + }), + ) + + // Project config with overrides + await Bun.write( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + "my-mcp-foo": { enabled: true }, + "my-mcp-baz": { enabled: false }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: path.join(tmp.path, "project"), + fn: async () => { + const config = await Config.get() + + // my-mcp-foo should have full config merged with enabled: true + expect(config.mcp?.["my-mcp-foo"]).toEqual({ + type: "remote", + url: "https://my-mcp-server-foo.com", + enabled: true, + }) + + // my-mcp-bar should be unchanged (no override) + expect(config.mcp?.["my-mcp-bar"]).toEqual({ + type: "remote", + url: "https://my-mcp-server-bar.com", + }) + + // my-mcp-baz should have full config merged with enabled: false + expect(config.mcp?.["my-mcp-baz"]).toEqual({ + type: "local", + command: ["npx", "-y", "my-mcp-command"], + enabled: false, + }) + }, + }) +}) + +test("project can enable globally disabled MCP", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const projectDir = path.join(dir, "project") + const opencodeDir = path.join(projectDir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + + // Global config with disabled MCP + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + "my-mcp-foo": { + type: "remote", + url: "https://my-mcp-server-foo.com", + enabled: false, + }, + }, + }), + ) + + // Project enables it + await Bun.write( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + "my-mcp-foo": { enabled: true }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: path.join(tmp.path, "project"), + fn: async () => { + const config = await Config.get() + expect(config.mcp?.["my-mcp-foo"]).toEqual({ + type: "remote", + url: "https://my-mcp-server-foo.com", + enabled: true, + }) + }, + }) +}) + +test("MCP override without base config results in override-only object", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Only override, no base config defined + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + "my-mcp-nonexistent": { enabled: true }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + // The config will have just the override (no type field) + // MCP initialization will fail with "Missing configuration" + expect(config.mcp?.["my-mcp-nonexistent"]).toEqual({ enabled: true }) + expect("type" in (config.mcp?.["my-mcp-nonexistent"] ?? {})).toBe(false) + }, + }) +}) diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 48b38442c7d..e96d15f401c 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -54,6 +54,58 @@ You can also disable a server by setting `enabled` to `false`. This is useful if --- +### Per-project overrides + +If you define MCP servers in your global config, you can override the `enabled` field per-project without redefining the full configuration. + +```jsonc title="~/.config/opencode/opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-mcp-foo": { "type": "remote", "url": "https://my-mcp-server-foo.com" }, + "my-mcp-bar": { "type": "remote", "url": "https://my-mcp-server-bar.com" }, + "my-mcp-baz": { "type": "local", "command": ["npx", "-y", "my-mcp-command"] }, + }, +} +``` + +```jsonc title="~/my-project/.opencode/opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-mcp-foo": { "enabled": true }, + "my-mcp-bar": { "enabled": true }, + // my-mcp-baz not mentioned - uses global config (enabled by default) + }, +} +``` + +This also works in reverse - disable globally, enable per-project: + +```jsonc title="~/.config/opencode/opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-mcp-foo": { "type": "remote", "url": "https://my-mcp-server-foo.com", "enabled": false }, + }, +} +``` + +```jsonc title="~/my-project/.opencode/opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-mcp-foo": { "enabled": true }, + }, +} +``` + +:::note +If you specify an override for an MCP that isn't defined in a parent config, it will show as failed with the error "Missing configuration". +::: + +--- + ### Local Add local MCP servers using `type` to `"local"` within the MCP object. From 4e42c69121a2207d26cde7727a18f47a214dc3f8 Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Fri, 12 Dec 2025 12:16:10 +1100 Subject: [PATCH 02/11] refactor(plugin): migrate to SDK v2 Update plugin package and plugin loader to import from @opencode-ai/sdk/v2 instead of the legacy v1 SDK. Required for compatibility with McpOverrideConfig type added in the MCP config overrides feature. --- packages/opencode/src/plugin/index.ts | 2 +- packages/plugin/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index b492c7179e6..977e22375ff 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -2,7 +2,7 @@ import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/ import { Config } from "../config/config" import { Bus } from "../bus" import { Log } from "../util/log" -import { createOpencodeClient } from "@opencode-ai/sdk" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Server } from "../server/server" import { BunProc } from "../bun" import { Instance } from "../project/instance" diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 57ca75d604f..956f241055e 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -10,7 +10,7 @@ import type { Part, Auth, Config, -} from "@opencode-ai/sdk" +} from "@opencode-ai/sdk/v2" import type { BunShell } from "./shell" import { type ToolDefinition } from "./tool" From 42dde5c43fac86cf9939e605e0e906a679ce42d2 Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Fri, 12 Dec 2025 12:16:20 +1100 Subject: [PATCH 03/11] fix(mcp): add type guards for MCP config override support Add type guard functions to safely distinguish between full MCP configs (local/remote) and override-only configs which only have the 'enabled' field. - Add isFullMcpConfig, isLocalMcpConfig, isRemoteMcpConfig helpers - Update MCP CLI commands to use type guards - Update OAuth flow to use type guards - Filter override configs when adding MCP servers via ACP Fixes type errors introduced by McpOverrideConfig in the per-project MCP config overrides feature. --- packages/opencode/src/acp/agent.ts | 32 +++++++++++++++----------- packages/opencode/src/cli/cmd/mcp.ts | 16 +++++++++---- packages/opencode/src/config/config.ts | 19 +++++++++++++++ packages/opencode/src/mcp/index.ts | 4 ++-- 4 files changed, 50 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index ae4798d963d..3e2a9114d9e 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -495,20 +495,24 @@ export namespace ACP { } await Promise.all( - Object.entries(mcpServers).map(async ([key, mcp]) => { - await this.sdk.mcp - .add( - { - directory, - name: key, - config: mcp, - }, - { throwOnError: true }, - ) - .catch((error) => { - log.error("failed to add mcp server", { name: key, error }) - }) - }), + Object.entries(mcpServers) + .filter((entry): entry is [string, Config.McpLocalConfig | Config.McpRemoteConfig] => + Config.isFullMcpConfig(entry[1]), + ) + .map(async ([key, mcp]) => { + await this.sdk.mcp + .add( + { + directory, + name: key, + config: mcp, + }, + { throwOnError: true }, + ) + .catch((error) => { + log.error("failed to add mcp server", { name: key, error }) + }) + }), ) setTimeout(() => { diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 9ca4b3bff8b..ac1fb040e2b 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -46,7 +46,7 @@ export const McpListCommand = cmd({ for (const [name, serverConfig] of Object.entries(mcpServers)) { const status = statuses[name] - const hasOAuth = serverConfig.type === "remote" && !!serverConfig.oauth + const hasOAuth = Config.isRemoteMcpConfig(serverConfig) && !!serverConfig.oauth const hasStoredTokens = await MCP.hasStoredTokens(name) let statusIcon: string @@ -78,7 +78,11 @@ export const McpListCommand = cmd({ hint = "\n " + status.error } - const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") + const typeHint = Config.isRemoteMcpConfig(serverConfig) + ? serverConfig.url + : Config.isLocalMcpConfig(serverConfig) + ? serverConfig.command.join(" ") + : "(override)" prompts.log.info( `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, ) @@ -109,7 +113,9 @@ export const McpAuthCommand = cmd({ const mcpServers = config.mcp ?? {} // Get OAuth-enabled servers - const oauthServers = Object.entries(mcpServers).filter(([_, cfg]) => cfg.type === "remote" && !!cfg.oauth) + const oauthServers = Object.entries(mcpServers).filter( + ([_, cfg]) => Config.isRemoteMcpConfig(cfg) && !!cfg.oauth, + ) if (oauthServers.length === 0) { prompts.log.warn("No OAuth-enabled MCP servers configured") @@ -135,7 +141,7 @@ export const McpAuthCommand = cmd({ options: oauthServers.map(([name, cfg]) => ({ label: name, value: name, - hint: cfg.type === "remote" ? cfg.url : undefined, + hint: Config.isRemoteMcpConfig(cfg) ? cfg.url : undefined, })), }) if (prompts.isCancel(selected)) throw new UI.CancelledError() @@ -149,7 +155,7 @@ export const McpAuthCommand = cmd({ return } - if (serverConfig.type !== "remote" || !serverConfig.oauth) { + if (!Config.isRemoteMcpConfig(serverConfig) || !serverConfig.oauth) { prompts.log.error(`MCP server ${serverName} does not have OAuth configured`) prompts.outro("Done") return diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 49be4f34946..62ec3074beb 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -379,6 +379,25 @@ export namespace Config { export const Mcp = z.union([z.discriminatedUnion("type", [McpLocal, McpRemote]), McpOverride]) export type Mcp = z.infer + export type McpLocalConfig = z.infer + export type McpRemoteConfig = z.infer + export type McpOverrideConfig = z.infer + + /** Type guard to check if MCP config is a full config (local or remote) vs an override */ + export function isFullMcpConfig(config: Mcp): config is McpLocalConfig | McpRemoteConfig { + return "type" in config + } + + /** Type guard to check if MCP config is a local config */ + export function isLocalMcpConfig(config: Mcp): config is McpLocalConfig { + return "type" in config && config.type === "local" + } + + /** Type guard to check if MCP config is a remote config */ + export function isRemoteMcpConfig(config: Mcp): config is McpRemoteConfig { + return "type" in config && config.type === "remote" + } + export const Permission = z.enum(["ask", "allow", "deny"]) export type Permission = z.infer diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 092af79c96d..9bfc61b9aad 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -435,7 +435,7 @@ export namespace MCP { throw new Error(`MCP server not found: ${mcpName}`) } - if (mcpConfig.type !== "remote") { + if (!Config.isRemoteMcpConfig(mcpConfig)) { throw new Error(`MCP server ${mcpName} is not a remote server`) } @@ -573,7 +573,7 @@ export namespace MCP { export async function supportsOAuth(mcpName: string): Promise { const cfg = await Config.get() const mcpConfig = cfg.mcp?.[mcpName] - return mcpConfig?.type === "remote" && mcpConfig.oauth !== false + return !!mcpConfig && Config.isRemoteMcpConfig(mcpConfig) && mcpConfig.oauth !== false } /** From 2229108251e59ca1af7b22f560414860bd034003 Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Fri, 12 Dec 2025 12:37:14 +1100 Subject: [PATCH 04/11] chore: regenerate SDK --- packages/sdk/js/src/v2/gen/sdk.gen.ts | 3 ++- packages/sdk/js/src/v2/gen/types.gen.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 90df76c2234..1a754b32ae4 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -45,6 +45,7 @@ import type { McpConnectResponses, McpDisconnectResponses, McpLocalConfig, + McpOverrideConfig, McpRemoteConfig, McpStatusResponses, PathGetResponses, @@ -2112,7 +2113,7 @@ export class Mcp extends HeyApiClient { parameters?: { directory?: string name?: string - config?: McpLocalConfig | McpRemoteConfig + config?: McpLocalConfig | McpRemoteConfig | McpOverrideConfig }, options?: Options, ) { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f8890d9fb70..62b61951d88 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1310,6 +1310,13 @@ export type McpRemoteConfig = { timeout?: number } +export type McpOverrideConfig = { + /** + * Enable or disable the MCP server on startup + */ + enabled: boolean +} + /** * @deprecated Always uses stretch layout. */ @@ -1424,7 +1431,7 @@ export type Config = { * MCP (Model Context Protocol) server configurations */ mcp?: { - [key: string]: McpLocalConfig | McpRemoteConfig + [key: string]: McpLocalConfig | McpRemoteConfig | McpOverrideConfig } formatter?: | false @@ -3559,7 +3566,7 @@ export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] export type McpAddData = { body?: { name: string - config: McpLocalConfig | McpRemoteConfig + config: McpLocalConfig | McpRemoteConfig | McpOverrideConfig } path?: never query?: { From 2f1a1813453ab34488982f9c939c6e173da8360a Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Fri, 12 Dec 2025 13:27:28 +1100 Subject: [PATCH 05/11] revert: Remove unnecessary properties and changes --- packages/opencode/src/acp/agent.ts | 32 +++++++++++--------------- packages/opencode/src/config/config.ts | 19 --------------- 2 files changed, 14 insertions(+), 37 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 3e2a9114d9e..ae4798d963d 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -495,24 +495,20 @@ export namespace ACP { } await Promise.all( - Object.entries(mcpServers) - .filter((entry): entry is [string, Config.McpLocalConfig | Config.McpRemoteConfig] => - Config.isFullMcpConfig(entry[1]), - ) - .map(async ([key, mcp]) => { - await this.sdk.mcp - .add( - { - directory, - name: key, - config: mcp, - }, - { throwOnError: true }, - ) - .catch((error) => { - log.error("failed to add mcp server", { name: key, error }) - }) - }), + Object.entries(mcpServers).map(async ([key, mcp]) => { + await this.sdk.mcp + .add( + { + directory, + name: key, + config: mcp, + }, + { throwOnError: true }, + ) + .catch((error) => { + log.error("failed to add mcp server", { name: key, error }) + }) + }), ) setTimeout(() => { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 62ec3074beb..49be4f34946 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -379,25 +379,6 @@ export namespace Config { export const Mcp = z.union([z.discriminatedUnion("type", [McpLocal, McpRemote]), McpOverride]) export type Mcp = z.infer - export type McpLocalConfig = z.infer - export type McpRemoteConfig = z.infer - export type McpOverrideConfig = z.infer - - /** Type guard to check if MCP config is a full config (local or remote) vs an override */ - export function isFullMcpConfig(config: Mcp): config is McpLocalConfig | McpRemoteConfig { - return "type" in config - } - - /** Type guard to check if MCP config is a local config */ - export function isLocalMcpConfig(config: Mcp): config is McpLocalConfig { - return "type" in config && config.type === "local" - } - - /** Type guard to check if MCP config is a remote config */ - export function isRemoteMcpConfig(config: Mcp): config is McpRemoteConfig { - return "type" in config && config.type === "remote" - } - export const Permission = z.enum(["ask", "allow", "deny"]) export type Permission = z.infer From c93b7c341ec71bb096243eb2f4c933956ec714fd Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Fri, 12 Dec 2025 13:38:09 +1100 Subject: [PATCH 06/11] fix: check for type prop to avoid using overrides --- packages/opencode/src/cli/cmd/mcp.ts | 23 +++++++++++++---------- packages/opencode/src/mcp/index.ts | 5 +++-- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index ac1fb040e2b..c4bb98a4af9 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -46,7 +46,7 @@ export const McpListCommand = cmd({ for (const [name, serverConfig] of Object.entries(mcpServers)) { const status = statuses[name] - const hasOAuth = Config.isRemoteMcpConfig(serverConfig) && !!serverConfig.oauth + const hasOAuth = "type" in serverConfig && serverConfig.type === "remote" && !!serverConfig.oauth const hasStoredTokens = await MCP.hasStoredTokens(name) let statusIcon: string @@ -78,11 +78,12 @@ export const McpListCommand = cmd({ hint = "\n " + status.error } - const typeHint = Config.isRemoteMcpConfig(serverConfig) - ? serverConfig.url - : Config.isLocalMcpConfig(serverConfig) - ? serverConfig.command.join(" ") - : "(override)" + const typeHint = + "type" in serverConfig + ? serverConfig.type === "remote" + ? serverConfig.url + : serverConfig.command.join(" ") + : "override" prompts.log.info( `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, ) @@ -114,7 +115,7 @@ export const McpAuthCommand = cmd({ // Get OAuth-enabled servers const oauthServers = Object.entries(mcpServers).filter( - ([_, cfg]) => Config.isRemoteMcpConfig(cfg) && !!cfg.oauth, + ([_, cfg]) => "type" in cfg && cfg.type === "remote" && !!cfg.oauth, ) if (oauthServers.length === 0) { @@ -141,7 +142,7 @@ export const McpAuthCommand = cmd({ options: oauthServers.map(([name, cfg]) => ({ label: name, value: name, - hint: Config.isRemoteMcpConfig(cfg) ? cfg.url : undefined, + hint: "type" in cfg && cfg.type === "remote" ? cfg.url : undefined, })), }) if (prompts.isCancel(selected)) throw new UI.CancelledError() @@ -155,7 +156,7 @@ export const McpAuthCommand = cmd({ return } - if (!Config.isRemoteMcpConfig(serverConfig) || !serverConfig.oauth) { + if (!("type" in serverConfig) || serverConfig.type !== "remote" || !serverConfig.oauth) { prompts.log.error(`MCP server ${serverName} does not have OAuth configured`) prompts.outro("Done") return @@ -185,7 +186,8 @@ export const McpAuthCommand = cmd({ spinner.stop("Authentication failed", 1) prompts.log.error(status.error) prompts.log.info("Add clientId to your MCP server config:") - prompts.log.info(` + if ("type" in serverConfig && serverConfig.type === "remote") { + prompts.log.info(` "mcp": { "${serverName}": { "type": "remote", @@ -196,6 +198,7 @@ export const McpAuthCommand = cmd({ } } }`) + } } else if (status.status === "failed") { spinner.stop("Authentication failed", 1) prompts.log.error(status.error) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 9bfc61b9aad..7bbf5f8ba81 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -435,7 +435,7 @@ export namespace MCP { throw new Error(`MCP server not found: ${mcpName}`) } - if (!Config.isRemoteMcpConfig(mcpConfig)) { + if (!("type" in mcpConfig) || mcpConfig.type !== "remote") { throw new Error(`MCP server ${mcpName} is not a remote server`) } @@ -573,7 +573,8 @@ export namespace MCP { export async function supportsOAuth(mcpName: string): Promise { const cfg = await Config.get() const mcpConfig = cfg.mcp?.[mcpName] - return !!mcpConfig && Config.isRemoteMcpConfig(mcpConfig) && mcpConfig.oauth !== false + if (!mcpConfig || !("type" in mcpConfig)) return false + return mcpConfig.type === "remote" && mcpConfig.oauth !== false } /** From 8deb7e1fca2fce1cdd2268ffd522102a46e85c4b Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Sun, 14 Dec 2025 09:23:29 +1100 Subject: [PATCH 07/11] Revert "refactor(plugin): migrate to SDK v2" This reverts commit 4e42c69121a2207d26cde7727a18f47a214dc3f8. --- packages/opencode/src/plugin/index.ts | 2 +- packages/plugin/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 977e22375ff..b492c7179e6 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -2,7 +2,7 @@ import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/ import { Config } from "../config/config" import { Bus } from "../bus" import { Log } from "../util/log" -import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { createOpencodeClient } from "@opencode-ai/sdk" import { Server } from "../server/server" import { BunProc } from "../bun" import { Instance } from "../project/instance" diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 956f241055e..57ca75d604f 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -10,7 +10,7 @@ import type { Part, Auth, Config, -} from "@opencode-ai/sdk/v2" +} from "@opencode-ai/sdk" import type { BunShell } from "./shell" import { type ToolDefinition } from "./tool" From 5841603a085717791dead9ed05391686bbd159dd Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Sun, 14 Dec 2025 09:48:43 +1100 Subject: [PATCH 08/11] fix: Filter config instead of updating to SDK v2 --- packages/opencode/src/plugin/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index b492c7179e6..f4eb9e82235 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -76,8 +76,17 @@ export namespace Plugin { export async function init() { const hooks = await state().then((x) => x.hooks) const config = await Config.get() + // Filter out MCP override entries (those without 'type' property) to match SDK Config type + const filteredConfig = { + ...config, + mcp: config.mcp + ? Object.fromEntries( + Object.entries(config.mcp).filter(([_, v]) => "type" in v), + ) + : undefined, + } for (const hook of hooks) { - await hook.config?.(config) + await hook.config?.(filteredConfig as Parameters>[0]) } Bus.subscribeAll(async (input) => { const hooks = await state().then((x) => x.hooks) From 4ff2c2d64604ae201508fa22410ffce1e3400e64 Mon Sep 17 00:00:00 2001 From: Jake Nelson Date: Wed, 24 Dec 2025 09:43:20 +1100 Subject: [PATCH 09/11] fix: Add type in checks for local overrides --- packages/opencode/src/cli/cmd/mcp.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c23c15cb5a2..d20d89710cc 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -268,7 +268,7 @@ export const McpAuthListCommand = cmd({ // Get OAuth-capable servers const oauthServers = Object.entries(mcpServers).filter( - ([_, cfg]) => cfg.type === "remote" && cfg.oauth !== false, + ([_, cfg]) => "type" in cfg && cfg.type === "remote" && cfg.oauth !== false, ) if (oauthServers.length === 0) { @@ -281,7 +281,7 @@ export const McpAuthListCommand = cmd({ const authStatus = await MCP.getAuthStatus(name) const icon = getAuthStatusIcon(authStatus) const statusText = getAuthStatusText(authStatus) - const url = serverConfig.type === "remote" ? serverConfig.url : "" + const url = "type" in serverConfig && serverConfig.type === "remote" ? serverConfig.url : "" prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) } @@ -511,7 +511,7 @@ export const McpDebugCommand = cmd({ return } - if (serverConfig.type !== "remote") { + if (!("type" in serverConfig) || serverConfig.type !== "remote") { prompts.log.error(`MCP server ${serverName} is not a remote server`) prompts.outro("Done") return From 799c83435acecaa3a2a2a259ef895dfdca0bcd99 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 2 Jan 2026 22:24:34 -0600 Subject: [PATCH 10/11] revert changes --- packages/opencode/src/cli/cmd/mcp.ts | 27 +-- .../cli/cmd/tui/routes/session/sidebar.tsx | 4 +- packages/opencode/src/config/config.ts | 11 +- packages/opencode/src/mcp/index.ts | 15 +- packages/opencode/src/plugin/index.ts | 9 - packages/opencode/test/config/config.test.ts | 205 ------------------ packages/sdk/js/src/v2/gen/sdk.gen.ts | 3 +- packages/sdk/js/src/v2/gen/types.gen.ts | 11 +- packages/web/src/content/docs/mcp-servers.mdx | 52 ----- 9 files changed, 19 insertions(+), 318 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index d20d89710cc..b4ae8a37f7b 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -72,7 +72,7 @@ export const McpListCommand = cmd({ for (const [name, serverConfig] of Object.entries(mcpServers)) { const status = statuses[name] - const hasOAuth = "type" in serverConfig && serverConfig.type === "remote" && !!serverConfig.oauth + const hasOAuth = serverConfig.type === "remote" && !!serverConfig.oauth const hasStoredTokens = await MCP.hasStoredTokens(name) let statusIcon: string @@ -104,12 +104,7 @@ export const McpListCommand = cmd({ hint = "\n " + status.error } - const typeHint = - "type" in serverConfig - ? serverConfig.type === "remote" - ? serverConfig.url - : serverConfig.command.join(" ") - : "override" + const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") prompts.log.info( `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, ) @@ -140,9 +135,10 @@ export const McpAuthCommand = cmd({ const config = await Config.get() const mcpServers = config.mcp ?? {} + // Get OAuth-capable servers (remote servers with oauth not explicitly disabled) const oauthServers = Object.entries(mcpServers).filter( - ([_, cfg]) => "type" in cfg && cfg.type === "remote" && cfg.oauth !== false, + ([_, cfg]) => cfg.type === "remote" && cfg.oauth !== false, ) if (oauthServers.length === 0) { @@ -167,7 +163,7 @@ export const McpAuthCommand = cmd({ const authStatus = await MCP.getAuthStatus(name) const icon = getAuthStatusIcon(authStatus) const statusText = getAuthStatusText(authStatus) - const url = "type" in cfg && cfg.type === "remote" ? cfg.url : "" + const url = cfg.type === "remote" ? cfg.url : "" return { label: `${icon} ${name} (${statusText})`, value: name, @@ -190,7 +186,8 @@ export const McpAuthCommand = cmd({ prompts.outro("Done") return } - if (!("type" in serverConfig) || serverConfig.type !== "remote" || serverConfig.oauth === false) { + + if (serverConfig.type !== "remote" || serverConfig.oauth === false) { prompts.log.error(`MCP server ${serverName} does not support OAuth (oauth is disabled)`) prompts.outro("Done") return @@ -222,8 +219,7 @@ export const McpAuthCommand = cmd({ spinner.stop("Authentication failed", 1) prompts.log.error(status.error) prompts.log.info("Add clientId to your MCP server config:") - if ("type" in serverConfig && serverConfig.type === "remote") { - prompts.log.info(` + prompts.log.info(` "mcp": { "${serverName}": { "type": "remote", @@ -234,7 +230,6 @@ export const McpAuthCommand = cmd({ } } }`) - } } else if (status.status === "failed") { spinner.stop("Authentication failed", 1) prompts.log.error(status.error) @@ -268,7 +263,7 @@ export const McpAuthListCommand = cmd({ // Get OAuth-capable servers const oauthServers = Object.entries(mcpServers).filter( - ([_, cfg]) => "type" in cfg && cfg.type === "remote" && cfg.oauth !== false, + ([_, cfg]) => cfg.type === "remote" && cfg.oauth !== false, ) if (oauthServers.length === 0) { @@ -281,7 +276,7 @@ export const McpAuthListCommand = cmd({ const authStatus = await MCP.getAuthStatus(name) const icon = getAuthStatusIcon(authStatus) const statusText = getAuthStatusText(authStatus) - const url = "type" in serverConfig && serverConfig.type === "remote" ? serverConfig.url : "" + const url = serverConfig.type === "remote" ? serverConfig.url : "" prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) } @@ -511,7 +506,7 @@ export const McpDebugCommand = cmd({ return } - if (!("type" in serverConfig) || serverConfig.type !== "remote") { + if (serverConfig.type !== "remote") { prompts.log.error(`MCP server ${serverName} is not a remote server`) prompts.outro("Done") return diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 5b7438b64e3..a9ed042d1bb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -120,7 +120,7 @@ export function Sidebar(props: { sessionID: string }) { {([key, item]) => ( - + - + {key}{" "} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6a050843253..077a9dd1135 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -375,16 +375,7 @@ export namespace Config { ref: "McpRemoteConfig", }) - export const McpOverride = z - .object({ - enabled: z.boolean().describe("Enable or disable the MCP server on startup"), - }) - .strict() - .meta({ - ref: "McpOverrideConfig", - }) - - export const Mcp = z.union([z.discriminatedUnion("type", [McpLocal, McpRemote]), McpOverride]) + export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote]) export type Mcp = z.infer export const PermissionAction = z.enum(["ask", "allow", "deny"]).meta({ diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 28db3562469..10a0636675f 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -237,16 +237,6 @@ export namespace MCP { status: { status: "disabled" as const }, } } - if (!("type" in mcp)) { - log.warn("mcp override without base config", { key }) - return { - mcpClient: undefined, - status: { - status: "failed" as const, - error: "Missing configuration", - }, - } - } log.info("found", { key, type: mcp.type }) let mcpClient: MCPClient | undefined let status: Status | undefined = undefined @@ -589,7 +579,7 @@ export namespace MCP { throw new Error(`MCP server not found: ${mcpName}`) } - if (!("type" in mcpConfig) || mcpConfig.type !== "remote") { + if (mcpConfig.type !== "remote") { throw new Error(`MCP server ${mcpName} is not a remote server`) } @@ -747,8 +737,7 @@ export namespace MCP { export async function supportsOAuth(mcpName: string): Promise { const cfg = await Config.get() const mcpConfig = cfg.mcp?.[mcpName] - if (!mcpConfig || !("type" in mcpConfig)) return false - return mcpConfig.type === "remote" && mcpConfig.oauth !== false + return mcpConfig?.type === "remote" && mcpConfig.oauth !== false } /** diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 493d138ac34..9b9d3266a9c 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -82,15 +82,6 @@ export namespace Plugin { export async function init() { const hooks = await state().then((x) => x.hooks) const config = await Config.get() - // Filter out MCP override entries (those without 'type' property) to match SDK Config type - const filteredConfig = { - ...config, - mcp: config.mcp - ? Object.fromEntries( - Object.entries(config.mcp).filter(([_, v]) => "type" in v), - ) - : undefined, - } for (const hook of hooks) { // @ts-expect-error this is because we haven't moved plugin to sdk v2 await hook.config?.(config) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index f7f0d3bb4b4..c35a391f838 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -868,208 +868,3 @@ test("merges legacy tools with existing permission config", async () => { }, }) }) -test("accepts full MCP configuration", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - "my-mcp-foo": { - type: "remote", - url: "https://my-mcp-server-foo.com", - }, - "my-mcp-bar": { - type: "local", - command: ["npx", "-y", "my-mcp-command"], - }, - }, - }), - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await Config.get() - expect(config.mcp?.["my-mcp-foo"]).toEqual({ - type: "remote", - url: "https://my-mcp-server-foo.com", - }) - expect(config.mcp?.["my-mcp-bar"]).toEqual({ - type: "local", - command: ["npx", "-y", "my-mcp-command"], - }) - }, - }) -}) - -test("accepts MCP override with only enabled field", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - "my-mcp-foo": { enabled: false }, - }, - }), - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await Config.get() - expect(config.mcp?.["my-mcp-foo"]).toEqual({ enabled: false }) - }, - }) -}) - -test("merges MCP override with full config from parent", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const projectDir = path.join(dir, "project") - const opencodeDir = path.join(projectDir, ".opencode") - await fs.mkdir(opencodeDir, { recursive: true }) - - // Parent config with full MCP definitions - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - "my-mcp-foo": { - type: "remote", - url: "https://my-mcp-server-foo.com", - }, - "my-mcp-bar": { - type: "remote", - url: "https://my-mcp-server-bar.com", - }, - "my-mcp-baz": { - type: "local", - command: ["npx", "-y", "my-mcp-command"], - }, - }, - }), - ) - - // Project config with overrides - await Bun.write( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - "my-mcp-foo": { enabled: true }, - "my-mcp-baz": { enabled: false }, - }, - }), - ) - }, - }) - - await Instance.provide({ - directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await Config.get() - - // my-mcp-foo should have full config merged with enabled: true - expect(config.mcp?.["my-mcp-foo"]).toEqual({ - type: "remote", - url: "https://my-mcp-server-foo.com", - enabled: true, - }) - - // my-mcp-bar should be unchanged (no override) - expect(config.mcp?.["my-mcp-bar"]).toEqual({ - type: "remote", - url: "https://my-mcp-server-bar.com", - }) - - // my-mcp-baz should have full config merged with enabled: false - expect(config.mcp?.["my-mcp-baz"]).toEqual({ - type: "local", - command: ["npx", "-y", "my-mcp-command"], - enabled: false, - }) - }, - }) -}) - -test("project can enable globally disabled MCP", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const projectDir = path.join(dir, "project") - const opencodeDir = path.join(projectDir, ".opencode") - await fs.mkdir(opencodeDir, { recursive: true }) - - // Global config with disabled MCP - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - "my-mcp-foo": { - type: "remote", - url: "https://my-mcp-server-foo.com", - enabled: false, - }, - }, - }), - ) - - // Project enables it - await Bun.write( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - "my-mcp-foo": { enabled: true }, - }, - }), - ) - }, - }) - - await Instance.provide({ - directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await Config.get() - expect(config.mcp?.["my-mcp-foo"]).toEqual({ - type: "remote", - url: "https://my-mcp-server-foo.com", - enabled: true, - }) - }, - }) -}) - -test("MCP override without base config results in override-only object", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - // Only override, no base config defined - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - mcp: { - "my-mcp-nonexistent": { enabled: true }, - }, - }), - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await Config.get() - // The config will have just the override (no type field) - // MCP initialization will fail with "Missing configuration" - expect(config.mcp?.["my-mcp-nonexistent"]).toEqual({ enabled: true }) - expect("type" in (config.mcp?.["my-mcp-nonexistent"] ?? {})).toBe(false) - }, - }) -}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index bf4887387f9..702af632457 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -47,7 +47,6 @@ import type { McpConnectResponses, McpDisconnectResponses, McpLocalConfig, - McpOverrideConfig, McpRemoteConfig, McpStatusResponses, Part as Part2, @@ -2343,7 +2342,7 @@ export class Mcp extends HeyApiClient { parameters?: { directory?: string name?: string - config?: McpLocalConfig | McpRemoteConfig | McpOverrideConfig + config?: McpLocalConfig | McpRemoteConfig }, options?: Options, ) { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index de0d1cf7190..f083dc85d6e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1443,13 +1443,6 @@ export type McpRemoteConfig = { timeout?: number } -export type McpOverrideConfig = { - /** - * Enable or disable the MCP server on startup - */ - enabled: boolean -} - /** * @deprecated Always uses stretch layout. */ @@ -1573,7 +1566,7 @@ export type Config = { * MCP (Model Context Protocol) server configurations */ mcp?: { - [key: string]: McpLocalConfig | McpRemoteConfig | McpOverrideConfig + [key: string]: McpLocalConfig | McpRemoteConfig } formatter?: | false @@ -3947,7 +3940,7 @@ export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] export type McpAddData = { body?: { name: string - config: McpLocalConfig | McpRemoteConfig | McpOverrideConfig + config: McpLocalConfig | McpRemoteConfig } path?: never query?: { diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index dc68bc98d13..2101129b4d9 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -44,58 +44,6 @@ You can also disable a server by setting `enabled` to `false`. This is useful if --- -### Per-project overrides - -If you define MCP servers in your global config, you can override the `enabled` field per-project without redefining the full configuration. - -```jsonc title="~/.config/opencode/opencode.jsonc" -{ - "$schema": "https://opencode.ai/config.json", - "mcp": { - "my-mcp-foo": { "type": "remote", "url": "https://my-mcp-server-foo.com" }, - "my-mcp-bar": { "type": "remote", "url": "https://my-mcp-server-bar.com" }, - "my-mcp-baz": { "type": "local", "command": ["npx", "-y", "my-mcp-command"] }, - }, -} -``` - -```jsonc title="~/my-project/.opencode/opencode.jsonc" -{ - "$schema": "https://opencode.ai/config.json", - "mcp": { - "my-mcp-foo": { "enabled": true }, - "my-mcp-bar": { "enabled": true }, - // my-mcp-baz not mentioned - uses global config (enabled by default) - }, -} -``` - -This also works in reverse - disable globally, enable per-project: - -```jsonc title="~/.config/opencode/opencode.jsonc" -{ - "$schema": "https://opencode.ai/config.json", - "mcp": { - "my-mcp-foo": { "type": "remote", "url": "https://my-mcp-server-foo.com", "enabled": false }, - }, -} -``` - -```jsonc title="~/my-project/.opencode/opencode.jsonc" -{ - "$schema": "https://opencode.ai/config.json", - "mcp": { - "my-mcp-foo": { "enabled": true }, - }, -} -``` - -:::note -If you specify an override for an MCP that isn't defined in a parent config, it will show as failed with the error "Missing configuration". -::: - ---- - ## Local Add local MCP servers using `type` to `"local"` within the MCP object. From 49816c20f1083bba63fcec155fe37dcbeef708e2 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 3 Jan 2026 01:11:49 -0600 Subject: [PATCH 11/11] tweak --- packages/opencode/src/cli/cmd/mcp.ts | 38 ++++++++++++++++++-------- packages/opencode/src/config/config.ts | 15 +++++++++- packages/opencode/src/mcp/index.ts | 33 ++++++++++++++++++++-- 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index b4ae8a37f7b..63069d74e4b 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -36,6 +36,18 @@ function getAuthStatusText(status: MCP.AuthStatus): string { } } +type McpEntry = NonNullable[string] + +type McpConfigured = Config.Mcp +function isMcpConfigured(config: McpEntry): config is McpConfigured { + return typeof config === "object" && config !== null && "type" in config +} + +type McpRemote = Extract +function isMcpRemote(config: McpEntry): config is McpRemote { + return isMcpConfigured(config) && config.type === "remote" +} + export const McpCommand = cmd({ command: "mcp", builder: (yargs) => @@ -64,15 +76,19 @@ export const McpListCommand = cmd({ const mcpServers = config.mcp ?? {} const statuses = await MCP.status() - if (Object.keys(mcpServers).length === 0) { + const servers = Object.entries(mcpServers).filter((entry): entry is [string, McpConfigured] => + isMcpConfigured(entry[1]), + ) + + if (servers.length === 0) { prompts.log.warn("No MCP servers configured") prompts.outro("Add servers with: opencode mcp add") return } - for (const [name, serverConfig] of Object.entries(mcpServers)) { + for (const [name, serverConfig] of servers) { const status = statuses[name] - const hasOAuth = serverConfig.type === "remote" && !!serverConfig.oauth + const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth const hasStoredTokens = await MCP.hasStoredTokens(name) let statusIcon: string @@ -110,7 +126,7 @@ export const McpListCommand = cmd({ ) } - prompts.outro(`${Object.keys(mcpServers).length} server(s)`) + prompts.outro(`${servers.length} server(s)`) }, }) }, @@ -138,7 +154,7 @@ export const McpAuthCommand = cmd({ // Get OAuth-capable servers (remote servers with oauth not explicitly disabled) const oauthServers = Object.entries(mcpServers).filter( - ([_, cfg]) => cfg.type === "remote" && cfg.oauth !== false, + (entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false, ) if (oauthServers.length === 0) { @@ -163,7 +179,7 @@ export const McpAuthCommand = cmd({ const authStatus = await MCP.getAuthStatus(name) const icon = getAuthStatusIcon(authStatus) const statusText = getAuthStatusText(authStatus) - const url = cfg.type === "remote" ? cfg.url : "" + const url = cfg.url return { label: `${icon} ${name} (${statusText})`, value: name, @@ -187,8 +203,8 @@ export const McpAuthCommand = cmd({ return } - if (serverConfig.type !== "remote" || serverConfig.oauth === false) { - prompts.log.error(`MCP server ${serverName} does not support OAuth (oauth is disabled)`) + if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) { + prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`) prompts.outro("Done") return } @@ -263,7 +279,7 @@ export const McpAuthListCommand = cmd({ // Get OAuth-capable servers const oauthServers = Object.entries(mcpServers).filter( - ([_, cfg]) => cfg.type === "remote" && cfg.oauth !== false, + (entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false, ) if (oauthServers.length === 0) { @@ -276,7 +292,7 @@ export const McpAuthListCommand = cmd({ const authStatus = await MCP.getAuthStatus(name) const icon = getAuthStatusIcon(authStatus) const statusText = getAuthStatusText(authStatus) - const url = serverConfig.type === "remote" ? serverConfig.url : "" + const url = serverConfig.url prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) } @@ -506,7 +522,7 @@ export const McpDebugCommand = cmd({ return } - if (serverConfig.type !== "remote") { + if (!isMcpRemote(serverConfig)) { prompts.log.error(`MCP server ${serverName} is not a remote server`) prompts.outro("Done") return diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 077a9dd1135..f62581db369 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -817,7 +817,20 @@ export namespace Config { .record(z.string(), Provider) .optional() .describe("Custom provider configurations and model overrides"), - mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"), + mcp: z + .record( + z.string(), + z.union([ + Mcp, + z + .object({ + enabled: z.boolean(), + }) + .strict(), + ]), + ) + .optional() + .describe("MCP (Model Context Protocol) server configurations"), formatter: z .union([ z.literal(false), diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 10a0636675f..322761c551e 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -135,6 +135,11 @@ export namespace MCP { // Prompt cache types type PromptInfo = Awaited>["prompts"][number] + type McpEntry = NonNullable[string] + function isMcpConfigured(entry: McpEntry): entry is Config.Mcp { + return typeof entry === "object" && entry !== null && "type" in entry + } + const state = Instance.state( async () => { const cfg = await Config.get() @@ -144,6 +149,11 @@ export namespace MCP { await Promise.all( Object.entries(config).map(async ([key, mcp]) => { + if (!isMcpConfigured(mcp)) { + log.error("Ignoring MCP config entry without type", { key }) + return + } + // If disabled by config, mark as disabled without trying to connect if (mcp.enabled === false) { status[key] = { status: "disabled" } @@ -237,6 +247,7 @@ export namespace MCP { status: { status: "disabled" as const }, } } + log.info("found", { key, type: mcp.type }) let mcpClient: MCPClient | undefined let status: Status | undefined = undefined @@ -434,8 +445,9 @@ export namespace MCP { const config = cfg.mcp ?? {} const result: Record = {} - // Include all MCPs from config, not just connected ones - for (const key of Object.keys(config)) { + // Include all configured MCPs from config, not just connected ones + for (const [key, mcp] of Object.entries(config)) { + if (!isMcpConfigured(mcp)) continue result[key] = s.status[key] ?? { status: "disabled" } } @@ -455,6 +467,11 @@ export namespace MCP { return } + if (!isMcpConfigured(mcp)) { + log.error("Ignoring MCP connect request for config without type", { name }) + return + } + const result = await create(name, { ...mcp, enabled: true }) if (!result) { @@ -579,6 +596,10 @@ export namespace MCP { throw new Error(`MCP server not found: ${mcpName}`) } + if (!isMcpConfigured(mcpConfig)) { + throw new Error(`MCP server ${mcpName} is disabled or missing configuration`) + } + if (mcpConfig.type !== "remote") { throw new Error(`MCP server ${mcpName} is not a remote server`) } @@ -705,6 +726,10 @@ export namespace MCP { throw new Error(`MCP server not found: ${mcpName}`) } + if (!isMcpConfigured(mcpConfig)) { + throw new Error(`MCP server ${mcpName} is disabled or missing configuration`) + } + // Re-add the MCP server to establish connection pendingOAuthTransports.delete(mcpName) const result = await add(mcpName, mcpConfig) @@ -737,7 +762,9 @@ export namespace MCP { export async function supportsOAuth(mcpName: string): Promise { const cfg = await Config.get() const mcpConfig = cfg.mcp?.[mcpName] - return mcpConfig?.type === "remote" && mcpConfig.oauth !== false + if (!mcpConfig) return false + if (!isMcpConfigured(mcpConfig)) return false + return mcpConfig.type === "remote" && mcpConfig.oauth !== false } /**