Files
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

335 lines
12 KiB
Go

// plugins.go loads and persists the runtime-installed external tool providers:
// the MCP server list in plugins.json. Unlike models.json/channels.json there is
// no env fallback — a plugin only exists because the owner installed it through
// the admin API, so an absent file simply means "none installed".
//
// plugins.json is gitignored (local runtime config) and may hold a server API
// key, so this package never logs a key and MaskedPlugin redacts every secret
// before a caller returns it to a client.
package runtimeconfig
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// pluginsFileName is the plugins.json basename stored under the data dir.
const pluginsFileName = "plugins.json"
// PluginKindMCP is the only installable plugin kind in v1: an external MCP
// server whose tools are hot-registered into the capability registry. A compiled
// Go plugin cannot be hot-installed in a single static binary (the project red
// line), so an MCP server is the runtime-installable unit.
const PluginKindMCP = "mcp"
// Transport names accepted in a PluginConfig.Transport.
const (
TransportStdio = "stdio"
TransportHTTP = "http"
TransportSSE = "sse"
)
// ErrInvalidPlugin is returned when a plugins.json entry fails validation.
var ErrInvalidPlugin = errors.New("runtimeconfig: invalid plugin config")
// PluginConfig describes one runtime-installed external provider. It is the
// on-disk plugins.json shape and the admin install body's config block.
type PluginConfig struct {
// ID is a stable identifier, generated when the panel omits one.
ID string `json:"id"`
// Name is the unique plugin label; it names the MCP server and prefixes its
// KindMCP capability ("mcp:<name>").
Name string `json:"name"`
// Kind classifies the provider. Only "mcp" is installable in v1.
Kind string `json:"kind"`
// Transport selects the wire transport: "stdio", "http" (Streamable HTTP) or
// "sse". Empty is inferred from Command/Endpoint.
Transport string `json:"transport,omitempty"`
// Endpoint is the server URL for http/sse transports.
Endpoint string `json:"endpoint,omitempty"`
// Command is the executable path for a stdio transport.
Command string `json:"command,omitempty"`
// Args are the stdio command arguments.
Args []string `json:"args,omitempty"`
// APIKey is the bearer credential sent to an http/sse server. It is
// persisted but never returned or logged verbatim (MaskedPlugin).
APIKey string `json:"apiKey,omitempty"`
// 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)
}
// Normalize fills the defaults an install request may omit: kind=mcp and the
// transport inferred from command/endpoint (stdio when a command is set,
// otherwise http when an endpoint is set).
func (p PluginConfig) Normalize() PluginConfig {
if strings.TrimSpace(p.Kind) == "" {
p.Kind = PluginKindMCP
}
if strings.TrimSpace(p.Transport) == "" {
switch {
case strings.TrimSpace(p.Command) != "":
p.Transport = TransportStdio
case strings.TrimSpace(p.Endpoint) != "":
p.Transport = TransportHTTP
}
}
return p
}
// Validate checks the plugin's required fields. It validates the normalized
// form so a caller that only fills endpoint or command still gets a coherent
// transport. It never includes a secret in the returned error.
func (p PluginConfig) Validate() error {
p = p.Normalize()
if strings.TrimSpace(p.Name) == "" {
return fmt.Errorf("%w: name is required", ErrInvalidPlugin)
}
if p.Kind != PluginKindMCP {
return fmt.Errorf("%w: kind %q must be %s", ErrInvalidPlugin, p.Kind, PluginKindMCP)
}
switch p.Transport {
case TransportStdio:
if strings.TrimSpace(p.Command) == "" {
return fmt.Errorf("%w: command is required for %s transport", ErrInvalidPlugin, TransportStdio)
}
case TransportHTTP, "streamable", TransportSSE:
if strings.TrimSpace(p.Endpoint) == "" {
return fmt.Errorf("%w: endpoint is required for %s transport", ErrInvalidPlugin, p.Transport)
}
default:
return fmt.Errorf("%w: transport %q must be stdio, http, streamable or sse", ErrInvalidPlugin, p.Transport)
}
return nil
}
// LoadPlugins returns the persisted plugin list. A missing file is the normal
// "nothing installed" case and yields an empty list with no error. A
// present-but-invalid file returns a non-nil error so the caller can warn
// instead of silently dropping installed plugins.
func LoadPlugins(path string) ([]PluginConfig, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return []PluginConfig{}, nil
}
return nil, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
}
if len(strings.TrimSpace(string(raw))) == 0 {
return nil, fmt.Errorf("%w: empty file", ErrInvalidPlugin)
}
var plugins []PluginConfig
if err := json.Unmarshal(raw, &plugins); err != nil {
return nil, fmt.Errorf("%w: decode: %w", ErrInvalidPlugin, err)
}
for i, p := range plugins {
if err := p.Validate(); err != nil {
return nil, fmt.Errorf("%w: entry %d: %w", ErrInvalidPlugin, i, err)
}
}
if plugins == nil {
plugins = []PluginConfig{}
}
return plugins, nil
}
// SavePlugins writes plugins as a pretty-printed JSON array to path with mode
// 0600 (the file may hold an API key). It writes to a temp file then renames so
// a concurrent reader never sees a partial file. Every entry is validated first.
func SavePlugins(path string, plugins []PluginConfig) error {
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("runtimeconfig: mkdir: %w", err)
}
}
out := make([]PluginConfig, 0, len(plugins))
for i, p := range plugins {
p = p.Normalize()
if err := p.Validate(); err != nil {
return fmt.Errorf("runtimeconfig: save: entry %d: %w", i, err)
}
out = append(out, p)
}
raw, err := json.MarshalIndent(out, "", " ")
if err != nil {
return fmt.Errorf("runtimeconfig: marshal: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("runtimeconfig: rename: %w", err)
}
return nil
}
// UpsertPlugin returns plugins with p inserted or replaced in place by name
// (falling back to id), preserving order. A blank id is assigned a generated one
// so a panel save without an explicit id still produces a stable, addressable
// entry.
func UpsertPlugin(plugins []PluginConfig, p PluginConfig) []PluginConfig {
p = p.Normalize()
if strings.TrimSpace(p.ID) == "" {
p.ID = NewID()
}
out := make([]PluginConfig, 0, len(plugins)+1)
replaced := false
for _, cur := range plugins {
if samePlugin(cur, p) {
out = append(out, p)
replaced = true
continue
}
out = append(out, cur)
}
if !replaced {
out = append(out, p)
}
return out
}
// DeletePlugin returns plugins with the entry matching name (by name or id)
// removed. The bool reports whether an entry was actually removed.
func DeletePlugin(plugins []PluginConfig, name string) ([]PluginConfig, bool) {
out := make([]PluginConfig, 0, len(plugins))
removed := false
for _, cur := range plugins {
if cur.Name == name || cur.ID == name {
removed = true
continue
}
out = append(out, cur)
}
return out, removed
}
// MaskedPlugin returns a copy of p whose API key and sensitive env values are
// redacted, for a wire response or a log line. The on-disk plugins.json is
// untouched.
func MaskedPlugin(p PluginConfig) PluginConfig {
out := p
if strings.TrimSpace(p.APIKey) != "" {
out.APIKey = maskToken(strings.TrimSpace(p.APIKey))
}
out.Env = maskEnv(p.Env)
return out
}
// maskEnv redacts the values of sensitive env keys, passing non-sensitive keys,
// empty values and non-string values through unchanged.
func maskEnv(env map[string]string) map[string]string {
if env == nil {
return nil
}
out := make(map[string]string, len(env))
for k, v := range env {
if isSensitiveKey(k) && strings.TrimSpace(v) != "" {
out[k] = maskToken(v)
continue
}
out[k] = v
}
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 {
if p.Name != "" && cur.Name == p.Name {
return true
}
return p.ID != "" && cur.ID == p.ID
}