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` 通过。
This commit is contained in:
1 parent
7ea2850f18
commit
86aef7b454
15 files changed
+1094
-48
No files matched your search
@@ -7,12 +7,28 @@ import "context"
|
||||
const backthinkListLimit = 50
|
||||
|
||||
// PluginSummary 是管理列表中一个能力/插件的线上结构。
|
||||
//
|
||||
// Origin(v0.83 契约增量)回答「这条能力来自哪台上游 server」,供面板把 MCP 工具的
|
||||
// 呈现按 server 分节(DevDocs/audit/MCP工具按上游分组-2026-10-24.md §2 方案①)。
|
||||
// 它是**可选键**:宿主内置能力根本不实现来源,因此该键整体**缺席**(不是 `{}` 也不是
|
||||
// `null`)。归属来自注册时的元数据,绝不解析 `name` 推断 —— MCP 工具名里没有任何前缀。
|
||||
// 老客户端忽略未知键即零改动;严格解析器需要容忍/声明该可选字段。
|
||||
type PluginSummary struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Description string `json:"description"`
|
||||
AllowInGroup bool `json:"allowInGroup"`
|
||||
AllowInStranger bool `json:"allowInStranger"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Description string `json:"description"`
|
||||
AllowInGroup bool `json:"allowInGroup"`
|
||||
AllowInStranger bool `json:"allowInStranger"`
|
||||
Origin *PluginOrigin `json:"origin,omitempty"`
|
||||
}
|
||||
|
||||
// PluginOrigin 是一条能力的上游来源。Server 是 server 名(= plugins.json /
|
||||
// MCP_SERVERS_JSON 的 name),Transport 是 stdio / sse / http;两者按 src 的实际
|
||||
// 已知程度省略,Kind 恒有值(目前只有 "mcp")。
|
||||
type PluginOrigin struct {
|
||||
Kind string `json:"kind"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
}
|
||||
|
||||
// ChannelSummary 是一个已注册渠道适配器的线上结构。Accounts
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// pluginInstallTimeout 限定安装请求所等待的 connect+discover 握手
|
||||
@@ -208,6 +209,18 @@ func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugins_unavailable", "capability registry is not configured"))
|
||||
return
|
||||
}
|
||||
// 🔴 单把 MCP 工具与 `mcp:<server>` 登记条目**不能**走这条裸删路径
|
||||
// (DevDocs/audit/MCP工具按上游分组-2026-10-24.md §1.5-2):管理器
|
||||
// Uninstall 只按 server 名/ID 匹配,落到这里就等于绕过插件管理器直接改注册表 ——
|
||||
// 该 server 的 names 账本不同步(下次 refresh 会把工具当「新出现的」再注册回来,
|
||||
// 或让 KindMCP 条目与工具各自消失一半)。判据是**注册时的来源章**,不是名字前缀。
|
||||
if cap, err := reg.Get(name); err == nil {
|
||||
if reason := serverScopedUninstall(name, cap); reason != "" {
|
||||
s.auditClaims(r, "plugin.uninstall", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "plugin_uninstall_scope", "%s", reason))
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := reg.Remove(name); err != nil {
|
||||
s.auditClaims(r, "plugin.uninstall", "error")
|
||||
if errors.Is(err, capreg.ErrNotFound) {
|
||||
@@ -221,6 +234,22 @@ func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "uninstalled": true})
|
||||
}
|
||||
|
||||
// serverScopedUninstall 报告「这个名字必须在『MCP 服务器』页按整台 server 处理,
|
||||
// 不能在这里单删」的原因;返回空串 = 可以按能力名删除(技能 / 普通工具 / 渠道)。
|
||||
//
|
||||
// 它拦住两类真实事故(同一条根因:绕开插件管理器裸删):
|
||||
// - 一把 MCP 工具:server 的 names 账本不同步,重连/refresh 后工具复活,用户以为卸了;
|
||||
// - `mcp:<server>` 登记条目:server 条目消失了而它的工具还在,注册表里留下无法归属的孤儿。
|
||||
func serverScopedUninstall(name string, cap capreg.Capability) string {
|
||||
if cap.Kind == capreg.KindMCP {
|
||||
return fmt.Sprintf("%q 是一台 MCP server 的登记条目;请在「MCP 服务器」页卸载该 server", name)
|
||||
}
|
||||
if cap.Origin.Kind == sdk.OriginKindMCP {
|
||||
return fmt.Sprintf("%q 是 MCP server %q 的工具,单把工具不能单独卸载;请在「MCP 服务器」页启停或卸载该 server", name, cap.Origin.Server)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// adminListInstalledPlugins 列出运行时安装的插件,附其脱敏配置
|
||||
// 与每 server 工具数。它是读端点(owner/admin),
|
||||
// 绝不原样返回 API key 或敏感 env 值。
|
||||
|
||||
@@ -39,6 +39,10 @@ func (s *Server) adminListMemories(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// adminListPlugins 列出所有已注册能力(管理读)。
|
||||
//
|
||||
// 每条能力带上它的上游来源(若有):来源章的读取面在这里一次性给出,
|
||||
// 前端「插件与能力」页据此把工具按 server 分节。**策略字段与集合完全不变** ——
|
||||
// 只是多了一个可选键,权限判定与模型可见的工具面都不读它。
|
||||
func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
out := make([]PluginSummary, 0)
|
||||
if s.caps != nil {
|
||||
@@ -49,12 +53,26 @@ func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
Description: cap.Description,
|
||||
AllowInGroup: cap.Policy.AllowInGroup,
|
||||
AllowInStranger: cap.Policy.AllowInStranger,
|
||||
Origin: pluginOriginOf(cap),
|
||||
})
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// pluginOriginOf 把能力条目上的来源章映射为线上结构;零值(宿主内置 / 来源未知)
|
||||
// 返回 nil,使 JSON 里**没有** origin 键 —— 缺席是「宿主内置」的表达。
|
||||
func pluginOriginOf(cap capreg.Capability) *PluginOrigin {
|
||||
if cap.Origin.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &PluginOrigin{
|
||||
Kind: cap.Origin.Kind,
|
||||
Server: cap.Origin.Server,
|
||||
Transport: cap.Origin.Transport,
|
||||
}
|
||||
}
|
||||
|
||||
// adminListChannels 列出已注册的渠道适配器(管理读)。当接线了
|
||||
// 渠道配置存储时,它还会附上各平台已配置的账号,并做脱敏,
|
||||
// 确保原始 token 绝不抵达客户端。
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件钉住「插件与能力」读面的**来源章**(DevDocs/audit/MCP工具按上游分组-2026-10-24.md
|
||||
// §2 方案① / §6 契约增量),以及与之配套的服务端拒绝:
|
||||
//
|
||||
// - 来自 MCP server 的工具带 `origin:{kind,server,transport}`;
|
||||
// - 宿主内置工具的该键**缺席**(不是 `{}`、不是 `null`)—— 缺席才表示「没有上游」;
|
||||
// - 只改呈现:`allowInGroup`/`allowInStranger` 逐字不变,`kind` 仍是 `tool`;
|
||||
// - 对一把 MCP 工具调 `POST /admin/plugins/uninstall` 必须 400(绕开管理器裸删会让
|
||||
// server 的 names 账本不同步),而普通能力的卸载行为不变。
|
||||
|
||||
// originStubTool 是自报来源的最小工具。
|
||||
type originStubTool struct {
|
||||
name string
|
||||
origin sdk.Origin
|
||||
}
|
||||
|
||||
func (t originStubTool) Name() string { return t.name }
|
||||
func (t originStubTool) Description() string { return "MCP 工具" }
|
||||
func (t originStubTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{"type": "object"}, nil
|
||||
}
|
||||
func (t originStubTool) Execute(_ context.Context, _ any) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (t originStubTool) Origin() sdk.Origin { return t.origin }
|
||||
|
||||
// builtinStubTool 是宿主内置工具(不实现 sdk.OriginAware)。
|
||||
type builtinStubTool struct{ name string }
|
||||
|
||||
func (t builtinStubTool) Name() string { return t.name }
|
||||
func (t builtinStubTool) Description() string { return "内置工具" }
|
||||
func (t builtinStubTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{"type": "object"}, nil
|
||||
}
|
||||
func (t builtinStubTool) Execute(_ context.Context, _ any) (any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// listPlugins 取管理读面的能力清单(保留原始 JSON 以便断言键的**缺席**)。
|
||||
func listPlugins(t *testing.T, url, token string) []byte {
|
||||
t.Helper()
|
||||
resp, raw := doReq(t, http.MethodGet, url+"/api/v1/admin/plugins", nil, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("plugins list status = %d, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// TestAdminListPluginsExposesOrigin 是读面的主判据:MCP 工具带章、内置工具缺席。
|
||||
func TestAdminListPluginsExposesOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPluginTestEnv(t, nil)
|
||||
mcpOrigin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"}
|
||||
if err := env.caps.RegisterTool(originStubTool{name: "read_file_mcp", origin: mcpOrigin}); err != nil {
|
||||
t.Fatalf("register mcp tool: %v", err)
|
||||
}
|
||||
if err := env.caps.RegisterTool(builtinStubTool{name: "web_search"}); err != nil {
|
||||
t.Fatalf("register builtin tool: %v", err)
|
||||
}
|
||||
|
||||
raw := listPlugins(t, env.ts.URL, env.token)
|
||||
var list struct {
|
||||
Data []PluginSummary `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
t.Fatalf("decode list: %v (%s)", err, raw)
|
||||
}
|
||||
byName := make(map[string]PluginSummary, len(list.Data))
|
||||
for _, it := range list.Data {
|
||||
byName[it.Name] = it
|
||||
}
|
||||
mcpCap, ok := byName["read_file_mcp"]
|
||||
if !ok {
|
||||
t.Fatalf("MCP 工具不在清单里:%s", raw)
|
||||
}
|
||||
if mcpCap.Origin == nil {
|
||||
t.Fatalf("MCP 工具没有 origin:%+v", mcpCap)
|
||||
}
|
||||
if *mcpCap.Origin != (PluginOrigin{Kind: "mcp", Server: "fs", Transport: "stdio"}) {
|
||||
t.Fatalf("origin = %+v, want {mcp fs stdio}", *mcpCap.Origin)
|
||||
}
|
||||
// 呈现层新增不改既有语义:kind 与两个策略布尔逐字不变。
|
||||
if mcpCap.Kind != "tool" || mcpCap.AllowInGroup || mcpCap.AllowInStranger {
|
||||
t.Fatalf("既有字段被改了:%+v", mcpCap)
|
||||
}
|
||||
builtin, ok := byName["web_search"]
|
||||
if !ok {
|
||||
t.Fatalf("内置工具不在清单里:%s", raw)
|
||||
}
|
||||
if builtin.Origin != nil {
|
||||
t.Fatalf("内置工具带上了 origin:%+v", builtin.Origin)
|
||||
}
|
||||
// 🔴 线上必须是「键缺席」而不是空对象:`"origin":{}` / `"origin":null` 都会被
|
||||
// 前端/严格解析器读成「有来源但内容为空」。
|
||||
entry := rawEntryOf(t, raw, "web_search")
|
||||
if strings.Contains(entry, "origin") {
|
||||
t.Fatalf("内置工具的条目里出现了 origin 键:%s", entry)
|
||||
}
|
||||
}
|
||||
|
||||
// rawEntryOf 从原始响应里截出某个名为 name 的条目的 JSON 片段,用于断言键的缺席
|
||||
// (反序列化后的结构体无法区分「缺键」与「零值」)。
|
||||
func rawEntryOf(t *testing.T, raw []byte, name string) string {
|
||||
t.Helper()
|
||||
var items []json.RawMessage
|
||||
var envelope struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err == nil {
|
||||
items = envelope.Data
|
||||
} else if err := json.Unmarshal(raw, &items); err != nil {
|
||||
t.Fatalf("decode raw list: %v (%s)", err, raw)
|
||||
}
|
||||
for _, item := range items {
|
||||
var probe struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(item, &probe); err != nil {
|
||||
t.Fatalf("decode item: %v (%s)", err, item)
|
||||
}
|
||||
if probe.Name == name {
|
||||
return string(item)
|
||||
}
|
||||
}
|
||||
t.Fatalf("条目 %q 不在清单里:%s", name, raw)
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestAdminPluginUninstallRejectsMCPTool 是那条 high finding 的服务端一半:
|
||||
// 单把 MCP 工具不能绕过插件管理器裸删,必须指回「MCP 服务器」页。
|
||||
func TestAdminPluginUninstallRejectsMCPTool(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPluginTestEnv(t, nil)
|
||||
origin := sdk.Origin{Kind: sdk.OriginKindMCP, Server: "fs", Transport: "stdio"}
|
||||
if err := env.caps.RegisterTool(originStubTool{name: "read_file_mcp", origin: origin}); err != nil {
|
||||
t.Fatalf("register mcp tool: %v", err)
|
||||
}
|
||||
|
||||
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall",
|
||||
map[string]any{"name": "read_file_mcp"}, env.token)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400(单把 MCP 工具不能在这里卸载),body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "plugin_uninstall_scope") {
|
||||
t.Fatalf("错误码不是 plugin_uninstall_scope:%s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "MCP 服务器") {
|
||||
t.Fatalf("错误消息没说该去哪里卸:%s", raw)
|
||||
}
|
||||
if _, err := env.caps.Get("read_file_mcp"); err != nil {
|
||||
t.Fatalf("被拒的卸载却把工具删了:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminPluginUninstallStillRemovesPlainCapability:普通能力(技能/内置工具)
|
||||
// 的卸载路径逐字不变 —— 拒绝只针对「有 MCP 来源」的条目。
|
||||
func TestAdminPluginUninstallStillRemovesPlainCapability(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPluginTestEnv(t, nil)
|
||||
if err := env.caps.RegisterTool(builtinStubTool{name: "web_search"}); err != nil {
|
||||
t.Fatalf("register builtin tool: %v", err)
|
||||
}
|
||||
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall",
|
||||
map[string]any{"name": "web_search"}, env.token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
if _, err := env.caps.Get("web_search"); err == nil {
|
||||
t.Fatal("普通能力没有被卸载")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstalledMCPServerToolsCarryOrigin 走**热装整链**(install 端点 → 插件管理器 →
|
||||
// mcp.Registry → RegisterTool):装完那台 server 的工具在管理读面上带 origin,
|
||||
// 而按 server 名卸载后条目**连 origin 一起消失**,不留孤儿。
|
||||
//
|
||||
// 它与 `internal/capability/mcp` 的盖章用例互补:那条钉的是两条接入路径的合流处
|
||||
// (RegisterTool),这条钉的是运行时这条路径真的走到了那里。
|
||||
func TestInstalledMCPServerToolsCarryOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPluginTestEnv(t, newFakeMCPHandler("", "add", "mul"))
|
||||
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", map[string]any{
|
||||
"kind": "mcp",
|
||||
"name": "calc",
|
||||
"config": map[string]any{"transport": "http", "endpoint": env.mcpURL},
|
||||
}, env.token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("install status = %d, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
|
||||
var list struct {
|
||||
Data []PluginSummary `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(listPlugins(t, env.ts.URL, env.token), &list); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
want := PluginOrigin{Kind: "mcp", Server: "calc", Transport: "http"}
|
||||
tools := 0
|
||||
for _, it := range list.Data {
|
||||
if it.Kind != "tool" {
|
||||
continue
|
||||
}
|
||||
tools++
|
||||
if it.Origin == nil || *it.Origin != want {
|
||||
t.Fatalf("工具 %q 的 origin = %+v, want %+v", it.Name, it.Origin, want)
|
||||
}
|
||||
}
|
||||
if tools != 2 {
|
||||
t.Fatalf("工具数 = %d, want 2(add/mul)", tools)
|
||||
}
|
||||
|
||||
// 按 server 名卸载(这是**合法**路径,管理器会连工具带账本一起收),
|
||||
// 卸载后工具条目连 origin 一起消失 —— 「停用 server ⇒ 工具消失」语义不变。
|
||||
resp, raw = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall",
|
||||
map[string]any{"name": "calc"}, env.token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("uninstall status = %d, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
if left := env.caps.List(capreg.KindTool); len(left) != 0 {
|
||||
t.Fatalf("卸载 server 后还剩 %d 把工具(不应留下带 origin 的孤儿)", len(left))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user