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, 面板安装表单待后续
This commit is contained in:
1 parent
53002f1e11
commit
70c9fba529
20 files changed
+1885
-57
No files matched your search
@@ -0,0 +1,255 @@
|
||||
// plugins.go loads and persists the runtime-installed external tool providers:
|
||||
// the MCP server list in plugins.json. Unlike models.json/channels.json there is
|
||||
// no env fallback — a plugin only exists because the owner installed it through
|
||||
// the admin API, so an absent file simply means "none installed".
|
||||
//
|
||||
// plugins.json is gitignored (local runtime config) and may hold a server API
|
||||
// key, so this package never logs a key and MaskedPlugin redacts every secret
|
||||
// before a caller returns it to a client.
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// pluginsFileName is the plugins.json basename stored under the data dir.
|
||||
const pluginsFileName = "plugins.json"
|
||||
|
||||
// PluginKindMCP is the only installable plugin kind in v1: an external MCP
|
||||
// server whose tools are hot-registered into the capability registry. A compiled
|
||||
// Go plugin cannot be hot-installed in a single static binary (the project red
|
||||
// line), so an MCP server is the runtime-installable unit.
|
||||
const PluginKindMCP = "mcp"
|
||||
|
||||
// Transport names accepted in a PluginConfig.Transport.
|
||||
const (
|
||||
TransportStdio = "stdio"
|
||||
TransportHTTP = "http"
|
||||
TransportSSE = "sse"
|
||||
)
|
||||
|
||||
// ErrInvalidPlugin is returned when a plugins.json entry fails validation.
|
||||
var ErrInvalidPlugin = errors.New("runtimeconfig: invalid plugin config")
|
||||
|
||||
// PluginConfig describes one runtime-installed external provider. It is the
|
||||
// on-disk plugins.json shape and the admin install body's config block.
|
||||
type PluginConfig struct {
|
||||
// ID is a stable identifier, generated when the panel omits one.
|
||||
ID string `json:"id"`
|
||||
// Name is the unique plugin label; it names the MCP server and prefixes its
|
||||
// KindMCP capability ("mcp:<name>").
|
||||
Name string `json:"name"`
|
||||
// Kind classifies the provider. Only "mcp" is installable in v1.
|
||||
Kind string `json:"kind"`
|
||||
// Transport selects the wire transport: "stdio", "http" (Streamable HTTP) or
|
||||
// "sse". Empty is inferred from Command/Endpoint.
|
||||
Transport string `json:"transport,omitempty"`
|
||||
// Endpoint is the server URL for http/sse transports.
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
// Command is the executable path for a stdio transport.
|
||||
Command string `json:"command,omitempty"`
|
||||
// Args are the stdio command arguments.
|
||||
Args []string `json:"args,omitempty"`
|
||||
// APIKey is the bearer credential sent to an http/sse server. It is
|
||||
// persisted but never returned or logged verbatim (MaskedPlugin).
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
// Env is extra environment for a stdio subprocess. Values under a sensitive
|
||||
// key (token/secret/password/key...) are redacted by MaskedPlugin.
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
// CreatedAt is the RFC3339 install time.
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultPluginsPath returns the plugins.json path under dataDir.
|
||||
func DefaultPluginsPath(dataDir string) string {
|
||||
return filepath.Join(dataDir, pluginsFileName)
|
||||
}
|
||||
|
||||
// Normalize fills the defaults an install request may omit: kind=mcp and the
|
||||
// transport inferred from command/endpoint (stdio when a command is set,
|
||||
// otherwise http when an endpoint is set).
|
||||
func (p PluginConfig) Normalize() PluginConfig {
|
||||
if strings.TrimSpace(p.Kind) == "" {
|
||||
p.Kind = PluginKindMCP
|
||||
}
|
||||
if strings.TrimSpace(p.Transport) == "" {
|
||||
switch {
|
||||
case strings.TrimSpace(p.Command) != "":
|
||||
p.Transport = TransportStdio
|
||||
case strings.TrimSpace(p.Endpoint) != "":
|
||||
p.Transport = TransportHTTP
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Validate checks the plugin's required fields. It validates the normalized
|
||||
// form so a caller that only fills endpoint or command still gets a coherent
|
||||
// transport. It never includes a secret in the returned error.
|
||||
func (p PluginConfig) Validate() error {
|
||||
p = p.Normalize()
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
return fmt.Errorf("%w: name is required", ErrInvalidPlugin)
|
||||
}
|
||||
if p.Kind != PluginKindMCP {
|
||||
return fmt.Errorf("%w: kind %q must be %s", ErrInvalidPlugin, p.Kind, PluginKindMCP)
|
||||
}
|
||||
switch p.Transport {
|
||||
case TransportStdio:
|
||||
if strings.TrimSpace(p.Command) == "" {
|
||||
return fmt.Errorf("%w: command is required for %s transport", ErrInvalidPlugin, TransportStdio)
|
||||
}
|
||||
case TransportHTTP, "streamable", TransportSSE:
|
||||
if strings.TrimSpace(p.Endpoint) == "" {
|
||||
return fmt.Errorf("%w: endpoint is required for %s transport", ErrInvalidPlugin, p.Transport)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: transport %q must be stdio, http, streamable or sse", ErrInvalidPlugin, p.Transport)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadPlugins returns the persisted plugin list. A missing file is the normal
|
||||
// "nothing installed" case and yields an empty list with no error. A
|
||||
// present-but-invalid file returns a non-nil error so the caller can warn
|
||||
// instead of silently dropping installed plugins.
|
||||
func LoadPlugins(path string) ([]PluginConfig, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []PluginConfig{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
|
||||
}
|
||||
if len(strings.TrimSpace(string(raw))) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty file", ErrInvalidPlugin)
|
||||
}
|
||||
var plugins []PluginConfig
|
||||
if err := json.Unmarshal(raw, &plugins); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode: %w", ErrInvalidPlugin, err)
|
||||
}
|
||||
for i, p := range plugins {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("%w: entry %d: %w", ErrInvalidPlugin, i, err)
|
||||
}
|
||||
}
|
||||
if plugins == nil {
|
||||
plugins = []PluginConfig{}
|
||||
}
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
// SavePlugins writes plugins as a pretty-printed JSON array to path with mode
|
||||
// 0600 (the file may hold an API key). It writes to a temp file then renames so
|
||||
// a concurrent reader never sees a partial file. Every entry is validated first.
|
||||
func SavePlugins(path string, plugins []PluginConfig) error {
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: mkdir: %w", err)
|
||||
}
|
||||
}
|
||||
out := make([]PluginConfig, 0, len(plugins))
|
||||
for i, p := range plugins {
|
||||
p = p.Normalize()
|
||||
if err := p.Validate(); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: save: entry %d: %w", i, err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
raw, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("runtimeconfig: marshal: %w", err)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertPlugin returns plugins with p inserted or replaced in place by name
|
||||
// (falling back to id), preserving order. A blank id is assigned a generated one
|
||||
// so a panel save without an explicit id still produces a stable, addressable
|
||||
// entry.
|
||||
func UpsertPlugin(plugins []PluginConfig, p PluginConfig) []PluginConfig {
|
||||
p = p.Normalize()
|
||||
if strings.TrimSpace(p.ID) == "" {
|
||||
p.ID = NewID()
|
||||
}
|
||||
out := make([]PluginConfig, 0, len(plugins)+1)
|
||||
replaced := false
|
||||
for _, cur := range plugins {
|
||||
if samePlugin(cur, p) {
|
||||
out = append(out, p)
|
||||
replaced = true
|
||||
continue
|
||||
}
|
||||
out = append(out, cur)
|
||||
}
|
||||
if !replaced {
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DeletePlugin returns plugins with the entry matching name (by name or id)
|
||||
// removed. The bool reports whether an entry was actually removed.
|
||||
func DeletePlugin(plugins []PluginConfig, name string) ([]PluginConfig, bool) {
|
||||
out := make([]PluginConfig, 0, len(plugins))
|
||||
removed := false
|
||||
for _, cur := range plugins {
|
||||
if cur.Name == name || cur.ID == name {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
out = append(out, cur)
|
||||
}
|
||||
return out, removed
|
||||
}
|
||||
|
||||
// MaskedPlugin returns a copy of p whose API key and sensitive env values are
|
||||
// redacted, for a wire response or a log line. The on-disk plugins.json is
|
||||
// untouched.
|
||||
func MaskedPlugin(p PluginConfig) PluginConfig {
|
||||
out := p
|
||||
if strings.TrimSpace(p.APIKey) != "" {
|
||||
out.APIKey = maskToken(strings.TrimSpace(p.APIKey))
|
||||
}
|
||||
out.Env = maskEnv(p.Env)
|
||||
return out
|
||||
}
|
||||
|
||||
// maskEnv redacts the values of sensitive env keys, passing non-sensitive keys,
|
||||
// empty values and non-string values through unchanged.
|
||||
func maskEnv(env map[string]string) map[string]string {
|
||||
if env == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(env))
|
||||
for k, v := range env {
|
||||
if isSensitiveKey(k) && strings.TrimSpace(v) != "" {
|
||||
out[k] = maskToken(v)
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// samePlugin reports whether cur is the entry p replaces: same name when p has
|
||||
// one, otherwise same id.
|
||||
func samePlugin(cur, p PluginConfig) bool {
|
||||
if p.Name != "" && cur.Name == p.Name {
|
||||
return true
|
||||
}
|
||||
return p.ID != "" && cur.ID == p.ID
|
||||
}
|
||||
Reference in new issue
Block a user