Files
AskaEth 86aef7b454 feat(capability): 工具按上游 MCP server 分节(来源章 + 呈现隔离)
主人 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` 通过。
2026-10-24 20:21:22 +08:00

392 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
}