package plugin // 本文件是「MCP 服务器」管理页的**声明面**(design/09 §5.2 的 adminPanels,形状即 // `pkg/plugins/sdk` 的 Panel):清单表(状态列 + 行内动作)+ 安装表单。 // // 为什么这页由插件管理器自己声明(而不是某个外部插件):它是**管理器自己的页面** —— // 数据面就是 `*Manager` 的 `Statuses()`,动作面就是 `Install/Reconnect/SetEnabled/ // Uninstall`。把声明放到别处就要再导出一套「代管理器说话」的中转,两处必然分叉。 // // 声明只说三件事:要什么字段、怎么渲染、动作叫什么。它不注入 JS/CSS/HTML、不给 URL // (只有动作名)、不开任何路由 —— 分发走既有的通用动作端点 // `POST /api/v1/admin/plugins/{id}/actions/{action}`(写动作要求 owner/admin + 审计)。 // // 与前端通用渲染器的核对结论(本片任务书要求先验,`frontend/web/src/admin/plugin/`): // - 表格列只有 `key`/`label`(没有列类型/徽标)⇒ 状态列显示**闭集码**(与 web-search // 页的 `state` 列同一口径),工具栏退化成一列截断文本; // - 行内动作没有二次确认,且一律按**写**动作鉴权 ⇒ 卸载只能一键即发; // - `args`(字符串列表)用 `multiSelect`、`env`(键值对)用 `kv`:这两项**能直接渲染** // (无候选的 multiSelect 降级成逗号/换行分隔文本框,提交值仍是 `string[]`; // kv 是 JSON 对象输入),因此本页不需要新增字段类型、也不需要改前端。 import ( "fmt" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // MCPPanelPluginID 是「MCP 服务器」页在面板目录里的命名空间。 // // 它是**宿主内置提供者**的 id,不是某个热装 MCP server 的名字。两者可能撞名(主人可以 // 把 server 也叫 mcp),因此热装插件的生命周期一律不许改动这个名字的目录条目 // (见 isReservedPanelKey)。 const MCPPanelPluginID = "mcp" // MCPPanelID 是面板 id。前端路由为 `/admin/plugin/mcp/servers`。 const MCPPanelID = "servers" // 本页声明的动作名。它们只是名字(不是 URL),分发端点是既有的 // `POST /api/v1/admin/plugins/mcp/actions/`。 const ( // ActionListMCPServers 列出全部 server 的状态快照(表格数据源,读面)。 ActionListMCPServers = "plugin.mcpServers" // ActionInstallMCPServer 安装(或刷新)一台 server(表单提交,写面)。 ActionInstallMCPServer = "plugin.installMcpServer" // ActionReconnectMCPServer 断开重连并重新发现工具(行内动作,写面)。 ActionReconnectMCPServer = "plugin.reconnectMcpServer" // ActionEnableMCPServer 启用一台被停用的 server(行内动作,写面)。 ActionEnableMCPServer = "plugin.enableMcpServer" // ActionDisableMCPServer 停用一台 server(行内动作,写面)。 ActionDisableMCPServer = "plugin.disableMcpServer" // ActionUninstallMCPServer 卸载一台 server(行内动作,写面)。 ActionUninstallMCPServer = "plugin.uninstallMcpServer" // ActionMCPTransports 是传输形态的候选来源(读面):`optionsFrom` 为 // OptionsSourceTransports 时前端按此动作名取候选。 ActionMCPTransports = "plugin.mcpTransports" ) // OptionsSourceTransports 是表单 `transport` 字段的候选来源(声明面写法 `plugin:` 前缀)。 const OptionsSourceTransports = "plugin:mcpTransports" // 「MCP 服务器」表单与表格的字段 key。它们是**面板契约**的一部分(前端提交的键名), // 因此集中在这里,处理器引用常量而不另写字面量。 const ( // FieldServerName 是 server 名(唯一标签,也是能力命名空间 `mcp:`)。 FieldServerName = "name" // FieldTransport 是传输形态(stdio / http / sse)。 FieldTransport = "transport" // FieldCommand 是 stdio 的启动命令。 FieldCommand = "command" // FieldArgs 是 stdio 的命令参数(字符串列表)。 FieldArgs = "args" // FieldURL 是 http/sse 的服务地址(与运行时字段 endpoint 同义,面板用 url 更直白)。 FieldURL = "url" // FieldAPIKey 是下发给 server 的 Bearer 凭据(secret:读侧永不回显)。 FieldAPIKey = "apiKey" // FieldEnv 是 stdio 子进程的额外环境变量(键值对)。 FieldEnv = "env" ) // MCPAdminPanels 返回「MCP 服务器」页的声明。 func MCPAdminPanels() []sdk.Panel { return []sdk.Panel{{ ID: MCPPanelID, Title: "MCP 服务器", Description: "接入外部 MCP server 并管理它们的运行状态:每台可独立启停、重连、" + "卸载,清单里直接看到连没连上、暴露了哪些工具、最近一次错在哪。" + "配置落在 $DATA_DIR/plugins.json,装/卸/启停**立即生效**(下一轮对话即可用," + "无需重启)。", Group: sdk.GroupPlugins, Order: 10, Icon: "server", // 内置提供者恒为启用;写条件是为了让「启用事实」由面板中枢给出(单一事实来源), // 而不是由声明自己假设。 When: &sdk.When{Enabled: flagBool(true)}, Sections: []sdk.Section{ { Type: sdk.SectionTable, ID: "list", Title: "已接入的服务器", Source: &sdk.Source{Action: ActionListMCPServers}, // 列 key 与 ServerRow 的字段同源;`target` / `toolsText` 是给表格用的 // 显示派生量(地址或命令二选一、工具名拼一行),机器字段仍是 // `endpoint`/`command`/`tools[]`。 Columns: []sdk.Column{ {Key: "name", Label: "名称"}, {Key: "transport", Label: "传输"}, {Key: "target", Label: "地址 / 命令"}, {Key: "state", Label: "状态"}, {Key: "toolsText", Label: "工具"}, {Key: "lastError", Label: "最近一次错误"}, {Key: "lastSeenAt", Label: "最近连接"}, }, RowActions: []sdk.RowAction{ {ID: "reconnect", Label: "重连", Action: ActionReconnectMCPServer}, {ID: "enable", Label: "启用", Action: ActionEnableMCPServer}, {ID: "disable", Label: "停用", Action: ActionDisableMCPServer}, {ID: "uninstall", Label: "卸载", Action: ActionUninstallMCPServer}, }, }, { Type: sdk.SectionForm, ID: "install", Title: "安装 / 刷新服务器", Fields: []sdk.Field{ {Key: FieldServerName, Label: "名称", Type: sdk.FieldText, Required: true, Placeholder: "如 fs / github;同名即刷新,能力命名空间为 mcp:<名称>"}, {Key: FieldTransport, Label: "传输", Type: sdk.FieldSelect, OptionsFrom: OptionsSourceTransports, Placeholder: "留空 = 按 command / url 推断(有命令走 stdio,有地址走 http)"}, {Key: FieldCommand, Label: "启动命令(stdio)", Type: sdk.FieldText, Placeholder: "npx / uvx / 可执行文件路径"}, {Key: FieldArgs, Label: "命令参数(stdio)", Type: sdk.FieldMultiSelect, Placeholder: "逗号或换行分隔,如 -y,@modelcontextprotocol/server-filesystem,/tmp"}, {Key: FieldURL, Label: "服务地址(http / sse)", Type: sdk.FieldText, Placeholder: "https://..."}, {Key: FieldAPIKey, Label: "API Key", Type: sdk.FieldSecret, Placeholder: "留空 = 不带鉴权(非空则作为 Bearer 下发)"}, {Key: FieldEnv, Label: "环境变量(stdio)", Type: sdk.FieldKV, Placeholder: `{"TOKEN":"..."}`}, }, Submit: &sdk.Submit{Action: ActionInstallMCPServer}, }, }, }} } // DeclareMCPPanel 把「MCP 服务器」页登记到面板中枢(幂等:重复调用等于重新声明)。 // // 它是组合根在**声明阶段**要调的那一步;动作处理器另由 `(*Manager).RegisterMCPPanelActions` // 登记(声明与登记是两个事实,见 actions.go 的口径)。 func DeclareMCPPanel(hub *Panels) ([]string, error) { if hub == nil { return nil, fmt.Errorf("plugin: nil panel hub") } return hub.Declare(MCPPanelPluginID, MCPAdminPanels()) } // AttachMCPPanel 把「MCP 服务器」页完整接到本管理器的已接线中枢上:先声明、再登记动作 // 处理器。组合根在 `wirePanelHub`(AttachPanels 之后)调它一次即可。 // // 两步都在这里做完,是因为它们必须成对出现:只声明不登记 ⇒ 每个动作都诚实回 501; // 只登记不声明 ⇒ 动作不可达(Dispatch 先查声明)。中枢未接线时报错而不是静默成功。 func (m *Manager) AttachMCPPanel() error { hub := m.Panels() if hub == nil { err := fmt.Errorf("%w: %s(面板中枢未接线,MCP 管理页无处挂)", ErrInvalidActionRegistration, MCPPanelPluginID) m.log.Error("mcp panel not declared; its actions will answer 501", "pluginId", MCPPanelPluginID, "error", err) return err } if _, err := DeclareMCPPanel(hub); err != nil { m.log.Error("mcp panel declaration rejected; its actions will answer 501", "pluginId", MCPPanelPluginID, "error", err) return fmt.Errorf("plugin: declare mcp panel: %w", err) } if err := m.RegisterMCPPanelActions(); err != nil { m.log.Error("mcp panel actions not registered; they will answer 501", "pluginId", MCPPanelPluginID, "error", err) return err } m.log.Info("mcp panel attached", "pluginId", MCPPanelPluginID, "panelId", MCPPanelID) return nil } // isReservedPanelKey 报告该目录键属于**宿主内置面板提供者**(不是热装插件的命名空间)。 // // 护栏的方向是单向的:热装插件的安装/停用/卸载绝不许改动内置提供者的目录条目。理由很 // 实在 —— 内置提供者的 id 是给人看的短名字(mcp),而 server 的名字由主人随手起;一台 // 恰好叫 mcp 的 server 在卸载时若照常 MarkUninstalled("mcp"),整页「MCP 服务器」就消失了。 func isReservedPanelKey(key string) bool { return key == MCPPanelPluginID }