Files
AutoZero/mcp/mcp_test.go
T
Sakurasan d5dac5a6fc feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
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。
2026-10-26 00:54:46 +08:00

532 lines
17 KiB
Go
Raw 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 (
"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)
}
}