Files
Agent-Native/runtime/mcp/mcp_test.go
T
weichen b2638da64c Add an MCP client (stdio and streamable HTTP) with namespaced tool bridging
Why: external tool ecosystems speak MCP; the kernel had no way to
mount them, so every external capability needed a bespoke builtin.

Evidence: go vet ./... clean; go test ./... 11 packages ok. Tests run
against a REAL stdio server (test binary re-exec via
mcp.RunFakeServer) and httptest HTTP servers: initialize handshake,
tools/list with pagination cap, tools/call success / tool-level
isError / unknown-tool rpc error, JSON and SSE response modes,
Mcp-Session-Id capture, manager registration as mcp__<server>__<tool>,
namespace wildcard allowlist (mcp__fake__* keeps only that namespace,
unknown wildcard fails closed), unreachable server failing the step,
hung server bounded by per-request timeout, config validation
(duplicate names, dual/missing transport, bad name chars), and an
adapter end-to-end execution calling an MCP tool.

Impact: new runtime/mcp package; native.Options gains MCP *mcp.Manager
and Close() for process cleanup; tools.Registry.Restrict gains
prefix/* namespace entries; server-provided metadata is capped (64
tools/server, 1 KiB descriptions, 64 KiB text results) and treated as
untrusted input.

Refs: plan B5; agent-execution standard (tool providers get
authorized capability only).
2026-10-01 05:43:28 +00:00

242 lines
7.1 KiB
Go

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) != 2 || 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 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) != 2 {
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) != 2 {
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)
}
}