diff --git a/apps/docs/content/docs/install-agent-runtime.mdx b/apps/docs/content/docs/install-agent-runtime.mdx
index 5b0012e7d..d0ed7ae43 100644
--- a/apps/docs/content/docs/install-agent-runtime.mdx
+++ b/apps/docs/content/docs/install-agent-runtime.mdx
@@ -37,7 +37,7 @@ Listed roughly from most to least common. Pick whichever ones you already have c
### Claude Code (Anthropic)
-The most complete integration. Session resumption works, MCP works, and it's the **only one of the 11 that actually consumes the `mcp_config` field** on agents (see the [matrix](/providers#mcp-configuration-only-claude-code-actually-reads-it)).
+The most complete integration. Session resumption works, MCP works, and it's one of two tools that actually consume the `mcp_config` field on agents (the other is OpenCode — see the [matrix](/providers#mcp-configuration-provider-specific-support)).
| | |
|---|---|
@@ -89,7 +89,7 @@ Supports the Gemini 2.5 and 3 series. No session resumption, no MCP — suitable
### OpenCode (SST)
-Open-source CLI agent. Dynamically discovers available models from its own configuration file — good fit for users who want to bring their own model catalog.
+Open-source CLI agent. Dynamically discovers available models from its own configuration file — good fit for users who want to bring their own model catalog. Consumes the agent's `mcp_config` field (one of two tools that does, alongside Claude Code).
| | |
|---|---|
diff --git a/apps/docs/content/docs/install-agent-runtime.zh.mdx b/apps/docs/content/docs/install-agent-runtime.zh.mdx
index 8711a5096..223e27ef5 100644
--- a/apps/docs/content/docs/install-agent-runtime.zh.mdx
+++ b/apps/docs/content/docs/install-agent-runtime.zh.mdx
@@ -37,7 +37,7 @@ multica daemon restart
### Claude Code(Anthropic)
-集成最完整的一款。会话续接好用,MCP 好用,而且 **12 款里只有它真正会读 agent 配置里的 `mcp_config` 字段**(见[矩阵](/zh/providers))。
+集成最完整的一款。会话续接好用,MCP 好用,是真正会读 agent 配置里 `mcp_config` 字段的两款工具之一(另一款是 OpenCode,见[矩阵](/zh/providers))。
| | |
|---|---|
@@ -89,7 +89,7 @@ Cursor 编辑器的 CLI 对应物。**会话续接是坏的** —— Cursor CLI
### OpenCode(SST)
-开源 CLI agent。会从自己的配置文件里动态发现可用模型 —— 适合想自己掌控模型清单的用户。
+开源 CLI agent。会从自己的配置文件里动态发现可用模型 —— 适合想自己掌控模型清单的用户。会消费 agent 配置里的 `mcp_config` 字段(12 款里和 Claude Code 一起的两款之一)。
| | |
|---|---|
diff --git a/apps/docs/content/docs/providers.mdx b/apps/docs/content/docs/providers.mdx
index 47588551d..e2fa52e51 100644
--- a/apps/docs/content/docs/providers.mdx
+++ b/apps/docs/content/docs/providers.mdx
@@ -22,7 +22,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/` (fallback) | Dynamic discovery |
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | Dynamic discovery |
| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | Dynamic discovery |
-| **OpenCode** | SST | ✅ | ❌ | `.opencode/skills/` | Dynamic discovery |
+| **OpenCode** | SST | ✅ | ✅ | `.opencode/skills/` | Dynamic discovery |
| **OpenClaw** | Open source | ✅ | ❌ | `.agent_context/skills/` (fallback) | Bound to the agent, can't be switched per task |
| **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/skills/` | Dynamic discovery |
@@ -34,7 +34,7 @@ From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service a
### Claude Code
-From Anthropic. **First choice for new users** — the most complete feature set: session resumption actually works, it's the **only one of the 11 that truly reads MCP configuration**, and it supports fine-tuning flags like `--max-turns` and `--append-system-prompt`. Requires an Anthropic API key.
+From Anthropic. **First choice for new users** — the most complete feature set: session resumption actually works, it **reads MCP configuration** (one of only two tools that does, alongside OpenCode), and it supports fine-tuning flags like `--max-turns` and `--append-system-prompt`. Requires an Anthropic API key.
### Codex
@@ -66,7 +66,7 @@ From Amazon. Uses ACP over stdio via `kiro-cli acp`. Session resumption works th
### OpenCode
-From SST, open source. Dynamically discovers available models (scans the CLI's configuration file). Session resumption works. **Suitable for tinkerers who want to customize their model catalog.**
+From SST, open source. Dynamically discovers available models (scans the CLI's configuration file). Session resumption works, and it consumes the agent's `mcp_config` field — Multica injects it inline through the `OPENCODE_CONFIG_CONTENT` environment variable, so the agent's MCP servers reach OpenCode without writing anything into the task workdir's `opencode.json` (the agent or the user keep ownership of that file). **Suitable for tinkerers who want to customize their model catalog.**
### OpenClaw
@@ -88,12 +88,14 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu
**For your decision**: if your workflow needs agents to preserve context across tasks (failure retries, manual reruns, conversational iteration), pick only from the ✅ row.
-## MCP configuration: only Claude Code actually reads it
+## MCP configuration: provider-specific support
-**Of the 12 tools, only Claude Code actually consumes `mcp_config`**. The other 11 accept the field but **completely ignore it** — no error, no warning, the config just has no effect.
+**Of the 12 tools, only Claude Code and OpenCode actually consume `mcp_config`**. The other 10 accept the field but **completely ignore it** — no error, no warning, the config just has no effect.
+
+Claude Code receives it through `--mcp-config` paired with `--strict-mcp-config` — the latter intentionally **disables** any merge with the user's Claude global config, so the agent's `mcp_config` is the only MCP server set Claude Code sees. OpenCode receives it inline via the `OPENCODE_CONFIG_CONTENT` environment variable, which **deep-merges** with the user's OpenCode global config (`~/.config/opencode/opencode.json` etc.). Neither path rewrites the project's `opencode.json` or any other user-owned file on disk.
-If you set `mcp_config` in an agent configuration but pick a tool other than Claude Code, your MCP servers have **no effect** on that agent. MCP integration currently covers Claude Code only.
+If you set `mcp_config` in an agent configuration but pick a tool other than Claude Code or OpenCode, your MCP servers have **no effect** on that agent. MCP integration is provider-specific and only the tools marked ✅ in the matrix consume it.
## Where skill files go
diff --git a/apps/docs/content/docs/providers.zh.mdx b/apps/docs/content/docs/providers.zh.mdx
index 0c462f340..94f674733 100644
--- a/apps/docs/content/docs/providers.zh.mdx
+++ b/apps/docs/content/docs/providers.zh.mdx
@@ -22,7 +22,7 @@ Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/` (fallback)| 动态发现 |
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | 动态发现 |
| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | 动态发现 |
-| **OpenCode** | SST | ✅ | ❌ | `.opencode/skills/` | 动态发现 |
+| **OpenCode** | SST | ✅ | ✅ | `.opencode/skills/` | 动态发现 |
| **OpenClaw** | 开源项目 | ✅ | ❌ | `.agent_context/skills/` (fallback)| 绑定在智能体上,不能在任务里切换 |
| **Pi** | Inflection AI | ✅(session 为文件路径)| ❌ | `.pi/skills/` | 动态发现 |
@@ -34,7 +34,7 @@ Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,
### Claude Code
-Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,是 **12 款里唯一真读 MCP 配置**的工具,支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。
+Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,**会读 MCP 配置**(12 款里只有它和 OpenCode 真读),支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。
### Codex
@@ -66,7 +66,7 @@ Amazon 出品。通过 `kiro-cli acp` 使用 ACP stdio 协议。会话恢复走
### OpenCode
-SST 出品,开源。动态发现可用模型(扫 CLI 的配置文件)。会话恢复真用。**适合爱折腾、想自定义模型目录**的开发者。
+SST 出品,开源。动态发现可用模型(扫 CLI 的配置文件)。会话恢复真用,会消费智能体的 `mcp_config` 字段——Multica 通过 `OPENCODE_CONFIG_CONTENT` 环境变量内联注入,让智能体的 MCP server 直接到达 OpenCode,**不会去碰任务工作目录里的 `opencode.json`**(那个文件归智能体或用户所有)。**适合爱折腾、想自定义模型目录**的开发者。
### OpenClaw
@@ -88,12 +88,14 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
**对你的决策**:如果工作流需要智能体在多次任务之间保持上下文(失败重试、手动重跑、对话式迭代),只选 ✅ 那一行的工具。
-## MCP 配置:只有 Claude Code 真的读
+## MCP 配置:按工具不同
-**12 款工具里只有 Claude Code 实际消费 `mcp_config`**。其他 11 款会接收这个字段但**完全忽略**——不报错、不警告,只是配置不生效。
+**12 款工具里只有 Claude Code 和 OpenCode 实际消费 `mcp_config`**。其他 10 款会接收这个字段但**完全忽略**——不报错、不警告,只是配置不生效。
+
+Claude Code 通过 `--mcp-config` 加 `--strict-mcp-config` 接收——后者**显式关闭**与用户 Claude 全局配置的合并,所以智能体的 `mcp_config` 就是 Claude Code 看到的全部 MCP server。OpenCode 通过 `OPENCODE_CONFIG_CONTENT` 环境变量内联接收,**会**和用户 OpenCode 全局配置(`~/.config/opencode/opencode.json` 等)做 deep-merge。两条路径都**不会**改写项目里的 `opencode.json` 或任何其他用户拥有的文件。
-如果你在智能体配置里设置了 `mcp_config`,但选了 Claude Code 之外的工具,你的 MCP server 对这个智能体**没有效果**。目前的 MCP 集成只覆盖 Claude Code。
+如果你在智能体配置里设置了 `mcp_config`,但选了 Claude Code 或 OpenCode 之外的工具,你的 MCP server 对这个智能体**没有效果**。MCP 集成是按工具实现的,只有矩阵里标 ✅ 的工具会读取。
## skill 文件该放哪儿
diff --git a/server/pkg/agent/opencode.go b/server/pkg/agent/opencode.go
index 8cff18e3b..076bab69a 100644
--- a/server/pkg/agent/opencode.go
+++ b/server/pkg/agent/opencode.go
@@ -103,6 +103,28 @@ func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecO
if opts.Cwd != "" {
env = append(env, "PWD="+opts.Cwd)
}
+ // Project agent.mcp_config into OpenCode via OPENCODE_CONFIG_CONTENT —
+ // OpenCode's general inline-config injection mechanism that merges at
+ // "local" scope (after the project-config loop, before remote / managed
+ // configs). MCP is the only field we currently project there; if a
+ // future Multica field needs the same channel it would assemble a
+ // combined OpenCode config slice before the env append.
+ //
+ // This deliberately leaves /opencode.json untouched — the
+ // workdir is reused across turns for the same (agent, issue), and any
+ // agent- or user-written model / tools / permission settings in it must
+ // survive across runs.
+ mcpContent, err := buildOpenCodeMCPConfigContent(opts.McpConfig)
+ if err != nil {
+ cancel()
+ return nil, err
+ }
+ if mcpContent != "" {
+ if _, dup := b.cfg.Env["OPENCODE_CONFIG_CONTENT"]; dup {
+ b.cfg.Logger.Warn("agent.custom_env sets OPENCODE_CONFIG_CONTENT but agent.mcp_config takes precedence and overrides it")
+ }
+ env = append(env, "OPENCODE_CONFIG_CONTENT="+mcpContent)
+ }
cmd.Env = env
stdout, err := cmd.StdoutPipe()
diff --git a/server/pkg/agent/opencode_mcp.go b/server/pkg/agent/opencode_mcp.go
new file mode 100644
index 000000000..1ccc773a3
--- /dev/null
+++ b/server/pkg/agent/opencode_mcp.go
@@ -0,0 +1,415 @@
+package agent
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+)
+
+// opencodeMCPLocal mirrors the McpLocalConfig slice of OpenCode's config
+// schema (https://opencode.ai/config.json). Decoded with
+// DisallowUnknownFields so any field outside this struct fails validation
+// before the daemon hands the config to OpenCode — matching the schema's
+// `additionalProperties: false`.
+type opencodeMCPLocal struct {
+ Type string `json:"type"`
+ Command []string `json:"command"`
+ Environment map[string]string `json:"environment,omitempty"`
+ Enabled *bool `json:"enabled,omitempty"`
+ Timeout *int `json:"timeout,omitempty"`
+}
+
+// opencodeMCPRemote mirrors the McpRemoteConfig slice. OAuth is held as
+// json.RawMessage because the schema allows two shapes (an oauth-config
+// object OR the literal `false`); the type discriminator is checked in
+// validateOpenCodeOAuth.
+type opencodeMCPRemote struct {
+ Type string `json:"type"`
+ URL string `json:"url"`
+ Headers map[string]string `json:"headers,omitempty"`
+ OAuth json.RawMessage `json:"oauth,omitempty"`
+ Enabled *bool `json:"enabled,omitempty"`
+ Timeout *int `json:"timeout,omitempty"`
+}
+
+// opencodeMCPEnabledOnly is the third shape OpenCode's schema accepts —
+// a bare `{"enabled": true|false}` entry that toggles a server inherited
+// from global / project config without redefining it. The discriminator
+// is "no `type` field, but `enabled` is set".
+type opencodeMCPEnabledOnly struct {
+ Enabled *bool `json:"enabled"`
+}
+
+// opencodeMCPOAuth mirrors McpOAuthConfig. callbackPort is range-checked
+// in validateOpenCodeOAuth (the schema requires 1..65535). It is held
+// as `*int` so the absent / unset case (nil) and an explicit
+// `"callbackPort": 0` (rejected as out-of-range) are distinguishable —
+// Go's int zero value would otherwise collapse them.
+type opencodeMCPOAuth struct {
+ ClientID string `json:"clientId,omitempty"`
+ ClientSecret string `json:"clientSecret,omitempty"`
+ Scope string `json:"scope,omitempty"`
+ CallbackPort *int `json:"callbackPort,omitempty"`
+ RedirectURI string `json:"redirectUri,omitempty"`
+}
+
+// buildOpenCodeMCPConfigContent translates an agent.mcp_config payload into
+// a JSON string carrying just the `mcp` slice of OpenCode's config schema,
+// suitable for the OPENCODE_CONFIG_CONTENT environment variable. Returns
+// ("", nil) when raw is empty so callers can skip the env entry entirely
+// instead of injecting an empty config.
+//
+// OPENCODE_CONFIG_CONTENT is OpenCode's general inline-config injection
+// mechanism — it accepts any subset of OpenCode's schema (model, agent,
+// mode, plugin, mcp, …), not just MCP. This function is scoped to MCP
+// because that's the agent.mcp_config field this PR plumbs through; if a
+// future Multica field needs to project into the same env var (e.g. an
+// agent-level model override), the assemble-and-inject step would move
+// up a layer and merge multiple slices into one OPENCODE_CONFIG_CONTENT
+// value. For now, MCP is the only consumer.
+//
+// Why env-var injection vs writing /opencode.json: the task workdir
+// is reused across turns for the same (agent, issue), and the agent itself
+// (or the user) may have written model / tools / permission settings into
+// /opencode.json. Writing or removing that file as part of the
+// mcp_config lifecycle would silently overwrite their state. The env-var
+// approach avoids the workdir entirely — nothing is written to disk and no
+// cleanup is needed because env dies with the spawned process.
+//
+// OPENCODE_CONFIG_CONTENT was added to OpenCode in v1.4.10 (2025-09) and is
+// the same mechanism the official @opencode-ai/sdk uses to inject runtime
+// config. OpenCode merges it AFTER the project-config loop at "local" scope,
+// so it deep-merges with global + project config (same observable behaviour
+// as writing into /opencode.json), but its later merge position
+// also gives daemon-injected entries precedence over any same-key entry
+// the user happened to put in their project file — which matches the
+// semantics of agent.mcp_config being the authoritative daemon-managed
+// field.
+func buildOpenCodeMCPConfigContent(raw json.RawMessage) (string, error) {
+ if len(raw) == 0 {
+ return "", nil
+ }
+ servers, err := translateMCPConfigForOpenCode(raw)
+ if err != nil {
+ return "", err
+ }
+ // Empty result (no servers after translation) is observably the same as
+ // raw being nil — return "" so the caller skips the env entry, and a
+ // JSON null / empty object never clobbers what the user put in
+ // agent.custom_env.OPENCODE_CONFIG_CONTENT.
+ if len(servers) == 0 {
+ return "", nil
+ }
+ data, err := json.Marshal(map[string]any{"mcp": servers})
+ if err != nil {
+ return "", fmt.Errorf("opencode mcp_config: marshal: %w", err)
+ }
+ return string(data), nil
+}
+
+// translateMCPConfigForOpenCode converts an agent.mcp_config payload into the
+// shape OpenCode expects under its `mcp` key. Two input shapes are accepted:
+//
+// - Claude-style `{"mcpServers": {name: {url|command, ...}}}` — translated
+// into OpenCode's `type: "local"|"remote"` form, command coerced to an
+// array, env renamed to environment, etc.
+// - Native OpenCode `{"mcp": {name: {type, ...}}}` — passed through after
+// validating each entry against OpenCode's schema. Without validation,
+// a malformed agent.mcp_config would be surfaced to OpenCode verbatim
+// and either silently disable the server or crash the CLI at startup.
+func translateMCPConfigForOpenCode(raw json.RawMessage) (map[string]any, error) {
+ var payload struct {
+ MCPServers map[string]map[string]any `json:"mcpServers"`
+ MCP map[string]json.RawMessage `json:"mcp"`
+ }
+ if err := json.Unmarshal(raw, &payload); err != nil {
+ return nil, fmt.Errorf("opencode mcp_config: parse mcp_config: %w", err)
+ }
+ if len(payload.MCPServers) == 0 {
+ if payload.MCP == nil {
+ return map[string]any{}, nil
+ }
+ return validateOpenCodeNativeMCPMap(payload.MCP)
+ }
+
+ servers := make(map[string]any, len(payload.MCPServers)+len(payload.MCP))
+ for name, rawEntry := range payload.MCP {
+ validated, err := validateOpenCodeNativeMCPEntry(name, rawEntry)
+ if err != nil {
+ return nil, err
+ }
+ servers[name] = validated
+ }
+ for name, server := range payload.MCPServers {
+ translated, err := translateMCPServerForOpenCode(name, server)
+ if err != nil {
+ return nil, err
+ }
+ // Re-validate the translated entry through the native validator so
+ // both input shapes — Claude-style `mcpServers` and OpenCode-native
+ // `mcp` — are gated by the same schema rules. Without this re-check,
+ // Claude-style inputs with malformed `headers`, `environment`,
+ // `oauth`, or `timeout` values would bypass daemon validation and
+ // surface as a confusing OpenCode startup error instead of a clear
+ // daemon-side rejection. One validator, one source of truth.
+ rawTranslated, err := json.Marshal(translated)
+ if err != nil {
+ return nil, fmt.Errorf("opencode mcp_config: server %q: marshal translated entry: %w", name, err)
+ }
+ validated, err := validateOpenCodeNativeMCPEntry(name, rawTranslated)
+ if err != nil {
+ return nil, err
+ }
+ servers[name] = validated
+ }
+ return servers, nil
+}
+
+// validateOpenCodeNativeMCPMap validates every entry in a native-shape
+// mcp map and returns a parallel map[string]any of validated entries
+// (each entry round-tripped through json so the output is the verbatim
+// representation OpenCode would observe in its config).
+func validateOpenCodeNativeMCPMap(mcp map[string]json.RawMessage) (map[string]any, error) {
+ out := make(map[string]any, len(mcp))
+ for name, raw := range mcp {
+ validated, err := validateOpenCodeNativeMCPEntry(name, raw)
+ if err != nil {
+ return nil, err
+ }
+ out[name] = validated
+ }
+ return out, nil
+}
+
+// validateOpenCodeNativeMCPEntry strict-decodes one native-shape entry
+// against OpenCode's schema and returns the equivalent map[string]any
+// representation. The decode is intentionally strict
+// (DisallowUnknownFields) — any field outside the McpLocalConfig /
+// McpRemoteConfig / `{enabled: bool}` shapes is rejected, matching the
+// schema's `additionalProperties: false` and surfacing user typos as
+// errors before they reach OpenCode.
+func validateOpenCodeNativeMCPEntry(name string, raw json.RawMessage) (map[string]any, error) {
+ wrap := func(err error) error {
+ return fmt.Errorf("opencode mcp_config: server %q: %w", name, err)
+ }
+
+ // JSON-object guard: the discriminator probe and strict decoders
+ // below assume an object; without this guard a primitive (string,
+ // number, array, null) would surface a confusing decoder error.
+ trimmed := bytes.TrimSpace(raw)
+ if len(trimmed) == 0 || trimmed[0] != '{' {
+ return nil, wrap(errors.New("entry must be a JSON object"))
+ }
+
+ // Discriminator probe: peek `type` to choose the right strict
+ // decode target. This first decode is intentionally permissive so
+ // "type: 5" surfaces a clear "type must be a string" error rather
+ // than the strict-decode generic "json: cannot unmarshal number".
+ var probe struct {
+ Type *json.RawMessage `json:"type,omitempty"`
+ }
+ if err := json.Unmarshal(raw, &probe); err != nil {
+ return nil, wrap(fmt.Errorf("parse: %w", err))
+ }
+ var typeStr string
+ if probe.Type != nil {
+ if err := json.Unmarshal(*probe.Type, &typeStr); err != nil {
+ return nil, wrap(fmt.Errorf("`type` must be a string, got %s", strings.TrimSpace(string(*probe.Type))))
+ }
+ }
+
+ switch typeStr {
+ case "local":
+ var entry opencodeMCPLocal
+ if err := strictDecode(raw, &entry); err != nil {
+ return nil, wrap(err)
+ }
+ if len(entry.Command) == 0 {
+ return nil, wrap(errors.New("local server missing required field `command`"))
+ }
+ if entry.Timeout != nil && *entry.Timeout <= 0 {
+ return nil, wrap(fmt.Errorf("`timeout` must be a positive integer, got %d", *entry.Timeout))
+ }
+ case "remote":
+ var entry opencodeMCPRemote
+ if err := strictDecode(raw, &entry); err != nil {
+ return nil, wrap(err)
+ }
+ if entry.URL == "" {
+ return nil, wrap(errors.New("remote server missing required field `url`"))
+ }
+ if entry.Timeout != nil && *entry.Timeout <= 0 {
+ return nil, wrap(fmt.Errorf("`timeout` must be a positive integer, got %d", *entry.Timeout))
+ }
+ if len(entry.OAuth) > 0 {
+ if err := validateOpenCodeOAuth(entry.OAuth); err != nil {
+ return nil, wrap(fmt.Errorf("`oauth`: %w", err))
+ }
+ }
+ case "":
+ // No `type` field. The bare `{"enabled": bool}` override shape
+ // is OpenCode's third native variant; anything else without a
+ // type is a malformed local/remote attempt. Surface a single
+ // friendly "missing type" error instead of the strict-decode
+ // "json: unknown field" leak — the user usually didn't realise
+ // they were mis-using the override shape.
+ var entry opencodeMCPEnabledOnly
+ if err := strictDecode(raw, &entry); err != nil || entry.Enabled == nil {
+ return nil, wrap(errors.New("missing required field `type` (must be \"local\" or \"remote\", or use bare {\"enabled\": bool} to override an inherited server)"))
+ }
+ default:
+ return nil, wrap(fmt.Errorf("invalid type %q (must be \"local\" or \"remote\")", typeStr))
+ }
+
+ // Validation passed; re-decode the raw bytes into map[string]any for
+ // the output. Identical observable representation, just typed as a
+ // generic map for the caller.
+ var out map[string]any
+ if err := json.Unmarshal(raw, &out); err != nil {
+ return nil, wrap(fmt.Errorf("parse: %w", err))
+ }
+ return out, nil
+}
+
+// strictDecode runs a json.Decoder with DisallowUnknownFields so any
+// field outside the target struct's tags is rejected, enforcing the
+// schema's `additionalProperties: false`.
+func strictDecode(raw json.RawMessage, target any) error {
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.DisallowUnknownFields()
+ return dec.Decode(target)
+}
+
+// validateOpenCodeOAuth enforces the `oauth: McpOAuthConfig | false`
+// union from OpenCode's schema. The literal `false` disables OAuth
+// entirely (overriding the auto-detection default); any other primitive
+// or `true` is rejected as ambiguous.
+func validateOpenCodeOAuth(raw json.RawMessage) error {
+ trimmed := bytes.TrimSpace(raw)
+ if bytes.Equal(trimmed, []byte("false")) {
+ return nil
+ }
+ if len(trimmed) == 0 || trimmed[0] != '{' {
+ return fmt.Errorf("must be an object or `false`, got %s", string(trimmed))
+ }
+ var oauth opencodeMCPOAuth
+ if err := strictDecode(raw, &oauth); err != nil {
+ return err
+ }
+ // callbackPort: nil means absent (legal); any concrete value must be
+ // in 1..65535 per the schema. The pointer type lets us reject an
+ // explicit `"callbackPort": 0` instead of silently accepting it as
+ // the Go int zero value.
+ if oauth.CallbackPort != nil && (*oauth.CallbackPort < 1 || *oauth.CallbackPort > 65535) {
+ return fmt.Errorf("`callbackPort` must be in 1..65535, got %d", *oauth.CallbackPort)
+ }
+ return nil
+}
+
+// translateMCPServerForOpenCode converts one Claude-style mcpServers entry
+// into an OpenCode native entry. The `enabled` field is only emitted when
+// the source explicitly sets it: OpenCode defaults to enabled when absent,
+// so hard-injecting `enabled: true` would only add noise to the merged
+// config.
+func translateMCPServerForOpenCode(name string, server map[string]any) (map[string]any, error) {
+ if url, ok := stringField(server, "url"); ok && url != "" {
+ out := map[string]any{
+ "type": "remote",
+ "url": url,
+ }
+ if v, ok := server["enabled"].(bool); ok {
+ out["enabled"] = v
+ }
+ copyIfPresent(out, server, "headers")
+ copyIfPresent(out, server, "oauth")
+ copyIfPresent(out, server, "timeout")
+ return out, nil
+ }
+
+ command, err := openCodeCommand(server)
+ if err != nil {
+ return nil, fmt.Errorf("server %q: %w", name, err)
+ }
+ if len(command) == 0 {
+ return nil, fmt.Errorf("server %q has neither url nor command", name)
+ }
+ out := map[string]any{
+ "type": "local",
+ "command": command,
+ }
+ if v, ok := server["enabled"].(bool); ok {
+ out["enabled"] = v
+ }
+ if env, ok := server["env"]; ok {
+ out["environment"] = env
+ } else {
+ copyIfPresent(out, server, "environment")
+ }
+ copyIfPresent(out, server, "timeout")
+ return out, nil
+}
+
+// openCodeCommand normalises the `command` field into a string slice.
+// Claude's mcpServers accepts a single string with separate `args`; OpenCode
+// expects one combined array. A pre-existing array (used by some MCP
+// generators) is also passed through after a type check.
+func openCodeCommand(server map[string]any) ([]string, error) {
+ raw, ok := server["command"]
+ if !ok {
+ return nil, nil
+ }
+ switch v := raw.(type) {
+ case string:
+ cmd := []string{v}
+ args, err := stringSliceField(server, "args")
+ if err != nil {
+ return nil, err
+ }
+ return append(cmd, args...), nil
+ case []any:
+ cmd := make([]string, 0, len(v))
+ for _, item := range v {
+ s, ok := item.(string)
+ if !ok {
+ return nil, fmt.Errorf("command array must contain only strings")
+ }
+ cmd = append(cmd, s)
+ }
+ return cmd, nil
+ default:
+ return nil, fmt.Errorf("command must be a string or string array")
+ }
+}
+
+func stringField(m map[string]any, key string) (string, bool) {
+ v, ok := m[key].(string)
+ return v, ok
+}
+
+func stringSliceField(m map[string]any, key string) ([]string, error) {
+ raw, ok := m[key]
+ if !ok {
+ return nil, nil
+ }
+ items, ok := raw.([]any)
+ if !ok {
+ return nil, fmt.Errorf("%s must be an array", key)
+ }
+ out := make([]string, 0, len(items))
+ for _, item := range items {
+ s, ok := item.(string)
+ if !ok {
+ return nil, fmt.Errorf("%s must contain only strings", key)
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
+
+func copyIfPresent(dst, src map[string]any, key string) {
+ if v, ok := src[key]; ok {
+ dst[key] = v
+ }
+}
diff --git a/server/pkg/agent/opencode_mcp_test.go b/server/pkg/agent/opencode_mcp_test.go
new file mode 100644
index 000000000..dd2456b55
--- /dev/null
+++ b/server/pkg/agent/opencode_mcp_test.go
@@ -0,0 +1,611 @@
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestBuildOpenCodeMCPConfigContent_Empty pins the early-return contract: an
+// empty/nil mcp_config returns ("", nil) so the caller can skip the env
+// entry entirely. Without this, an empty entry would unset whatever value
+// the user had in agent.custom_env (Go's os/exec dedup keeps the last
+// occurrence, including an empty string).
+func TestBuildOpenCodeMCPConfigContent_Empty(t *testing.T) {
+ t.Parallel()
+ for _, raw := range []json.RawMessage{nil, json.RawMessage(""), json.RawMessage("null")} {
+ got, err := buildOpenCodeMCPConfigContent(raw)
+ if err != nil {
+ t.Fatalf("err for %q: %v", string(raw), err)
+ }
+ if got != "" {
+ t.Fatalf("expected empty content for %q, got %q", string(raw), got)
+ }
+ }
+}
+
+// TestBuildOpenCodeMCPConfigContent_Remote covers the Claude → OpenCode
+// translation for HTTP MCP servers: the daemon receives the Claude shape
+// (mcpServers with url + headers) and produces the OpenCode native shape
+// (mcp with type:"remote" + url + headers).
+func TestBuildOpenCodeMCPConfigContent_Remote(t *testing.T) {
+ t.Parallel()
+ raw := json.RawMessage(`{
+ "mcpServers": {
+ "mcpbase": {
+ "url": "https://mcpbase.example/multica-ai/mcp",
+ "headers": {"Authorization": "Bearer test-token"}
+ }
+ }
+ }`)
+ content, err := buildOpenCodeMCPConfigContent(raw)
+ if err != nil {
+ t.Fatalf("build: %v", err)
+ }
+
+ cfg := parseJSONString(t, content)
+ mcpbase := cfg["mcp"].(map[string]any)["mcpbase"].(map[string]any)
+ if got := mcpbase["type"]; got != "remote" {
+ t.Fatalf("type = %v, want remote", got)
+ }
+ if got := mcpbase["url"]; got != "https://mcpbase.example/multica-ai/mcp" {
+ t.Fatalf("url = %v", got)
+ }
+ if _, present := mcpbase["enabled"]; present {
+ t.Fatalf("enabled should not be injected when not in source, got %v", mcpbase["enabled"])
+ }
+ if got := mcpbase["headers"].(map[string]any)["Authorization"]; got != "Bearer test-token" {
+ t.Fatalf("Authorization header = %v", got)
+ }
+}
+
+// TestBuildOpenCodeMCPConfigContent_Local pins the Claude → OpenCode
+// translation for subprocess MCP servers: `command` is normalised to an
+// array, `env` is renamed to `environment`, and `type: "local"` is added.
+func TestBuildOpenCodeMCPConfigContent_Local(t *testing.T) {
+ t.Parallel()
+ raw := json.RawMessage(`{"mcpServers":{"local":{"command":"node","args":["server.js"],"env":{"TOKEN":"x"}}}}`)
+ content, err := buildOpenCodeMCPConfigContent(raw)
+ if err != nil {
+ t.Fatalf("build: %v", err)
+ }
+ cfg := parseJSONString(t, content)
+ local := cfg["mcp"].(map[string]any)["local"].(map[string]any)
+ if got := local["type"]; got != "local" {
+ t.Fatalf("type = %v, want local", got)
+ }
+ command, ok := local["command"].([]any)
+ if !ok || len(command) != 2 || command[0] != "node" || command[1] != "server.js" {
+ t.Fatalf("command = %#v, want [node server.js]", local["command"])
+ }
+ env, ok := local["environment"].(map[string]any)
+ if !ok || env["TOKEN"] != "x" {
+ t.Fatalf("environment = %#v, want {TOKEN:x}", local["environment"])
+ }
+ if _, present := local["env"]; present {
+ t.Fatal("legacy `env` key should have been renamed to `environment`")
+ }
+}
+
+// TestBuildOpenCodeMCPConfigContent_Native pins that an mcp_config in
+// OpenCode's native shape is passed through (after validation) without
+// being treated like a Claude-shape payload.
+func TestBuildOpenCodeMCPConfigContent_Native(t *testing.T) {
+ t.Parallel()
+ raw := json.RawMessage(`{
+ "mcp": {
+ "native": {
+ "type": "remote",
+ "url": "https://native.example/mcp",
+ "enabled": false
+ }
+ }
+ }`)
+ content, err := buildOpenCodeMCPConfigContent(raw)
+ if err != nil {
+ t.Fatalf("build: %v", err)
+ }
+ cfg := parseJSONString(t, content)
+ native := cfg["mcp"].(map[string]any)["native"].(map[string]any)
+ if got := native["enabled"]; got != false {
+ t.Fatalf("enabled = %v, want false", got)
+ }
+ if got := native["url"]; got != "https://native.example/mcp" {
+ t.Fatalf("url = %v", got)
+ }
+}
+
+// TestBuildOpenCodeMCPConfigContent_NativeAcceptsAllSchemaFields pins that
+// every field OpenCode's schema permits round-trips through validation
+// unchanged. Each subtest exercises one of the three native variants:
+// McpLocalConfig, McpRemoteConfig (with oauth as both an object and the
+// `false` literal), and the bare `{enabled: bool}` override shape.
+func TestBuildOpenCodeMCPConfigContent_NativeAcceptsAllSchemaFields(t *testing.T) {
+ t.Parallel()
+
+ t.Run("local with all optional fields", func(t *testing.T) {
+ t.Parallel()
+ raw := json.RawMessage(`{"mcp":{"x":{
+ "type":"local",
+ "command":["python","-m","my_mcp"],
+ "environment":{"API_KEY":"secret","REGION":"us"},
+ "enabled":true,
+ "timeout":30000
+ }}}`)
+ content, err := buildOpenCodeMCPConfigContent(raw)
+ if err != nil {
+ t.Fatalf("build: %v", err)
+ }
+ x := parseJSONString(t, content)["mcp"].(map[string]any)["x"].(map[string]any)
+ if x["type"] != "local" || x["timeout"].(float64) != 30000 || x["enabled"].(bool) != true {
+ t.Fatalf("local fields lost in round-trip: %#v", x)
+ }
+ cmd := x["command"].([]any)
+ if len(cmd) != 3 || cmd[0] != "python" {
+ t.Fatalf("command lost: %#v", cmd)
+ }
+ env := x["environment"].(map[string]any)
+ if env["API_KEY"] != "secret" {
+ t.Fatalf("environment lost: %#v", env)
+ }
+ })
+
+ t.Run("remote with oauth object", func(t *testing.T) {
+ t.Parallel()
+ raw := json.RawMessage(`{"mcp":{"x":{
+ "type":"remote",
+ "url":"https://e.example/mcp",
+ "headers":{"Authorization":"Bearer T","X-Trace":"abc"},
+ "oauth":{"clientId":"cid","scope":"read","callbackPort":3000},
+ "enabled":true,
+ "timeout":5000
+ }}}`)
+ content, err := buildOpenCodeMCPConfigContent(raw)
+ if err != nil {
+ t.Fatalf("build: %v", err)
+ }
+ x := parseJSONString(t, content)["mcp"].(map[string]any)["x"].(map[string]any)
+ oauth := x["oauth"].(map[string]any)
+ if oauth["clientId"] != "cid" || oauth["callbackPort"].(float64) != 3000 {
+ t.Fatalf("oauth fields lost: %#v", oauth)
+ }
+ })
+
+ t.Run("remote with oauth false", func(t *testing.T) {
+ t.Parallel()
+ raw := json.RawMessage(`{"mcp":{"x":{"type":"remote","url":"https://e/","oauth":false}}}`)
+ content, err := buildOpenCodeMCPConfigContent(raw)
+ if err != nil {
+ t.Fatalf("build: %v", err)
+ }
+ x := parseJSONString(t, content)["mcp"].(map[string]any)["x"].(map[string]any)
+ // JSON `false` decodes to bool(false) when the receiver is map[string]any.
+ // We just need to verify the literal survived (the validator's job is to
+ // allow `false` and reject `true`; the schema-allowed-but-false case
+ // falls through unchanged).
+ if v, ok := x["oauth"].(bool); !ok || v {
+ t.Fatalf("oauth literal `false` not preserved: %#v", x["oauth"])
+ }
+ })
+
+ t.Run("bare enabled override", func(t *testing.T) {
+ t.Parallel()
+ // The third native variant: toggle a server inherited from
+ // global/project config without redefining it. Required field
+ // is `enabled`; no `type` field allowed.
+ raw := json.RawMessage(`{"mcp":{"inherited":{"enabled":false}}}`)
+ content, err := buildOpenCodeMCPConfigContent(raw)
+ if err != nil {
+ t.Fatalf("build: %v", err)
+ }
+ x := parseJSONString(t, content)["mcp"].(map[string]any)["inherited"].(map[string]any)
+ if v, ok := x["enabled"].(bool); !ok || v {
+ t.Fatalf("override `enabled:false` lost: %#v", x)
+ }
+ if _, hasType := x["type"]; hasType {
+ t.Fatalf("override should not have a type field: %#v", x)
+ }
+ })
+
+ t.Run("bare enabled override rejects extra fields", func(t *testing.T) {
+ t.Parallel()
+ // The override shape is also schema-strict: extra fields without
+ // type are rejected via the friendlier "missing type" message.
+ raw := json.RawMessage(`{"mcp":{"x":{"enabled":true,"foo":"bar"}}}`)
+ _, err := buildOpenCodeMCPConfigContent(raw)
+ if err == nil {
+ t.Fatal("expected validation failure for extra fields without type")
+ }
+ if !strings.Contains(err.Error(), "missing required field `type`") {
+ t.Fatalf("expected friendly missing-type error, got %q", err.Error())
+ }
+ })
+}
+
+// TestBuildOpenCodeMCPConfigContent_RejectsMalformedNative covers the
+// schema check: a native-shape entry without a recognised type / required
+// field must be rejected before the env var is built, so OpenCode never
+// receives malformed input that would silently disable the server or
+// crash at startup.
+func TestBuildOpenCodeMCPConfigContent_RejectsMalformedNative(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ raw string
+ want string
+ }{
+ // Discriminator + required fields
+ {"missing type", `{"mcp":{"x":{"url":"https://e.example/mcp"}}}`, "missing required field `type`"},
+ {"invalid type", `{"mcp":{"x":{"type":"bogus","url":"https://e.example/mcp"}}}`, "invalid type"},
+ {"remote missing url", `{"mcp":{"x":{"type":"remote"}}}`, "remote server missing required field `url`"},
+ {"local missing command", `{"mcp":{"x":{"type":"local"}}}`, "local server missing required field `command`"},
+ {"entry not an object (string)", `{"mcp":{"x":"not-an-object"}}`, "entry must be a JSON object"},
+ {"entry not an object (number)", `{"mcp":{"x":42}}`, "entry must be a JSON object"},
+ {"entry not an object (array)", `{"mcp":{"x":["a","b"]}}`, "entry must be a JSON object"},
+ {"entry not an object (null)", `{"mcp":{"x":null}}`, "entry must be a JSON object"},
+ {"type field is not a string", `{"mcp":{"x":{"type":42}}}`, "`type` must be a string"},
+
+ // command: must be []string (the headline case Bohan-J flagged)
+ {"local command is string", `{"mcp":{"x":{"type":"local","command":"node"}}}`, "json: cannot unmarshal string"},
+ {"local command has non-string element", `{"mcp":{"x":{"type":"local","command":["node",5]}}}`, "json: cannot unmarshal number"},
+ {"local command is object", `{"mcp":{"x":{"type":"local","command":{"foo":"bar"}}}}`, "json: cannot unmarshal object"},
+
+ // environment / headers: values must be strings
+ {"local env value is number", `{"mcp":{"x":{"type":"local","command":["node"],"environment":{"PORT":3000}}}}`, "json: cannot unmarshal number"},
+ {"local env value is array", `{"mcp":{"x":{"type":"local","command":["node"],"environment":{"FOO":["a"]}}}}`, "json: cannot unmarshal array"},
+ {"remote header value is number", `{"mcp":{"x":{"type":"remote","url":"https://e/","headers":{"X-Limit":10}}}}`, "json: cannot unmarshal number"},
+ {"remote header value is bool", `{"mcp":{"x":{"type":"remote","url":"https://e/","headers":{"X-Auth":true}}}}`, "json: cannot unmarshal bool"},
+
+ // oauth: must be object | false (true is rejected as ambiguous)
+ {"oauth is true", `{"mcp":{"x":{"type":"remote","url":"https://e/","oauth":true}}}`, "must be an object or `false`"},
+ {"oauth is string", `{"mcp":{"x":{"type":"remote","url":"https://e/","oauth":"yes"}}}`, "must be an object or `false`"},
+ {"oauth is number", `{"mcp":{"x":{"type":"remote","url":"https://e/","oauth":1}}}`, "must be an object or `false`"},
+ {"oauth has unknown field", `{"mcp":{"x":{"type":"remote","url":"https://e/","oauth":{"foo":"bar"}}}}`, `json: unknown field "foo"`},
+ {"oauth callbackPort out of range (high)", `{"mcp":{"x":{"type":"remote","url":"https://e/","oauth":{"callbackPort":70000}}}}`, "`callbackPort` must be in 1..65535"},
+ {"oauth callbackPort out of range (negative)", `{"mcp":{"x":{"type":"remote","url":"https://e/","oauth":{"callbackPort":-1}}}}`, "`callbackPort` must be in 1..65535"},
+ // Explicit zero must be rejected too — `*int` lets us tell
+ // "absent" from "explicit 0" so the schema's `minimum: 1` is
+ // honoured. Without the pointer change, Go's int zero value
+ // would mask this case as "unset" and let it through.
+ {"oauth callbackPort explicit zero", `{"mcp":{"x":{"type":"remote","url":"https://e/","oauth":{"callbackPort":0}}}}`, "`callbackPort` must be in 1..65535"},
+
+ // timeout: positive integer
+ {"timeout zero", `{"mcp":{"x":{"type":"local","command":["node"],"timeout":0}}}`, "`timeout` must be a positive integer"},
+ {"timeout negative", `{"mcp":{"x":{"type":"remote","url":"https://e/","timeout":-1}}}`, "`timeout` must be a positive integer"},
+ {"timeout fractional", `{"mcp":{"x":{"type":"local","command":["node"],"timeout":60.5}}}`, "json: cannot unmarshal number"},
+ {"timeout string", `{"mcp":{"x":{"type":"remote","url":"https://e/","timeout":"60"}}}`, "json: cannot unmarshal string"},
+
+ // additionalProperties: false (the schema-strict requirement)
+ {"local has unknown field", `{"mcp":{"x":{"type":"local","command":["node"],"unknown":"x"}}}`, `json: unknown field "unknown"`},
+ {"local has remote-only field", `{"mcp":{"x":{"type":"local","command":["node"],"url":"https://e/"}}}`, `json: unknown field "url"`},
+ {"remote has local-only field", `{"mcp":{"x":{"type":"remote","url":"https://e/","command":["node"]}}}`, `json: unknown field "command"`},
+ {"remote has unknown field", `{"mcp":{"x":{"type":"remote","url":"https://e/","extra":1}}}`, `json: unknown field "extra"`},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ content, err := buildOpenCodeMCPConfigContent(json.RawMessage(tc.raw))
+ if err == nil {
+ t.Fatalf("expected error containing %q, got nil (content=%q)", tc.want, content)
+ }
+ if !strings.Contains(err.Error(), tc.want) {
+ t.Fatalf("expected error containing %q, got %q", tc.want, err.Error())
+ }
+ if content != "" {
+ t.Fatalf("content should be empty on validation failure, got %q", content)
+ }
+ })
+ }
+}
+
+// TestBuildOpenCodeMCPConfigContent_ClaudeStyleOAuthRoundTrip is a
+// protective test: a well-formed Claude-style payload that includes an
+// `oauth` object MUST survive translation + the unified validator + the
+// final round-trip into OPENCODE_CONFIG_CONTENT with every oauth field
+// preserved. If a future refactor accidentally tightens the validator or
+// drops fields during translation, this test fails immediately instead
+// of letting a regression reach users whose mcp_config was previously
+// accepted. Pairs with TestBuildOpenCodeMCPConfigContent_RejectsMalformedClaudeStyle
+// — that one pins what we reject; this one pins what we must keep accepting.
+func TestBuildOpenCodeMCPConfigContent_ClaudeStyleOAuthRoundTrip(t *testing.T) {
+ t.Parallel()
+ raw := json.RawMessage(`{"mcpServers":{"x":{
+ "url":"https://oauth.example/mcp",
+ "headers":{"Authorization":"Bearer T"},
+ "oauth":{"clientId":"cid","clientSecret":"sec","scope":"read write","callbackPort":3000,"redirectUri":"https://example/cb"},
+ "timeout":5000
+ }}}`)
+ content, err := buildOpenCodeMCPConfigContent(raw)
+ if err != nil {
+ t.Fatalf("build: %v", err)
+ }
+ x := parseJSONString(t, content)["mcp"].(map[string]any)["x"].(map[string]any)
+ if x["type"] != "remote" {
+ t.Fatalf("type = %v, want remote", x["type"])
+ }
+ if x["timeout"].(float64) != 5000 {
+ t.Fatalf("timeout = %v, want 5000", x["timeout"])
+ }
+ oauth, ok := x["oauth"].(map[string]any)
+ if !ok {
+ t.Fatalf("oauth not preserved as object: %#v", x["oauth"])
+ }
+ if oauth["clientId"] != "cid" || oauth["clientSecret"] != "sec" || oauth["scope"] != "read write" {
+ t.Fatalf("oauth string fields lost: %#v", oauth)
+ }
+ if oauth["callbackPort"].(float64) != 3000 {
+ t.Fatalf("oauth.callbackPort = %v, want 3000", oauth["callbackPort"])
+ }
+ if oauth["redirectUri"] != "https://example/cb" {
+ t.Fatalf("oauth.redirectUri = %v", oauth["redirectUri"])
+ }
+}
+
+// TestBuildOpenCodeMCPConfigContent_RejectsMalformedClaudeStyle covers the
+// unified-validator contract: Claude-style `mcpServers` input is translated
+// to OpenCode's native shape and then re-validated through the same
+// `validateOpenCodeNativeMCPEntry` that gates the native path. Without that
+// re-validation step, a Claude-style payload with malformed `headers`,
+// `env`, `oauth`, or `timeout` would slip past daemon validation and surface
+// as a confusing OpenCode startup error. This test pins each of those four
+// bypass cases the reviewer flagged.
+func TestBuildOpenCodeMCPConfigContent_RejectsMalformedClaudeStyle(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ name string
+ raw string
+ want string
+ }{
+ // headers: values must be strings (translation kept the bad
+ // value as-is; the unified validator catches it on the way out).
+ {"remote header value is number", `{"mcpServers":{"x":{"url":"https://e/","headers":{"Authorization":123}}}}`, "json: cannot unmarshal number"},
+ {"remote header value is bool", `{"mcpServers":{"x":{"url":"https://e/","headers":{"X-Auth":true}}}}`, "json: cannot unmarshal bool"},
+ // env (renamed to environment during translation): values must be strings.
+ {"local env value is number", `{"mcpServers":{"x":{"command":"node","env":{"FOO":42}}}}`, "json: cannot unmarshal number"},
+ {"local env value is array", `{"mcpServers":{"x":{"command":"node","env":{"FOO":["a"]}}}}`, "json: cannot unmarshal array"},
+ // oauth: must be object | false. `true` is rejected as ambiguous.
+ {"oauth is true", `{"mcpServers":{"x":{"url":"https://e/","oauth":true}}}`, "must be an object or `false`"},
+ // timeout: positive integer.
+ {"timeout negative", `{"mcpServers":{"x":{"command":"node","timeout":-1}}}`, "`timeout` must be a positive integer"},
+ {"timeout zero", `{"mcpServers":{"x":{"command":"node","timeout":0}}}`, "`timeout` must be a positive integer"},
+ // callbackPort: explicit 0 must be rejected (verifies the
+ // pointer-typed CallbackPort flows through translation too).
+ {"oauth callbackPort explicit zero (claude-style)", `{"mcpServers":{"x":{"url":"https://e/","oauth":{"callbackPort":0}}}}`, "`callbackPort` must be in 1..65535"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ content, err := buildOpenCodeMCPConfigContent(json.RawMessage(tc.raw))
+ if err == nil {
+ t.Fatalf("expected error containing %q, got nil (content=%q)", tc.want, content)
+ }
+ if !strings.Contains(err.Error(), tc.want) {
+ t.Fatalf("expected error containing %q, got %q", tc.want, err.Error())
+ }
+ if content != "" {
+ t.Fatalf("content should be empty on validation failure, got %q", content)
+ }
+ })
+ }
+}
+
+// TestOpencodeBackendInjectsMCPConfigViaEnv is the end-to-end happy path:
+// dispatch a task with mcp_config, and assert the spawned process saw the
+// translated config in $OPENCODE_CONFIG_CONTENT. Crucially also asserts
+// no /opencode.json was written — that file is owned by the
+// agent / user across turns, and the daemon must never touch it.
+func TestOpencodeBackendInjectsMCPConfigViaEnv(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ fakePath := filepath.Join(tempDir, "opencode")
+ captureFile := filepath.Join(tempDir, "env-capture.txt")
+ writeTestExecutable(t, fakePath, []byte(fakeOpencodeScriptCapturingEnv()))
+
+ workDir := t.TempDir()
+ backend, err := New("opencode", Config{
+ ExecutablePath: fakePath,
+ Logger: slog.Default(),
+ Env: map[string]string{
+ "OPENCODE_CAPTURE_FILE": captureFile,
+ },
+ })
+ if err != nil {
+ t.Fatalf("new backend: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
+ Cwd: workDir,
+ Timeout: 5 * time.Second,
+ McpConfig: json.RawMessage(`{"mcpServers":{"mcpbase":{"url":"https://mcpbase.example/mcp"}}}`),
+ })
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ go func() {
+ for range session.Messages {
+ }
+ }()
+ result := <-session.Result
+ if result.Status != "completed" {
+ t.Fatalf("status = %q, error = %q; want completed", result.Status, result.Error)
+ }
+
+ // (1) Child saw the translated mcp config in OPENCODE_CONFIG_CONTENT.
+ captured := readCapturedEnv(t, captureFile)
+ got := captured["OPENCODE_CONFIG_CONTENT"]
+ if !strings.Contains(got, "https://mcpbase.example/mcp") {
+ t.Fatalf("OPENCODE_CONFIG_CONTENT did not include managed url:\n%s", got)
+ }
+ if !strings.Contains(got, `"type":"remote"`) {
+ t.Fatalf("OPENCODE_CONFIG_CONTENT missing translated type=remote:\n%s", got)
+ }
+
+ // (2) /opencode.json was not touched. The agent / user owns
+ // that file across turns; the daemon must never write or remove it.
+ if _, statErr := os.Stat(filepath.Join(workDir, "opencode.json")); !os.IsNotExist(statErr) {
+ body, _ := os.ReadFile(filepath.Join(workDir, "opencode.json"))
+ t.Fatalf("daemon must not write /opencode.json; found:\n%s", string(body))
+ }
+}
+
+// TestOpencodeBackendOmitsMCPEnvWhenEmpty asserts the no-mcp_config path
+// does NOT inject OPENCODE_CONFIG_CONTENT, so any value the user set in
+// agent.custom_env is preserved untouched. Without this, an empty
+// mcp_config would silently clobber the user's escape hatch.
+func TestOpencodeBackendOmitsMCPEnvWhenEmpty(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ fakePath := filepath.Join(tempDir, "opencode")
+ captureFile := filepath.Join(tempDir, "env-capture.txt")
+ writeTestExecutable(t, fakePath, []byte(fakeOpencodeScriptCapturingEnv()))
+
+ const userContent = `{"mcp":{"user_only":{"type":"remote","url":"https://user.example/mcp"}}}`
+ workDir := t.TempDir()
+ backend, err := New("opencode", Config{
+ ExecutablePath: fakePath,
+ Logger: slog.Default(),
+ Env: map[string]string{
+ "OPENCODE_CAPTURE_FILE": captureFile,
+ "OPENCODE_CONFIG_CONTENT": userContent,
+ },
+ })
+ if err != nil {
+ t.Fatalf("new backend: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
+ Cwd: workDir,
+ Timeout: 5 * time.Second,
+ McpConfig: nil, // explicit no-mcp path
+ })
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ go func() {
+ for range session.Messages {
+ }
+ }()
+ if r := <-session.Result; r.Status != "completed" {
+ t.Fatalf("status = %q, error = %q; want completed", r.Status, r.Error)
+ }
+
+ captured := readCapturedEnv(t, captureFile)
+ if got := captured["OPENCODE_CONFIG_CONTENT"]; got != userContent {
+ t.Fatalf("user OPENCODE_CONFIG_CONTENT was not preserved:\n want %q\n got %q", userContent, got)
+ }
+}
+
+// TestOpencodeBackendOverridesUserOpenCodeConfigContent asserts the
+// daemon-level mcp_config wins over a user-supplied OPENCODE_CONFIG_CONTENT
+// in agent.custom_env. This is the behaviour Go's os/exec dedup gives us
+// (last occurrence wins) and the warning log is the documented signal of
+// the override.
+func TestOpencodeBackendOverridesUserOpenCodeConfigContent(t *testing.T) {
+ t.Parallel()
+ tempDir := t.TempDir()
+ fakePath := filepath.Join(tempDir, "opencode")
+ captureFile := filepath.Join(tempDir, "env-capture.txt")
+ writeTestExecutable(t, fakePath, []byte(fakeOpencodeScriptCapturingEnv()))
+
+ const userBogus = `{"this-should-not-survive":true}`
+ workDir := t.TempDir()
+ backend, err := New("opencode", Config{
+ ExecutablePath: fakePath,
+ Logger: slog.Default(),
+ Env: map[string]string{
+ "OPENCODE_CAPTURE_FILE": captureFile,
+ "OPENCODE_CONFIG_CONTENT": userBogus,
+ },
+ })
+ if err != nil {
+ t.Fatalf("new backend: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
+ Cwd: workDir,
+ Timeout: 5 * time.Second,
+ McpConfig: json.RawMessage(`{"mcpServers":{"daemon":{"url":"https://daemon.example/mcp"}}}`),
+ })
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ go func() {
+ for range session.Messages {
+ }
+ }()
+ if r := <-session.Result; r.Status != "completed" {
+ t.Fatalf("status = %q, error = %q; want completed", r.Status, r.Error)
+ }
+
+ captured := readCapturedEnv(t, captureFile)
+ got := captured["OPENCODE_CONFIG_CONTENT"]
+ if strings.Contains(got, "this-should-not-survive") {
+ t.Fatalf("user-set OPENCODE_CONFIG_CONTENT survived dedup; daemon mcp_config did not win:\n%s", got)
+ }
+ if !strings.Contains(got, "https://daemon.example/mcp") {
+ t.Fatalf("daemon mcp_config did not reach the child process:\n%s", got)
+ }
+}
+
+// fakeOpencodeScriptCapturingEnv returns a POSIX-sh script that writes the
+// child process's relevant env vars to $OPENCODE_CAPTURE_FILE before
+// emitting a minimal-valid stream and exiting. Lets a test observe what
+// the child saw at exec time.
+func fakeOpencodeScriptCapturingEnv() string {
+ return `#!/bin/sh
+if [ -n "$OPENCODE_CAPTURE_FILE" ]; then
+ {
+ printf 'OPENCODE_CONFIG_CONTENT=%s\n' "${OPENCODE_CONFIG_CONTENT-}"
+ } > "$OPENCODE_CAPTURE_FILE"
+fi
+printf '{"type":"step_start","timestamp":1,"sessionID":"ses_fake","part":{"type":"step-start"}}\n'
+printf '{"type":"text","timestamp":2,"sessionID":"ses_fake","part":{"type":"text","text":"ok"}}\n'
+printf '{"type":"step_finish","timestamp":3,"sessionID":"ses_fake","part":{"type":"step-finish"}}\n'
+`
+}
+
+// readCapturedEnv parses the env-dump file emitted by
+// fakeOpencodeScriptCapturingEnv. Each line is `KEY=VALUE`. The single
+// known sentinel `` flags an env var that was not set in the
+// child's environment (vs. an explicitly empty string).
+func readCapturedEnv(t *testing.T, path string) map[string]string {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read capture %s: %v", path, err)
+ }
+ out := make(map[string]string)
+ for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") {
+ k, v, ok := strings.Cut(line, "=")
+ if !ok {
+ continue
+ }
+ if v == "" {
+ continue
+ }
+ out[k] = v
+ }
+ return out
+}
+
+// parseJSONString is a tiny helper for tests that work with the JSON the
+// daemon will hand to OpenCode via OPENCODE_CONFIG_CONTENT.
+func parseJSONString(t *testing.T, s string) map[string]any {
+ t.Helper()
+ var out map[string]any
+ if err := json.Unmarshal([]byte(s), &out); err != nil {
+ t.Fatalf("parse content: %v\n%s", err, s)
+ }
+ return out
+}