Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

833 lines
27 KiB
Go

package streamable
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
"github.com/zzet/gortex/internal/daemon"
"go.uber.org/zap"
)
// newTestMCPServer mints an mcp-go server pre-loaded with an `echo`
// tool. The tool's handler observes the in-context session ID via
// gortexmcp.SessionIDFromContext so tests can verify the transport
// threads that value through.
func newTestMCPServer() *mcpserver.MCPServer {
srv := mcpserver.NewMCPServer("test", "0.0.0",
mcpserver.WithToolCapabilities(false),
mcpserver.WithRecovery())
srv.AddTool(
mcp.NewTool("echo",
mcp.WithDescription("echo"),
mcp.WithString("message"),
),
func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
msg, _ := args["message"].(string)
return mcp.NewToolResultText(msg), nil
},
)
return srv
}
// newTransport stands up a transport backed by a fresh MemoryStore
// and the test mcp-go server. Returns the transport and a cleanup
// func that releases the store's background sweeper.
func newTransport(t *testing.T) (*Transport, func()) {
t.Helper()
store := NewMemoryStore(time.Minute)
tr := New(Config{
Dispatcher: MCPServerDispatcher{Server: newTestMCPServer()},
Store: store,
})
return tr, func() { store.Close() }
}
// jsonRPC builds a single JSON-RPC request body. id may be a string,
// number, or nil (notification).
func jsonRPC(id any, method string, params any) []byte {
env := map[string]any{
"jsonrpc": "2.0",
"method": method,
}
if id != nil {
env["id"] = id
}
if params != nil {
env["params"] = params
}
out, _ := json.Marshal(env)
return out
}
// doPOST sends one request to the transport and returns the response
// recorder. headers is optional; nil = none.
func doPOST(t *testing.T, tr *Transport, body []byte, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
tr.ServeHTTP(rec, req)
return rec
}
// TestInitializeMintsSessionID asserts the spec's headline behaviour:
// an initialize POST returns a fresh `Mcp-Session-Id` header that
// subsequent requests carry forward.
func TestInitializeMintsSessionID(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
body := jsonRPC(1, "initialize", map[string]any{
"protocolVersion": "2026-03-26",
"capabilities": map[string]any{},
"clientInfo": map[string]any{
"name": "claude-code",
"version": "1.0.0",
},
})
rec := doPOST(t, tr, body, nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
sid := rec.Header().Get(HeaderSessionID)
if sid == "" {
t.Fatal("response missing Mcp-Session-Id header")
}
if tr.store.Len() != 1 {
t.Errorf("store.Len() = %d, want 1", tr.store.Len())
}
state, ok := tr.store.Get(sid)
if !ok {
t.Fatalf("store doesn't know session %q", sid)
}
if state.ClientName != "claude-code" {
t.Errorf("ClientName = %q, want claude-code", state.ClientName)
}
if state.ClientVersion != "1.0.0" {
t.Errorf("ClientVersion = %q, want 1.0.0", state.ClientVersion)
}
}
// TestSessionReplayAcrossRequests covers the load-balancer use case:
// initialize on one logical "worker", then a tools/call hitting a
// fresh worker that only shares the session store. The transport
// must look the session up by id and dispatch the call as if it had
// always known about it.
func TestSessionReplayAcrossRequests(t *testing.T) {
store := NewMemoryStore(time.Minute)
defer store.Close()
// Two transport instances — same store, different MCP servers —
// model what's behind a load balancer.
workerA := New(Config{
Dispatcher: MCPServerDispatcher{Server: newTestMCPServer()},
Store: store,
})
workerB := New(Config{
Dispatcher: MCPServerDispatcher{Server: newTestMCPServer()},
Store: store,
})
// 1) Initialize on worker A.
initBody := jsonRPC(1, "initialize", map[string]any{
"protocolVersion": "2026-03-26",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "test", "version": "0.0.0"},
})
rec := doPOST(t, workerA, initBody, nil)
sid := rec.Header().Get(HeaderSessionID)
if sid == "" {
t.Fatal("worker A initialize: no session id")
}
// 2) tools/call on worker B with the same session id.
callBody := jsonRPC(2, "tools/call", map[string]any{
"name": "echo",
"arguments": map[string]any{"message": "hello"},
})
rec = doPOST(t, workerB, callBody, map[string]string{HeaderSessionID: sid})
if rec.Code != http.StatusOK {
t.Fatalf("worker B status = %d, body=%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get(HeaderSessionID); got != sid {
t.Errorf("worker B echoed session id = %q, want %q", got, sid)
}
if !strings.Contains(rec.Body.String(), "hello") {
t.Errorf("worker B response missing tool output: %s", rec.Body.String())
}
}
// TestUnknownSessionRejected covers the stale-session race: a client
// reconnects with a session id the store no longer knows. The
// transport returns a JSON-RPC error, not a silent fresh session.
func TestUnknownSessionRejected(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
body := jsonRPC(1, "tools/call", map[string]any{
"name": "echo", "arguments": map[string]any{"message": "x"},
})
rec := doPOST(t, tr, body, map[string]string{HeaderSessionID: "never_existed"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (JSON-RPC error envelope is 200)", rec.Code)
}
var env map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatalf("body not JSON: %v\n%s", err, rec.Body.String())
}
errBlock, ok := env["error"].(map[string]any)
if !ok {
t.Fatalf("response missing error block: %v", env)
}
msg, _ := errBlock["message"].(string)
if !strings.Contains(msg, "never_existed") {
t.Errorf("error message = %q; want it to name the missing session", msg)
}
}
// TestDeleteDropsSession covers the explicit teardown path.
func TestDeleteDropsSession(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
id, err := tr.store.Create(SessionState{ClientName: "test"})
if err != nil {
t.Fatalf("seed Create: %v", err)
}
req := httptest.NewRequest(http.MethodDelete, "/mcp", nil)
req.Header.Set(HeaderSessionID, id)
rec := httptest.NewRecorder()
tr.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
if _, ok := tr.store.Get(id); ok {
t.Error("session still in store after DELETE")
}
}
// TestDeleteUnknownIsIdempotent — spec mandates 204 even for unknown ids.
func TestDeleteUnknownIsIdempotent(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
req := httptest.NewRequest(http.MethodDelete, "/mcp", nil)
req.Header.Set(HeaderSessionID, "never_existed")
rec := httptest.NewRecorder()
tr.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
}
// TestStatelessModeOmitsSessionID — when the store is the StatelessStore,
// initialize succeeds but the response carries no Mcp-Session-Id and
// the store stays empty.
func TestStatelessModeOmitsSessionID(t *testing.T) {
tr := New(Config{
Dispatcher: MCPServerDispatcher{Server: newTestMCPServer()},
Store: StatelessStore{},
})
body := jsonRPC(1, "initialize", map[string]any{
"protocolVersion": "2026-03-26",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "test", "version": "0.0.0"},
})
rec := doPOST(t, tr, body, nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
if rec.Header().Get(HeaderSessionID) != "" {
t.Errorf("stateless mode set Mcp-Session-Id = %q", rec.Header().Get(HeaderSessionID))
}
if tr.store.Len() != 0 {
t.Errorf("stateless store got %d entries", tr.store.Len())
}
}
// TestBatchRequestPreservesOrder asserts the JSON-RPC batch contract:
// multiple frames in one POST produce a JSON array of replies in the
// same order as the input frames.
func TestBatchRequestPreservesOrder(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
// Build a 3-call batch (interleaved request + notification +
// request) — notifications produce no reply, so the reply array
// should have exactly two entries.
frames := []json.RawMessage{
jsonRPC(1, "tools/call", map[string]any{
"name": "echo", "arguments": map[string]any{"message": "first"},
}),
// Notification — no id.
jsonRPC(nil, "notifications/initialized", nil),
jsonRPC(2, "tools/call", map[string]any{
"name": "echo", "arguments": map[string]any{"message": "second"},
}),
}
body, _ := json.Marshal(frames)
rec := doPOST(t, tr, body, nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var replies []json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &replies); err != nil {
t.Fatalf("response not a JSON array: %v\n%s", err, rec.Body.String())
}
if len(replies) != 2 {
t.Fatalf("replies len = %d, want 2 (notifications produce no reply)", len(replies))
}
if !strings.Contains(string(replies[0]), "first") {
t.Errorf("reply[0] = %s, want it to contain 'first'", string(replies[0]))
}
if !strings.Contains(string(replies[1]), "second") {
t.Errorf("reply[1] = %s, want it to contain 'second'", string(replies[1]))
}
}
// TestNotificationOnlyBatchReturns202 — when every frame is a
// notification, the spec calls for HTTP 202 with an empty body.
func TestNotificationOnlyBatchReturns202(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
frames := []json.RawMessage{
jsonRPC(nil, "notifications/initialized", nil),
jsonRPC(nil, "notifications/cancelled", map[string]any{"requestId": 1}),
}
body, _ := json.Marshal(frames)
rec := doPOST(t, tr, body, nil)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202", rec.Code)
}
if rec.Body.Len() > 0 {
t.Errorf("body not empty: %q", rec.Body.String())
}
}
// TestOriginAllowlist — when configured, only matching Origin
// headers are accepted; missing Origin is allowed (same-origin
// requests).
func TestOriginAllowlist(t *testing.T) {
tr := New(Config{
Dispatcher: MCPServerDispatcher{Server: newTestMCPServer()},
Store: NewMemoryStore(time.Minute),
AllowedOrigins: []string{"https://app.example.com"},
})
body := jsonRPC(1, "tools/list", nil)
// Allowed origin.
rec := doPOST(t, tr, body, map[string]string{"Origin": "https://app.example.com"})
if rec.Code != http.StatusOK {
t.Errorf("allowed origin: status = %d", rec.Code)
}
// Disallowed origin.
rec = doPOST(t, tr, body, map[string]string{"Origin": "https://attacker.example.org"})
if rec.Code != http.StatusForbidden {
t.Errorf("disallowed origin: status = %d, want 403", rec.Code)
}
// Missing origin (same-origin / curl) — allowed.
rec = doPOST(t, tr, body, nil)
if rec.Code != http.StatusOK {
t.Errorf("missing origin: status = %d", rec.Code)
}
}
// TestEmptyBodyReturnsParseError — guards the request-parse branch.
func TestEmptyBodyReturnsParseError(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
rec := doPOST(t, tr, []byte{}, nil)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
// TestMalformedJSONReturnsParseError covers garbage inputs that don't
// even look like JSON.
func TestMalformedJSONReturnsParseError(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
rec := doPOST(t, tr, []byte("not json"), nil)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
// TestMethodNotAllowed — PUT on /mcp should return 405.
func TestMethodNotAllowed(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
req := httptest.NewRequest(http.MethodPut, "/mcp", nil)
rec := httptest.NewRecorder()
tr.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", rec.Code)
}
}
// TestServerInitiatedPush — opens a GET stream, pushes a frame, and
// confirms the SSE delivery format.
func TestServerInitiatedPush(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
id, err := tr.store.Create(SessionState{ClientName: "test"})
if err != nil {
t.Fatalf("seed Create: %v", err)
}
srv := httptest.NewServer(tr)
defer srv.Close()
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/mcp", nil)
req.Header.Set(HeaderSessionID, id)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET /mcp: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Errorf("Content-Type = %q, want text/event-stream", ct)
}
// Push a frame after the GET handler is wired up. Poll up to
// a short deadline so we don't race the stream registration.
var pushed bool
for i := 0; i < 50 && !pushed; i++ {
pushed = tr.Push(id, json.RawMessage(`{"jsonrpc":"2.0","method":"notifications/message","params":{"text":"hi"}}`))
if !pushed {
time.Sleep(5 * time.Millisecond)
}
}
if !pushed {
t.Fatal("Push never returned true — stream not registered")
}
// Read until we see the data line for our frame, with a bounded
// timeout in case SSE delivery breaks.
type readResult struct {
line string
err error
}
done := make(chan readResult, 1)
go func() {
buf := make([]byte, 4096)
var acc strings.Builder
for {
n, err := resp.Body.Read(buf)
if n > 0 {
acc.Write(buf[:n])
if strings.Contains(acc.String(), `notifications/message`) {
done <- readResult{line: acc.String()}
return
}
}
if err != nil {
done <- readResult{err: err}
return
}
}
}()
select {
case r := <-done:
if r.err != nil && !strings.Contains(r.err.Error(), "notifications/message") {
t.Fatalf("read: %v", r.err)
}
if !strings.Contains(r.line, "event: message") {
t.Errorf("missing 'event: message' line in %q", r.line)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for SSE delivery")
}
}
// TestGetRejectsUnknownSession — GET requires a known session.
func TestGetRejectsUnknownSession(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
req.Header.Set(HeaderSessionID, "nope")
rec := httptest.NewRecorder()
tr.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
// TestGetRequiresSessionHeader — bare GET without Mcp-Session-Id is
// rejected.
func TestGetRequiresSessionHeader(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
rec := httptest.NewRecorder()
tr.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
// TestConcurrentDispatchesShareSession — many goroutines fire
// requests against the same session and the transport never trips
// over them.
func TestConcurrentDispatchesShareSession(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
id, _ := tr.store.Create(SessionState{Initialized: true, ClientName: "test"})
const workers = 25
var wg sync.WaitGroup
var failures atomic.Int32
for i := 0; i < workers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
body := jsonRPC(i+100, "tools/call", map[string]any{
"name": "echo",
"arguments": map[string]any{"message": "worker-" + strconv.Itoa(i)},
})
rec := doPOST(t, tr, body, map[string]string{HeaderSessionID: id})
if rec.Code != http.StatusOK {
failures.Add(1)
return
}
if !strings.Contains(rec.Body.String(), "worker-"+strconv.Itoa(i)) {
failures.Add(1)
}
}(i)
}
wg.Wait()
if failures.Load() != 0 {
t.Errorf("%d/%d concurrent dispatches failed", failures.Load(), workers)
}
}
// TestInitializeReplacesPreviousSession — initialize always mints a
// fresh session id even when the client carried a stale one.
func TestInitializeReplacesPreviousSession(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
stale, _ := tr.store.Create(SessionState{ClientName: "old"})
body := jsonRPC(1, "initialize", map[string]any{
"protocolVersion": "2026-03-26",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "new", "version": "1.0.0"},
})
rec := doPOST(t, tr, body, map[string]string{HeaderSessionID: stale})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
fresh := rec.Header().Get(HeaderSessionID)
if fresh == "" {
t.Fatal("no Mcp-Session-Id on initialize response")
}
if fresh == stale {
t.Errorf("initialize reused stale id %q", stale)
}
if _, ok := tr.store.Get(stale); ok {
t.Error("stale session not evicted")
}
}
// TestProtocolVersionHeaderEcho — when the client supplies an
// Mcp-Protocol-Version header, the response carries the same value;
// when absent, the transport advertises its DefaultProtocolVersion.
func TestProtocolVersionHeaderEcho(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
body := jsonRPC(1, "tools/list", nil)
rec := doPOST(t, tr, body, nil)
if got := rec.Header().Get(HeaderProtocolVersion); got != DefaultProtocolVersion {
t.Errorf("default version = %q, want %q", got, DefaultProtocolVersion)
}
rec = doPOST(t, tr, body, map[string]string{HeaderProtocolVersion: "2025-12-01"})
if got := rec.Header().Get(HeaderProtocolVersion); got != "2025-12-01" {
t.Errorf("echoed version = %q, want 2025-12-01", got)
}
}
// TestOptionsReturnsAllow covers the CORS preflight branch.
func TestOptionsReturnsAllow(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
req := httptest.NewRequest(http.MethodOptions, "/mcp", nil)
rec := httptest.NewRecorder()
tr.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", rec.Code)
}
if rec.Header().Get("Allow") == "" {
t.Error("Allow header missing on OPTIONS reply")
}
}
// TestNewPanicsWithoutDispatcher — programmer-error guard. Caught at
// process startup, not at first request.
func TestNewPanicsWithoutDispatcher(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("New(Config{}) did not panic")
}
}()
_ = New(Config{Store: NewMemoryStore(time.Minute)})
}
// TestNewFallsBackToMemoryStore — convenience path: Store left nil
// gets a MemoryStore with the supplied TTL.
func TestNewFallsBackToMemoryStore(t *testing.T) {
tr := New(Config{
Dispatcher: MCPServerDispatcher{Server: newTestMCPServer()},
SessionTTL: time.Minute,
})
if _, ok := tr.Store().(*MemoryStore); !ok {
t.Errorf("Store() = %T, want *MemoryStore", tr.Store())
}
}
// TestSplitJSONRPC_Inputs unit-tests the body splitter directly.
func TestSplitJSONRPC_Inputs(t *testing.T) {
cases := []struct {
name string
body string
wantCount int
wantBatch bool
wantErr bool
}{
{name: "single object", body: `{"jsonrpc":"2.0","id":1,"method":"x"}`, wantCount: 1, wantBatch: false},
{name: "batch of two", body: `[{"id":1,"method":"a"},{"id":2,"method":"b"}]`, wantCount: 2, wantBatch: true},
{name: "whitespace prefix", body: " {\"id\":1,\"method\":\"x\"}", wantCount: 1, wantBatch: false},
{name: "empty array", body: "[]", wantBatch: true, wantErr: true},
{name: "garbage", body: "garbage", wantErr: true},
{name: "blank", body: " ", wantErr: true},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
frames, batch, err := splitJSONRPC([]byte(c.body))
if (err != nil) != c.wantErr {
t.Fatalf("err = %v, wantErr = %v", err, c.wantErr)
}
if err != nil {
return
}
if len(frames) != c.wantCount {
t.Errorf("frames = %d, want %d", len(frames), c.wantCount)
}
if batch != c.wantBatch {
t.Errorf("batched = %v, want %v", batch, c.wantBatch)
}
})
}
}
// TestLargeBodyClampedByMaxBytes — the io.LimitReader cap should
// prevent unbounded growth from a malicious / runaway client.
func TestLargeBodyClampedByMaxBytes(t *testing.T) {
tr := New(Config{
Dispatcher: MCPServerDispatcher{Server: newTestMCPServer()},
Store: NewMemoryStore(time.Minute),
MaxRequestBytes: 256,
})
huge := make([]byte, 4096)
for i := range huge {
huge[i] = 'x'
}
body := append([]byte(`{"jsonrpc":"2.0","id":1,"method":"x","params":{"junk":"`), huge...)
body = append(body, []byte(`"}}`)...)
rec := doPOST(t, tr, body, nil)
// The body is truncated mid-stream so mcp-go's JSON-RPC parser
// returns a parse-error envelope (HTTP 200 + JSON-RPC error).
// The load-bearing assertion is that the transport refused to
// process the trailing bytes — a buggy implementation that
// disabled the io.LimitReader would have ingested the full
// 4 KiB payload and produced a successful response. We confirm
// the response carries an error block in the JSON envelope.
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (JSON-RPC parse error envelope)", rec.Code)
}
if !strings.Contains(rec.Body.String(), `"error"`) {
t.Errorf("body missing error envelope after truncation: %s", rec.Body.String())
}
}
// TestInitializeHookFires asserts the daemon-side hook contract: the
// transport invokes InitializeHook synchronously after extracting
// clientInfo. The daemon adapter relies on this to register the
// detached session up-front.
func TestInitializeHookFires(t *testing.T) {
var called atomic.Bool
var seenName string
tr := New(Config{
Dispatcher: MCPServerDispatcher{Server: newTestMCPServer()},
Store: NewMemoryStore(time.Minute),
InitializeHook: func(_ context.Context, state *SessionState) {
called.Store(true)
seenName = state.ClientName
},
})
body := jsonRPC(1, "initialize", map[string]any{
"protocolVersion": "2026-03-26",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "cursor", "version": "3.0.0"},
})
rec := doPOST(t, tr, body, nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if !called.Load() {
t.Fatal("InitializeHook never fired")
}
if seenName != "cursor" {
t.Errorf("hook saw ClientName = %q, want cursor", seenName)
}
}
// TestMCPServerDispatcherNilFailsCleanly — guard against the
// constructor-misuse case where the embedded server pointer is nil.
func TestMCPServerDispatcherNilFailsCleanly(t *testing.T) {
d := MCPServerDispatcher{}
_, err := d.Dispatch(context.Background(), []byte(`{"jsonrpc":"2.0","id":1,"method":"x"}`))
if err == nil {
t.Error("Dispatch(nil server) returned nil error")
}
}
// TestRouterPreservesFullArguments pins the regression fix: when the
// streamable transport routes a tools/call through a daemon.Router
// whose local executor unmarshals to a map, the executor must see the
// caller's ORIGINAL arguments — not a stripped {workspace,cwd} peek.
//
// A previous version of tryRouteToolCall re-marshalled only the typed
// peek struct (workspace+cwd) and dropped every other key on the
// floor, breaking every real MCP usage with "X is required" because
// the args map was effectively empty. This test fails on that bug
// and passes on the fix.
func TestRouterPreservesFullArguments(t *testing.T) {
var seenBody []byte
router := daemon.NewRouter(daemon.RouterConfig{
LocalExecute: func(_ context.Context, _ string, body []byte) ([]byte, int, error) {
seenBody = append([]byte(nil), body...)
// Mirror the production local executor: unwrap
// `{"arguments": {...}}` then assert every caller key
// survived the round-trip.
var nested struct {
Arguments map[string]any `json:"arguments"`
}
if err := json.Unmarshal(body, &nested); err != nil {
return nil, 500, err
}
if nested.Arguments == nil {
return []byte(`{"error":"no arguments"}`), 200, nil
}
out, _ := json.Marshal(map[string]any{"ok": true, "args": nested.Arguments})
return out, 200, nil
},
Logger: zap.NewNop(),
})
store := NewMemoryStore(time.Minute)
defer store.Close()
tr := New(Config{
Dispatcher: MCPServerDispatcher{Server: newTestMCPServer()},
Store: store,
Router: router,
})
// Seed an initialized session so the transport accepts the call.
sid, err := store.Create(SessionState{Initialized: true, ClientName: "test"})
if err != nil {
t.Fatalf("seed Create: %v", err)
}
callBody := jsonRPC(7, "tools/call", map[string]any{
"name": "search_symbols",
"arguments": map[string]any{
"query": "NewServer",
"limit": 10,
},
})
rec := doPOST(t, tr, callBody, map[string]string{HeaderSessionID: sid})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
// 1) The local executor must have seen the original args.
var nested struct {
Arguments map[string]any `json:"arguments"`
}
if err := json.Unmarshal(seenBody, &nested); err != nil {
t.Fatalf("local executor body not JSON: %v\nbody=%s", err, string(seenBody))
}
if nested.Arguments == nil {
t.Fatalf("local executor saw nil arguments — args were stripped. body=%s", string(seenBody))
}
if got, _ := nested.Arguments["query"].(string); got != "NewServer" {
t.Errorf("query = %q, want %q (args stripped before reaching executor). body=%s",
got, "NewServer", string(seenBody))
}
// JSON numbers decode to float64 in interface{}; compare as such.
if got, _ := nested.Arguments["limit"].(float64); got != 10 {
t.Errorf("limit = %v, want 10 (args stripped before reaching executor). body=%s",
got, string(seenBody))
}
// 2) The wrapped tool result must reach the client too.
if !strings.Contains(rec.Body.String(), "NewServer") {
t.Errorf("client response missing forwarded args: %s", rec.Body.String())
}
}
// TestHTTPRoundTripEndToEnd — fires the transport behind an
// httptest.Server so the body actually flows through net/http; covers
// the boundary the per-test recorder can't.
func TestHTTPRoundTripEndToEnd(t *testing.T) {
tr, cleanup := newTransport(t)
defer cleanup()
srv := httptest.NewServer(tr)
defer srv.Close()
body := jsonRPC(1, "initialize", map[string]any{
"protocolVersion": "2026-03-26",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "test", "version": "0.0.0"},
})
resp, err := http.Post(srv.URL+"/mcp", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST /mcp: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
if resp.Header.Get(HeaderSessionID) == "" {
t.Error("response missing Mcp-Session-Id")
}
out, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(out), `"jsonrpc":"2.0"`) {
t.Errorf("body missing jsonrpc envelope: %s", out)
}
}