Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 12 additions & 24 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,27 +377,15 @@ Notes:

#### `GET /api/v1/agents/{id}/mcp-servers`

Returns the agent's editable MCP server configuration. Unlike generic Agent
responses, this configuration-page endpoint returns raw values so a user can
review and edit existing tokens. `desired` and `actual` are each direct
server-name-to-server-config maps when non-null; neither is nested under
another MCP field. `desired` preserves the distinction between `null` (no
managed set) and `{}` (an explicitly managed empty set). If the native runtime
configuration cannot be read, the endpoint still returns `desired`, sets
`actual` to `null`, and includes an optional `actual_error` message.
Returns the agent's MCP server configuration as a direct raw
server-name-to-server-config map in `servers`. Unlike generic Agent responses,
this endpoint does not redact configured token values.

```json
{
"agent_id": "u-alice",
"runtime_kind": "openclaw_sandbox",
"desired": {
"context7": {
"command": "uvx",
"args": ["context7-mcp"],
"env": { "CONTEXT7_API_KEY": "secret" }
}
},
"actual": {
"servers": {
"context7": {
"command": "uvx",
"args": ["context7-mcp"],
Expand All @@ -407,17 +395,17 @@ configuration cannot be read, the endpoint still returns `desired`, sets
}
```

#### `PUT /api/v1/agents/{id}/mcp-servers`

Replaces the agent's `mcpServers` direct map. Sending `null` clears the managed
set. The response has the same raw `desired`/`actual` shape as the `GET`
endpoint.

#### `POST /api/v1/agents/{id}/mcp-servers:batchAdd`

Accepts `{ "names": ["..."] }` and merges the named server definitions from
the MCP catalog into the agent's `mcpServers` map. The response has the same raw
`desired`/`actual` shape as the `GET` endpoint.
the MCP catalog into the agent's managed MCP servers. The response has the same
shape as the `GET` endpoint.

#### `POST /api/v1/agents/{id}/mcp-servers:batchDelete`

Accepts `{ "names": ["..."] }` and removes the named servers from the
agent's managed MCP servers. The response has the same shape as the `GET`
endpoint.

### MCP server catalog

Expand Down
19 changes: 6 additions & 13 deletions docs/api.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,20 +367,13 @@ OpenClaw、PicoClaw 和 Codex CLI agent 通过顶层 `mcpServers` 字段配置 M

#### `GET /api/v1/agents/{id}/mcp-servers`

返回 Agent 可编辑的 MCP server 配置。与通用 Agent 响应不同,这个配置页面接口会返回原始值,以便用户查看和编辑已配置的 token。非空的 `desired` 与 `actual` 都是从 server 名称到 server 配置的直接映射,不再嵌套在其他 MCP 字段中。`desired` 会保留 `null`(未托管集合)与 `{}`(显式托管空集合)的区别;如果无法读取 runtime 原生配置,接口仍会返回 `desired`,将 `actual` 设为 `null`,并在可选 `actual_error` 中给出失败原因
返回 Agent MCP server 配置。`servers` 是从 server 名称到 server 配置的直接原始映射。与通用 Agent 响应不同,这个接口不会脱敏已配置的 token

```json
{
"agent_id": "u-alice",
"runtime_kind": "openclaw_sandbox",
"desired": {
"context7": {
"command": "uvx",
"args": ["context7-mcp"],
"env": { "CONTEXT7_API_KEY": "secret" }
}
},
"actual": {
"servers": {
"context7": {
"command": "uvx",
"args": ["context7-mcp"],
Expand All @@ -390,13 +383,13 @@ OpenClaw、PicoClaw 和 Codex CLI agent 通过顶层 `mcpServers` 字段配置 M
}
```

#### `PUT /api/v1/agents/{id}/mcp-servers`
#### `POST /api/v1/agents/{id}/mcp-servers:batchAdd`

