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 的取证)。
204 lines
10 KiB
Go
204 lines
10 KiB
Go
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/<name>`。
|
||
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:<name>`)。
|
||
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 / web-search / browser),而 server 的
|
||
// 名字由主人随手起;一台恰好同名的 server 在卸载时若照常 MarkUninstalled,那整页内置页
|
||
// 就在**重启前**消失了(`forgetPanels → MarkUninstalled` 连声明与动作索引一起清)。
|
||
//
|
||
// 名单写死成 switch 而不是包级 map:新增内置页时没补这一行,review 时一眼可见(与
|
||
// paneldecl.shapeOf 同一取舍)。当前三个内置提供者:本包的 mcp 页(MCP 服务器)、
|
||
// `plugins/tools/io/search` 的 web-search 页、`plugins/tools/browser` 的 browser 页。
|
||
func isReservedPanelKey(key string) bool {
|
||
switch key {
|
||
case MCPPanelPluginID, "web-search", "browser":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|