Files
Agent-Native/runtime/mcp/mcp_test.go
T
weichen fcd2539b9e
ci / vet-and-test (pull_request) Successful in 52s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Align the MCP client with 2026-07-28: resultType, input_required retry, tasks surface (N2 WP3)
Why: the completion doc requires the client to recognize resultType,
treat input_required as a protocol-level result whose answer retries the
original tools/call, and expose tasks/get|update|cancel; today only a
content-block special case existed and the advertised version was never
checked against the server's.
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestInputRequiredResultTypeRetriesOriginalCall (ask → tasks/update →
retry, call order and final content), TestNegotiatedVersionFallbackKeepsLegacyPath
(byte-level legacy behavior on 2024-11-05), TestTasksGetAndCancelRoundTrip,
TestRunEndCancelsSeenTasks, TestDefaultSchemaDeclares202012; the
acceptance-③ regression TestStdioSlowCancelThenEcho passes unchanged.
Impact: advertise 2026-07-28 and branch on the negotiated version
(older servers keep byte-identical behavior); synthesized default tool
schemas now declare JSON Schema 2020-12; runs cancel the MCP tasks they
saw via a per-run TaskTracker (other runs on the shared manager are
unaffected).
Refs: Agent-System-Documents#42 (N2 plan WP3)
2026-10-07 13:38:51 +00:00