替换该 Agent 的 `mcpServers` 直接映射。传入 `null` 会清除托管集合。响应与 `GET` 一样,返回未脱敏的 `desired`/`actual`
接收 `{ "names": ["..."] }`,将 MCP catalog 中同名 server 的定义合并到该 Agent 托管的 MCP server。响应与 `GET` 一样。

#### `POST /api/v1/agents/{id}/mcp-servers:batchAdd`
#### `POST /api/v1/agents/{id}/mcp-servers:batchDelete`

接收 `{ "names": ["..."] }`,将 MCP catalog 中同名 server 的定义合并到该 Agent 的 `mcpServers` 映射。响应与 `GET` 一样,返回未脱敏的 `desired`/`actual`
接收 `{ "names": ["..."] }`,从该 Agent 托管的 MCP server 中移除同名 server。响应与 `GET` 一样。

### MCP server catalog

Expand Down
41 changes: 14 additions & 27 deletions docs/openclaw-mcp-restart-plan.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,23 +97,16 @@ MCP servers 通道。
配置页面使用下面的专用接口:

- `GET /api/v1/agents/{id}/mcp-servers`
- `PUT /api/v1/agents/{id}/mcp-servers`
- `POST /api/v1/agents/{id}/mcp-servers:batchAdd`
- `POST /api/v1/agents/{id}/mcp-servers:batchDelete`

`GET` 返回:

```json
{
"agent_id": "u-alice",
"runtime_kind": "openclaw_sandbox",
"desired": {
"context7": {
"command": "uvx",
"args": ["context7-mcp"],
"env": { "CONTEXT7_API_KEY": "secret" }
}
},
"actual": {
"servers": {
"context7": {
"command": "uvx",
"args": ["context7-mcp"],
Expand All @@ -123,16 +116,10 @@ MCP servers 通道。
}
```

非空的 `desired` 和 `actual` 都是直接 server 映射,且专用接口不脱敏,供有权限的
配置页面回显和编辑 token。`desired` 是持久化的声明,保留 `null`(未托管)与
`{}`(显式托管空集合)的区别;`actual` 是从 runtime 原生配置读回的结果。若 runtime
展开了 workspace 占位符,二者在路径值上可以不同,删除和保存应以 `desired` 为准。
如果原生配置暂时无法读取,接口仍会返回 `desired`,并将 `actual` 设为 `null`,同时
在可选的 `actual_error` 中说明读取失败原因,避免配置页无法修复期望配置。

`PUT` 接收 Agent 的顶层 `mcpServers` 字段并整体替换集合;响应与 `GET`
相同。`batchAdd` 接收 `{ "names": ["..."] }`,把 MCP catalog 中同名的
server 定义合并到该 Agent 的集合后返回同一视图。
`servers` 是直接 server 映射,且专用接口不脱敏,供有权限的配置页面回显和编辑 token。
`batchAdd` 接收 `{ "names": ["..."] }`,把 MCP catalog 中同名的 server
定义合并到该 Agent 的集合;`batchDelete` 用相同请求形态移除指定 server。两者都返回
同一视图。

### MCP catalog

Expand Down Expand Up @@ -191,7 +178,7 @@ env = { "CONTEXT7_API_KEY" = "secret" }

远程 server 的 `headers` 会转写为 Codex 的 `http_headers`。各 adapter 在
写入原生配置前解析 `${workspace}`、`${workspaceDir}`、`{workspace}` 与
`{workspaceDir}` 占位符;因此 `actual` 中可能显示已解析的运行时路径
`{workspaceDir}` 占位符;这个运行时渲染过程不会修改持久化的 `mcpServers`

## 5. 保存、同步与失败处理

Expand All @@ -203,18 +190,18 @@ env = { "CONTEXT7_API_KEY" = "secret" }
4. 通过 `MCPServersRestartRequired` 判断是否需要重建 runtime。
5. 无需重建时调用 `ReconcileMCPServers`;需要重建时由既有 lifecycle
流程 provision 新 runtime 配置。
6. 若同步或重建失败,保留已保存的期望状态并暴露既有 restart-required
6. 若同步或重建失败,保留已保存的 MCP server 配置并暴露既有 restart-required
状态,用户可以通过现有重建动作重试。

