Skip to content

Commit aaa80f7

Browse files
authored
Merge pull request #101 from 5uck1ess/fix/mcp-stdout-regression-test
test(mcp): add stdout regression test for devkit mcp
2 parents 8e3a8b9 + 30a8bc1 commit aaa80f7

1 file changed

Lines changed: 157 additions & 0 deletions

File tree

src/cmd/mcp_test.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"errors"
7+
"io"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"runtime"
12+
"strings"
13+
"sync"
14+
"testing"
15+
"time"
16+
)
17+
18+
// devkit mcp speaks JSON-RPC over stdout. A single byte of non-JSON on
19+
// that stream breaks the handshake (Codex saw "connection closed:
20+
// initialize response"). This test runs the real binary, sends an
21+
// initialize request, and asserts stdout carries only the JSON-RPC
22+
// response.
23+
24+
var (
25+
binBuildOnce sync.Once
26+
binBuildPath string
27+
binBuildErr error
28+
)
29+
30+
func buildDevkitBinary(t *testing.T) string {
31+
t.Helper()
32+
binBuildOnce.Do(func() {
33+
tmpDir, err := os.MkdirTemp("", "devkit-mcp-test-*")
34+
if err != nil {
35+
binBuildErr = err
36+
return
37+
}
38+
name := "devkit-test"
39+
if runtime.GOOS == "windows" {
40+
name += ".exe"
41+
}
42+
binBuildPath = filepath.Join(tmpDir, name)
43+
// Build from src/ (parent of cmd/). The test's working
44+
// directory is the package dir, so "../" resolves to src/.
45+
cmd := exec.Command("go", "build", "-o", binBuildPath, ".")
46+
cmd.Dir = ".."
47+
var stderr bytes.Buffer
48+
cmd.Stderr = &stderr
49+
if err := cmd.Run(); err != nil {
50+
binBuildErr = errors.New("go build failed: " + err.Error() + ": " + stderr.String())
51+
}
52+
})
53+
if binBuildErr != nil {
54+
t.Fatalf("build devkit: %v", binBuildErr)
55+
}
56+
return binBuildPath
57+
}
58+
59+
// TestMCPStdoutIsCleanJSONRPC is a regression test for the stdout
60+
// contamination bug. Any banner, log line, or stray Println in a code
61+
// path reachable from `devkit mcp` will fail this test by appearing on
62+
// stdout before the JSON-RPC response.
63+
func TestMCPStdoutIsCleanJSONRPC(t *testing.T) {
64+
if testing.Short() {
65+
t.Skip("skipping subprocess build in short mode")
66+
}
67+
68+
bin := buildDevkitBinary(t)
69+
70+
initReq := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":` +
71+
`{"protocolVersion":"2024-11-05","capabilities":{},` +
72+
`"clientInfo":{"name":"regression-test","version":"0.0.0"}}}` + "\n"
73+
74+
// CLAUDE_PLUGIN_ROOT points the server at this repo's workflows
75+
// dir; without it, NewServer would fall back to repoRoot and the
76+
// process needs to be inside a git repo (it is — tests run from
77+
// the package dir which is inside the devkit checkout).
78+
repoRoot, err := filepath.Abs("../..")
79+
if err != nil {
80+
t.Fatalf("resolve repo root: %v", err)
81+
}
82+
83+
cmd := exec.Command(bin, "mcp")
84+
cmd.Env = append(os.Environ(),
85+
"CLAUDE_PLUGIN_ROOT="+repoRoot,
86+
"CLAUDE_PLUGIN_DATA="+t.TempDir(),
87+
)
88+
cmd.Stdin = strings.NewReader(initReq)
89+
var stdout, stderr bytes.Buffer
90+
cmd.Stdout = &stdout
91+
cmd.Stderr = &stderr
92+
93+
if err := cmd.Start(); err != nil {
94+
t.Fatalf("start devkit mcp: %v", err)
95+
}
96+
done := make(chan error, 1)
97+
go func() { done <- cmd.Wait() }()
98+
99+
// The server keeps reading stdin after responding to initialize.
100+
// Closing stdin (already exhausted) and giving it a bounded wait
101+
// is enough — if it doesn't exit, kill it.
102+
select {
103+
case <-done:
104+
case <-time.After(10 * time.Second):
105+
_ = cmd.Process.Kill()
106+
<-done
107+
t.Fatalf("devkit mcp did not exit within 10s\nstdout: %q\nstderr: %q", stdout.String(), stderr.String())
108+
}
109+
110+
out := stdout.Bytes()
111+
if len(out) == 0 {
112+
t.Fatalf("devkit mcp wrote nothing to stdout\nstderr: %q", stderr.String())
113+
}
114+
if out[0] != '{' {
115+
t.Fatalf("stdout does not start with JSON-RPC object — leading bytes: %q\nfull stdout: %q\nstderr: %q",
116+
leadingBytes(out, 80), out, stderr.String())
117+
}
118+
119+
// Every line on stdout must parse as JSON. A stray Println would
120+
// land on its own line and fail decoding here.
121+
dec := json.NewDecoder(bytes.NewReader(out))
122+
sawInitResp := false
123+
for {
124+
var msg map[string]any
125+
err := dec.Decode(&msg)
126+
if errors.Is(err, io.EOF) {
127+
break
128+
}
129+
if err != nil {
130+
t.Fatalf("stdout contains non-JSON content: %v\nstdout: %q\nstderr: %q", err, out, stderr.String())
131+
}
132+
if id, ok := msg["id"]; ok {
133+
// JSON numbers decode as float64; the initialize id is 1.
134+
if n, ok := id.(float64); ok && n == 1 {
135+
sawInitResp = true
136+
}
137+
}
138+
}
139+
if !sawInitResp {
140+
t.Fatalf("did not see initialize response on stdout\nstdout: %q\nstderr: %q", out, stderr.String())
141+
}
142+
143+
// The diagnostic banner belongs on stderr. This is the affirmative
144+
// half of the contract — if someone deletes the banner, that's
145+
// fine; if they move it back to stdout, the JSON check above
146+
// fails first.
147+
if strings.Contains(stdout.String(), "devkit MCP server ready") {
148+
t.Fatalf("ready banner leaked to stdout — must go to stderr\nstdout: %q", stdout.String())
149+
}
150+
}
151+
152+
func leadingBytes(b []byte, n int) []byte {
153+
if len(b) < n {
154+
return b
155+
}
156+
return b[:n]
157+
}

0 commit comments

Comments
 (0)