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

123 lines
3.8 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 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
}