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-),重试时回显;最终内容形如 // "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) } }