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 +}