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。
This commit is contained in:
Sakurasan committed 2026-10-26 00:54:46 +08:00
commit d5dac5a6fc
140 files changed
+26497

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
package mcp
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// httpTransport 实现 Streamable HTTP 传输(2026-07-28:无会话)。
//
// 每个 JSON-RPC 请求一次 POST,响应自带结果——与 stdio 共用 Client 的年代探测与 MRTR 逻辑。
//
// 规范要点:
// - POST 单条请求(不支持批处理),Content-Type: application/json;
// - Accept 必须同时列 application/json 与 text/event-stream:服务端可能回 JSON 对象,
// 也可能回 SSE 流,客户端两种都要支持;
// - 头:MCP-Protocol-Version(必须与 params._meta 里的版本一致)、Mcp-Method(所有请求)、
// Mcp-Name(tools/call / resources/read / prompts/get);
// - 无 Mcp-Session-Id、无 Last-Event-ID 续传、无 GET 流端点(那是已废弃的 HTTP+SSE)。
// - 取消 = 关闭响应流(交给 ctx)。
//
// 年代回退:旧版 Streamable HTTP(2025-11-25 及更早)需要先 POST initialize 并在响应里拿
// Mcp-Session-Id,之后每次请求带上它。sessionID 只在这条分支使用。
type httpTransport struct {
url string
headers map[string]string
client *http.Client
sessionID string
}
func newHTTPTransport(url string, headers map[string]string) *httpTransport {
return &httpTransport{url: url, headers: headers, client: &http.Client{}}
}
func (t *httpTransport) Kind() string { return "http" }
// SetSession 记录旧版传输的会话 id(initialize 之后调用)。
func (t *httpTransport) SetSession(id string) { t.sessionID = id }
func (t *httpTransport) Call(ctx context.Context, body []byte, meta reqMeta) (rpcMsg, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.url, bytes.NewReader(body))
if err != nil {
return rpcMsg{}, err
}
t.applyHeaders(req, meta)
resp, err := t.client.Do(req)
if err != nil {
return rpcMsg{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return t.classifyError(resp)
}
// 旧版语义:initialize 的响应里下发 Mcp-Session-Id,之后每次请求都要带上。现代服务端
// 不发这个头,所以这里只是顺手接住,不产生副作用。
if id := resp.Header.Get("Mcp-Session-Id"); id != "" {
t.sessionID = id
}
return DecodeHTTPResponse(resp)
}
// applyHeaders 生成规范要求的请求头。
func (t *httpTransport) applyHeaders(req *http.Request, meta reqMeta) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("MCP-Protocol-Version", meta.version)
if meta.method != "" {
req.Header.Set("Mcp-Method", meta.method)
}
if meta.name != "" {
req.Header.Set("Mcp-Name", meta.name)
}
for k, v := range t.headers {
req.Header.Set(k, v)
}
if t.sessionID != "" {
req.Header.Set("Mcp-Session-Id", t.sessionID)
}
}
// classifyError 把非 200 分成三类:
// 1. body 是"可识别的现代错误"(错误码落在规范保留的 -32020..-32099 段)→ 原样返回响应,
// 上层据此重试/换版本,**不回退**;
// 2. 400/404/405 且 body 不是现代错误 → 判为 legacy,上层回退 initialize;
// 3. 其它状态码 → 直接报错。
func (t *httpTransport) classifyError(resp *http.Response) (rpcMsg, error) {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
snippet := strings.TrimSpace(string(body))
var m rpcMsg
if json.Unmarshal(bytes.TrimSpace(body), &m) == nil && m.Error != nil && m.Error.Code <= -32020 && m.Error.Code >= -32099 {
return m, nil
}
switch resp.StatusCode {
case http.StatusBadRequest, http.StatusNotFound, http.StatusMethodNotAllowed:
return rpcMsg{}, &legacyServerError{
reason: fmt.Sprintf("服务端返回 HTTP %d 且响应体不是可识别的现代错误,按旧版处理(%s)", resp.StatusCode, snippet)}
case http.StatusUnauthorized, http.StatusForbidden:
// 常见场景:远程 MCP 要求 OAuth。本客户端还没实现 MCP 的授权流程——给一条可操作的
// 提示:先用 headers 直接塞 Bearer token(用户的 westock-mcp 正是这种情况)。
return rpcMsg{}, &authRequiredError{msg: fmt.Sprintf(
"服务端要求鉴权(HTTP %d:%s)。本客户端尚未实现 MCP 的 OAuth 授权流程;如已有 token,可暂用配置的 headers 传 Authorization。",
resp.StatusCode, snippet)}
}
return rpcMsg{}, fmt.Errorf("mcp http %d: %s", resp.StatusCode, snippet)
}
// DecodeHTTPResponse 解析 200 响应:单个 JSON 对象,或 SSE 流(流上先来的是与本次请求相关
// 的通知,最后一条带 id 的就是响应;`:` 开头的注释行忽略)。
func DecodeHTTPResponse(resp *http.Response) (rpcMsg, error) {
ct := resp.Header.Get("Content-Type")
if strings.Contains(ct, "text/event-stream") {
return decodeSSE(resp.Body)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<24))
if err != nil {
return rpcMsg{}, err
}
var m rpcMsg
if err := json.Unmarshal(bytes.TrimSpace(body), &m); err != nil {
return rpcMsg{}, fmt.Errorf("解析 MCP 响应失败: %w", err)
}
return m, nil
}
// decodeSSE 读 SSE 流,返回第一条带 id 的 JSON-RPC 消息。
func decodeSSE(r io.Reader) (rpcMsg, error) {
sc := bufio.NewScanner(r)
var data []string
flush := func() (rpcMsg, bool) {
if len(data) == 0 {
return rpcMsg{}, false
}
payload := strings.Join(data, "\n")
data = nil
var m rpcMsg
if json.Unmarshal([]byte(payload), &m) != nil {
return rpcMsg{}, false
}
return m, true
}
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, ":"):
continue // 注释 / keep-alive
case line == "":
if m, ok := flush(); ok && len(m.ID) > 0 {
return m, nil
}
case strings.HasPrefix(line, "data:"):
data = append(data, strings.TrimPrefix(line, "data:"))
}
}
if err := sc.Err(); err != nil {
return rpcMsg{}, err
}
if m, ok := flush(); ok && len(m.ID) > 0 {
return m, nil
}
return rpcMsg{}, fmt.Errorf("SSE 流结束但没拿到 JSON-RPC 响应")
}
// Notify 发送无 id 通知(仅 legacy 握手的 notifications/initialized 用;现代协议没有
// 客户端通知)。
func (t *httpTransport) Notify(body []byte, meta reqMeta) error {
req, err := http.NewRequest(http.MethodPost, t.url, bytes.NewReader(body))
if err != nil {
return err
}
t.applyHeaders(req, meta)
resp, err := t.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
return fmt.Errorf("通知被拒绝: HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func (t *httpTransport) Close() error { return nil }
+327
View File
@@ -0,0 +1,327 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// 本文件用 httptest 起真的 HTTP MCP server,端到端验证 Streamable HTTP 传输:
// modern(2026-07-28 无会话)/ SSE 响应 / legacy 回退(initialize + Mcp-Session-Id)/ 401 鉴权。
// stdio 的用例在 mcp_test.go,不受本文件影响。
// httpRecorder 记录服务端看到的请求(httptest 的 handler 在另一个 goroutine,需要加锁)。
type httpRecorder struct {
mu sync.Mutex
reqs []recordedReq
sessionSeen bool
}
type recordedReq struct {
method string
header http.Header
}
func (r *httpRecorder) add(method string, h http.Header) {
r.mu.Lock()
defer r.mu.Unlock()
r.reqs = append(r.reqs, recordedReq{method: method, header: h.Clone()})
if h.Get("Mcp-Session-Id") != "" {
r.sessionSeen = true
}
}
// every 检查是否所有请求都满足 fn。
func (r *httpRecorder) every(fn func(recordedReq) bool) bool {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.reqs) == 0 {
return false
}
for _, q := range r.reqs {
if !fn(q) {
return false
}
}
return true
}
func (r *httpRecorder) count() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.reqs)
}
func (r *httpRecorder) sawSession() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.sessionSeen
}
// hasMethodName 检查某次请求是否带了正确的 Mcp-Name 头。
func (r *httpRecorder) hasMethodName(method, name string) bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, q := range r.reqs {
if q.method == method {
return q.header.Get("Mcp-Name") == name
}
}
return false
}
func mustJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
// modernServer 起一个现代(无会话)MCP HTTP server。mode 决定响应形式:json 或 sse。
func modernServer(rec *httpRecorder, mode string) *httptest.Server {
write := func(w http.ResponseWriter, resp map[string]any) {
if mode == "sse" {
w.Header().Set("Content-Type", "text/event-stream")
// 先来 keep-alive 注释与一条与本次请求相关的通知,客户端应跳过它们,只取最后的响应。
fmt.Fprintf(w, ": keep-alive\r\n\r\n")
fmt.Fprintf(w, "data: %s\r\n\r\n", mustJSON(map[string]any{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": map[string]any{"progress": 10},
}))
fmt.Fprintf(w, "data: %s\r\n\r\n", mustJSON(resp))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
rec.add(req.Method, r.Header)
resp := map[string]any{"jsonrpc": "2.0", "id": req.ID}
switch req.Method {
case "server/discover":
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-http", "version": "3.0.0"}},
}
case "tools/list":
resp["result"] = map[string]any{
"resultType": "complete",
"tools": []map[string]any{{
"name": "echo",
"description": "Echo",
"inputSchema": map[string]any{"type": "object"},
}},
}
case "tools/call":
resp["result"] = map[string]any{
"resultType": "complete",
"content": []map[string]any{{"type": "text", "text": "http echo"}},
"isError": false,
}
default:
resp["error"] = map[string]any{"code": -32601, "message": "no such method"}
}
write(w, resp)
}))
}
// TestHTTPModernProtocol:现代 Streamable HTTP。
// 核心断言:每个请求都带 MCP-Protocol-Version / 与 body 一致的 Mcp-Method,
// tools/call 带 Mcp-Name=echo,Accept 同时接受两种 content type,且不用 Mcp-Session-Id。
func TestHTTPModernProtocol(t *testing.T) {
rec := &httpRecorder{}
srv := modernServer(rec, "json")
defer srv.Close()
cli, err := Start("httptest", Config{URL: srv.URL})
if err != nil {
t.Fatalf("start: %v", err)
}
defer cli.Close()
if cli.Era() != eraModern {
t.Fatalf("era = %q, want modern", cli.Era())
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
got, err := cli.CallTool(ctx, "echo", `{"text":"x"}`)
if err != nil {
t.Fatalf("call: %v", err)
}
if got != "http echo" {
t.Fatalf("call result: %q", got)
}
if !rec.every(func(q recordedReq) bool {
return q.header.Get("MCP-Protocol-Version") == protocolVersion
}) {
t.Errorf("每个请求都必须带 MCP-Protocol-Version %s", protocolVersion)
}
if !rec.every(func(q recordedReq) bool {
return q.header.Get("Mcp-Method") == q.method
}) {
t.Errorf("每个请求都必须带与 body 一致的 Mcp-Method 头(%d 个请求)", rec.count())
}
if !rec.every(func(q recordedReq) bool {
a := q.header.Get("Accept")
return strings.Contains(a, "application/json") && strings.Contains(a, "text/event-stream")
}) {
t.Error("Accept 必须同时列 application/json 与 text/event-stream")
}
if !rec.hasMethodName("tools/call", "echo") {
t.Error("tools/call 必须带 Mcp-Name 头(值为 params.name)")
}
if rec.sawSession() {
t.Error("现代传输不应使用 Mcp-Session-Id")
}
}
// TestHTTPSSEResponse:服务端用 text/event-stream 回:流上先来注释与通知,最后才是响应。
func TestHTTPSSEResponse(t *testing.T) {
rec := &httpRecorder{}
srv := modernServer(rec, "sse")
defer srv.Close()
cli, err := Start("httptest", Config{URL: srv.URL})
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"}`)
if err != nil {
t.Fatalf("call: %v", err)
}
if got != "http echo" {
t.Fatalf("call result: %q", got)
}
}
// TestHTTPLegacyFallback:服务端不认识 server/discover(404 + 非现代 body)→ 回退
// initialize,之后每个请求都要带上服务端下发的 Mcp-Session-Id。
func TestHTTPLegacyFallback(t *testing.T) {
rec := &httpRecorder{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
rec.add(req.Method, r.Header)
if req.Method == "server/discover" {
// 旧服务端:404 且 body 不是 JSON-RPC 错误 → 客户端应判为 legacy。
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("not found"))
return
}
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{"jsonrpc": "2.0", "id": req.ID}
switch req.Method {
case "initialize":
// 旧版语义:initialize 响应里下发会话 id。
w.Header().Set("Mcp-Session-Id", "sess-abc")
resp["result"] = map[string]any{
"protocolVersion": legacyVersion,
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "fake-http-legacy", "version": "1.2.3"},
}
case "tools/list":
resp["result"] = map[string]any{
"tools": []map[string]any{{"name": "echo", "inputSchema": map[string]any{"type": "object"}}},
}
case "tools/call":
resp["result"] = map[string]any{
"content": []map[string]any{{"type": "text", "text": "legacy http echo"}},
}
default:
resp["error"] = map[string]any{"code": -32601, "message": "no such method"}
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
cli, err := Start("httptest", Config{URL: srv.URL})
if err != nil {
t.Fatalf("start: %v", err)
}
defer cli.Close()
if cli.Era() != eraLegacy {
t.Fatalf("era = %q, want legacy(discover 404 应回退握手)", 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":"x"}`)
if err != nil {
t.Fatalf("call: %v", err)
}
if got != "legacy http echo" {
t.Fatalf("call result: %q", got)
}
if !rec.sawSession() {
t.Error("legacy 传输的后续请求必须带上 Mcp-Session-Id")
}
}
// TestHTTPAuthRequired:远程 MCP 要求鉴权时给出可操作的错误(用户的 westock-mcp 正是这种情况)。
func TestHTTPAuthRequired(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", "Bearer resource_metadata=\"https://example.com/.well-known/oauth-protected-resource\"")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("{\"error\":\"unauthorized\"}"))
}))
defer srv.Close()
_, err := Start("httptest", Config{URL: srv.URL})
if err == nil {
t.Fatal("401 应报错")
}
if !strings.Contains(err.Error(), "鉴权") {
t.Errorf("错误信息应说明是鉴权问题:%v", err)
}
}
// TestHTTPHeadersPassedThrough:配置里的 headers(例如 Authorization)要真的发出去。
func TestHTTPHeadersPassedThrough(t *testing.T) {
rec := &httpRecorder{}
srv := modernServer(rec, "json")
defer srv.Close()
cli, err := Start("httptest", Config{URL: srv.URL, Headers: map[string]string{"Authorization": "Bearer tok-123"}})
if err != nil {
t.Fatalf("start: %v", err)
}
defer cli.Close()
// 配置里的 headers 要随每个请求发出(远程 MCP 常用 Authorization 传 token)。
if !rec.every(func(q recordedReq) bool {
return q.header.Get("Authorization") == "Bearer tok-123"
}) {
t.Errorf("配置的 headers 必须随每个请求发出(%d 个请求)", rec.count())
}
}
+618
View File
@@ -0,0 +1,618 @@
// Package mcp 实现最小 MCP(Model Context Protocol)stdio 客户端。
// 手写 JSON-RPC 2.0(换行分隔帧),不依赖官方 SDK——与项目零重依赖原则一致。
//
// 协议**年代(era)**:2026-07-28 起 MCP 变成无状态,没有 initialize 握手——
//
// - modern:每个请求在 params._meta 里自带 protocolVersion / clientCapabilities /
// clientInfo,结果带 resultType;版本不匹配时服务端回 -32022 并列出自己支持的版本。
// - legacy(2025-11-25 及更早):initialize → notifications/initialized 握手,
// 版本在握手时协商一次。
//
// 本客户端是 **dual-era**:Start 时先发 server/discover 探测年代,探测不到再回退
// legacy 握手。只认新版的话,线上现存的 server(几乎全是 legacy)会全部不可用。
//
// 本次**没有**实现的部分(都是明确取舍,不是遗漏):
// - Streamable HTTP 传输:本包只有 stdio。
// - subscriptions/listen(替代旧的通知订阅):我们在启动时列一次工具表,不消费
// 服务端的工具变更通知,所以 server 动态增删工具不会反映出来。
// - elicitation/sampling/roots:我们声明空的 clientCapabilities,合规服务端因此
// **不得**向我们发这类请求;真收到 inputRequests 就报可操作的错误。
package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"os/exec"
"sort"
"strings"
"sync"
"time"
"git.gopher.ink/mirrors2/AutoZero/diag"
"git.gopher.ink/mirrors2/AutoZero/execx"
"git.gopher.ink/mirrors2/AutoZero/tools"
)
const (
// protocolVersion 是首选(modern)协议版本。
protocolVersion = "2026-07-28"
// legacyVersion 是回退握手时声明的版本;legacy 服务端会在 initialize 的响应里
// 汇报它实际选用的版本。
legacyVersion = "2025-11-25"
// errUnsupportedProtocolVersion 是规范定义的错误码:服务端不认识/不支持请求里
// 声明的版本,并在 data.supported 里列出自己支持的版本。
errUnsupportedProtocolVersion = -32022
// discoverTimeout 是年代探测的超时。要短:探测失败就走 legacy 回退,而"服务端
// 静默"这种最坏情况每个 server 都要等满这个时间(N 个 server 就是 N 倍)。
discoverTimeout = 3 * time.Second
initTimeout = 15 * time.Second
defaultCallLimit = 120 * time.Second
// maxMRTRRetries 限制 input_required 的重试次数。服务端可以反复要求输入,
// 我们必须有界,否则一次工具调用可能无限空转。
maxMRTRRetries = 3
clientName = "autogo"
clientVersion = "0.1.0"
)
// _meta 键名(规范要求带前缀,由 MCP 规范方保留)。
const (
metaProtocolVersion = "io.modelcontextprotocol/protocolVersion"
metaClientCapabilities = "io.modelcontextprotocol/clientCapabilities"
metaClientInfo = "io.modelcontextprotocol/clientInfo"
metaServerInfo = "io.modelcontextprotocol/serverInfo"
)
// 年代取值。
const (
eraModern = "modern"
eraLegacy = "legacy"
)
// knownVersions 是我们能讲的协议版本,按新→旧排列(用于协商挑版本)。
var knownVersions = []string{protocolVersion, legacyVersion, "2025-06-18", "2025-03-26"}
// Config 是一个 MCP server 的启动配置(兼容 Claude Code 的 mcpServers 格式)。
//
// 两种传输二选一:
// - stdio:command/args/env,起本地子进程;
// - http:url(+ 可选 headers),走 Streamable HTTP(2026-07-28 无会话版;
// 旧版会在 initialize 时下发 Mcp-Session-Id,客户端会自动带上)。
type Config struct {
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
// Type 只用于识别"明确声明为已废弃的 http+sse(type="sse")以便给出提示。
Type string `json:"type"`
}
// Kind 返回配置指定的传输类型:stdio | http | ""(两种都不是)。
func (c Config) Kind() string {
switch {
case c.Command != "":
return "stdio"
case c.URL != "":
return "http"
default:
return ""
}
}
// LoadConfigs 合并多个 mcp.json(后者同名覆盖前者);文件缺失或损坏时跳过。
func LoadConfigs(paths ...string) map[string]Config {
type fileFormat struct {
MCPServers map[string]Config `json:"mcpServers"`
}
out := map[string]Config{}
for _, p := range paths {
data, err := os.ReadFile(p)
if err != nil {
continue
}
var f fileFormat
if err := json.Unmarshal(data, &f); err != nil {
continue
}
for name, cfg := range f.MCPServers {
// 明确声明 type=sse 的是**已废弃的 HTTP+SSE 传输**:即使带 url 也不能当成
// Streamable HTTP 用——那个传输靠 GET 打开 SSE 流拿到 endpoint,POST 语义
// 完全不同,硬当成 http 只会在运行时报出看不懂的错。
if cfg.Type == "sse" {
log.Printf("[mcp] 跳过 %q:type=sse 是已废弃的 HTTP+SSE 传输,本客户端只支持 stdio 与 Streamable HTTP", name)
continue
}
switch cfg.Kind() {
case "stdio", "http":
out[name] = cfg
default:
// 以前这里要求 command 非空,url 型条目会被**静默**丢掉(用户的
// westock-mcp 就是这样消失的)。现在至少说明原因。
log.Printf("[mcp] 跳过 %q:既没有 command(stdio)也没有 url(http),无法判断传输类型", name)
}
}
}
return out
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
// rpcCallError 把 JSON-RPC 错误做成 Go error,并**保留错误码与 data**。
// 年代探测要靠 -32022 及其 data.supported 判断,把错误压成字符串就没法判了。
type rpcCallError struct {
method string
code int
msg string
data json.RawMessage
}
func (e *rpcCallError) Error() string {
if len(e.data) > 0 {
return fmt.Sprintf("%s: %s (code %d, data %s)", e.method, e.msg, e.code, e.data)
}
return fmt.Sprintf("%s: %s (code %d)", e.method, e.msg, e.code)
}
type rpcMsg struct {
JSONRPC string `json:"jsonrpc"`
// ID 用 RawMessage 原样接收:规范示例里 id 可以是字符串,用 *int64 会让
// 字符串 id 的消息解析失败。
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Result json.RawMessage `json:"result"`
Error *rpcError `json:"error"`
}
// ToolInfo 是 server 声明的一个工具。
type ToolInfo struct {
Name string
Description string
InputSchema map[string]any
}
// ServerInfo 是服务端自报的身份(仅用于日志/展示,规范明确不可用于安全决策)。
type ServerInfo struct {
Name string
Version string
}
// Client 是与一个 MCP server 进程的连接。
type Client struct {
tr transport
mu sync.Mutex
nextID int64
closed bool
// 年代与协商结果:进程生命周期内缓存(规范要求:年代是服务端的属性,
// 不是单次请求的)。Start 返回后不再改写。
era string
version string
server ServerInfo
}
// Start 建立与一个 MCP server 的连接并确定协议年代。name 只用于日志。
//
// 传输按配置二选一:command → stdio 子进程;url → Streamable HTTP。
//
// 诊断走 diag(默认 os.Stderr,TUI 会把它换到日志文件):MCP 子进程的 stderr 直接
// 接终端的话,在 TUI 的 alt-screen 下会打穿界面——而 MCP 的惯例恰恰是往 stderr 打日志。
func Start(name string, cfg Config) (*Client, error) {
tr, err := openTransport(cfg)
if err != nil {
return nil, err
}
// 默认按 modern 起步:探测请求自己也要带 _meta,探测失败再降级。
c := &Client{tr: tr, era: eraModern, version: protocolVersion}
if err := c.detectEra(); err != nil {
_ = tr.Close()
return nil, err
}
diag.Printf("[mcp:%s] %s(%s) 协议 %s server=%s %s\n",
name, tr.Kind(), cfg.Command+cfg.URL, c.versionOrDash(), c.server.Name, c.server.Version)
return c, nil
}
// openTransport 按配置建立传输:stdio 或 Streamable HTTP。
func openTransport(cfg Config) (transport, error) {
switch cfg.Kind() {
case "stdio":
cmd := exec.Command(cfg.Command, cfg.Args...)
// 自成进程组 + 有限 Wait:Close 时 kill(-pgid) 才带得走 npx/uvx 包装
// 后面的真 server,也不会让 Wait 卡在谁手里(见 internal/execx)。
execx.SeparateGroup(cmd)
cmd.WaitDelay = execx.WaitGrace
cmd.Env = os.Environ()
for k, v := range cfg.Env {
cmd.Env = append(cmd.Env, k+"="+v)
}
// 子进程的 stderr 交给 diag.Indirect:启动这一刻 TUI 还没建好日志文件,
// 传具体 writer 会把 os.Stderr 钉死(见 diag 包注释)。
cmd.Stderr = diag.Indirect()
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
return newStdioTransport(cmd, stdin, stdout), nil
case "http":
return newHTTPTransport(cfg.URL, cfg.Headers), nil
default:
return nil, errors.New("配置既没有 command 也没有 url,无法确定传输类型")
}
}
// Era 返回探测到的协议年代(modern|legacy)。
func (c *Client) Era() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.era
}
// Version 返回协商后的协议版本。
func (c *Client) Version() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.version
}
func (c *Client) versionOrDash() string {
if v := c.Version(); v != "" {
return v
}
return "-"
}
// detectEra 按规范确定服务端的协议年代。
//
// stdio 侧的规则(原文):先用 server/discover 探测,**只有"可识别的现代错误"
// 才算现代**;其它一切(方法不存在 / 超时 / 静默 / 无法解析的输出)都判为 legacy,
// 回退 initialize 握手。
func (c *Client) detectEra() error {
ctx, cancel := context.WithTimeout(context.Background(), discoverTimeout)
raw, err := c.rpc(ctx, "server/discover", map[string]any{})
cancel()
if err == nil {
var res struct {
SupportedVersions []string `json:"supportedVersions"`
Meta struct {
Info ServerInfo `json:"io.modelcontextprotocol/serverInfo"`
} `json:"_meta"`
}
_ = json.Unmarshal(raw, &res)
c.setServer(res.Meta.Info)
// 服务端自报支持的版本里挑一个我们能讲的;列表为空视为"接受我声明的版本"。
v := pickVersion(res.SupportedVersions)
if v == "" && len(res.SupportedVersions) == 0 {
v = protocolVersion
}
if v != protocolVersion {
// 只支持 legacy 修订版的服务端:那条路要走握手,而不是"带 _meta 声明旧版本"。
return c.initLegacy()
}
c.setVersion(v)
return nil
}
// 鉴权失败不是年代问题:直接报出去,别去试 legacy 握手。
var ae *authRequiredError
if errors.As(err, &ae) {
return ae
}
var ce *rpcCallError
if errors.As(err, &ce) && ce.code == errUnsupportedProtocolVersion {
// 现代服务端,只是不支持我们声明的版本:按规范从 data.supported 里挑一个。
if v := pickVersion(supportedFromError(ce)); v == protocolVersion {
c.setVersion(v)
return nil
}
return c.initLegacy()
}
// 其它一切(method not found / 超时 / 静默 / 垃圾输出)都按 legacy 处理。
return c.initLegacy()
}
// initLegacy 走握手式协议:initialize → notifications/initialized。
// **用新的 ctx**:探测可能已经把上一个超时耗尽了。
func (c *Client) initLegacy() error {
c.setEraLegacy()
ctx, cancel := context.WithTimeout(context.Background(), initTimeout)
defer cancel()
raw, err := c.rpc(ctx, "initialize", map[string]any{
"protocolVersion": legacyVersion,
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": clientName, "version": clientVersion},
})
if err != nil {
return fmt.Errorf("initialize: %w", err)
}
var res struct {
ProtocolVersion string `json:"protocolVersion"`
ServerInfo ServerInfo `json:"serverInfo"`
}
_ = json.Unmarshal(raw, &res)
c.setVersion(res.ProtocolVersion)
c.setServer(res.ServerInfo)
_ = c.notify("notifications/initialized")
return nil
}
// supportedFromError 从 -32022 的 data.supported 里取支持的版本列表。
func supportedFromError(e *rpcCallError) []string {
var data struct {
Supported []string `json:"supported"`
}
if len(e.data) == 0 || json.Unmarshal(e.data, &data) != nil {
return nil
}
return data.Supported
}
// pickVersion 从服务端声明的版本里挑一个我们也能讲的(优先新版本)。
func pickVersion(server []string) string {
for _, ours := range knownVersions {
for _, theirs := range server {
if theirs == ours {
return ours
}
}
}
return ""
}
func (c *Client) setVersion(v string) {
c.mu.Lock()
c.version = v
c.mu.Unlock()
}
func (c *Client) setServer(s ServerInfo) {
c.mu.Lock()
c.server = s
c.mu.Unlock()
}
// setEraLegacy 把年代降级为 legacy(此后请求不再带 _meta,改走握手语义)。
func (c *Client) setEraLegacy() {
c.mu.Lock()
c.era = eraLegacy
c.mu.Unlock()
}
// isModern 报告当前是否按 modern 协议说话。
func (c *Client) isModern() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.era == eraModern
}
// ListTools 拉取 server 的工具列表。
func (c *Client) ListTools(ctx context.Context) ([]ToolInfo, error) {
raw, err := c.rpc(ctx, "tools/list", map[string]any{})
if err != nil {
return nil, err
}
var res struct {
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]any `json:"inputSchema"`
} `json:"tools"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return nil, err
}
out := make([]ToolInfo, 0, len(res.Tools))
for _, t := range res.Tools {
out = append(out, ToolInfo{Name: t.Name, Description: t.Description, InputSchema: t.InputSchema})
}
return out, nil
}
// CallTool 调用 server 工具,返回拼接后的文本内容;isError 结果转为 error。
func (c *Client) CallTool(ctx context.Context, name, argsJSON string) (string, error) {
if !json.Valid([]byte(argsJSON)) {
return "", errors.New("invalid arguments JSON")
}
if strings.TrimSpace(argsJSON) == "" {
argsJSON = "{}"
}
raw, err := c.rpc(ctx, "tools/call", map[string]any{
"name": name, "arguments": json.RawMessage(argsJSON),
})
if err != nil {
return "", err
}
var res struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return "", err
}
var texts []string
for _, c := range res.Content {
if c.Text != "" {
texts = append(texts, c.Text)
}
}
joined := strings.Join(texts, "\n")
if res.IsError {
return "", errors.New(joined)
}
return joined, nil
}
// AsTool 把一个 server 工具包装为 Registry 工具(命名 mcp__<server>__<tool>,
// 与 Claude Code 约定一致)。语义未知 → fail-closed(视为副作用、不可并发)。
func (c *Client) AsTool(server, name, description string, schema map[string]any) *tools.Tool {
full := "mcp__" + server + "__" + name
if description != "" {
description = "[mcp:" + server + "] " + description
} else {
description = "[mcp:" + server + "] tool " + name
}
if schema == nil {
schema = map[string]any{"type": "object"}
}
return &tools.Tool{
Name: full,
Description: description,
Parameters: schema,
SideEffect: true,
Run: func(ctx context.Context, input string) (tools.Result, error) {
cctx, cancel := context.WithTimeout(ctx, defaultCallLimit)
defer cancel()
out, err := c.CallTool(cctx, name, input)
return tools.TextResult(out), err
},
}
}
// Close 关闭传输(stdio 会终止子进程;HTTP 无连接可关)。
func (c *Client) Close() error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil
}
c.closed = true
c.mu.Unlock()
return c.tr.Close()
}
// rpc 发送请求并等待响应,内部处理 MRTR(input_required)的有界重试。
//
// MRTR 放在这里而不是暴露给调用方:requestState 是**服务端在调用过程中**给的,
// 上层(ListTools/CallTool/AsTool)根本无从提供,只能是本层的重试循环。
func (c *Client) rpc(ctx context.Context, method string, params map[string]any) (json.RawMessage, error) {
var requestState string
for attempt := 1; ; attempt++ {
raw, err := c.rpcOnce(ctx, method, params, requestState)
if err != nil {
return nil, err
}
var env struct {
ResultType string `json:"resultType"`
RequestState string `json:"requestState"`
InputRequests map[string]json.RawMessage `json:"inputRequests"`
}
if json.Unmarshal(raw, &env) != nil {
return raw, nil // 不是对象(例如个别实现的裸值):原样返回
}
// 缺 resultType 按 complete 处理——兼容 2026-07-28 之前的服务端。
if env.ResultType != "input_required" {
return raw, nil
}
if len(env.InputRequests) > 0 {
// 我们声明的是空 clientCapabilities,合规服务端不得发 elicitation/sampling/
// roots 请求。真收到就说清楚我们满足不了、它要什么。
keys := make([]string, 0, len(env.InputRequests))
for k := range env.InputRequests {
keys = append(keys, k)
}
sort.Strings(keys)
return nil, fmt.Errorf("%s: 服务端要求补充输入,但本客户端未声明相应能力(elicitation/sampling/roots),无法满足:%s",
method, strings.Join(keys, ", "))
}
if env.RequestState == "" {
// 既没 inputRequests 也没 requestState:重试也没有新信息,报错防空转。
return nil, fmt.Errorf("%s: 服务端返回 input_required 但既无 inputRequests 也无 requestState", method)
}
if attempt >= maxMRTRRetries {
return nil, fmt.Errorf("%s: 服务端连续 %d 次要求补充输入,放弃", method, attempt)
}
requestState = env.RequestState // 下一轮原样回显
}
}
// rpcOnce 发一次请求并等待同 id 响应。requestState 非空时作为 MRTR 重试回显。
func (c *Client) rpcOnce(ctx context.Context, method string, params map[string]any, requestState string) (json.RawMessage, error) {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil, errors.New("mcp client closed")
}
c.nextID++
id := c.nextID
key := fmt.Sprintf("%d", id)
modern := c.era == eraModern
version := c.version
c.mu.Unlock()
msg := map[string]any{"jsonrpc": "2.0", "id": id, "method": method}
p := make(map[string]any, len(params)+2)
for k, v := range params {
p[k] = v
}
if modern {
// _meta 放在 params 顶层。**绝不并进 arguments**:那是工具入参,混进去会污染
// 用户数据。
p["_meta"] = map[string]any{
metaProtocolVersion: version,
metaClientCapabilities: map[string]any{},
metaClientInfo: map[string]any{"name": clientName, "version": clientVersion},
}
}
if requestState != "" {
p["requestState"] = requestState
}
if len(p) > 0 {
msg["params"] = p
}
body, err := json.Marshal(msg)
if err != nil {
return nil, err
}
// Mcp-Name:tools/call 用 params.name(stdio 不用头,传了也无害。服务端不认识就忽略。
meta := reqMeta{id: key, method: method, name: nameOf(params), version: version}
m, err := c.tr.Call(ctx, body, meta)
if err != nil {
return nil, err
}
if m.Error != nil {
return nil, &rpcCallError{method: method, code: m.Error.Code, msg: m.Error.Message, data: m.Error.Data}
}
return m.Result, nil
}
// nameOf 取 params 里的 name / uri——HTTP 的 Mcp-Name 头需要它。
func nameOf(params map[string]any) string {
for _, k := range []string{"name", "uri"} {
if v, ok := params[k]; ok {
if s, ok := v.(string); ok {
return s
}
}
}
return ""
}
// notify 发送无 id 通知(仅 legacy 握手的 notifications/initialized 用)。
func (c *Client) notify(method string) error {
body, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "method": method})
if err != nil {
return err
}
return c.tr.Notify(body, reqMeta{method: method, version: c.Version()})
}
+531
View File
@@ -0,0 +1,531 @@
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)
}
}
+168
View File
@@ -0,0 +1,168 @@
package mcp
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"strings"
"sync"
"git.gopher.ink/mirrors2/AutoZero/diag"
"git.gopher.ink/mirrors2/AutoZero/execx"
)
// reqMeta 是发送一条请求时传输层需要的元数据:HTTP 要据此生成 Mcp-Method / Mcp-Name 头,
// stdio 只用到 id(按 id 分发响应)。
type reqMeta struct {
id string // 归一化后的请求 id
method string // JSON-RPC method
name string // tools/call 的 params.name / resources/read 的 params.uri
version string // 本次请求声明的协议版本(HTTP 放进 MCP-Protocol-Version 头)
}
// transport 是"发一条 JSON-RPC 消息并拿到响应"的抽象。
//
// stdio:一条连接复用多次请求,按 id 分发;HTTP:每个请求一次 POST,响应自带。
type transport interface {
Call(ctx context.Context, body []byte, meta reqMeta) (rpcMsg, error)
Notify(body []byte, meta reqMeta) error
Close() error
Kind() string // "stdio" | "http",仅用于日志
}
// legacyServerError 表示"服务端不是现代的(无 initialize 之外的现代特征)",
// 客户端据此回退到 handshake 式协议。HTTP 侧要靠它区分 400 的两种含义。
type legacyServerError struct{ reason string }
func (e *legacyServerError) Error() string { return e.reason }
// authRequiredError 表示服务端要求鉴权。它**不是年代信号**——两种年代都会返回 401,
// 所以年代探测遇到它应当直接终止,而不是再去试一次 legacy 握手(那样错误里会多出一个
// 误导性的 "initialize:" 前缀,让人以为是握手失败)。
type authRequiredError struct{ msg string }
func (e *authRequiredError) Error() string { return e.msg }
// ---- stdio ----
// stdioTransport 用子进程的 stdin/stdout 通信(JSON-RPC,换行分隔帧)。
type stdioTransport struct {
cmd *exec.Cmd
stdin io.WriteCloser
pending map[string]chan rpcMsg
mu sync.Mutex
}
func newStdioTransport(cmd *exec.Cmd, stdin io.WriteCloser, stdout io.Reader) *stdioTransport {
t := &stdioTransport{cmd: cmd, stdin: stdin, pending: map[string]chan rpcMsg{}}
go t.readLoop(stdout)
return t
}
func (t *stdioTransport) Kind() string { return "stdio" }
func (t *stdioTransport) Call(ctx context.Context, body []byte, meta reqMeta) (rpcMsg, error) {
ch := make(chan rpcMsg, 1)
t.mu.Lock()
t.pending[meta.id] = ch
t.mu.Unlock()
if _, err := t.stdin.Write(append(body, '\n')); err != nil {
t.drop(meta.id)
return rpcMsg{}, err
}
select {
case m := <-ch:
return m, nil
case <-ctx.Done():
t.drop(meta.id)
return rpcMsg{}, ctx.Err()
}
}
func (t *stdioTransport) Notify(body []byte, _ reqMeta) error {
_, err := t.stdin.Write(append(body, '\n'))
return err
}
func (t *stdioTransport) drop(id string) {
t.mu.Lock()
delete(t.pending, id)
t.mu.Unlock()
}
func (t *stdioTransport) Close() error {
t.mu.Lock()
for id, ch := range t.pending {
ch <- rpcMsg{Error: &rpcError{Message: "client closed"}}
delete(t.pending, id)
}
t.mu.Unlock()
_ = t.stdin.Close()
if t.cmd != nil {
// 只 Kill 直接子进程会把真正的 server 留成孤儿(npx/uvx 起的 node/python
// 就是孙子):它继续占着 stdout,readLoop goroutine 永不退出。
_ = execx.KillTree(t.cmd)
return t.cmd.Wait() // 构造时已设 WaitDelay,这里不会无限阻塞
}
return nil
}
// readLoop 按行读 server stdout 并分发给等待者。
//
// **不能用 json.Decoder 直接解流**:stdout 上出现一行非 JSON(很多 server 会打启动
// banner)就会让它报错并停止读取,之后所有请求都等不到回复;而规范要求的恰恰是
// "无法识别的输出 ⇒ 判为 legacy ⇒ 回退 initialize",按那种写法那次回退必然白等到超时。
// 所以这里跳过解析不了的行,只在 EOF/读错误时唤醒等待者。
func (t *stdioTransport) readLoop(r io.Reader) {
br := bufio.NewReader(r)
for {
line, err := br.ReadBytes('\n')
if trimmed := bytes.TrimSpace(line); len(trimmed) > 0 {
var m rpcMsg
if json.Unmarshal(trimmed, &m) == nil {
t.dispatch(m)
} else {
diag.Printf("[mcp] 忽略无法解析的 stdout 行: %.120s\n", trimmed)
}
}
if err != nil {
t.failAll(fmt.Errorf("mcp server stopped: %w", err))
return
}
}
}
// dispatch 把带 id 的响应交给等待者。通知与服务端发起的请求都不处理
// (现代协议里服务端不再发起请求;legacy 下我们声明了空 capabilities)。
func (t *stdioTransport) dispatch(m rpcMsg) {
if len(m.ID) == 0 {
return
}
key := idKey(m.ID)
t.mu.Lock()
ch := t.pending[key]
delete(t.pending, key)
t.mu.Unlock()
if ch != nil {
ch <- m
}
}
func (t *stdioTransport) failAll(err error) {
t.mu.Lock()
defer t.mu.Unlock()
for id, ch := range t.pending {
ch <- rpcMsg{Error: &rpcError{Message: err.Error()}}
delete(t.pending, id)
}
}
// idKey 把 id 归一化成 map 键:去掉引号与空白。有些实现会把数字 id 回成字符串。
func idKey(raw json.RawMessage) string {
return strings.Trim(strings.TrimSpace(string(raw)), `"`)
}