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。
274 lines
12 KiB
Go
274 lines
12 KiB
Go
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)
|
||
}
|