主人 2026-10-24:「能否将每个 MCP 服务器所承载的插件和工具按上游(来源)服务器分类
并与其他插件相对隔离?」
归属信息此前后端根本没有(GET /admin/plugins 每条只有 name/kind/description/两个
布尔),而 `mcp:` 是每台 server 一条的 KindMCP 条目的注册键,**不是**工具的命名空间
(工具名由 server 原样给出,连前缀都没有)⇒ 只能靠注册时的元数据,不能解析名字。
后端(唯一汇点盖章):
- 新增 pkg/plugins/sdk/origin.go:Origin{Kind,Server,Transport} + 可选接口
OriginAware(没实现 = 宿主内置,老工具零改动)+ OriginOf(零值视为无来源)。
- registry.Capability 加 Origin 字段;RegisterTool 盖章(覆盖热装与 .env
MCP_SERVERS_JSON 两条接入路径的合流处),ReplaceTool **同样重新盖章**(它逐字段
重建条目,漏了就是「热替换把来源抹掉」)。
- mcp.mcpTool 实现 Origin()(server 名与传输都在 cfg 上),transport 用新的
transportNameOf 报**实际会用的**形态(stdio/sse/http),与 newTransport 同一推断。
- 网关 PluginSummary 加可选 origin(omitempty):零值 ⇒ 键缺席,不是空对象。
- 卸载守卫:对单把 MCP 工具或 `mcp:<server>` 登记条目直接 400
(plugin_uninstall_scope)并指向「MCP 服务器」页 —— 原来会绕过插件管理器裸删,
server 的 names 账本不同步、重连后工具复活。判据是来源章,不是名字前缀。
前端(呈现分组):
- admin/modules/pluginOrigin.ts:纯函数按 origin.server 分节(按 server 名排序),
transport 未报告时标题省略括号,缺 server 名不冒充内置。
- Plugins.tsx:工具段两级(`来自 fs(stdio)· N 把工具` + 宿主内置),MCP 工具行去掉
「卸载」改为指向 MCP 页;`kind:"mcp"` 登记条目与未知 kind(iot)各有段如实显示,
不再静默丢弃(design/09 §5.2.1 红线③)。
零影响(有断言):tool_policies 表/策略写路径未改;ToolSchemas 不发来源(测试断言
模型面既无 origin 键也无 server 名);停用 server ⇒ 条目连来源一起消失,无孤儿。
测试:registry 盖章/保章(两条均已实测变异变红)/内置零值/改策略不清章;mcp 注册
路径盖章 + transport 推断表;网关读面缺席与存在、热装整链、卸载守卫;前端 esbuild+cjs
逻辑测试 12 项。验收:go build/vet/test 全绿,frontend `npm run build` 通过。
296 lines
12 KiB
Go
296 lines
12 KiB
Go
package gateway
|
||
|
||
// 管理侧插件安装/卸载与已安装插件列表。运行时「插件」按 design/09
|
||
// §2.1 有两类,都经同一个 install 端点、由 body 的 `kind` 分派:
|
||
//
|
||
// - MCP server(本文件):安装即连接它、把它的工具热注册进能力注册表
|
||
// (从而无需重启就注入下一次模型请求),把其配置持久化到被 gitignore
|
||
// 的 plugins.json,并持有活着的 server 以便之后刷新/卸载。
|
||
// - Skill(admin_plugins_skill.go):安装即解析一份工作流 YAML 并
|
||
// RegisterSkill 进注册表。
|
||
//
|
||
// 在单静态二进制中安装编译好的 Go 插件不可能,因此不提供该方式。
|
||
// 写操作仅 owner 且带审计;API key 会被持久化,但绝不返回,
|
||
// 也绝不原样记日志。
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
)
|
||
|
||
// pluginInstallTimeout 限定安装请求所等待的 connect+discover 握手
|
||
// 时长。插件管理器在其中施加自己的每 server 超时。
|
||
const pluginInstallTimeout = 20 * time.Second
|
||
|
||
// pluginActionRequest 是 POST /api/v1/admin/plugins/uninstall 的请求体。
|
||
type pluginActionRequest struct {
|
||
Name string `json:"name"`
|
||
Kind string `json:"kind"`
|
||
}
|
||
|
||
// pluginInstallRequest 是 POST /api/v1/admin/plugins/install 的请求体。Config
|
||
// 承载 MCP server 连接字段;顶层的 name/kind 被单独
|
||
// 保留,使该请求读起来与其他管理写一致。YAML/Path 是
|
||
// kind="skill" 的两种来源(内联内容或磁盘路径,二选一)。
|
||
type pluginInstallRequest struct {
|
||
Kind string `json:"kind"`
|
||
Name string `json:"name"`
|
||
Config pluginInstallConfig `json:"config"`
|
||
// YAML 是 Skill 定义的 YAML 文本(kind="skill")。
|
||
YAML string `json:"yaml"`
|
||
// Path 是 Skill 定义的 YAML 文件路径(kind="skill"),与 YAML 互斥。
|
||
Path string `json:"path"`
|
||
}
|
||
|
||
// pluginInstallConfig 是 MCP server 连接块。Endpoint 是 http/sse
|
||
// 的 URL;url 作为别名被接受。APIKey/Env 会被持久化,但绝不
|
||
// 回显。
|
||
type pluginInstallConfig struct {
|
||
Transport string `json:"transport"`
|
||
Endpoint string `json:"endpoint"`
|
||
URL string `json:"url"`
|
||
Command string `json:"command"`
|
||
Args []string `json:"args"`
|
||
APIKey string `json:"apiKey"`
|
||
Env map[string]string `json:"env"`
|
||
// Enabled 是安装时的初始启用态。缺省(nil)=启用;显式 false =装上但先不连
|
||
// (配置落盘、工具不注册,之后可由面板「启用」)。
|
||
Enabled *bool `json:"enabled"`
|
||
}
|
||
|
||
// AdminInstalledPlugin 是管理列表中一个已安装插件的线上
|
||
// 结构。API key 与任何敏感 env 值都会脱敏。
|
||
type AdminInstalledPlugin struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Kind string `json:"kind"`
|
||
Transport string `json:"transport"`
|
||
Endpoint string `json:"endpoint,omitempty"`
|
||
Command string `json:"command,omitempty"`
|
||
Args []string `json:"args,omitempty"`
|
||
APIKey string `json:"apiKey,omitempty"`
|
||
Env map[string]string `json:"env,omitempty"`
|
||
CreatedAt string `json:"createdAt,omitempty"`
|
||
// Enabled 是这一台的启用态(plugins.json 的 `enabled`,缺省视为 true)。
|
||
// 停用 ≠ 卸载:条目仍在列表里,只是没连、没注册工具。
|
||
Enabled bool `json:"enabled"`
|
||
// Live 报告 MCP server 是否已连接、其工具是否已注册。
|
||
Live bool `json:"live"`
|
||
// ToolCount 是活着的 server 已注册的工具数。
|
||
ToolCount int `json:"toolCount"`
|
||
}
|
||
|
||
// adminPluginInstall 安装(或刷新)一个运行时插件。它先按 body 的
|
||
// kind 分派(缺省为 mcp),两类的失败语义一致:校验或接入失败即返回
|
||
// 400/502/409 且不留下任何半安装状态。
|
||
func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
|
||
var req pluginInstallRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
s.auditClaims(r, "plugin.install", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||
return
|
||
}
|
||
kind := strings.TrimSpace(req.Kind)
|
||
if kind == "" {
|
||
kind = runtimeconfig.PluginKindMCP
|
||
}
|
||
switch kind {
|
||
case runtimeconfig.PluginKindMCP:
|
||
s.adminInstallMCPPlugin(w, r, req)
|
||
case pluginKindSkill:
|
||
s.adminInstallSkillPlugin(w, r, req)
|
||
default:
|
||
s.auditClaims(r, "plugin.install", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "kind %q is not installable; only %s and %s", kind, runtimeconfig.PluginKindMCP, pluginKindSkill))
|
||
}
|
||
}
|
||
|
||
// adminInstallMCPPlugin 安装(或刷新)一个 MCP server 插件:它校验
|
||
// 请求、连接并发现该 server 的工具,然后持久化配置。
|
||
// connect/discover/register 失败会返回 502 且不持久化任何东西,
|
||
// 因此插件绝不会处于半安装状态。
|
||
func (s *Server) adminInstallMCPPlugin(w http.ResponseWriter, r *http.Request, req pluginInstallRequest) {
|
||
name := strings.TrimSpace(req.Name)
|
||
if name == "" {
|
||
s.auditClaims(r, "plugin.install", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
|
||
return
|
||
}
|
||
if s.plugins == nil {
|
||
s.auditClaims(r, "plugin.install", "error")
|
||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "插件服务未注入(未注入 plugin manager)", HTTPCode: http.StatusNotImplemented})
|
||
return
|
||
}
|
||
cfg := runtimeconfig.PluginConfig{
|
||
Name: name,
|
||
Kind: runtimeconfig.PluginKindMCP,
|
||
Transport: strings.TrimSpace(req.Config.Transport),
|
||
Endpoint: firstNonEmpty(req.Config.Endpoint, req.Config.URL),
|
||
Command: strings.TrimSpace(req.Config.Command),
|
||
Args: req.Config.Args,
|
||
APIKey: req.Config.APIKey,
|
||
Env: req.Config.Env,
|
||
Enabled: req.Config.Enabled,
|
||
}.Normalize()
|
||
if err := cfg.Validate(); err != nil {
|
||
s.auditClaims(r, "plugin.install", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", err))
|
||
return
|
||
}
|
||
ctx, cancel := context.WithTimeout(r.Context(), pluginInstallTimeout)
|
||
defer cancel()
|
||
res, err := s.plugins.Install(ctx, cfg)
|
||
if err != nil {
|
||
s.auditClaims(r, "plugin.install", "error")
|
||
s.log.Error("plugin install failed", "name", name, "transport", cfg.Transport, "error", err)
|
||
writeError(w, &apperr.AppError{
|
||
Code: "plugin_install_failed",
|
||
Message: fmt.Sprintf("连接/发现 MCP server %q 失败,未落盘", name),
|
||
Cause: err,
|
||
HTTPCode: http.StatusBadGateway,
|
||
})
|
||
return
|
||
}
|
||
s.auditClaims(r, "plugin.install", "ok")
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"installed": true,
|
||
"name": res.Config.Name,
|
||
"kind": res.Config.Kind,
|
||
"transport": res.Config.Transport,
|
||
"enabled": res.Config.IsEnabled(),
|
||
"toolCount": res.ToolCount,
|
||
})
|
||
}
|
||
|
||
// adminPluginUninstall 卸载一个插件。已安装的 MCP server 会被关闭、
|
||
// 其工具经插件管理器移除,随后其配置从 plugins.json 删除;
|
||
// 其他任何名字回退为把该能力从注册表移除,
|
||
// 保持既有行为。
|
||
func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) {
|
||
var req pluginActionRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||
return
|
||
}
|
||
name := strings.TrimSpace(req.Name)
|
||
if name == "" {
|
||
s.auditClaims(r, "plugin.uninstall", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
|
||
return
|
||
}
|
||
if s.plugins != nil {
|
||
removed, err := s.plugins.Uninstall(r.Context(), name)
|
||
if err != nil {
|
||
s.auditClaims(r, "plugin.uninstall", "error")
|
||
s.log.Error("plugin uninstall failed", "name", name, "error", err)
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugin_remove_failed", "failed to uninstall plugin"))
|
||
return
|
||
}
|
||
if removed {
|
||
s.auditClaims(r, "plugin.uninstall", "ok")
|
||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "uninstalled": true, "kind": runtimeconfig.PluginKindMCP})
|
||
return
|
||
}
|
||
}
|
||
reg, ok := s.caps.(*capreg.Registry)
|
||
if !ok {
|
||
s.auditClaims(r, "plugin.uninstall", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugins_unavailable", "capability registry is not configured"))
|
||
return
|
||
}
|
||
// 🔴 单把 MCP 工具与 `mcp:<server>` 登记条目**不能**走这条裸删路径
|
||
// (DevDocs/audit/MCP工具按上游分组-2026-10-24.md §1.5-2):管理器
|
||
// Uninstall 只按 server 名/ID 匹配,落到这里就等于绕过插件管理器直接改注册表 ——
|
||
// 该 server 的 names 账本不同步(下次 refresh 会把工具当「新出现的」再注册回来,
|
||
// 或让 KindMCP 条目与工具各自消失一半)。判据是**注册时的来源章**,不是名字前缀。
|
||
if cap, err := reg.Get(name); err == nil {
|
||
if reason := serverScopedUninstall(name, cap); reason != "" {
|
||
s.auditClaims(r, "plugin.uninstall", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "plugin_uninstall_scope", "%s", reason))
|
||
return
|
||
}
|
||
}
|
||
if err := reg.Remove(name); err != nil {
|
||
s.auditClaims(r, "plugin.uninstall", "error")
|
||
if errors.Is(err, capreg.ErrNotFound) {
|
||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "plugin %q not found", name))
|
||
return
|
||
}
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugin_remove_failed", "failed to uninstall plugin"))
|
||
return
|
||
}
|
||
s.auditClaims(r, "plugin.uninstall", "ok")
|
||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "uninstalled": true})
|
||
}
|
||
|
||
// serverScopedUninstall 报告「这个名字必须在『MCP 服务器』页按整台 server 处理,
|
||
// 不能在这里单删」的原因;返回空串 = 可以按能力名删除(技能 / 普通工具 / 渠道)。
|
||
//
|
||
// 它拦住两类真实事故(同一条根因:绕开插件管理器裸删):
|
||
// - 一把 MCP 工具:server 的 names 账本不同步,重连/refresh 后工具复活,用户以为卸了;
|
||
// - `mcp:<server>` 登记条目:server 条目消失了而它的工具还在,注册表里留下无法归属的孤儿。
|
||
func serverScopedUninstall(name string, cap capreg.Capability) string {
|
||
if cap.Kind == capreg.KindMCP {
|
||
return fmt.Sprintf("%q 是一台 MCP server 的登记条目;请在「MCP 服务器」页卸载该 server", name)
|
||
}
|
||
if cap.Origin.Kind == sdk.OriginKindMCP {
|
||
return fmt.Sprintf("%q 是 MCP server %q 的工具,单把工具不能单独卸载;请在「MCP 服务器」页启停或卸载该 server", name, cap.Origin.Server)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// adminListInstalledPlugins 列出运行时安装的插件,附其脱敏配置
|
||
// 与每 server 工具数。它是读端点(owner/admin),
|
||
// 绝不原样返回 API key 或敏感 env 值。
|
||
func (s *Server) adminListInstalledPlugins(w http.ResponseWriter, r *http.Request) {
|
||
out := make([]AdminInstalledPlugin, 0)
|
||
if s.plugins != nil {
|
||
for _, it := range s.plugins.List() {
|
||
out = append(out, adminInstalledPluginOf(it))
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
// adminInstalledPluginOf 把已安装插件映射为其脱敏后的线上结构。
|
||
func adminInstalledPluginOf(it pluginmgr.Installed) AdminInstalledPlugin {
|
||
cfg := runtimeconfig.MaskedPlugin(it.Config)
|
||
return AdminInstalledPlugin{
|
||
ID: cfg.ID,
|
||
Name: cfg.Name,
|
||
Kind: cfg.Kind,
|
||
Transport: cfg.Transport,
|
||
Endpoint: cfg.Endpoint,
|
||
Command: cfg.Command,
|
||
Args: cfg.Args,
|
||
APIKey: cfg.APIKey,
|
||
Env: cfg.Env,
|
||
CreatedAt: cfg.CreatedAt,
|
||
Enabled: cfg.IsEnabled(),
|
||
Live: it.Live,
|
||
ToolCount: it.ToolCount,
|
||
}
|
||
}
|
||
|
||
// firstNonEmpty 返回首个非空值,因此安装请求体可用 endpoint
|
||
// 或 url 任一字段给出 server 地址。
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, v := range values {
|
||
if strings.TrimSpace(v) != "" {
|
||
return strings.TrimSpace(v)
|
||
}
|
||
}
|
||
return ""
|
||
}
|