Files
AskaEth a1083a322f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(mcp): 协议层换成官方 Model Context Protocol SDK,删自研实现(裁决 10.4.6 + AGENTS §9.5)
- 新增依赖:github.com/modelcontextprotocol/go-sdk v1.8.0(direct)+ github.com/google/jsonschema-go v0.4.3
  (SDK 自带依赖,直接用于 schema 校验/默认值,避免再造一份);go.sum 纯新增
- 删除自研协议层:rpc.go / stdio.go / http.go / sse.go 及其测试
- 用 SDK 的成熟逻辑:命令传输(stdio 子进程)/ SSE / Streamable HTTP、协议版本协商与
  -32022 回退、keepalive(含失败阈值)、tools/list_changed 订阅、能力协商(InitializeResult.Capabilities)
- 保留自研的四件事(其余零自研):配置→传输、发现→registry 注册、能力族推断、schema 缓存(Prepare 命中不重复解析)
- 能力闸诚实化:服务器声明 capabilities 但不含 tools 时 ListTools/DiscoverTools 返回
  ErrCapabilityNotDeclared(不静默空集);完全不声明 capabilities 的老式服务器仍按 legacy 处理
- 复核:build 绿 + capability 全包测试绿(mcp 42 用例,含真子进程 stdio / httptest Streamable HTTP /
  内存传输三类假 server,不依赖外网)+ `-race` 干净;变异 2 条(featuresFrom 恒 true / 删 applySchema 调用)已还原
- 无 REST/WS/env/数据目录变更;包内 API 有增删(见回报),仓外调用方签名未变(未改 cmd/cyrene 与 plugin/manager)
2026-10-04 13:53:47 +08:00

124 lines
3.9 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 (
"net/http"
"net/http/httptest"
"testing"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// TestNewTransport_SelectsByType 覆盖显式传输类型与历史推断。
func TestNewTransport_SelectsByType(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg MCPConfig
want any
}{
{name: "http", cfg: MCPConfig{URL: "http://x", TransportType: "http"}, want: &mcpsdk.StreamableClientTransport{}},
{name: "streamable 别名", cfg: MCPConfig{URL: "http://x", TransportType: "streamable"}, want: &mcpsdk.StreamableClientTransport{}},
{name: "sse", cfg: MCPConfig{URL: "http://x", TransportType: "sse"}, want: &mcpsdk.SSEClientTransport{}},
{name: "stdio", cfg: MCPConfig{Command: "x", TransportType: "stdio"}, want: &mcpsdk.CommandTransport{}},
{name: "推断 stdio", cfg: MCPConfig{Command: "x"}, want: &mcpsdk.CommandTransport{}},
{name: "推断 sse", cfg: MCPConfig{URL: "http://x"}, want: &mcpsdk.SSEClientTransport{}},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tr, err := newTransport(tt.cfg)
if err != nil {
t.Fatalf("newTransport: %v", err)
}
if got, want := typeName(tr), typeName(tt.want); got != want {
t.Fatalf("传输类型 = %s, 期望 %s", got, want)
}
})
}
}
// TestNewTransport_NoTransport 两个字段都空时必须报 ErrNoTransport。
func TestNewTransport_NoTransport(t *testing.T) {
t.Parallel()
if _, err := newTransport(MCPConfig{Name: "s"}); err == nil {
t.Fatal("没有 Command/URL 却选出了传输")
}
// 显式类型但缺必要字段同样要报错(不静默构造坏传输)。
if _, err := newTransport(MCPConfig{TransportType: "http"}); err == nil {
t.Fatal("TransportType=http 但 URL 为空却没有报错")
}
if _, err := newTransport(MCPConfig{TransportType: "stdio"}); err == nil {
t.Fatal("TransportType=stdio 但 Command 为空却没有报错")
}
}
// TestBearerTransport_SetsAuthorization 凭据以 Authorization: Bearer 下发,
// 并且不污染原始请求。
func TestBearerTransport_SetsAuthorization(t *testing.T) {
t.Parallel()
got := make(chan string, 1)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got <- r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
client := httpClientFor(MCPConfig{APIKey: "sk-1234"})
if client == nil {
t.Fatal("配了 APIKey 却没有返回自定义 http client")
}
req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
_ = resp.Body.Close()
if auth := <-got; auth != "Bearer sk-1234" {
t.Fatalf("Authorization = %q, 期望 Bearer sk-1234", auth)
}
if req.Header.Get("Authorization") != "" {
t.Fatal("原始请求被就地改写了")
}
if httpClientFor(MCPConfig{}) != nil {
t.Fatal("无 APIKey 时应返回 nil(用 SDK 默认客户端)")
}
}
// TestEnvWith_OverridesAndPreserves stdio 子进程环境变量叠加语义。
func TestEnvWith_OverridesAndPreserves(t *testing.T) {
t.Parallel()
base := []string{"PATH=/usr/bin", "HOME=/root"}
got := envWith(base, map[string]string{"HOME": "/tmp", "TOKEN": "abc"})
if len(got) != 3 {
t.Fatalf("len = %d, 期望 3(%v)", len(got), got)
}
seen := map[string]bool{}
for _, kv := range got {
seen[kv] = true
}
if !seen["PATH=/usr/bin"] || !seen["HOME=/tmp"] || !seen["TOKEN=abc"] {
t.Fatalf("合并后的环境 = %v", got)
}
if len(envWith(base, nil)) != len(base) {
t.Fatal("空 extra 改变了基础环境")
}
}
// typeName 返回传输的类别名,避免在断言里依赖具体指针比较。
func typeName(v any) string {
switch v.(type) {
case *mcpsdk.StreamableClientTransport:
return "streamable"
case *mcpsdk.SSEClientTransport:
return "sse"
case *mcpsdk.CommandTransport:
return "stdio"
default:
return "unknown"
}
}