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 "" }