`MCPServersListController` 在读取实际原生文件时生成 `actual`,用于让配置页
识别“期望状态已保存但 runtime 尚未同步”的情况。
`MCPServersListController` 只在 Agent 尚未进入 CSGClaw 管理时读取 runtime
原生配置,用它建立首次管理所需的 `servers` 集合;进入管理后,`servers` 直接
来自持久化的 `Agent.MCPServers`,不会再额外返回一套 runtime 读取视图。

## 6. 前端行为与验证

前端创建和编辑 Agent 都以 `mcpServers` 作为草稿字段。通用 Agent 查询仅用于
展示脱敏摘要;打开 MCP 编辑器时,前端获取专用原始视图并将 `desired` 合并进
草稿。保存时只提交直接映射,避免 token 被脱敏占位值或 runtime 已解析的路径
覆盖。
展示脱敏摘要;打开 MCP 编辑器时,前端获取专用原始视图的 `servers` 作为草稿。
保存时只提交直接映射,避免 token 被脱敏占位值覆盖。

验证覆盖应至少包括:

Expand All @@ -223,5 +210,5 @@ env = { "CONTEXT7_API_KEY" = "secret" }
- 通用 Agent 响应的 `env`/`headers` 脱敏,以及专用视图的原始回显。
- 精确的 Agent 和 catalog 路由,包括 batchAdd 后缀。
- OpenClaw、PicoClaw、Codex 的原生渲染和实际配置读取。
- workspace 占位符的解析不会污染 `desired`。
- workspace 占位符的解析不会污染持久化的 `mcpServers`。
- MCP 编辑不会覆盖无关的 `runtime_options` 或 profile 字段。
26 changes: 4 additions & 22 deletions internal/agent/mcp_servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,13 @@ import (
"fmt"
"strings"

agentruntime "csgclaw/internal/runtime"
"csgclaw/internal/utils"
)

type MCPServersView struct {
AgentID string `json:"agent_id"`
RuntimeKind string `json:"runtime_kind"`
Desired map[string]any `json:"desired"`
Actual map[string]any `json:"actual"`
ActualError string `json:"actual_error,omitempty"`
Servers map[string]any `json:"servers"`
}

// cloneMCPServers preserves the distinction between no managed MCP state and
Expand Down Expand Up @@ -44,27 +41,12 @@ func (s *Service) MCPServersView(ctx context.Context, id string) (MCPServersView
view := MCPServersView{
AgentID: got.ID,
RuntimeKind: runtimeKind,
Desired: cloneMCPServers(got.MCPServers),
}
if runtimeKind == "" {
return view, nil
}

rt, err := s.runtimeForKind(runtimeKind)
if err != nil {
view.ActualError = fmt.Sprintf("read runtime MCP servers: %v", err)
return view, nil
}
lister, ok := rt.(agentruntime.MCPServersListController)
if !ok {
view.ActualError = fmt.Sprintf("runtime_kind %q does not expose MCP server state", runtimeKind)
return view, nil
}
listed, err := lister.ListMCPServers(ctx, runtimeHandleForAgent(got), mcpServersSnapshotForAgent(got.MCPServers))
servers, err := s.currentMCPServersForManagement(ctx, got)
if err != nil {
view.ActualError = fmt.Sprintf("read runtime MCP servers: %v", err)
view.Servers = cloneMCPServers(got.MCPServers)
return view, nil
}
view.Actual = cloneMCPServers(listed.Servers)
view.Servers = cloneMCPServers(servers)
return view, nil
}
60 changes: 40 additions & 20 deletions internal/agent/mcp_servers_view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"testing"

