From fd3e1738e916c2cef96530252ee5ba457fc5c01f Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Fri, 26 Jun 2026 17:04:03 +0800 Subject: [PATCH 1/7] docs(openclaw): add MCP restart plan --- docs/openclaw-mcp-restart-plan.zh.md | 434 +++++++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 docs/openclaw-mcp-restart-plan.zh.md diff --git a/docs/openclaw-mcp-restart-plan.zh.md b/docs/openclaw-mcp-restart-plan.zh.md new file mode 100644 index 00000000..919e06cb --- /dev/null +++ b/docs/openclaw-mcp-restart-plan.zh.md @@ -0,0 +1,434 @@ +# OpenClaw MCP 配置接入方案 + +本文档约束 CSGClaw 为 `openclaw_sandbox` worker 增加 MCP 能力的实现方案。阶段一采用“变更后需要重建”的策略:MCP 配置保存后先进入 CSGClaw 持久化状态,只有执行 agent recreate 后才写入并生效到新的 OpenClaw gateway 进程。 + +## 1. 目标与边界 + +目标: + +- 只支持 `openclaw_sandbox` worker 的 MCP 配置。 +- MCP 配置由 CSGClaw 管理,并渲染到 OpenClaw 原生 `mcp.servers` 配置块。 +- 创建 agent 时 MCP 配置随 OpenClaw 初次启动直接生效。 +- 修改已有 agent 的 MCP 配置后,标记“需要重建”,通过现有 agent recreate 流程生效。 +- API 和前端不回显 MCP secret 明文。 + +非目标: + +- 不在阶段一支持 PicoClaw MCP。 +- 不改 bootstrap manager 的运行时策略。 +- 不依赖 OpenClaw gateway 热加载 MCP。 +- 不把 MCP 写入 workspace 文件让 OpenClaw 扫描发现。 +- 不做 MCP marketplace、自动安装、连接测试或动态发现。 + +## 2. 当前 OpenClaw 配置注入路径 + +CSGClaw 当前已经通过配置文件注入 OpenClaw 运行参数,路径如下: + +```text +Agent create/recreate provisioning + -> internal/agent.Service.provisionRuntime(...) + -> openclawsandbox.Runtime.Provision(...) + -> openclawsandbox.EnsureConfig(...) + -> renderConfig(defaults/openclaw-gateway.json + CSGClaw runtime inputs) + -> ~/.csgclaw/agents//.openclaw/openclaw.json +``` + +`EnsureConfig` 会创建宿主机目录: + +```text +~/.csgclaw/agents//.openclaw/ +``` + +并写入: + +```text +~/.csgclaw/agents//.openclaw/openclaw.json +~/.csgclaw/agents//.openclaw/exec-approvals.json +``` + +OpenClaw sandbox 创建时,CSGClaw 会把该目录挂载进容器: + +```text +宿主机: ~/.csgclaw/agents//.openclaw +容器内: /home/node/.openclaw +``` + +同时设置: + +```text +HOME=/home/node +``` + +OpenClaw gateway 启动命令为: + +```bash +node /app/openclaw.mjs gateway --allow-unconfigured --bind lan --port 18789 +``` + +因此 OpenClaw 读取的是默认路径: + +```text +/home/node/.openclaw/openclaw.json +``` + +结论:CSGClaw 的 OpenClaw 配置注入不是把文件写进 workspace,也不是通过 task wrapper 或 `OPENCLAW_CONFIG_PATH` 指向临时文件,而是维护每个 agent 的长期 OpenClaw 配置根目录,并通过 sandbox mount 让 OpenClaw gateway 按默认路径读取。 + +## 3. MCP 配置形态 + +CSGClaw API 建议接收与 Multica 一致的输入形态: + +```json +{ + "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" + } + } + } +} +``` + +渲染到 OpenClaw 时转换为 OpenClaw 原生配置: + +```json +{ + "mcp": { + "servers": { + "context7": { + "command": "uvx", + "args": ["context7-mcp"], + "env": { + "CONTEXT7_API_KEY": "secret" + } + } + } + } +} +``` + +约束: + +- 每个 server entry 必须是 JSON object。 +- 每个 server entry 必须声明 `command` 或 `url`。 +- stdio server 使用 `command`、`args`、`env`。 +- HTTP/SSE server 使用 `url`,并直接使用 OpenClaw 识别的 `transport`,例如 `streamable-http`,不自动把 Claude 的 `type` 翻译成 OpenClaw 字段。 +- server 名称排序后写入,保证 `openclaw.json` 输出稳定。 +- MCP command 在 OpenClaw sandbox 内执行,不在宿主机执行;需要的二进制必须在镜像内存在,或通过 workspace/projects 等已有挂载路径可访问。 + +三态语义: + +| 输入 | 语义 | 渲染结果 | +| --- | --- | --- | +| 字段缺失 | 保持现有 MCP 配置不变 | update 时不修改保存值 | +| `null` | 清除 CSGClaw 管理的 MCP 配置 | recreate 后不写 `mcp.servers` | +| `{}` 或 `{"mcpServers": {}}` | 显式托管空集合 | recreate 后写入 `mcp.servers: {}` | +| 非空 `mcpServers` | 使用 CSGClaw 托管集合 | recreate 后写入对应 `mcp.servers` | + +阶段一不支持直接提交完整 OpenClaw `mcp` 对象。这样可以保持 API 稳定,并避免用户把 OpenClaw 其它 `mcp.*` 设置和 server 列表混在一起造成合并语义不清。 + +## 4. 后端设计 + +### 4.1 数据模型 + +不要把 MCP 配置放进 `RuntimeOptions`。当前 `runtime_options` 会原样出现在 API 和前端模型中,MCP 配置可能包含 `env`、`headers`、token 等 secret。 + +建议新增显式字段: + +```go +type Agent struct { + MCPConfig json.RawMessage `json:"mcp_config,omitempty"` +} +``` + +需要同步扩展: + +- `agent.Agent` +- `agent.persistedAgent` +- `agent.CreateAgentSpec` +- `agent.UpdateRequest` +- `apitypes.CreateAgentRequest` +- API response 的 redacted MCP summary +- participant agent binding 中的 create/update payload + +API response 不返回 `mcp_config` 原文,返回脱敏摘要: + +```json +{ + "mcp_configured": true, + "mcp_servers": [ + { + "name": "context7", + "kind": "stdio", + "command": "uvx", + "env_keys": ["CONTEXT7_API_KEY"] + }, + { + "name": "remote-search", + "kind": "http", + "url": "https://mcp.example.com/mcp", + "header_keys": ["Authorization"] + } + ] +} +``` + +### 4.2 ProvisionRequest 传递 + +在 runtime provision 边界加入 MCP 配置: + +```go +type ProvisionRequest struct { + MCPConfig json.RawMessage +} +``` + +调用链: + +```text +Agent.MCPConfig + -> Service.provisionRuntimeForAgent(...) + -> agentruntime.ProvisionRequest.MCPConfig + -> openclawsandbox.Runtime.Provision(...) + -> openclawsandbox.EnsureConfig(...) + -> renderConfig(...) + -> openclaw.json +``` + +`openclawsandbox.EnsureConfig` 新增参数后,`renderConfig` 在现有步骤之后追加: + +```text +updateOpenClawModelProvider +updateOpenClawCsgclawChannel +updateOpenClawFeishuChannel +updateOpenClawGatewayAuth +updateOpenClawMCP +``` + +`updateOpenClawMCP` 负责解析 CSGClaw 保存的 `mcp_config`,并写入 `cfg["mcp"]["servers"]`。 + +### 4.3 保存与重建 + +MCP 修改时不直接依赖 OpenClaw hot reload。 + +保存已有 agent 的 MCP 配置时: + +1. 校验 JSON 和 server entry。 +2. 持久化到 agent state。 +3. 如果 runtime kind 是 `openclaw_sandbox`,设置 `agent_profile.env_restart_required = true`。 +4. 不主动写运行中的 `/home/node/.openclaw/openclaw.json`。 +5. 不主动 stop running gateway。 + +生效方式: + +1. 用户点击“重建”或“保存并重建”。 +2. 后端调用现有 `POST /api/v1/agents/{id}/recreate`。 +3. `Service.Recreate` 删除旧 sandbox handle。 +4. `Provision` 使用最新 `Agent.MCPConfig` 重新写入宿主机 `openclaw.json`。 +5. 新 sandbox 挂载同一 `.openclaw` 目录并启动 OpenClaw gateway。 +6. `persistRecreatedAgent` 成功后清除 `env_restart_required`。 + +这不是重启 CSGClaw server,而是重建该 OpenClaw agent 的 sandbox/gateway。 + +### 4.4 与现有 profile 更新路径的关系 + +当前 OpenClaw profile runtime input 变化时,CSGClaw 已有 `syncGatewayHostConfig` 路径会重写 OpenClaw host config。MCP 阶段一不要复用这条热更新路径。 + +原因: + +- `EnsureConfig` 当前已有注释说明,运行中写 `openclaw.json` 会触发 OpenClaw hot reload,并可能遇到 gateway lock race。 +- MCP server 可能创建子进程、HTTP session、tool registry cache,热替换行为需要 OpenClaw 侧明确保证。 +- recreate 能用现有生命周期语义保证新 gateway 从干净状态加载 MCP。 + +## 5. API 设计 + +### 5.1 Create Agent + +`POST /api/v1/agents` 和 participant agent binding create payload 增加: + +```json +{ + "runtime_kind": "openclaw_sandbox", + "mcp_config": { + "mcpServers": { + "context7": { + "command": "uvx", + "args": ["context7-mcp"] + } + } + } +} +``` + +创建时 MCP 配置直接参与 provision,agent 首次启动后生效,不需要额外 recreate。 + +### 5.2 Update Agent + +`PATCH /api/v1/agents/{id}` 增加: + +```json +{ + "mcp_config": { + "mcpServers": { + "context7": { + "command": "uvx", + "args": ["context7-mcp"] + } + } + } +} +``` + +语义: + +- 字段缺失:不修改 MCP。 +- `mcp_config: null`:清除 MCP。 +- object:替换整个 MCP 配置。 + +成功返回的 agent response 应带: + +```json +{ + "agent_profile": { + "env_restart_required": true + }, + "mcp_configured": true, + "mcp_servers": [] +} +``` + +前端可继续使用已有 `env_restart_required` badge 和 recreate action。 + +### 5.3 可选 CLI + +后续可以给 CLI 增加: + +```bash +csgclaw agent create --name alice --runtime openclaw_sandbox --mcp-config-file ./mcp.json +csgclaw agent update alice --mcp-config-file ./mcp.json +csgclaw agent update alice --clear-mcp-config +``` + +不建议提供 `--mcp-config ''` 作为主入口,因为 secret 容易进入 shell history。若实现 inline 参数,也必须在帮助文本里提示风险。 + +## 6. 前端 UI 设计 + +### 6.1 页面位置 + +在现有 Agent 编辑路径中增加 OpenClaw 专属 MCP 区域: + +- 创建 agent modal:`AgentProfileModal` 的 runtime section 下,只有选择 `openclaw_sandbox` 时展示。 +- Agent 详情页:`AgentDetailPane` 的 runtime section 下,只有当前 agent 是 `openclaw_sandbox` 时展示。 + +不要把 MCP 做成通用 `RuntimeOptionsFields`。现有 runtime options 控件只支持简单 input/directory,无法承载 JSON 编辑、secret 脱敏、清除语义和 recreate 提示。 + +### 6.2 区域结构 + +建议新增一个页面私有或业务组件: + +```text +OpenClawMCPConfigPanel +``` + +区域内容: + +- 标题:`MCP Servers` +- 摘要表:server name、类型、command 或 URL、env/header keys。 +- JSON 编辑器:textarea 或轻量 code editor,保存前做 JSON parse。 +- 操作按钮:保存、保存并重建、清除配置、取消。 +- 状态提示:有未生效变更时显示“重建后生效”,并提供“重建”按钮。 + +创建 agent 时: + +- 展示空 JSON 编辑器。 +- 提供示例填充按钮。 +- 保存创建后直接生效,不显示“需要重建”。 + +编辑已有 agent 时: + +- 默认展示脱敏摘要。 +- JSON 编辑区为空,placeholder 表示留空不修改现有 MCP 配置。 +- 用户选择“替换配置”后输入完整 JSON。 +- 用户选择“清除配置”后提交 `mcp_config: null`。 +- 保存成功但未重建时,使用现有 `profileRestartRequired` badge,并在详情页 runtime section 展示重建 CTA。 + +### 6.3 Secret 处理 + +前端不应拿到原始 secret,因此不能默认把现有 MCP JSON 原样填回编辑器。 + +编辑体验采用“摘要 + 替换”: + +- 摘要表只显示 server 名称和非敏感字段。 +- `env` 只显示 key,不显示 value。 +- `headers` 只显示 key,不显示 value。 +- 如果用户要修改已有 server,需要重新提交完整 `mcp_config`。 +- 如果用户只想保留现有配置,保存其它 agent 字段时不发送 `mcp_config`。 + +### 6.4 重建交互 + +MCP 修改保存后,前端不自动刷新或假装生效。 + +推荐交互: + +1. 用户点击“保存”: + - 保存配置。 + - 返回 agent response。 + - 显示 `Recreate required` badge。 + - 在 MCP 区域显示“已保存,重建后生效”状态。 +2. 用户点击“保存并重建”: + - 先调用 update。 + - update 成功后调用 `POST /api/v1/agents/{id}/recreate`。 + - recreate 成功后刷新 agent 列表和详情。 +3. 用户点击已有 agent action 的“重建”: + - 复用现有 recreate action。 + - 成功后 `env_restart_required` 清除。 + +如果 agent 正在运行,保存 MCP 配置不停止当前 sandbox。当前 OpenClaw gateway 继续使用旧 MCP 配置,直到 recreate 完成。 + +## 7. 验证计划 + +后端测试: + +- `openclawsandbox.renderConfig` 能把 `mcp_config.mcpServers` 渲染为 `mcp.servers`。 +- `null`、字段缺失、空对象、空 `mcpServers` 的三态语义正确。 +- 非 object server entry 报错。 +- 缺少 `command` 和 `url` 的 server entry 报错。 +- MCP 配置变更后 `openclaw_sandbox` agent 标记 `env_restart_required`。 +- `Recreate` 后重新 provision,并把最新 MCP 写入 `openclaw.json`。 +- API response 不泄漏 `env` value、header value、token。 + +前端测试: + +- `AgentProfileModal` 只在 `openclaw_sandbox` 显示 MCP 区域。 +- `AgentDetailPane` 对 OpenClaw agent 显示 MCP 摘要和编辑入口。 +- 保存普通字段时不误提交 `mcp_config`。 +- 替换 MCP 时提交 object。 +- 清除 MCP 时提交 `null`。 +- 保存并重建按顺序调用 update 和 recreate。 + +手工验证: + +1. 创建 OpenClaw worker,带一个 stdio MCP server。 +2. 检查宿主机 `~/.csgclaw/agents//.openclaw/openclaw.json` 包含 `mcp.servers`。 +3. 启动 agent 后确认 OpenClaw gateway 能看到 MCP tool。 +4. 修改 MCP 配置并只保存,确认 UI 标记需要重建,旧 gateway 不被停止。 +5. 点击重建,确认新 `openclaw.json` 生效,`env_restart_required` 清除。 + +## 8. 实施顺序 + +1. 增加后端 MCP 数据模型、持久化和 API 脱敏摘要。 +2. 增加 MCP parser/validator 和 `openclawsandbox` 配置渲染。 +3. 扩展 provision request,把 agent MCP 配置传到 OpenClaw runtime。 +4. 增加 update 后标记 `env_restart_required` 的逻辑,但不触发 OpenClaw hot reload。 +5. 接入前端 Agent 创建/详情页 MCP UI。 +6. 增加 CLI `--mcp-config-file` 和 `--clear-mcp-config`。 +7. 补充 `docs/config.zh.md`、`docs/api.zh.md`、`docs/cli.zh.md`。 From 4fd02fe3f91be9f3523011525cb051a4dd64de41 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Fri, 26 Jun 2026 17:26:01 +0800 Subject: [PATCH 2/7] docs: generalize runtime mcp plan --- docs/openclaw-mcp-restart-plan.zh.md | 408 +++++++++++++++------------ 1 file changed, 227 insertions(+), 181 deletions(-) diff --git a/docs/openclaw-mcp-restart-plan.zh.md b/docs/openclaw-mcp-restart-plan.zh.md index 919e06cb..80a562bd 100644 --- a/docs/openclaw-mcp-restart-plan.zh.md +++ b/docs/openclaw-mcp-restart-plan.zh.md @@ -1,28 +1,29 @@ -# OpenClaw MCP 配置接入方案 +# 通用 Runtime MCP 配置接入方案 -本文档约束 CSGClaw 为 `openclaw_sandbox` worker 增加 MCP 能力的实现方案。阶段一采用“变更后需要重建”的策略:MCP 配置保存后先进入 CSGClaw 持久化状态,只有执行 agent recreate 后才写入并生效到新的 OpenClaw gateway 进程。 +本文档约束 CSGClaw 为 agent 增加 MCP 能力的实现方案。文件名保留 OpenClaw 历史语境,但方案本身按通用 runtime 能力设计:MCP 配置先存入 agent 的 `runtime_options.mcp`,再由各 runtime adapter 在 provision/recreate 阶段渲染为对应运行时的原生配置。 + +阶段一采用“变更后需要重建”的策略:MCP 配置保存后进入 CSGClaw 持久化状态,并标记 agent 需要重建;执行 agent recreate 后,新 runtime 进程加载最新 MCP 配置。暂不处理运行中配置写入触发的 hot reload race,只在关键路径打日志,方便后续分析。 ## 1. 目标与边界 目标: -- 只支持 `openclaw_sandbox` worker 的 MCP 配置。 -- MCP 配置由 CSGClaw 管理,并渲染到 OpenClaw 原生 `mcp.servers` 配置块。 -- 创建 agent 时 MCP 配置随 OpenClaw 初次启动直接生效。 +- 所有 runtime kind 使用统一 API 形态:`runtime_options.mcp`。 +- MCP 配置由 CSGClaw 管理,随 agent create/update 持久化。 +- 创建 agent 时,MCP 配置随首次 provision 生效。 - 修改已有 agent 的 MCP 配置后,标记“需要重建”,通过现有 agent recreate 流程生效。 -- API 和前端不回显 MCP secret 明文。 +- runtime adapter 负责把 `runtime_options.mcp` 转成原生配置;OpenClaw adapter 渲染到 `mcp.servers`。 非目标: -- 不在阶段一支持 PicoClaw MCP。 -- 不改 bootstrap manager 的运行时策略。 -- 不依赖 OpenClaw gateway 热加载 MCP。 -- 不把 MCP 写入 workspace 文件让 OpenClaw 扫描发现。 -- 不做 MCP marketplace、自动安装、连接测试或动态发现。 +- 阶段一不做 MCP marketplace、自动安装、连接测试或动态发现。 +- 阶段一不依赖任一 runtime 的 MCP hot reload 保证。 +- 阶段一不新增 CLI MCP 参数或 `agent update` 子命令。 +- 暂不处理 MCP secret 脱敏体验;API 和前端沿用 `runtime_options` 的现有回显语义。 ## 2. 当前 OpenClaw 配置注入路径 -CSGClaw 当前已经通过配置文件注入 OpenClaw 运行参数,路径如下: +OpenClaw 是第一条需要落地的 runtime adapter。CSGClaw 当前已经通过配置文件注入 OpenClaw 运行参数,路径如下: ```text Agent create/recreate provisioning @@ -71,34 +72,39 @@ node /app/openclaw.mjs gateway --allow-unconfigured --bind lan --port 18789 /home/node/.openclaw/openclaw.json ``` -结论:CSGClaw 的 OpenClaw 配置注入不是把文件写进 workspace,也不是通过 task wrapper 或 `OPENCLAW_CONFIG_PATH` 指向临时文件,而是维护每个 agent 的长期 OpenClaw 配置根目录,并通过 sandbox mount 让 OpenClaw gateway 按默认路径读取。 +结论:OpenClaw MCP adapter 不需要写 workspace 文件,也不需要通过 task wrapper 或 `OPENCLAW_CONFIG_PATH` 指向临时文件;它应该继续维护每个 agent 的长期 OpenClaw 配置根目录,并在 provision/recreate 时把 `runtime_options.mcp` 渲染进 `openclaw.json`。 ## 3. MCP 配置形态 -CSGClaw API 建议接收与 Multica 一致的输入形态: +CSGClaw API 接收与 Multica 一致的 MCP server 输入形态,但位置放在 `runtime_options.mcp`: ```json { - "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" + "runtime_kind": "openclaw_sandbox", + "runtime_options": { + "mcp": { + "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" + } + } } } } } ``` -渲染到 OpenClaw 时转换为 OpenClaw 原生配置: +OpenClaw adapter 渲染到 OpenClaw 原生配置: ```json { @@ -116,95 +122,91 @@ CSGClaw API 建议接收与 Multica 一致的输入形态: } ``` -约束: +通用约束: +- 持久化后的 `runtime_options.mcp` 缺失表示该 agent 没有 CSGClaw 管理的 MCP 配置。 +- API update 的字段缺失语义以第 5.2 节为准:`runtime_options` 整体缺失才表示不修改;`runtime_options` 一旦提交就是整体替换。 +- `runtime_options.mcp: null` 表示清除 CSGClaw 管理的 MCP 配置。 +- `runtime_options.mcp: {}` 或 `{"mcpServers": {}}` 表示显式托管空集合。 +- 非空 `mcpServers` 表示使用 CSGClaw 托管集合。 - 每个 server entry 必须是 JSON object。 - 每个 server entry 必须声明 `command` 或 `url`。 - stdio server 使用 `command`、`args`、`env`。 -- HTTP/SSE server 使用 `url`,并直接使用 OpenClaw 识别的 `transport`,例如 `streamable-http`,不自动把 Claude 的 `type` 翻译成 OpenClaw 字段。 -- server 名称排序后写入,保证 `openclaw.json` 输出稳定。 -- MCP command 在 OpenClaw sandbox 内执行,不在宿主机执行;需要的二进制必须在镜像内存在,或通过 workspace/projects 等已有挂载路径可访问。 +- HTTP/SSE server 使用 `url`,并直接使用目标 runtime 识别的 `transport`。 +- server 名称排序后写入,保证生成配置稳定。 -三态语义: +OpenClaw 约束: -| 输入 | 语义 | 渲染结果 | -| --- | --- | --- | -| 字段缺失 | 保持现有 MCP 配置不变 | update 时不修改保存值 | -| `null` | 清除 CSGClaw 管理的 MCP 配置 | recreate 后不写 `mcp.servers` | -| `{}` 或 `{"mcpServers": {}}` | 显式托管空集合 | recreate 后写入 `mcp.servers: {}` | -| 非空 `mcpServers` | 使用 CSGClaw 托管集合 | recreate 后写入对应 `mcp.servers` | - -阶段一不支持直接提交完整 OpenClaw `mcp` 对象。这样可以保持 API 稳定,并避免用户把 OpenClaw 其它 `mcp.*` 设置和 server 列表混在一起造成合并语义不清。 +- `runtime_options.mcp.mcpServers` 渲染为 OpenClaw `mcp.servers`。 +- 不支持直接提交完整 OpenClaw `mcp` 对象,避免用户把 OpenClaw 其它 `mcp.*` 设置和 server 列表混在一起造成合并语义不清。 +- MCP command 在 OpenClaw sandbox 内执行,不在宿主机执行;需要的二进制必须在镜像内存在,或通过 workspace/projects 等已有挂载路径可访问。 ## 4. 后端设计 ### 4.1 数据模型 -不要把 MCP 配置放进 `RuntimeOptions`。当前 `runtime_options` 会原样出现在 API 和前端模型中,MCP 配置可能包含 `env`、`headers`、token 等 secret。 +不要新增 top-level `Agent.MCPConfig`。阶段一把 MCP 配置放在 `Agent.RuntimeOptions["mcp"]`,保持和已有 runtime option 架构一致。 -建议新增显式字段: +需要同步处理: -```go -type Agent struct { - MCPConfig json.RawMessage `json:"mcp_config,omitempty"` -} -``` +- `agent.CreateAgentSpec.RuntimeOptions` +- `agent.UpdateRequest.RuntimeOptions` +- `apitypes.CreateAgentRequest.RuntimeOptions` +- participant agent binding 中的 create payload +- Web `AgentLike.runtime_options` +- runtime option 保存/合并逻辑 -需要同步扩展: +注意:当前 `PATCH /api/v1/agents/{id}` 的 `runtime_options` 是整体替换语义。前端保存 MCP 时必须基于当前 agent 的 `runtime_options` 合并后提交,避免只提交 `{"mcp": ...}` 时覆盖其它 runtime option,例如 Codex 的 `local_workspace_dir`。 -- `agent.Agent` -- `agent.persistedAgent` -- `agent.CreateAgentSpec` -- `agent.UpdateRequest` -- `apitypes.CreateAgentRequest` -- API response 的 redacted MCP summary -- participant agent binding 中的 create/update payload +建议增加共享 helper: -API response 不返回 `mcp_config` 原文,返回脱敏摘要: +```go +const RuntimeOptionMCPKey = "mcp" -```json -{ - "mcp_configured": true, - "mcp_servers": [ - { - "name": "context7", - "kind": "stdio", - "command": "uvx", - "env_keys": ["CONTEXT7_API_KEY"] - }, - { - "name": "remote-search", - "kind": "http", - "url": "https://mcp.example.com/mcp", - "header_keys": ["Authorization"] - } - ] -} +func ExtractMCPConfig(runtimeOptions map[string]any) (MCPConfigState, error) +func RuntimeOptionsWithMCP(runtimeOptions map[string]any, mcp any) map[string]any ``` +`MCPConfigState` 至少区分: + +- absent +- cleared/null +- empty managed set +- managed server set + ### 4.2 ProvisionRequest 传递 -在 runtime provision 边界加入 MCP 配置: +因为 MCP 已放入 `runtime_options`,provision 边界应传递通用 runtime options,而不是新增 MCP 专用字段: ```go type ProvisionRequest struct { - MCPConfig json.RawMessage + RuntimeOptions map[string]any } ``` 调用链: ```text -Agent.MCPConfig +Agent.RuntimeOptions["mcp"] -> Service.provisionRuntimeForAgent(...) - -> agentruntime.ProvisionRequest.MCPConfig + -> agentruntime.ProvisionRequest.RuntimeOptions + -> concrete runtime Provision(...) + -> runtime MCP adapter + -> native runtime config +``` + +OpenClaw adapter 的调用链: + +```text +runtime_options.mcp -> openclawsandbox.Runtime.Provision(...) -> openclawsandbox.EnsureConfig(...) -> renderConfig(...) - -> openclaw.json + -> updateOpenClawMCP(...) + -> ~/.csgclaw/agents//.openclaw/openclaw.json ``` -`openclawsandbox.EnsureConfig` 新增参数后,`renderConfig` 在现有步骤之后追加: +`openclawsandbox.EnsureConfig` 新增 runtime options 参数后,`renderConfig` 在现有步骤之后追加: ```text updateOpenClawModelProvider @@ -214,55 +216,97 @@ updateOpenClawGatewayAuth updateOpenClawMCP ``` -`updateOpenClawMCP` 负责解析 CSGClaw 保存的 `mcp_config`,并写入 `cfg["mcp"]["servers"]`。 +### 4.3 Runtime adapter 策略 + +每个 runtime kind 都通过同一个 `runtime_options.mcp` 输入获得 MCP 配置,但落地方式由 runtime adapter 决定。 -### 4.3 保存与重建 +建议定义 runtime MCP policy: + +```go +type RuntimeMCPPolicy interface { + ValidateMCPConfig(runtimeOptions map[string]any) error + MCPRestartRequired(previous, current map[string]any) bool +} +``` -MCP 修改时不直接依赖 OpenClaw hot reload。 +对 provisioner runtime,adapter 在 `Provision` 期间渲染原生配置。对非 provisioner runtime,如果 MCP 需要写入启动前配置,也应把写入逻辑放在 runtime 自己的 host-side preparation 路径中,不要散落到 API handler 或 agent service。 + +阶段一默认策略: + +- `runtime_options.mcp` 变更后需要重建。 +- 不依赖 runtime hot reload。 +- 如果某个 runtime adapter 已确认支持安全热应用,可以后续单独改 policy。 + +OpenClaw adapter: + +- 渲染到 `openclaw.json` 的 `mcp.servers`。 +- `null` 清除 CSGClaw 管理的 `mcp.servers`。 +- `{}` 或空 `mcpServers` 写入 `mcp.servers: {}`。 + +PicoClaw/Codex/其它 runtime: + +- 复用相同 `runtime_options.mcp` 输入和校验。 +- 由对应 runtime adapter 渲染为其原生 MCP 配置或启动参数。 +- 不允许静默忽略已保存 MCP 配置;如果 adapter 暂无可生效路径,应在 validate/provision 阶段返回明确错误,避免用户误以为配置已经生效。 + +### 4.4 保存与重建 保存已有 agent 的 MCP 配置时: -1. 校验 JSON 和 server entry。 -2. 持久化到 agent state。 -3. 如果 runtime kind 是 `openclaw_sandbox`,设置 `agent_profile.env_restart_required = true`。 -4. 不主动写运行中的 `/home/node/.openclaw/openclaw.json`。 -5. 不主动 stop running gateway。 +1. 合并并校验新的 `runtime_options`。 +2. 校验 `runtime_options.mcp` 的 JSON 和 server entry。 +3. 持久化到 agent state。 +4. 如果 `runtime_options.mcp` 发生变化,设置 `agent_profile.env_restart_required = true`。 +5. 不主动 stop running runtime。 +6. 不依赖 runtime hot reload。 生效方式: 1. 用户点击“重建”或“保存并重建”。 2. 后端调用现有 `POST /api/v1/agents/{id}/recreate`。 -3. `Service.Recreate` 删除旧 sandbox handle。 -4. `Provision` 使用最新 `Agent.MCPConfig` 重新写入宿主机 `openclaw.json`。 -5. 新 sandbox 挂载同一 `.openclaw` 目录并启动 OpenClaw gateway。 +3. `Service.Recreate` 删除旧 runtime handle。 +4. `Provision` 使用最新 `Agent.RuntimeOptions` 重新生成 runtime 原生配置。 +5. 新 runtime 进程启动并加载 MCP。 6. `persistRecreatedAgent` 成功后清除 `env_restart_required`。 -这不是重启 CSGClaw server,而是重建该 OpenClaw agent 的 sandbox/gateway。 +这不是重启 CSGClaw server,而是重建该 agent 的 runtime 进程。 + +### 4.5 与现有 profile 热同步路径的关系 -### 4.4 与现有 profile 更新路径的关系 +当前 gateway runtime 的 profile runtime input 变化时,CSGClaw 已有 `syncGatewayHostConfig` 路径会重写 host config。阶段一暂不处理这条路径和 MCP 变更之间的 hot reload race。 -当前 OpenClaw profile runtime input 变化时,CSGClaw 已有 `syncGatewayHostConfig` 路径会重写 OpenClaw host config。MCP 阶段一不要复用这条热更新路径。 +实现要求: -原因: +- 保持现有 `syncGatewayHostConfig` 行为。 +- 不因为 MCP pending 状态阻塞 profile 配置热同步。 +- 在 `syncGatewayHostConfig` 即将写 host config 时,如果当前 agent 配置了 `runtime_options.mcp` 或 `env_restart_required = true`,打印结构化日志。 +- 日志至少包含 `agent_id`、`agent_name`、`runtime_kind`、`mcp_configured`、`env_restart_required`、`stage`。 +- 日志不要求解决 race,只要求后续能定位“保存 MCP 后是否又发生了 host config 写入”。 -- `EnsureConfig` 当前已有注释说明,运行中写 `openclaw.json` 会触发 OpenClaw hot reload,并可能遇到 gateway lock race。 -- MCP server 可能创建子进程、HTTP session、tool registry cache,热替换行为需要 OpenClaw 侧明确保证。 -- recreate 能用现有生命周期语义保证新 gateway 从干净状态加载 MCP。 +建议日志点: + +- MCP 配置保存成功。 +- MCP 配置清除成功。 +- runtime provision 开始渲染 MCP。 +- runtime provision 写入原生配置成功。 +- `syncGatewayHostConfig` 在 MCP configured 或 restart required 状态下执行。 ## 5. API 设计 ### 5.1 Create Agent -`POST /api/v1/agents` 和 participant agent binding create payload 增加: +`POST /api/v1/agents` 和 participant agent binding create payload 使用现有 `runtime_options`: ```json { "runtime_kind": "openclaw_sandbox", - "mcp_config": { - "mcpServers": { - "context7": { - "command": "uvx", - "args": ["context7-mcp"] + "runtime_options": { + "mcp": { + "mcpServers": { + "context7": { + "command": "uvx", + "args": ["context7-mcp"] + } } } } @@ -273,15 +317,17 @@ MCP 修改时不直接依赖 OpenClaw hot reload。 ### 5.2 Update Agent -`PATCH /api/v1/agents/{id}` 增加: +`PATCH /api/v1/agents/{id}` 继续使用 `runtime_options`: ```json { - "mcp_config": { - "mcpServers": { - "context7": { - "command": "uvx", - "args": ["context7-mcp"] + "runtime_options": { + "mcp": { + "mcpServers": { + "context7": { + "command": "uvx", + "args": ["context7-mcp"] + } } } } @@ -290,59 +336,68 @@ MCP 修改时不直接依赖 OpenClaw hot reload。 语义: -- 字段缺失:不修改 MCP。 -- `mcp_config: null`:清除 MCP。 -- object:替换整个 MCP 配置。 +- `runtime_options` 字段缺失:不修改 runtime options,也不修改 MCP。 +- `runtime_options` 字段存在:整体替换当前 runtime options。 +- `runtime_options.mcp` 缺失:提交后的 runtime options 不再包含 MCP。 +- `runtime_options.mcp: null`:清除 MCP。 +- `runtime_options.mcp` 为 object:替换整个 MCP 配置。 -成功返回的 agent response 应带: +因此前端保存 MCP 时应总是提交合并后的完整 `runtime_options`: ```json { - "agent_profile": { - "env_restart_required": true - }, - "mcp_configured": true, - "mcp_servers": [] + "runtime_options": { + "local_workspace_dir": "/path/kept-from-existing-options", + "mcp": { + "mcpServers": { + "context7": { + "command": "uvx", + "args": ["context7-mcp"] + } + } + } + } } ``` -前端可继续使用已有 `env_restart_required` badge 和 recreate action。 - -### 5.3 可选 CLI +成功返回沿用现有 agent response,`runtime_options` 中包含当前 MCP 配置: -后续可以给 CLI 增加: - -```bash -csgclaw agent create --name alice --runtime openclaw_sandbox --mcp-config-file ./mcp.json -csgclaw agent update alice --mcp-config-file ./mcp.json -csgclaw agent update alice --clear-mcp-config +```json +{ + "agent_profile": { + "env_restart_required": true + }, + "runtime_options": { + "mcp": { + "mcpServers": {} + } + } +} ``` -不建议提供 `--mcp-config ''` 作为主入口,因为 secret 容易进入 shell history。若实现 inline 参数,也必须在帮助文本里提示风险。 +前端继续使用已有 `env_restart_required` badge 和 recreate action。 ## 6. 前端 UI 设计 ### 6.1 页面位置 -在现有 Agent 编辑路径中增加 OpenClaw 专属 MCP 区域: +在现有 Agent 创建和编辑路径中增加通用 MCP 区域: -- 创建 agent modal:`AgentProfileModal` 的 runtime section 下,只有选择 `openclaw_sandbox` 时展示。 -- Agent 详情页:`AgentDetailPane` 的 runtime section 下,只有当前 agent 是 `openclaw_sandbox` 时展示。 +- 创建 agent modal:`AgentProfileModal` 的 runtime section 下展示。 +- Agent 详情页:`AgentDetailPane` 的 runtime section 下展示。 +- 控件读写 `draft.runtime_options.mcp`,不引入 top-level `mcp_config` 状态。 -不要把 MCP 做成通用 `RuntimeOptionsFields`。现有 runtime options 控件只支持简单 input/directory,无法承载 JSON 编辑、secret 脱敏、清除语义和 recreate 提示。 - -### 6.2 区域结构 - -建议新增一个页面私有或业务组件: +MCP 区域可以做成专用组件,但它必须作为 runtime options 的编辑器存在: ```text -OpenClawMCPConfigPanel +MCPRuntimeOptionsPanel ``` +### 6.2 区域结构 + 区域内容: - 标题:`MCP Servers` -- 摘要表:server name、类型、command 或 URL、env/header keys。 - JSON 编辑器:textarea 或轻量 code editor,保存前做 JSON parse。 - 操作按钮:保存、保存并重建、清除配置、取消。 - 状态提示:有未生效变更时显示“重建后生效”,并提供“重建”按钮。 @@ -355,32 +410,19 @@ OpenClawMCPConfigPanel 编辑已有 agent 时: -- 默认展示脱敏摘要。 -- JSON 编辑区为空,placeholder 表示留空不修改现有 MCP 配置。 -- 用户选择“替换配置”后输入完整 JSON。 -- 用户选择“清除配置”后提交 `mcp_config: null`。 +- 默认把现有 `runtime_options.mcp` 填回编辑器。 +- 用户保存时,前端将编辑后的 MCP 合并回完整 `runtime_options` 后提交。 +- 用户选择“清除配置”后,提交合并后的 `runtime_options`,其中 `mcp` 为 `null` 或删除 `mcp` key。 - 保存成功但未重建时,使用现有 `profileRestartRequired` badge,并在详情页 runtime section 展示重建 CTA。 -### 6.3 Secret 处理 - -前端不应拿到原始 secret,因此不能默认把现有 MCP JSON 原样填回编辑器。 - -编辑体验采用“摘要 + 替换”: - -- 摘要表只显示 server 名称和非敏感字段。 -- `env` 只显示 key,不显示 value。 -- `headers` 只显示 key,不显示 value。 -- 如果用户要修改已有 server,需要重新提交完整 `mcp_config`。 -- 如果用户只想保留现有配置,保存其它 agent 字段时不发送 `mcp_config`。 - -### 6.4 重建交互 +### 6.3 重建交互 MCP 修改保存后,前端不自动刷新或假装生效。 推荐交互: 1. 用户点击“保存”: - - 保存配置。 + - 保存合并后的 `runtime_options`。 - 返回 agent response。 - 显示 `Recreate required` badge。 - 在 MCP 区域显示“已保存,重建后生效”状态。 @@ -392,43 +434,47 @@ MCP 修改保存后,前端不自动刷新或假装生效。 - 复用现有 recreate action。 - 成功后 `env_restart_required` 清除。 -如果 agent 正在运行,保存 MCP 配置不停止当前 sandbox。当前 OpenClaw gateway 继续使用旧 MCP 配置,直到 recreate 完成。 +如果 agent 正在运行,保存 MCP 配置不停止当前 runtime。当前 runtime 继续使用旧 MCP 配置,直到 recreate 完成。 ## 7. 验证计划 后端测试: -- `openclawsandbox.renderConfig` 能把 `mcp_config.mcpServers` 渲染为 `mcp.servers`。 -- `null`、字段缺失、空对象、空 `mcpServers` 的三态语义正确。 +- `runtime_options.mcp` 的缺失、`null`、空对象、空 `mcpServers`、非空 `mcpServers` 语义正确。 - 非 object server entry 报错。 - 缺少 `command` 和 `url` 的 server entry 报错。 -- MCP 配置变更后 `openclaw_sandbox` agent 标记 `env_restart_required`。 -- `Recreate` 后重新 provision,并把最新 MCP 写入 `openclaw.json`。 -- API response 不泄漏 `env` value、header value、token。 +- 保存 MCP 时保留其它 runtime options。 +- MCP 配置变更后 agent 标记 `env_restart_required`。 +- `Recreate` 后重新 provision,并把最新 MCP 传给 runtime adapter。 +- `openclawsandbox.renderConfig` 能把 `runtime_options.mcp.mcpServers` 渲染为 `mcp.servers`。 +- OpenClaw `null` 清除和空集合渲染行为正确。 +- `syncGatewayHostConfig` 在 MCP configured 或 restart required 状态下会打出可诊断日志。 前端测试: -- `AgentProfileModal` 只在 `openclaw_sandbox` 显示 MCP 区域。 -- `AgentDetailPane` 对 OpenClaw agent 显示 MCP 摘要和编辑入口。 -- 保存普通字段时不误提交 `mcp_config`。 -- 替换 MCP 时提交 object。 -- 清除 MCP 时提交 `null`。 +- `AgentProfileModal` 能编辑 `runtime_options.mcp`。 +- `AgentDetailPane` 能显示和编辑 `runtime_options.mcp`。 +- 保存 MCP 时不覆盖其它 runtime options。 +- 替换 MCP 时提交合并后的 `runtime_options`。 +- 清除 MCP 时提交合并后的 `runtime_options`。 - 保存并重建按顺序调用 update 和 recreate。 手工验证: -1. 创建 OpenClaw worker,带一个 stdio MCP server。 -2. 检查宿主机 `~/.csgclaw/agents//.openclaw/openclaw.json` 包含 `mcp.servers`。 -3. 启动 agent 后确认 OpenClaw gateway 能看到 MCP tool。 -4. 修改 MCP 配置并只保存,确认 UI 标记需要重建,旧 gateway 不被停止。 -5. 点击重建,确认新 `openclaw.json` 生效,`env_restart_required` 清除。 +1. 创建 agent,带一个 stdio MCP server。 +2. 对 OpenClaw agent,检查宿主机 `~/.csgclaw/agents//.openclaw/openclaw.json` 包含 `mcp.servers`。 +3. 启动 agent 后确认 runtime 能看到 MCP tool。 +4. 修改 MCP 配置并只保存,确认 UI 标记需要重建,旧 runtime 不被停止。 +5. 点击重建,确认新配置生效,`env_restart_required` 清除。 +6. 在 MCP pending 状态下修改 profile,确认日志能看到 host config sync 记录。 ## 8. 实施顺序 -1. 增加后端 MCP 数据模型、持久化和 API 脱敏摘要。 -2. 增加 MCP parser/validator 和 `openclawsandbox` 配置渲染。 -3. 扩展 provision request,把 agent MCP 配置传到 OpenClaw runtime。 -4. 增加 update 后标记 `env_restart_required` 的逻辑,但不触发 OpenClaw hot reload。 -5. 接入前端 Agent 创建/详情页 MCP UI。 -6. 增加 CLI `--mcp-config-file` 和 `--clear-mcp-config`。 -7. 补充 `docs/config.zh.md`、`docs/api.zh.md`、`docs/cli.zh.md`。 +1. 增加 `runtime_options.mcp` parser/validator 和共享 helper。 +2. 扩展 provision request,把 `Agent.RuntimeOptions` 传到 runtime provision 边界。 +3. 增加通用 MCP restart policy:`runtime_options.mcp` 变化后标记 `env_restart_required`。 +4. 增加关键路径结构化日志,尤其是 MCP save/provision 和 gateway host config sync。 +5. 实现 OpenClaw adapter,把 `runtime_options.mcp.mcpServers` 渲染到 `openclaw.json` 的 `mcp.servers`。 +6. 接入其它 runtime adapter 的 MCP 原生渲染路径;没有可生效路径时返回明确错误,不静默忽略。 +7. 接入前端 Agent 创建/详情页 MCP runtime options UI。 +8. 补充 `docs/api.zh.md` 和相关英文 API 文档。 From 908a1d2baf86dfc88c4032e96baa8f75a02e344a Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Fri, 26 Jun 2026 17:58:49 +0800 Subject: [PATCH 3/7] docs: update mcp plan for automatic recreate --- docs/openclaw-mcp-restart-plan.zh.md | 104 +++++++++++++++------------ 1 file changed, 60 insertions(+), 44 deletions(-) diff --git a/docs/openclaw-mcp-restart-plan.zh.md b/docs/openclaw-mcp-restart-plan.zh.md index 80a562bd..6537519a 100644 --- a/docs/openclaw-mcp-restart-plan.zh.md +++ b/docs/openclaw-mcp-restart-plan.zh.md @@ -2,7 +2,7 @@ 本文档约束 CSGClaw 为 agent 增加 MCP 能力的实现方案。文件名保留 OpenClaw 历史语境,但方案本身按通用 runtime 能力设计:MCP 配置先存入 agent 的 `runtime_options.mcp`,再由各 runtime adapter 在 provision/recreate 阶段渲染为对应运行时的原生配置。 -阶段一采用“变更后需要重建”的策略:MCP 配置保存后进入 CSGClaw 持久化状态,并标记 agent 需要重建;执行 agent recreate 后,新 runtime 进程加载最新 MCP 配置。暂不处理运行中配置写入触发的 hot reload race,只在关键路径打日志,方便后续分析。 +阶段一沿用现有 gateway runtime 配置变更路径:MCP 配置保存后进入 CSGClaw 持久化状态,并立即触发现有 agent recreate 流程;recreate 成功后,新 runtime 进程加载最新 MCP 配置,`env_restart_required` 被清除。recreate 失败时,已保存的 MCP 配置保留在 agent state 中,`env_restart_required` 继续为 true,用户可通过现有“重建”动作重试。阶段一不依赖 runtime hot reload。 ## 1. 目标与边界 @@ -11,7 +11,7 @@ - 所有 runtime kind 使用统一 API 形态:`runtime_options.mcp`。 - MCP 配置由 CSGClaw 管理,随 agent create/update 持久化。 - 创建 agent 时,MCP 配置随首次 provision 生效。 -- 修改已有 agent 的 MCP 配置后,标记“需要重建”,通过现有 agent recreate 流程生效。 +- 修改已有 agent 的 MCP 配置后,后端通过现有 agent recreate 流程自动生效。 - runtime adapter 负责把 `runtime_options.mcp` 转成原生配置;OpenClaw adapter 渲染到 `mcp.servers`。 非目标: @@ -233,7 +233,10 @@ type RuntimeMCPPolicy interface { 阶段一默认策略: -- `runtime_options.mcp` 变更后需要重建。 +- `runtime_options.mcp` 变更后需要通过 recreate 生效。 +- 后端保存已有 agent 的 MCP 配置后,立即调用现有 recreate 流程。 +- recreate 成功后清除 `env_restart_required`。 +- recreate 失败时保留 `env_restart_required = true`,让用户可通过现有重建动作重试。 - 不依赖 runtime hot reload。 - 如果某个 runtime adapter 已确认支持安全热应用,可以后续单独改 policy。 @@ -249,39 +252,43 @@ PicoClaw/Codex/其它 runtime: - 由对应 runtime adapter 渲染为其原生 MCP 配置或启动参数。 - 不允许静默忽略已保存 MCP 配置;如果 adapter 暂无可生效路径,应在 validate/provision 阶段返回明确错误,避免用户误以为配置已经生效。 -### 4.4 保存与重建 +### 4.4 保存与自动重建 保存已有 agent 的 MCP 配置时: 1. 合并并校验新的 `runtime_options`。 2. 校验 `runtime_options.mcp` 的 JSON 和 server entry。 3. 持久化到 agent state。 -4. 如果 `runtime_options.mcp` 发生变化,设置 `agent_profile.env_restart_required = true`。 -5. 不主动 stop running runtime。 -6. 不依赖 runtime hot reload。 +4. 如果 `runtime_options.mcp` 发生变化,设置 `agent_profile.env_restart_required = true`,作为持久化重建标记。 +5. 对支持 MCP 渲染的 gateway runtime,立即调用现有 `Service.Recreate`。 +6. `Recreate` 成功后,新 runtime 进程加载最新 MCP 配置,`persistRecreatedAgent` 清除 `env_restart_required`。 +7. `Recreate` 失败时,返回错误;已保存 MCP 配置保留,`env_restart_required` 保留为 true,用户可通过现有 recreate action 重试。 +8. 不依赖 runtime hot reload。 生效方式: -1. 用户点击“重建”或“保存并重建”。 -2. 后端调用现有 `POST /api/v1/agents/{id}/recreate`。 -3. `Service.Recreate` 删除旧 runtime handle。 -4. `Provision` 使用最新 `Agent.RuntimeOptions` 重新生成 runtime 原生配置。 -5. 新 runtime 进程启动并加载 MCP。 -6. `persistRecreatedAgent` 成功后清除 `env_restart_required`。 +1. 用户保存 MCP 配置。 +2. 后端持久化最新 `Agent.RuntimeOptions`。 +3. 后端调用现有 `Service.Recreate`。 +4. `Service.Recreate` 删除旧 runtime handle。 +5. `Provision` 使用最新 `Agent.RuntimeOptions` 重新生成 runtime 原生配置。 +6. 新 runtime 进程启动并加载 MCP。 +7. `persistRecreatedAgent` 成功后清除 `env_restart_required`。 这不是重启 CSGClaw server,而是重建该 agent 的 runtime 进程。 ### 4.5 与现有 profile 热同步路径的关系 -当前 gateway runtime 的 profile runtime input 变化时,CSGClaw 已有 `syncGatewayHostConfig` 路径会重写 host config。阶段一暂不处理这条路径和 MCP 变更之间的 hot reload race。 +当前 gateway runtime 的 profile runtime input 变化时,CSGClaw 已有 `syncGatewayHostConfig` 路径会重写 host config。因为 OpenClaw 配置是从 embedded defaults 重新渲染,而不是读旧文件做增量修改,所以该路径也必须接收当前 agent 的 `runtime_options`,并在重写 host config 时保留/渲染 `runtime_options.mcp`。否则 profile 热同步会把已经生效的 `mcp.servers` 覆盖掉。 实现要求: -- 保持现有 `syncGatewayHostConfig` 行为。 -- 不因为 MCP pending 状态阻塞 profile 配置热同步。 -- 在 `syncGatewayHostConfig` 即将写 host config 时,如果当前 agent 配置了 `runtime_options.mcp` 或 `env_restart_required = true`,打印结构化日志。 +- 保持现有 `syncGatewayHostConfig` 的 profile 热同步能力。 +- `syncGatewayHostConfig` 调用 OpenClaw/PicoClaw `EnsureConfig` 时必须传入当前 `Agent.RuntimeOptions`。 +- `EnsureConfig`/`renderConfig` 每次写 host config 都应根据当前 `runtime_options.mcp` 渲染 runtime 原生 MCP 配置。 +- 如果当前 agent 配置了 `runtime_options.mcp` 或 `env_restart_required = true`,打印结构化日志。 - 日志至少包含 `agent_id`、`agent_name`、`runtime_kind`、`mcp_configured`、`env_restart_required`、`stage`。 -- 日志不要求解决 race,只要求后续能定位“保存 MCP 后是否又发生了 host config 写入”。 +- 日志用于定位“保存 MCP 后是否又发生了 host config 写入”,但正确性必须由重新渲染 MCP 保证。 建议日志点: @@ -290,6 +297,8 @@ PicoClaw/Codex/其它 runtime: - runtime provision 开始渲染 MCP。 - runtime provision 写入原生配置成功。 - `syncGatewayHostConfig` 在 MCP configured 或 restart required 状态下执行。 +- recreate 成功。 +- recreate 失败且保留 `env_restart_required`。 ## 5. API 设计 @@ -360,12 +369,12 @@ PicoClaw/Codex/其它 runtime: } ``` -成功返回沿用现有 agent response,`runtime_options` 中包含当前 MCP 配置: +成功返回沿用现有 agent response,`runtime_options` 中包含当前 MCP 配置。已有 agent 的 MCP 变更会在返回前尝试自动 recreate;如果 recreate 成功,`env_restart_required` 为 false 或省略: ```json { "agent_profile": { - "env_restart_required": true + "env_restart_required": false }, "runtime_options": { "mcp": { @@ -375,7 +384,7 @@ PicoClaw/Codex/其它 runtime: } ``` -前端继续使用已有 `env_restart_required` badge 和 recreate action。 +如果 update 已持久化但 recreate 失败,后端返回错误;前端应刷新 agent 状态并继续使用已有 `env_restart_required` badge 和 recreate action 作为重试入口。 ## 6. 前端 UI 设计 @@ -399,8 +408,8 @@ MCPRuntimeOptionsPanel - 标题:`MCP Servers` - JSON 编辑器:textarea 或轻量 code editor,保存前做 JSON parse。 -- 操作按钮:保存、保存并重建、清除配置、取消。 -- 状态提示:有未生效变更时显示“重建后生效”,并提供“重建”按钮。 +- 操作按钮:保存、清除配置、取消。 +- 状态提示:保存时提示会自动重建 runtime;如果后端返回 recreate 失败,刷新 agent 并显示现有 `env_restart_required` badge 和“重建”按钮。 创建 agent 时: @@ -413,28 +422,30 @@ MCPRuntimeOptionsPanel - 默认把现有 `runtime_options.mcp` 填回编辑器。 - 用户保存时,前端将编辑后的 MCP 合并回完整 `runtime_options` 后提交。 - 用户选择“清除配置”后,提交合并后的 `runtime_options`,其中 `mcp` 为 `null` 或删除 `mcp` key。 -- 保存成功但未重建时,使用现有 `profileRestartRequired` badge,并在详情页 runtime section 展示重建 CTA。 +- 保存成功表示后端已完成自动 recreate 并刷新为新 runtime。 +- 保存失败时刷新 agent;如果 `env_restart_required` 为 true,使用现有 `profileRestartRequired` badge,并在详情页 runtime section 展示重建 CTA。 -### 6.3 重建交互 +### 6.3 自动重建交互 -MCP 修改保存后,前端不自动刷新或假装生效。 +MCP 修改保存后,后端负责自动重建 runtime;前端不再提供专门的“保存并重建”主流程。 推荐交互: 1. 用户点击“保存”: - 保存合并后的 `runtime_options`。 - - 返回 agent response。 - - 显示 `Recreate required` badge。 - - 在 MCP 区域显示“已保存,重建后生效”状态。 -2. 用户点击“保存并重建”: - - 先调用 update。 - - update 成功后调用 `POST /api/v1/agents/{id}/recreate`。 - - recreate 成功后刷新 agent 列表和详情。 + - 后端自动调用 recreate。 + - update 成功返回 agent response。 + - 刷新 agent 列表和详情。 + - 不显示 `Recreate required` badge。 +2. update 返回错误: + - 展示错误。 + - 刷新 agent 列表和详情。 + - 如果刷新后的 agent 仍有 `env_restart_required`,显示现有重建 CTA。 3. 用户点击已有 agent action 的“重建”: - 复用现有 recreate action。 - 成功后 `env_restart_required` 清除。 -如果 agent 正在运行,保存 MCP 配置不停止当前 runtime。当前 runtime 继续使用旧 MCP 配置,直到 recreate 完成。 +如果 agent 正在运行,保存 MCP 配置会触发 runtime recreate。当前路径会重建该 agent 的 runtime 进程,不依赖 hot reload。 ## 7. 验证计划 @@ -444,11 +455,13 @@ MCP 修改保存后,前端不自动刷新或假装生效。 - 非 object server entry 报错。 - 缺少 `command` 和 `url` 的 server entry 报错。 - 保存 MCP 时保留其它 runtime options。 -- MCP 配置变更后 agent 标记 `env_restart_required`。 +- MCP 配置变更后先标记 `env_restart_required`,随后自动调用 `Recreate`。 - `Recreate` 后重新 provision,并把最新 MCP 传给 runtime adapter。 +- 自动 `Recreate` 成功后清除 `env_restart_required`。 +- 自动 `Recreate` 失败后保留已保存的 `runtime_options.mcp` 和 `env_restart_required = true`。 - `openclawsandbox.renderConfig` 能把 `runtime_options.mcp.mcpServers` 渲染为 `mcp.servers`。 - OpenClaw `null` 清除和空集合渲染行为正确。 -- `syncGatewayHostConfig` 在 MCP configured 或 restart required 状态下会打出可诊断日志。 +- `syncGatewayHostConfig` 在 MCP configured 或 restart required 状态下会打出可诊断日志,并且重写 host config 时不会丢失 MCP 配置。 前端测试: @@ -457,24 +470,27 @@ MCP 修改保存后,前端不自动刷新或假装生效。 - 保存 MCP 时不覆盖其它 runtime options。 - 替换 MCP 时提交合并后的 `runtime_options`。 - 清除 MCP 时提交合并后的 `runtime_options`。 -- 保存并重建按顺序调用 update 和 recreate。 +- 保存已有 agent 的 MCP 配置时只调用 update;recreate 由后端自动完成。 +- update 失败后刷新 agent,并能展示现有 recreate required 状态。 手工验证: 1. 创建 agent,带一个 stdio MCP server。 2. 对 OpenClaw agent,检查宿主机 `~/.csgclaw/agents//.openclaw/openclaw.json` 包含 `mcp.servers`。 3. 启动 agent 后确认 runtime 能看到 MCP tool。 -4. 修改 MCP 配置并只保存,确认 UI 标记需要重建,旧 runtime 不被停止。 -5. 点击重建,确认新配置生效,`env_restart_required` 清除。 -6. 在 MCP pending 状态下修改 profile,确认日志能看到 host config sync 记录。 +4. 修改 MCP 配置并保存,确认后端自动重建 runtime。 +5. 自动重建成功后确认新配置生效,`env_restart_required` 清除。 +6. 模拟 recreate 失败,确认已保存 MCP 配置保留,`env_restart_required` 为 true,现有重建 action 可恢复。 +7. MCP 配置生效后修改 profile,确认 host config sync 不会丢失 `mcp.servers`,并能看到诊断日志。 ## 8. 实施顺序 1. 增加 `runtime_options.mcp` parser/validator 和共享 helper。 2. 扩展 provision request,把 `Agent.RuntimeOptions` 传到 runtime provision 边界。 -3. 增加通用 MCP restart policy:`runtime_options.mcp` 变化后标记 `env_restart_required`。 +3. 增加通用 MCP recreate policy:`runtime_options.mcp` 变化后标记 `env_restart_required`,并对已支持的 gateway runtime 自动调用现有 `Recreate`。 4. 增加关键路径结构化日志,尤其是 MCP save/provision 和 gateway host config sync。 5. 实现 OpenClaw adapter,把 `runtime_options.mcp.mcpServers` 渲染到 `openclaw.json` 的 `mcp.servers`。 -6. 接入其它 runtime adapter 的 MCP 原生渲染路径;没有可生效路径时返回明确错误,不静默忽略。 -7. 接入前端 Agent 创建/详情页 MCP runtime options UI。 -8. 补充 `docs/api.zh.md` 和相关英文 API 文档。 +6. 让 `syncGatewayHostConfig` 重写 host config 时也传入并渲染当前 `Agent.RuntimeOptions`,避免 profile 热同步丢失 MCP。 +7. 接入其它 runtime adapter 的 MCP 原生渲染路径;没有可生效路径时返回明确错误,不静默忽略。 +8. 接入前端 Agent 创建/详情页 MCP runtime options UI。 +9. 补充 `docs/api.zh.md` 和相关英文 API 文档。 From d746dd981ceb76727d8782f46f392bddc72a61c7 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Thu, 2 Jul 2026 12:04:03 +0800 Subject: [PATCH 4/7] feat: add OpenClaw MCP runtime options --- docs/openclaw-mcp-restart-plan.zh.md | 96 ++--- internal/agent/service.go | 28 +- internal/agent/service_profiles.go | 37 +- internal/agent/service_test.go | 355 ++++++++++++++++++ internal/runtime/mcp_options.go | 3 + internal/runtime/openclawsandbox/config.go | 13 +- .../openclawsandbox/config_controller.go | 21 ++ .../runtime/openclawsandbox/config_test.go | 208 ++++++++++ internal/runtime/openclawsandbox/mcp.go | 277 ++++++++++++++ .../runtime/openclawsandbox/provision_test.go | 29 +- internal/runtime/openclawsandbox/runtime.go | 2 +- internal/runtime/provision.go | 1 + .../MCPRuntimeOptionsPanel.tsx | 105 ++++++ .../ProfileControls/ProfileControls.css | 51 +++ .../business/ProfileControls/index.ts | 1 + web/app/src/models/agents.ts | 60 +++ .../AgentDetailPane/AgentDetailPane.tsx | 45 ++- .../WorkspaceModals/AgentProfileModal.tsx | 19 + web/app/src/shared/i18n/messages.ts | 14 + .../tests/components/AgentActions.test.tsx | 83 +++- .../components/AgentProfileModal.test.tsx | 130 ++++++- web/app/tests/models/agents.test.ts | 45 +++ 22 files changed, 1540 insertions(+), 83 deletions(-) create mode 100644 internal/runtime/mcp_options.go create mode 100644 internal/runtime/openclawsandbox/config_controller.go create mode 100644 internal/runtime/openclawsandbox/mcp.go create mode 100644 web/app/src/components/business/ProfileControls/MCPRuntimeOptionsPanel.tsx diff --git a/docs/openclaw-mcp-restart-plan.zh.md b/docs/openclaw-mcp-restart-plan.zh.md index 6537519a..60b36ad0 100644 --- a/docs/openclaw-mcp-restart-plan.zh.md +++ b/docs/openclaw-mcp-restart-plan.zh.md @@ -1,6 +1,6 @@ -# 通用 Runtime MCP 配置接入方案 +# OpenClaw Runtime MCP 配置接入方案 -本文档约束 CSGClaw 为 agent 增加 MCP 能力的实现方案。文件名保留 OpenClaw 历史语境,但方案本身按通用 runtime 能力设计:MCP 配置先存入 agent 的 `runtime_options.mcp`,再由各 runtime adapter 在 provision/recreate 阶段渲染为对应运行时的原生配置。 +本文档约束 CSGClaw 为 OpenClaw agent 增加 MCP 能力的阶段一实现方案。阶段一只支持 `openclaw_sandbox`:MCP 配置先存入 agent 的 `runtime_options.mcp`,再由 OpenClaw runtime adapter 在 provision/recreate 阶段渲染为 OpenClaw 原生配置。非 OpenClaw runtime 暂不接收 `runtime_options.mcp`,后续 runtime 如需支持再单独接入。 阶段一沿用现有 gateway runtime 配置变更路径:MCP 配置保存后进入 CSGClaw 持久化状态,并立即触发现有 agent recreate 流程;recreate 成功后,新 runtime 进程加载最新 MCP 配置,`env_restart_required` 被清除。recreate 失败时,已保存的 MCP 配置保留在 agent state 中,`env_restart_required` 继续为 true,用户可通过现有“重建”动作重试。阶段一不依赖 runtime hot reload。 @@ -8,17 +8,19 @@ 目标: -- 所有 runtime kind 使用统一 API 形态:`runtime_options.mcp`。 -- MCP 配置由 CSGClaw 管理,随 agent create/update 持久化。 +- OpenClaw agent 使用现有 API 形态:`runtime_options.mcp`。 +- MCP 配置由 CSGClaw 管理,随 OpenClaw agent create/update 持久化。 - 创建 agent 时,MCP 配置随首次 provision 生效。 - 修改已有 agent 的 MCP 配置后,后端通过现有 agent recreate 流程自动生效。 -- runtime adapter 负责把 `runtime_options.mcp` 转成原生配置;OpenClaw adapter 渲染到 `mcp.servers`。 +- OpenClaw adapter 负责把 `runtime_options.mcp` 转成 `openclaw.json` 中的 `mcp.servers`。 +- 非 OpenClaw runtime 收到 `runtime_options.mcp` 时返回明确错误,不静默忽略。 非目标: - 阶段一不做 MCP marketplace、自动安装、连接测试或动态发现。 - 阶段一不依赖任一 runtime 的 MCP hot reload 保证。 - 阶段一不新增 CLI MCP 参数或 `agent update` 子命令。 +- 阶段一不抽象通用 MCP runtime policy,也不接入 PicoClaw/Codex。 - 暂不处理 MCP secret 脱敏体验;API 和前端沿用 `runtime_options` 的现有回显语义。 ## 2. 当前 OpenClaw 配置注入路径 @@ -76,7 +78,7 @@ node /app/openclaw.mjs gateway --allow-unconfigured --bind lan --port 18789 ## 3. MCP 配置形态 -CSGClaw API 接收与 Multica 一致的 MCP server 输入形态,但位置放在 `runtime_options.mcp`: +CSGClaw API 接收与 Multica 一致的 MCP server 配置输入形态,但位置放在 `runtime_options.mcp`。这里的 `mcpServers` 是配置包裹形态,不是 MCP 标准协议的 JSON-RPC 消息格式;真正的 MCP 协议交互由 OpenClaw runtime 和对应 MCP server 处理。 ```json { @@ -122,7 +124,7 @@ OpenClaw adapter 渲染到 OpenClaw 原生配置: } ``` -通用约束: +输入约束: - 持久化后的 `runtime_options.mcp` 缺失表示该 agent 没有 CSGClaw 管理的 MCP 配置。 - API update 的字段缺失语义以第 5.2 节为准:`runtime_options` 整体缺失才表示不修改;`runtime_options` 一旦提交就是整体替换。 @@ -158,16 +160,13 @@ OpenClaw 约束: 注意:当前 `PATCH /api/v1/agents/{id}` 的 `runtime_options` 是整体替换语义。前端保存 MCP 时必须基于当前 agent 的 `runtime_options` 合并后提交,避免只提交 `{"mcp": ...}` 时覆盖其它 runtime option,例如 Codex 的 `local_workspace_dir`。 -建议增加共享 helper: +实现时保留现有 runtime option 边界,只增加最小共享常量和 OpenClaw 内部解析逻辑: ```go const RuntimeOptionMCPKey = "mcp" - -func ExtractMCPConfig(runtimeOptions map[string]any) (MCPConfigState, error) -func RuntimeOptionsWithMCP(runtimeOptions map[string]any, mcp any) map[string]any ``` -`MCPConfigState` 至少区分: +OpenClaw 解析状态至少区分: - absent - cleared/null @@ -191,7 +190,7 @@ Agent.RuntimeOptions["mcp"] -> Service.provisionRuntimeForAgent(...) -> agentruntime.ProvisionRequest.RuntimeOptions -> concrete runtime Provision(...) - -> runtime MCP adapter + -> OpenClaw MCP adapter -> native runtime config ``` @@ -216,29 +215,13 @@ updateOpenClawGatewayAuth updateOpenClawMCP ``` -### 4.3 Runtime adapter 策略 - -每个 runtime kind 都通过同一个 `runtime_options.mcp` 输入获得 MCP 配置,但落地方式由 runtime adapter 决定。 - -建议定义 runtime MCP policy: - -```go -type RuntimeMCPPolicy interface { - ValidateMCPConfig(runtimeOptions map[string]any) error - MCPRestartRequired(previous, current map[string]any) bool -} -``` - -对 provisioner runtime,adapter 在 `Provision` 期间渲染原生配置。对非 provisioner runtime,如果 MCP 需要写入启动前配置,也应把写入逻辑放在 runtime 自己的 host-side preparation 路径中,不要散落到 API handler 或 agent service。 +### 4.3 OpenClaw adapter 策略 -阶段一默认策略: +阶段一复用现有 `RuntimeConfigController` 扩展点,不新增 MCP 专用跨 runtime 抽象。OpenClaw runtime controller 负责: -- `runtime_options.mcp` 变更后需要通过 recreate 生效。 -- 后端保存已有 agent 的 MCP 配置后,立即调用现有 recreate 流程。 -- recreate 成功后清除 `env_restart_required`。 -- recreate 失败时保留 `env_restart_required = true`,让用户可通过现有重建动作重试。 -- 不依赖 runtime hot reload。 -- 如果某个 runtime adapter 已确认支持安全热应用,可以后续单独改 policy。 +- 校验 `runtime_options.mcp`。 +- 判断 `runtime_options.mcp` 变更是否需要 recreate。 +- 在 `Provision` 期间把 MCP 配置渲染进 `openclaw.json`。 OpenClaw adapter: @@ -246,11 +229,10 @@ OpenClaw adapter: - `null` 清除 CSGClaw 管理的 `mcp.servers`。 - `{}` 或空 `mcpServers` 写入 `mcp.servers: {}`。 -PicoClaw/Codex/其它 runtime: +非 OpenClaw runtime: -- 复用相同 `runtime_options.mcp` 输入和校验。 -- 由对应 runtime adapter 渲染为其原生 MCP 配置或启动参数。 -- 不允许静默忽略已保存 MCP 配置;如果 adapter 暂无可生效路径,应在 validate/provision 阶段返回明确错误,避免用户误以为配置已经生效。 +- 阶段一不支持 `runtime_options.mcp`。 +- create/update/start 等需要校验 runtime config 的路径遇到 `runtime_options.mcp` 时返回明确错误。 ### 4.4 保存与自动重建 @@ -284,21 +266,8 @@ PicoClaw/Codex/其它 runtime: 实现要求: - 保持现有 `syncGatewayHostConfig` 的 profile 热同步能力。 -- `syncGatewayHostConfig` 调用 OpenClaw/PicoClaw `EnsureConfig` 时必须传入当前 `Agent.RuntimeOptions`。 +- `syncGatewayHostConfig` 调用 OpenClaw `EnsureConfig` 时必须传入当前 `Agent.RuntimeOptions`。 - `EnsureConfig`/`renderConfig` 每次写 host config 都应根据当前 `runtime_options.mcp` 渲染 runtime 原生 MCP 配置。 -- 如果当前 agent 配置了 `runtime_options.mcp` 或 `env_restart_required = true`,打印结构化日志。 -- 日志至少包含 `agent_id`、`agent_name`、`runtime_kind`、`mcp_configured`、`env_restart_required`、`stage`。 -- 日志用于定位“保存 MCP 后是否又发生了 host config 写入”,但正确性必须由重新渲染 MCP 保证。 - -建议日志点: - -- MCP 配置保存成功。 -- MCP 配置清除成功。 -- runtime provision 开始渲染 MCP。 -- runtime provision 写入原生配置成功。 -- `syncGatewayHostConfig` 在 MCP configured 或 restart required 状态下执行。 -- recreate 成功。 -- recreate 失败且保留 `env_restart_required`。 ## 5. API 设计 @@ -390,10 +359,11 @@ PicoClaw/Codex/其它 runtime: ### 6.1 页面位置 -在现有 Agent 创建和编辑路径中增加通用 MCP 区域: +在现有 Agent 创建和编辑路径中增加 OpenClaw MCP 区域: - 创建 agent modal:`AgentProfileModal` 的 runtime section 下展示。 - Agent 详情页:`AgentDetailPane` 的 runtime section 下展示。 +- 仅当 draft 的 `runtime_kind` 为 `openclaw_sandbox` 时展示。 - 控件读写 `draft.runtime_options.mcp`,不引入 top-level `mcp_config` 状态。 MCP 区域可以做成专用组件,但它必须作为 runtime options 的编辑器存在: @@ -408,8 +378,8 @@ MCPRuntimeOptionsPanel - 标题:`MCP Servers` - JSON 编辑器:textarea 或轻量 code editor,保存前做 JSON parse。 -- 操作按钮:保存、清除配置、取消。 -- 状态提示:保存时提示会自动重建 runtime;如果后端返回 recreate 失败,刷新 agent 并显示现有 `env_restart_required` badge 和“重建”按钮。 +- 操作按钮:填入示例、清除配置。 +- 保存动作复用创建/编辑表单已有保存按钮,不在 MCP 面板内新增独立保存流程。 创建 agent 时: @@ -461,7 +431,8 @@ MCP 修改保存后,后端负责自动重建 runtime;前端不再提供专 - 自动 `Recreate` 失败后保留已保存的 `runtime_options.mcp` 和 `env_restart_required = true`。 - `openclawsandbox.renderConfig` 能把 `runtime_options.mcp.mcpServers` 渲染为 `mcp.servers`。 - OpenClaw `null` 清除和空集合渲染行为正确。 -- `syncGatewayHostConfig` 在 MCP configured 或 restart required 状态下会打出可诊断日志,并且重写 host config 时不会丢失 MCP 配置。 +- 非 OpenClaw runtime 收到 `runtime_options.mcp` 时返回明确错误。 +- `syncGatewayHostConfig` 重写 OpenClaw host config 时不会丢失 MCP 配置。 前端测试: @@ -487,10 +458,9 @@ MCP 修改保存后,后端负责自动重建 runtime;前端不再提供专 1. 增加 `runtime_options.mcp` parser/validator 和共享 helper。 2. 扩展 provision request,把 `Agent.RuntimeOptions` 传到 runtime provision 边界。 -3. 增加通用 MCP recreate policy:`runtime_options.mcp` 变化后标记 `env_restart_required`,并对已支持的 gateway runtime 自动调用现有 `Recreate`。 -4. 增加关键路径结构化日志,尤其是 MCP save/provision 和 gateway host config sync。 -5. 实现 OpenClaw adapter,把 `runtime_options.mcp.mcpServers` 渲染到 `openclaw.json` 的 `mcp.servers`。 -6. 让 `syncGatewayHostConfig` 重写 host config 时也传入并渲染当前 `Agent.RuntimeOptions`,避免 profile 热同步丢失 MCP。 -7. 接入其它 runtime adapter 的 MCP 原生渲染路径;没有可生效路径时返回明确错误,不静默忽略。 -8. 接入前端 Agent 创建/详情页 MCP runtime options UI。 -9. 补充 `docs/api.zh.md` 和相关英文 API 文档。 +3. 增加 OpenClaw runtime config controller:`runtime_options.mcp` 变化后标记 `env_restart_required`,并自动调用现有 `Recreate`。 +4. 实现 OpenClaw adapter,把 `runtime_options.mcp.mcpServers` 渲染到 `openclaw.json` 的 `mcp.servers`。 +5. 让 `syncGatewayHostConfig` 重写 OpenClaw host config 时也传入并渲染当前 `Agent.RuntimeOptions`,避免 profile 热同步丢失 MCP。 +6. 拒绝非 OpenClaw runtime 的 `runtime_options.mcp`。 +7. 接入前端 Agent 创建/详情页 MCP runtime options UI。 +8. 补充 `docs/api.zh.md` 和相关英文 API 文档。 diff --git a/internal/agent/service.go b/internal/agent/service.go index fbb57c02..7b762681 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,24 @@ 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 + } + resolvedProfile, err := s.profileForCreateRequest(ctx, &spec) + if err != nil { + return err + } + return s.validateRuntimeConfig(ctx, runtimeKind, runtimeConfigSnapshotForAgent(s.hydrateProfileFromCatalog(resolvedProfile), spec.RuntimeOptions)) +} + func (s *Service) managerImageOverrideForReplace(ctx context.Context, existing Agent, runtimeKind string) string { runtimeKind = strings.TrimSpace(runtimeKind) if runtimeKind == "" { @@ -1877,6 +1898,9 @@ func (s *Service) CreateWorker(ctx context.Context, spec CreateAgentSpec) (Agent if err != nil { return Agent{}, err } + if err := validateMCPRuntimeOptionSupport(runtimeKind, spec.RuntimeOptions); err != nil { + return Agent{}, err + } resolvedProfile, err := s.profileForCreateRequest(ctx, &spec) if err != nil { return Agent{}, err @@ -1892,6 +1916,7 @@ func (s *Service) CreateWorker(ctx context.Context, spec CreateAgentSpec) (Agent ParticipantID: participantIDForAgent(name, id), AgentName: name, Profile: runtimeProfile, + RuntimeOptions: utils.CloneAnyMap(spec.RuntimeOptions), WorkspaceOverlay: strings.TrimSpace(spec.FromTemplate), }); err != nil { return Agent{}, fmt.Errorf("provision worker runtime: %w", err) @@ -2112,6 +2137,7 @@ 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), WorkspaceOverlay: strings.TrimSpace(workspaceOverlay), }) } diff --git a/internal/agent/service_profiles.go b/internal/agent/service_profiles.go index 7d4dabd2..b29ad499 100644 --- a/internal/agent/service_profiles.go +++ b/internal/agent/service_profiles.go @@ -164,13 +164,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 } @@ -192,7 +192,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.EnsureConfigWithRuntimeOptions(agentHome, participantID, got.ID, s.server, modelCfg, got.RuntimeOptions, resolveManagerBaseURL, feishuProvider); err != nil { return fmt.Errorf("sync gateway openclaw config: %w", err) } default: @@ -353,6 +353,10 @@ func (s *Service) Update(ctx context.Context, id string, req UpdateRequest) (Age req.RuntimeOptions = &empty } patch = *req.RuntimeOptions + if err := validateMCPRuntimeOptionSupport(runtimeKind, patch); err != nil { + s.mu.Unlock() + return Agent{}, err + } } mergedFlat := runtimeOptionsAfterPatch(current.RuntimeKind, current.RuntimeOptions, nil) if runtimeOptionsUpdated { @@ -478,6 +482,17 @@ func runtimeConfigSnapshotForAgent(profile AgentProfile, options map[string]any) } } +func validateMCPRuntimeOptionSupport(runtimeKind string, options map[string]any) error { + _, ok := options[agentruntime.RuntimeOptionMCPKey] + if !ok { + return nil + } + if strings.TrimSpace(runtimeKind) == RuntimeKindOpenClawSandbox { + return nil + } + return fmt.Errorf("runtime_options.%s is only supported for runtime_kind %q", agentruntime.RuntimeOptionMCPKey, RuntimeKindOpenClawSandbox) +} + func (s *Service) hydrateProfileFromCatalog(profile AgentProfile) AgentProfile { if s == nil { return profile @@ -547,6 +562,9 @@ func (s *Service) validateRuntimeConfig(ctx context.Context, runtimeKind string, if runtimeKind == "" { return nil } + if err := validateMCPRuntimeOptionSupport(runtimeKind, current.Options); err != nil { + return err + } rt, err := s.runtimeForKind(runtimeKind) if err != nil { return err @@ -755,11 +773,12 @@ 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), }); 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..d9c993b7 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" @@ -1512,6 +1513,360 @@ func TestUpdateCodexLocalWorkspaceDirMarksRunningRuntimeForRestart(t *testing.T) } } +func TestUpdateOpenClawMCPRuntimeOptionsRecreatesAndProvisionsLatestOptions(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + var provisionCalls []agentruntime.ProvisionRequest + reconcileCalls := 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, + restart: func(change agentruntime.RuntimeConfigChange) (bool, error) { + return !reflect.DeepEqual(change.Previous.Options["mcp"], change.Current.Options["mcp"]), nil + }, + reconcile: func(context.Context, agentruntime.Handle, agentruntime.RuntimeConfigChange) error { + reconcileCalls++ + 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), + } + + nextRuntimeOptions := map[string]any{ + "local_workspace_dir": "/tmp/keep", + "mcp": 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{RuntimeOptions: &nextRuntimeOptions}) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + if reconcileCalls != 1 { + t.Fatalf("ReconcileConfig() calls = %d, want 1", reconcileCalls) + } + 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, ok := provisionCalls[0].RuntimeOptions["mcp"].(map[string]any) + if !ok { + t.Fatalf("Provision().RuntimeOptions[mcp] = %#v, want object", provisionCalls[0].RuntimeOptions["mcp"]) + } + servers := mcpRoot["mcpServers"].(map[string]any) + if _, ok := servers["context7"]; !ok { + t.Fatalf("Provision().RuntimeOptions 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 TestUpdateOpenClawMCPRuntimeOptionsRecreateFailureKeepsRestartRequired(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, + restart: func(change agentruntime.RuntimeConfigChange) (bool, error) { + return !reflect.DeepEqual(change.Previous.Options["mcp"], change.Current.Options["mcp"]), 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), + } + + nextRuntimeOptions := map[string]any{ + "mcp": map[string]any{ + "mcpServers": map[string]any{ + "context7": map[string]any{"command": "uvx"}, + }, + }, + } + _, err = svc.Update(context.Background(), "u-alice", UpdateRequest{RuntimeOptions: &nextRuntimeOptions}) + 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.RuntimeOptions["mcp"].(map[string]any); !ok { + t.Fatalf("Agent().RuntimeOptions = %#v, want saved mcp config", got.RuntimeOptions) + } + if got.BoxID != "openclaw-box-old" { + t.Fatalf("Agent().BoxID = %q, want old box id after failed recreate", got.BoxID) + } +} + +func TestUpdateMCPRuntimeOptionsRejectsNonOpenClawRuntime(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{}}, + } + _, err = svc.Update(context.Background(), "u-dev", UpdateRequest{RuntimeOptions: &nextRuntimeOptions}) + if err == nil || !strings.Contains(err.Error(), `runtime_options.mcp is only supported`) { + t.Fatalf("Update() error = %v, want unsupported mcp runtime", err) + } +} + +func TestCreateMCPRuntimeOptionsRejectsNonOpenClawRuntime(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) + } + + _, 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 || !strings.Contains(err.Error(), `runtime_options.mcp is only supported`) { + t.Fatalf("Create() error = %v, want unsupported mcp runtime", err) + } +} + +func TestReplaceRejectsInheritedMCPBeforeDeletingExistingAgent(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", + Role: RoleWorker, + Status: string(agentruntime.StateStopped), + RuntimeOptions: map[string]any{ + "mcp": 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), + } + + _, 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 || !strings.Contains(err.Error(), `runtime_options.mcp is only supported`) { + t.Fatalf("Create(replace) error = %v, want unsupported mcp runtime", err) + } + got, ok := svc.Agent("u-dev") + if !ok { + t.Fatal("Agent(u-dev) ok = false, want existing agent preserved after failed replace") + } + if got.RuntimeKind != RuntimeKindOpenClawSandbox { + t.Fatalf("Agent().RuntimeKind = %q, want original %q", got.RuntimeKind, RuntimeKindOpenClawSandbox) + } + if _, ok := got.RuntimeOptions["mcp"].(map[string]any); !ok { + t.Fatalf("Agent().RuntimeOptions = %#v, want original mcp preserved", got.RuntimeOptions) + } +} + +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{"runtime_options"}, + Spec: CreateAgentSpec{ + ID: "u-dev", + RuntimeOptions: map[string]any{ + "mcp": map[string]any{ + "servers": map[string]any{ + "context7": map[string]any{"command": "uvx"}, + }, + }, + }, + }, + }) + if err == nil || !strings.Contains(err.Error(), `runtime_options.mcp 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/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..f55b8515 100644 --- a/internal/runtime/openclawsandbox/config.go +++ b/internal/runtime/openclawsandbox/config.go @@ -49,11 +49,15 @@ 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 EnsureConfigWithRuntimeOptions(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) { 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 := renderConfigWithRuntimeOptions(participantID, agentID, server, model, runtimeOptions, resolveBaseURL, feishuProvider) if err != nil { return "", err } @@ -143,6 +147,10 @@ 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 renderConfigWithRuntimeOptions(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) { participantID = strings.TrimSpace(participantID) agentID = strings.TrimSpace(agentID) if participantID == "" { @@ -168,6 +176,9 @@ func renderConfig(participantID, agentID string, server config.ServerConfig, mod return nil, err } updateOpenClawWorkspaceDefault(cfg, workspaceGuestPathForGOOS(goruntime.GOOS)) + if err := updateOpenClawMCP(cfg, runtimeOptions); err != nil { + return nil, err + } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return nil, fmt.Errorf("encode openclaw config: %w", err) diff --git a/internal/runtime/openclawsandbox/config_controller.go b/internal/runtime/openclawsandbox/config_controller.go new file mode 100644 index 00000000..d7760784 --- /dev/null +++ b/internal/runtime/openclawsandbox/config_controller.go @@ -0,0 +1,21 @@ +package openclawsandbox + +import ( + "context" + + agentruntime "csgclaw/internal/runtime" +) + +var _ agentruntime.RuntimeConfigController = (*Runtime)(nil) + +func (r *Runtime) ValidateConfig(_ context.Context, current agentruntime.RuntimeConfigSnapshot) error { + return validateOpenClawMCPRuntimeOptions(current.Options) +} + +func (r *Runtime) RestartRequired(change agentruntime.RuntimeConfigChange) (bool, error) { + return openClawMCPRestartRequired(change.Previous.Options, change.Current.Options) +} + +func (r *Runtime) ReconcileConfig(context.Context, agentruntime.Handle, agentruntime.RuntimeConfigChange) 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..93909d26 --- /dev/null +++ b/internal/runtime/openclawsandbox/mcp.go @@ -0,0 +1,277 @@ +package openclawsandbox + +import ( + "fmt" + "reflect" + "sort" + "strings" + + agentruntime "csgclaw/internal/runtime" +) + +type openClawMCPMode int + +const ( + openClawMCPAbsent openClawMCPMode = iota + openClawMCPCleared + openClawMCPManaged +) + +type openClawMCPConfig struct { + mode openClawMCPMode + servers map[string]any +} + +func validateOpenClawMCPRuntimeOptions(runtimeOptions map[string]any) error { + _, err := openClawMCPConfigFromRuntimeOptions(runtimeOptions) + return err +} + +func openClawMCPRestartRequired(previous, current map[string]any) (bool, error) { + prev, prevErr := openClawMCPConfigFromRuntimeOptions(previous) + currentConfig, err := openClawMCPConfigFromRuntimeOptions(current) + if err != nil { + return false, err + } + if prevErr != nil { + return true, nil + } + return !reflect.DeepEqual(openClawMCPEffectiveServers(prev), openClawMCPEffectiveServers(currentConfig)), nil +} + +func updateOpenClawMCP(cfg map[string]any, runtimeOptions map[string]any) error { + mcpConfig, err := openClawMCPConfigFromRuntimeOptions(runtimeOptions) + if err != nil { + return err + } + switch mcpConfig.mode { + case openClawMCPAbsent: + return nil + case openClawMCPCleared: + mcpRoot, ok := cfg["mcp"].(map[string]any) + if !ok { + return nil + } + delete(mcpRoot, "servers") + if len(mcpRoot) == 0 { + delete(cfg, "mcp") + } + return nil + case openClawMCPManaged: + mcpRoot, _ := cfg["mcp"].(map[string]any) + if mcpRoot == nil { + mcpRoot = map[string]any{} + cfg["mcp"] = mcpRoot + } + if mcpConfig.servers == nil { + mcpConfig.servers = map[string]any{} + } + mcpRoot["servers"] = mcpConfig.servers + return nil + default: + return nil + } +} + +func openClawMCPEffectiveServers(config openClawMCPConfig) map[string]any { + if config.mode != openClawMCPManaged { + return nil + } + if config.servers == nil { + return map[string]any{} + } + return config.servers +} + +func openClawMCPConfigFromRuntimeOptions(runtimeOptions map[string]any) (openClawMCPConfig, error) { + raw, ok := runtimeOptions[agentruntime.RuntimeOptionMCPKey] + if !ok { + return openClawMCPConfig{mode: openClawMCPAbsent}, nil + } + if raw == nil { + return openClawMCPConfig{mode: openClawMCPCleared}, nil + } + mcpRoot, ok := raw.(map[string]any) + if !ok { + return openClawMCPConfig{}, fmt.Errorf("runtime_options.%s must be an object or null", agentruntime.RuntimeOptionMCPKey) + } + if unsupported := unsupportedOpenClawMCPRootKeys(mcpRoot); len(unsupported) > 0 { + return openClawMCPConfig{}, fmt.Errorf("runtime_options.%s contains unsupported field(s) %q; only mcpServers is supported", agentruntime.RuntimeOptionMCPKey, strings.Join(unsupported, ", ")) + } + if len(mcpRoot) == 0 { + return openClawMCPConfig{mode: openClawMCPManaged, servers: map[string]any{}}, nil + } + rawServers, ok := mcpRoot["mcpServers"] + if !ok { + return openClawMCPConfig{mode: openClawMCPManaged, servers: map[string]any{}}, nil + } + serverMap, ok := rawServers.(map[string]any) + if !ok { + return openClawMCPConfig{}, fmt.Errorf("runtime_options.%s.mcpServers must be an object", agentruntime.RuntimeOptionMCPKey) + } + servers := make(map[string]any, len(serverMap)) + for rawName, rawEntry := range serverMap { + name := strings.TrimSpace(rawName) + if name == "" { + return openClawMCPConfig{}, fmt.Errorf("runtime_options.%s.mcpServers contains an empty server name", agentruntime.RuntimeOptionMCPKey) + } + if _, exists := servers[name]; exists { + return openClawMCPConfig{}, fmt.Errorf("runtime_options.%s.mcpServers contains duplicate server name %q", agentruntime.RuntimeOptionMCPKey, name) + } + entry, ok := rawEntry.(map[string]any) + if !ok { + return openClawMCPConfig{}, fmt.Errorf("runtime_options.%s.mcpServers.%s must be an object", agentruntime.RuntimeOptionMCPKey, name) + } + normalized, err := normalizeOpenClawMCPServerEntry(name, entry) + if err != nil { + return openClawMCPConfig{}, err + } + servers[name] = normalized + } + return openClawMCPConfig{mode: openClawMCPManaged, servers: servers}, nil +} + +func unsupportedOpenClawMCPRootKeys(root map[string]any) []string { + out := make([]string, 0) + for key := range root { + if key != "mcpServers" { + out = append(out, key) + } + } + sort.Strings(out) + return out +} + +func normalizeOpenClawMCPServerEntry(name string, entry map[string]any) (map[string]any, error) { + normalized, ok := cloneMCPJSONObject(entry).(map[string]any) + if !ok { + return nil, fmt.Errorf("runtime_options.%s.mcpServers.%s must be an object", agentruntime.RuntimeOptionMCPKey, name) + } + command, hasCommand, err := mcpStringField(normalized, "command") + if err != nil { + return nil, fmt.Errorf("runtime_options.%s.mcpServers.%s.command %s", agentruntime.RuntimeOptionMCPKey, name, err) + } + url, hasURL, err := mcpStringField(normalized, "url") + if err != nil { + return nil, fmt.Errorf("runtime_options.%s.mcpServers.%s.url %s", agentruntime.RuntimeOptionMCPKey, name, err) + } + if !hasCommand && !hasURL { + return nil, fmt.Errorf("runtime_options.%s.mcpServers.%s must declare command or url", agentruntime.RuntimeOptionMCPKey, 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("runtime_options.%s.mcpServers.%s.args must be an array of strings", agentruntime.RuntimeOptionMCPKey, name) + } + if err := validateMCPStringMapField(normalized, "env"); err != nil { + return nil, fmt.Errorf("runtime_options.%s.mcpServers.%s.env must be an object with string values", agentruntime.RuntimeOptionMCPKey, name) + } + if err := validateMCPStringMapField(normalized, "headers"); err != nil { + return nil, fmt.Errorf("runtime_options.%s.mcpServers.%s.headers must be an object with string values", agentruntime.RuntimeOptionMCPKey, name) + } + if err := validateMCPStringField(normalized, "transport"); err != nil { + return nil, fmt.Errorf("runtime_options.%s.mcpServers.%s.transport must be a string", agentruntime.RuntimeOptionMCPKey, 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 + } + _, ok = raw.(string) + if !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/openclawsandbox/provision_test.go b/internal/runtime/openclawsandbox/provision_test.go index 5c519761..dba83bd6 100644 --- a/internal/runtime/openclawsandbox/provision_test.go +++ b/internal/runtime/openclawsandbox/provision_test.go @@ -25,10 +25,17 @@ 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{}, + RuntimeOptions: map[string]any{ + "mcp": 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 +52,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..0874da86 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 := EnsureConfigWithRuntimeOptions(agentHome, participantID, req.AgentID, gateway.Server, configModelFromProfile(profile), req.RuntimeOptions, 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..3f8979e9 100644 --- a/internal/runtime/provision.go +++ b/internal/runtime/provision.go @@ -29,6 +29,7 @@ type ProvisionRequest struct { ParticipantID string AgentName string Profile Profile + RuntimeOptions map[string]any WorkspaceOverlay string Gateway *GatewayProvision } diff --git a/web/app/src/components/business/ProfileControls/MCPRuntimeOptionsPanel.tsx b/web/app/src/components/business/ProfileControls/MCPRuntimeOptionsPanel.tsx new file mode 100644 index 00000000..b497cb2f --- /dev/null +++ b/web/app/src/components/business/ProfileControls/MCPRuntimeOptionsPanel.tsx @@ -0,0 +1,105 @@ +import { useEffect, useId, useState } from "react"; +import { + MCP_RUNTIME_OPTIONS_EXAMPLE, + mcpRuntimeOptionsText, + parseMCPRuntimeOptionsText, + setMCPRuntimeOptions, + type AgentDraft, + type JSONRecord, +} from "@/models/agents"; +import type { TranslateFn } from "@/models/conversations"; +import { Button } from "@/components/ui/Button/Button"; + +export type MCPRuntimeOptionsPanelProps = { + draft: AgentDraft; + onDraftChange: (draft: AgentDraft) => void; + onInvalidChange?: (invalid: boolean) => void; + t: TranslateFn; +}; + +function cloneMCPExample(): JSONRecord { + return JSON.parse(JSON.stringify(MCP_RUNTIME_OPTIONS_EXAMPLE)) as JSONRecord; +} + +function errorMessageForKey(key: "invalid_json" | "object_required", t: TranslateFn): string { + return key === "invalid_json" ? t("profileMCPServersInvalidJSON") : t("profileMCPServersObjectRequired"); +} + +export function MCPRuntimeOptionsPanel({ draft, onDraftChange, onInvalidChange, t }: MCPRuntimeOptionsPanelProps) { + const textareaId = useId(); + const draftText = mcpRuntimeOptionsText(draft.runtime_options); + 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 = parseMCPRuntimeOptionsText(nextText); + if (!parsed.ok) { + setError(errorMessageForKey(parsed.error, t)); + onInvalidChange?.(true); + return; + } + setError(""); + onInvalidChange?.(false); + onDraftChange({ + ...draft, + runtime_options: setMCPRuntimeOptions(draft.runtime_options, parsed.value), + }); + } + + function fillExample() { + const example = cloneMCPExample(); + setError(""); + onInvalidChange?.(false); + setText(JSON.stringify(example, null, 2)); + onDraftChange({ + ...draft, + runtime_options: setMCPRuntimeOptions(draft.runtime_options, example), + }); + } + + function clearMCPConfig() { + setError(""); + onInvalidChange?.(false); + setText(""); + onDraftChange({ + ...draft, + runtime_options: setMCPRuntimeOptions(draft.runtime_options, null), + }); + } + + return ( +
+
+ +
+ + +
+
+