534 lines
16 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 (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"agent-native/runtime/tools"
)
// TestMain:环境变量置位时,本测试二进制化身为 stdio MCP server(真实子进程测试)。
func TestMain(m *testing.M) {
if os.Getenv(FakeServerEnv) == "1" {
RunFakeServer()
os.Exit(0)
}
os.Exit(m.Run())
}
func fakeStdioConfig(t *testing.T) ServerConfig {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
return ServerConfig{
Name: "fake",
Command: exe,
Env: append(os.Environ(), FakeServerEnv+"=1"),
}
}
func TestStdioClientHandshakeListAndCall(t *testing.T) {
ctx := context.Background()
c, err := DialStdio(ctx, fakeStdioConfig(t))
if err != nil {
t.Fatal(err)
}
defer c.Close()
list, err := c.ListTools(ctx)
if err != nil || len(list) != 5 || list[0].Name != "echo" {
t.Fatalf("list failed: %v %+v", err, list)
}
res, err := c.CallTool(ctx, "echo", json.RawMessage(`{"text":"hi"}`))
if err != nil || len(res.Content) != 1 || res.Content[0].Text != "echo: hi" {
t.Fatalf("call failed: %v %+v", err, res)
}
_, err = c.CallTool(ctx, "no_such_tool", nil)
if err == nil || !strings.Contains(err.Error(), "unknown tool") {
t.Fatalf("unknown-tool call must fail via rpc error, got %v", err)
}
}
func TestStdioClientServerRejectsBadSchema(t *testing.T) {
ctx := context.Background()
c, err := DialStdio(ctx, fakeStdioConfig(t))
if err != nil {
t.Fatal(err)
}
defer c.Close()
// server 端 tool 报 isError:作为 callToolResult 返回而非 rpc 错误。
res, err := c.CallTool(ctx, "boom", nil)
if err != nil {
t.Fatal(err)
}
if !res.IsError || res.Content[0].Text != "boom failed" {
t.Fatalf("want isError result, got %+v", res)
}
}
func TestStdioSlowCancelThenEcho(t *testing.T) {
ctx := context.Background()
c, err := DialStdio(ctx, fakeStdioConfig(t))
if err != nil {
t.Fatal(err)
}
defer c.Close()
slowCtx, cancel := context.WithTimeout(ctx, 80*time.Millisecond)
defer cancel()
_, err = c.CallTool(slowCtx, "slow", nil)
if err == nil {
t.Fatal("slow call should time out")
}
echoCtx, echoCancel := context.WithTimeout(ctx, 3*time.Second)
defer echoCancel()
res, err := c.CallTool(echoCtx, "echo", json.RawMessage(`{"text":"after-slow"}`))
if err != nil {
t.Fatal(err)
}
if len(res.Content) != 1 || res.Content[0].Text != "echo: after-slow" {
t.Fatalf("echo must receive its own result, got %+v", res)
}
if c.Readers() != 1 {
t.Fatalf("cancel must leave a single reader, got %d", c.Readers())
}
}
func fakeHTTPHandler(w http.ResponseWriter, r *http.Request) {
var req rpcRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(400)
return
}
resp := fakeServerHandle(req)
if sid := r.Header.Get("Mcp-Session-Id"); sid != "" {
w.Header().Set("Mcp-Session-Id", sid)
} else {
w.Header().Set("Mcp-Session-Id", "sess-1")
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func TestHTTPClientJSONMode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(fakeHTTPHandler))
defer srv.Close()
ctx := context.Background()
c, err := DialHTTP(ctx, ServerConfig{Name: "fakehttp", URL: srv.URL})
if err != nil {
t.Fatal(err)
}
defer c.Close()
if c.sessionID != "sess-1" {
t.Fatalf("session id must be captured, got %q", c.sessionID)
}
list, err := c.ListTools(ctx)
if err != nil || len(list) != 5 {
t.Fatalf("list failed: %v %+v", err, list)
}
res, err := c.CallTool(ctx, "echo", json.RawMessage(`{"text":"net"}`))
if err != nil || res.Content[0].Text != "echo: net" {
t.Fatalf("call failed: %v %+v", err, res)
}
}
func TestHTTPClientSSEMode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req rpcRequest
_ = json.NewDecoder(r.Body).Decode(&req)
resp := fakeServerHandle(req)
w.Header().Set("Content-Type", "text/event-stream")
body, _ := json.Marshal(resp)
fmt.Fprintf(w, "event: message\ndata: %s\n\n", body)
}))
defer srv.Close()
ctx := context.Background()
c, err := DialHTTP(ctx, ServerConfig{Name: "fakesse", URL: srv.URL})
if err != nil {
t.Fatal(err)
}
defer c.Close()
list, err := c.ListTools(ctx)
if err != nil || len(list) != 5 {
t.Fatalf("sse list failed: %v %+v", err, list)
}
}
func TestManagerRegisterIntoAndCallThroughRegistry(t *testing.T) {
mgr, err := NewManager([]ServerConfig{fakeStdioConfig(t)})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry(t.TempDir())
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
var names []string
for _, d := range reg.Decls() {
names = append(names, d.Name)
}
joined := strings.Join(names, ",")
if !strings.Contains(joined, "mcp__fake__echo") || !strings.Contains(joined, "mcp__fake__boom") {
t.Fatalf("mcp tools not registered: %v", names)
}
res := reg.Execute(context.Background(), "mcp__fake__echo", json.RawMessage(`{"text":"bridge"}`))
if res.IsError || res.Content != "echo: bridge\n" {
t.Fatalf("bridge call failed: %+v", res)
}
res = reg.Execute(context.Background(), "mcp__fake__boom", nil)
if !res.IsError || !strings.Contains(res.Content, "boom failed") {
t.Fatalf("bridge error propagation failed: %+v", res)
}
}
func TestManagerNamespaceWildcardRestrict(t *testing.T) {
mgr, _ := NewManager([]ServerConfig{fakeStdioConfig(t)})
defer mgr.Close()
reg := tools.WorkspaceTools(t.TempDir())
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
if err := reg.Restrict([]string{"mcp__fake__*"}); err != nil {
t.Fatal(err)
}
for _, d := range reg.Decls() {
if !strings.HasPrefix(d.Name, "mcp__fake__") {
t.Fatalf("wildcard restrict must keep only mcp namespace, got %s", d.Name)
}
}
// 通配未命中任何工具 → fail-closed。
err := reg.Restrict([]string{"mcp__nope__*"})
if err == nil || !strings.Contains(err.Error(), "mcp__nope__*") {
t.Fatalf("unknown namespace wildcard must fail-closed, got %v", err)
}
}
func TestManagerFailClosedOnUnreachableServer(t *testing.T) {
mgr, err := NewManager([]ServerConfig{{Name: "dead", Command: "/nonexistent/mcp-server-bin"}})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry(t.TempDir())
err = mgr.RegisterInto(context.Background(), reg)
if err == nil || !strings.Contains(err.Error(), "unreachable") {
t.Fatalf("unreachable server must fail-closed, got %v", err)
}
}
func TestManagerConfigValidation(t *testing.T) {
if _, err := NewManager([]ServerConfig{{Name: "bad name", Command: "x"}}); err == nil {
t.Fatal("invalid name must be rejected")
}
if _, err := NewManager([]ServerConfig{{Name: "both", Command: "x", URL: "http://y"}}); err == nil {
t.Fatal("dual transport must be rejected")
}
if _, err := NewManager([]ServerConfig{{Name: "none"}}); err == nil {
t.Fatal("missing transport must be rejected")
}
_, err := NewManager([]ServerConfig{{Name: "a", Command: "x"}, {Name: "a", URL: "http://y"}})
if err == nil || !strings.Contains(err.Error(), "twice") {
t.Fatal("duplicate names must be rejected")
}
}
func TestManagerRegisterTimeoutBounded(t *testing.T) {
// server 挂起不响应:initialize 必须在超时内 fail-closed,而不是永久阻塞。
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(10 * time.Second):
}
}))
defer srv.Close()
mgr, err := NewManager([]ServerConfig{{Name: "slow", URL: srv.URL, Timeout: 300 * time.Millisecond}})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry(t.TempDir())
start := time.Now()
err = mgr.RegisterInto(context.Background(), reg)
elapsed := time.Since(start)
if err == nil || !strings.Contains(err.Error(), "unreachable") {
t.Fatalf("hung server must fail-closed, got %v", err)
}
if elapsed > 5*time.Second {
t.Fatalf("timeout must bound the dial, took %s", elapsed)
}
}
func TestBackgroundConnectDoesNotBlockTheCaller(t *testing.T) {
started := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-started:
default:
close(started)
}
time.Sleep(400 * time.Millisecond)
fakeHTTPHandler(w, r)
}))
defer srv.Close()
mgr, err := NewManager([]ServerConfig{{Name: "late", URL: srv.URL}})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry(t.TempDir())
t0 := time.Now()
mgr.StartBackground(context.Background(), reg)
if time.Since(t0) > 150*time.Millisecond {
t.Fatalf("background connect blocked the caller for %s", time.Since(t0))
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if reg.Has("mcp__late__echo") {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("tools did not appear after the server became ready")
}
func TestDeferredMCPToolsUseSearchAndCall(t *testing.T) {
mgr, err := NewManager([]ServerConfig{{Name: "fake", Command: fakeStdioConfig(t).Command, Args: fakeStdioConfig(t).Args, Env: fakeStdioConfig(t).Env, Exposure: "deferred"}})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry(t.TempDir())
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
for _, d := range reg.Decls() {
if strings.HasPrefix(d.Name, "mcp__fake__") {
t.Fatalf("deferred mcp tool listed: %s", d.Name)
}
}
if !strings.Contains(reg.Search("echo"), "mcp__fake__echo") {
t.Fatalf("search %q", reg.Search("echo"))
}
res := reg.Execute(context.Background(), "call_tool", json.RawMessage(`{"name":"mcp__fake__echo","arguments":{"text":"via"}}`))
if res.IsError || !strings.Contains(res.Content, "echo: via") {
t.Fatalf("call %+v", res)
}
}
func TestDisabledServerIsSkipped(t *testing.T) {
off := false
mgr, err := NewManager([]ServerConfig{{Name: "fake", Command: "x", Enabled: &off}})
if err != nil {
t.Fatal(err)
}
reg := tools.NewRegistry(t.TempDir())
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
if reg.Has("mcp__fake__echo") {
t.Fatal("disabled server registered a tool")
}
if err := mgr.ApplyOverrides(`{"fake":{"enabled":true,"exposure":"direct"}}`); err != nil {
t.Fatal(err)
}
if !mgr.Configs()[0].IsEnabled() || mgr.Configs()[0].Exposure != "direct" {
t.Fatalf("override %+v", mgr.Configs()[0])
}
}
func TestManyToolsDefaultToDeferred(t *testing.T) {
if chooseExposure("", 9) != tools.ExposureDeferred {
t.Fatal("many tools should default to deferred")
}
if chooseExposure("direct", 9) != tools.ExposureDirect {
t.Fatal("explicit direct must win")
}
}
type askClient struct{}
func (askClient) Initialize(context.Context) (initializeResult, error) {
return initializeResult{}, nil
}
func (askClient) NegotiatedVersion() string { return LegacyProtocolVersion }
func (askClient) ListTools(context.Context) ([]toolInfo, error) { return nil, nil }
func (askClient) Close() error { return nil }
func (askClient) TasksGet(context.Context, string) (taskResult, error) {
return taskResult{}, nil
}
func (askClient) TasksUpdate(context.Context, string, string) error { return nil }
func (askClient) TasksCancel(context.Context, string) error { return nil }
func (askClient) CallTool(context.Context, string, json.RawMessage) (callToolResult, error) {
return callToolResult{Content: []contentBlock{{Type: "input_required", Text: "选一个颜色"}}}, nil
}
func TestInputRequiredUsesAsk(t *testing.T) {
tool := &bridgeTool{
client: askClient{}, declName: "mcp__x__ask", toolName: "ask",
schema: json.RawMessage(`{"type":"object","properties":{}}`),
}
ctx := WithAsk(context.Background(), func(context.Context, string) (string, error) { return "蓝色", nil })
res, err := tool.Execute(ctx, json.RawMessage(`{}`))
if err != nil || res.IsError || res.Content != "蓝色" {
t.Fatalf("ask result %+v %v", res, err)
}
}
// ---- N2 WP3:2026-07-28 对齐 ----
// 协议级 input_required:ask 取应答 → tasks/update 投递 → 原参数重试得终结果。
func TestInputRequiredResultTypeRetriesOriginalCall(t *testing.T) {
mgr, err := NewManager([]ServerConfig{fakeStdioConfig(t)})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry("")
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
var questions []string
ctx := WithAsk(context.Background(), func(_ context.Context, q string) (string, error) {
questions = append(questions, q)
return "蓝色", nil
})
res := reg.Execute(ctx, "mcp__fake__need_input", json.RawMessage(`{}`))
if res.IsError || res.Content != "答案已收到: 蓝色" {
t.Fatalf("final result: %+v", res)
}
if len(questions) != 1 || questions[0] != "要答案" {
t.Fatalf("ask questions: %v", questions)
}
}
// 协商回落:服务端只讲 2024-11-05 时,协议级路径不启用,旧内容块路径不变。
func TestNegotiatedVersionFallbackKeepsLegacyPath(t *testing.T) {
cfg := fakeStdioConfig(t)
cfg.Env = append(cfg.Env, "AGENT_NATIVE_TEST_MCP_VERSION="+LegacyProtocolVersion)
mgr, err := NewManager([]ServerConfig{cfg})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry("")
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
ctx := WithAsk(context.Background(), func(_ context.Context, q string) (string, error) {
return "蓝色", nil
})
res := reg.Execute(ctx, "mcp__fake__legacy_ask", json.RawMessage(`{}`))
if res.IsError || res.Content != "蓝色" {
t.Fatalf("legacy path: %+v", res)
}
}
// tasks 客户端面:get 回状态、cancel 清除任务输入。
func TestTasksGetAndCancelRoundTrip(t *testing.T) {
cfg := fakeStdioConfig(t)
ctx := context.Background()
c, err := DialStdio(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
if got := c.NegotiatedVersion(); !SupportsResultType(got) {
t.Fatalf("negotiated = %q, want >= 2026-07-28", got)
}
if err := c.TasksUpdate(ctx, "task-x", "输入"); err != nil {
t.Fatal(err)
}
tr, err := c.TasksGet(ctx, "task-x")
if err != nil || tr.TaskID != "task-x" || tr.Status != "running" {
t.Fatalf("tasks/get: %+v %v", tr, err)
}
if err := c.TasksCancel(ctx, "task-x"); err != nil {
t.Fatal(err)
}
}
// 合成默认 schema 声明 JSON Schema 2020-12。
func TestDefaultSchemaDeclares202012(t *testing.T) {
mgr, err := NewManager([]ServerConfig{fakeStdioConfig(t)})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry("")
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
for _, d := range reg.Decls() {
if d.Name == "mcp__fake__legacy_ask" {
// legacy_ask 在 fake 里无 InputSchema → 走合成默认
if !strings.Contains(string(d.Parameters), "2020-12") {
t.Fatalf("默认 schema 未声明 2020-12:%s", d.Parameters)
}
return
}
}
t.Fatal("mcp__fake__legacy_ask not registered")
}
// 任务跟踪器:记录 → 按服务端分组取消 → 清空。
func TestTaskTrackerCancelAllPerRun(t *testing.T) {
cfg := fakeStdioConfig(t)
ctx := context.Background()
c, err := DialStdio(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
tr := &TaskTracker{seen: map[Client]map[string]struct{}{}}
if err := c.TasksUpdate(ctx, "task-a", "1"); err != nil {
t.Fatal(err)
}
if err := c.TasksUpdate(ctx, "task-b", "2"); err != nil {
t.Fatal(err)
}
tr.note(c, "task-a")
tr.note(c, "task-b")
tr.note(c, "task-a") // 幂等
if n := tr.CancelAll(ctx); n != 2 {
t.Fatalf("CancelAll = %d, want 2", n)
}
if n := tr.CancelAll(ctx); n != 0 {
t.Fatalf("第二次 CancelAll 应为 0,得到 %d", n)
}
}
// 端到端:经注册表执行 input_required 工具的 run 结束时,task-1 被取消。
func TestRunEndCancelsSeenTasks(t *testing.T) {
cfg := fakeStdioConfig(t)
mgr, err := NewManager([]ServerConfig{cfg})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry("")
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
ctx, tr := WithTaskTracker(context.Background())
ctx = WithAsk(ctx, func(_ context.Context, q string) (string, error) { return "蓝", nil })
res := reg.Execute(ctx, "mcp__fake__need_input", json.RawMessage(`{}`))
if res.IsError {
t.Fatalf("execute: %+v", res)
}
// 模仿 adapter run 结束:取消本 run 见过的任务(task-1 已被记录)。
if n := tr.CancelAll(context.Background()); n != 1 {
t.Fatalf("CancelAll = %d, want 1(task-1)", n)
}
}