agentruntime "csgclaw/internal/runtime"
Expand Down Expand Up @@ -56,9 +55,6 @@ func TestMCPServersViewPreservesNilAndExplicitEmptyMaps(t *testing.T) {
if err != nil {
t.Fatalf("MCPServersView() error = %v", err)
}
if view.ActualError != "" {
t.Fatalf("ActualError = %q, want empty", view.ActualError)
}
data, err := json.Marshal(view)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
Expand All @@ -67,17 +63,47 @@ func TestMCPServersViewPreservesNilAndExplicitEmptyMaps(t *testing.T) {
if err := json.Unmarshal(data, &fields); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got := string(fields["desired"]); got != test.want {
t.Fatalf("desired = %s, want %s", got, test.want)
}
if got := string(fields["actual"]); got != test.want {
t.Fatalf("actual = %s, want %s", got, test.want)
if got := string(fields["servers"]); got != test.want {
t.Fatalf("servers = %s, want %s", got, test.want)
}
})
}
}

func TestMCPServersViewKeepsDesiredWhenRuntimeReadFails(t *testing.T) {
func TestMCPServersViewReadsRuntimeServersBeforeFirstManagement(t *testing.T) {
svc := &Service{
agents: map[string]Agent{
"u-mcp": {
ID: "u-mcp",
RuntimeKind: RuntimeKindCodex,
},
},
runtimeRegistry: map[string]agentruntime.Runtime{
RuntimeKindCodex: mcpServersViewTestRuntime{
fakeAgentRuntime: fakeAgentRuntime{kind: RuntimeKindCodex},
list: func(context.Context, agentruntime.Handle, agentruntime.MCPServersSnapshot) (agentruntime.MCPServersSnapshot, error) {
return agentruntime.MCPServersSnapshot{Servers: map[string]any{
"manual": map[string]any{"command": "uvx"},
}}, nil
},
},
},
}

view, err := svc.MCPServersView(context.Background(), "u-mcp")
if err != nil {
t.Fatalf("MCPServersView() error = %v", err)
}
server, ok := view.Servers["manual"].(map[string]any)
if !ok || server["command"] != "uvx" {
t.Fatalf("Servers = %#v, want runtime server", view.Servers)
}
if got, ok := svc.Agent("u-mcp"); !ok || got.MCPServers != nil {
t.Fatalf("Agent().MCPServers = %#v, want unmanaged state preserved on read", got.MCPServers)
}
}

func TestMCPServersViewKeepsPersistedServersWhenRuntimeReadFails(t *testing.T) {
readErr := errors.New("native config is unreadable")
desired := map[string]any{
"context7": map[string]any{
Expand Down Expand Up @@ -107,15 +133,9 @@ func TestMCPServersViewKeepsDesiredWhenRuntimeReadFails(t *testing.T) {
if err != nil {
t.Fatalf("MCPServersView() error = %v, want desired state to remain readable", err)
}
if view.Actual != nil {
t.Fatalf("Actual = %#v, want nil when the runtime read fails", view.Actual)
}
if !strings.Contains(view.ActualError, readErr.Error()) {
t.Fatalf("ActualError = %q, want %q", view.ActualError, readErr)
}
server, ok := view.Desired["context7"].(map[string]any)
server, ok := view.Servers["context7"].(map[string]any)
if !ok || server["command"] != "uvx" {
t.Fatalf("Desired = %#v, want raw desired server", view.Desired)
t.Fatalf("Servers = %#v, want raw persisted server", view.Servers)
}
data, err := json.Marshal(view)
if err != nil {
Expand All @@ -125,7 +145,7 @@ func TestMCPServersViewKeepsDesiredWhenRuntimeReadFails(t *testing.T) {
if err := json.Unmarshal(data, &fields); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got := string(fields["actual"]); got != "null" {
t.Fatalf("actual = %s, want null", got)
if got := string(fields["servers"]); got == "null" {
t.Fatalf("servers = %s, want persisted server map", got)
}
}
Loading
Loading