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:
1 parent
4ed5925296
commit
9654ce8861
14 files changed
+1969
-115
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user