主人 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` 通过。
123 lines
3.8 KiB
Go
123 lines
3.8 KiB
Go
package mcp
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"strings"
|
||
|
||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||
)
|
||
|
||
// newTransport 按 cfg 选传输。显式 TransportType 优先(stdio / sse / http,http 与
|
||
// streamable 等价,对齐 design/09 §2.1 的枚举);为空则沿用历史推断:
|
||
// 有 Command 走 stdio,有 URL 走 SSE(既有 URL 配置多为 /sse 端点)。
|
||
// APIKey 通过自定义 RoundTripper 以 Authorization: Bearer 发送。
|
||
func newTransport(cfg MCPConfig) (mcpsdk.Transport, error) {
|
||
switch cfg.TransportType {
|
||
case "http", "streamable":
|
||
return httpURLTransport(cfg, true)
|
||
case "sse":
|
||
return httpURLTransport(cfg, false)
|
||
case "stdio":
|
||
return commandTransport(cfg)
|
||
}
|
||
switch {
|
||
case cfg.Command != "":
|
||
return commandTransport(cfg)
|
||
case cfg.URL != "":
|
||
return httpURLTransport(cfg, false)
|
||
default:
|
||
return nil, fmt.Errorf("mcp: %w", ErrNoTransport)
|
||
}
|
||
}
|
||
|
||
// transportNameOf 返回 cfg **实际会用到**的传输名(stdio / sse / http),与
|
||
// newTransport 的推断同源:显式 TransportType 优先(http / streamable 归一为 http),
|
||
// 为空则「有 Command 走 stdio,有 URL 走 sse」。两者都没有时返回空串 —— 配置不成立,
|
||
// 上游来源里该键缺席,而不是编一个 stdio。
|
||
//
|
||
// 它服务于能力条目的来源章(mcpTool.Origin):面板要能写「来自 fs(stdio)」,
|
||
// 而 cfg.TransportType 常常留空(由推断决定),因此不能直接下发原始配置值。
|
||
func transportNameOf(cfg MCPConfig) string {
|
||
switch cfg.TransportType {
|
||
case "http", "streamable":
|
||
return "http"
|
||
case "sse", "stdio":
|
||
return cfg.TransportType
|
||
}
|
||
switch {
|
||
case cfg.Command != "":
|
||
return "stdio"
|
||
case cfg.URL != "":
|
||
return "sse"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// httpURLTransport 构建 URL 型传输:streamable=true 走 Streamable HTTP(2025-11-25
|
||
// 规范首选),否则走旧式 SSE。
|
||
func httpURLTransport(cfg MCPConfig, streamable bool) (mcpsdk.Transport, error) {
|
||
if cfg.URL == "" {
|
||
return nil, fmt.Errorf("mcp transport %s: %w", cfg.TransportType, ErrNoTransport)
|
||
}
|
||
client := httpClientFor(cfg)
|
||
if streamable {
|
||
return &mcpsdk.StreamableClientTransport{Endpoint: cfg.URL, HTTPClient: client}, nil
|
||
}
|
||
return &mcpsdk.SSEClientTransport{Endpoint: cfg.URL, HTTPClient: client}, nil
|
||
}
|
||
|
||
// commandTransport 拉起 stdio 子进程,按换行分隔的 JSON-RPC 通信。
|
||
func commandTransport(cfg MCPConfig) (mcpsdk.Transport, error) {
|
||
if cfg.Command == "" {
|
||
return nil, fmt.Errorf("mcp transport stdio: %w", ErrNoTransport)
|
||
}
|
||
cmd := exec.Command(cfg.Command, cfg.Args...)
|
||
cmd.Env = envWith(os.Environ(), cfg.Env)
|
||
return &mcpsdk.CommandTransport{Command: cmd}, nil
|
||
}
|
||
|
||
// httpClientFor 返回带鉴权头的 HTTP 客户端;没有 APIKey 时返回 nil(用 SDK 默认)。
|
||
func httpClientFor(cfg MCPConfig) *http.Client {
|
||
if cfg.APIKey == "" {
|
||
return nil
|
||
}
|
||
return &http.Client{Transport: &bearerTransport{base: http.DefaultTransport, token: cfg.APIKey}}
|
||
}
|
||
|
||
// bearerTransport 给每个请求加 Authorization: Bearer。凭据不写日志、不进错误消息。
|
||
type bearerTransport struct {
|
||
base http.RoundTripper
|
||
token string
|
||
}
|
||
|
||
// RoundTrip 实现 http.RoundTripper。
|
||
func (t *bearerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||
clone := req.Clone(req.Context())
|
||
clone.Header.Set("Authorization", "Bearer "+t.token)
|
||
return t.base.RoundTrip(clone)
|
||
}
|
||
|
||
// envWith 返回 base 叠加 extra 后的环境变量(同名以 extra 为准);extra 为空时原样返回。
|
||
func envWith(base []string, extra map[string]string) []string {
|
||
if len(extra) == 0 {
|
||
return base
|
||
}
|
||
out := make([]string, 0, len(base)+len(extra))
|
||
for _, kv := range base {
|
||
if k, _, ok := strings.Cut(kv, "="); ok {
|
||
if _, dup := extra[k]; dup {
|
||
continue
|
||
}
|
||
}
|
||
out = append(out, kv)
|
||
}
|
||
for k, v := range extra {
|
||
out = append(out, k+"="+v)
|
||
}
|
||
return out
|
||
}
|