package plugin // 本文件是 MCP 服务器的**状态读面**(切片 M20):每台 server 的连接态、工具清单、最近一次 // 错误与时间。它是「看不见连没连上 / 暴露了哪些工具 / 为什么挂了」这个洞的补丁。 // // 三条口径: // // 1. **三态,缺席 = 未知**(项目既有纪律:零值不得冒充「没有/未连接」,见 // 「零值 = 不知道」类裁决)。因此 `connected` 是 `*bool`:present true = 连着; // present false = 确认没连(停用 / 上次连接失败);**absent = 宿主不知道**。同理 // `toolCount` / `tools` / `lastSeenAt` 只在「知道」时才出现 —— 一台停用的 server // 不是「暴露 0 把工具」,而是「没连,所以不知道它有什么」。 // 2. **凭据绝不出现在快照里**:`apiKey` 不进任何字段(Env 与 Args 也一律不下发 —— // stdio 的 args 常带 `--token xxx`),自由文本(`lastError`、`endpoint`、`command`) // 在**记录/构造那一刻**就用 `runtimeconfig.RedactPluginSecrets` 抹过(含凭据的任意 // 片段)。抹在这里而不是在网关:读面不止一条路(面板动作、日志、未来的端点), // 把安全押在「每个调用方都记得抹」上迟早漏一次。 // 3. **状态是派生的、不是另存一份**:`state` 由「活没活 / 启没启用 / 有没有失败记录」 // 推出来(见 statusOf),只有运行期观察(lastError / lastSeenAt)是记下来的事实。 // 两处各存一份状态必然分叉 —— 那正是「面板说连上了、模型调不到工具」的形状。 import ( "sort" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) // serverState 的取值闭集(`state` 列 / `Status.State`)。它们是**机器读**的码,不是文案: // 前端按码分支,中文名由前端映射(与 web-search 页 `state` 列同一口径)。 const ( // ServerStateConnected 表示 server 已连接、工具已注册。 ServerStateConnected = "connected" // ServerStateError 表示已启用但最近一次连接/发现失败(`lastError` 有值)。 ServerStateError = "error" // ServerStateDisabled 表示被显式停用(`enabled=false`):没连,是**有意**没连。 ServerStateDisabled = "disabled" // ServerStateUnknown 表示宿主说不清:已启用、没连着、也没有失败记录 // (例如 plugins.json 被手工改过、启动期还没轮到它)。 ServerStateUnknown = "unknown" ) // 快照里的截断上限(都是「表格能扫一眼」的排版倒推出来的,不是随手取的数)。 const ( // toolDescriptionLimit 是单把工具描述的字符上限。 toolDescriptionLimit = 200 // lastErrorLimit 是最近一次错误的字符上限。错误链可能很长,排障要的是病因那一句。 lastErrorLimit = 400 // toolsTextLimit 是表格「工具」列(工具名拼成的文本)的字符上限。 toolsTextLimit = 240 ) // Status 是一台 MCP server 的状态快照(可序列化,供面板行与未来端点复用)。 type Status struct { // Name 是 server 名(plugins.json 的唯一标签,也是能力的命名空间)。 Name string `json:"name"` // ID 是 server 的稳定标识(缺省省略)。 ID string `json:"id,omitempty"` // Kind 是插件种类(本期恒为 mcp)。 Kind string `json:"kind,omitempty"` // Transport 是连接形态(stdio / http / sse)。 Transport string `json:"transport,omitempty"` // Endpoint 是 http/sse 的地址(已抹凭据)。 Endpoint string `json:"endpoint,omitempty"` // Command 是 stdio 的可执行文件(参数**不下发**:args 常带凭据)。 Command string `json:"command,omitempty"` // Enabled 是启用态(来自 plugins.json;缺省视为 true)。 Enabled bool `json:"enabled"` // State 是状态闭集码(见 ServerState*)。 State string `json:"state"` // Connected 是三态连接态:true/false 是事实,**缺席是未知**(见文件头口径①)。 Connected *bool `json:"connected,omitempty"` // ToolCount 是已注册的工具数。只在**连着**时给出:不连的 server 不是「0 把工具」, // 而是「不知道它有哪些工具」。 ToolCount *int `json:"toolCount,omitempty"` // Tools 是工具摘要(名字 + 截断描述)。同样只在连着时给出;连着但零工具时 // `toolCount` 是 0 而本字段缺席(空数组同样意味着「没有工具」,两种表达不必并存)。 Tools []mcp.ToolSummary `json:"tools,omitempty"` // LastError 是最近一次连接/发现失败的文本(已抹凭据、已截断)。 LastError string `json:"lastError,omitempty"` // LastErrorAt 是那次失败的时刻(RFC3339)。 LastErrorAt string `json:"lastErrorAt,omitempty"` // LastSeenAt 是最近一次**成功**连上的时刻(RFC3339)。缺席 = 还没成功连过。 LastSeenAt string `json:"lastSeenAt,omitempty"` } // observed 是一台 server 最近一次被观察到的运行期事实(内存态,不落盘)。 // // 它只记「事件」:成功连上过(lastSeenAt)、失败过(lastError/lastErrorAt)。启用态与 // 连接态都不在这里 —— 前者的真源是 plugins.json,后者的真源是 servers 表。 type observed struct { cfg runtimeconfig.PluginConfig lastError string lastErrorAt time.Time lastSeenAt time.Time } // Statuses 返回全部 MCP server 的状态快照:plugins.json 的顺序在前,其后是「只活在内存里」 // 的(装了但还没落盘、或文件丢了但 server 活着)与「只留下失败记录」的,各自按名字排序 // (map 迭代顺序不稳定,快照顺序必须稳定 —— 否则前端每次刷新都在跳行)。 func (m *Manager) Statuses() []Status { cfgs, err := runtimeconfig.LoadPlugins(m.path) if err != nil { m.log.Warn("plugins.json load skipped", "path", m.path, "error", err) cfgs = nil } live, obs := m.snapshotState() out := make([]Status, 0, len(cfgs)+len(obs)) seen := make(map[string]bool, len(cfgs)+len(obs)) for _, cfg := range cfgs { out = append(out, statusOf(cfg, live[cfg.Name], obs[cfg.Name])) seen[cfg.Name] = true } for _, name := range sortedKeys(obs) { if seen[name] { continue } out = append(out, statusOf(obs[name].cfg, live[name], obs[name])) seen[name] = true } for _, name := range sortedKeys(live) { if seen[name] { continue } out = append(out, statusOf(live[name].cfg, live[name], obs[name])) seen[name] = true } return out } // snapshotState 复制一份活 server 表与观察表(锁内复制、锁外计算:statusOf 会问能力注册表, // 那是外部代码,持本管理器的锁去碰它等于把两把锁的顺序交给外部)。 func (m *Manager) snapshotState() (map[string]*server, map[string]observed) { m.mu.Lock() defer m.mu.Unlock() live := make(map[string]*server, len(m.servers)) for name, srv := range m.servers { live[name] = srv } obs := make(map[string]observed, len(m.observed)) for name, rec := range m.observed { obs[name] = rec } return live, obs } // statusOf 把「配置 + 活 server + 观察记录」推成一条快照。 func statusOf(cfg runtimeconfig.PluginConfig, srv *server, rec observed) Status { st := Status{ Name: cfg.Name, ID: cfg.ID, Kind: cfg.Kind, Transport: cfg.Transport, Endpoint: runtimeconfig.RedactPluginSecrets(cfg, cfg.Endpoint), Command: runtimeconfig.RedactPluginSecrets(cfg, cfg.Command), Enabled: cfg.IsEnabled(), LastError: rec.lastError, LastErrorAt: formatTime(rec.lastErrorAt), } switch { case srv != nil: st.State = ServerStateConnected st.Connected = flagBool(true) count := srv.toolCount st.ToolCount = &count st.Tools = srv.reg.ToolSummaries(toolDescriptionLimit) st.LastSeenAt = formatTime(rec.lastSeenAt) case !cfg.IsEnabled(): // 停用是**有意**没连:连接态是确认的 false(不是未知),工具则是未知。 // // 「想启用但失败了」也落在这里(落盘的 enabled 仍是 false,见 enabled.go):此时 // `state` 说「它现在是停用的」,`lastError` 说「刚才那次启用为什么没成」—— 两个 // 事实各归各的列,谁也不冒充谁。若让 error 抢在 disabled 前面,一台**故意**停用 // 过又失败过一次的 server 会永远显示「错误」,用户就没法把它当成「关着的」。 st.State = ServerStateDisabled st.Connected = flagBool(false) st.LastSeenAt = formatTime(rec.lastSeenAt) case rec.lastError != "": st.State = ServerStateError st.Connected = flagBool(false) st.LastSeenAt = formatTime(rec.lastSeenAt) default: // 已启用、没连着、也没有失败记录 ⇒ 宿主不知道它到底行不行(不猜)。 st.State = ServerStateUnknown } return st } // noteSeen 记录一次成功连接(tools 已注册)。 func (m *Manager) noteSeen(cfg runtimeconfig.PluginConfig) { m.observe(cfg, func(rec *observed) { rec.lastSeenAt = time.Now().UTC() }) } // noteFailure 记录一次连接/发现失败。错误文本在**这一刻**就抹掉凭据并截断:内存里也不留 // 明文,之后的任何出口(面板、日志、未来端点)都不必再各自记得抹。 func (m *Manager) noteFailure(cfg runtimeconfig.PluginConfig, err error) { if err == nil { return } text := mcp.TruncateRunes(runtimeconfig.RedactPluginSecrets(cfg, err.Error()), lastErrorLimit) m.observe(cfg, func(rec *observed) { rec.lastError = text rec.lastErrorAt = time.Now().UTC() }) } // noteDisabled 记录「这台 server 现在是停用的」(保留历史观察:排障时要看它停用前发生过什么)。 func (m *Manager) noteDisabled(cfg runtimeconfig.PluginConfig) { m.observe(cfg, func(*observed) {}) } // observe 在锁内更新一台 server 的观察记录(不存在则新建)。 func (m *Manager) observe(cfg runtimeconfig.PluginConfig, apply func(*observed)) { name := cfg.Name if name == "" { return } m.mu.Lock() defer m.mu.Unlock() if m.observed == nil { m.observed = make(map[string]observed, 1) } rec := m.observed[name] rec.cfg = cfg apply(&rec) m.observed[name] = rec } // forgetObserved 丢掉一台 server 的观察记录(卸载即不再有这台 server 的任何事实)。 func (m *Manager) forgetObserved(name string) { m.mu.Lock() defer m.mu.Unlock() delete(m.observed, name) } // formatTime 把时刻格式化成 RFC3339;零值返回空串(缺席 = 还没发生,不写 1970)。 func formatTime(t time.Time) string { if t.IsZero() { return "" } return t.UTC().Format(time.RFC3339) } // flagBool 是 `&true` / `&false` 的简写(Connected 是三态指针)。 func flagBool(v bool) *bool { return &v } // sortedKeys 返回 map 键的有序副本(快照顺序必须稳定)。 func sortedKeys[V any](m map[string]V) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } // toolsText 把工具摘要拼成表格里「工具」列的一行文本(名字在前、描述省略)。 // // 表格单元格只认标量(前端 `cellText` 会把数组用「、」拼起来,但长度不可控),因此这里 // 自己拼并截断:排障要看的是**有哪些工具**,不是每把工具的完整描述(描述在 `tools[]` 里)。 func toolsText(tools []mcp.ToolSummary) string { if len(tools) == 0 { return "" } names := make([]string, 0, len(tools)) for _, t := range tools { names = append(names, t.Name) } return mcp.TruncateRunes(strings.Join(names, "、"), toolsTextLimit) }