package mcp_test import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/mathiasbq/hyperguild/ingestion/internal/capture" "github.com/mathiasbq/hyperguild/ingestion/internal/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func body(t *testing.T, v any) *bytes.Buffer { t.Helper() b, err := json.Marshal(v) require.NoError(t, err) return bytes.NewBuffer(b) } func TestServerInitialize(t *testing.T) { srv := mcp.NewServer(t.TempDir(), nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": map[string]any{}, })) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) var resp map[string]any require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) result := resp["result"].(map[string]any) assert.Equal(t, "2024-11-05", result["protocolVersion"]) } func TestServerToolsList(t *testing.T) { srv := mcp.NewServer(t.TempDir(), nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", })) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) var resp map[string]any require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) tools := resp["result"].(map[string]any)["tools"].([]any) names := make([]string, 0, len(tools)) for _, t := range tools { names = append(names, t.(map[string]any)["name"].(string)) } assert.ElementsMatch(t, []string{ "brain_query", "brain_write", "brain_update", "brain_get", "brain_pending", "brain_promote", "brain_reject", "brain_read", "brain_edit", "brain_tunnel", "brain_ingest_raw", "brain_ingest", "brain_answer", "brain_classify", "brain_graph", "brain_context", "session_log", }, names) // brain_index is auto-called after every brain_write, so surfacing it as // a caller verb is cognitive load with no decision behind it (ADR-0012 // rule 4, #78). The capability stays reachable — see the admin test below. assert.NotContains(t, names, "brain_index") // brain_edit_root is de-surfaced for a sharper reason than brain_index: // brain content is ingested from the outside world and reaches a model's // context, so a listed governance-file editor would be a durable // prompt-injection path — one poisoned page rewriting the CLAUDE.md a // .dispatch-allow agent later loads as instructions. assert.NotContains(t, names, "brain_edit_root") } func TestBrainIndexStaysCallableForAdminUseAfterDeSurfacing(t *testing.T) { // De-surfacing hides the verb from the model-facing list; it must not // remove the operation. A maintenance caller still needs to rebuild a // Map of Content by hand. srv := mcp.NewServer(t.TempDir(), nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": map[string]any{"name": "brain_index", "arguments": map[string]any{}}, })) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) require.Equal(t, http.StatusOK, rr.Code) var resp map[string]any require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) assert.NotContains(t, resp, "error", "brain_index must still dispatch: %v", resp["error"]) } func TestServerNotificationGetsNoBody(t *testing.T) { srv := mcp.NewServer(t.TempDir(), nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{ "jsonrpc": "2.0", "method": "notifications/initialized", })) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) assert.Empty(t, strings.TrimSpace(rr.Body.String())) } func TestServerUnknownMethodReturnsError(t *testing.T) { srv := mcp.NewServer(t.TempDir(), nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{ "jsonrpc": "2.0", "id": 3, "method": "unknown/method", })) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) var resp map[string]any require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) require.NotNil(t, resp["error"]) errObj := resp["error"].(map[string]any) assert.Equal(t, float64(-32601), errObj["code"]) assert.Contains(t, errObj["message"].(string), "unknown/method") } type stubTracker struct{} func (stubTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) { return capture.IssueRef{}, nil } func (stubTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) { return capture.IssueRef{}, nil } func (stubTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) { return capture.IssueRef{}, nil } func TestWithIssueTrackerInjects(t *testing.T) { srv := mcp.NewServer(t.TempDir(), nil, nil, nil) assert.Nil(t, srv.IssueTracker(), "tracker is off by default") srv = srv.WithIssueTracker(stubTracker{}) assert.NotNil(t, srv.IssueTracker(), "tracker injected behind the interface") }