Files
Cyrene_v2/internal/services/gateway/admin_plugins.go
T
AskaEth 70c9fba529
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(plugin): 插件热安装(MCP server) — POST /admin/plugins/install 连接+发现+注册工具进registry+落盘plugins.json
- capability/plugin/manager.go: Manager(Install/Uninstall/List/Restore/Close); Install=连接+发现+mcp:name注册工具,失败不落盘; holder持有*mcp.Registry; boot Restore重装
- config/runtimeconfig/plugins.go: PluginConfig(Normalize/Validate), LoadPlugins/SavePlugins(0600+tmp+rename)/Upsert/Delete/Masked(apiKey+敏感env掩码)
- mcp: MCPConfig加APIKey/Env, http/sse Bearer应用, stdio子进程env合并; registry.ToolNames()
- gateway: adminPluginInstall/Uninstall/ListInstalledPlugins; Deps.Plugins注入
- cmd/cyrene/plugins.go: wirePlugins(boot Restore+close)
- 契约: body{kind:mcp,name,config{transport, endpoint?/command?,args?,apiKey?,env?}} → {installed,name,kind,transport,toolCount}; 失败502不落盘/非法400/未注入501; 卸载连带unregister+删配置
- 测试(manager/plugins/admin/mcp_auth/组合根), build/vet/test绿+3平台交叉编译
- Docs/api-client v0.28 + api-reference install=501改真实; .gitignore加plugins.json
- 前端api.ts仍发旧body(无config)会400, 面板安装表单待后续
2026-09-08 23:20:12 +08:00

239 lines
8.9 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
// Admin plugin install/uninstall and the installed-plugin listing. A runtime
// "plugin" in v1 is an external MCP server: install connects it, hot-registers
// its tools into the capability registry (so they are injected into the next
// model request without a restart), persists its config to the gitignored
// plugins.json, and holds the live server for a later refresh/uninstall
// (design/09 插件生态 §1). Installing a compiled Go plugin is impossible in a
// single static binary, so it is not offered. Writes are owner-only and audited;
// an API key is persisted but never returned or logged verbatim.
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"
)
// pluginInstallTimeout bounds the connect+discover handshake the install request
// waits for. The plugin manager applies its own per-server timeout inside it.
const pluginInstallTimeout = 20 * time.Second
// pluginActionRequest is the POST /api/v1/admin/plugins/uninstall body.
type pluginActionRequest struct {
Name string `json:"name"`
Kind string `json:"kind"`
}
// pluginInstallRequest is the POST /api/v1/admin/plugins/install body. Config
// carries the MCP server connection fields; the top-level name/kind are kept
// separate so the request reads like the other admin writes.
type pluginInstallRequest struct {
Kind string `json:"kind"`
Name string `json:"name"`
Config pluginInstallConfig `json:"config"`
}
// pluginInstallConfig is the MCP server connection block. Endpoint is the URL
// for http/sse; url is accepted as an alias. APIKey/Env are persisted but never
// echoed back.
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"`
}
// AdminInstalledPlugin is the wire shape of one installed plugin in the admin
// list. The API key and any sensitive env values are masked.
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"`
// Live reports whether the MCP server is connected and its tools registered.
Live bool `json:"live"`
// ToolCount is the number of tools the live server registered.
ToolCount int `json:"toolCount"`
}
// adminPluginInstall installs (or refreshes) an MCP server plugin: it validates
// the request, connects and discovers the server's tools, then persists the
// config. A connect/discover/register failure returns 502 and persists nothing,
// so a plugin is never half-installed.
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
}
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
}
kind := strings.TrimSpace(req.Kind)
if kind == "" {
kind = runtimeconfig.PluginKindMCP
}
if kind != runtimeconfig.PluginKindMCP {
s.auditClaims(r, "plugin.install", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "kind %q is not installable; only %s", kind, runtimeconfig.PluginKindMCP))
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: kind,
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,
}.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,
"toolCount": res.ToolCount,
})
}
// adminPluginUninstall uninstalls a plugin. An installed MCP server is closed and
// its tools removed through the plugin manager, then its config is deleted from
// plugins.json; any other name falls back to removing the capability from the
// registry, preserving the pre-existing behavior.
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
}
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})
}
// adminListInstalledPlugins lists the runtime-installed plugins with their
// masked config and per-server tool count. It is a read endpoint (owner/admin)
// and never returns an API key or a sensitive env value verbatim.
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 maps an installed plugin to its masked wire shape.
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,
Live: it.Live,
ToolCount: it.ToolCount,
}
}
// firstNonEmpty returns the first non-blank value, so an install body may send
// either endpoint or url for the server address.
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}