- 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, 面板安装表单待后续
239 lines
8.9 KiB
Go
239 lines
8.9 KiB
Go
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 ""
|
||
}
|