Files
Cyrene_v2/internal/services/gateway/admin_plugins.go
T
AskaEth 86aef7b454 feat(capability): 工具按上游 MCP server 分节(来源章 + 呈现隔离)
主人 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` 通过。
2026-10-24 20:21:22 +08:00

296 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ""
}