主人 2026-10-24:「能否将每个 MCP 服务器所承载的插件和工具按上游(来源)服务器分类
并与其他插件相对隔离?」
归属信息此前后端根本没有(GET /admin/plugins 每条只有 name/kind/description/两个
布尔),而 `mcp:` 是每台 server 一条的 KindMCP 条目的注册键,**不是**工具的命名空间
(工具名由 server 原样给出,连前缀都没有)⇒ 只能靠注册时的元数据,不能解析名字。
后端(唯一汇点盖章):
- 新增 pkg/plugins/sdk/origin.go:Origin{Kind,Server,Transport} + 可选接口
OriginAware(没实现 = 宿主内置,老工具零改动)+ OriginOf(零值视为无来源)。
- registry.Capability 加 Origin 字段;RegisterTool 盖章(覆盖热装与 .env
MCP_SERVERS_JSON 两条接入路径的合流处),ReplaceTool **同样重新盖章**(它逐字段
重建条目,漏了就是「热替换把来源抹掉」)。
- mcp.mcpTool 实现 Origin()(server 名与传输都在 cfg 上),transport 用新的
transportNameOf 报**实际会用的**形态(stdio/sse/http),与 newTransport 同一推断。
- 网关 PluginSummary 加可选 origin(omitempty):零值 ⇒ 键缺席,不是空对象。
- 卸载守卫:对单把 MCP 工具或 `mcp:<server>` 登记条目直接 400
(plugin_uninstall_scope)并指向「MCP 服务器」页 —— 原来会绕过插件管理器裸删,
server 的 names 账本不同步、重连后工具复活。判据是来源章,不是名字前缀。
前端(呈现分组):
- admin/modules/pluginOrigin.ts:纯函数按 origin.server 分节(按 server 名排序),
transport 未报告时标题省略括号,缺 server 名不冒充内置。
- Plugins.tsx:工具段两级(`来自 fs(stdio)· N 把工具` + 宿主内置),MCP 工具行去掉
「卸载」改为指向 MCP 页;`kind:"mcp"` 登记条目与未知 kind(iot)各有段如实显示,
不再静默丢弃(design/09 §5.2.1 红线③)。
零影响(有断言):tool_policies 表/策略写路径未改;ToolSchemas 不发来源(测试断言
模型面既无 origin 键也无 server 名);停用 server ⇒ 条目连来源一起消失,无孤儿。
测试:registry 盖章/保章(两条均已实测变异变红)/内置零值/改策略不清章;mcp 注册
路径盖章 + transport 推断表;网关读面缺席与存在、热装整链、卸载守卫;前端 esbuild+cjs
逻辑测试 12 项。验收:go build/vet/test 全绿,frontend `npm run build` 通过。
392 lines
15 KiB
Go
392 lines
15 KiB
Go
// Package registry provides the global capability registry. Every capability a
|
||
// character can use — tools, skills, channels, MCP servers and IoT providers —
|
||
// is registered here so it is globally shared and always available to any
|
||
// character and mode (DevDocs/design/01-角色人格与行为模式.md §4).
|
||
//
|
||
// It is the single source of truth that tool dispatch (M2) and plugin/skill/MCP
|
||
// wiring (M5) read from. The registry is safe for concurrent use; it implements
|
||
// the pkg/plugins/sdk Registrar interface so plugins register through the SDK
|
||
// helpers without knowing this concrete type.
|
||
package registry
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"sync"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
)
|
||
|
||
// Kind classifies a registered capability.
|
||
type Kind string
|
||
|
||
const (
|
||
// KindTool is a callable function with a JSON Schema (sdk.Tool).
|
||
KindTool Kind = "tool"
|
||
// KindSkill is a reusable workflow with steps, prompts, refs and guardrails
|
||
// (sdk.Skill).
|
||
KindSkill Kind = "skill"
|
||
// KindChannel is a platform adapter (sdk.Channel).
|
||
KindChannel Kind = "channel"
|
||
// KindMCP is a capability exposed by an MCP server (subsystem-owned).
|
||
KindMCP Kind = "mcp"
|
||
// KindIoT is an IoT device or provider capability (subsystem-owned).
|
||
KindIoT Kind = "iot"
|
||
)
|
||
|
||
// ToolPolicy holds the editable Boolean markers that gate a tool/capability in
|
||
// non-owner environments (DevDocs/design/11 §3). Both fields default to false,
|
||
// so a capability is owner-only until a marker is explicitly enabled. Policies
|
||
// are per-capability and editable at runtime (the admin panel, M10).
|
||
type ToolPolicy struct {
|
||
// AllowInGroup permits invocation from a group chat environment.
|
||
AllowInGroup bool
|
||
// AllowInStranger permits invocation from a stranger's private chat
|
||
// environment.
|
||
AllowInStranger bool
|
||
}
|
||
|
||
// Capability is a globally shared, always-available capability registered in
|
||
// the registry.
|
||
type Capability struct {
|
||
// Name is the unique capability identifier.
|
||
Name string
|
||
// Kind classifies the capability.
|
||
Kind Kind
|
||
// Description is a human-readable summary used for selection and display.
|
||
Description string
|
||
// Policy is the editable reachability markers for this capability. It
|
||
// defaults to zero, which the policy engine reads as owner-only.
|
||
Policy ToolPolicy
|
||
// Origin 是能力的**上游来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md
|
||
// 方案①):由工具自己通过 sdk.OriginAware 声明,注册汇点
|
||
// RegisterTool/ReplaceTool 盖在这里。零值 = 宿主内置 / 来源未知 ——
|
||
// 这枚章**只用于呈现归属**,不参与权限判定、不进模型可见的工具面
|
||
// (restrict 只按名字查策略,ToolSchemas 只取 Name/Description/Parameters)。
|
||
Origin sdk.Origin
|
||
// Value is the underlying capability object. For KindTool/KindSkill/
|
||
// KindChannel it is the SDK interface from pkg/plugins/sdk; for KindMCP and
|
||
// KindIoT it is owned by the relevant subsystem.
|
||
Value any
|
||
}
|
||
|
||
// Registry is the thread-safe global capability registry.
|
||
type Registry struct {
|
||
mu sync.RWMutex
|
||
caps map[string]Capability
|
||
// store 是策略落盘载体(design/11 §3、裁决 10.4.2)。为 nil 时注册表
|
||
// 退回纯内存态(既有行为);AttachPolicyStore 装上后,SetPolicy 系列
|
||
// 先落库再改内存。
|
||
store PolicyStore
|
||
// persisted 是最近一次落盘成功的策略快照(能力名 → 标记)。注册表在
|
||
// Register 时用它覆盖能力的初始 Policy,因此「策略在能力注册之前就已落盘」
|
||
// 与「能力先注册、策略后落盘」两种顺序都得到同一个结果。
|
||
persisted map[string]ToolPolicy
|
||
}
|
||
|
||
// Compile-time assertion that *Registry satisfies the SDK Registrar interface.
|
||
var _ sdk.Registrar = (*Registry)(nil)
|
||
|
||
// Sentinel errors returned by the registry. Use errors.Is to test for them.
|
||
var (
|
||
// ErrNotFound is returned by Get when no capability has the given name.
|
||
ErrNotFound = errors.New("registry: capability not found")
|
||
// ErrDuplicate is returned by Register when a capability with the same name
|
||
// is already registered.
|
||
ErrDuplicate = errors.New("registry: capability already registered")
|
||
// ErrInvalidArgument is returned when a capability has an empty name, an
|
||
// unknown kind, or a nil Value.
|
||
ErrInvalidArgument = errors.New("registry: invalid capability")
|
||
)
|
||
|
||
// New returns an empty, concurrency-safe registry.
|
||
func New() *Registry {
|
||
return &Registry{caps: make(map[string]Capability)}
|
||
}
|
||
|
||
// Register adds cap to the registry. It returns ErrInvalidArgument when the
|
||
// name is empty, the kind is unknown or the Value is nil, and ErrDuplicate when
|
||
// a capability with the same name already exists.
|
||
func (r *Registry) Register(cap Capability) error {
|
||
if cap.Name == "" || !validKind(cap.Kind) || cap.Value == nil {
|
||
return fmt.Errorf("registry: register %q: %w", cap.Name, ErrInvalidArgument)
|
||
}
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
if _, exists := r.caps[cap.Name]; exists {
|
||
return fmt.Errorf("registry: register %q: %w", cap.Name, ErrDuplicate)
|
||
}
|
||
// 落盘策略覆盖注册时带的初始标记:DB 是策略的权威载体(裁决 10.4.2),
|
||
// 因此重启后先 Load、再热注册(MCP 热装等)也能拿到已配置的值。
|
||
if pol, ok := r.persisted[cap.Name]; ok {
|
||
cap.Policy = pol
|
||
}
|
||
r.caps[cap.Name] = cap
|
||
return nil
|
||
}
|
||
|
||
// Get returns the capability registered under name. It returns ErrNotFound when
|
||
// no such capability is registered.
|
||
func (r *Registry) Get(name string) (Capability, error) {
|
||
r.mu.RLock()
|
||
defer r.mu.RUnlock()
|
||
cap, ok := r.caps[name]
|
||
if !ok {
|
||
return Capability{}, fmt.Errorf("registry: get %q: %w", name, ErrNotFound)
|
||
}
|
||
return cap, nil
|
||
}
|
||
|
||
// Remove deletes the capability registered under name. It returns ErrNotFound
|
||
// when no such capability is registered.
|
||
func (r *Registry) Remove(name string) error {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
if _, ok := r.caps[name]; !ok {
|
||
return fmt.Errorf("registry: remove %q: %w", name, ErrNotFound)
|
||
}
|
||
delete(r.caps, name)
|
||
return nil
|
||
}
|
||
|
||
// List returns a copy of the capabilities whose Kind matches kind. An empty
|
||
// kind returns every registered capability. The returned slice is owned by the
|
||
// caller and safe to mutate.
|
||
func (r *Registry) List(kind Kind) []Capability {
|
||
r.mu.RLock()
|
||
defer r.mu.RUnlock()
|
||
out := make([]Capability, 0, len(r.caps))
|
||
for _, cap := range r.caps {
|
||
if kind != "" && cap.Kind != kind {
|
||
continue
|
||
}
|
||
out = append(out, cap)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Policy returns the editable reachability markers for the capability named
|
||
// name. It returns ErrNotFound when no such capability is registered.
|
||
func (r *Registry) Policy(name string) (ToolPolicy, error) {
|
||
r.mu.RLock()
|
||
defer r.mu.RUnlock()
|
||
cap, ok := r.caps[name]
|
||
if !ok {
|
||
return ToolPolicy{}, fmt.Errorf("registry: policy %q: %w", name, ErrNotFound)
|
||
}
|
||
return cap.Policy, nil
|
||
}
|
||
|
||
// SetPolicy replaces the reachability markers for the capability named name. It
|
||
// returns ErrNotFound when no such capability is registered.
|
||
//
|
||
// 落盘(裁决 1.3/10.4.2):已装上 PolicyStore 时先写库,成功后才改内存——
|
||
// 写库失败即返回错误、内存保持旧值,绝不产生「面板说保存成功、重启却复位」。
|
||
// 顺序是「存在性读 → 落库(锁外做 SQL)→ 持锁套用」。
|
||
func (r *Registry) SetPolicy(name string, p ToolPolicy) error {
|
||
if _, err := r.Policy(name); err != nil {
|
||
return fmt.Errorf("registry: set policy %q: %w", name, err)
|
||
}
|
||
if err := r.persist(name, p); err != nil {
|
||
return err
|
||
}
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
cap, ok := r.caps[name]
|
||
if !ok {
|
||
return fmt.Errorf("registry: set policy %q: %w", name, ErrNotFound)
|
||
}
|
||
cap.Policy = p
|
||
r.caps[name] = cap
|
||
r.remember(name, p)
|
||
return nil
|
||
}
|
||
|
||
// SetAllowInGroup toggles the group-chat marker for the capability named name.
|
||
// It returns ErrNotFound when no such capability is registered. 它复用
|
||
// setPolicyField,因此同样先落库再改内存(裁决 10.4.2)。
|
||
func (r *Registry) SetAllowInGroup(name string, allow bool) error {
|
||
return r.setPolicyField(name, "allow_in_group", func(p *ToolPolicy) { p.AllowInGroup = allow })
|
||
}
|
||
|
||
// SetAllowInStranger toggles the stranger-chat marker for the capability named
|
||
// name. It returns ErrNotFound when no such capability is registered. 同
|
||
// SetAllowInGroup:先落库再改内存。
|
||
func (r *Registry) SetAllowInStranger(name string, allow bool) error {
|
||
return r.setPolicyField(name, "allow_in_stranger", func(p *ToolPolicy) { p.AllowInStranger = allow })
|
||
}
|
||
|
||
// setPolicyField 是三个策略写入口共用的「读当前值 → 改一位 → SetPolicy」路径
|
||
// (先落库再改内存,见 SetPolicy)。field 只用于错误消息里的操作名。
|
||
func (r *Registry) setPolicyField(name, field string, apply func(*ToolPolicy)) error {
|
||
cur, err := r.Policy(name)
|
||
if err != nil {
|
||
return fmt.Errorf("registry: set %s %q: %w", field, name, err)
|
||
}
|
||
apply(&cur)
|
||
if err := r.SetPolicy(name, cur); err != nil {
|
||
return fmt.Errorf("registry: set %s %q: %w", field, name, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// RegisterTool registers t as a KindTool capability.
|
||
//
|
||
// 它是**来源章的唯一汇点**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①):
|
||
// MCP 工具的**两条接入路径**都从这里进注册表 —— 运行时热装
|
||
// (plugin.Manager.start → mcp.NewRegistry → mcp.Registry.Register)与 `.env` 的
|
||
// MCP_SERVERS_JSON 启动期装(cmd/cyrene/mcp.go,不经 Manager)—— 因此在这一处盖章
|
||
// 一次就覆盖两条路(以及 tools/list_changed 的增量 refresh)。
|
||
// 不从 Manager 现场反推归属:那会漏掉 env 路径,把现役接入方式误报成「无来源」。
|
||
func (r *Registry) RegisterTool(t sdk.Tool) error {
|
||
if t == nil {
|
||
return fmt.Errorf("registry: register tool: %w", ErrInvalidArgument)
|
||
}
|
||
return r.Register(Capability{
|
||
Name: t.Name(),
|
||
Kind: KindTool,
|
||
Description: t.Description(),
|
||
Origin: originOf(t),
|
||
Value: t,
|
||
})
|
||
}
|
||
|
||
// originOf 返回 t 自报的来源章;没实现 sdk.OriginAware(宿主内置工具)或自报零值时
|
||
// 返回零值 Origin —— 零值在线上表现为**键缺席**,不是一个空对象。
|
||
func originOf(t sdk.Tool) sdk.Origin {
|
||
origin, ok := sdk.OriginOf(t)
|
||
if !ok {
|
||
return sdk.Origin{}
|
||
}
|
||
return origin
|
||
}
|
||
|
||
// ReplaceTool 用**同名**的 t 就地换掉已登记的工具条目(热替换,design/09 §5.5:
|
||
// 面板保存后下一轮即用新接线,不必重启)。
|
||
//
|
||
// 与「Remove + Register」的两处实质差别,正是这个窄方法存在的理由:
|
||
// - **原子**:替换在一次写锁内完成,读者中间看不到「这个名字不存在」的空窗
|
||
// (Remove+Register 之间若有 Invoke 落到这个名字上,会得到 ErrUnknownTool);
|
||
// - **保留 Policy**:分组/陌生人的可达标记是**运行期可改的配置**(§11 §3),
|
||
// 不属于工具实现。初始注册时带的标记没有进 persisted 快照,Remove+Register
|
||
// 会把它悄悄重置成零值(= 退回 owner-only),用户会看到「面板保存后某个群
|
||
// 忽然不能用了」。这里原样沿用内存里的既有标记。
|
||
//
|
||
// 没有同名条目时返回 ErrNotFound(**不**顺手新增:热替换的语义是「换掉现在这把」,
|
||
// 名字打错时静默插入会掩盖调用方的错);既有条目不是 KindTool 时返回 ErrInvalidArgument。
|
||
func (r *Registry) ReplaceTool(t sdk.Tool) error {
|
||
if t == nil || t.Name() == "" {
|
||
return fmt.Errorf("registry: replace tool: %w", ErrInvalidArgument)
|
||
}
|
||
name := t.Name()
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
cur, ok := r.caps[name]
|
||
if !ok {
|
||
return fmt.Errorf("registry: replace %q: %w", name, ErrNotFound)
|
||
}
|
||
if cur.Kind != KindTool {
|
||
return fmt.Errorf("registry: replace %q: 既有能力类型是 %q,不是工具: %w", name, cur.Kind, ErrInvalidArgument)
|
||
}
|
||
r.caps[name] = Capability{
|
||
Name: name,
|
||
Kind: KindTool,
|
||
Description: t.Description(),
|
||
// Policy 沿用旧条目的:它是可编辑的运行期配置,不属于工具的构造产出。
|
||
Policy: cur.Policy,
|
||
// Origin **必须重新盖章**:这里逐字段重建条目,漏掉一位就等于「热替换把来源
|
||
// 抹掉」,面板上那把工具会从它的 server 分节里掉进「宿主内置」。来源是工具的
|
||
// 构造产出(跟 RegisterTool 同一口径取),不沿用旧条目 —— 换了实现就该换章。
|
||
Origin: originOf(t),
|
||
Value: t,
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// RegisterSkill registers s as a KindSkill capability.
|
||
func (r *Registry) RegisterSkill(s sdk.Skill) error {
|
||
if s == nil {
|
||
return fmt.Errorf("registry: register skill: %w", ErrInvalidArgument)
|
||
}
|
||
return r.Register(Capability{
|
||
Name: s.Name(),
|
||
Kind: KindSkill,
|
||
Description: s.Description(),
|
||
Value: s,
|
||
})
|
||
}
|
||
|
||
// RegisterChannel registers c as a KindChannel capability.
|
||
func (r *Registry) RegisterChannel(c sdk.Channel) error {
|
||
if c == nil {
|
||
return fmt.Errorf("registry: register channel: %w", ErrInvalidArgument)
|
||
}
|
||
return r.Register(Capability{
|
||
Name: c.Name(),
|
||
Kind: KindChannel,
|
||
Description: c.Name(),
|
||
Value: c,
|
||
})
|
||
}
|
||
|
||
// ReplaceChannel 用**同名**的 c 就地更新已登记的渠道条目(热重载,design/04 §3.1)。
|
||
//
|
||
// 为什么必须是替换而不是 Register:渠道热重载**必然同名**(同名账号的账号名由
|
||
// channels.json 决定,重载前后不变),而 Register 遇重名返回 ErrDuplicate,会把
|
||
// 新适配器挡在门外 —— 注册表于是继续指向旧实例,管理面板照旧按旧配置读写它的状态
|
||
// (显示「未连接」、点「连接」拨的是旧地址)。
|
||
//
|
||
// 语义是 **upsert**,与 ReplaceTool 有意不同(工具的窄方法是「只换现有这把」):
|
||
// - 名字不存在 ⇒ 按 RegisterChannel 同口径新增(账号被新加进配置后,热重载是它
|
||
// 第一次出现在注册表里的时机,没有别处会替它注册);
|
||
// - 名字已存在且 Kind == KindChannel ⇒ 覆盖 Value(指向新适配器),**保留既有
|
||
// Policy** —— 分组/陌生人的可达标记是运行期可编辑配置(design/11 §3),不属于
|
||
// 适配器的构造产出,热重载把它清掉等于「面板保存一次,某个群忽然不能用了」;
|
||
// - 名字已存在但 Kind != KindChannel ⇒ 报错,绝不悄悄改掉那条能力的类型。
|
||
func (r *Registry) ReplaceChannel(c sdk.Channel) error {
|
||
if c == nil || c.Name() == "" {
|
||
return fmt.Errorf("registry: replace channel: %w", ErrInvalidArgument)
|
||
}
|
||
name := c.Name()
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
cur, ok := r.caps[name]
|
||
if !ok {
|
||
// 新增分支与 Register 同口径:落盘策略覆盖初始标记(DB 是策略的权威载体)。
|
||
added := Capability{
|
||
Name: name,
|
||
Kind: KindChannel,
|
||
Description: name,
|
||
Value: c,
|
||
}
|
||
if pol, ok := r.persisted[name]; ok {
|
||
added.Policy = pol
|
||
}
|
||
r.caps[name] = added
|
||
return nil
|
||
}
|
||
if cur.Kind != KindChannel {
|
||
return fmt.Errorf("registry: replace %q: 既有能力类型是 %q,不是渠道: %w", name, cur.Kind, ErrInvalidArgument)
|
||
}
|
||
r.caps[name] = Capability{
|
||
Name: name,
|
||
Kind: KindChannel,
|
||
// Description 与 RegisterChannel 一致地取适配器名(面板按注册名精确匹配)。
|
||
Description: name,
|
||
// Policy 沿用旧条目:它是可编辑的运行期配置,不属于适配器的构造产出。
|
||
Policy: cur.Policy,
|
||
Value: c,
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validKind reports whether k is one of the known capability kinds.
|
||
func validKind(k Kind) bool {
|
||
switch k {
|
||
case KindTool, KindSkill, KindChannel, KindMCP, KindIoT:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|