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。
This commit is contained in:
AskaEth committed 2026-10-24 19:31:31 +08:00
1 parent 4ed5925296
commit 9654ce8861
14 files changed
+1969 -115

No files matched your search

+79
View File
@@ -14,6 +14,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
@@ -61,10 +62,30 @@ type PluginConfig struct {
// 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"`
// Enabled is the per-server on/off switch. It is a pointer so that "the file
// has no enabled key" (every plugins.json written before this field existed)
// stays distinguishable from an explicit false: absent means enabled
// (IsEnabled), and only an explicit disable is persisted.
//
// Disabled is NOT uninstalled: the entry stays in plugins.json (so it survives
// a restart as disabled) and only its live connection + tools go away.
Enabled *bool `json:"enabled,omitempty"`
// CreatedAt is the RFC3339 install time.
CreatedAt string `json:"createdAt,omitempty"`
}
// IsEnabled reports whether the server should be connected. A missing enabled key
// (older plugins.json / an install that never mentioned it) counts as enabled.
func (p PluginConfig) IsEnabled() bool {
return p.Enabled == nil || *p.Enabled
}
// WithEnabled returns a copy of p with an explicit enabled flag.
func (p PluginConfig) WithEnabled(on bool) PluginConfig {
p.Enabled = &on
return p
}
// DefaultPluginsPath returns the plugins.json path under dataDir.
func DefaultPluginsPath(dataDir string) string {
return filepath.Join(dataDir, pluginsFileName)
@@ -245,6 +266,64 @@ func maskEnv(env map[string]string) map[string]string {
return out
}
// secretMask is what a redacted secret (or a fragment of one) collapses to inside
// free text. It is deliberately not maskToken: free text needs a marker that
// cannot be mistaken for a real tail of the credential.
const secretMask = "***"
// minRedactFragment is the shortest run of a secret that is still scrubbed from
// free text. Anything shorter is not evidence of that credential (a 2-3 char run
// of a hex key shows up in ordinary prose by chance), and keeping the floor low
// enough that a leaked *middle* of a key (an error printing a substring) is still
// caught is the point of scanning fragments at all.
const minRedactFragment = 6
// RedactPluginSecrets scrubs every configured credential out of text, returning a
// string that is safe to show in a status snapshot or a log line.
//
// Why this exists (and why MaskedPlugin is not enough): a connect failure's error
// text is written by the MCP client, the HTTP stack or the server itself, so it
// can carry the bearer token (a 401 echoing the Authorization header, a URL with
// `?token=...`, a stdio subprocess dumping its env). The snapshot exposes that
// text as `lastError`, therefore the text — not just the config — has to be
// redacted. Replacement is by whole value first, then by every fragment of at
// least minRedactFragment characters, so a partially printed key is caught too.
func RedactPluginSecrets(p PluginConfig, text string) string {
if text == "" {
return ""
}
out := redactSecret(text, p.APIKey)
keys := make([]string, 0, len(p.Env))
for k := range p.Env {
if isSensitiveKey(k) {
keys = append(keys, k)
}
}
// 排序只为结果稳定:Env 是 map,不排序会让同一份输入在不同运行里产出不同文本。
sort.Strings(keys)
for _, k := range keys {
out = redactSecret(out, p.Env[k])
}
return out
}
// redactSecret replaces secret and every fragment of it (longest first, down to
// minRedactFragment runes-worth of bytes) with secretMask.
func redactSecret(text, secret string) string {
secret = strings.TrimSpace(secret)
if secret == "" || text == "" {
return text
}
out := strings.ReplaceAll(text, secret, secretMask)
// 长片段优先:先抹掉最长的,剩下的短片段才不会被抹成一片 *** 后漏掉边界。
for n := len(secret) - 1; n >= minRedactFragment; n-- {
for i := 0; i+n <= len(secret); i++ {
out = strings.ReplaceAll(out, secret[i:i+n], secretMask)
}
}
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 {
@@ -186,3 +186,64 @@ func TestMaskedPlugin_RedactsKeyAndEnv(t *testing.T) {
t.Fatalf("masked JSON leaks a secret: %s", raw)
}
}
// TestPluginEnabled_BackwardCompatible 锁定 enabled 的向后兼容:既有 plugins.json
// (没有 enabled 键)必须被当成**启用**,而显式 false 必须原样存活(含重启)。
func TestPluginEnabled_BackwardCompatible(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "plugins.json")
legacy := `[{"name":"calc","kind":"mcp","transport":"http","endpoint":"https://x"}]`
if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil {
t.Fatalf("write legacy file: %v", err)
}
got, err := LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if len(got) != 1 || !got[0].IsEnabled() {
t.Fatalf("老文件(无 enabled 键)应视为启用:%+v", got)
}
if got[0].Enabled != nil {
t.Fatalf("缺失的 enabled 必须保持 nil(= 缺席),得到 %v", *got[0].Enabled)
}
off := got[0].WithEnabled(false)
if err := SavePlugins(path, []PluginConfig{off}); err != nil {
t.Fatalf("SavePlugins: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("显式停用必须落盘:%s", raw)
}
back, err := LoadPlugins(path)
if err != nil {
t.Fatalf("LoadPlugins: %v", err)
}
if back[0].IsEnabled() {
t.Fatalf("重启后停用态丢失:%+v", back[0])
}
}
// TestRedactPluginSecrets_ScrubsFragments 锁定「凭据任意片段也不许泄漏」。
func TestRedactPluginSecrets_ScrubsFragments(t *testing.T) {
t.Parallel()
const key = "sk-live-0123456789abcdef"
p := PluginConfig{APIKey: key, Env: map[string]string{"GH_TOKEN": "ghp_secret_value"}}
for _, text := range []string{
"bearer " + key,
"echo " + key[8:20],
"tail " + key[len(key)-6:],
"env GH_TOKEN=ghp_secret_value",
} {
out := RedactPluginSecrets(p, text)
if strings.Contains(out, key) || strings.Contains(out, "ghp_secret_value") {
t.Fatalf("整把凭据仍在:%q", out)
}
if strings.Contains(out, key[8:20]) || strings.Contains(out, key[len(key)-6:]) {
t.Fatalf("凭据片段仍在:%q", out)
}
}
}