mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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>
This commit is contained in:
@@ -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 <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()
|
||||
|
||||
415
server/pkg/agent/opencode_mcp.go
Normal file
415
server/pkg/agent/opencode_mcp.go
Normal file
@@ -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 <workdir>/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
|
||||
// <workdir>/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 <workdir>/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
|
||||
}
|
||||
}
|
||||
611
server/pkg/agent/opencode_mcp_test.go
Normal file
611
server/pkg/agent/opencode_mcp_test.go
Normal file
@@ -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 <workdir>/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) <workdir>/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 <workdir>/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-<unset>}"
|
||||
} > "$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 `<unset>` 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 == "<unset>" {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user