Files
Maven/internal/mcp/stdio_test.go
T
kami 87d03cf8c6 mcp: bound and abandon transport reads
The stdio reader ran inline under the transport lock, and bufio never
observes a context. A server that accepted a request and then wrote
nothing held that lock forever. alive() takes the same lock and Refresh
calls alive() while holding the manager lock, so one mute python server
wedged Tools, Status and every Call, including turns that touch no MCP
tool at all. The read now runs on its own goroutine feeding a channel,
the call selects on the context, and a call that gives up drops the
connection so the manager re-dials.

The frame bound was measured after the line had been assembled, which is
not a bound. A server emitting 500 MB with no newline had all 500 MB in
mavend before the check could reject it, which on the deploy target is
an OOM kill of the core daemon. The scanner's own buffer limit enforces
it now.

The HTTP transport never checked the response id. A server request sent
mid-stream, sampling/createMessage or roots/list, unmarshalled into a
response with neither result nor error, so the call reported success
with an empty string. The act was logged as done and the tool never ran.
The id must match and the frame must carry a result or an error.

Found in review of #70.
2026-08-01 14:11:24 +04:00

217 lines
6.0 KiB
Go

package mcp
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// The stdio transport is tested against a real subprocess — this test binary,
// re-executed with MAVEN_MCP_FAKE set, acting as a minimal MCP server. No
// python, no fixture file, no network.
func TestMain(m *testing.M) {
if os.Getenv("MAVEN_MCP_FAKE") != "" {
fakeStdioServer()
return
}
os.Exit(m.Run())
}
func fakeStdioServer() {
h := echoServer()
sc := bufio.NewScanner(os.Stdin)
out := bufio.NewWriter(os.Stdout)
defer out.Flush()
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
var req struct {
ID *int64 `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if json.Unmarshal([]byte(line), &req) != nil {
continue
}
if req.ID == nil {
// A notification gets no reply, but we emit an unrelated
// notification so the client's frame-skipping is exercised.
_, _ = out.WriteString("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\"}\n")
_ = out.Flush()
continue
}
// "mute" answers the handshake and then goes silent: a python server
// that hit an unhandled exception in its own read loop but did not
// exit is the ordinary way to get here.
if os.Getenv("MAVEN_MCP_FAKE") == "mute" && req.Method == "tools/call" {
select {} // never answer, never exit
}
// "flood" writes one enormous line with no newline in it.
if os.Getenv("MAVEN_MCP_FAKE") == "flood" && req.Method == "tools/call" {
for i := 0; i < 64; i++ {
_, _ = out.Write(make([]byte, 1<<20))
}
_ = out.Flush()
continue
}
result, rerr := h(req.Method, req.Params)
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID}
if rerr != nil {
resp["error"] = map[string]any{"code": rerr.Code, "message": rerr.Message}
} else {
resp["result"] = result
}
raw, _ := json.Marshal(resp)
_, _ = out.Write(append(raw, '\n'))
_ = out.Flush()
if os.Getenv("MAVEN_MCP_FAKE") == "die" && req.Method == "tools/list" {
return // hang up, so the reconnect path has something to see
}
}
}
func stdioManager(t *testing.T, mode string) *Manager {
t.Helper()
self, err := os.Executable()
if err != nil {
t.Skipf("no executable path: %v", err)
}
if _, err := exec.LookPath(self); err != nil && !strings.Contains(self, "/") {
t.Skip("test binary not executable")
}
m, err := NewManager(nil, []ServerConfig{{
Name: "fake",
Command: self,
Env: []string{"MAVEN_MCP_FAKE=" + mode},
Enabled: true,
}})
if err != nil {
t.Fatal(err)
}
m.Connect(context.Background())
return m
}
func TestStdioTransportEndToEnd(t *testing.T) {
m := stdioManager(t, "1")
defer m.Close()
st := m.Status()
if len(st) != 1 || !st[0].Connected {
t.Fatalf("status = %+v", st)
}
if st[0].Transport != "stdio" {
t.Fatalf("transport = %q", st[0].Transport)
}
if got := len(m.Tools()); got != 2 {
t.Fatalf("tools = %d", got)
}
out, err := m.Call(context.Background(), "fake", "read_thing", map[string]any{"q": "стдио"})
if err != nil {
t.Fatalf("call: %v", err)
}
if out != "read_thing:стдио" {
t.Fatalf("out = %q", out)
}
res := m.Resources(context.Background())
if len(res) != 1 || res[0].URI != "note://one" {
t.Fatalf("resources = %+v", res)
}
body, err := m.ReadResource(context.Background(), "fake", "note://one")
if err != nil {
t.Fatal(err)
}
if body != "тело ресурса" {
t.Fatalf("body = %q", body)
}
}
func TestStdioServerThatDiesIsNotUsable(t *testing.T) {
m := stdioManager(t, "die")
defer m.Close()
// The server hung up after tools/list; the next call must fail cleanly
// rather than hang or panic.
if _, err := m.Call(context.Background(), "fake", "read_thing", nil); err == nil {
t.Fatal("a call into a dead server must error")
}
}
func TestStdioMissingCommand(t *testing.T) {
m, err := NewManager(nil, []ServerConfig{{
Name: "nope", Command: "/nonexistent/mcp-server-that-is-not-there", Enabled: true,
}})
if err != nil {
t.Fatal(err)
}
m.Connect(context.Background())
st := m.Status()
if st[0].Connected || st[0].Err == "" {
t.Fatalf("a missing binary must be recorded, not fatal: %+v", st)
}
if got := len(m.Tools()); got != 0 {
t.Fatalf("tools = %d", got)
}
if !strings.Contains(fmt.Sprint(st[0].Err), "start") {
t.Logf("err = %q", st[0].Err)
}
}
// A stdio server that accepts a call and then answers nothing must not wedge
// the manager. Before the read moved onto its own goroutine, the read held the
// transport lock, Refresh took that lock through alive() while holding the
// manager lock, and from then on Tools, Status and Call blocked for EVERY
// server — including turns that touch no MCP tool at all.
func TestStdioSilentServerDoesNotWedgeTheManager(t *testing.T) {
m := stdioManager(t, "mute")
defer m.Close()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
done := make(chan error, 1)
go func() {
_, err := m.Call(ctx, "fake", "read_thing", nil)
done <- err
}()
select {
case err := <-done:
if err == nil {
t.Fatal("a call into a silent server must fail, not succeed")
}
case <-time.After(5 * time.Second):
t.Fatal("the call never returned: the context is not observed during the read")
}
// The manager must still answer while (and after) that call was stuck.
ready := make(chan struct{})
go func() {
m.Refresh(context.Background())
m.Tools()
m.Status()
close(ready)
}()
select {
case <-ready:
case <-time.After(5 * time.Second):
t.Fatal("Refresh/Tools/Status deadlocked behind the hung call")
}
}
// One frame is bounded by the reader's buffer, not measured after the whole
// thing has already been assembled in mavend's heap.
func TestStdioOversizedFrameIsRefused(t *testing.T) {
m := stdioManager(t, "flood")
defer m.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := m.Call(ctx, "fake", "read_thing", nil); err == nil {
t.Fatal("a 64 MiB frame must be refused")
}
}