llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
532 lines
17 KiB
Go
532 lines
17 KiB
Go
package mcp
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
|
||
"git.gopher.ink/mirrors2/AutoZero/diag"
|
||
"time"
|
||
)
|
||
|
||
// 本测试用 Go 的 helper-process 模式:重新执行测试二进制作为一个"真的"子进程
|
||
// MCP server,端到端验证年代探测 → tools/list → tools/call。
|
||
// 用 AUTOGO_MCP_ERA 选择假服务端说哪个年代:modern(2026-07-28)或 legacy(握手式)。
|
||
//
|
||
// 假服务端会在 stdout **先打一行非 JSON 的 banner**:这是很多真实 server 的行为,
|
||
// 而客户端必须容忍它(历史上用 json.Decoder 解流时,这一行会让读循环永久停摆)。
|
||
|
||
// syncBuf 是并发安全的 writer(子进程 stderr 由 os/exec 的拷贝 goroutine 写入,
|
||
// 测试同时读取,裸 bytes.Buffer 会被 -race 抓)。
|
||
type syncBuf struct {
|
||
mu sync.Mutex
|
||
b bytes.Buffer
|
||
}
|
||
|
||
func (s *syncBuf) Write(p []byte) (int, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return s.b.Write(p)
|
||
}
|
||
|
||
func (s *syncBuf) String() string {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return s.b.String()
|
||
}
|
||
|
||
// helperConfig 组装"用测试二进制当 MCP server"的启动配置,并把诊断输出接到
|
||
// 一个并发安全的 buffer(MCP 的诊断现在走 diag,默认是 os.Stderr)。
|
||
func helperConfig(t *testing.T, testName, era string) (Config, *syncBuf) {
|
||
t.Helper()
|
||
exe, err := os.Executable()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
logs := &syncBuf{}
|
||
diag.SetWriter(logs)
|
||
t.Cleanup(func() { diag.SetWriter(nil) })
|
||
cfg := Config{
|
||
Command: exe,
|
||
Args: []string{"-test.run=" + testName, "-test.v=false"},
|
||
Env: map[string]string{"AUTOGO_MCP_HELPER": "1", "AUTOGO_MCP_ERA": era},
|
||
}
|
||
return cfg, logs
|
||
}
|
||
|
||
// runFakeMCPServer 在 stdin/stdout 上实现最小 MCP server(按 era 分流)。
|
||
//
|
||
// modern:实现 server/discover,**要求每个请求都带 _meta**(缺了就往 stderr 写
|
||
// META-MISSING 并退出,父进程据此断言);结果都带 resultType。
|
||
// legacy:对 server/discover 回 -32601,靠 initialize 握手。
|
||
func runFakeMCPServer() {
|
||
era := os.Getenv("AUTOGO_MCP_ERA")
|
||
// banner:真实 server 常把启动日志打到 stdout,客户端必须跳过它。
|
||
fmt.Fprintln(os.Stdout, "fake-mcp-server starting (this line is not JSON)")
|
||
|
||
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
|
||
enc := json.NewEncoder(os.Stdout)
|
||
fail := func(format string, args ...any) {
|
||
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||
os.Exit(1)
|
||
}
|
||
sawMeta := true
|
||
|
||
for {
|
||
var req struct {
|
||
ID json.RawMessage `json:"id"`
|
||
Method string `json:"method"`
|
||
Params struct {
|
||
Name string `json:"name"`
|
||
Arguments json.RawMessage `json:"arguments"`
|
||
Meta map[string]any `json:"_meta"`
|
||
RequestState string `json:"requestState"`
|
||
} `json:"params"`
|
||
}
|
||
if err := dec.Decode(&req); err != nil {
|
||
return
|
||
}
|
||
if len(req.ID) == 0 {
|
||
continue // 通知(legacy 的 notifications/initialized)
|
||
}
|
||
if era == "modern" {
|
||
if req.Params.Meta == nil {
|
||
sawMeta = false
|
||
fail("META-MISSING on method %s", req.Method)
|
||
}
|
||
for _, k := range []string{metaProtocolVersion, metaClientCapabilities, metaClientInfo} {
|
||
if _, ok := req.Params.Meta[k]; !ok {
|
||
sawMeta = false
|
||
fail("META-KEY-MISSING %s on method %s", k, req.Method)
|
||
}
|
||
}
|
||
}
|
||
_ = sawMeta
|
||
|
||
id := string(req.ID)
|
||
resp := map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID)}
|
||
switch req.Method {
|
||
case "server/discover":
|
||
switch {
|
||
case era == "modern":
|
||
resp["result"] = map[string]any{
|
||
"resultType": "complete",
|
||
"supportedVersions": []string{"2026-07-28"},
|
||
"capabilities": map[string]any{"tools": map[string]any{}},
|
||
"_meta": map[string]any{
|
||
metaServerInfo: map[string]any{"name": "fake-modern", "version": "2.0.0"},
|
||
},
|
||
"instructions": "fake server for tests",
|
||
}
|
||
case era == "oldversion":
|
||
// 现代服务端,但只支持旧修订版:客户端应回退到握手。
|
||
resp["error"] = map[string]any{
|
||
"code": errUnsupportedProtocolVersion, "message": "Unsupported protocol version",
|
||
"data": map[string]any{"supported": []string{"2025-11-25"}, "requested": protocolVersion},
|
||
}
|
||
default:
|
||
resp["error"] = map[string]any{"code": -32601, "message": "method not found: " + req.Method}
|
||
}
|
||
case "initialize":
|
||
resp["result"] = map[string]any{
|
||
"protocolVersion": legacyVersion,
|
||
"capabilities": map[string]any{"tools": map[string]any{}},
|
||
"serverInfo": map[string]any{"name": "fake-legacy", "version": "1.0.0"},
|
||
}
|
||
case "tools/list":
|
||
r := map[string]any{
|
||
"tools": []map[string]any{{
|
||
"name": "echo",
|
||
"description": "Echo the given text",
|
||
"inputSchema": map[string]any{
|
||
"type": "object",
|
||
"properties": map[string]any{
|
||
"text": map[string]any{"type": "string"},
|
||
"fail": map[string]any{"type": "boolean"},
|
||
"mrt": map[string]any{"type": "boolean"},
|
||
"ask": map[string]any{"type": "boolean"},
|
||
},
|
||
},
|
||
}},
|
||
}
|
||
if era == "modern" {
|
||
r["resultType"] = "complete"
|
||
}
|
||
resp["result"] = r
|
||
case "tools/call":
|
||
var args struct {
|
||
Text string `json:"text"`
|
||
Fail bool `json:"fail"`
|
||
MRT bool `json:"mrt"`
|
||
Ask bool `json:"ask"`
|
||
}
|
||
_ = json.Unmarshal(req.Params.Arguments, &args)
|
||
|
||
// MRTR:第一次回 input_required,重试才给结果。
|
||
if args.MRT && req.Params.RequestState == "" {
|
||
resp["result"] = map[string]any{
|
||
"resultType": "input_required",
|
||
"requestState": "st-" + id, // 把首次 id 编进去,客户端必须原样回显
|
||
}
|
||
_ = enc.Encode(resp)
|
||
continue
|
||
}
|
||
if args.MRT {
|
||
resp["result"] = map[string]any{
|
||
"resultType": "complete",
|
||
"content": []map[string]any{{"type": "text",
|
||
"text": "retried state=" + req.Params.RequestState + " id=" + id}},
|
||
"isError": false,
|
||
}
|
||
_ = enc.Encode(resp)
|
||
continue
|
||
}
|
||
// 要求客户端未声明能力的输入(合规服务端不该这么干)。
|
||
if args.Ask {
|
||
resp["result"] = map[string]any{
|
||
"resultType": "input_required",
|
||
"inputRequests": map[string]any{
|
||
"github_login": map[string]any{
|
||
"method": "elicitation/create",
|
||
"params": map[string]any{"message": "username?"},
|
||
},
|
||
},
|
||
}
|
||
_ = enc.Encode(resp)
|
||
continue
|
||
}
|
||
|
||
content := []map[string]any{{"type": "text", "text": "echo: " + args.Text}}
|
||
if args.Fail {
|
||
content[0]["text"] = "simulated failure: " + args.Text
|
||
resp["result"] = map[string]any{"content": content, "isError": true}
|
||
} else {
|
||
resp["result"] = map[string]any{"content": content, "isError": false}
|
||
}
|
||
default:
|
||
resp["error"] = map[string]any{"code": -32601, "message": "method not found: " + req.Method}
|
||
}
|
||
if err := enc.Encode(resp); err != nil {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestMCPModernProtocol:2026-07-28 现代协议端到端。
|
||
// 核心断言是"每个请求都带三个 _meta 键"——假服务端缺了就写 META-MISSING 并自杀。
|
||
func TestMCPModernProtocol(t *testing.T) {
|
||
if os.Getenv("AUTOGO_MCP_HELPER") == "1" {
|
||
runFakeMCPServer()
|
||
return
|
||
}
|
||
cfg, logs := helperConfig(t, "TestMCPModernProtocol", "modern")
|
||
cli, err := Start("fake", cfg)
|
||
if err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
defer cli.Close()
|
||
|
||
if cli.Era() != eraModern {
|
||
t.Errorf("era = %q, want modern", cli.Era())
|
||
}
|
||
if cli.Version() != protocolVersion {
|
||
t.Errorf("version = %q, want %q", cli.Version(), protocolVersion)
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
toolsList, err := cli.ListTools(ctx)
|
||
if err != nil {
|
||
t.Fatalf("list tools: %v", err)
|
||
}
|
||
if len(toolsList) != 1 || toolsList[0].Name != "echo" {
|
||
t.Fatalf("unexpected tools: %+v", toolsList)
|
||
}
|
||
|
||
got, err := cli.CallTool(ctx, "echo", `{"text":"hi autogo"}`)
|
||
if err != nil {
|
||
t.Fatalf("call echo: %v", err)
|
||
}
|
||
if got != "echo: hi autogo" {
|
||
t.Fatalf("echo result: %q", got)
|
||
}
|
||
if _, err := cli.CallTool(ctx, "echo", `{"text":"boom","fail":true}`); err == nil {
|
||
t.Fatal("expected error from isError result")
|
||
}
|
||
if s := logs.String(); strings.Contains(s, "META-MISSING") || strings.Contains(s, "META-KEY-MISSING") {
|
||
t.Fatalf("请求缺少必需的 _meta:%s", s)
|
||
}
|
||
|
||
// AsTool 包装 + fail-closed 标注
|
||
tl := cli.AsTool("fake", "echo", "Echo text", toolsList[0].InputSchema)
|
||
if tl.Name != "mcp__fake__echo" || !tl.SideEffect || tl.ConcurrencySafe {
|
||
t.Fatalf("AsTool wrapping wrong: %+v", tl)
|
||
}
|
||
res, err := tl.Run(context.Background(), `{"text":"wrapped"}`)
|
||
if err != nil || res.Text != "echo: wrapped" {
|
||
t.Fatalf("wrapped run: %+v %v", res, err)
|
||
}
|
||
}
|
||
|
||
// TestMCPLegacyFallback:服务端不认识 server/discover(回 -32601)时必须回退到
|
||
// initialize 握手,并且回退之后的请求**不能再带 _meta**。
|
||
func TestMCPLegacyFallback(t *testing.T) {
|
||
if os.Getenv("AUTOGO_MCP_HELPER") == "1" {
|
||
runFakeMCPServer()
|
||
return
|
||
}
|
||
cfg, _ := helperConfig(t, "TestMCPLegacyFallback", "legacy")
|
||
cli, err := Start("fake", cfg)
|
||
if err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
defer cli.Close()
|
||
|
||
if cli.Era() != eraLegacy {
|
||
t.Fatalf("era = %q, want legacy(discover 回 -32601 应回退握手)", cli.Era())
|
||
}
|
||
if cli.Version() != legacyVersion {
|
||
t.Errorf("version = %q, want %q", cli.Version(), legacyVersion)
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
got, err := cli.CallTool(ctx, "echo", `{"text":"legacy hi"}`)
|
||
if err != nil {
|
||
t.Fatalf("call echo: %v", err)
|
||
}
|
||
if got != "echo: legacy hi" {
|
||
t.Fatalf("echo result: %q", got)
|
||
}
|
||
}
|
||
|
||
// TestMCPUnsupportedVersionFallsBack:现代服务端回 -32022(只支持 2025-11-25)时,
|
||
// 客户端应从 data.supported 里判断出"讲不了现代版本",回退握手而不是硬发 _meta。
|
||
func TestMCPUnsupportedVersionFallsBack(t *testing.T) {
|
||
if os.Getenv("AUTOGO_MCP_HELPER") == "1" {
|
||
runFakeMCPServer()
|
||
return
|
||
}
|
||
cfg, _ := helperConfig(t, "TestMCPUnsupportedVersionFallsBack", "oldversion")
|
||
cli, err := Start("fake", cfg)
|
||
if err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
defer cli.Close()
|
||
|
||
if cli.Era() != eraLegacy {
|
||
t.Fatalf("era = %q, want legacy", cli.Era())
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
if _, err := cli.ListTools(ctx); err != nil {
|
||
t.Fatalf("list tools: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestMCPMRTRRequestState:服务端回 input_required + requestState 时,客户端要
|
||
// **换新 id、原样回显 requestState** 重试。
|
||
func TestMCPMRTRRequestState(t *testing.T) {
|
||
if os.Getenv("AUTOGO_MCP_HELPER") == "1" {
|
||
runFakeMCPServer()
|
||
return
|
||
}
|
||
cfg, _ := helperConfig(t, "TestMCPMRTRRequestState", "modern")
|
||
cli, err := Start("fake", cfg)
|
||
if err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
defer cli.Close()
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
got, err := cli.CallTool(ctx, "echo", `{"text":"x","mrt":true}`)
|
||
if err != nil {
|
||
t.Fatalf("call with MRTR: %v", err)
|
||
}
|
||
// 假服务端把首次 id 编进 requestState(st-<id>),重试时回显;最终内容形如
|
||
// "retried state=st-2 id=3"。断言:state 被原样带回,且重试用的是**新 id**。
|
||
var stID, retryID string
|
||
if _, err := fmt.Sscanf(got, "retried state=st-%s id=%s", &stID, &retryID); err != nil {
|
||
t.Fatalf("无法解析重试结果 %q: %v", got, err)
|
||
}
|
||
if stID == "" || retryID == "" {
|
||
t.Fatalf("重试结果格式异常: %q", got)
|
||
}
|
||
// stID 形如 "2",retryID 形如 "3"(首尾可能有空白/括号)
|
||
stID = strings.TrimSpace(strings.Trim(stID, "()"))
|
||
retryID = strings.TrimSpace(strings.Trim(retryID, "()"))
|
||
if stID == retryID {
|
||
t.Errorf("重试必须用新的 JSON-RPC id,实际 state 里的 id=%s、重试 id=%s", stID, retryID)
|
||
}
|
||
}
|
||
|
||
// TestMCPMRTRInputRequests:服务端要求我们未声明能力的输入(elicitation)时,
|
||
// 必须给出可操作的错误,而不是空转或静默失败。
|
||
func TestMCPMRTRInputRequests(t *testing.T) {
|
||
if os.Getenv("AUTOGO_MCP_HELPER") == "1" {
|
||
runFakeMCPServer()
|
||
return
|
||
}
|
||
cfg, _ := helperConfig(t, "TestMCPMRTRInputRequests", "modern")
|
||
cli, err := Start("fake", cfg)
|
||
if err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
defer cli.Close()
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
_, err = cli.CallTool(ctx, "echo", `{"text":"x","ask":true}`)
|
||
if err == nil {
|
||
t.Fatal("服务端要求未声明能力的输入时应报错")
|
||
}
|
||
if !strings.Contains(err.Error(), "github_login") {
|
||
t.Errorf("错误里应列出服务端要什么,实际: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestPickVersion 与 idKey 的边界。
|
||
func TestPickVersionAndIDKey(t *testing.T) {
|
||
if got := pickVersion([]string{"2025-03-26", protocolVersion}); got != protocolVersion {
|
||
t.Errorf("pickVersion 应优先最新: %q", got)
|
||
}
|
||
if got := pickVersion([]string{"2025-06-18"}); got != "2025-06-18" {
|
||
t.Errorf("pickVersion = %q", got)
|
||
}
|
||
if got := pickVersion([]string{"2099-01-01"}); got != "" {
|
||
t.Errorf("没有共同版本时应返回空,实际 %q", got)
|
||
}
|
||
// 有些实现会把数字 id 回成字符串,归一化后必须能对上。
|
||
if idKey(json.RawMessage(`1`)) != idKey(json.RawMessage(`"1"`)) {
|
||
t.Error("数字 id 与字符串 id 应归一化成同一个键")
|
||
}
|
||
}
|
||
|
||
// TestLoadConfigs:合并与同名覆盖。
|
||
func TestLoadConfigs(t *testing.T) {
|
||
dir := t.TempDir()
|
||
a := filepath.Join(dir, "a.json")
|
||
b := filepath.Join(dir, "b.json")
|
||
os.WriteFile(a, []byte(`{"mcpServers":{"s1":{"command":"a-cmd"},"s2":{"command":"b-cmd"}}}`), 0o644)
|
||
os.WriteFile(b, []byte(`{"mcpServers":{"s1":{"command":"override","args":["-x"]}},"bad":{}}`), 0o644)
|
||
|
||
got := LoadConfigs(a, filepath.Join(dir, "missing.json"), b)
|
||
if len(got) != 2 {
|
||
t.Fatalf("want 2 servers, got %d: %+v", len(got), got)
|
||
}
|
||
if got["s1"].Command != "override" || len(got["s1"].Args) != 1 {
|
||
t.Fatalf("s1 should be overridden by later file: %+v", got["s1"])
|
||
}
|
||
if got["s2"].Command != "b-cmd" {
|
||
t.Fatalf("s2: %+v", got["s2"])
|
||
}
|
||
if _, ok := got["bad"]; ok {
|
||
t.Fatal("server without command should be dropped")
|
||
}
|
||
}
|
||
|
||
// TestLoadConfigsHTTPAndWarnings:url 型条目要**保留**(以前会被静默丢弃),
|
||
// 无法判断传输类型的条目要丢掉但**留下日志**——列表里少一个 server 却没有线索,
|
||
// 是最难查的那种问题。
|
||
func TestLoadConfigsHTTPAndWarnings(t *testing.T) {
|
||
dir := t.TempDir()
|
||
p := filepath.Join(dir, "mcp.json")
|
||
body := `{"mcpServers":{
|
||
"stdioOne":{"command":"npx","args":["-y","x"]},
|
||
"httpOne":{"type":"http","url":"https://example.com/mcp","headers":{"Authorization":"Bearer t"}},
|
||
"deprecated":{"type":"sse","url":"https://example.com/sse"},
|
||
"broken":{"env":{"A":"1"}}
|
||
}}`
|
||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
var logged bytes.Buffer
|
||
prev := log.Writer()
|
||
log.SetOutput(&logged)
|
||
defer log.SetOutput(prev)
|
||
|
||
got := LoadConfigs(p)
|
||
if len(got) != 2 {
|
||
t.Fatalf("应保留 stdio 与 http 两条,实际 %d: %+v", len(got), got)
|
||
}
|
||
if got["httpOne"].URL == "" || got["httpOne"].Kind() != "http" {
|
||
t.Errorf("url 型条目没被保留/识别: %+v", got["httpOne"])
|
||
}
|
||
if got["stdioOne"].Kind() != "stdio" {
|
||
t.Errorf("stdio 型识别错误: %+v", got["stdioOne"])
|
||
}
|
||
if _, ok := got["deprecated"]; ok {
|
||
t.Error("已废弃的 sse 传输不该被当成可用配置")
|
||
}
|
||
out := logged.String()
|
||
if !strings.Contains(out, "deprecated") || !strings.Contains(out, "sse") {
|
||
t.Errorf("废弃的 sse 条目应有明确日志,实际: %q", out)
|
||
}
|
||
if !strings.Contains(out, "broken") {
|
||
t.Errorf("无法判断传输类型的条目应有日志,实际: %q", out)
|
||
}
|
||
}
|
||
|
||
// TestRPCError:server 返回 JSON-RPC error 时转为 Go error(且保留错误码)。
|
||
// 顺带覆盖"探测遇到非现代错误 ⇒ 回退 legacy"这条路径。
|
||
func TestRPCError(t *testing.T) {
|
||
if os.Getenv("AUTOGO_MCP_ERR_HELPER") == "1" {
|
||
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
|
||
enc := json.NewEncoder(os.Stdout)
|
||
for {
|
||
var req struct {
|
||
ID json.RawMessage `json:"id"`
|
||
Method string `json:"method"`
|
||
}
|
||
if err := dec.Decode(&req); err != nil {
|
||
return
|
||
}
|
||
if len(req.ID) == 0 {
|
||
continue
|
||
}
|
||
var resp map[string]any
|
||
if req.Method == "initialize" {
|
||
resp = map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID),
|
||
"result": map[string]any{"protocolVersion": legacyVersion}}
|
||
} else {
|
||
resp = map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID),
|
||
"error": map[string]any{"code": -32601, "message": "no such method"}}
|
||
}
|
||
_ = enc.Encode(resp)
|
||
}
|
||
}
|
||
exe, _ := os.Executable()
|
||
cli, err := Start("errfake", Config{
|
||
Command: exe,
|
||
Args: []string{"-test.run=TestRPCError"},
|
||
Env: map[string]string{"AUTOGO_MCP_ERR_HELPER": "1"},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("start: %v", err)
|
||
}
|
||
defer cli.Close()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
_, err = cli.ListTools(ctx)
|
||
if err == nil || !strings.Contains(err.Error(), "no such method") {
|
||
t.Fatalf("expected rpc error, got %v", err)
|
||
}
|
||
// 错误里要保留错误码(年代探测靠它判 -32022)。
|
||
var ce *rpcCallError
|
||
if !errors.As(err, &ce) || ce.code != -32601 {
|
||
t.Fatalf("错误码没保留下来: %v", err)
|
||
}
|
||
}
|