// Package plugin manages runtime-installed external tool providers. In v1 the // only installable kind is an MCP server (DevDocs/design/09 插件生态 §1): a // compiled Go plugin cannot be hot-loaded into a single static binary, so a // runtime "install" is an external MCP server whose config is persisted and // whose discovered tools are hot-registered into the shared capability registry. // // The manager owns the live *mcp.Registry of every installed server so the // server can be refreshed (reinstall under the same name) or uninstalled, and // the config can be restored on the next boot. A connect/discover failure never // persists a half-installed plugin. package plugin import ( "context" "errors" "fmt" "log/slog" "strings" "sync" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) const ( // connectTimeout bounds one server's connect + discover handshake so an // unreachable server fails the install instead of hanging the request. connectTimeout = 15 * time.Second // mcpCapabilityPrefix names the KindMCP capability a server surfaces as, so // the admin plugin list enumerates the server itself as well as its tools. mcpCapabilityPrefix = "mcp:" ) // Installed is one plugin as reported to the admin API: its persisted config, // whether a live server currently backs it, and how many tools it registered. type Installed struct { // Config is the persisted plugin config (the caller masks it before wire). Config runtimeconfig.PluginConfig // Live reports whether the MCP server is connected and its tools registered. Live bool // ToolCount is the number of tools the live server registered (0 when not // live). ToolCount int } // server is a live installed MCP server. type server struct { cfg runtimeconfig.PluginConfig reg *mcp.Registry toolCount int } // Manager installs, refreshes, lists and uninstalls runtime plugins over one // capability registry and one plugins.json path. It is safe for concurrent use. type Manager struct { reg *registry.Registry path string log *slog.Logger mu sync.Mutex servers map[string]*server // panelKeys 记住每个已安装插件**当时用过的**面板目录键(插件名 → 目录键)。 // 它是 memo 而不是第二份真源:目录键由 PanelPluginID 从 cfg 推出,这里只把 // 结果留下,好让 Uninstall 在活 server 已不在时仍能精确清掉该插件的面板。 panelKeys map[string]string // panels 是可选的面板声明中枢(§5.2.1)。为 nil 时管理器完全不知道面板, // 既有行为逐字不变(未接线的进程/测试就是这样)。 panels *Panels } // NewManager returns a Manager backed by reg and the plugins.json at path. func NewManager(reg *registry.Registry, path string, log *slog.Logger) *Manager { if log == nil { log = slog.New(slog.DiscardHandler) } return &Manager{ reg: reg, path: path, log: log, servers: make(map[string]*server), panelKeys: make(map[string]string), } } // AttachPanels 把面板声明中枢接到管理器上(design/09 §5.2.1「插件管理器 = 唯一 // 中枢」)。必须在插件被安装**之前**接好,否则启动期恢复的那些插件不会留下目录键。 // 传 nil 等于不接线(合法:未接线的进程行为与接线前逐字一致)。 func (m *Manager) AttachPanels(p *Panels) { m.mu.Lock() m.panels = p m.mu.Unlock() } // Panels 返回已接线的面板中枢;未接线时返回 nil(网关据此诚实作答 501, // 而不是回一份空面板列表假装「没有插件的页面」)。 func (m *Manager) Panels() *Panels { if m == nil { return nil } m.mu.Lock() defer m.mu.Unlock() return m.panels } // Install connects cfg's MCP server, registers its tools, persists the config // and holds the live registry. Installing a name that already exists refreshes // it: the previous server is unregistered first, and if the new one fails to // connect the previous one is restored (the on-disk config is never changed on a // failure). func (m *Manager) Install(ctx context.Context, cfg runtimeconfig.PluginConfig) (Installed, error) { return m.install(ctx, cfg, true, PanelChangeInstalled) } // install is Install with the persist step optional (Restore reuses it without // rewriting the file it just read). changeReason labels the panel change pushed // when the plugin becomes live (install vs enable). func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, persist bool, changeReason string) (Installed, error) { cfg = cfg.Normalize() if err := cfg.Validate(); err != nil { return Installed{}, err } prev := m.take(cfg.Name) cfg = inheritIdentity(cfg, prev) if strings.TrimSpace(cfg.CreatedAt) == "" { cfg.CreatedAt = time.Now().UTC().Format(time.RFC3339) } if prev != nil { m.stop(ctx, prev) } srv, err := m.start(ctx, cfg) if err != nil { m.restore(prev) return Installed{}, err } if persist { if err := m.persistUpsert(cfg); err != nil { m.stop(ctx, srv) m.restore(prev) return Installed{}, err } } m.put(srv) m.log.Info("plugin installed", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport, "tools", srv.toolCount) m.markInstalled(cfg, changeReason) return Installed{Config: cfg, Live: true, ToolCount: srv.toolCount}, nil } // Uninstall removes the plugin matching name: it unregisters the live server's // tools and closes it, then deletes the persisted config. It reports whether // anything matched, so the caller can fall back to a plain capability removal. func (m *Manager) Uninstall(ctx context.Context, name string) (bool, error) { srv := m.takeAny(name) deleted, err := m.persistDelete(name) if srv != nil { m.stop(ctx, srv) } if err != nil { return srv != nil, fmt.Errorf("plugin %q: %w", name, err) } removed := srv != nil || deleted if removed { panelName := name if srv != nil { panelName = srv.cfg.Name } m.forgetPanels(panelName) m.log.Info("plugin uninstalled", "name", name) } return removed, nil } // List returns every persisted plugin (in file order) followed by any live // server not in the file, each annotated with its live state and tool count. func (m *Manager) List() []Installed { cfgs, err := runtimeconfig.LoadPlugins(m.path) if err != nil { m.log.Warn("plugins.json load skipped", "path", m.path, "error", err) cfgs = nil } out := make([]Installed, 0, len(cfgs)) seen := make(map[string]bool, len(cfgs)) m.mu.Lock() defer m.mu.Unlock() for _, cfg := range cfgs { item := Installed{Config: cfg} if srv, ok := m.servers[cfg.Name]; ok { item.Live = true item.ToolCount = srv.toolCount } out = append(out, item) seen[cfg.Name] = true } for name, srv := range m.servers { if seen[name] { continue } out = append(out, Installed{Config: srv.cfg, Live: true, ToolCount: srv.toolCount}) } return out } // Restore installs every persisted plugin. A server that is unreachable at boot // is logged and left in plugins.json (so the owner can retry or uninstall it); // boot never fails because an external server is down. func (m *Manager) Restore(ctx context.Context) { cfgs, err := runtimeconfig.LoadPlugins(m.path) if err != nil { m.log.Warn("plugins.json load skipped", "path", m.path, "error", err) return } for _, cfg := range cfgs { if _, err := m.install(ctx, cfg, false, PanelChangeInstalled); err != nil { m.log.Warn("plugin restore skipped", "name", cfg.Name, "error", err) } } } // Close unregisters every live server without touching plugins.json. func (m *Manager) Close(ctx context.Context) { m.mu.Lock() live := make([]*server, 0, len(m.servers)) for _, srv := range m.servers { live = append(live, srv) } m.servers = make(map[string]*server) m.mu.Unlock() for _, srv := range live { m.stop(ctx, srv) } } // SetEnabled 启用或停用一个已安装插件。 // // 语义(§5.2.1 生命周期一栏):停用会关闭活的 server(其工具随之下线)并隐藏它的 // 管理面板入口(`when: {enabled:true}` 的入口不再出现),**配置保留在 plugins.json** // —— 因此重启后它仍然「已安装」,只是要不要连着由这次调用决定;启用则重新连接并 // 让入口回来。两条路都会推一次 `panels_changed`。 // // ⚠️ 已知缺口(本片如实登记):启用状态本身**目前不持久化**(PluginConfig 没有该 // 字段,给 plugins.json 加字段属 runtimeconfig 面,不在本片文件范围内),因此 // **重启后停用态丢失、按已安装恢复为启用**。需要跨重启保持就寝语义时,先给 // plugins.json 加 `enabled` 字段(并让 Restore 读它)。 // // 找不到该插件(既不在活 server 里也不在 plugins.json 里)时返回 ErrPluginNotFound。 func (m *Manager) SetEnabled(ctx context.Context, name string, on bool) error { name = strings.TrimSpace(name) if name == "" { return fmt.Errorf("%w: empty name", ErrPluginNotFound) } if !on { return m.disable(ctx, name) } return m.enable(ctx, name) } // ErrPluginNotFound 报告 SetEnabled 指定的插件既没活着也不在 plugins.json 里。 var ErrPluginNotFound = errors.New("plugin: not found") // disable 停用一个插件:摘掉活的 server,保留配置,并隐藏面板入口。 // // 它刻意**不**动 plugins.json:「停用」不是「卸载」,卸载才是删配置 // (design/09 §5.2.1 把 install/uninstall 与 enable/disable 并列,说明两者不同)。 func (m *Manager) disable(ctx context.Context, name string) error { srv := m.takeAny(name) _, persisted := m.persistedConfig(name) if srv == nil && !persisted { return fmt.Errorf("%w: %s", ErrPluginNotFound, name) } if srv != nil { m.stop(ctx, srv) } m.setPanelsEnabled(name, false) m.log.Info("plugin disabled", "name", name) return nil } // enable 重新启用一个已安装插件:按 plugins.json 里的配置重新连接。 // 已经活着的插件是幂等空操作(面板集合没变 ⇒ 不推帧)。 func (m *Manager) enable(ctx context.Context, name string) error { if m.liveServer(name) != nil { return nil } cfg, ok := m.persistedConfig(name) if !ok { return fmt.Errorf("%w: %s", ErrPluginNotFound, name) } if _, err := m.install(ctx, cfg, false, PanelChangeEnabled); err != nil { return err } return nil } // liveServer 返回活着的 server(按名字或 id 匹配,无则 nil)。 func (m *Manager) liveServer(name string) *server { m.mu.Lock() defer m.mu.Unlock() if srv := m.servers[name]; srv != nil { return srv } for _, srv := range m.servers { if srv.cfg.ID != "" && srv.cfg.ID == name { return srv } } return nil } // takeAny 按名字或 id 摘掉活着的 server(take 只认精确的名字键)。 func (m *Manager) takeAny(name string) *server { if srv := m.take(name); srv != nil { return srv } m.mu.Lock() defer m.mu.Unlock() for key, srv := range m.servers { if srv.cfg.ID != "" && srv.cfg.ID == name { delete(m.servers, key) return srv } } return nil } // persistedConfig 按名(或 id)在 plugins.json 里找一条配置。文件读不出来时按 // 「没有这条」处理并记日志:停用/启用不该因为一个坏文件而变成 500。 func (m *Manager) persistedConfig(name string) (runtimeconfig.PluginConfig, bool) { cfgs, err := runtimeconfig.LoadPlugins(m.path) if err != nil { m.log.Warn("plugins.json load failed", "path", m.path, "error", err) return runtimeconfig.PluginConfig{}, false } for _, cfg := range cfgs { if cfg.Name == name || (cfg.ID != "" && cfg.ID == name) { return cfg, true } } return runtimeconfig.PluginConfig{}, false } // start connects cfg, registers its tools and surfaces its KindMCP capability. // On a registration failure it cleans up and returns the error. func (m *Manager) start(ctx context.Context, cfg runtimeconfig.PluginConfig) (*server, error) { mreg := mcp.NewRegistry(m.reg, mcpConfigOf(cfg), mcp.WithLogger(m.log)) connectCtx, cancel := context.WithTimeout(ctx, connectTimeout) defer cancel() if err := mreg.Register(connectCtx); err != nil { _ = mreg.Unregister(context.Background()) return nil, fmt.Errorf("plugin %q: %w", cfg.Name, err) } names := mreg.ToolNames() err := m.reg.Register(registry.Capability{ Name: mcpCapabilityPrefix + cfg.Name, Kind: registry.KindMCP, Description: "MCP server " + cfg.Name, Value: mreg, }) if err != nil && !errors.Is(err, registry.ErrDuplicate) { m.log.Warn("plugin capability register skipped", "name", cfg.Name, "error", err) } return &server{cfg: cfg, reg: mreg, toolCount: len(names)}, nil } // stop unregisters a live server's tools and closes it, then drops its KindMCP // capability. Errors are logged, never fatal: uninstall must still remove the // config. func (m *Manager) stop(ctx context.Context, srv *server) { if srv == nil { return } if err := srv.reg.Unregister(ctx); err != nil { m.log.Warn("plugin unregister", "name", srv.cfg.Name, "error", err) } if err := m.reg.Remove(mcpCapabilityPrefix + srv.cfg.Name); err != nil && !errors.Is(err, registry.ErrNotFound) { m.log.Warn("plugin capability remove", "name", srv.cfg.Name, "error", err) } } // restore re-installs a server that a failed refresh displaced, so a failed // refresh never leaves the registry without a previously working server. func (m *Manager) restore(prev *server) { if prev == nil { return } srv, err := m.start(context.Background(), prev.cfg) if err != nil { m.log.Warn("plugin rollback failed", "name", prev.cfg.Name, "error", err) return } m.put(srv) } // take removes and returns the live server named name, or nil. func (m *Manager) take(name string) *server { m.mu.Lock() defer m.mu.Unlock() srv := m.servers[name] delete(m.servers, name) return srv } // put records srv as the live server for its name. func (m *Manager) put(srv *server) { m.mu.Lock() defer m.mu.Unlock() m.servers[srv.cfg.Name] = srv } // persistUpsert writes cfg into plugins.json (upsert by name). func (m *Manager) persistUpsert(cfg runtimeconfig.PluginConfig) error { cur, err := runtimeconfig.LoadPlugins(m.path) if err != nil { return fmt.Errorf("load plugins.json: %w", err) } if err := runtimeconfig.SavePlugins(m.path, runtimeconfig.UpsertPlugin(cur, cfg)); err != nil { return fmt.Errorf("save plugins.json: %w", err) } return nil } // persistDelete removes name (by name or id) from plugins.json, reporting // whether an entry was removed. A missing file is not an error. func (m *Manager) persistDelete(name string) (bool, error) { cur, err := runtimeconfig.LoadPlugins(m.path) if err != nil { return false, fmt.Errorf("load plugins.json: %w", err) } out, removed := runtimeconfig.DeletePlugin(cur, name) if !removed { return false, nil } if err := runtimeconfig.SavePlugins(m.path, out); err != nil { return false, fmt.Errorf("save plugins.json: %w", err) } return true, nil } // mcpConfigOf maps a plugin config to the MCP client config. func mcpConfigOf(cfg runtimeconfig.PluginConfig) mcp.MCPConfig { return mcp.MCPConfig{ Name: cfg.Name, Command: cfg.Command, Args: cfg.Args, URL: cfg.Endpoint, TransportType: cfg.Transport, APIKey: cfg.APIKey, Env: cfg.Env, Enabled: true, } } // inheritIdentity keeps a refresh's id and install time when the request omits // them, so a reinstall updates a plugin rather than minting a new identity. func inheritIdentity(cfg runtimeconfig.PluginConfig, prev *server) runtimeconfig.PluginConfig { if prev == nil { return cfg } if strings.TrimSpace(cfg.ID) == "" { cfg.ID = prev.cfg.ID } if strings.TrimSpace(cfg.CreatedAt) == "" { cfg.CreatedAt = prev.cfg.CreatedAt } return cfg }