r1 复审 P2-1 + 两条顺手件。
P2-1(必修):`actionStringMap` / `actionStringList` 之前对非字符串值 `continue`,于是
前端 kv 控件里最自然的写法 `{"PORT":8080}` 会被**静默丢掉**、安装仍报 `installed:true`。
现在非字符串值、空键、非数组一律 400 invalid_config,文案只点名**键与类型**(不回显值);
盘上零变更、注册表零工具、快照零行(测试同时断言)。
顺手件 1:`isReservedPanelKey` 从「只挡 mcp」改成内置提供者闭集(mcp / web-search /
browser,写成 switch,新增内置页时漏补一眼可见)。之前一台名为 web-search 的 server 被
卸载时,`forgetPanels → MarkUninstalled` 会把内置「搜索提供商」页在重启前整页摘掉。
顺手件 2:启用「已连上但启用态未落盘」的文案改成事实(原来那句「它仍处于停用状态」与
事实相反)。为此新增 `ErrPluginStatePersist` 哨兵,把「磁盘没记住」与「连不上」分开;
`state=connected` 的派生口径不动。停用失败文案同时补上「server 仍在运行」。
测试:三条新用例,均已验「修前红 → 修后绿」(见 review-request 的取证)。
406 lines
16 KiB
Go
406 lines
16 KiB
Go
package plugin
|
||
|
||
// 本文件是「MCP 服务器」页的**执行面**(切片 M20):动作处理器 + 载荷解析 + 失败分类。
|
||
//
|
||
// 三条分工(与 design/09 §5.2.1 的动作分发一栏同向):
|
||
// - 鉴权与审计在宿主端点(网关 `adminPluginAction`:写动作要求 owner/admin,每次落审计);
|
||
// - **能不能调**由声明决定(Dispatch 先查声明),处理器只负责「这个名字要做什么」;
|
||
// - 失败要说清病因:ActionError 带稳定错误码 + 一句人话 + 目标状态码,绝不把上游报文
|
||
// 原样吐给前端(§5.4.6),也绝不把凭据写进返回值或日志。
|
||
//
|
||
// 载荷形状由前端通用渲染器决定(`frontend/web/src/admin/plugin/tableState.ts` /
|
||
// `formState.ts`):表格数据源发空体;行内动作发 `{"row":{…行数据…}}`;表单提交发
|
||
// `{"values":{…}}`(multiSelect → `[]string`、kv → JSON 对象、switch → bool、其余 → string)。
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
)
|
||
|
||
// 本页动作的失败码(机器可读、稳定;前端按码本地化,不解析文案)。
|
||
const (
|
||
// mcpCodeInvalidConfig 报告提交的配置本身不合法(缺 name / 缺 command / 缺 endpoint)。
|
||
mcpCodeInvalidConfig = "invalid_config"
|
||
// mcpCodeInstallFailed 报告连接或发现失败(配置**不会**落盘)。
|
||
mcpCodeInstallFailed = "install_failed"
|
||
// mcpCodeReconnectFailed 报告重连失败(原连接已被摘下)。
|
||
mcpCodeReconnectFailed = "reconnect_failed"
|
||
// mcpCodeEnableFailed 报告启用失败(server 保持停用,不假装开着)。
|
||
mcpCodeEnableFailed = "enable_failed"
|
||
// mcpCodeDisableFailed 报告停用失败(落盘失败;连接可能仍在)。
|
||
mcpCodeDisableFailed = "disable_failed"
|
||
// mcpCodeUninstallFailed 报告卸载失败(落盘失败;工具是否已下线取决于错误发生的时机)。
|
||
mcpCodeUninstallFailed = "uninstall_failed"
|
||
// mcpCodeServerNotFound 报告点名的 server 既不在 plugins.json 里也不活着。
|
||
mcpCodeServerNotFound = "server_not_found"
|
||
// mcpCodeServerDisabled 报告对一台已停用的 server 发起了「重连」。
|
||
mcpCodeServerDisabled = "server_disabled"
|
||
)
|
||
|
||
// RegisterMCPPanelActions 给「MCP 服务器」页登记动作处理器。
|
||
//
|
||
// 它必须在**声明之后**调用(`AttachMCPPanel` 就按这个顺序做):登记期会核对「登记的
|
||
// 处理器有没有对应声明」,顺序反过来只会得到一串「声明还没到」的 WARN。
|
||
func (m *Manager) RegisterMCPPanelActions() error {
|
||
return m.RegisterActions(MCPPanelPluginID, map[string]ActionHandler{
|
||
ActionListMCPServers: m.actListServers,
|
||
ActionInstallMCPServer: m.actInstallServer,
|
||
ActionReconnectMCPServer: m.actReconnectServer,
|
||
ActionEnableMCPServer: m.actEnableServer,
|
||
ActionDisableMCPServer: m.actDisableServer,
|
||
ActionUninstallMCPServer: m.actUninstallServer,
|
||
ActionMCPTransports: m.actTransports,
|
||
})
|
||
}
|
||
|
||
// actListServers 返回全部 server 的状态快照(表格数据源)。
|
||
//
|
||
// 它是**读面**(viewer 也能调):快照里没有任何凭据(见 status.go 的口径②)。
|
||
func (m *Manager) actListServers(_ context.Context, _ ActionRequest) (any, error) {
|
||
statuses := m.Statuses()
|
||
rows := make([]ServerRow, 0, len(statuses))
|
||
for _, st := range statuses {
|
||
rows = append(rows, serverRowOf(st))
|
||
}
|
||
return map[string]any{"rows": rows}, nil
|
||
}
|
||
|
||
// actTransports 返回传输形态候选(`transport` 字段的 `optionsFrom`)。
|
||
//
|
||
// 候选的 value 与运行时接受的取值逐字一致(`runtimeconfig.Transport*`),label 是给人看
|
||
// 的一句说明:写 `http` 而以为是「老式 SSE」是最常见的一次配错。
|
||
func (m *Manager) actTransports(_ context.Context, _ ActionRequest) (any, error) {
|
||
return map[string]any{"options": []map[string]string{
|
||
{"value": runtimeconfig.TransportStdio, "label": "stdio(启动本地子进程)"},
|
||
{"value": runtimeconfig.TransportHTTP, "label": "http(Streamable HTTP)"},
|
||
{"value": runtimeconfig.TransportSSE, "label": "sse(老式 SSE 长连接)"},
|
||
}}, nil
|
||
}
|
||
|
||
// actInstallServer 安装(或刷新)一台 server:连接 + 发现 + 注册后才落盘。
|
||
func (m *Manager) actInstallServer(ctx context.Context, req ActionRequest) (any, error) {
|
||
values, err := actionValues(req.Payload)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
cfg, err := installConfigOf(values)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := cfg.Validate(); err != nil {
|
||
return nil, NewActionError(mcpCodeInvalidConfig, fmt.Sprintf("配置不完整:%v", err), 400, err)
|
||
}
|
||
res, err := m.Install(ctx, cfg)
|
||
if err != nil {
|
||
m.log.Warn("mcp install failed", "name", cfg.Name, "transport", cfg.Transport, "error", err)
|
||
return nil, NewActionError(mcpCodeInstallFailed,
|
||
fmt.Sprintf("连接/发现 MCP server %q 失败,配置未落盘", cfg.Name), 502, err)
|
||
}
|
||
return map[string]any{
|
||
"installed": true,
|
||
"name": res.Config.Name,
|
||
"transport": res.Config.Transport,
|
||
"toolCount": res.ToolCount,
|
||
}, nil
|
||
}
|
||
|
||
// installConfigOf 把表单值翻译成插件配置。
|
||
//
|
||
// `args` / `env` 的形状错**必须在这里变成 400**:前端的 `kv` 控件是自由 JSON textarea,
|
||
// 主人写 `{"PORT":8080}` 是最自然的写法,若后端把非字符串值静默丢掉,结果就是「装完提示
|
||
// 成功、server 也连上了、env 却没生效」—— 正是任务书点名要防的「看着配好了、其实没生效」。
|
||
// 同理 `args` 里的非字符串元素也不许悄悄消失。
|
||
func installConfigOf(values map[string]any) (runtimeconfig.PluginConfig, error) {
|
||
args, err := actionStringList(values[FieldArgs])
|
||
if err != nil {
|
||
return runtimeconfig.PluginConfig{}, invalidConfigError(err)
|
||
}
|
||
env, err := actionStringMap(values[FieldEnv])
|
||
if err != nil {
|
||
return runtimeconfig.PluginConfig{}, invalidConfigError(err)
|
||
}
|
||
return runtimeconfig.PluginConfig{
|
||
Name: actionText(values, FieldServerName),
|
||
Kind: runtimeconfig.PluginKindMCP,
|
||
Transport: actionText(values, FieldTransport),
|
||
Endpoint: firstNonEmpty(actionText(values, FieldURL), actionText(values, "endpoint")),
|
||
Command: actionText(values, FieldCommand),
|
||
Args: args,
|
||
APIKey: actionText(values, FieldAPIKey),
|
||
Env: env,
|
||
}.Normalize(), nil
|
||
}
|
||
|
||
// invalidConfigError 把一条「配置没写对」包成结构化动作失败(400)。
|
||
//
|
||
// 文案里只出现**键名与类型**,绝不回显值:env 的值可能就是凭据。
|
||
func invalidConfigError(err error) error {
|
||
return NewActionError(mcpCodeInvalidConfig, fmt.Sprintf("配置没写对:%v", err), 400, err)
|
||
}
|
||
|
||
// actReconnectServer 断开并重新连接一台已启用的 server(重新发现工具)。
|
||
func (m *Manager) actReconnectServer(ctx context.Context, req ActionRequest) (any, error) {
|
||
name, err := actionRowName(req.Payload)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
res, err := m.Reconnect(ctx, name)
|
||
switch {
|
||
case errors.Is(err, ErrPluginNotFound):
|
||
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
|
||
case errors.Is(err, ErrPluginDisabled):
|
||
return nil, NewActionError(mcpCodeServerDisabled,
|
||
fmt.Sprintf("server %q 已停用:先「启用」再重连", name), 409, err)
|
||
case err != nil:
|
||
m.log.Warn("mcp reconnect failed", "name", name, "error", err)
|
||
return nil, NewActionError(mcpCodeReconnectFailed,
|
||
fmt.Sprintf("重连 %q 失败,详细原因见后端日志", name), 502, err)
|
||
}
|
||
return map[string]any{"name": res.Config.Name, "reconnected": true, "toolCount": res.ToolCount}, nil
|
||
}
|
||
|
||
// actEnableServer 启用一台 server(连接成功后才把 enabled=true 落盘)。
|
||
func (m *Manager) actEnableServer(ctx context.Context, req ActionRequest) (any, error) {
|
||
name, err := actionRowName(req.Payload)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := m.SetEnabled(ctx, name, true); err != nil {
|
||
if errors.Is(err, ErrPluginNotFound) {
|
||
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
|
||
}
|
||
m.log.Warn("mcp enable failed", "name", name, "error", err)
|
||
// 两种失败的事实不同,文案也必须不同:连不上 ⇒ 它仍是停用的;**已经连上但启用态
|
||
// 没落盘** ⇒ 现在能用、重启后会回到停用(说成「仍处于停用状态」就是与事实相反)。
|
||
if errors.Is(err, ErrPluginStatePersist) {
|
||
return nil, NewActionError(mcpCodeEnableFailed,
|
||
fmt.Sprintf("%q 已连上,但启用态未落盘:重启后会回到停用,详细原因见后端日志", name), 502, err)
|
||
}
|
||
return nil, NewActionError(mcpCodeEnableFailed,
|
||
fmt.Sprintf("启用 %q 失败:它仍处于停用状态,详细原因见后端日志", name), 502, err)
|
||
}
|
||
return map[string]any{"name": name, "enabled": true, "connected": true}, nil
|
||
}
|
||
|
||
// actDisableServer 停用一台 server(工具随之下线,配置保留)。
|
||
func (m *Manager) actDisableServer(ctx context.Context, req ActionRequest) (any, error) {
|
||
name, err := actionRowName(req.Payload)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := m.SetEnabled(ctx, name, false); err != nil {
|
||
if errors.Is(err, ErrPluginNotFound) {
|
||
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err)
|
||
}
|
||
m.log.Warn("mcp disable failed", "name", name, "error", err)
|
||
// 停用的落盘在**前面**(见 enabled.go):写不进去时 server 还在跑、工具还在。
|
||
return nil, NewActionError(mcpCodeDisableFailed,
|
||
fmt.Sprintf("停用 %q 失败:启用态未能落盘,server 仍在运行(可再试一次),详细原因见后端日志", name), 500, err)
|
||
}
|
||
return map[string]any{"name": name, "enabled": false, "connected": false}, nil
|
||
}
|
||
|
||
// actUninstallServer 卸载一台 server(摘工具 + 删配置)。
|
||
func (m *Manager) actUninstallServer(ctx context.Context, req ActionRequest) (any, error) {
|
||
name, err := actionRowName(req.Payload)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
removed, err := m.Uninstall(ctx, name)
|
||
if err != nil {
|
||
m.log.Warn("mcp uninstall failed", "name", name, "error", err)
|
||
return nil, NewActionError(mcpCodeUninstallFailed,
|
||
fmt.Sprintf("卸载 %q 失败,详细原因见后端日志", name), 500, err)
|
||
}
|
||
if !removed {
|
||
return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, nil)
|
||
}
|
||
return map[string]any{"name": name, "uninstalled": true}, nil
|
||
}
|
||
|
||
// ServerRow 是「MCP 服务器」表格的一行:状态快照 + 两列显示派生量。
|
||
//
|
||
// 快照字段(Status)逐字嵌入,因此一行既是表格数据、也是机器可读的状态契约;两个派生列
|
||
// 只为表格排版而存在(一列只能显示一个标量)。
|
||
type ServerRow struct {
|
||
Status
|
||
// Target 是「地址 / 命令」列的显示值(http/sse ⇒ 地址;stdio ⇒ 命令)。
|
||
Target string `json:"target,omitempty"`
|
||
// ToolsText 是「工具」列的显示值(工具名以「、」拼接并截断)。
|
||
ToolsText string `json:"toolsText,omitempty"`
|
||
}
|
||
|
||
// serverRowOf 把一条快照翻成表格行。
|
||
func serverRowOf(st Status) ServerRow {
|
||
target := st.Endpoint
|
||
if target == "" {
|
||
target = st.Command
|
||
}
|
||
return ServerRow{Status: st, Target: target, ToolsText: toolsText(st.Tools)}
|
||
}
|
||
|
||
// --- 载荷解析 -----------------------------------------------------------------
|
||
|
||
// actionValues 取出表单提交的 `values` 对象(缺省 / 形状不对都如实报错)。
|
||
func actionValues(payload any) (map[string]any, error) {
|
||
obj, ok := payload.(map[string]any)
|
||
if !ok {
|
||
return nil, NewActionError(ActionCodeInvalidPayload, "请求体缺少 values 对象", 400, nil)
|
||
}
|
||
raw, ok := obj["values"]
|
||
if !ok {
|
||
return nil, NewActionError(ActionCodeInvalidPayload, "请求体缺少 values 对象", 400, nil)
|
||
}
|
||
values, ok := raw.(map[string]any)
|
||
if !ok {
|
||
return nil, NewActionError(ActionCodeInvalidPayload, "values 必须是对象", 400, nil)
|
||
}
|
||
return values, nil
|
||
}
|
||
|
||
// actionRowName 取出行内动作点名的 server 名(`{"row":{"name":…}}`,也接受 `{"name":…}`)。
|
||
func actionRowName(payload any) (string, error) {
|
||
obj, ok := payload.(map[string]any)
|
||
if !ok {
|
||
return "", NewActionError(ActionCodeInvalidPayload, "行内动作缺少 row 对象", 400, nil)
|
||
}
|
||
row, ok := obj["row"].(map[string]any)
|
||
if !ok {
|
||
// 容错:某些客户端直接发 `{name, …}`。名字仍然只有这一个真源,不猜别的键。
|
||
row = obj
|
||
}
|
||
name := actionText(row, FieldServerName)
|
||
if name == "" {
|
||
return "", NewActionError(ActionCodeInvalidPayload, "row 里缺少 name", 400, nil)
|
||
}
|
||
return name, nil
|
||
}
|
||
|
||
// actionText 取一个字符串值(非字符串 / 缺省都给空串 —— 空串在配置里就是「没填」)。
|
||
func actionText(values map[string]any, key string) string {
|
||
raw, ok := values[key]
|
||
if !ok {
|
||
return ""
|
||
}
|
||
s, ok := raw.(string)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
// actionStringList 取一个字符串列表(`multiSelect` 提交的是 `[]string`,JSON 解码后是
|
||
// `[]any`)。
|
||
//
|
||
// 形状错一律报错:非数组、元素非字符串都不许**静默丢弃**(少一个参数、或多出一个空参数,
|
||
// 都会让 server 起来后行为不对,而面板还报「装好了」)。空项按前端 `splitListValue` 的同一
|
||
// 口径去掉(那只来自多余的分隔符,不是用户意图)。
|
||
func actionStringList(raw any) ([]string, error) {
|
||
switch items := raw.(type) {
|
||
case nil:
|
||
return nil, nil
|
||
case []string:
|
||
return compactStrings(items), nil
|
||
case []any:
|
||
out := make([]string, 0, len(items))
|
||
for i, item := range items {
|
||
s, ok := item.(string)
|
||
if !ok {
|
||
return nil, fmt.Errorf("args 的第 %d 项不是字符串(收到 %s);每个参数都必须是字符串", i+1, jsonKindOf(item))
|
||
}
|
||
out = append(out, s)
|
||
}
|
||
return compactStrings(out), nil
|
||
default:
|
||
return nil, fmt.Errorf("args 必须是字符串数组(收到 %s)", jsonKindOf(raw))
|
||
}
|
||
}
|
||
|
||
// actionStringMap 取一个键值对(`kv` 提交的是 JSON 对象)。
|
||
//
|
||
// 值**只接受字符串**,其它类型一律报错而不是丢掉那个键:`{"PORT":8080}` 是最自然的写法,
|
||
// 静默丢掉它等于「装完提示成功、env 却没生效」(前端 `KvControl` 只校验「是个对象」,
|
||
// 所以这条路 UI 天然可达)。空键同理 —— 丢一个键与拒一个键必须选后者。
|
||
func actionStringMap(raw any) (map[string]string, error) {
|
||
var pairs map[string]any
|
||
switch obj := raw.(type) {
|
||
case nil:
|
||
return nil, nil
|
||
case map[string]any:
|
||
pairs = obj
|
||
case map[string]string:
|
||
pairs = make(map[string]any, len(obj))
|
||
for k, v := range obj {
|
||
pairs[k] = v
|
||
}
|
||
default:
|
||
return nil, fmt.Errorf("env 必须是键值对对象(收到 %s)", jsonKindOf(raw))
|
||
}
|
||
out := make(map[string]string, len(pairs))
|
||
for k, v := range pairs {
|
||
key := strings.TrimSpace(k)
|
||
if key == "" {
|
||
return nil, fmt.Errorf("env 里有空键;键就是环境变量名,不能为空")
|
||
}
|
||
s, ok := v.(string)
|
||
if !ok {
|
||
return nil, fmt.Errorf("env.%s 的值不是字符串(收到 %s);要写数字/布尔请加引号,如 \"8080\"", key, jsonKindOf(v))
|
||
}
|
||
out[key] = s
|
||
}
|
||
if len(out) == 0 {
|
||
return nil, nil
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// jsonKindOf 给出一个 JSON 值的中文类型名(只为错误文案:说清「收到的是什么」)。
|
||
//
|
||
// 它刻意不打印值本身:env 的值可能就是凭据。
|
||
func jsonKindOf(v any) string {
|
||
switch v.(type) {
|
||
case nil:
|
||
return "null"
|
||
case string:
|
||
return "字符串"
|
||
case bool:
|
||
return "布尔"
|
||
case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||
return "数字"
|
||
case []any:
|
||
return "数组"
|
||
case map[string]any:
|
||
return "对象"
|
||
default:
|
||
return fmt.Sprintf("%T", v)
|
||
}
|
||
}
|
||
|
||
// compactStrings 去掉两端空白与空项;结果为空时返回 nil。
|
||
func compactStrings(items []string) []string {
|
||
out := make([]string, 0, len(items))
|
||
for _, item := range items {
|
||
if s := strings.TrimSpace(item); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
// firstNonEmpty 返回首个非空值(安装表单里 url 与 endpoint 是同义键)。
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, v := range values {
|
||
if strings.TrimSpace(v) != "" {
|
||
return strings.TrimSpace(v)
|
||
}
|
||
}
|
||
return ""
|
||
}
|