diff --git a/docs/api.md b/docs/api.md index 80714e88..8380cccf 100644 --- a/docs/api.md +++ b/docs/api.md @@ -290,6 +290,42 @@ Additional notes: - `field_mask` limits which fields are overwritten during replacement - `agent_profile.api_key` is write-only and will be redacted in reads +OpenClaw, PicoClaw, and Codex CLI agents can configure MCP servers through the +top-level `mcp_config` field. The API accepts the same `mcpServers` object for +all supported runtimes and each runtime adapter renders it into its native +configuration format: + +```json +{ + "name": "alice", + "runtime_kind": "openclaw_sandbox", + "mcp_config": { + "mcpServers": { + "workspace-filesystem": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/home/node/.openclaw/workspace" + ] + } + } + }, + "profile": "api.gpt-5.4" +} +``` + +Runtime adapters map this shared input as follows: + +- OpenClaw: `mcp_config.mcpServers` -> `openclaw.json` `mcp.servers` +- PicoClaw: `mcp_config.mcpServers` -> PicoClaw config `tools.mcp.servers` +- Codex CLI: `mcp_config.mcpServers` -> managed `[mcp_servers.""]` blocks in the isolated Codex home `config.toml` + +MCP commands run inside the target runtime environment, so filesystem server +directory arguments must be paths visible to that runtime. Existing clients +that still send legacy `runtime_options.mcp` are normalized into `mcp_config`; +new clients should use `mcp_config`. + ### `GET /api/v1/agents/{id}` Gets a single agent. @@ -306,6 +342,7 @@ Supported fields: - `description` - `image` - `runtime_options` +- `mcp_config` - `agent_profile` Example request body: @@ -322,6 +359,9 @@ Example request body: Notes: - Omitted fields are left unchanged +- `runtime_options` uses whole-object replacement when submitted +- `mcp_config` uses whole-object replacement when submitted; send `null` to clear CSGClaw-managed MCP config +- Updating MCP config on OpenClaw, PicoClaw, or Codex CLI agents may recreate that agent runtime so the native config takes effect - If `agent_profile.api_key` is sent empty, the server keeps the existing key - If `agent_profile.env` changes, `env_restart_required` may become `true` in the response diff --git a/docs/api.zh.md b/docs/api.zh.md index 39aa8eb8..98412148 100644 --- a/docs/api.zh.md +++ b/docs/api.zh.md @@ -287,6 +287,36 @@ Participant 是 channel-scoped identity,用于房间、消息、mention、通 - `field_mask` 用于替换时只覆盖指定字段 - `agent_profile.api_key` 只在写入时使用,读取时会被脱敏 +OpenClaw、PicoClaw 和 Codex CLI agent 可通过顶层 `mcp_config` 字段配置 MCP server。CSGClaw API 对所有已支持 runtime 接收一致的 `mcpServers` 输入,再由各 runtime adapter 渲染为对应的原生配置: + +```json +{ + "name": "alice", + "runtime_kind": "openclaw_sandbox", + "mcp_config": { + "mcpServers": { + "workspace-filesystem": { + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/home/node/.openclaw/workspace" + ] + } + } + }, + "profile": "api.gpt-5.4" +} +``` + +各 runtime adapter 的映射关系: + +- OpenClaw:`mcp_config.mcpServers` -> `openclaw.json` 的 `mcp.servers` +- PicoClaw:`mcp_config.mcpServers` -> PicoClaw 配置的 `tools.mcp.servers` +- Codex CLI:`mcp_config.mcpServers` -> 隔离 Codex home `config.toml` 中由 CSGClaw 管理的 `[mcp_servers.""]` 块 + +注意:MCP command 在目标 runtime 环境内执行,filesystem server 的目录参数也必须是该 runtime 可见路径。旧客户端继续提交 `runtime_options.mcp` 时会被服务端迁移为 `mcp_config`;新客户端应直接使用 `mcp_config`。 + ### `GET /api/v1/agents/{id}` 获取单个 agent。 @@ -303,6 +333,7 @@ Participant 是 channel-scoped identity,用于房间、消息、mention、通 - `description` - `image` - `runtime_options` +- `mcp_config` - `agent_profile` 请求体示例: @@ -319,6 +350,9 @@ Participant 是 channel-scoped identity,用于房间、消息、mention、通 说明: - 省略的字段不会修改 +- `runtime_options` 一旦提交就是整体替换 +- `mcp_config` 一旦提交就是整体替换;传 `null` 可清除 CSGClaw 托管的 MCP 配置 +- OpenClaw、PicoClaw 或 Codex CLI agent 的 MCP 配置变更可能触发该 agent runtime recreate,使原生配置生效 - `agent_profile.api_key` 如果传空,服务端会保留原有密钥 - 如果 `agent_profile.env` 发生变化,响应中的 `env_restart_required` 可能为 `true` diff --git a/docs/openclaw-mcp-restart-plan.zh.md b/docs/openclaw-mcp-restart-plan.zh.md new file mode 100644 index 00000000..ceb4cb3a --- /dev/null +++ b/docs/openclaw-mcp-restart-plan.zh.md @@ -0,0 +1,303 @@ +# MCP 配置控制器实现说明 + +本文档记录当前分支对 CSGClaw MCP 配置接入的实现。旧方案把 OpenClaw 的 MCP 配置放在 `runtime_options.mcp`;当前实现已调整为独立的顶层 `mcp_config`,并在 runtime 接口层增加 `MCPConfigController`,由各 runtime adapter 负责把统一输入转换为自己的原生配置格式。 + +## 1. 目标与边界 + +已实现目标: + +- API、持久化和前端使用统一的顶层 `mcp_config` 字段。 +- `mcp_config` 的输入形态固定为 `{"mcpServers": {...}}`。 +- runtime 层新增 `MCPConfigController`,与现有 start/stop/provision 和 runtime config controller 分离。 +- OpenClaw、PicoClaw、Codex CLI 三类 agent 都接入 MCP server 配置。 +- 前端创建、编辑和详情页使用同一份 MCP Server JSON 配置形态。 +- 旧客户端提交的 `runtime_options.mcp` 会迁移到 `mcp_config`,并从 `runtime_options` 中剥离。 + +当前未覆盖: + +- 不做 MCP marketplace、自动安装、连接测试或动态发现。 +- 不承诺 runtime hot reload;需要重启/重建时仍走现有 runtime recreate 路径。 +- 暂不做 MCP secret 脱敏体验,`mcp_config` 仍按普通 JSON 配置回显。 + +## 2. 统一配置形态 + +API 输入: + +```json +{ + "runtime_kind": "openclaw_sandbox", + "mcp_config": { + "mcpServers": { + "context7": { + "command": "uvx", + "args": ["context7-mcp"], + "env": { + "CONTEXT7_API_KEY": "secret" + } + }, + "remote-search": { + "url": "https://mcp.example.com/mcp", + "transport": "streamable-http", + "headers": { + "Authorization": "Bearer secret" + } + } + } + } +} +``` + +语义: + +- `mcp_config` 字段缺失:不修改已有 MCP 配置。 +- `mcp_config: null`:清除 CSGClaw 托管 MCP 配置。 +- `mcp_config: {}` 或 `{"mcpServers": {}}`:显式托管空集合。 +- 非空 `mcpServers`:使用 CSGClaw 托管 server 集合。 +- 顶层仅支持 `mcpServers`,避免混入 runtime 原生 MCP 配置导致合并语义不清。 + +校验规则: + +- `mcpServers` 必须是 object。 +- server 名称去空白后不能为空,重复名称报错。 +- 每个 server entry 必须是 object。 +- 每个 server entry 必须声明 `command` 或 `url`。 +- `args` 必须是 string array。 +- `env`、`headers` 必须是 string map。 +- `transport` 必须是 string。 + +## 3. Runtime 接口 + +共享接口定义在 `internal/runtime/mcp_config.go`: + +```go +type MCPConfigController interface { + ValidateMCPConfig(ctx context.Context, current MCPConfigSnapshot) error + MCPConfigRestartRequired(change MCPConfigChange) (bool, error) + ReconcileMCPConfig(ctx context.Context, h Handle, change MCPConfigChange) error +} +``` + +它的职责: + +- 校验当前 runtime 是否支持 `mcp_config` 以及配置是否符合该 runtime 的约束。 +- 判断 MCP 配置变更是否需要 runtime recreate。 +- 对无需 recreate 的 runtime,或需要即时重写本地配置的 runtime,执行 reconcile。 + +`ProvisionRequest` 也新增了独立字段: + +```go +type ProvisionRequest struct { + MCPConfig map[string]any +} +``` + +这样 MCP 不再通过通用 `RuntimeOptions` 传递,runtime options 和 MCP 配置的生命周期可以独立演进。 + +## 4. Runtime Adapter 映射 + +### OpenClaw + +OpenClaw 接收统一 `mcp_config.mcpServers`,渲染到 OpenClaw 原生配置: + +```json +{ + "mcp": { + "servers": { + "context7": { + "command": "uvx", + "args": ["context7-mcp"] + } + } + } +} +``` + +OpenClaw 配置文件路径仍是: + +```text +~/.csgclaw/agents//.openclaw/openclaw.json +``` + +sandbox 内读取路径: + +```text +/home/node/.openclaw/openclaw.json +``` + +OpenClaw adapter 使用共享 JSON helper 更新 `mcp.servers`。MCP command 在 OpenClaw sandbox 内执行,filesystem server 参数必须是容器内可见路径。 + +### PicoClaw + +PicoClaw 接收同样的 `mcp_config.mcpServers`,渲染到 PicoClaw config: + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "context7": { + "command": "uvx", + "args": ["context7-mcp"] + } + } + } + } +} +``` + +当 `mcp_config` 缺失时,adapter 会关闭 CSGClaw 托管 MCP: + +```json +{ + "tools": { + "mcp": { + "enabled": false + } + } +} +``` + +### Codex CLI + +Codex CLI 接收同样的 `mcp_config.mcpServers`,写入隔离 Codex home 的 `config.toml` 管理块: + +```toml +# BEGIN csgclaw-managed mcp +[mcp_servers."context7"] +command = "uvx" +args = ["context7-mcp"] +env = { "CONTEXT7_API_KEY" = "secret" } +# END csgclaw-managed mcp +``` + +远程 server 会写入 Codex 支持的字段: + +```toml +[mcp_servers."remote-search"] +url = "https://mcp.example.com/mcp" +bearer_token_env_var = "MCP_TOKEN" +oauth_client_id = "client-id" +oauth_resource = "resource" +``` + +Codex adapter 会忽略统一输入中 Codex 不支持的共享字段,例如 `headers`、`transport`,但共享 validator 仍保证这些字段是可预测的 string/string-map 类型。 + +## 5. API 与持久化 + +涉及字段: + +- `agent.Agent.MCPConfig` +- `agent.CreateAgentSpec.MCPConfig` +- `agent.UpdateRequest.MCPConfig` +- `apitypes.Agent.MCPConfig` +- participant agent binding 中的 create payload +- Web `AgentLike.mcp_config` / `AgentDraft.mcp_config` + +创建 agent: + +```json +{ + "name": "alice", + "runtime_kind": "picoclaw_sandbox", + "mcp_config": { + "mcpServers": { + "context7": { + "command": "uvx", + "args": ["context7-mcp"] + } + } + } +} +``` + +更新 agent: + +```json +{ + "mcp_config": { + "mcpServers": { + "context7": { + "command": "uvx", + "args": ["context7-mcp"] + } + } + } +} +``` + +清除 MCP: + +```json +{ + "mcp_config": null +} +``` + +兼容逻辑: + +- create/update 收到旧 `runtime_options.mcp` 时,会迁移到 `MCPConfig`。 +- 迁移后保存的 runtime options 不再包含 `mcp`。 +- 如果同一次请求同时提交 `mcp_config` 和 `runtime_options.mcp`,以显式 `mcp_config` 为准。 + +## 6. 保存与重建 + +更新 `mcp_config` 的流程: + +1. 解析请求并迁移 legacy `runtime_options.mcp`。 +2. 通过对应 runtime 的 `MCPConfigController.ValidateMCPConfig` 校验。 +3. 持久化新的 `Agent.MCPConfig`。 +4. 通过 `MCPConfigRestartRequired` 判断是否需要 recreate。 +5. 需要 recreate 时,设置 `env_restart_required = true`,再调用现有 `Recreate`。 +6. recreate 成功后,新 runtime provision 使用最新 `MCPConfig` 生成原生配置,并清除 `env_restart_required`。 +7. recreate 失败时,已保存的 `mcp_config` 保留,`env_restart_required` 继续为 true,用户可通过现有重建动作重试。 +8. 如果 runtime 支持无需重建的配置同步,则通过 `ReconcileMCPConfig` 完成。 + +当前 OpenClaw、PicoClaw 和 Codex 的 MCP 配置变更都按需要重建处理,Codex 同时实现了 `ReconcileMCPConfig` 用于重写隔离 Codex home 配置。 + +## 7. 前端行为 + +前端保留现有 MCP JSON 编辑器组件,但读写对象改为 `draft.mcp_config`。 + +展示位置: + +- 创建 agent modal 的 runtime section。 +- Agent 详情页 runtime section。 + +展示条件: + +- `openclaw_sandbox` +- `picoclaw_sandbox` +- `codex` + +保存行为: + +- 创建 agent 时,非空 MCP JSON 写入 create payload 的 `mcp_config`。 +- 编辑 agent 时,MCP JSON 变化写入 update payload 的 `mcp_config`。 +- 清除配置时,update payload 写入 `mcp_config: null`。 +- `runtime_options` 和 `mcp_config` 独立比较、独立提交,避免 MCP 编辑覆盖其它 runtime option,例如 Codex 的 `local_workspace_dir`。 + +## 8. 验证 + +已覆盖的后端测试重点: + +- `mcp_config` 缺失、空对象、空 `mcpServers`、非空 `mcpServers` 的语义。 +- legacy `runtime_options.mcp` 迁移。 +- OpenClaw/PicoClaw 接受 MCP 配置。 +- Codex 生成 `[mcp_servers.""]` 配置块。 +- MCP 配置变更触发 recreate,失败时保留 restart required 状态。 + +已覆盖的前端测试重点: + +- 模型层解析、保存和清除 `mcp_config`。 +- OpenClaw/PicoClaw/Codex runtime 都展示 MCP 配置入口。 +- Agent 创建/编辑 payload 使用顶层 `mcp_config`。 +- MCP 编辑不覆盖普通 `runtime_options`。 + +本分支使用过的验证命令: + +```bash +go test ./internal/runtime/openclawsandbox ./internal/runtime/picoclawsandbox ./internal/runtime/codex ./internal/agent ./internal/api ./internal/apitypes +pnpm exec vitest run tests/models/agents.test.ts tests/components/AgentProfileModal.test.tsx tests/components/AgentActions.test.tsx +pnpm exec tsc --noEmit +``` diff --git a/internal/agent/model.go b/internal/agent/model.go index 205ff00f..dda27307 100644 --- a/internal/agent/model.go +++ b/internal/agent/model.go @@ -2,6 +2,7 @@ package agent import ( "encoding/json" + "fmt" "slices" "strings" "time" @@ -29,6 +30,7 @@ type Agent struct { Avatar string `json:"avatar,omitempty"` BoxID string `json:"box_id,omitempty"` RuntimeOptions map[string]any `json:"runtime_options,omitempty"` + MCPConfig map[string]any `json:"mcp_config,omitempty"` Role string `json:"role"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` @@ -81,6 +83,7 @@ func (a *Agent) UnmarshalJSON(data []byte) error { Avatar string `json:"avatar,omitempty"` BoxID string `json:"box_id,omitempty"` RuntimeOptions map[string]any `json:"runtime_options,omitempty"` + MCPConfig map[string]any `json:"mcp_config,omitempty"` Role string `json:"role"` Status string `json:"status"` CreatedAt time.Time `json:"created_at"` @@ -107,6 +110,7 @@ func (a *Agent) UnmarshalJSON(data []byte) error { Avatar: decoded.Avatar, BoxID: decoded.BoxID, RuntimeOptions: utils.CloneAnyMap(decoded.RuntimeOptions), + MCPConfig: utils.CloneAnyMap(decoded.MCPConfig), Role: decoded.Role, Status: decoded.Status, CreatedAt: decoded.CreatedAt, @@ -148,6 +152,7 @@ func (a *Agent) UnmarshalJSON(data []byte) error { out.SandboxEnabled = *decoded.Runtime.SandboxEnabled } } + out.RuntimeOptions, out.MCPConfig = splitLegacyRuntimeOptionsMCP(out.RuntimeOptions, out.MCPConfig) out.SetRuntimeConfig(out.RuntimeConfig()) profilePayload := decoded.ModelConfig if len(profilePayload) == 0 || string(profilePayload) == "null" { @@ -187,6 +192,7 @@ type CreateAgentSpec struct { UpdatedAt time.Time `json:"updated_at,omitempty"` Profile string `json:"profile,omitempty"` RuntimeOptions map[string]any `json:"runtime_options,omitempty"` + MCPConfig map[string]any `json:"mcp_config,omitempty"` AgentProfile AgentProfile `json:"agent_profile,omitempty"` } @@ -228,6 +234,7 @@ func (s CreateAgentSpec) MarshalJSON() ([]byte, error) { UpdatedAt time.Time `json:"updated_at,omitempty"` Profile string `json:"profile,omitempty"` RuntimeOptions map[string]any `json:"runtime_options,omitempty"` + MCPConfig map[string]any `json:"mcp_config,omitempty"` AgentProfile AgentProfile `json:"agent_profile,omitempty"` } runtimeName := strings.TrimSpace(s.RuntimeName) @@ -270,6 +277,7 @@ func (s CreateAgentSpec) MarshalJSON() ([]byte, error) { UpdatedAt: s.UpdatedAt, Profile: s.Profile, RuntimeOptions: utils.CloneAnyMap(s.RuntimeOptions), + MCPConfig: utils.CloneAnyMap(s.MCPConfig), AgentProfile: cloneProfile(s.AgentProfile), }) } @@ -293,6 +301,7 @@ func (s *CreateAgentSpec) UnmarshalJSON(data []byte) error { ModelConfig json.RawMessage `json:"model_config,omitempty"` Profile json.RawMessage `json:"profile,omitempty"` RuntimeOptions map[string]any `json:"runtime_options,omitempty"` + MCPConfig map[string]any `json:"mcp_config,omitempty"` AgentProfile AgentProfile `json:"agent_profile,omitempty"` } var decoded createAgentSpecJSON @@ -312,6 +321,7 @@ func (s *CreateAgentSpec) UnmarshalJSON(data []byte) error { CreatedAt: decoded.CreatedAt, UpdatedAt: decoded.UpdatedAt, RuntimeOptions: utils.CloneAnyMap(decoded.RuntimeOptions), + MCPConfig: utils.CloneAnyMap(decoded.MCPConfig), AgentProfile: cloneProfile(decoded.AgentProfile), } if decoded.SandboxEnabled != nil { @@ -340,6 +350,7 @@ func (s *CreateAgentSpec) UnmarshalJSON(data []byte) error { out.SandboxEnabled = *decoded.Runtime.SandboxEnabled } } + out.RuntimeOptions, out.MCPConfig = splitLegacyRuntimeOptionsMCP(out.RuntimeOptions, out.MCPConfig) out.SetRuntimeConfig(out.RuntimeConfig()) profilePayload := decoded.ModelConfig if len(profilePayload) == 0 || string(profilePayload) == "null" { @@ -370,6 +381,8 @@ type UpdateRequest struct { Avatar *string `json:"-"` Profile *string `json:"profile,omitempty"` RuntimeOptions *map[string]any `json:"runtime_options,omitempty"` + MCPConfig *map[string]any `json:"mcp_config,omitempty"` + MCPConfigSet bool `json:"-"` AgentProfile *AgentProfile `json:"agent_profile,omitempty"` FieldMask []string `json:"field_mask,omitempty"` } @@ -385,6 +398,7 @@ func (r *UpdateRequest) UnmarshalJSON(data []byte) error { Profile json.RawMessage `json:"profile,omitempty"` Runtime *RuntimeRecord `json:"runtime,omitempty"` RuntimeOptions *map[string]any `json:"runtime_options,omitempty"` + MCPConfig *map[string]any `json:"mcp_config,omitempty"` AgentProfile *AgentProfile `json:"agent_profile,omitempty"` FieldMask []string `json:"field_mask,omitempty"` } @@ -398,9 +412,23 @@ func (r *UpdateRequest) UnmarshalJSON(data []byte) error { Instructions: decoded.Instructions, Image: decoded.Image, RuntimeOptions: decoded.RuntimeOptions, + MCPConfig: decoded.MCPConfig, AgentProfile: decoded.AgentProfile, FieldMask: append([]string(nil), decoded.FieldMask...), } + var rawFields map[string]json.RawMessage + if err := json.Unmarshal(data, &rawFields); err == nil { + if raw, ok := rawFields["mcp_config"]; ok { + out.MCPConfigSet = true + if string(raw) != "null" { + var cfg map[string]any + if err := json.Unmarshal(raw, &cfg); err != nil { + return err + } + out.MCPConfig = &cfg + } + } + } profileField := "" profilePayload := decoded.ModelConfig if len(profilePayload) == 0 || string(profilePayload) == "null" { @@ -425,6 +453,23 @@ func (r *UpdateRequest) UnmarshalJSON(data []byte) error { options := utils.CloneAnyMap(decoded.Runtime.Options) out.RuntimeOptions = &options } + if out.RuntimeOptions != nil { + var existingMCPConfig map[string]any + if out.MCPConfig != nil { + existingMCPConfig = *out.MCPConfig + } + options, mcpConfig, legacyMCPSet, err := splitLegacyRuntimeOptionsMCPStrictWithPresence(*out.RuntimeOptions, existingMCPConfig, out.MCPConfigSet) + if err != nil { + return err + } + out.RuntimeOptions = &options + if !out.MCPConfigSet && legacyMCPSet { + if mcpConfig != nil { + out.MCPConfig = &mcpConfig + } + out.MCPConfigSet = true + } + } if len(out.FieldMask) > 0 { out.FieldMask = normalizeCompactUpdateFieldMask(out.FieldMask, profileField, decoded.Runtime != nil) } @@ -537,5 +582,77 @@ func cloneAgent(src *Agent) *Agent { dst.AgentProfile = cloneProfile(src.AgentProfile) dst.DetectionResults = append([]ProfileDetectionResult(nil), src.DetectionResults...) dst.RuntimeOptions = utils.CloneAnyMap(src.RuntimeOptions) + dst.MCPConfig = utils.CloneAnyMap(src.MCPConfig) return &dst } + +func splitLegacyRuntimeOptionsMCP(runtimeOptions map[string]any, mcpConfig map[string]any) (map[string]any, map[string]any) { + options, currentMCP, _, _ := splitLegacyRuntimeOptionsMCPValue(runtimeOptions, mcpConfig, mcpConfig != nil, false) + return options, currentMCP +} + +func splitLegacyRuntimeOptionsMCPStrict(runtimeOptions map[string]any, mcpConfig map[string]any, mcpConfigProvided bool) (map[string]any, map[string]any, error) { + options, currentMCP, _, err := splitLegacyRuntimeOptionsMCPValue(runtimeOptions, mcpConfig, mcpConfigProvided, true) + return options, currentMCP, err +} + +func splitLegacyRuntimeOptionsMCPStrictWithPresence(runtimeOptions map[string]any, mcpConfig map[string]any, mcpConfigProvided bool) (map[string]any, map[string]any, bool, error) { + return splitLegacyRuntimeOptionsMCPValue(runtimeOptions, mcpConfig, mcpConfigProvided, true) +} + +func splitLegacyRuntimeOptionsMCPValue(runtimeOptions map[string]any, mcpConfig map[string]any, mcpConfigProvided, strict bool) (map[string]any, map[string]any, bool, error) { + options := utils.CloneAnyMap(runtimeOptions) + currentMCP := cloneAnyMapPreserveEmpty(mcpConfig) + if options == nil { + return nil, currentMCP, false, nil + } + raw, ok := options[agentruntime.RuntimeOptionMCPKey] + if !ok { + if len(options) == 0 { + return nil, currentMCP, false, nil + } + return options, currentMCP, false, nil + } + delete(options, agentruntime.RuntimeOptionMCPKey) + if !mcpConfigProvided { + if raw == nil { + currentMCP = nil + } else { + rawMap, ok := raw.(map[string]any) + if !ok { + if strict { + return nil, nil, true, fmt.Errorf("runtime_options.%s must be an object or null", agentruntime.RuntimeOptionMCPKey) + } + } else if normalized, err := agentruntime.NormalizeMCPConfig(rawMap); err == nil { + currentMCP = normalized + } else if strict { + return nil, nil, true, legacyRuntimeOptionsMCPErrorPath(err) + } else { + currentMCP = cloneAnyMapPreserveEmpty(rawMap) + } + } + } + if len(options) == 0 { + options = nil + } + return options, currentMCP, true, nil +} + +func legacyRuntimeOptionsMCPErrorPath(err error) error { + if err == nil { + return nil + } + message := strings.ReplaceAll(err.Error(), "mcp_config", "runtime_options."+agentruntime.RuntimeOptionMCPKey) + return fmt.Errorf("%s", message) +} + +func cloneAnyMapPreserveEmpty(src map[string]any) map[string]any { + if src == nil { + return nil + } + out := make(map[string]any, len(src)) + for key, value := range src { + out[key] = value + } + return out +} diff --git a/internal/agent/runtime_record.go b/internal/agent/runtime_record.go index f8b29842..2d66c4af 100644 --- a/internal/agent/runtime_record.go +++ b/internal/agent/runtime_record.go @@ -181,13 +181,14 @@ func runtimeRecordForAgent(a Agent) RuntimeRecord { if a.CreatedAt.IsZero() { createdAt = time.Time{} } + runtimeOptions, _ := splitLegacyRuntimeOptionsMCP(a.RuntimeOptions, a.MCPConfig) return normalizeRuntimeRecord(RuntimeRecord{ ID: normalizeRuntimeID(a.RuntimeID, a.ID), Kind: a.RuntimeConfig().LegacyKind(), State: agentruntime.State(strings.TrimSpace(a.Status)), AgentIDs: []string{strings.TrimSpace(a.ID)}, SandboxID: strings.TrimSpace(a.BoxID), - Options: a.RuntimeOptions, + Options: runtimeOptions, CreatedAt: createdAt, }) } diff --git a/internal/agent/service.go b/internal/agent/service.go index fbb57c02..18c21135 100644 --- a/internal/agent/service.go +++ b/internal/agent/service.go @@ -1126,10 +1126,13 @@ func (s *Service) replace(ctx context.Context, req CreateRequest) (Agent, error) return s.ensureManager(ctx, true, managerImageOverride, spec.RuntimeKind) } if shouldCreateWorkerSpec(spec) || strings.EqualFold(existing.Role, RoleWorker) { + spec.Role = RoleWorker + if err := s.validateReplaceWorkerSpecBeforeDelete(ctx, spec); err != nil { + return Agent{}, err + } if err := s.Delete(ctx, existing.ID); err != nil { return Agent{}, err } - spec.Role = RoleWorker return s.CreateWorker(ctx, spec) } @@ -1139,6 +1142,37 @@ func (s *Service) replace(ctx context.Context, req CreateRequest) (Agent, error) return s.createNew(ctx, spec) } +func (s *Service) validateReplaceWorkerSpecBeforeDelete(ctx context.Context, spec CreateAgentSpec) error { + runtimeKind := strings.TrimSpace(spec.RuntimeKind) + switch { + case runtimeKind == "": + return fmt.Errorf("runtime_kind is required") + case isGatewayRuntimeKind(runtimeKind) && strings.TrimSpace(spec.Image) == "": + return fmt.Errorf("image is required for runtime_kind %q", runtimeKind) + } + if _, err := s.runtimeForKind(runtimeKind); err != nil { + return err + } + var err error + spec.RuntimeOptions, spec.MCPConfig, err = splitLegacyRuntimeOptionsMCPStrict(spec.RuntimeOptions, spec.MCPConfig, spec.MCPConfig != nil) + if err != nil { + return err + } + normalizedMCPConfig, err := normalizeMCPConfig(spec.MCPConfig) + if err != nil { + return err + } + spec.MCPConfig = normalizedMCPConfig + resolvedProfile, err := s.profileForCreateRequest(ctx, &spec) + if err != nil { + return err + } + if err := s.validateRuntimeConfig(ctx, runtimeKind, runtimeConfigSnapshotForAgent(s.hydrateProfileFromCatalog(resolvedProfile), spec.RuntimeOptions)); err != nil { + return err + } + return s.validateMCPConfig(ctx, runtimeKind, mcpConfigSnapshotForAgent(spec.MCPConfig)) +} + func (s *Service) managerImageOverrideForReplace(ctx context.Context, existing Agent, runtimeKind string) string { runtimeKind = strings.TrimSpace(runtimeKind) if runtimeKind == "" { @@ -1167,6 +1201,7 @@ func mergeReplaceSpec(existing Agent, next CreateAgentSpec, fieldMask []string) UpdatedAt: existing.UpdatedAt, Profile: existing.Profile, RuntimeOptions: utils.CloneAnyMap(existing.RuntimeOptions), + MCPConfig: utils.CloneAnyMap(existing.MCPConfig), AgentProfile: cloneProfile(existing.AgentProfile), } for _, field := range fieldMask { @@ -1201,6 +1236,7 @@ func mergeReplaceSpec(existing Agent, next CreateAgentSpec, fieldMask []string) merged.RuntimeName = next.RuntimeName merged.SandboxEnabled = next.SandboxEnabled merged.RuntimeOptions = utils.CloneAnyMap(next.RuntimeOptions) + merged.MCPConfig = utils.CloneAnyMap(next.MCPConfig) case "role": merged.Role = next.Role case "status": @@ -1218,6 +1254,8 @@ func mergeReplaceSpec(existing Agent, next CreateAgentSpec, fieldMask []string) merged.AgentProfile = cloneProfile(next.AgentProfile) case "runtime_options": merged.RuntimeOptions = utils.CloneAnyMap(next.RuntimeOptions) + case "mcp_config": + merged.MCPConfig = utils.CloneAnyMap(next.MCPConfig) default: return CreateAgentSpec{}, fmt.Errorf("unsupported agent field mask path %q", field) } @@ -1389,6 +1427,9 @@ func (s *Service) Start(ctx context.Context, id string) (Agent, error) { if err := s.validateRuntimeConfig(ctx, strings.TrimSpace(got.RuntimeKind), runtimeConfigSnapshotForAgent(startProfile, got.RuntimeOptions)); err != nil { return Agent{}, err } + if err := s.validateMCPConfig(ctx, strings.TrimSpace(got.RuntimeKind), mcpConfigSnapshotForAgent(got.MCPConfig)); err != nil { + return Agent{}, err + } runtimeImpl, err := s.runtimeForKind(strings.TrimSpace(got.RuntimeKind)) if err != nil { @@ -1643,6 +1684,10 @@ func (s *Service) recreateLegacyNamedGatewayAgentBox(ctx context.Context, got Ag _ = s.closeBox(box) return got, false, err } + if err := s.validateMCPConfig(ctx, strings.TrimSpace(got.RuntimeKind), mcpConfigSnapshotForAgent(got.MCPConfig)); err != nil { + _ = s.closeBox(box) + return got, false, err + } runtimeImpl, err := s.runtimeForKind(strings.TrimSpace(got.RuntimeKind)) if err != nil { _ = s.closeBox(box) @@ -1809,6 +1854,15 @@ func (s *Service) CreateWorker(ctx context.Context, spec CreateAgentSpec) (Agent return Agent{}, err } spec.SetRuntimeConfig(runtimeCfg) + spec.RuntimeOptions, spec.MCPConfig, err = splitLegacyRuntimeOptionsMCPStrict(spec.RuntimeOptions, spec.MCPConfig, spec.MCPConfig != nil) + if err != nil { + return Agent{}, err + } + normalizedMCPConfig, err := normalizeMCPConfig(spec.MCPConfig) + if err != nil { + return Agent{}, err + } + spec.MCPConfig = normalizedMCPConfig runtimeKind := spec.RuntimeKind runtimeName := spec.RuntimeName sandboxed := spec.SandboxEnabled @@ -1885,6 +1939,9 @@ func (s *Service) CreateWorker(ctx context.Context, spec CreateAgentSpec) (Agent if err := s.validateRuntimeConfig(ctx, runtimeKind, runtimeConfigSnapshotForAgent(runtimeResolvedProfile, spec.RuntimeOptions)); err != nil { return Agent{}, err } + if err := s.validateMCPConfig(ctx, runtimeKind, mcpConfigSnapshotForAgent(spec.MCPConfig)); err != nil { + return Agent{}, err + } runtimeProfile := s.runtimeProfileForKind(runtimeKind, id, name, description, runtimeResolvedProfile) if err := s.provisionRuntime(ctx, runtimeImpl, runtimeKind, agentruntime.ProvisionRequest{ RuntimeID: runtimeIDForAgentID(id), @@ -1892,6 +1949,8 @@ func (s *Service) CreateWorker(ctx context.Context, spec CreateAgentSpec) (Agent ParticipantID: participantIDForAgent(name, id), AgentName: name, Profile: runtimeProfile, + RuntimeOptions: utils.CloneAnyMap(spec.RuntimeOptions), + MCPConfig: utils.CloneAnyMap(spec.MCPConfig), WorkspaceOverlay: strings.TrimSpace(spec.FromTemplate), }); err != nil { return Agent{}, fmt.Errorf("provision worker runtime: %w", err) @@ -1915,14 +1974,14 @@ func (s *Service) CreateWorker(ctx context.Context, spec CreateAgentSpec) (Agent defer func() { _ = s.closeBox(box) }() - return s.persistCreatedWorker(ctx, id, name, description, instructions, image, avatar, runtimeKind, runtimeName, sandboxed, resolvedProfile, spec.RuntimeOptions, agentruntime.Info{ + return s.persistCreatedWorker(ctx, id, name, description, instructions, image, avatar, runtimeKind, runtimeName, sandboxed, resolvedProfile, spec.RuntimeOptions, spec.MCPConfig, agentruntime.Info{ HandleID: strings.TrimSpace(info.ID), State: agentruntime.State(info.State), CreatedAt: info.CreatedAt.UTC(), }) } if runtimeKind == RuntimeKindCodex { - if err := s.persistStartingWorker(ctx, id, name, description, instructions, image, avatar, runtimeKind, runtimeName, sandboxed, resolvedProfile, spec.RuntimeOptions); err != nil { + if err := s.persistStartingWorker(ctx, id, name, description, instructions, image, avatar, runtimeKind, runtimeName, sandboxed, resolvedProfile, spec.RuntimeOptions, spec.MCPConfig); err != nil { return Agent{}, err } defer func() { @@ -1947,10 +2006,10 @@ func (s *Service) CreateWorker(ctx context.Context, spec CreateAgentSpec) (Agent CreatedAt: time.Now().UTC(), } - return s.persistCreatedWorker(ctx, id, name, description, instructions, image, avatar, runtimeKind, runtimeName, sandboxed, resolvedProfile, spec.RuntimeOptions, info) + return s.persistCreatedWorker(ctx, id, name, description, instructions, image, avatar, runtimeKind, runtimeName, sandboxed, resolvedProfile, spec.RuntimeOptions, spec.MCPConfig, info) } -func (s *Service) persistStartingWorker(ctx context.Context, id, name, description, instructions, image, avatar, runtimeKind, runtimeName string, sandboxEnabled bool, profile AgentProfile, runtimeOptions map[string]any) error { +func (s *Service) persistStartingWorker(ctx context.Context, id, name, description, instructions, image, avatar, runtimeKind, runtimeName string, sandboxEnabled bool, profile AgentProfile, runtimeOptions map[string]any, mcpConfig map[string]any) error { s.mu.Lock() if _, _, ok := s.agentByIDLocked(id); ok { @@ -1962,7 +2021,7 @@ func (s *Service) persistStartingWorker(ctx context.Context, id, name, descripti return fmt.Errorf("agent name %q already exists", name) } - worker := newWorkerAgent(id, name, description, instructions, image, avatar, runtimeKind, runtimeName, sandboxEnabled, profile, runtimeOptions, agentruntime.Info{ + worker := newWorkerAgent(id, name, description, instructions, image, avatar, runtimeKind, runtimeName, sandboxEnabled, profile, runtimeOptions, mcpConfig, agentruntime.Info{ State: agentruntime.StateCreated, CreatedAt: time.Now().UTC(), }) @@ -1989,7 +2048,7 @@ func (s *Service) removeStartingWorker(ctx context.Context, id string) error { return err } -func (s *Service) persistCreatedWorker(ctx context.Context, id, name, description, instructions, image, avatar, runtimeKind, runtimeName string, sandboxEnabled bool, profile AgentProfile, createRuntimeExt map[string]any, info agentruntime.Info) (Agent, error) { +func (s *Service) persistCreatedWorker(ctx context.Context, id, name, description, instructions, image, avatar, runtimeKind, runtimeName string, sandboxEnabled bool, profile AgentProfile, createRuntimeExt map[string]any, mcpConfig map[string]any, info agentruntime.Info) (Agent, error) { s.mu.Lock() if existing, _, ok := s.agentByIDLocked(id); ok && !isStartingWorker(existing) { @@ -2001,7 +2060,7 @@ func (s *Service) persistCreatedWorker(ctx context.Context, id, name, descriptio return Agent{}, fmt.Errorf("agent name %q already exists", name) } - worker := newWorkerAgent(id, name, description, instructions, image, avatar, runtimeKind, runtimeName, sandboxEnabled, profile, createRuntimeExt, info) + worker := newWorkerAgent(id, name, description, instructions, image, avatar, runtimeKind, runtimeName, sandboxEnabled, profile, createRuntimeExt, mcpConfig, info) s.agents[worker.ID] = worker s.syncRuntimeRecordLocked(worker) if worker.AgentProfile.ProfileComplete { @@ -2021,7 +2080,7 @@ func (s *Service) persistCreatedWorker(ctx context.Context, id, name, descriptio return created, nil } -func newWorkerAgent(id, name, description, instructions, image, avatar, runtimeKind, runtimeName string, sandboxEnabled bool, profile AgentProfile, runtimeOptions map[string]any, info agentruntime.Info) Agent { +func newWorkerAgent(id, name, description, instructions, image, avatar, runtimeKind, runtimeName string, sandboxEnabled bool, profile AgentProfile, runtimeOptions map[string]any, mcpConfig map[string]any, info agentruntime.Info) Agent { createdAt := info.CreatedAt.UTC() if info.CreatedAt.IsZero() { createdAt = time.Now().UTC() @@ -2035,6 +2094,7 @@ func newWorkerAgent(id, name, description, instructions, image, avatar, runtimeK if len(runtimeOptions) > 0 { agentRX = utils.CloneAnyMap(runtimeOptions) } + agentRX, mcpConfig = splitLegacyRuntimeOptionsMCP(agentRX, mcpConfig) runtimeCfg, _ := agentruntime.RuntimeConfigFromSelection(runtimeKind, runtimeName, sandboxEnabled) resolvedRuntimeKind := runtimeCfg.LegacyKind() resolvedRuntimeName := runtimeCfg.Name @@ -2054,6 +2114,7 @@ func newWorkerAgent(id, name, description, instructions, image, avatar, runtimeK CreatedAt: createdAt, UpdatedAt: createdAt, RuntimeOptions: agentRX, + MCPConfig: utils.CloneAnyMap(mcpConfig), Profile: profileSelector(prof), AgentProfile: prof, ProfileComplete: prof.ProfileComplete, @@ -2112,6 +2173,8 @@ func (s *Service) provisionRuntimeForAgent(ctx context.Context, rt agentruntime. ParticipantID: participantIDForAgent(got.Name, got.ID), AgentName: strings.TrimSpace(got.Name), Profile: s.runtimeProfileForAgent(got), + RuntimeOptions: utils.CloneAnyMap(got.RuntimeOptions), + MCPConfig: utils.CloneAnyMap(got.MCPConfig), WorkspaceOverlay: strings.TrimSpace(workspaceOverlay), }) } diff --git a/internal/agent/service_profiles.go b/internal/agent/service_profiles.go index 7d4dabd2..84a5b3d2 100644 --- a/internal/agent/service_profiles.go +++ b/internal/agent/service_profiles.go @@ -11,6 +11,7 @@ import ( "csgclaw/internal/identity" agentruntime "csgclaw/internal/runtime" "csgclaw/internal/runtime/openclawsandbox" + "csgclaw/internal/runtime/picoclawsandbox" "csgclaw/internal/sandbox" "csgclaw/internal/utils" ) @@ -164,13 +165,13 @@ func (s *Service) syncGatewayAfterProfileChange(ctx context.Context, id string, _, err := s.EnsureManager(ctx, false) return err } - if gatewayProfileRuntimeRestartRequired(previous, normalized) { - return s.syncGatewayHostConfig(got, runtimeNormalized) - } if restartRequired { _, err := s.Recreate(ctx, id) return err } + if gatewayProfileRuntimeRestartRequired(previous, normalized) { + return s.syncGatewayHostConfig(got, runtimeNormalized) + } return nil } @@ -183,7 +184,11 @@ func (s *Service) syncGatewayHostConfig(got Agent, profile AgentProfile) error { switch strings.TrimSpace(got.RuntimeKind) { case RuntimeKindPicoClawSandbox: feishuProvider := s.currentFeishuProviderForRuntime(RuntimeKindPicoClawSandbox) - if _, err := s.ensureAgentPicoClawConfigForParticipantWithResolver(got.Name, participantID, got.ID, s.server, modelCfg, resolveManagerBaseURL, feishuProvider); err != nil { + agentHome, err := s.agentHomeDir(got.ID) + if err != nil { + return err + } + if _, err := picoclawsandbox.EnsureConfigWithMCPConfig(agentHome, participantID, got.ID, s.server, modelCfg, got.MCPConfig, resolveManagerBaseURL, feishuProvider); err != nil { return fmt.Errorf("sync gateway picoclaw config: %w", err) } case RuntimeKindOpenClawSandbox: @@ -192,7 +197,7 @@ func (s *Service) syncGatewayHostConfig(got Agent, profile AgentProfile) error { return err } feishuProvider := s.currentFeishuProviderForRuntime(RuntimeKindOpenClawSandbox) - if _, err := openclawsandbox.EnsureConfig(agentHome, participantID, got.ID, s.server, modelCfg, resolveManagerBaseURL, feishuProvider); err != nil { + if _, err := openclawsandbox.EnsureConfigWithMCPConfig(agentHome, participantID, got.ID, s.server, modelCfg, got.MCPConfig, resolveManagerBaseURL, feishuProvider); err != nil { return fmt.Errorf("sync gateway openclaw config: %w", err) } default: @@ -258,6 +263,11 @@ func (s *Service) Update(ctx context.Context, id string, req UpdateRequest) (Age } instructionsUpdated := updateRequested("instructions", req.Instructions != nil) runtimeOptionsUpdated := updateRequested("runtime_options", req.RuntimeOptions != nil) + mcpConfigUpdated := updateRequested("mcp_config", req.MCPConfigSet) + if mcpConfigUpdated && !req.MCPConfigSet { + s.mu.Unlock() + return Agent{}, fmt.Errorf("field_mask includes mcp_config but request is missing mcp_config") + } if updateRequested("name", req.Name != nil) { if req.Name == nil { s.mu.Unlock() @@ -320,7 +330,7 @@ func (s *Service) Update(ctx context.Context, id string, req UpdateRequest) (Age current.Profile = strings.TrimSpace(*req.Profile) } agentProfileUpdated := updateRequested("agent_profile", req.AgentProfile != nil) - if !agentProfileUpdated && strings.TrimSpace(current.Profile) != "" { + if !hasFieldMask && !agentProfileUpdated && strings.TrimSpace(current.Profile) != "" { if selected, ok := CatalogProviderModelConfig(s.llm, current.Profile); ok { selected.Name = current.AgentProfile.Name selected.Description = current.AgentProfile.Description @@ -332,7 +342,7 @@ func (s *Service) Update(ctx context.Context, id string, req UpdateRequest) (Age agentProfileUpdated = true } } - if agentProfileUpdated || runtimeOptionsUpdated { + if agentProfileUpdated || runtimeOptionsUpdated || mcpConfigUpdated { profileUpdated = true profile := current.AgentProfile if agentProfileUpdated { @@ -353,6 +363,20 @@ func (s *Service) Update(ctx context.Context, id string, req UpdateRequest) (Age req.RuntimeOptions = &empty } patch = *req.RuntimeOptions + var legacyMCPConfig map[string]any + var legacyMCPSet bool + var err error + patch, legacyMCPConfig, legacyMCPSet, err = splitLegacyRuntimeOptionsMCPStrictWithPresence(patch, nil, false) + if err != nil { + s.mu.Unlock() + return Agent{}, err + } + if !mcpConfigUpdated && legacyMCPSet { + if legacyMCPConfig != nil { + req.MCPConfig = &legacyMCPConfig + } + mcpConfigUpdated = true + } } mergedFlat := runtimeOptionsAfterPatch(current.RuntimeKind, current.RuntimeOptions, nil) if runtimeOptionsUpdated { @@ -361,17 +385,41 @@ func (s *Service) Update(ctx context.Context, id string, req UpdateRequest) (Age } else { current.RuntimeOptions = nextAgentRuntimeOptions(current.RuntimeKind, current.RuntimeOptions, mergedFlat) } + if mcpConfigUpdated { + if req.MCPConfig == nil { + current.MCPConfig = nil + } else { + normalizedMCPConfig, err := agentruntime.NormalizeMCPConfig(*req.MCPConfig) + if err != nil { + s.mu.Unlock() + return Agent{}, err + } + current.MCPConfig = normalizedMCPConfig + } + } normalized := normalizeProfileForAgentRuntime(profile, current.RuntimeOptions, current.Name, current.Description, current.RuntimeKind, mergedFlat) runtimePrevious := s.hydrateProfileFromCatalogLocked(previous.AgentProfile) runtimeNormalized := s.hydrateProfileFromCatalogLocked(normalized) change := runtimeConfigChangeForAgent(runtimePrevious, runtimeNormalized, previous.RuntimeOptions, current.RuntimeOptions) + mcpChange := mcpConfigChangeForAgent(previous.MCPConfig, current.MCPConfig) + runtimeConfigUpdated := agentProfileUpdated || runtimeOptionsUpdated restartRequired = profileRestartRequired(previous, normalized) - controllerRestartRequired, err := s.runtimeConfigRestartRequired(runtimeKind, change) - if err != nil { - s.mu.Unlock() - return Agent{}, err + if runtimeConfigUpdated { + controllerRestartRequired, err := s.runtimeConfigRestartRequired(runtimeKind, change) + if err != nil { + s.mu.Unlock() + return Agent{}, err + } + restartRequired = restartRequired || controllerRestartRequired + } + if mcpConfigUpdated { + controllerMCPRestartRequired, err := s.mcpConfigRestartRequired(runtimeKind, mcpChange) + if err != nil { + s.mu.Unlock() + return Agent{}, err + } + restartRequired = restartRequired || controllerMCPRestartRequired } - restartRequired = restartRequired || controllerRestartRequired normalized.EnvRestartRequired = restartRequired current.AgentProfile = normalized current.ProfileComplete = normalized.ProfileComplete @@ -380,10 +428,17 @@ func (s *Service) Update(ctx context.Context, id string, req UpdateRequest) (Age if current.ProfileComplete && strings.EqualFold(strings.TrimSpace(current.Status), "profile_incomplete") { current.Status = string(sandbox.StateStopped) } - if runtimeNormalized.ProfileComplete { + if (runtimeConfigUpdated && runtimeNormalized.ProfileComplete) || mcpConfigUpdated { s.mu.Unlock() - if err := s.validateRuntimeConfig(ctx, runtimeKind, change.Current); err != nil { - return Agent{}, err + if runtimeConfigUpdated && runtimeNormalized.ProfileComplete { + if err := s.validateRuntimeConfig(ctx, runtimeKind, change.Current); err != nil { + return Agent{}, err + } + } + if mcpConfigUpdated { + if err := s.validateMCPConfig(ctx, runtimeKind, mcpChange.Current); err != nil { + return Agent{}, err + } } s.mu.Lock() if _, key, ok = s.agentByIDLocked(id); !ok { @@ -409,6 +464,11 @@ func (s *Service) Update(ctx context.Context, id string, req UpdateRequest) (Age return Agent{}, err } } + if mcpConfigUpdated { + if err := s.reconcileMCPConfig(ctx, previous, current); err != nil { + return Agent{}, err + } + } if restartRequired && runtimeRunning && !isGatewayRuntimeKind(runtimeKind) { s.stopLifecycleAgent(id) } @@ -478,6 +538,17 @@ func runtimeConfigSnapshotForAgent(profile AgentProfile, options map[string]any) } } +func mcpConfigChangeForAgent(previousConfig, currentConfig map[string]any) agentruntime.MCPConfigChange { + return agentruntime.MCPConfigChange{ + Previous: mcpConfigSnapshotForAgent(previousConfig), + Current: mcpConfigSnapshotForAgent(currentConfig), + } +} + +func mcpConfigSnapshotForAgent(config map[string]any) agentruntime.MCPConfigSnapshot { + return agentruntime.MCPConfigSnapshot{Config: utils.CloneAnyMap(config)} +} + func (s *Service) hydrateProfileFromCatalog(profile AgentProfile) AgentProfile { if s == nil { return profile @@ -577,6 +648,50 @@ func (s *Service) runtimeConfigRestartRequired(runtimeKind string, change agentr return controller.RestartRequired(change) } +func (s *Service) validateMCPConfig(ctx context.Context, runtimeKind string, current agentruntime.MCPConfigSnapshot) error { + if s == nil { + return fmt.Errorf("agent service is required") + } + if current.Config == nil { + return nil + } + runtimeKind = strings.TrimSpace(runtimeKind) + if runtimeKind == "" { + return nil + } + rt, err := s.runtimeForKind(runtimeKind) + if err != nil { + return err + } + controller, ok := rt.(agentruntime.MCPConfigController) + if !ok { + return fmt.Errorf("mcp_config is not supported for runtime_kind %q", runtimeKind) + } + return controller.ValidateMCPConfig(ctx, current) +} + +func (s *Service) mcpConfigRestartRequired(runtimeKind string, change agentruntime.MCPConfigChange) (bool, error) { + if s == nil { + return false, fmt.Errorf("agent service is required") + } + if change.Previous.Config == nil && change.Current.Config == nil { + return false, nil + } + runtimeKind = strings.TrimSpace(runtimeKind) + if runtimeKind == "" { + return false, nil + } + rt, err := s.runtimeForKind(runtimeKind) + if err != nil { + return false, err + } + controller, ok := rt.(agentruntime.MCPConfigController) + if !ok { + return false, fmt.Errorf("mcp_config is not supported for runtime_kind %q", runtimeKind) + } + return controller.MCPConfigRestartRequired(change) +} + func (s *Service) reconcileRuntimeConfig(ctx context.Context, previous, current Agent) error { if s == nil { return fmt.Errorf("agent service is required") @@ -598,6 +713,35 @@ func (s *Service) reconcileRuntimeConfig(ctx context.Context, previous, current return controller.ReconcileConfig(ctx, runtimeHandleForAgent(current), runtimeConfigChangeForAgent(previous.AgentProfile, current.AgentProfile, previous.RuntimeOptions, current.RuntimeOptions)) } +func (s *Service) reconcileMCPConfig(ctx context.Context, previous, current Agent) error { + if s == nil { + return fmt.Errorf("agent service is required") + } + runtimeKind := strings.TrimSpace(current.RuntimeKind) + if runtimeKind == "" { + return nil + } + rt, err := s.runtimeForKind(runtimeKind) + if err != nil { + return err + } + controller, ok := rt.(agentruntime.MCPConfigController) + if !ok { + if current.MCPConfig == nil { + return nil + } + return fmt.Errorf("mcp_config is not supported for runtime_kind %q", runtimeKind) + } + return controller.ReconcileMCPConfig(ctx, runtimeHandleForAgent(current), mcpConfigChangeForAgent(previous.MCPConfig, current.MCPConfig)) +} + +func normalizeMCPConfig(config map[string]any) (map[string]any, error) { + if config == nil { + return nil, nil + } + return agentruntime.NormalizeMCPConfig(config) +} + func (s *Service) storedAPIKeyForModelRequest(req ProfileModelRequest, profile AgentProfile) string { agentID := strings.TrimSpace(req.AgentID) if s == nil || agentID == "" || profile.Provider != ProviderAPI { @@ -684,6 +828,9 @@ func (s *Service) recreate(ctx context.Context, id string, imageFor func(context if err := s.validateRuntimeConfig(ctx, strings.TrimSpace(got.RuntimeKind), runtimeConfigSnapshotForAgent(profile, got.RuntimeOptions)); err != nil { return Agent{}, err } + if err := s.validateMCPConfig(ctx, strings.TrimSpace(got.RuntimeKind), mcpConfigSnapshotForAgent(got.MCPConfig)); err != nil { + return Agent{}, err + } runtimeImpl, err := s.runtimeForKind(strings.TrimSpace(got.RuntimeKind)) if err != nil { @@ -755,11 +902,13 @@ func (s *Service) recreate(ctx context.Context, id string, imageFor func(context return Agent{}, fmt.Errorf("refresh gateway template skills: %w", err) } if err := s.provisionRuntime(ctx, runtimeImpl, runtimeKind, agentruntime.ProvisionRequest{ - RuntimeID: createSpec.RuntimeID, - AgentID: createSpec.AgentID, - ParticipantID: participantIDForAgent(createSpec.AgentName, createSpec.AgentID), - AgentName: createSpec.AgentName, - Profile: runtimeProfile, + RuntimeID: createSpec.RuntimeID, + AgentID: createSpec.AgentID, + ParticipantID: participantIDForAgent(createSpec.AgentName, createSpec.AgentID), + AgentName: createSpec.AgentName, + Profile: runtimeProfile, + RuntimeOptions: utils.CloneAnyMap(got.RuntimeOptions), + MCPConfig: utils.CloneAnyMap(got.MCPConfig), }); err != nil { return Agent{}, fmt.Errorf("provision agent runtime: %w", err) } diff --git a/internal/agent/service_test.go b/internal/agent/service_test.go index ea924ae4..f1029a6e 100644 --- a/internal/agent/service_test.go +++ b/internal/agent/service_test.go @@ -8,6 +8,7 @@ import ( "io" "os" "path/filepath" + "reflect" "strings" "syscall" "testing" @@ -113,21 +114,24 @@ func (f *fakeInstance) Close() error { } type fakeAgentRuntime struct { - kind string - workspace func(string) string - skills func(string) string - hostLogs func(string) []string - validate func(context.Context, agentruntime.RuntimeConfigSnapshot) error - restart func(agentruntime.RuntimeConfigChange) (bool, error) - reconcile func(context.Context, agentruntime.Handle, agentruntime.RuntimeConfigChange) error - provision func(context.Context, agentruntime.ProvisionRequest) error - new func(context.Context, agentruntime.Spec) (agentruntime.Handle, error) - start func(context.Context, agentruntime.Handle) (agentruntime.State, error) - stop func(context.Context, agentruntime.Handle) (agentruntime.State, error) - del func(context.Context, agentruntime.Handle) error - state func(context.Context, agentruntime.Handle) (agentruntime.State, error) - info func(context.Context, agentruntime.Handle) (agentruntime.Info, error) - streamLogs func(context.Context, agentruntime.Handle, agentruntime.LogOptions) error + kind string + workspace func(string) string + skills func(string) string + hostLogs func(string) []string + validate func(context.Context, agentruntime.RuntimeConfigSnapshot) error + restart func(agentruntime.RuntimeConfigChange) (bool, error) + reconcile func(context.Context, agentruntime.Handle, agentruntime.RuntimeConfigChange) error + mcpValidate func(context.Context, agentruntime.MCPConfigSnapshot) error + mcpRestart func(agentruntime.MCPConfigChange) (bool, error) + mcpReconcile func(context.Context, agentruntime.Handle, agentruntime.MCPConfigChange) error + provision func(context.Context, agentruntime.ProvisionRequest) error + new func(context.Context, agentruntime.Spec) (agentruntime.Handle, error) + start func(context.Context, agentruntime.Handle) (agentruntime.State, error) + stop func(context.Context, agentruntime.Handle) (agentruntime.State, error) + del func(context.Context, agentruntime.Handle) error + state func(context.Context, agentruntime.Handle) (agentruntime.State, error) + info func(context.Context, agentruntime.Handle) (agentruntime.Info, error) + streamLogs func(context.Context, agentruntime.Handle, agentruntime.LogOptions) error } type fakeBareAgentRuntime struct { @@ -246,6 +250,27 @@ func (f fakeAgentRuntime) ReconcileConfig(ctx context.Context, h agentruntime.Ha return nil } +func (f fakeAgentRuntime) ValidateMCPConfig(ctx context.Context, current agentruntime.MCPConfigSnapshot) error { + if f.mcpValidate != nil { + return f.mcpValidate(ctx, current) + } + return agentruntime.ValidateMCPConfig(current.Config) +} + +func (f fakeAgentRuntime) MCPConfigRestartRequired(change agentruntime.MCPConfigChange) (bool, error) { + if f.mcpRestart != nil { + return f.mcpRestart(change) + } + return agentruntime.MCPConfigNeedsRestart(change.Previous.Config, change.Current.Config) +} + +func (f fakeAgentRuntime) ReconcileMCPConfig(ctx context.Context, h agentruntime.Handle, change agentruntime.MCPConfigChange) error { + if f.mcpReconcile != nil { + return f.mcpReconcile(ctx, h, change) + } + return nil +} + func (f fakeAgentRuntime) Start(ctx context.Context, h agentruntime.Handle) (agentruntime.State, error) { if f.start != nil { return f.start(ctx, h) @@ -1512,6 +1537,455 @@ func TestUpdateCodexLocalWorkspaceDirMarksRunningRuntimeForRestart(t *testing.T) } } +func TestUpdateMCPConfigRecreatesOpenClawAndProvisionsLatestConfig(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + var provisionCalls []agentruntime.ProvisionRequest + mcpReconcileCalls := 0 + svc, err := NewService( + testModelConfig(), + config.ServerConfig{ + ListenAddr: "127.0.0.1:18080", + AdvertiseBaseURL: "http://127.0.0.1:18080", + AccessToken: "shared-token", + }, + "openclaw-image:test", + "", + WithRuntime(fakeAgentRuntime{ + kind: RuntimeKindOpenClawSandbox, + mcpRestart: func(change agentruntime.MCPConfigChange) (bool, error) { + return !reflect.DeepEqual(change.Previous.Config, change.Current.Config), nil + }, + mcpReconcile: func(context.Context, agentruntime.Handle, agentruntime.MCPConfigChange) error { + mcpReconcileCalls++ + return nil + }, + provision: func(_ context.Context, req agentruntime.ProvisionRequest) error { + provisionCalls = append(provisionCalls, req) + return nil + }, + new: func(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) { + return agentruntime.Handle{RuntimeID: spec.RuntimeID, HandleID: "openclaw-box-new"}, nil + }, + info: func(context.Context, agentruntime.Handle) (agentruntime.Info, error) { + return agentruntime.Info{ + HandleID: "openclaw-box-new", + State: agentruntime.StateRunning, + CreatedAt: time.Date(2026, 6, 26, 9, 0, 0, 0, time.UTC), + }, nil + }, + }), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + currentProfile := AgentProfile{ + Name: "alice", + Provider: ProviderAPI, + BaseURL: "https://api.example/v1", + APIKey: "api-key", + ModelID: "qwen3.7-max", + ProfileComplete: true, + } + svc.agents["u-alice"] = Agent{ + ID: "u-alice", + Name: "alice", + RuntimeID: "rt-u-alice", + RuntimeKind: RuntimeKindOpenClawSandbox, + Image: "openclaw-image:test", + BoxID: "openclaw-box-old", + Role: RoleWorker, + Status: string(agentruntime.StateRunning), + RuntimeOptions: map[string]any{ + "local_workspace_dir": "/tmp/keep", + }, + AgentProfile: currentProfile, + ProfileComplete: true, + CreatedAt: time.Date(2026, 5, 18, 9, 0, 0, 0, time.UTC), + } + + nextMCPConfig := map[string]any{ + "mcpServers": map[string]any{ + "context7": map[string]any{ + "command": "uvx", + "args": []any{"context7-mcp"}, + }, + }, + } + updated, err := svc.Update(context.Background(), "u-alice", UpdateRequest{MCPConfig: &nextMCPConfig, MCPConfigSet: true}) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + if mcpReconcileCalls != 1 { + t.Fatalf("ReconcileMCPConfig() calls = %d, want 1", mcpReconcileCalls) + } + if len(provisionCalls) != 1 { + t.Fatalf("Provision() calls = %d, want 1", len(provisionCalls)) + } + if got, want := provisionCalls[0].RuntimeOptions["local_workspace_dir"], "/tmp/keep"; got != want { + t.Fatalf("Provision().RuntimeOptions local_workspace_dir = %#v, want %q", got, want) + } + mcpRoot := provisionCalls[0].MCPConfig + if mcpRoot == nil { + t.Fatalf("Provision().MCPConfig = %#v, want object", provisionCalls[0].MCPConfig) + } + servers := mcpRoot["mcpServers"].(map[string]any) + if _, ok := servers["context7"]; !ok { + t.Fatalf("Provision().MCPConfig mcpServers = %#v, want context7", servers) + } + if updated.AgentProfile.EnvRestartRequired { + t.Fatal("Update().AgentProfile.EnvRestartRequired = true, want false after successful recreate") + } + if got, want := updated.BoxID, "openclaw-box-new"; got != want { + t.Fatalf("Update().BoxID = %q, want %q", got, want) + } +} + +func TestUpdateMCPConfigRecreateFailureKeepsRestartRequired(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + svc, err := NewService( + testModelConfig(), + config.ServerConfig{ + ListenAddr: "127.0.0.1:18080", + AdvertiseBaseURL: "http://127.0.0.1:18080", + AccessToken: "shared-token", + }, + "openclaw-image:test", + "", + WithRuntime(fakeAgentRuntime{ + kind: RuntimeKindOpenClawSandbox, + mcpRestart: func(change agentruntime.MCPConfigChange) (bool, error) { + return !reflect.DeepEqual(change.Previous.Config, change.Current.Config), nil + }, + provision: func(context.Context, agentruntime.ProvisionRequest) error { + return fmt.Errorf("provision failed") + }, + }), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + currentProfile := AgentProfile{ + Name: "alice", + Provider: ProviderAPI, + BaseURL: "https://api.example/v1", + APIKey: "api-key", + ModelID: "qwen3.7-max", + ProfileComplete: true, + } + svc.agents["u-alice"] = Agent{ + ID: "u-alice", + Name: "alice", + RuntimeID: "rt-u-alice", + RuntimeKind: RuntimeKindOpenClawSandbox, + Image: "openclaw-image:test", + BoxID: "openclaw-box-old", + Role: RoleWorker, + Status: string(agentruntime.StateRunning), + AgentProfile: currentProfile, + ProfileComplete: true, + CreatedAt: time.Date(2026, 5, 18, 9, 0, 0, 0, time.UTC), + } + + nextMCPConfig := map[string]any{ + "mcpServers": map[string]any{ + "context7": map[string]any{"command": "uvx"}, + }, + } + _, err = svc.Update(context.Background(), "u-alice", UpdateRequest{MCPConfig: &nextMCPConfig, MCPConfigSet: true}) + if err == nil || !strings.Contains(err.Error(), "provision failed") { + t.Fatalf("Update() error = %v, want provision failed", err) + } + got, ok := svc.Agent("u-alice") + if !ok { + t.Fatal("Agent() ok = false, want true") + } + if !got.AgentProfile.EnvRestartRequired { + t.Fatal("Agent().AgentProfile.EnvRestartRequired = false, want true after failed recreate") + } + if _, ok := got.MCPConfig["mcpServers"].(map[string]any); !ok { + t.Fatalf("Agent().MCPConfig = %#v, want saved mcp config", got.MCPConfig) + } + if got.BoxID != "openclaw-box-old" { + t.Fatalf("Agent().BoxID = %q, want old box id after failed recreate", got.BoxID) + } +} + +func TestUpdatePicoClawMCPConfigIsAccepted(t *testing.T) { + svc, err := NewService( + testModelConfig(), + config.ServerConfig{}, + "manager-image:test", + "", + WithRuntime(fakeAgentRuntime{kind: RuntimeKindPicoClawSandbox}), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + svc.agents["u-dev"] = Agent{ + ID: "u-dev", + Name: "dev", + RuntimeID: "rt-u-dev", + RuntimeKind: RuntimeKindPicoClawSandbox, + Image: "picoclaw-image:test", + Role: RoleWorker, + Status: "profile_incomplete", + AgentProfile: AgentProfile{Name: "dev"}, + ProfileComplete: false, + CreatedAt: time.Date(2026, 5, 18, 9, 0, 0, 0, time.UTC), + } + + nextRuntimeOptions := map[string]any{ + "mcp": map[string]any{"mcpServers": map[string]any{}}, + } + updated, err := svc.Update(context.Background(), "u-dev", UpdateRequest{RuntimeOptions: &nextRuntimeOptions}) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + if _, ok := updated.MCPConfig["mcpServers"].(map[string]any); !ok { + t.Fatalf("Update().MCPConfig = %#v, want managed empty config", updated.MCPConfig) + } +} + +func TestUpdateRequestLegacyMCPRuntimeOptionsNullMarksMCPConfigClear(t *testing.T) { + var req UpdateRequest + if err := json.Unmarshal([]byte(`{"runtime_options":{"local_workspace_dir":"/tmp/project","mcp":null}}`), &req); err != nil { + t.Fatalf("json.Unmarshal(UpdateRequest) error = %v", err) + } + if !req.MCPConfigSet { + t.Fatal("UpdateRequest.MCPConfigSet = false, want true for legacy runtime_options.mcp null") + } + if req.MCPConfig != nil { + t.Fatalf("UpdateRequest.MCPConfig = %#v, want nil clear", *req.MCPConfig) + } + if req.RuntimeOptions == nil { + t.Fatal("UpdateRequest.RuntimeOptions = nil, want legacy mcp stripped options") + } + if _, ok := (*req.RuntimeOptions)[agentruntime.RuntimeOptionMCPKey]; ok { + t.Fatalf("UpdateRequest.RuntimeOptions still contains legacy mcp: %#v", *req.RuntimeOptions) + } +} + +func TestUpdateLegacyMCPRuntimeOptionsNullClearsMCPConfig(t *testing.T) { + svc, err := NewService( + testModelConfig(), + config.ServerConfig{}, + "manager-image:test", + "", + WithRuntime(fakeAgentRuntime{kind: RuntimeKindPicoClawSandbox}), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + svc.agents["u-dev"] = Agent{ + ID: "u-dev", + Name: "dev", + RuntimeID: "rt-u-dev", + RuntimeKind: RuntimeKindPicoClawSandbox, + Image: "picoclaw-image:test", + Role: RoleWorker, + Status: "profile_incomplete", + RuntimeOptions: map[string]any{ + "local_workspace_dir": "/tmp/project", + }, + MCPConfig: map[string]any{ + "mcpServers": map[string]any{ + "context7": map[string]any{"command": "uvx"}, + }, + }, + AgentProfile: AgentProfile{Name: "dev"}, + ProfileComplete: false, + CreatedAt: time.Date(2026, 5, 18, 9, 0, 0, 0, time.UTC), + } + + nextRuntimeOptions := map[string]any{ + "local_workspace_dir": "/tmp/project", + "mcp": nil, + } + updated, err := svc.Update(context.Background(), "u-dev", UpdateRequest{RuntimeOptions: &nextRuntimeOptions}) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + if updated.MCPConfig != nil { + t.Fatalf("Update().MCPConfig = %#v, want nil after legacy mcp null", updated.MCPConfig) + } + if _, ok := updated.RuntimeOptions[agentruntime.RuntimeOptionMCPKey]; ok { + t.Fatalf("Update().RuntimeOptions still contains legacy mcp: %#v", updated.RuntimeOptions) + } +} + +func TestUpdateLegacyMCPRuntimeOptionsRejectsInvalidRoot(t *testing.T) { + svc, err := NewService( + testModelConfig(), + config.ServerConfig{}, + "manager-image:test", + "", + WithRuntime(fakeAgentRuntime{kind: RuntimeKindPicoClawSandbox}), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + svc.agents["u-dev"] = Agent{ + ID: "u-dev", + Name: "dev", + RuntimeID: "rt-u-dev", + RuntimeKind: RuntimeKindPicoClawSandbox, + Image: "picoclaw-image:test", + Role: RoleWorker, + Status: "profile_incomplete", + AgentProfile: AgentProfile{Name: "dev"}, + ProfileComplete: false, + CreatedAt: time.Date(2026, 5, 18, 9, 0, 0, 0, time.UTC), + } + + runtimeOptions := map[string]any{"mcp": "invalid"} + _, err = svc.Update(context.Background(), "u-dev", UpdateRequest{RuntimeOptions: &runtimeOptions}) + if err == nil || !strings.Contains(err.Error(), "runtime_options.mcp must be an object or null") { + t.Fatalf("Update() error = %v, want legacy mcp object error", err) + } +} + +func TestCreatePicoClawMCPConfigIsAccepted(t *testing.T) { + svc, err := NewService( + testModelConfig(), + config.ServerConfig{}, + "manager-image:test", + "", + WithRuntime(fakeAgentRuntime{kind: RuntimeKindPicoClawSandbox}), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + created, err := svc.Create(context.Background(), CreateRequest{Spec: CreateAgentSpec{ + Name: "dev", + Role: RoleWorker, + RuntimeKind: RuntimeKindPicoClawSandbox, + Image: "picoclaw-image:test", + RuntimeOptions: map[string]any{ + "mcp": map[string]any{"mcpServers": map[string]any{}}, + }, + }}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if _, ok := created.MCPConfig["mcpServers"].(map[string]any); !ok { + t.Fatalf("Create().MCPConfig = %#v, want managed empty config", created.MCPConfig) + } +} + +func TestReplacePreservesInheritedMCPBeforeDeletingExistingAgent(t *testing.T) { + svc, err := NewService( + testModelConfig(), + config.ServerConfig{}, + "manager-image:test", + "", + WithRuntime(fakeAgentRuntime{kind: RuntimeKindOpenClawSandbox}), + WithRuntime(fakeAgentRuntime{kind: RuntimeKindPicoClawSandbox}), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + svc.agents["u-dev"] = Agent{ + ID: "u-dev", + Name: "dev", + RuntimeID: "rt-u-dev", + RuntimeKind: RuntimeKindOpenClawSandbox, + Image: "openclaw-image:test", + BoxID: "openclaw-box-old", + Role: RoleWorker, + Status: string(agentruntime.StateStopped), + MCPConfig: map[string]any{ + "mcpServers": map[string]any{"context7": map[string]any{"command": "uvx"}}, + }, + AgentProfile: AgentProfile{ + Name: "dev", + Provider: ProviderAPI, + BaseURL: "https://api.example/v1", + APIKey: "api-key", + ModelID: "qwen3.7-max", + ProfileComplete: true, + }, + ProfileComplete: true, + CreatedAt: time.Date(2026, 5, 18, 9, 0, 0, 0, time.UTC), + } + + replaced, err := svc.Create(context.Background(), CreateRequest{ + Replace: true, + FieldMask: []string{"runtime_kind", "image"}, + Spec: CreateAgentSpec{ + ID: "u-dev", + RuntimeKind: RuntimeKindPicoClawSandbox, + Image: "picoclaw-image:test", + }, + }) + if err != nil { + t.Fatalf("Create(replace) error = %v", err) + } + if replaced.RuntimeKind != RuntimeKindPicoClawSandbox { + t.Fatalf("Create(replace).RuntimeKind = %q, want %q", replaced.RuntimeKind, RuntimeKindPicoClawSandbox) + } + servers, _ := replaced.MCPConfig["mcpServers"].(map[string]any) + if _, ok := servers["context7"]; !ok { + t.Fatalf("Create(replace).MCPConfig = %#v, want inherited context7", replaced.MCPConfig) + } +} + +func TestReplaceRejectsInvalidOpenClawMCPBeforeDeletingExistingAgent(t *testing.T) { + svc, err := NewService( + testModelConfig(), + config.ServerConfig{}, + "manager-image:test", + "", + WithRuntime(openclawsandbox.New(openclawsandbox.Dependencies{})), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + svc.agents["u-dev"] = Agent{ + ID: "u-dev", + Name: "dev", + RuntimeID: "rt-u-dev", + RuntimeKind: RuntimeKindOpenClawSandbox, + Image: "openclaw-image:test", + Role: RoleWorker, + Status: string(agentruntime.StateStopped), + AgentProfile: AgentProfile{ + Name: "dev", + Provider: ProviderAPI, + BaseURL: "https://api.example/v1", + APIKey: "api-key", + ModelID: "qwen3.7-max", + ProfileComplete: true, + }, + ProfileComplete: true, + CreatedAt: time.Date(2026, 5, 18, 9, 0, 0, 0, time.UTC), + } + + _, err = svc.Create(context.Background(), CreateRequest{ + Replace: true, + FieldMask: []string{"mcp_config"}, + Spec: CreateAgentSpec{ + ID: "u-dev", + MCPConfig: map[string]any{ + "servers": map[string]any{ + "context7": map[string]any{"command": "uvx"}, + }, + }, + }, + }) + if err == nil || !strings.Contains(err.Error(), `mcp_config contains unsupported field`) { + t.Fatalf("Create(replace) error = %v, want unsupported mcp field", err) + } + svc.mu.RLock() + _, ok := svc.agents["u-dev"] + svc.mu.RUnlock() + if !ok { + t.Fatal("Agent(u-dev) ok = false, want existing agent preserved after failed replace") + } +} + func TestUpdateFieldMaskClearsRuntimeOptions(t *testing.T) { svc, err := NewService( testModelConfig(), diff --git a/internal/agent/store.go b/internal/agent/store.go index 0e4de23c..dbcc5f6a 100644 --- a/internal/agent/store.go +++ b/internal/agent/store.go @@ -77,6 +77,7 @@ type persistedAgent struct { BoxID string `json:"box_id,omitempty"` Runtime *RuntimeRecord `json:"runtime,omitempty"` RuntimeOptions map[string]any `json:"-"` + MCPConfig map[string]any `json:"-"` Role string `json:"role"` Status string `json:"status,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -132,6 +133,9 @@ func (a persistedAgent) MarshalJSON() ([]byte, error) { if !profileEmpty(profile) { out["model_config"] = profile } + if len(a.MCPConfig) > 0 { + out["mcp_config"] = a.MCPConfig + } if len(a.DetectionResults) > 0 { out["detection_results"] = a.DetectionResults } @@ -145,6 +149,7 @@ func (a *persistedAgent) UnmarshalJSON(data []byte) error { ModelConfig json.RawMessage `json:"model_config"` Profile json.RawMessage `json:"profile"` RuntimeOptions map[string]any `json:"runtime_options"` + MCPConfig map[string]any `json:"mcp_config"` } var decoded persistedAgentJSON if err := json.Unmarshal(data, &decoded); err != nil { @@ -152,6 +157,7 @@ func (a *persistedAgent) UnmarshalJSON(data []byte) error { } *a = persistedAgent(decoded.persistedAgentAlias) a.RuntimeOptions = utils.CloneAnyMap(decoded.RuntimeOptions) + a.MCPConfig = utils.CloneAnyMap(decoded.MCPConfig) profilePayload := decoded.ModelConfig if len(profilePayload) == 0 || string(profilePayload) == "null" { profilePayload = decoded.Profile @@ -172,6 +178,7 @@ func (a *persistedAgent) UnmarshalJSON(data []byte) error { if a.Runtime != nil && len(a.Runtime.Options) > 0 && len(a.RuntimeOptions) == 0 { a.RuntimeOptions = utils.CloneAnyMap(a.Runtime.Options) } + a.RuntimeOptions, a.MCPConfig = splitLegacyRuntimeOptionsMCP(a.RuntimeOptions, a.MCPConfig) return nil } @@ -198,6 +205,7 @@ func newPersistedAgent(a Agent) persistedAgent { if len(a.RuntimeOptions) > 0 { topRX = utils.CloneAnyMap(a.RuntimeOptions) } + topRX, mcpConfig := splitLegacyRuntimeOptionsMCP(topRX, a.MCPConfig) ap.BaseURL, ap.ModelID = pol.StripProfileLLMFields(a.RuntimeKind, ap.BaseURL, ap.ModelID) ap = compactPersistedProfile(ap) updatedAt := a.UpdatedAt.UTC() @@ -212,6 +220,7 @@ func newPersistedAgent(a Agent) persistedAgent { Image: a.Image, Runtime: compactPersistedRuntime(runtimeRecordForAgent(a), topRX), RuntimeOptions: topRX, + MCPConfig: mcpConfig, Role: a.Role, CreatedAt: a.CreatedAt, UpdatedAt: updatedAt, @@ -226,6 +235,7 @@ func (a persistedAgent) toAgent() Agent { ap = cloneProfile(a.AgentProfile) } rx := utils.CloneAnyMap(a.RuntimeOptions) + mcpConfig := utils.CloneAnyMap(a.MCPConfig) if strings.TrimSpace(ap.Name) == "" { ap.Name = a.Name } @@ -269,6 +279,7 @@ func (a persistedAgent) toAgent() Agent { rx = utils.CloneAnyMap(rt.Options) } } + rx, mcpConfig = splitLegacyRuntimeOptionsMCP(rx, mcpConfig) runtimeCfg := agentruntime.RuntimeConfigForKind(runtimeKind) ag := Agent{ ID: a.ID, @@ -283,6 +294,7 @@ func (a persistedAgent) toAgent() Agent { Avatar: a.Avatar, BoxID: boxID, RuntimeOptions: rx, + MCPConfig: mcpConfig, Role: a.Role, Status: status, CreatedAt: a.CreatedAt, diff --git a/internal/api/handler.go b/internal/api/handler.go index 0ad97bde..344c2afe 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -151,6 +151,7 @@ type agentResponse struct { Profile string `json:"-"` ProfileConfig apitypes.AgentProfile `json:"model_config,omitempty"` RuntimeOptions map[string]any `json:"-"` + MCPConfig map[string]any `json:"mcp_config,omitempty"` RuntimeOptionSchemas []agentruntime.RuntimeOptionSchema `json:"-"` AgentProfile agent.AgentProfileView `json:"-"` ProfileComplete bool `json:"-"` @@ -185,6 +186,7 @@ func (r *agentResponse) UnmarshalJSON(data []byte) error { Profile: apiAgent.Profile, ProfileConfig: apiAgent.ProfileConfig, RuntimeOptions: apiAgent.Runtime.Options, + MCPConfig: utils.CloneAnyMap(apiAgent.MCPConfig), AgentProfile: agentProfileViewFromAPI(apiAgent.ProfileConfig), UserID: apiAgent.UserID, UserName: apiAgent.UserName, @@ -210,6 +212,7 @@ func (r *agentResponse) UnmarshalJSON(data []byte) error { BoxID string `json:"box_id"` Status string `json:"status"` RuntimeOptions map[string]any `json:"runtime_options"` + MCPConfig map[string]any `json:"mcp_config"` RuntimeOptionSchemas []agentruntime.RuntimeOptionSchema `json:"runtime_option_schemas"` AgentProfile agent.AgentProfileView `json:"agent_profile"` ProfileComplete bool `json:"profile_complete"` @@ -242,6 +245,9 @@ func (r *agentResponse) UnmarshalJSON(data []byte) error { if len(legacy.RuntimeOptions) > 0 { r.RuntimeOptions = legacy.RuntimeOptions } + if len(legacy.MCPConfig) > 0 { + r.MCPConfig = legacy.MCPConfig + } if len(legacy.RuntimeOptionSchemas) > 0 { r.RuntimeOptionSchemas = legacy.RuntimeOptionSchemas } @@ -1263,6 +1269,7 @@ func agentCreateRequestFromAPI(req apitypes.CreateAgentRequest) agent.CreateRequ UpdatedAt: req.CreatedAt, Profile: req.Profile, RuntimeOptions: runtimeOptions, + MCPConfig: utils.CloneAnyMapShallowNestedStringMaps(req.MCPConfig), AgentProfile: prof, }, Replace: req.Replace, @@ -2712,6 +2719,7 @@ func presentAgent(item agent.Agent) agentResponse { UpdatedAt: item.UpdatedAt, Profile: item.Profile, RuntimeOptions: runtimeOptions, + MCPConfig: utils.CloneAnyMap(item.MCPConfig), ProfileConfig: profile, AgentProfile: av, ProfileComplete: item.ProfileComplete, diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index aeee409a..e768fb74 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -170,6 +170,45 @@ func (f fakeConversationRuntime) NewConversation(ctx context.Context, handle age }, nil } +type apiFakeCodexBinaryProvider struct{} + +func (apiFakeCodexBinaryProvider) Ensure(context.Context) (string, error) { + return "/tmp/codex", nil +} + +type apiFakeCodexManager struct{} + +func (apiFakeCodexManager) Start(_ context.Context, spec codexruntime.SessionSpec) (*codexruntime.Session, error) { + now := time.Now().UTC() + return &codexruntime.Session{ + RuntimeID: spec.RuntimeID, + AgentID: spec.AgentID, + AgentName: spec.AgentName, + SessionID: "session-" + spec.AgentID, + BinaryPath: spec.BinaryPath, + RuntimeDir: spec.RuntimeDir, + WorkspaceDir: spec.WorkspaceDir, + HomeDir: spec.HomeDir, + CodexHomeDir: spec.CodexHomeDir, + StderrPath: spec.StderrPath, + ProcessID: os.Getpid(), + CreatedAt: now, + StartedAt: now, + }, nil +} + +func (apiFakeCodexManager) Stop(context.Context, codexruntime.SessionHandle) error { + return nil +} + +func (apiFakeCodexManager) Session(codexruntime.SessionHandle) (*codexruntime.Session, error) { + return nil, os.ErrNotExist +} + +func (apiFakeCodexManager) Prompt(context.Context, codexruntime.SessionHandle, codexruntime.PromptRequest) (codexruntime.PromptResponse, error) { + return codexruntime.PromptResponse{}, os.ErrNotExist +} + type fakeCodexBridgeController struct { ensureCalls []agent.Agent stopCalls []string @@ -2004,6 +2043,420 @@ func TestHandleAgentsCreateCodexWorkerEnsuresCodexBridge(t *testing.T) { } } +func TestHandleAgentsMCPConfigClosedLoopForSupportedRuntimes(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) + + codexRoot := t.TempDir() + codexHomes := map[string]string{} + statePath := filepath.Join(t.TempDir(), "agents.json") + codexRT := codexruntime.New(codexruntime.Dependencies{ + BinaryProvider: apiFakeCodexBinaryProvider{}, + AgentHome: func(agentID string) (string, error) { + home := filepath.Join(codexRoot, agentID) + codexHomes[agentID] = home + return home, nil + }, + ResolveAgent: func(h agentruntime.Handle) (codexruntime.AgentRef, error) { + return codexAgentRefFromStateFile(statePath, h.RuntimeID, agentruntime.Profile{ + Provider: agent.ProviderAPI, + BaseURL: "https://llm.example/v1", + APIKey: "sk-test", + ModelID: "model-1", + }) + }, + Manager: apiFakeCodexManager{}, + }) + + svc, err := agent.NewService( + config.ModelConfig{ + Provider: config.ProviderLLMAPI, + BaseURL: "https://llm.example/v1", + APIKey: "sk-default", + ModelID: "model-default", + }, + config.ServerConfig{ + ListenAddr: "127.0.0.1:18080", + AdvertiseBaseURL: "http://127.0.0.1:18080", + AccessToken: "shared-token", + }, + "manager-image:test", + statePath, + agent.WithRuntime(codexRT), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + srv := &Handler{svc: svc, im: im.NewService()} + + mcpConfig := map[string]any{ + "mcpServers": map[string]any{ + "context7": map[string]any{ + "command": "uvx", + "args": []any{"context7-mcp"}, + "env": map[string]any{ + "CONTEXT7_API_KEY": "secret", + }, + }, + "remote": map[string]any{ + "url": "https://mcp.example.com/mcp", + "bearer_token_env_var": "MCP_TOKEN", + }, + }, + } + + tests := []struct { + name string + id string + agentName string + runtimeKind string + image string + assertFile func(t *testing.T, created agentResponse) + }{ + { + name: "openclaw", + id: "u-openclawmcp", + agentName: "openclawmcp", + runtimeKind: agent.RuntimeKindOpenClawSandbox, + image: "openclaw-image:test", + assertFile: func(t *testing.T, created agentResponse) { + t.Helper() + home := testAgentHomeFromStatePath(statePath, created.ID) + cfg := readJSONMap(t, filepath.Join(openclawsandbox.Root(home), openclawsandbox.HostConfig)) + mcpRoot, ok := cfg["mcp"].(map[string]any) + if !ok { + t.Fatalf("openclaw config missing mcp object: %+v", cfg["mcp"]) + } + assertMCPServers(t, mcpRoot["servers"]) + }, + }, + { + name: "picoclaw", + id: "u-picoclawmcp", + agentName: "picoclawmcp", + runtimeKind: agent.RuntimeKindPicoClawSandbox, + image: "picoclaw-image:test", + assertFile: func(t *testing.T, created agentResponse) { + t.Helper() + home := testAgentHomeFromStatePath(statePath, created.ID) + cfg := readJSONMap(t, filepath.Join(picoclawsandbox.Root(home), picoclawsandbox.HostConfig)) + tools, ok := cfg["tools"].(map[string]any) + if !ok { + t.Fatalf("picoclaw config missing tools object: %+v", cfg["tools"]) + } + mcpRoot, ok := tools["mcp"].(map[string]any) + if !ok { + t.Fatalf("picoclaw config missing tools.mcp object: %+v", tools["mcp"]) + } + if enabled, _ := mcpRoot["enabled"].(bool); !enabled { + t.Fatalf("picoclaw tools.mcp.enabled = %v, want true", mcpRoot["enabled"]) + } + assertMCPServers(t, mcpRoot["servers"]) + }, + }, + { + name: "codex", + id: "u-codexmcp", + agentName: "codexmcp", + runtimeKind: agent.RuntimeKindCodex, + assertFile: func(t *testing.T, created agentResponse) { + t.Helper() + home := codexHomes[agent.CanonicalID(created.ID)] + if strings.TrimSpace(home) == "" { + t.Fatalf("codex agent home for %s was not resolved", created.ID) + } + configPath := filepath.Join(home, ".codex", "home", "config.toml") + raw, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read codex config %s: %v", configPath, err) + } + configText := string(raw) + for _, want := range []string{ + `# BEGIN csgclaw-managed mcp`, + `[mcp_servers."context7"]`, + `command = "uvx"`, + `args = ["context7-mcp"]`, + `env = { "CONTEXT7_API_KEY" = "secret" }`, + `[mcp_servers."remote"]`, + `url = "https://mcp.example.com/mcp"`, + `bearer_token_env_var = "MCP_TOKEN"`, + `# END csgclaw-managed mcp`, + } { + if !strings.Contains(configText, want) { + t.Fatalf("codex config missing %q:\n%s", want, configText) + } + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + createBody := map[string]any{ + "id": tt.id, + "name": tt.agentName, + "role": agent.RoleWorker, + "runtime_kind": tt.runtimeKind, + "agent_profile": map[string]any{ + "model_provider_id": "e2e-api", + "base_url": "https://llm.example/v1", + "api_key": "sk-test", + "model_id": "model-1", + }, + } + if tt.image != "" { + createBody["image"] = tt.image + } + created := doAgentJSON(t, srv, http.MethodPost, "/api/v1/agents", createBody, http.StatusCreated) + wantRuntime := agentruntime.RuntimeConfigForKind(tt.runtimeKind) + if created.RuntimeName != wantRuntime.Name || created.SandboxEnabled != wantRuntime.Sandboxed { + t.Fatalf("created runtime = %q/%t, want %q/%t", created.RuntimeName, created.SandboxEnabled, wantRuntime.Name, wantRuntime.Sandboxed) + } + + updated := doAgentJSON(t, srv, http.MethodPatch, "/api/v1/agents/"+created.ID, map[string]any{ + "field_mask": []string{"mcp_config"}, + "mcp_config": mcpConfig, + }, http.StatusOK) + assertMCPConfigResponse(t, updated.MCPConfig) + if _, ok := updated.RuntimeOptions[agentruntime.RuntimeOptionMCPKey]; ok { + t.Fatalf("runtime_options contains legacy %q after mcp_config patch: %+v", agentruntime.RuntimeOptionMCPKey, updated.RuntimeOptions) + } + tt.assertFile(t, updated) + }) + } +} + +func doAgentJSON(t *testing.T, srv *Handler, method, path string, body any, wantStatus int) agentResponse { + t.Helper() + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request body: %v", err) + } + req := httptest.NewRequest(method, path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != wantStatus { + t.Fatalf("%s %s status = %d, want %d; body=%s", method, path, rec.Code, wantStatus, rec.Body.String()) + } + var got agentResponse + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v; body=%s", err, rec.Body.String()) + } + return got +} + +func testAgentHomeFromStatePath(statePath, agentID string) string { + return filepath.Join(filepath.Dir(statePath), "agents", agent.CanonicalID(agentID)) +} + +func codexAgentRefFromStateFile(path, runtimeID string, fallback agentruntime.Profile) (codexruntime.AgentRef, error) { + raw, err := os.ReadFile(path) + if err != nil { + return codexruntime.AgentRef{}, err + } + var state struct { + Agents []struct { + ID string `json:"id"` + Name string `json:"name"` + Instructions string `json:"instructions,omitempty"` + RuntimeID string `json:"runtime_id,omitempty"` + RuntimeKind string `json:"runtime_kind,omitempty"` + BoxID string `json:"box_id,omitempty"` + Runtime *agent.RuntimeRecord `json:"runtime,omitempty"` + RuntimeOptions map[string]any `json:"runtime_options,omitempty"` + MCPConfig map[string]any `json:"mcp_config,omitempty"` + ModelConfig agent.AgentProfile `json:"model_config,omitempty"` + AgentProfile agent.AgentProfile `json:"agent_profile,omitempty"` + } `json:"agents"` + Items []struct { + ID string `json:"id"` + Name string `json:"name"` + Instructions string `json:"instructions,omitempty"` + RuntimeID string `json:"runtime_id,omitempty"` + RuntimeKind string `json:"runtime_kind,omitempty"` + BoxID string `json:"box_id,omitempty"` + Runtime *agent.RuntimeRecord `json:"runtime,omitempty"` + RuntimeOptions map[string]any `json:"runtime_options,omitempty"` + MCPConfig map[string]any `json:"mcp_config,omitempty"` + ModelConfig agent.AgentProfile `json:"model_config,omitempty"` + AgentProfile agent.AgentProfile `json:"agent_profile,omitempty"` + } `json:"items"` + } + if err := json.Unmarshal(raw, &state); err != nil { + return codexruntime.AgentRef{}, err + } + type stateAgent struct { + ID string + Name string + Instructions string + RuntimeID string + RuntimeKind string + BoxID string + Runtime *agent.RuntimeRecord + RuntimeOptions map[string]any + MCPConfig map[string]any + ModelConfig agent.AgentProfile + AgentProfile agent.AgentProfile + } + items := make([]stateAgent, 0, len(state.Agents)+len(state.Items)) + for _, item := range state.Agents { + items = append(items, stateAgent{ + ID: item.ID, + Name: item.Name, + Instructions: item.Instructions, + RuntimeID: item.RuntimeID, + RuntimeKind: item.RuntimeKind, + BoxID: item.BoxID, + Runtime: item.Runtime, + RuntimeOptions: item.RuntimeOptions, + MCPConfig: item.MCPConfig, + ModelConfig: item.ModelConfig, + AgentProfile: item.AgentProfile, + }) + } + for _, item := range state.Items { + items = append(items, stateAgent{ + ID: item.ID, + Name: item.Name, + Instructions: item.Instructions, + RuntimeID: item.RuntimeID, + RuntimeKind: item.RuntimeKind, + BoxID: item.BoxID, + Runtime: item.Runtime, + RuntimeOptions: item.RuntimeOptions, + MCPConfig: item.MCPConfig, + ModelConfig: item.ModelConfig, + AgentProfile: item.AgentProfile, + }) + } + for _, item := range items { + rtID := strings.TrimSpace(item.RuntimeID) + options := item.RuntimeOptions + boxID := strings.TrimSpace(item.BoxID) + if item.Runtime != nil { + if strings.TrimSpace(item.Runtime.ID) != "" { + rtID = strings.TrimSpace(item.Runtime.ID) + } + if len(options) == 0 && len(item.Runtime.Options) > 0 { + options = item.Runtime.Options + } + if boxID == "" { + boxID = strings.TrimSpace(item.Runtime.SandboxID) + } + } + if rtID == "" && strings.TrimSpace(item.ID) != "" { + rtID = "rt-" + strings.TrimSpace(item.ID) + } + if rtID != strings.TrimSpace(runtimeID) { + continue + } + profile := fallback.Normalized() + rawProfile := item.ModelConfig + if profileEmptyForTest(rawProfile) { + rawProfile = item.AgentProfile + } + if providerID := strings.TrimSpace(rawProfile.ModelProviderID); providerID != "" { + profile.Provider = agent.ProfileProviderForModelProviderID(providerID) + } + if provider := strings.TrimSpace(rawProfile.Provider); provider != "" { + profile.Provider = provider + } + if baseURL := strings.TrimRight(strings.TrimSpace(rawProfile.BaseURL), "/"); baseURL != "" { + profile.BaseURL = baseURL + } + if apiKey := strings.TrimSpace(rawProfile.APIKey); apiKey != "" { + profile.APIKey = apiKey + } + if modelID := strings.TrimSpace(rawProfile.ModelID); modelID != "" { + profile.ModelID = modelID + } + if reasoning := strings.TrimSpace(rawProfile.ReasoningEffort); reasoning != "" { + profile.ReasoningEffort = reasoning + } + if len(rawProfile.Env) > 0 { + profile.Env = rawProfile.Env + } + return codexruntime.AgentRef{ + ID: item.ID, + Name: item.Name, + RuntimeID: rtID, + HandleID: boxID, + Instructions: item.Instructions, + RuntimeOptions: options, + MCPConfig: item.MCPConfig, + Profile: profile, + }, nil + } + return codexruntime.AgentRef{}, os.ErrNotExist +} + +func profileEmptyForTest(profile agent.AgentProfile) bool { + return strings.TrimSpace(profile.Provider) == "" && + strings.TrimSpace(profile.ModelProviderID) == "" && + strings.TrimSpace(profile.BaseURL) == "" && + strings.TrimSpace(profile.APIKey) == "" && + strings.TrimSpace(profile.ModelID) == "" && + strings.TrimSpace(profile.ReasoningEffort) == "" && + len(profile.Env) == 0 +} + +func readJSONMap(t *testing.T, path string) map[string]any { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("decode %s: %v\n%s", path, err, string(raw)) + } + return got +} + +func assertMCPConfigResponse(t *testing.T, cfg map[string]any) { + t.Helper() + if len(cfg) == 0 { + t.Fatal("response missing mcp_config") + } + assertMCPServers(t, cfg[agentruntime.MCPConfigServersKey]) +} + +func assertMCPServers(t *testing.T, raw any) { + t.Helper() + servers, ok := raw.(map[string]any) + if !ok { + t.Fatalf("MCP servers = %#v, want object", raw) + } + context7, ok := servers["context7"].(map[string]any) + if !ok { + t.Fatalf("MCP servers missing context7: %+v", servers) + } + if command, _ := context7["command"].(string); command != "uvx" { + t.Fatalf("context7.command = %q, want uvx", command) + } + args, ok := context7["args"].([]any) + if !ok || len(args) != 1 || args[0] != "context7-mcp" { + t.Fatalf("context7.args = %#v, want [context7-mcp]", context7["args"]) + } + env, ok := context7["env"].(map[string]any) + if !ok || env["CONTEXT7_API_KEY"] != "secret" { + t.Fatalf("context7.env = %#v, want CONTEXT7_API_KEY", context7["env"]) + } + remote, ok := servers["remote"].(map[string]any) + if !ok { + t.Fatalf("MCP servers missing remote: %+v", servers) + } + if url, _ := remote["url"].(string); url != "https://mcp.example.com/mcp" { + t.Fatalf("remote.url = %q, want https://mcp.example.com/mcp", url) + } + if envVar, _ := remote["bearer_token_env_var"].(string); envVar != "MCP_TOKEN" { + t.Fatalf("remote.bearer_token_env_var = %q, want MCP_TOKEN", envVar) + } +} + func TestHandleAgentsCreateManagerUsesBootstrapManager(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) diff --git a/internal/apitypes/agent.go b/internal/apitypes/agent.go index 4984a81c..81019d87 100644 --- a/internal/apitypes/agent.go +++ b/internal/apitypes/agent.go @@ -72,29 +72,30 @@ type ProfileDetectionResult struct { } type Agent struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Instructions string `json:"instructions,omitempty"` - Runtime AgentRuntime `json:"runtime,omitempty"` - RuntimeID string `json:"-"` - RuntimeKind string `json:"-"` - RuntimeName string `json:"runtime_name,omitempty"` - SandboxEnabled bool `json:"sandbox_enabled,omitempty"` - Image string `json:"image,omitempty"` - Avatar string `json:"-"` - BoxID string `json:"-"` - Role string `json:"role"` - Status string `json:"-"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at,omitempty"` - Profile string `json:"-"` - ProfileConfig AgentProfile `json:"model_config,omitempty"` - UserID string `json:"user_id,omitempty"` - UserName string `json:"user_name,omitempty"` - ParticipantIDs []string `json:"participant_ids,omitempty"` - ParticipantNames []string `json:"participant_names,omitempty"` - Participants []Participant `json:"participants,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Instructions string `json:"instructions,omitempty"` + Runtime AgentRuntime `json:"runtime,omitempty"` + RuntimeID string `json:"-"` + RuntimeKind string `json:"-"` + RuntimeName string `json:"runtime_name,omitempty"` + SandboxEnabled bool `json:"sandbox_enabled,omitempty"` + MCPConfig map[string]any `json:"mcp_config,omitempty"` + Image string `json:"image,omitempty"` + Avatar string `json:"-"` + BoxID string `json:"-"` + Role string `json:"role"` + Status string `json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + Profile string `json:"-"` + ProfileConfig AgentProfile `json:"model_config,omitempty"` + UserID string `json:"user_id,omitempty"` + UserName string `json:"user_name,omitempty"` + ParticipantIDs []string `json:"participant_ids,omitempty"` + ParticipantNames []string `json:"participant_names,omitempty"` + Participants []Participant `json:"participants,omitempty"` } func (a *Agent) UnmarshalJSON(data []byte) error { @@ -139,6 +140,7 @@ func (a *Agent) UnmarshalJSON(data []byte) error { Status string `json:"status"` BoxID string `json:"box_id"` RuntimeOptions map[string]any `json:"runtime_options"` + MCPConfig map[string]any `json:"mcp_config"` } if err := json.Unmarshal(data, &legacy); err != nil { return err @@ -173,6 +175,9 @@ func (a *Agent) UnmarshalJSON(data []byte) error { if len(a.Runtime.Options) == 0 && len(legacy.RuntimeOptions) > 0 { a.Runtime.Options = legacy.RuntimeOptions } + if len(a.MCPConfig) == 0 && len(legacy.MCPConfig) > 0 { + a.MCPConfig = legacy.MCPConfig + } if strings.TrimSpace(a.RuntimeName) == "" { a.RuntimeName = strings.TrimSpace(a.Runtime.Name) } @@ -229,6 +234,7 @@ type CreateAgentRequest struct { CreatedAt time.Time `json:"created_at,omitempty"` Runtime AgentRuntime `json:"runtime,omitempty"` RuntimeOptions map[string]any `json:"runtime_options,omitempty"` + MCPConfig map[string]any `json:"mcp_config,omitempty"` Profile string `json:"-"` ProfileConfig *CreateAgentProfile `json:"model_config,omitempty"` AgentProfile *CreateAgentProfile `json:"agent_profile,omitempty"` @@ -251,6 +257,7 @@ func (r CreateAgentRequest) MarshalJSON() ([]byte, error) { CreatedAt time.Time `json:"created_at,omitempty"` Runtime AgentRuntime `json:"runtime,omitempty"` RuntimeOptions map[string]any `json:"runtime_options,omitempty"` + MCPConfig map[string]any `json:"mcp_config,omitempty"` ModelConfig *CreateAgentProfile `json:"model_config,omitempty"` Profile string `json:"profile,omitempty"` AgentProfile *CreateAgentProfile `json:"agent_profile,omitempty"` @@ -288,6 +295,7 @@ func (r CreateAgentRequest) MarshalJSON() ([]byte, error) { CreatedAt: r.CreatedAt, Runtime: runtime, RuntimeOptions: r.RuntimeOptions, + MCPConfig: r.MCPConfig, ModelConfig: r.ProfileConfig, Profile: profile, AgentProfile: r.AgentProfile, @@ -303,6 +311,7 @@ func (r *CreateAgentRequest) UnmarshalJSON(data []byte) error { RuntimeName string `json:"runtime_name,omitempty"` SandboxEnabled *bool `json:"sandbox_enabled,omitempty"` RuntimeOptions map[string]any `json:"runtime_options,omitempty"` + MCPConfig map[string]any `json:"mcp_config,omitempty"` ModelConfig *CreateAgentProfile `json:"model_config,omitempty"` AgentProfile *CreateAgentProfile `json:"agent_profile,omitempty"` Runtime struct { @@ -321,6 +330,7 @@ func (r *CreateAgentRequest) UnmarshalJSON(data []byte) error { r.RuntimeKind = strings.TrimSpace(decoded.RuntimeKind) r.RuntimeName = strings.TrimSpace(decoded.RuntimeName) r.RuntimeOptions = decoded.RuntimeOptions + r.MCPConfig = decoded.MCPConfig r.ProfileConfig = decoded.ModelConfig r.AgentProfile = decoded.AgentProfile r.Runtime.Kind = strings.TrimSpace(decoded.Runtime.Kind) diff --git a/internal/apitypes/types.go b/internal/apitypes/types.go index eae41c28..9f99a319 100644 --- a/internal/apitypes/types.go +++ b/internal/apitypes/types.go @@ -16,6 +16,7 @@ type LegacyBot struct { UserID string `json:"user_id"` Available bool `json:"available"` RuntimeOptions map[string]any `json:"runtime_options,omitempty"` + MCPConfig map[string]any `json:"mcp_config,omitempty"` RuntimeKind string `json:"runtime_kind,omitempty"` Image string `json:"image,omitempty"` Avatar string `json:"avatar,omitempty"` diff --git a/internal/app/runtimewiring/codex.go b/internal/app/runtimewiring/codex.go index 0b674629..bc84643c 100644 --- a/internal/app/runtimewiring/codex.go +++ b/internal/app/runtimewiring/codex.go @@ -37,6 +37,7 @@ func WithCodexRuntime() agent.ServiceOption { HandleID: strings.TrimSpace(got.BoxID), Instructions: got.Instructions, RuntimeOptions: got.RuntimeOptions, + MCPConfig: got.MCPConfig, Profile: profile, }, nil }, diff --git a/internal/participant/store.go b/internal/participant/store.go index 96e30e43..5be523bd 100644 --- a/internal/participant/store.go +++ b/internal/participant/store.go @@ -721,6 +721,9 @@ func isLegacyCSGClawManagerBot(b apitypes.LegacyBot, typ, channel, agentID strin func legacyBotMetadata(b apitypes.LegacyBot) map[string]any { metadata := cloneAnyMap(b.RuntimeOptions) + if len(b.MCPConfig) > 0 { + metadata["mcp_config"] = cloneAnyMap(b.MCPConfig) + } putMetadataString(metadata, "description", b.Description) putMetadataString(metadata, "legacy_bot_type", b.Type) putMetadataString(metadata, "legacy_role", b.Role) diff --git a/internal/runtime/codex/config_controller.go b/internal/runtime/codex/config_controller.go index 0e9d50e4..4213c49d 100644 --- a/internal/runtime/codex/config_controller.go +++ b/internal/runtime/codex/config_controller.go @@ -62,6 +62,26 @@ func (r *Runtime) ReconcileConfig(ctx context.Context, h agentruntime.Handle, ch return r.RefreshCodexHomeAgentsFile(ctx, h) } +func (r *Runtime) ValidateMCPConfig(_ context.Context, current agentruntime.MCPConfigSnapshot) error { + return agentruntime.ValidateMCPConfig(current.Config) +} + +func (r *Runtime) MCPConfigRestartRequired(change agentruntime.MCPConfigChange) (bool, error) { + return agentruntime.MCPConfigNeedsRestart(change.Previous.Config, change.Current.Config) +} + +func (r *Runtime) ReconcileMCPConfig(_ context.Context, h agentruntime.Handle, _ agentruntime.MCPConfigChange) error { + agentRef, err := r.resolveAgent(h) + if err != nil { + return err + } + codexHomeDir, err := r.resolveCodexHomeDir(agentRef.ID) + if err != nil { + return err + } + return r.seedCodexHomeConfig(codexHomeDir, agentRef.Profile.Normalized(), agentRef.MCPConfig) +} + type responsesProbeTargetConfig struct { provider string baseURL string diff --git a/internal/runtime/codex/runtime.go b/internal/runtime/codex/runtime.go index 74cec0bf..5619e88c 100644 --- a/internal/runtime/codex/runtime.go +++ b/internal/runtime/codex/runtime.go @@ -41,6 +41,7 @@ type AgentRef struct { HandleID string Instructions string RuntimeOptions map[string]any + MCPConfig map[string]any Profile agentruntime.Profile } @@ -136,6 +137,7 @@ var ( _ agentruntime.ConversationStarter = (*Runtime)(nil) _ agentruntime.RuntimeOptionSchemaProvider = (*Runtime)(nil) _ agentruntime.RuntimeConfigController = (*Runtime)(nil) + _ agentruntime.MCPConfigController = (*Runtime)(nil) ) func New(deps Dependencies) *Runtime { @@ -410,7 +412,7 @@ func (r *Runtime) ensureSession(ctx context.Context, spec SessionSpec) (*Session if err := r.seedCodexHomeAuth(spec.CodexHomeDir); err != nil { return nil, err } - if err := r.seedCodexHomeConfig(spec.CodexHomeDir, spec.Profile); err != nil { + if err := r.seedCodexHomeConfig(spec.CodexHomeDir, spec.Profile, agentRef.MCPConfig); err != nil { return nil, err } if err := r.seedCodexHomeSkills(spec.CodexHomeDir); err != nil { @@ -494,7 +496,7 @@ func (r *Runtime) hydratePersistedSession(ctx context.Context, manager *appServe if err := r.seedCodexHomeAuth(spec.CodexHomeDir); err != nil { return nil, err } - if err := r.seedCodexHomeConfig(spec.CodexHomeDir, spec.Profile); err != nil { + if err := r.seedCodexHomeConfig(spec.CodexHomeDir, spec.Profile, agentRef.MCPConfig); err != nil { return nil, err } if err := r.seedCodexHomeSkills(spec.CodexHomeDir); err != nil { @@ -562,11 +564,14 @@ func (r *Runtime) seedCodexHomeAuth(runtimeCodexHome string) error { return nil } -func (r *Runtime) seedCodexHomeConfig(runtimeCodexHome string, profile agentruntime.Profile) error { +func (r *Runtime) seedCodexHomeConfig(runtimeCodexHome string, profile agentruntime.Profile, mcpConfig map[string]any) error { runtimeCodexHome = strings.TrimSpace(runtimeCodexHome) if runtimeCodexHome == "" { return fmt.Errorf("codex home dir is required") } + if err := agentruntime.ValidateMCPConfig(mcpConfig); err != nil { + return err + } configPath := filepath.Join(runtimeCodexHome, configFileName) profile = profile.Normalized() configRaw, err := r.readFile(configPath) @@ -588,7 +593,7 @@ func (r *Runtime) seedCodexHomeConfig(runtimeCodexHome string, profile agentrunt } } - rendered := configureCodexHomeConfig(string(configRaw), profile) + rendered := configureCodexHomeConfig(string(configRaw), profile, mcpConfig) if err := r.writeFile(configPath, []byte(rendered), 0o600); err != nil { return fmt.Errorf("write runtime codex config %s: %w", configPath, err) } diff --git a/internal/runtime/codex/runtime_config.go b/internal/runtime/codex/runtime_config.go index 407f41de..4fe62b9d 100644 --- a/internal/runtime/codex/runtime_config.go +++ b/internal/runtime/codex/runtime_config.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strconv" "strings" @@ -22,6 +23,8 @@ const ( csgclawMemoryFeatureEndMarker = "# END csgclaw-managed memory-feature" csgclawMemoryConfigBeginMarker = "# BEGIN csgclaw-managed memory-config (do not edit; regenerated by runtime)" csgclawMemoryConfigEndMarker = "# END csgclaw-managed memory-config" + csgclawMCPBeginMarker = "# BEGIN csgclaw-managed mcp (do not edit; regenerated by runtime)" + csgclawMCPEndMarker = "# END csgclaw-managed mcp" ) var ( @@ -127,7 +130,60 @@ func buildMemoryConfigBlock(inMemoriesTable bool) string { return b.String() } -func configureCodexHomeConfig(existing string, profile agentruntime.Profile) string { +func buildMCPConfigBlock(mcpConfig map[string]any) (string, error) { + servers, err := agentruntime.MCPConfigServers(mcpConfig) + if err != nil { + return "", err + } + if servers == nil { + return "", nil + } + + names := make([]string, 0, len(servers)) + for name := range servers { + names = append(names, name) + } + sort.Strings(names) + + var b strings.Builder + b.WriteString(csgclawMCPBeginMarker) + b.WriteString("\n") + for idx, name := range names { + entry, ok := servers[name].(map[string]any) + if !ok { + return "", fmt.Errorf("mcp_config.%s.%s must be an object", agentruntime.MCPConfigServersKey, name) + } + if idx > 0 { + b.WriteString("\n") + } + fmt.Fprintf(&b, "[mcp_servers.%s]\n", tomlQuotedKey(name)) + if url := mcpTrimmedString(entry["url"]); url != "" { + fmt.Fprintf(&b, "url = %s\n", strconv.Quote(url)) + for _, key := range []string{"bearer_token_env_var", "oauth_client_id", "oauth_resource"} { + if value := mcpTrimmedString(entry[key]); value != "" { + fmt.Fprintf(&b, "%s = %s\n", key, strconv.Quote(value)) + } + } + continue + } + command := mcpTrimmedString(entry["command"]) + if command == "" { + return "", fmt.Errorf("mcp_config.%s.%s must declare command or url", agentruntime.MCPConfigServersKey, name) + } + fmt.Fprintf(&b, "command = %s\n", strconv.Quote(command)) + if args := mcpStringSlice(entry["args"]); len(args) > 0 { + fmt.Fprintf(&b, "args = %s\n", tomlStringArray(args)) + } + if env := mcpStringMap(entry["env"]); len(env) > 0 { + fmt.Fprintf(&b, "env = %s\n", tomlInlineStringMap(env)) + } + } + b.WriteString(csgclawMCPEndMarker) + b.WriteString("\n") + return b.String(), nil +} + +func configureCodexHomeConfig(existing string, profile agentruntime.Profile, mcpConfig map[string]any) string { content := sanitizeCopiedCodexConfigContent(existing) content = strings.TrimLeft(content, "\n") @@ -136,6 +192,7 @@ func configureCodexHomeConfig(existing string, profile agentruntime.Profile) str content = stripManagedBlock(content, csgclawMultiAgentBeginMarker, csgclawMultiAgentEndMarker) content = stripManagedBlock(content, csgclawMemoryFeatureBeginMarker, csgclawMemoryFeatureEndMarker) content = stripManagedBlock(content, csgclawMemoryConfigBeginMarker, csgclawMemoryConfigEndMarker) + content = stripManagedBlock(content, csgclawMCPBeginMarker, csgclawMCPEndMarker) content = stripLegacySandboxDirectives(content) content = stripUserMultiAgentDirectives(content) @@ -166,6 +223,9 @@ func configureCodexHomeConfig(existing string, profile agentruntime.Profile) str if block := buildProviderConfigBlock(profile); block != "" { content = hoistManagedBlock(content, block) } + if block, err := buildMCPConfigBlock(mcpConfig); err == nil && block != "" { + content = hoistManagedBlock(content, block) + } content = strings.TrimLeft(content, "\n") if strings.TrimSpace(content) == "" { @@ -174,6 +234,68 @@ func configureCodexHomeConfig(existing string, profile agentruntime.Profile) str return strings.TrimRight(content, "\n") + "\n" } +func mcpTrimmedString(value any) string { + text, _ := value.(string) + return strings.TrimSpace(text) +} + +func mcpStringSlice(value any) []string { + raw, ok := value.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, item := range raw { + text, ok := item.(string) + if !ok { + return nil + } + out = append(out, text) + } + return out +} + +func mcpStringMap(value any) map[string]string { + raw, ok := value.(map[string]any) + if !ok { + return nil + } + out := make(map[string]string, len(raw)) + for key, item := range raw { + text, ok := item.(string) + if !ok { + return nil + } + out[key] = text + } + return out +} + +func tomlQuotedKey(key string) string { + return strconv.Quote(strings.TrimSpace(key)) +} + +func tomlStringArray(values []string) string { + quoted := make([]string, 0, len(values)) + for _, value := range values { + quoted = append(quoted, strconv.Quote(value)) + } + return "[" + strings.Join(quoted, ", ") + "]" +} + +func tomlInlineStringMap(values map[string]string) string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + items := make([]string, 0, len(keys)) + for _, key := range keys { + items = append(items, strconv.Quote(key)+" = "+strconv.Quote(values[key])) + } + return "{ " + strings.Join(items, ", ") + " }" +} + func sanitizeCopiedCodexConfigContent(content string) string { if !strings.Contains(content, "[[skills.config]]") { return content diff --git a/internal/runtime/codex/runtime_test.go b/internal/runtime/codex/runtime_test.go index d28420e0..8bbb6b96 100644 --- a/internal/runtime/codex/runtime_test.go +++ b/internal/runtime/codex/runtime_test.go @@ -1400,8 +1400,8 @@ func TestConfigureCodexHomeConfigReplacesManagedBlocksIdempotently(t *testing.T) BaseURL: "https://runtime.example/v1", APIKey: "runtime-key", } - first := configureCodexHomeConfig(initial, profile) - second := configureCodexHomeConfig(first, profile) + first := configureCodexHomeConfig(initial, profile, nil) + second := configureCodexHomeConfig(first, profile, nil) if first != second { t.Fatalf("configureCodexHomeConfig should be idempotent\nfirst:\n%s\nsecond:\n%s", first, second) } @@ -1439,7 +1439,7 @@ func TestConfigureCodexHomeConfigReplacesManagedBlocksIdempotently(t *testing.T) func TestConfigureCodexHomeConfigIncompleteProfileSkipsProvider(t *testing.T) { config := configureCodexHomeConfig("approval_policy = \"manual\"\n", agentruntime.Profile{ BaseURL: "https://runtime.example/v1", - }) + }, nil) if strings.Contains(config, csgclawProviderBeginMarker) { t.Fatalf("config should skip provider block for incomplete profile:\n%s", config) } @@ -1456,6 +1456,41 @@ func TestConfigureCodexHomeConfigIncompleteProfileSkipsProvider(t *testing.T) { } } +func TestConfigureCodexHomeConfigRendersMCPServers(t *testing.T) { + config := configureCodexHomeConfig("approval_policy = \"manual\"\n", agentruntime.Profile{}, map[string]any{ + "mcpServers": map[string]any{ + "context7": map[string]any{ + "command": "uvx", + "args": []any{"context7-mcp"}, + "env": map[string]any{ + "CONTEXT7_API_KEY": "secret", + }, + }, + "remote": map[string]any{ + "url": "https://mcp.example.com/mcp", + "bearer_token_env_var": "MCP_TOKEN", + }, + }, + }) + + for _, want := range []string{ + csgclawMCPBeginMarker, + `[mcp_servers."context7"]`, + `command = "uvx"`, + `args = ["context7-mcp"]`, + `env = { "CONTEXT7_API_KEY" = "secret" }`, + `[mcp_servers."remote"]`, + `url = "https://mcp.example.com/mcp"`, + `bearer_token_env_var = "MCP_TOKEN"`, + csgclawMCPEndMarker, + `approval_policy = "manual"`, + } { + if !strings.Contains(config, want) { + t.Fatalf("config missing %q:\n%s", want, config) + } + } +} + func TestRuntimeCreateAlwaysWritesResponsesConfig(t *testing.T) { root := t.TempDir() rt := New(Dependencies{ diff --git a/internal/runtime/mcp_config.go b/internal/runtime/mcp_config.go new file mode 100644 index 00000000..3000c614 --- /dev/null +++ b/internal/runtime/mcp_config.go @@ -0,0 +1,307 @@ +package runtime + +import ( + "context" + "fmt" + "reflect" + "sort" + "strings" +) + +const MCPConfigServersKey = "mcpServers" + +type MCPConfigSnapshot struct { + Config map[string]any +} + +type MCPConfigChange struct { + Previous MCPConfigSnapshot + Current MCPConfigSnapshot +} + +type MCPConfigController interface { + ValidateMCPConfig(ctx context.Context, current MCPConfigSnapshot) error + MCPConfigRestartRequired(change MCPConfigChange) (bool, error) + ReconcileMCPConfig(ctx context.Context, h Handle, change MCPConfigChange) error +} + +type MCPConfigMode int + +const ( + MCPConfigAbsent MCPConfigMode = iota + MCPConfigManaged +) + +type MCPConfig struct { + Mode MCPConfigMode + Servers map[string]any +} + +func NormalizeMCPConfig(raw map[string]any) (map[string]any, error) { + parsed, err := DecodeMCPConfig(raw, "mcp_config") + if err != nil { + return nil, err + } + if parsed.Mode == MCPConfigAbsent { + return nil, nil + } + servers := parsed.Servers + if servers == nil { + servers = map[string]any{} + } + return map[string]any{MCPConfigServersKey: servers}, nil +} + +func ValidateMCPConfig(raw map[string]any) error { + _, err := DecodeMCPConfig(raw, "mcp_config") + return err +} + +func MCPConfigServers(raw map[string]any) (map[string]any, error) { + parsed, err := DecodeMCPConfig(raw, "mcp_config") + if err != nil { + return nil, err + } + if parsed.Mode != MCPConfigManaged { + return nil, nil + } + if parsed.Servers == nil { + return map[string]any{}, nil + } + return parsed.Servers, nil +} + +func MCPConfigNeedsRestart(previous, current map[string]any) (bool, error) { + prev, prevErr := DecodeMCPConfig(previous, "mcp_config") + next, err := DecodeMCPConfig(current, "mcp_config") + if err != nil { + return false, err + } + if prevErr != nil { + return true, nil + } + return !reflect.DeepEqual(MCPConfigEffectiveServers(prev), MCPConfigEffectiveServers(next)), nil +} + +func MCPConfigEffectiveServers(config MCPConfig) map[string]any { + if config.Mode != MCPConfigManaged { + return nil + } + if config.Servers == nil { + return map[string]any{} + } + return config.Servers +} + +func UpdateJSONMCPServers(cfg map[string]any, raw map[string]any) error { + servers, err := MCPConfigServers(raw) + if err != nil { + return err + } + if servers == nil { + mcpRoot, ok := cfg["mcp"].(map[string]any) + if !ok { + return nil + } + delete(mcpRoot, "servers") + if len(mcpRoot) == 0 { + delete(cfg, "mcp") + } + return nil + } + mcpRoot, _ := cfg["mcp"].(map[string]any) + if mcpRoot == nil { + mcpRoot = map[string]any{} + cfg["mcp"] = mcpRoot + } + mcpRoot["servers"] = servers + return nil +} + +func DecodeMCPConfig(raw map[string]any, path string) (MCPConfig, error) { + path = strings.TrimSpace(path) + if path == "" { + path = "mcp_config" + } + if raw == nil { + return MCPConfig{Mode: MCPConfigAbsent}, nil + } + if unsupported := unsupportedMCPRootKeys(raw); len(unsupported) > 0 { + return MCPConfig{}, fmt.Errorf("%s contains unsupported field(s) %q; only %s is supported", path, strings.Join(unsupported, ", "), MCPConfigServersKey) + } + if len(raw) == 0 { + return MCPConfig{Mode: MCPConfigManaged, Servers: map[string]any{}}, nil + } + rawServers, ok := raw[MCPConfigServersKey] + if !ok { + return MCPConfig{Mode: MCPConfigManaged, Servers: map[string]any{}}, nil + } + serverMap, ok := rawServers.(map[string]any) + if !ok { + return MCPConfig{}, fmt.Errorf("%s.%s must be an object", path, MCPConfigServersKey) + } + servers := make(map[string]any, len(serverMap)) + for rawName, rawEntry := range serverMap { + name := strings.TrimSpace(rawName) + if name == "" { + return MCPConfig{}, fmt.Errorf("%s.%s contains an empty server name", path, MCPConfigServersKey) + } + if _, exists := servers[name]; exists { + return MCPConfig{}, fmt.Errorf("%s.%s contains duplicate server name %q", path, MCPConfigServersKey, name) + } + entry, ok := rawEntry.(map[string]any) + if !ok { + return MCPConfig{}, fmt.Errorf("%s.%s.%s must be an object", path, MCPConfigServersKey, name) + } + normalized, err := normalizeMCPServerEntry(path, name, entry) + if err != nil { + return MCPConfig{}, err + } + servers[name] = normalized + } + return MCPConfig{Mode: MCPConfigManaged, Servers: servers}, nil +} + +func unsupportedMCPRootKeys(root map[string]any) []string { + out := make([]string, 0) + for key := range root { + if key != MCPConfigServersKey { + out = append(out, key) + } + } + sort.Strings(out) + return out +} + +func normalizeMCPServerEntry(path, name string, entry map[string]any) (map[string]any, error) { + normalized, ok := cloneMCPJSONObject(entry).(map[string]any) + if !ok { + return nil, fmt.Errorf("%s.%s.%s must be an object", path, MCPConfigServersKey, name) + } + command, hasCommand, err := mcpStringField(normalized, "command") + if err != nil { + return nil, fmt.Errorf("%s.%s.%s.command %s", path, MCPConfigServersKey, name, err) + } + url, hasURL, err := mcpStringField(normalized, "url") + if err != nil { + return nil, fmt.Errorf("%s.%s.%s.url %s", path, MCPConfigServersKey, name, err) + } + if !hasCommand && !hasURL { + return nil, fmt.Errorf("%s.%s.%s must declare command or url", path, MCPConfigServersKey, name) + } + if hasCommand { + normalized["command"] = command + } else { + delete(normalized, "command") + } + if hasURL { + normalized["url"] = url + } else { + delete(normalized, "url") + } + if err := validateMCPStringSliceField(normalized, "args"); err != nil { + return nil, fmt.Errorf("%s.%s.%s.args must be an array of strings", path, MCPConfigServersKey, name) + } + if err := validateMCPStringMapField(normalized, "env"); err != nil { + return nil, fmt.Errorf("%s.%s.%s.env must be an object with string values", path, MCPConfigServersKey, name) + } + if err := validateMCPStringMapField(normalized, "headers"); err != nil { + return nil, fmt.Errorf("%s.%s.%s.headers must be an object with string values", path, MCPConfigServersKey, name) + } + if err := validateMCPStringField(normalized, "transport"); err != nil { + return nil, fmt.Errorf("%s.%s.%s.transport must be a string", path, MCPConfigServersKey, name) + } + return normalized, nil +} + +func mcpStringField(values map[string]any, key string) (string, bool, error) { + raw, ok := values[key] + if !ok || raw == nil { + return "", false, nil + } + text, ok := raw.(string) + if !ok { + return "", false, fmt.Errorf("must be a string") + } + text = strings.TrimSpace(text) + if text == "" { + return "", false, fmt.Errorf("must not be blank") + } + return text, true, nil +} + +func validateMCPStringField(values map[string]any, key string) error { + raw, ok := values[key] + if !ok || raw == nil { + return nil + } + if _, ok := raw.(string); !ok { + return fmt.Errorf("not a string") + } + return nil +} + +func validateMCPStringSliceField(values map[string]any, key string) error { + raw, ok := values[key] + if !ok || raw == nil { + return nil + } + items, ok := raw.([]any) + if !ok { + return fmt.Errorf("not an array") + } + for _, item := range items { + if _, ok := item.(string); !ok { + return fmt.Errorf("contains non-string value") + } + } + return nil +} + +func validateMCPStringMapField(values map[string]any, key string) error { + raw, ok := values[key] + if !ok || raw == nil { + return nil + } + items, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("not an object") + } + for _, value := range items { + if _, ok := value.(string); !ok { + return fmt.Errorf("contains non-string value") + } + } + return nil +} + +func cloneMCPJSONObject(value any) any { + switch typed := value.(type) { + case map[string]any: + out := make(map[string]any, len(typed)) + for key, item := range typed { + out[key] = cloneMCPJSONObject(item) + } + return out + case map[string]string: + out := make(map[string]any, len(typed)) + for key, item := range typed { + out[key] = item + } + return out + case []any: + out := make([]any, len(typed)) + for idx, item := range typed { + out[idx] = cloneMCPJSONObject(item) + } + return out + case []string: + out := make([]any, len(typed)) + for idx, item := range typed { + out[idx] = item + } + return out + default: + return value + } +} diff --git a/internal/runtime/mcp_options.go b/internal/runtime/mcp_options.go new file mode 100644 index 00000000..a619929c --- /dev/null +++ b/internal/runtime/mcp_options.go @@ -0,0 +1,3 @@ +package runtime + +const RuntimeOptionMCPKey = "mcp" diff --git a/internal/runtime/openclawsandbox/config.go b/internal/runtime/openclawsandbox/config.go index 65a6a729..50dcc813 100644 --- a/internal/runtime/openclawsandbox/config.go +++ b/internal/runtime/openclawsandbox/config.go @@ -12,6 +12,7 @@ import ( "csgclaw/internal/channel/feishu" "csgclaw/internal/config" "csgclaw/internal/modelcap" + agentruntime "csgclaw/internal/runtime" ) //go:embed defaults/openclaw-gateway.json @@ -49,11 +50,23 @@ func HostGatewayLogPath(agentHome string) string { } func EnsureConfig(agentHome, participantID, agentID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, feishuProvider feishu.AgentCredentialProvider) (string, error) { + return EnsureConfigWithMCPConfig(agentHome, participantID, agentID, server, model, nil, resolveBaseURL, feishuProvider) +} + +func EnsureConfigWithRuntimeOptions(agentHome, participantID, agentID string, server config.ServerConfig, model config.ModelConfig, runtimeOptions map[string]any, resolveBaseURL BaseURLResolver, feishuProvider feishu.AgentCredentialProvider) (string, error) { + mcpConfig, err := mcpConfigFromLegacyRuntimeOptions(runtimeOptions) + if err != nil { + return "", err + } + return EnsureConfigWithMCPConfig(agentHome, participantID, agentID, server, model, mcpConfig, resolveBaseURL, feishuProvider) +} + +func EnsureConfigWithMCPConfig(agentHome, participantID, agentID string, server config.ServerConfig, model config.ModelConfig, mcpConfig map[string]any, resolveBaseURL BaseURLResolver, feishuProvider feishu.AgentCredentialProvider) (string, error) { hostRoot := Root(agentHome) if err := os.MkdirAll(hostRoot, 0o755); err != nil { return "", fmt.Errorf("create openclaw config dir: %w", err) } - data, err := renderConfig(participantID, agentID, server, model, resolveBaseURL, feishuProvider) + data, err := renderConfigWithMCPConfig(participantID, agentID, server, model, mcpConfig, resolveBaseURL, feishuProvider) if err != nil { return "", err } @@ -143,6 +156,18 @@ func updateOpenClawWorkspaceDefault(cfg map[string]any, workspace string) { defaults["workspace"] = workspace } func renderConfig(participantID, agentID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, feishuProvider feishu.AgentCredentialProvider) ([]byte, error) { + return renderConfigWithMCPConfig(participantID, agentID, server, model, nil, resolveBaseURL, feishuProvider) +} + +func renderConfigWithRuntimeOptions(participantID, agentID string, server config.ServerConfig, model config.ModelConfig, runtimeOptions map[string]any, resolveBaseURL BaseURLResolver, feishuProvider feishu.AgentCredentialProvider) ([]byte, error) { + mcpConfig, err := mcpConfigFromLegacyRuntimeOptions(runtimeOptions) + if err != nil { + return nil, err + } + return renderConfigWithMCPConfig(participantID, agentID, server, model, mcpConfig, resolveBaseURL, feishuProvider) +} + +func renderConfigWithMCPConfig(participantID, agentID string, server config.ServerConfig, model config.ModelConfig, mcpConfig map[string]any, resolveBaseURL BaseURLResolver, feishuProvider feishu.AgentCredentialProvider) ([]byte, error) { participantID = strings.TrimSpace(participantID) agentID = strings.TrimSpace(agentID) if participantID == "" { @@ -168,6 +193,9 @@ func renderConfig(participantID, agentID string, server config.ServerConfig, mod return nil, err } updateOpenClawWorkspaceDefault(cfg, workspaceGuestPathForGOOS(goruntime.GOOS)) + if err := updateOpenClawMCP(cfg, mcpConfig); err != nil { + return nil, err + } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return nil, fmt.Errorf("encode openclaw config: %w", err) @@ -175,6 +203,30 @@ func renderConfig(participantID, agentID string, server config.ServerConfig, mod return data, nil } +func mcpConfigFromLegacyRuntimeOptions(runtimeOptions map[string]any) (map[string]any, error) { + raw, ok := runtimeOptions[agentruntime.RuntimeOptionMCPKey] + if !ok || raw == nil { + return nil, nil + } + mcpConfig, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("runtime_options.%s must be an object or null", agentruntime.RuntimeOptionMCPKey) + } + normalized, err := agentruntime.NormalizeMCPConfig(mcpConfig) + if err != nil { + return nil, legacyMCPConfigErrorPath(err) + } + return normalized, nil +} + +func legacyMCPConfigErrorPath(err error) error { + if err == nil { + return nil + } + message := strings.ReplaceAll(err.Error(), "mcp_config", "runtime_options."+agentruntime.RuntimeOptionMCPKey) + return fmt.Errorf("%s", message) +} + func updateOpenClawModelProvider(cfg map[string]any, botID string, server config.ServerConfig, modelCfg config.ModelConfig, resolveBaseURL BaseURLResolver) error { modelCfg = modelCfg.Resolved() modelsRoot, ok := cfg["models"].(map[string]any) diff --git a/internal/runtime/openclawsandbox/config_controller.go b/internal/runtime/openclawsandbox/config_controller.go new file mode 100644 index 00000000..82e25ec4 --- /dev/null +++ b/internal/runtime/openclawsandbox/config_controller.go @@ -0,0 +1,34 @@ +package openclawsandbox + +import ( + "context" + + agentruntime "csgclaw/internal/runtime" +) + +var _ agentruntime.RuntimeConfigController = (*Runtime)(nil) +var _ agentruntime.MCPConfigController = (*Runtime)(nil) + +func (r *Runtime) ValidateConfig(_ context.Context, current agentruntime.RuntimeConfigSnapshot) error { + return nil +} + +func (r *Runtime) RestartRequired(change agentruntime.RuntimeConfigChange) (bool, error) { + return false, nil +} + +func (r *Runtime) ReconcileConfig(context.Context, agentruntime.Handle, agentruntime.RuntimeConfigChange) error { + return nil +} + +func (r *Runtime) ValidateMCPConfig(_ context.Context, current agentruntime.MCPConfigSnapshot) error { + return validateOpenClawMCPConfig(current.Config) +} + +func (r *Runtime) MCPConfigRestartRequired(change agentruntime.MCPConfigChange) (bool, error) { + return openClawMCPRestartRequired(change.Previous.Config, change.Current.Config) +} + +func (r *Runtime) ReconcileMCPConfig(context.Context, agentruntime.Handle, agentruntime.MCPConfigChange) error { + return nil +} diff --git a/internal/runtime/openclawsandbox/config_test.go b/internal/runtime/openclawsandbox/config_test.go index 2c511b61..322d01f5 100644 --- a/internal/runtime/openclawsandbox/config_test.go +++ b/internal/runtime/openclawsandbox/config_test.go @@ -179,6 +179,214 @@ func TestRenderAgentOpenClawConfigUsesBridgeWhenBaseURLEmpty(t *testing.T) { } } +func TestRenderAgentOpenClawConfigRendersMCPServers(t *testing.T) { + data, err := renderConfigWithRuntimeOptions("u-worker-1", "u-worker-1", config.ServerConfig{ + ListenAddr: "127.0.0.1:18080", + AdvertiseBaseURL: "http://127.0.0.1:18080", + AccessToken: "shared-token", + }, config.ModelConfig{ + ModelID: "MiniMax-M2.7", + }, map[string]any{ + "mcp": map[string]any{ + "mcpServers": map[string]any{ + "context7": map[string]any{ + "command": "uvx", + "args": []any{"context7-mcp"}, + "env": map[string]any{ + "CONTEXT7_API_KEY": "secret", + }, + }, + "remote-search": map[string]any{ + "command": nil, + "url": "https://mcp.example.com/mcp", + "transport": "streamable-http", + "headers": map[string]any{ + "Authorization": "Bearer secret", + }, + }, + }, + }, + }, testBaseURLResolver, nil) + if err != nil { + t.Fatalf("renderConfigWithRuntimeOptions() error = %v", err) + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + mcp := cfg["mcp"].(map[string]any) + servers := mcp["servers"].(map[string]any) + context7 := servers["context7"].(map[string]any) + if got, want := context7["command"], "uvx"; got != want { + t.Fatalf("context7 command = %#v, want %q", got, want) + } + if got, want := context7["args"], []any{"context7-mcp"}; !reflect.DeepEqual(got, want) { + t.Fatalf("context7 args = %#v, want %#v", got, want) + } + env := context7["env"].(map[string]any) + if got, want := env["CONTEXT7_API_KEY"], "secret"; got != want { + t.Fatalf("context7 env key = %#v, want %q", got, want) + } + remote := servers["remote-search"].(map[string]any) + if got, want := remote["url"], "https://mcp.example.com/mcp"; got != want { + t.Fatalf("remote-search url = %#v, want %q", got, want) + } + if _, ok := remote["command"]; ok { + t.Fatalf("remote-search command is present, want nil optional command omitted: %#v", remote) + } + if got, want := remote["transport"], "streamable-http"; got != want { + t.Fatalf("remote-search transport = %#v, want %q", got, want) + } + headers := remote["headers"].(map[string]any) + if got, want := headers["Authorization"], "Bearer secret"; got != want { + t.Fatalf("remote-search Authorization = %#v, want %q", got, want) + } +} + +func TestRenderAgentOpenClawConfigMCPEmptyAndClearSemantics(t *testing.T) { + baseServer := config.ServerConfig{ + ListenAddr: "127.0.0.1:18080", + AdvertiseBaseURL: "http://127.0.0.1:18080", + AccessToken: "shared-token", + } + baseModel := config.ModelConfig{ModelID: "MiniMax-M2.7"} + for _, tc := range []struct { + name string + runtimeOptions map[string]any + wantMCP bool + wantServers bool + }{ + { + name: "absent does not write mcp", + }, + { + name: "null clears managed mcp", + runtimeOptions: map[string]any{"mcp": nil}, + }, + { + name: "empty object writes empty servers", + runtimeOptions: map[string]any{"mcp": map[string]any{}}, + wantMCP: true, + wantServers: true, + }, + { + name: "empty mcpServers writes empty servers", + runtimeOptions: map[string]any{ + "mcp": map[string]any{"mcpServers": map[string]any{}}, + }, + wantMCP: true, + wantServers: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + data, err := renderConfigWithRuntimeOptions("u-worker-1", "u-worker-1", baseServer, baseModel, tc.runtimeOptions, testBaseURLResolver, nil) + if err != nil { + t.Fatalf("renderConfigWithRuntimeOptions() error = %v", err) + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + mcp, hasMCP := cfg["mcp"].(map[string]any) + if hasMCP != tc.wantMCP { + t.Fatalf("mcp present = %v, want %v; config:\n%s", hasMCP, tc.wantMCP, data) + } + if !hasMCP { + return + } + servers, hasServers := mcp["servers"].(map[string]any) + if hasServers != tc.wantServers { + t.Fatalf("mcp.servers present = %v, want %v; mcp=%#v", hasServers, tc.wantServers, mcp) + } + if hasServers && len(servers) != 0 { + t.Fatalf("mcp.servers = %#v, want empty", servers) + } + }) + } +} + +func TestRenderAgentOpenClawConfigRejectsInvalidMCPServer(t *testing.T) { + baseServer := config.ServerConfig{ + ListenAddr: "127.0.0.1:18080", + AdvertiseBaseURL: "http://127.0.0.1:18080", + AccessToken: "shared-token", + } + baseModel := config.ModelConfig{ModelID: "MiniMax-M2.7"} + for _, tc := range []struct { + name string + mcp any + want string + }{ + { + name: "mcp root must be object", + mcp: "invalid", + want: "must be an object or null", + }, + { + name: "mcpServers must be object", + mcp: map[string]any{"mcpServers": []any{}}, + want: "mcpServers must be an object", + }, + { + name: "mcpServers null is invalid", + mcp: map[string]any{"mcpServers": nil}, + want: "mcpServers must be an object", + }, + { + name: "direct openclaw servers object is rejected", + mcp: map[string]any{"servers": map[string]any{"context7": map[string]any{"command": "uvx"}}}, + want: "contains unsupported field", + }, + { + name: "server entry must be object", + mcp: map[string]any{"mcpServers": map[string]any{"broken": "invalid"}}, + want: "mcpServers.broken must be an object", + }, + { + name: "server requires command or url", + mcp: map[string]any{"mcpServers": map[string]any{"broken": map[string]any{"args": []any{"missing-command-or-url"}}}}, + want: "must declare command or url", + }, + { + name: "blank command is invalid even with url", + mcp: map[string]any{"mcpServers": map[string]any{"broken": map[string]any{"command": " ", "url": "https://mcp.example.com/mcp"}}}, + want: "command must not be blank", + }, + { + name: "blank url is invalid even with command", + mcp: map[string]any{"mcpServers": map[string]any{"broken": map[string]any{"command": "uvx", "url": " "}}}, + want: "url must not be blank", + }, + { + name: "args must contain strings", + mcp: map[string]any{"mcpServers": map[string]any{"broken": map[string]any{"command": "uvx", "args": []any{1}}}}, + want: "args must be an array of strings", + }, + { + name: "env values must be strings", + mcp: map[string]any{"mcpServers": map[string]any{"broken": map[string]any{"command": "uvx", "env": map[string]any{"TOKEN": 1}}}}, + want: "env must be an object with string values", + }, + { + name: "trimmed server names must be unique", + mcp: map[string]any{ + "mcpServers": map[string]any{ + "same": map[string]any{"command": "uvx"}, + " same": map[string]any{"command": "uvx"}, + }, + }, + want: `duplicate server name "same"`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := renderConfigWithRuntimeOptions("u-worker-1", "u-worker-1", baseServer, baseModel, map[string]any{"mcp": tc.mcp}, testBaseURLResolver, nil) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("renderConfigWithRuntimeOptions() error = %v, want containing %q", err, tc.want) + } + }) + } +} + func TestRenderAgentOpenClawConfigUsesCodexResponsesModelMetadata(t *testing.T) { data, err := renderConfig("u-manager", "u-manager", config.ServerConfig{ ListenAddr: "127.0.0.1:18080", diff --git a/internal/runtime/openclawsandbox/mcp.go b/internal/runtime/openclawsandbox/mcp.go new file mode 100644 index 00000000..d05a33c1 --- /dev/null +++ b/internal/runtime/openclawsandbox/mcp.go @@ -0,0 +1,17 @@ +package openclawsandbox + +import ( + agentruntime "csgclaw/internal/runtime" +) + +func validateOpenClawMCPConfig(config map[string]any) error { + return agentruntime.ValidateMCPConfig(config) +} + +func openClawMCPRestartRequired(previous, current map[string]any) (bool, error) { + return agentruntime.MCPConfigNeedsRestart(previous, current) +} + +func updateOpenClawMCP(cfg map[string]any, mcpConfig map[string]any) error { + return agentruntime.UpdateJSONMCPServers(cfg, mcpConfig) +} diff --git a/internal/runtime/openclawsandbox/provision_test.go b/internal/runtime/openclawsandbox/provision_test.go index 5c519761..fbe169b0 100644 --- a/internal/runtime/openclawsandbox/provision_test.go +++ b/internal/runtime/openclawsandbox/provision_test.go @@ -25,10 +25,15 @@ func TestProvisionPreparesGatewayAssets(t *testing.T) { rt := New(Dependencies{}) if err := rt.Provision(context.Background(), agentruntime.ProvisionRequest{ - RuntimeID: "rt-1", - AgentID: "u-alice", - AgentName: "alice", - Profile: agentruntime.Profile{}, + RuntimeID: "rt-1", + AgentID: "u-alice", + AgentName: "alice", + Profile: agentruntime.Profile{}, + MCPConfig: map[string]any{ + "mcpServers": map[string]any{ + "context7": map[string]any{"command": "uvx", "args": []any{"context7-mcp"}}, + }, + }, WorkspaceOverlay: overlayRoot, Gateway: &agentruntime.GatewayProvision{ ModelFallback: "fallback-model", @@ -45,6 +50,20 @@ func TestProvisionPreparesGatewayAssets(t *testing.T) { if _, err := os.Stat(filepath.Join(agentHome, HostDir, HostConfig)); err != nil { t.Fatalf("stat openclaw config: %v", err) } + configRaw, err := os.ReadFile(filepath.Join(agentHome, HostDir, HostConfig)) + if err != nil { + t.Fatalf("ReadFile(openclaw config) error = %v", err) + } + var configData map[string]any + if err := json.Unmarshal(configRaw, &configData); err != nil { + t.Fatalf("json.Unmarshal(openclaw config) error = %v", err) + } + mcp := configData["mcp"].(map[string]any) + servers := mcp["servers"].(map[string]any) + context7 := servers["context7"].(map[string]any) + if got, want := context7["command"], "uvx"; got != want { + t.Fatalf("openclaw config mcp.servers.context7.command = %#v, want %q", got, want) + } if _, err := os.Stat(filepath.Join(agentHome, HostDir, HostExecApproval)); err != nil { t.Fatalf("stat openclaw approvals: %v", err) } diff --git a/internal/runtime/openclawsandbox/runtime.go b/internal/runtime/openclawsandbox/runtime.go index 5155ae70..264ab5aa 100644 --- a/internal/runtime/openclawsandbox/runtime.go +++ b/internal/runtime/openclawsandbox/runtime.go @@ -83,7 +83,7 @@ func (r *Runtime) Provision(_ context.Context, req agentruntime.ProvisionRequest if participantID == "" { participantID = strings.TrimSpace(req.AgentID) } - if _, err := EnsureConfig(agentHome, participantID, req.AgentID, gateway.Server, configModelFromProfile(profile), fixedBaseURL(gateway.ManagerBaseURL), r.CurrentFeishuProvider()); err != nil { + if _, err := EnsureConfigWithMCPConfig(agentHome, participantID, req.AgentID, gateway.Server, configModelFromProfile(profile), req.MCPConfig, fixedBaseURL(gateway.ManagerBaseURL), r.CurrentFeishuProvider()); err != nil { return err } workspaceRoot := r.Layout(agentHome).WorkspaceRoot diff --git a/internal/runtime/picoclawsandbox/config.go b/internal/runtime/picoclawsandbox/config.go index 46e70bcd..e256b528 100644 --- a/internal/runtime/picoclawsandbox/config.go +++ b/internal/runtime/picoclawsandbox/config.go @@ -10,6 +10,7 @@ import ( "csgclaw/internal/channel/feishu" "csgclaw/internal/config" + agentruntime "csgclaw/internal/runtime" ) //go:embed defaults/picoclaw-config.json @@ -49,12 +50,16 @@ func WorkspaceConfigRoot(agentHome string) string { } func EnsureConfig(agentHome, participantID, agentID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, feishuProviders ...feishu.AgentCredentialProvider) (string, error) { + return EnsureConfigWithMCPConfig(agentHome, participantID, agentID, server, model, nil, resolveBaseURL, feishuProviders...) +} + +func EnsureConfigWithMCPConfig(agentHome, participantID, agentID string, server config.ServerConfig, model config.ModelConfig, mcpConfig map[string]any, resolveBaseURL BaseURLResolver, feishuProviders ...feishu.AgentCredentialProvider) (string, error) { hostRoot := Root(agentHome) if err := os.MkdirAll(hostRoot, 0o755); err != nil { return "", fmt.Errorf("create picoclaw config dir: %w", err) } - data, err := RenderConfig(participantID, agentID, server, model, resolveBaseURL, feishuProviders...) + data, err := RenderConfigWithMCPConfig(participantID, agentID, server, model, mcpConfig, resolveBaseURL, feishuProviders...) if err != nil { return "", err } @@ -71,6 +76,10 @@ func EnsureConfig(agentHome, participantID, agentID string, server config.Server } func RenderConfig(participantID, agentID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, feishuProviders ...feishu.AgentCredentialProvider) ([]byte, error) { + return RenderConfigWithMCPConfig(participantID, agentID, server, model, nil, resolveBaseURL, feishuProviders...) +} + +func RenderConfigWithMCPConfig(participantID, agentID string, server config.ServerConfig, model config.ModelConfig, mcpConfig map[string]any, resolveBaseURL BaseURLResolver, feishuProviders ...feishu.AgentCredentialProvider) ([]byte, error) { participantID = strings.TrimSpace(participantID) agentID = strings.TrimSpace(agentID) if participantID == "" { @@ -93,6 +102,9 @@ func RenderConfig(participantID, agentID string, server config.ServerConfig, mod if err := updateFeishuChannel(cfg, agentID, firstFeishuProvider(feishuProviders)); err != nil { return nil, err } + if err := updatePicoClawMCP(cfg, mcpConfig); err != nil { + return nil, err + } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { @@ -101,6 +113,30 @@ func RenderConfig(participantID, agentID string, server config.ServerConfig, mod return data, nil } +func updatePicoClawMCP(cfg map[string]any, mcpConfig map[string]any) error { + servers, err := agentruntime.MCPConfigServers(mcpConfig) + if err != nil { + return err + } + tools, ok := cfg["tools"].(map[string]any) + if !ok { + return fmt.Errorf("embedded manager picoclaw config is missing tools") + } + mcpRoot, _ := tools["mcp"].(map[string]any) + if mcpRoot == nil { + mcpRoot = map[string]any{} + tools["mcp"] = mcpRoot + } + if servers == nil { + mcpRoot["enabled"] = false + delete(mcpRoot, "servers") + return nil + } + mcpRoot["enabled"] = true + mcpRoot["servers"] = servers + return nil +} + func updateFeishuChannel(cfg map[string]any, agentID string, provider feishu.AgentCredentialProvider) error { agentID = strings.TrimSpace(agentID) if agentID == "" || provider == nil { diff --git a/internal/runtime/picoclawsandbox/config_controller.go b/internal/runtime/picoclawsandbox/config_controller.go new file mode 100644 index 00000000..ef5adf4d --- /dev/null +++ b/internal/runtime/picoclawsandbox/config_controller.go @@ -0,0 +1,21 @@ +package picoclawsandbox + +import ( + "context" + + agentruntime "csgclaw/internal/runtime" +) + +var _ agentruntime.MCPConfigController = (*Runtime)(nil) + +func (r *Runtime) ValidateMCPConfig(_ context.Context, current agentruntime.MCPConfigSnapshot) error { + return agentruntime.ValidateMCPConfig(current.Config) +} + +func (r *Runtime) MCPConfigRestartRequired(change agentruntime.MCPConfigChange) (bool, error) { + return agentruntime.MCPConfigNeedsRestart(change.Previous.Config, change.Current.Config) +} + +func (r *Runtime) ReconcileMCPConfig(context.Context, agentruntime.Handle, agentruntime.MCPConfigChange) error { + return nil +} diff --git a/internal/runtime/picoclawsandbox/config_test.go b/internal/runtime/picoclawsandbox/config_test.go index 6ad86a63..10bd8df5 100644 --- a/internal/runtime/picoclawsandbox/config_test.go +++ b/internal/runtime/picoclawsandbox/config_test.go @@ -2,6 +2,7 @@ package picoclawsandbox import ( "encoding/json" + "strings" "testing" "csgclaw/internal/channel/feishu" @@ -39,6 +40,95 @@ func TestRenderConfigDisablesUnconfiguredFeishuChannel(t *testing.T) { } } +func TestRenderConfigWithMCPConfigWritesPicoClawToolsMCP(t *testing.T) { + baseServer := config.ServerConfig{AccessToken: "shared-token"} + baseModel := config.ModelConfig{ModelID: "gpt-5.5"} + resolver := fixedBaseURL("http://127.0.0.1:18080") + + t.Run("absent disables mcp", func(t *testing.T) { + data, err := RenderConfigWithMCPConfig("manager", "u-manager", baseServer, baseModel, nil, resolver) + if err != nil { + t.Fatalf("RenderConfigWithMCPConfig() error = %v", err) + } + mcpRoot := renderedToolsMCP(t, data) + if got, want := mcpRoot["enabled"], false; got != want { + t.Fatalf("tools.mcp.enabled = %v, want %v in:\n%s", got, want, data) + } + if _, ok := mcpRoot["servers"]; ok { + t.Fatalf("tools.mcp.servers should be absent for unmanaged config: %#v", mcpRoot["servers"]) + } + }) + + t.Run("empty managed config enables empty servers", func(t *testing.T) { + data, err := RenderConfigWithMCPConfig("manager", "u-manager", baseServer, baseModel, map[string]any{}, resolver) + if err != nil { + t.Fatalf("RenderConfigWithMCPConfig() error = %v", err) + } + mcpRoot := renderedToolsMCP(t, data) + if got, want := mcpRoot["enabled"], true; got != want { + t.Fatalf("tools.mcp.enabled = %v, want %v in:\n%s", got, want, data) + } + servers, ok := mcpRoot["servers"].(map[string]any) + if !ok { + t.Fatalf("tools.mcp.servers = %#v, want object", mcpRoot["servers"]) + } + if len(servers) != 0 { + t.Fatalf("tools.mcp.servers = %#v, want empty", servers) + } + }) + + t.Run("server config is preserved", func(t *testing.T) { + data, err := RenderConfigWithMCPConfig("manager", "u-manager", baseServer, baseModel, map[string]any{ + "mcpServers": map[string]any{ + "context7": map[string]any{ + "command": "uvx", + "args": []any{"context7-mcp"}, + }, + }, + }, resolver) + if err != nil { + t.Fatalf("RenderConfigWithMCPConfig() error = %v", err) + } + mcpRoot := renderedToolsMCP(t, data) + if got, want := mcpRoot["enabled"], true; got != want { + t.Fatalf("tools.mcp.enabled = %v, want %v in:\n%s", got, want, data) + } + servers := mcpRoot["servers"].(map[string]any) + context7 := servers["context7"].(map[string]any) + if got, want := context7["command"], "uvx"; got != want { + t.Fatalf("context7.command = %#v, want %q", got, want) + } + args := context7["args"].([]any) + if got, want := args[0], "context7-mcp"; got != want { + t.Fatalf("context7.args[0] = %#v, want %q", got, want) + } + }) +} + +func TestRenderConfigWithMCPConfigRejectsInvalidPicoClawMCP(t *testing.T) { + _, err := RenderConfigWithMCPConfig("manager", "u-manager", config.ServerConfig{}, config.ModelConfig{ModelID: "gpt-5.5"}, map[string]any{ + "mcpServers": []any{}, + }, fixedBaseURL("http://127.0.0.1:18080")) + if err == nil || !strings.Contains(err.Error(), "mcp_config.mcpServers must be an object") { + t.Fatalf("RenderConfigWithMCPConfig() error = %v, want mcpServers object error", err) + } +} + +func renderedToolsMCP(t *testing.T, data []byte) map[string]any { + t.Helper() + var rendered struct { + Tools map[string]any `json:"tools"` + } + if err := json.Unmarshal(data, &rendered); err != nil { + t.Fatalf("RenderConfigWithMCPConfig() produced invalid JSON: %v", err) + } + mcpRoot, ok := rendered.Tools["mcp"].(map[string]any) + if !ok { + t.Fatalf("RenderConfigWithMCPConfig() missing tools.mcp in:\n%s", data) + } + return mcpRoot +} + func TestRenderConfigEnablesFeishuChannelWhenParticipantConfigured(t *testing.T) { data, err := RenderConfig("manager", "u-manager", config.ServerConfig{ AccessToken: "shared-token", diff --git a/internal/runtime/picoclawsandbox/runtime.go b/internal/runtime/picoclawsandbox/runtime.go index cb4ca840..a563cbdf 100644 --- a/internal/runtime/picoclawsandbox/runtime.go +++ b/internal/runtime/picoclawsandbox/runtime.go @@ -85,7 +85,7 @@ func (r *Runtime) Provision(_ context.Context, req agentruntime.ProvisionRequest if participantID == "" { participantID = strings.TrimSpace(req.AgentID) } - if _, err := EnsureConfig(agentHome, participantID, req.AgentID, gateway.Server, configModelFromProfile(profile), fixedBaseURL(gateway.ManagerBaseURL), r.CurrentFeishuProvider()); err != nil { + if _, err := EnsureConfigWithMCPConfig(agentHome, participantID, req.AgentID, gateway.Server, configModelFromProfile(profile), req.MCPConfig, fixedBaseURL(gateway.ManagerBaseURL), r.CurrentFeishuProvider()); err != nil { return err } workspaceRoot := r.Layout(agentHome).WorkspaceRoot diff --git a/internal/runtime/provision.go b/internal/runtime/provision.go index a14f60d2..93608458 100644 --- a/internal/runtime/provision.go +++ b/internal/runtime/provision.go @@ -29,6 +29,8 @@ type ProvisionRequest struct { ParticipantID string AgentName string Profile Profile + RuntimeOptions map[string]any + MCPConfig map[string]any WorkspaceOverlay string Gateway *GatewayProvision } diff --git a/web/app/src/api/agents.ts b/web/app/src/api/agents.ts index b1c6f8f0..3126fb84 100644 --- a/web/app/src/api/agents.ts +++ b/web/app/src/api/agents.ts @@ -46,6 +46,7 @@ export type AgentUpdatePayload = { instructions?: string; image?: string; model_config?: JSONRecord; + mcp_config?: JSONRecord | null; name?: string; profile?: JSONRecord | string; runtime?: { name?: RuntimeName; sandbox_enabled?: boolean; options?: JSONRecord }; @@ -287,6 +288,7 @@ export async function createBotRequest(payload: CreateBotPayload): Promise void; + onInvalidChange?: (invalid: boolean) => void; + t: TranslateFn; +}; + +function cloneMCPExample(): JSONRecord { + return JSON.parse(JSON.stringify(MCP_CONFIG_EXAMPLE)) as JSONRecord; +} + +function errorMessageForKey(key: "invalid_json" | "object_required", t: TranslateFn): string { + return key === "invalid_json" ? t("profileMCPServersInvalidJSON") : t("profileMCPServersObjectRequired"); +} + +export function MCPConfigPanel({ draft, onDraftChange, onInvalidChange, t }: MCPConfigPanelProps) { + const textareaId = useId(); + const draftText = mcpConfigText(draft.mcp_config); + const [text, setText] = useState(draftText); + const [error, setError] = useState(""); + + useEffect(() => { + setText(draftText); + setError(""); + onInvalidChange?.(false); + }, [draftText, onInvalidChange]); + + function commitText(nextText: string) { + setText(nextText); + const parsed = parseMCPConfigText(nextText); + if (!parsed.ok) { + setError(errorMessageForKey(parsed.error, t)); + onInvalidChange?.(true); + return; + } + setError(""); + onInvalidChange?.(false); + onDraftChange({ + ...draft, + mcp_config: setMCPConfig(parsed.value), + }); + } + + function fillExample() { + const example = cloneMCPExample(); + setError(""); + onInvalidChange?.(false); + setText(JSON.stringify(example, null, 2)); + onDraftChange({ + ...draft, + mcp_config: setMCPConfig(example), + }); + } + + function clearMCPConfig() { + setError(""); + onInvalidChange?.(false); + setText(""); + onDraftChange({ + ...draft, + mcp_config: setMCPConfig(null), + }); + } + + return ( +
+
+ +
+ + +
+
+