Files
multica/server/pkg/agent/opencode.go
feifeigood 382cdd6a0b feat(agent): consume OpenCode mcp_config via OPENCODE_CONFIG_CONTENT (#3098)
Closes the runtime-side gap of #2106: previously `agent.mcp_config` was
honored only by Claude Code (via `--mcp-config <file>`); for OpenCode the
field was accepted by the API but silently ignored at execution time.

## Approach

OpenCode has no `--mcp-config` flag. Project the agent's `mcp_config`
into OpenCode via OPENCODE_CONFIG_CONTENT — OpenCode's general
inline-config injection environment variable, which accepts any subset
of OpenCode's config schema (model / agent / mode / plugin / mcp / …)
and merges at "local" scope after the project-config loop. MCP is the
only field this PR projects through that channel; if a future Multica
field needs the same channel it would assemble a combined config slice
before the env append.

The env-var route was deliberate. An earlier draft of this PR wrote
the translated MCP servers into <workdir>/opencode.json and removed
the file on cleanup; review (#3098) flagged that the task workdir is
reused across turns for the same (agent, issue), and any agent- or
user-written model / tools / permission settings in opencode.json
must survive across runs. OPENCODE_CONFIG_CONTENT avoids the workdir
entirely — nothing is written to disk, no cleanup is needed, and the
env entry dies with the spawned process.

OPENCODE_CONFIG_CONTENT was added to OpenCode in v1.4.10 (2025-09); the
official @opencode-ai/sdk uses the same env var to inject runtime
config, so the surface is stable. Verified empirically against
OpenCode 1.15.6 in our K8s runtime: `opencode debug config` returns
the injected mcp slice deep-merged with the user's global config,
and <workdir>/opencode.json is observably untouched.

## Translation surface

`agent.mcp_config` accepts two shapes for portability:

- Claude-style `{"mcpServers": {name: {url|command, ...}}}` is
  translated into OpenCode's native form: `type: "local"|"remote"`,
  `command` coerced to a string array, `env` renamed to `environment`.
- Native OpenCode `{"mcp": {name: ...}}` accepts the three shapes
  OpenCode's schema permits and is strict-decoded against each:
    - McpLocalConfig:  `{type:"local", command:[…], environment?, enabled?, timeout?}`
    - McpRemoteConfig: `{type:"remote", url:"…", headers?, oauth?, enabled?, timeout?}`
    - bare override:   `{enabled: bool}` (toggle a server inherited
                        from global / project config without redefining it)
  Decoding uses `json.DisallowUnknownFields` so any field outside the
  matching schema is rejected — matching OpenCode's
  `additionalProperties: false`. Without this, a malformed payload
  (e.g. `command: "node"` instead of `command: ["node"]`) would reach
  OpenCode verbatim and either silently disable the server or crash
  the CLI at startup.

Field-level checks the strict decoder doesn't catch:
  - `timeout` must be a positive integer (rejects 0, negative, fractional)
  - `oauth` must be either an object (validated against McpOAuthConfig)
    or the literal `false`; primitives and `true` are rejected as ambiguous
  - `oauth.callbackPort` must be in 1..65535 when set

## Precedence

Go's os/exec dedups `cmd.Env` by key keeping the LAST occurrence
(Go 1.9+). Appending OPENCODE_CONFIG_CONTENT after `buildEnv(b.cfg.Env)`
guarantees the daemon's value wins over any value the user happened
to put in `agent.custom_env` — which matches the intended semantics
(`mcp_config` is the authoritative daemon-managed field; `custom_env`
is the escape hatch). When that override happens we surface a warning
log so accidental clobbers are debuggable.

## Limitation (out of scope, accepted in review)

OpenCode also deep-merges its **global** config
(`~/.config/opencode/opencode.json`) into every session and exposes no
flag to disable that. Operators who want strict per-agent isolation
from the global layer can set:

```jsonc
// agent.custom_env on the platform
{ "XDG_CONFIG_HOME": "/tmp/opencode-isolated" }
```

…pointing at any directory without an `opencode/` subdir. OpenCode then
reads no global config and only honors what the daemon injects via
OPENCODE_CONFIG_CONTENT. Verified with `opencode debug config`.

## Changes

server/pkg/agent/opencode_mcp.go (new):
  - buildOpenCodeMCPConfigContent — translates raw mcp_config into the
    JSON string OpenCode accepts via OPENCODE_CONFIG_CONTENT, returns
    "" when there's nothing to inject so the caller can skip the env
    entry (avoids clobbering anything the user put in
    agent.custom_env.OPENCODE_CONFIG_CONTENT)
  - translateMCPConfigForOpenCode + helpers — Claude-style → OpenCode
    native shape
  - validateOpenCodeNativeMCPEntry + opencodeMCPLocal /
    opencodeMCPRemote / opencodeMCPEnabledOnly / opencodeMCPOAuth
    typed structs — strict-decode native-shape entries against the
    schema (DisallowUnknownFields), plus targeted post-decode
    assertions for timeout / oauth / callbackPort

server/pkg/agent/opencode.go:
  - 12 lines of env injection in Execute(), placed AFTER buildEnv so
    the daemon's value wins via os/exec dedup
  - warning log when agent.custom_env duplicates the same key
  - no on-disk state, no rollback closure, no post-run cleanup —
    OPENCODE_CONFIG_CONTENT lives only in the spawned process env

server/pkg/agent/opencode_mcp_test.go (new):
  - TestBuildOpenCodeMCPConfigContent_{Empty,Remote,Local,Native}
  - TestBuildOpenCodeMCPConfigContent_NativeAcceptsAllSchemaFields —
    covers each native variant round-tripping every optional field
    (local with env+timeout+enabled; remote with headers+oauth-object+
    timeout+enabled; remote with oauth: false; bare {enabled} override)
  - TestBuildOpenCodeMCPConfigContent_RejectsMalformedNative — 31-case
    table covering every constraint on Bohan-J's review: command must
    be a string array, environment / headers values must be strings,
    oauth must be an object or false, timeout must be a positive
    integer, additionalProperties: false (per-shape allow-list checked
    via DisallowUnknownFields)
  - TestOpencodeBackendInjectsMCPConfigViaEnv — E2E happy path; fake
    opencode binary captures $OPENCODE_CONFIG_CONTENT, asserts the
    translated mcp slice is present AND <workdir>/opencode.json was
    NOT written
  - TestOpencodeBackendOmitsMCPEnvWhenEmpty — empty mcp_config does
    NOT inject the env, preserving any value the user set in
    agent.custom_env
  - TestOpencodeBackendOverridesUserOpenCodeConfigContent — daemon
    value wins via os/exec dedup keep-last

apps/docs/content/docs/providers.{en,zh}.mdx:
  - flip OpenCode's MCP cell from  to 
  - reword the "MCP configuration: only Claude Code actually reads it"
    section so OpenCode is included; describe each tool's mechanism
    (Claude → `--mcp-config`, OpenCode → OPENCODE_CONFIG_CONTENT)

apps/docs/content/docs/install-agent-runtime.{en,zh}.mdx:
  - update the Claude Code blurb (no longer "the only one")
  - expand the OpenCode blurb to mention mcp_config support
  - fix the now-broken /providers anchor

Refs #2106 (TS types and per-agent UI for mcp_config are separate
follow-ups, not in this PR).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:08:21 +08:00

481 lines
16 KiB
Go

package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
// opencodeBlockedArgs are flags hardcoded by the daemon that must not be
// overridden by user-configured custom_args.
var opencodeBlockedArgs = map[string]blockedArgMode{
"--format": blockedWithValue, // json output format for daemon communication
"--dir": blockedWithValue, // task workdir anchor for skill / AGENTS.md discovery
"--dangerously-skip-permissions": blockedStandalone, // daemon manages non-interactive permission prompts
}
// opencodeBackend implements Backend by spawning `opencode run --format json`
// and reading streaming JSON events from stdout — the same pattern as Claude.
type opencodeBackend struct {
cfg Config
}
func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "opencode"
}
resolved, err := exec.LookPath(execPath)
if err != nil {
return nil, fmt.Errorf("opencode executable not found at %q: %w", execPath, err)
}
if runtime.GOOS == "windows" {
if native := resolveOpenCodeNativeFromShim(resolved, os.Stat); native != "" {
b.cfg.Logger.Info("opencode resolved to native binary to avoid .cmd shim argv truncation", "shim", resolved, "native", native)
resolved = native
}
}
execPath = resolved
timeout := opts.Timeout
if timeout == 0 {
timeout = 20 * time.Minute
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
args := []string{"run", "--format", "json", "--dangerously-skip-permissions"}
// Anchor OpenCode's project discovery (AGENTS.md walk-up + .opencode/skills/
// project config scan) at the task workdir. Without this, OpenCode falls
// back to PWD (inherited from the daemon process) or process.cwd(), which
// in self-host deployments can resolve to the user's shell working
// directory and silently bypass the per-task workdir — agents lose
// visibility into their assigned skills and AGENTS.md instructions.
// PWD is also overridden below because OpenCode prefers PWD over cwd when
// `--dir` is absent and uses it as the starting point for any further
// path resolution.
if opts.Cwd != "" {
args = append(args, "--dir", opts.Cwd)
}
if opts.Model != "" {
args = append(args, "--model", opts.Model)
}
if opts.SystemPrompt != "" {
args = append(args, "--prompt", opts.SystemPrompt)
}
if opts.MaxTurns > 0 {
b.cfg.Logger.Warn("opencode does not support --max-turns; ignoring", "maxTurns", opts.MaxTurns)
}
if opts.ResumeSessionID != "" {
args = append(args, "--session", opts.ResumeSessionID)
}
args = append(args, filterCustomArgs(opts.CustomArgs, opencodeBlockedArgs, b.cfg.Logger)...)
args = append(args, prompt)
cmd := exec.CommandContext(runCtx, execPath, args...)
hideAgentWindow(cmd)
b.cfg.Logger.Info("agent command", "exec", execPath, "args", args)
cmd.WaitDelay = 10 * time.Second
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
env := buildEnv(b.cfg.Env)
// Keep daemon-mode runs non-interactive without relying on
// OPENCODE_PERMISSION. OpenCode deep-merges that env override into user
// config while preserving existing key order, so a pre-existing
// permission.question key can be followed by a wildcard allow and bypass
// the intended question deny. Current OpenCode run sessions inject their
// own question/plan deny rules after agent config; this flag only answers
// prompts that survive those explicit denies.
// Override PWD so the child OpenCode process resolves its discovery root
// to the task workdir. cmd.Dir alone is not enough: OpenCode reads PWD
// (inherited from the parent daemon) before falling back to process.cwd()
// when computing the directory it walks for AGENTS.md / .opencode/skills.
// See packages/opencode/src/cli/cmd/run.ts in the upstream source.
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 <workdir>/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()
if err != nil {
cancel()
return nil, fmt.Errorf("opencode stdout pipe: %w", err)
}
cmd.Stderr = newLogWriter(b.cfg.Logger, "[opencode:stderr] ")
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("start opencode: %w", err)
}
b.cfg.Logger.Info("opencode started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
// Close stdout when the context is cancelled so the scanner unblocks.
go func() {
<-runCtx.Done()
_ = stdout.Close()
}()
go func() {
defer cancel()
defer close(msgCh)
defer close(resCh)
startTime := time.Now()
scanResult := b.processEvents(stdout, msgCh)
// Wait for process exit.
exitErr := cmd.Wait()
duration := time.Since(startTime)
if runCtx.Err() == context.DeadlineExceeded {
scanResult.status = "timeout"
scanResult.errMsg = fmt.Sprintf("opencode timed out after %s", timeout)
} else if runCtx.Err() == context.Canceled {
scanResult.status = "aborted"
scanResult.errMsg = "execution cancelled"
} else if exitErr != nil && scanResult.status == "completed" {
scanResult.status = "failed"
scanResult.errMsg = fmt.Sprintf("opencode exited with error: %v", exitErr)
}
b.cfg.Logger.Info("opencode finished", "pid", cmd.Process.Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String())
// Build usage map. OpenCode doesn't report model per-step, so we
// attribute all usage to the configured model (or "unknown").
var usage map[string]TokenUsage
u := scanResult.usage
if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 {
model := opts.Model
if model == "" {
model = "unknown"
}
usage = map[string]TokenUsage{model: u}
}
resCh <- Result{
Status: scanResult.status,
Output: scanResult.output,
Error: scanResult.errMsg,
DurationMs: duration.Milliseconds(),
SessionID: scanResult.sessionID,
Usage: usage,
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
// ── Event handlers ──
// eventResult holds the accumulated state from processing the event stream.
type eventResult struct {
status string
errMsg string
output string
sessionID string
usage TokenUsage // accumulated token usage across all steps
}
// processEvents reads JSON lines from r, dispatches events to ch, and returns
// the accumulated result. This is the core scanner loop, extracted for testability.
func (b *opencodeBackend) processEvents(r io.Reader, ch chan<- Message) eventResult {
var output strings.Builder
var sessionID string
var usage TokenUsage
finalStatus := "completed"
var finalError string
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var event opencodeEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
continue
}
if event.SessionID != "" {
sessionID = event.SessionID
}
switch event.Type {
case "text":
b.handleTextEvent(event, ch, &output)
case "tool_use":
b.handleToolUseEvent(event, ch)
case "error":
b.handleErrorEvent(event, ch, &finalStatus, &finalError)
case "step_start":
trySend(ch, Message{Type: MessageStatus, Status: "running"})
case "step_finish":
// Accumulate token usage from step_finish events.
if t := event.Part.Tokens; t != nil {
usage.InputTokens += t.Input
usage.OutputTokens += t.Output
if t.Cache != nil {
usage.CacheReadTokens += t.Cache.Read
usage.CacheWriteTokens += t.Cache.Write
}
}
}
}
// Check for scanner errors (e.g. broken pipe, read errors).
if scanErr := scanner.Err(); scanErr != nil {
b.cfg.Logger.Warn("opencode stdout scanner error", "error", scanErr)
if finalStatus == "completed" {
finalStatus = "failed"
finalError = fmt.Sprintf("stdout read error: %v", scanErr)
}
}
return eventResult{
status: finalStatus,
errMsg: finalError,
output: output.String(),
sessionID: sessionID,
usage: usage,
}
}
func (b *opencodeBackend) handleTextEvent(event opencodeEvent, ch chan<- Message, output *strings.Builder) {
text := event.Part.Text
if text != "" {
output.WriteString(text)
trySend(ch, Message{Type: MessageText, Content: text})
}
}
// handleToolUseEvent processes "tool_use" events from opencode. A single
// tool_use event contains both the call and result in part.state when the
// tool has completed (state.status == "completed").
func (b *opencodeBackend) handleToolUseEvent(event opencodeEvent, ch chan<- Message) {
// Extract input from state.input (the tool invocation parameters).
var input map[string]any
if event.Part.State != nil && event.Part.State.Input != nil {
_ = json.Unmarshal(event.Part.State.Input, &input)
}
// Emit the tool-use message.
trySend(ch, Message{
Type: MessageToolUse,
Tool: event.Part.Tool,
CallID: event.Part.CallID,
Input: input,
})
// If the tool has completed, also emit a tool-result message.
if event.Part.State != nil && event.Part.State.Status == "completed" {
outputStr := extractToolOutput(event.Part.State.Output)
trySend(ch, Message{
Type: MessageToolResult,
Tool: event.Part.Tool,
CallID: event.Part.CallID,
Output: outputStr,
})
}
}
// handleErrorEvent processes "error" events from opencode. OpenCode can exit
// with RC=0 even on errors (e.g. invalid model), so error events are the
// reliable signal for failures.
func (b *opencodeBackend) handleErrorEvent(event opencodeEvent, ch chan<- Message, finalStatus, finalError *string) {
errMsg := ""
if event.Error != nil {
errMsg = event.Error.Message()
}
if errMsg == "" {
errMsg = "unknown opencode error"
}
b.cfg.Logger.Warn("opencode error event", "error", errMsg)
trySend(ch, Message{Type: MessageError, Content: errMsg})
*finalStatus = "failed"
*finalError = errMsg
}
// resolveOpenCodeNativeFromShim returns the path to the native OpenCode
// executable bundled inside the npm package, given the path to the npm
// `opencode.cmd` shim that PATH lookup found on Windows. Returns "" if shim
// doesn't end in `.cmd` or no candidate npm platform package has a bundled
// native binary present.
//
// Windows batch argument forwarding via `%*` does not preserve newlines, so
// multi-line positional argv is truncated at the first newline before the
// shim hands off to the JS entrypoint. Daemon prompts can include literal
// newlines (system prompt + user message), which makes the agent see only
// the first line. Native binary spawn skips the cmd.exe layer entirely.
//
// Layout when installed via `npm install -g opencode-ai`:
//
// <prefix>\opencode.cmd (shim)
// <prefix>\node_modules\opencode-ai\node_modules\opencode-windows-{x64,x64-baseline,arm64}\bin\opencode.exe (native)
//
// `opencode-windows-x64-baseline` ships for older CPUs without AVX2;
// `opencode-windows-arm64` ships for Surface / Copilot+ PC hosts.
// Candidates are tried in GOARCH-preferred order so the most likely match
// for the current host comes first.
//
// statFn is injected so this is testable on non-Windows hosts.
func resolveOpenCodeNativeFromShim(shimPath string, statFn func(string) (os.FileInfo, error)) string {
if !strings.EqualFold(filepath.Ext(shimPath), ".cmd") {
return ""
}
prefix := filepath.Dir(shimPath)
for _, pkg := range opencodeWindowsPackageCandidates(runtime.GOARCH) {
candidate := filepath.Join(prefix, "node_modules", "opencode-ai", "node_modules", pkg, "bin", "opencode.exe")
if _, err := statFn(candidate); err == nil {
return candidate
}
}
return ""
}
// opencodeWindowsPackageCandidates returns the npm platform package names
// that may host the bundled `opencode.exe` on Windows, ordered so the most
// likely match for the given GOARCH comes first. ARM64 hosts try the arm64
// build first; everything else tries x64, then the baseline x64 build for
// older CPUs without AVX2, then arm64 as a final fallback. Cost is one
// extra statFn call per miss when the GOARCH-preferred package isn't
// installed.
func opencodeWindowsPackageCandidates(goarch string) []string {
switch goarch {
case "arm64":
return []string{"opencode-windows-arm64", "opencode-windows-x64", "opencode-windows-x64-baseline"}
default:
return []string{"opencode-windows-x64", "opencode-windows-x64-baseline", "opencode-windows-arm64"}
}
}
// extractToolOutput converts the tool state output (which may be a string or
// structured object) into a string.
func extractToolOutput(output any) string {
if output == nil {
return ""
}
if s, ok := output.(string); ok {
return s
}
data, _ := json.Marshal(output)
return string(data)
}
// ── JSON types for `opencode run --format json` stdout events ──
// opencodeEvent represents a single JSON line from `opencode run --format json`.
//
// Event types observed in real output:
//
// "step_start" — agent step begins
// "text" — text output from agent (part.text)
// "tool_use" — tool invocation with call and result (part.tool, part.callID, part.state)
// "error" — error from opencode (error.name, error.data.message)
// "step_finish" — agent step completes (includes token usage)
type opencodeEvent struct {
Type string `json:"type"`
Timestamp int64 `json:"timestamp,omitempty"`
SessionID string `json:"sessionID,omitempty"`
Part opencodeEventPart `json:"part"`
Error *opencodeError `json:"error,omitempty"`
}
// opencodeEventPart represents the part field in an opencode event.
type opencodeEventPart struct {
ID string `json:"id,omitempty"`
MessageID string `json:"messageID,omitempty"`
SessionID string `json:"sessionID,omitempty"`
Type string `json:"type,omitempty"`
// Text events
Text string `json:"text,omitempty"`
// Tool use events
Tool string `json:"tool,omitempty"`
CallID string `json:"callID,omitempty"`
State *opencodeToolState `json:"state,omitempty"`
// step_finish token usage
Tokens *opencodeTokens `json:"tokens,omitempty"`
}
// opencodeTokens represents token usage in a step_finish event.
type opencodeTokens struct {
Input int64 `json:"input"`
Output int64 `json:"output"`
Cache *opencodeCacheTokens `json:"cache,omitempty"`
}
type opencodeCacheTokens struct {
Read int64 `json:"read"`
Write int64 `json:"write"`
}
// opencodeToolState represents the state of a tool invocation.
type opencodeToolState struct {
Status string `json:"status,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
Output any `json:"output,omitempty"`
}
// opencodeError represents an error event from opencode.
type opencodeError struct {
Name string `json:"name,omitempty"`
Data *opencodeErrData `json:"data,omitempty"`
}
// Message returns the human-readable error message.
func (e *opencodeError) Message() string {
if e.Data != nil && e.Data.Message != "" {
return e.Data.Message
}
if e.Name != "" {
return e.Name
}
return ""
}
type opencodeErrData struct {
Message string `json:"message,omitempty"`
}