主人 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` 通过。
157 lines
4.7 KiB
Go
157 lines
4.7 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
|
)
|
|
|
|
// ToolInfo 是 MCP 服务器暴露的一把工具。
|
|
type ToolInfo struct {
|
|
// Name 是调用该工具用的标识。
|
|
Name string
|
|
// Description 说明工具做什么。
|
|
Description string
|
|
// InputSchema 是工具的 JSON Schema,通常是
|
|
// {"type":"object","properties":{...}}。
|
|
InputSchema map[string]any
|
|
}
|
|
|
|
// ContentBlock 是工具调用结果里的一个内容块(MCP content block)。
|
|
type ContentBlock struct {
|
|
// Type 是块类型,最常见的是 "text";非文本块只保留类型标记。
|
|
Type string `json:"type"`
|
|
// Text 是文本载荷。
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
// ToolResult 是一次 tools/call 的结构化结果。
|
|
type ToolResult struct {
|
|
// Content 是服务器返回的内容块。
|
|
Content []ContentBlock
|
|
// Structured 是服务器返回的结构化结果(structuredContent),可能为 nil。
|
|
Structured any
|
|
// IsError 表示服务器把这次调用标成了错误。
|
|
IsError bool
|
|
}
|
|
|
|
// toolInfoFrom 把 SDK 的工具描述转成本包的 ToolInfo。
|
|
func toolInfoFrom(t *mcpsdk.Tool) ToolInfo {
|
|
return ToolInfo{
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
InputSchema: schemaMap(t.InputSchema),
|
|
}
|
|
}
|
|
|
|
// mcpTool 把一把 MCP 工具适配成 pkg/plugins/sdk 的 Tool 接口,
|
|
// 使其能被注册进 registry、被 agent 当作普通工具调用。字段不可变,可并发使用。
|
|
type mcpTool struct {
|
|
client *Client
|
|
info ToolInfo
|
|
}
|
|
|
|
// Name 返回 MCP 工具标识。
|
|
func (t *mcpTool) Name() string { return t.info.Name }
|
|
|
|
// Description 返回 MCP 工具描述。
|
|
func (t *mcpTool) Description() string { return t.info.Description }
|
|
|
|
// Origin 回报这把工具的上游来源(DevDocs/audit/MCP工具按上游分组-2026-10-24.md):
|
|
// server 名与传输形态都在手上的 `client.cfg` 里,因此归属是**注册时的事实**,
|
|
// 不靠工具名推断。宿主在 registry.RegisterTool 处读到它并盖在能力条目上。
|
|
func (t *mcpTool) Origin() sdk.Origin {
|
|
return sdk.Origin{
|
|
Kind: sdk.OriginKindMCP,
|
|
Server: t.client.cfg.Name,
|
|
Transport: transportNameOf(t.client.cfg),
|
|
}
|
|
}
|
|
|
|
// Parameters 返回 MCP input schema;服务器未给 schema 时返回无约束空对象 schema。
|
|
func (t *mcpTool) Parameters() (map[string]any, error) {
|
|
if t.info.InputSchema == nil {
|
|
return emptyObjectSchema(), nil
|
|
}
|
|
return t.info.InputSchema, nil
|
|
}
|
|
|
|
// Execute 调用 MCP 工具并返回其结果文本。
|
|
// 参数先经 input schema 缓存校验 + 补默认值(design/09 §1.1),
|
|
// 文本块全为空时退回结构化结果的 JSON 文本,保证模型总能看见东西。
|
|
func (t *mcpTool) Execute(ctx context.Context, args any) (any, error) {
|
|
params, err := normalizeArgs(args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res, err := t.client.callTool(ctx, t.info.Name, params, t.info.InputSchema)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if text := contentText(res.Content); text != "" {
|
|
return text, nil
|
|
}
|
|
return structuredText(res.Structured)
|
|
}
|
|
|
|
// contentText 把工具结果里的文本块拼成一个字符串,忽略非文本块。
|
|
func contentText(content []ContentBlock) string {
|
|
var b strings.Builder
|
|
for _, blk := range content {
|
|
if blk.Type == "" || blk.Type == "text" {
|
|
b.WriteString(blk.Text)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// structuredText 把结构化结果序列化成 JSON 文本;无结构化结果时返回空串。
|
|
func structuredText(structured any) (string, error) {
|
|
if structured == nil {
|
|
return "", nil
|
|
}
|
|
data, err := json.Marshal(structured)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal structured tool result: %w", err)
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
// toContentBlocks 把 SDK 内容块转成本包结构。非文本块只保留类型标记,
|
|
// 不把 base64 大块塞进模型上下文。
|
|
func toContentBlocks(content []mcpsdk.Content) []ContentBlock {
|
|
out := make([]ContentBlock, 0, len(content))
|
|
for _, blk := range content {
|
|
if text, ok := blk.(*mcpsdk.TextContent); ok {
|
|
out = append(out, ContentBlock{Type: "text", Text: text.Text})
|
|
continue
|
|
}
|
|
out = append(out, ContentBlock{Type: contentTypeOf(blk)})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// contentTypeOf 返回内容块的线上类型名(如 image / audio / resource);
|
|
// 未知类型统一记作 non-text。
|
|
func contentTypeOf(blk mcpsdk.Content) string {
|
|
switch blk.(type) {
|
|
case *mcpsdk.ImageContent:
|
|
return "image"
|
|
case *mcpsdk.AudioContent:
|
|
return "audio"
|
|
case *mcpsdk.ResourceLink, *mcpsdk.EmbeddedResource:
|
|
return "resource"
|
|
case *mcpsdk.ToolUseContent:
|
|
return "tool_use"
|
|
case *mcpsdk.ToolResultContent:
|
|
return "tool_result"
|
|
default:
|
|
return "non-text"
|
|
}
|
|
}
|