Files
Cyrene_v2/internal/capability/plugin/enabled.go
T
AskaEth 9654ce8861 feat(plugin): MCP 服务器管理页 + 按 server 独立启停与状态快照
design/09 §5.2.1 / §5.6.1:补上「看不见连没连上 / 暴露了哪些工具 / 为什么挂了」的洞。

- runtimeconfig:PluginConfig 新增 `enabled`(*bool,缺省=启用 ⇒ 既有 plugins.json
  向后兼容);新增 RedactPluginSecrets(整把凭据 + 任意 ≥6 字符片段一并抹除)。
- mcp:新增 status.go 的只读状态读面(ToolSummaries:工具名 + 截断描述,取自能力
  注册表本身,因此与模型可见的工具集合同源)。
- plugin 管理器:
  · Install/Restore 尊重 enabled(停用=落盘不连接,重启后仍是停用,且一次都不连);
  · SetEnabled 落盘 enabled(停用先落盘再下线;启用失败时 enabled 保持 false);
  · 新增 Reconnect(对已停用者拒绝,不替用户启用);
  · 新增 Statuses(三态:connected 缺席=未知,工具数/明细/时间只在知道时给出,
    lastError 在记录那一刻即脱敏截断);
  · 内置「MCP 服务器」页声明 + 7 个已声明动作(list/install/reconnect/enable/
    disable/uninstall/transports),走既有通用动作端点(owner-admin + 审计);
  · 护栏:热装插件的生命周期不改动内置提供者(mcp)的目录条目。
- gateway:已安装列表与 install 响应加 enabled,install 支持 config.enabled。
2026-10-24 19:31:31 +08:00

187 lines
6.8 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 plugin
// 本文件是「按 server 独立启停」的落点(切片 M20):启用态**持久化**在 plugins.json 的
// `enabled` 字段上,因此停用在重启后仍然有效;热切换的语义是「停用 = 摘掉工具,启用 =
// 连上并重新发现」,两条路都经插件管理器(design/09 §5.2.1「插件管理器 = 唯一中枢」)。
//
// 为什么这些函数从 manager.go 搬出来:manager.go 已接近「文件 ≤500 行」的上限,而启停
// 生命周期本身就有四件事——落盘、内存态、面板入口、运行期观察——挤在一起会既超行数又
// 说不清顺序。顺序在这里写死:**先落盘、再动活 server、最后改面板入口**;落盘失败即
// 原样返回错误,绝不出现「面板说停用了、重启又连上」。
//
// 刻意不做的事:
// - 停用**不动** plugins.json 的条目(停用 ≠ 卸载,卸载才删配置);
// - 启用的失败**不改**已落盘的 enabled(失败仍禁用,如实报错,不假装开着)。
import (
"context"
"errors"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// ErrPluginNotFound 报告指定的插件既没活着也不在 plugins.json 里。
var ErrPluginNotFound = errors.New("plugin: not found")
// notFound 构造一条带名字的「找不到」。
func notFound(name string) error {
if name == "" {
return fmt.Errorf("%w: empty name", ErrPluginNotFound)
}
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
}
// SetEnabled 启用或停用一台已安装的 server。
//
// 语义(design/09 §5.2.1 生命周期一栏):
// - 停用:落盘 `enabled:false` → 关掉活的 server(其工具随之下线,模型看不见)→ 隐藏
// 它的管理面板入口(`when:{enabled:true}` 的入口不再出现)。配置保留,重启后仍是停用。
// - 启用:按 plugins.json 的配置重新连接 + 发现 + 注册 → 入口回来 → 落盘 `enabled:true`。
// **连接失败时返回值即错误,且落盘的 enabled 保持 false**(不假装开着)。
//
// 两条路都会推一次 `panels_changed`(停用推 disable、启用推 enable)。
// 找不到该 server 时返回 ErrPluginNotFound。
func (m *Manager) SetEnabled(ctx context.Context, name string, on bool) error {
name = strings.TrimSpace(name)
if name == "" {
return notFound("")
}
if !on {
return m.disable(ctx, name)
}
return m.enable(ctx, name)
}
// Reconnect 断开并重新连接一台已启用的 server(重新发现工具,重新注册)。
//
// 它与「先停用再启用」的差别只在面板帧的原因标签与「已停用时拒绝」:重连不改变启用态,
// 所以它既不写 enabled,也不该替用户启用一个被停用的 server(那会让「为什么它忽然活了」
// 变成一个查不出的谜)。
func (m *Manager) Reconnect(ctx context.Context, name string) (Installed, error) {
name = strings.TrimSpace(name)
cfg, ok := m.persistedConfig(name)
if !ok {
if srv := m.liveServer(name); srv != nil {
cfg, ok = srv.cfg, true
}
}
if !ok {
return Installed{}, notFound(name)
}
if !cfg.IsEnabled() {
return Installed{}, fmt.Errorf("%w: %s", ErrPluginDisabled, name)
}
return m.install(ctx, cfg, false, PanelChangeInstalled)
}
// disable 停用一台 server:落盘 → 下线 → 隐藏入口。
//
// 落盘在**前面**:先写文件的好处是「写不进去时 server 还在跑」,而不是先摘掉工具再
// 报一个落盘错误(那样内存与文件分叉,用户看到的与重启后得到的不是一回事)。
func (m *Manager) disable(ctx context.Context, name string) error {
srv := m.liveServer(name)
cfg, persisted := m.persistedConfig(name)
if srv == nil && !persisted {
return notFound(name)
}
if !persisted {
// 文件里没有这条,但 server 活着(手工塞进来的):用活 server 的配置补一条,
// 否则「停用」重启后会变成「不存在」。
cfg = srv.cfg
}
if err := m.persistEnabled(cfg, false); err != nil {
return fmt.Errorf("plugin %q: %w", name, err)
}
if srv != nil {
m.take(srv.cfg.Name)
m.stop(ctx, srv)
}
m.noteDisabled(cfg)
m.setPanelsEnabled(cfg.Name, false)
m.log.Info("plugin disabled", "name", cfg.Name)
return nil
}
// enable 重新启用一台已安装的 server:按 plugins.json 的配置重新连接。
// 已经活着的 server 是幂等空操作(只把文件里的 enabled 纠正为 true,不推多余帧)。
func (m *Manager) enable(ctx context.Context, name string) error {
cfg, ok := m.persistedConfig(name)
if !ok {
if srv := m.liveServer(name); srv != nil {
return m.persistEnabled(srv.cfg, true)
}
return notFound(name)
}
if srv := m.liveServer(name); srv != nil {
if cfg.IsEnabled() {
return nil
}
return m.persistEnabled(cfg, true)
}
// 只有连上才落 enabled=true:连接/发现失败时文件保持停用,返回值就是那条失败。
res, err := m.install(ctx, cfg.WithEnabled(true), false, PanelChangeEnabled)
if err != nil {
return err
}
return m.persistEnabled(res.Config, true)
}
// persistEnabled 把启用态写回 plugins.json(upsert by name,其余字段原样保留)。
//
// 它**不**碰内存里的活 server:调用方按自己的顺序决定何时连线/断线。
func (m *Manager) persistEnabled(cfg runtimeconfig.PluginConfig, on bool) error {
if err := m.persistUpsert(cfg.WithEnabled(on)); err != nil {
return fmt.Errorf("插件 %q 的启用态未落盘: %w", cfg.Name, 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
}