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 := 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: actionStringList(values[FieldArgs]), APIKey: actionText(values, FieldAPIKey), Env: actionStringMap(values[FieldEnv]), }.Normalize() 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 } // 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) 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) return nil, NewActionError(mcpCodeDisableFailed, fmt.Sprintf("停用 %q 失败:启用态未能落盘,详细原因见后端日志", 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`;元素非字符串即忽略,空串不保留)。空列表返回 nil(配置里就不写这个键)。 func actionStringList(raw any) []string { items, ok := raw.([]any) if !ok { if strs, okStr := raw.([]string); okStr { return compactStrings(strs) } return nil } out := make([]string, 0, len(items)) for _, item := range items { s, ok := item.(string) if !ok { continue } out = append(out, s) } return compactStrings(out) } // actionStringMap 取一个键值对(`kv` 提交的是 JSON 对象)。 // // 值只接受字符串:`{"PORT":8080}`(数字)这类会被**拒之门外而不是静默转成 "8080"** —— // 静默转换正是「看着配好了、其实没生效」的来源。 func actionStringMap(raw any) map[string]string { obj, ok := raw.(map[string]any) if !ok { return nil } out := make(map[string]string, len(obj)) for k, v := range obj { s, ok := v.(string) if !ok { continue } key := strings.TrimSpace(k) if key == "" { continue } out[key] = s } if len(out) == 0 { return nil } return out } // 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 "" }