From a7bc30f8ac1b3c9467388360c3807513a28d576c Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 17 Mar 2026 09:03:51 +0000 Subject: [PATCH] =?UTF-8?q?fix(mcp):=20DX=20audit=20=E2=80=94=20update=20C?= =?UTF-8?q?LAUDE.md,=20replace=20os.*=20with=20go-io,=20add=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md: remove stale tools_ml.go reference (moved to go-ml), add agentic subsystem, document HTTP transport and MCP_AUTH_TOKEN - Replace os.ReadDir with coreio.Local.List in agentic package (plan.go, queue.go, status.go) - Replace os.Stat with coreio.Local.IsFile/List in agentic package (plan.go, pr.go, resume.go) - Add DirectSubsystem tests (brain): apiCall, remember, recall, forget with httptest mock server — coverage 6.5% → 39.6% - Add HTTP transport tests: health endpoint, Bearer auth, withAuth unit tests, Run() priority — coverage 51.2% → 56.0% Co-Authored-By: Virgil --- CLAUDE.md | 18 +- pkg/mcp/agentic/plan.go | 4 +- pkg/mcp/agentic/pr.go | 3 +- pkg/mcp/agentic/queue.go | 4 +- pkg/mcp/agentic/resume.go | 2 +- pkg/mcp/agentic/status.go | 2 +- pkg/mcp/brain/direct_test.go | 305 +++++++++++++++++++++++++++++++++ pkg/mcp/transport_http_test.go | 234 +++++++++++++++++++++++++ 8 files changed, 559 insertions(+), 13 deletions(-) create mode 100644 pkg/mcp/brain/direct_test.go create mode 100644 pkg/mcp/transport_http_test.go diff --git a/CLAUDE.md b/CLAUDE.md index a8383fe..1063924 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project overview -Core MCP is a Model Context Protocol implementation in two halves: a **Go binary** (`core-mcp`) that speaks native MCP over stdio/TCP/Unix, and a **PHP Laravel package** (`lthn/mcp`) that adds an HTTP MCP API with auth, quotas, and analytics. Both halves bridge to each other via REST or WebSocket. +Core MCP is a Model Context Protocol implementation in two halves: a **Go binary** (`core-mcp`) that speaks native MCP over stdio/TCP/HTTP/Unix, and a **PHP Laravel package** (`lthn/mcp`) that adds an HTTP MCP API with auth, quotas, and analytics. Both halves bridge to each other via REST or WebSocket. Module: `forge.lthn.ai/core/mcp` | Licence: EUPL-1.2 @@ -39,9 +39,11 @@ composer lint # Laravel Pint (PSR-12) ### Running locally ```bash -./core-mcp mcp serve # Stdio transport (Claude Code / IDE) -./core-mcp mcp serve --workspace /path/to/project # Sandbox file ops to directory -MCP_ADDR=127.0.0.1:9100 ./core-mcp mcp serve # TCP transport +./core-mcp mcp serve # Stdio transport (Claude Code / IDE) +./core-mcp mcp serve --workspace /path/to/project # Sandbox file ops to directory +MCP_ADDR=127.0.0.1:9100 ./core-mcp mcp serve # TCP transport +MCP_HTTP_ADDR=127.0.0.1:9101 ./core-mcp mcp serve # Streamable HTTP transport +MCP_HTTP_ADDR=:9101 MCP_AUTH_TOKEN=secret ./core-mcp mcp serve # HTTP with Bearer auth ``` ## Architecture @@ -61,11 +63,15 @@ MCP_ADDR=127.0.0.1:9100 ./core-mcp mcp serve # TCP transport - `ws` — `tools_ws.go` (requires `WithWSHub`) **Subsystem interface** (`Subsystem` / `SubsystemWithShutdown`): Pluggable tool groups registered via `WithSubsystem`. Three ship with the repo: -- `tools_ml.go` — ML inference subsystem (generate, score, probe, status, backends) - `pkg/mcp/ide/` — IDE bridge to Laravel backend over WebSocket (chat, build, dashboard tools) - `pkg/mcp/brain/` — OpenBrain knowledge store proxy (remember, recall, forget, list) +- `pkg/mcp/agentic/` — Agent orchestration (prep workspace, dispatch, resume, status, plans, PRs, epics, scan) -**Transports**: stdio (default), TCP (`MCP_ADDR` env var), Unix socket (`ServeUnix`). TCP binds `127.0.0.1` by default; `0.0.0.0` emits a security warning. +**Transports** (selected by `Run()` in priority order): +1. Streamable HTTP (`MCP_HTTP_ADDR` env var) — Bearer token auth via `MCP_AUTH_TOKEN`, endpoint at `/mcp` +2. TCP (`MCP_ADDR` env var) — binds `127.0.0.1` by default; `0.0.0.0` emits a security warning +3. Stdio (default) — used by Claude Code / IDEs +4. Unix socket (`ServeUnix`) — programmatic use only **REST bridge**: `BridgeToAPI` maps each `ToolRecord` to a `POST` endpoint via `api.ToolBridge`. 10 MB body limit. diff --git a/pkg/mcp/agentic/plan.go b/pkg/mcp/agentic/plan.go index cf4cf4e..081b7fe 100644 --- a/pkg/mcp/agentic/plan.go +++ b/pkg/mcp/agentic/plan.go @@ -255,7 +255,7 @@ func (s *PrepSubsystem) planDelete(_ context.Context, _ *mcp.CallToolRequest, in } path := planPath(s.plansDir(), input.ID) - if _, err := os.Stat(path); err != nil { + if !coreio.Local.IsFile(path) { return nil, PlanDeleteOutput{}, coreerr.E("planDelete", "plan not found: "+input.ID, nil) } @@ -275,7 +275,7 @@ func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, inpu return nil, PlanListOutput{}, coreerr.E("planList", "failed to access plans directory", err) } - entries, err := os.ReadDir(dir) + entries, err := coreio.Local.List(dir) if err != nil { return nil, PlanListOutput{}, coreerr.E("planList", "failed to read plans directory", err) } diff --git a/pkg/mcp/agentic/pr.go b/pkg/mcp/agentic/pr.go index e200de6..de0769e 100644 --- a/pkg/mcp/agentic/pr.go +++ b/pkg/mcp/agentic/pr.go @@ -13,6 +13,7 @@ import ( "path/filepath" "strings" + coreio "forge.lthn.ai/core/go-io" coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -58,7 +59,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in wsDir := filepath.Join(home, "Code", "host-uk", "core", ".core", "workspace", input.Workspace) srcDir := filepath.Join(wsDir, "src") - if _, err := os.Stat(srcDir); err != nil { + if _, err := coreio.Local.List(srcDir); err != nil { return nil, CreatePROutput{}, coreerr.E("createPR", "workspace not found: "+input.Workspace, nil) } diff --git a/pkg/mcp/agentic/queue.go b/pkg/mcp/agentic/queue.go index f42add2..26ca6ac 100644 --- a/pkg/mcp/agentic/queue.go +++ b/pkg/mcp/agentic/queue.go @@ -108,7 +108,7 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int { home, _ := os.UserHomeDir() wsRoot := filepath.Join(home, "Code", "host-uk", "core", ".core", "workspace") - entries, err := os.ReadDir(wsRoot) + entries, err := coreio.Local.List(wsRoot) if err != nil { return 0 } @@ -167,7 +167,7 @@ func (s *PrepSubsystem) drainQueue() { home, _ := os.UserHomeDir() wsRoot := filepath.Join(home, "Code", "host-uk", "core", ".core", "workspace") - entries, err := os.ReadDir(wsRoot) + entries, err := coreio.Local.List(wsRoot) if err != nil { return } diff --git a/pkg/mcp/agentic/resume.go b/pkg/mcp/agentic/resume.go index dba9de8..62dafca 100644 --- a/pkg/mcp/agentic/resume.go +++ b/pkg/mcp/agentic/resume.go @@ -50,7 +50,7 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu srcDir := filepath.Join(wsDir, "src") // Verify workspace exists - if _, err := os.Stat(srcDir); err != nil { + if _, err := coreio.Local.List(srcDir); err != nil { return nil, ResumeOutput{}, coreerr.E("resume", "workspace not found: "+input.Workspace, nil) } diff --git a/pkg/mcp/agentic/status.go b/pkg/mcp/agentic/status.go index db30b33..2c4115f 100644 --- a/pkg/mcp/agentic/status.go +++ b/pkg/mcp/agentic/status.go @@ -98,7 +98,7 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu home, _ := os.UserHomeDir() wsRoot := filepath.Join(home, "Code", "host-uk", "core", ".core", "workspace") - entries, err := os.ReadDir(wsRoot) + entries, err := coreio.Local.List(wsRoot) if err != nil { return nil, StatusOutput{}, coreerr.E("status", "no workspaces found", err) } diff --git a/pkg/mcp/brain/direct_test.go b/pkg/mcp/brain/direct_test.go new file mode 100644 index 0000000..db3c5bd --- /dev/null +++ b/pkg/mcp/brain/direct_test.go @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// newTestDirect creates a DirectSubsystem pointing at a test server. +func newTestDirect(url string) *DirectSubsystem { + return &DirectSubsystem{ + apiURL: url, + apiKey: "test-key", + client: http.DefaultClient, + } +} + +// --- DirectSubsystem interface tests --- + +func TestDirectSubsystem_Good_Name(t *testing.T) { + s := &DirectSubsystem{} + if s.Name() != "brain" { + t.Errorf("expected Name() = 'brain', got %q", s.Name()) + } +} + +func TestDirectSubsystem_Good_Shutdown(t *testing.T) { + s := &DirectSubsystem{} + if err := s.Shutdown(context.Background()); err != nil { + t.Errorf("Shutdown failed: %v", err) + } +} + +// --- apiCall tests --- + +func TestApiCall_Good_PostWithBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer test-key" { + t.Errorf("missing or wrong Authorization header") + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("missing Content-Type header") + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]any{"id": "mem-123", "success": true}) + })) + defer srv.Close() + + s := newTestDirect(srv.URL) + result, err := s.apiCall(context.Background(), "POST", "/v1/brain/remember", map[string]string{"content": "test"}) + if err != nil { + t.Fatalf("apiCall failed: %v", err) + } + if result["id"] != "mem-123" { + t.Errorf("expected id=mem-123, got %v", result["id"]) + } +} + +func TestApiCall_Good_GetNilBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]any{"status": "ok"}) + })) + defer srv.Close() + + s := newTestDirect(srv.URL) + result, err := s.apiCall(context.Background(), "GET", "/status", nil) + if err != nil { + t.Fatalf("apiCall failed: %v", err) + } + if result["status"] != "ok" { + t.Errorf("expected status=ok, got %v", result["status"]) + } +} + +func TestApiCall_Bad_NoApiKey(t *testing.T) { + s := &DirectSubsystem{apiKey: "", client: http.DefaultClient} + _, err := s.apiCall(context.Background(), "GET", "/test", nil) + if err == nil { + t.Error("expected error when apiKey is empty") + } +} + +func TestApiCall_Bad_HttpError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte(`{"error":"internal server error"}`)) + })) + defer srv.Close() + + s := newTestDirect(srv.URL) + _, err := s.apiCall(context.Background(), "POST", "/fail", map[string]string{}) + if err == nil { + t.Error("expected error on HTTP 500") + } +} + +func TestApiCall_Bad_InvalidJson(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte("not json")) + })) + defer srv.Close() + + s := newTestDirect(srv.URL) + _, err := s.apiCall(context.Background(), "GET", "/bad-json", nil) + if err == nil { + t.Error("expected error on invalid JSON response") + } +} + +func TestApiCall_Bad_Unreachable(t *testing.T) { + s := &DirectSubsystem{ + apiURL: "http://127.0.0.1:1", // nothing listening + apiKey: "key", + client: http.DefaultClient, + } + _, err := s.apiCall(context.Background(), "GET", "/test", nil) + if err == nil { + t.Error("expected error for unreachable server") + } +} + +// --- remember tool tests --- + +func TestDirectRemember_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + if body["content"] != "test memory" { + t.Errorf("unexpected content: %v", body["content"]) + } + if body["agent_id"] != "cladius" { + t.Errorf("expected agent_id=cladius, got %v", body["agent_id"]) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]any{"id": "mem-456"}) + })) + defer srv.Close() + + s := newTestDirect(srv.URL) + _, out, err := s.remember(context.Background(), nil, RememberInput{ + Content: "test memory", + Type: "observation", + Project: "test-project", + }) + if err != nil { + t.Fatalf("remember failed: %v", err) + } + if !out.Success { + t.Error("expected success=true") + } + if out.MemoryID != "mem-456" { + t.Errorf("expected memoryId=mem-456, got %q", out.MemoryID) + } +} + +func TestDirectRemember_Bad_ApiError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(422) + w.Write([]byte(`{"error":"validation failed"}`)) + })) + defer srv.Close() + + s := newTestDirect(srv.URL) + _, _, err := s.remember(context.Background(), nil, RememberInput{Content: "x", Type: "bug"}) + if err == nil { + t.Error("expected error on API failure") + } +} + +// --- recall tool tests --- + +func TestDirectRecall_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + if body["query"] != "scoring algorithm" { + t.Errorf("unexpected query: %v", body["query"]) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]any{ + "memories": []any{ + map[string]any{ + "id": "mem-1", + "content": "scoring uses weighted average", + "type": "architecture", + "project": "eaas", + "agent_id": "virgil", + "score": 0.92, + "created_at": "2026-03-01T00:00:00Z", + }, + }, + }) + })) + defer srv.Close() + + s := newTestDirect(srv.URL) + _, out, err := s.recall(context.Background(), nil, RecallInput{ + Query: "scoring algorithm", + TopK: 5, + Filter: RecallFilter{Project: "eaas"}, + }) + if err != nil { + t.Fatalf("recall failed: %v", err) + } + if !out.Success || out.Count != 1 { + t.Errorf("expected 1 memory, got %d", out.Count) + } + if out.Memories[0].ID != "mem-1" { + t.Errorf("expected id=mem-1, got %q", out.Memories[0].ID) + } + if out.Memories[0].Confidence != 0.92 { + t.Errorf("expected score=0.92, got %f", out.Memories[0].Confidence) + } +} + +func TestDirectRecall_Good_DefaultTopK(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + // TopK=0 should default to 10 + if topK, ok := body["top_k"].(float64); !ok || topK != 10 { + t.Errorf("expected top_k=10, got %v", body["top_k"]) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]any{"memories": []any{}}) + })) + defer srv.Close() + + s := newTestDirect(srv.URL) + _, out, err := s.recall(context.Background(), nil, RecallInput{Query: "test"}) + if err != nil { + t.Fatalf("recall failed: %v", err) + } + if !out.Success || out.Count != 0 { + t.Errorf("expected empty result, got %d", out.Count) + } +} + +func TestDirectRecall_Bad_ApiError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte(`{"error":"internal"}`)) + })) + defer srv.Close() + + s := newTestDirect(srv.URL) + _, _, err := s.recall(context.Background(), nil, RecallInput{Query: "test"}) + if err == nil { + t.Error("expected error on API failure") + } +} + +// --- forget tool tests --- + +func TestDirectForget_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/v1/brain/forget/mem-789" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]any{"success": true}) + })) + defer srv.Close() + + s := newTestDirect(srv.URL) + _, out, err := s.forget(context.Background(), nil, ForgetInput{ + ID: "mem-789", + Reason: "outdated", + }) + if err != nil { + t.Fatalf("forget failed: %v", err) + } + if !out.Success || out.Forgotten != "mem-789" { + t.Errorf("unexpected output: %+v", out) + } +} + +func TestDirectForget_Bad_ApiError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + w.Write([]byte(`{"error":"not found"}`)) + })) + defer srv.Close() + + s := newTestDirect(srv.URL) + _, _, err := s.forget(context.Background(), nil, ForgetInput{ID: "nonexistent"}) + if err == nil { + t.Error("expected error on 404") + } +} diff --git a/pkg/mcp/transport_http_test.go b/pkg/mcp/transport_http_test.go new file mode 100644 index 0000000..3fdacf3 --- /dev/null +++ b/pkg/mcp/transport_http_test.go @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package mcp + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "testing" + "time" +) + +func TestServeHTTP_Good_HealthEndpoint(t *testing.T) { + s, err := New() + if err != nil { + t.Fatalf("Failed to create service: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Get a free port + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to find free port: %v", err) + } + addr := listener.Addr().String() + listener.Close() + + errCh := make(chan error, 1) + go func() { + errCh <- s.ServeHTTP(ctx, addr) + }() + + // Wait for server to start + time.Sleep(100 * time.Millisecond) + + resp, err := http.Get(fmt.Sprintf("http://%s/health", addr)) + if err != nil { + t.Fatalf("health check failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + cancel() + <-errCh +} + +func TestServeHTTP_Good_DefaultAddr(t *testing.T) { + if DefaultHTTPAddr != "127.0.0.1:9101" { + t.Errorf("expected default HTTP addr 127.0.0.1:9101, got %s", DefaultHTTPAddr) + } +} + +func TestServeHTTP_Good_AuthRequired(t *testing.T) { + os.Setenv("MCP_AUTH_TOKEN", "test-secret-token") + defer os.Unsetenv("MCP_AUTH_TOKEN") + + s, err := New() + if err != nil { + t.Fatalf("Failed to create service: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to find free port: %v", err) + } + addr := listener.Addr().String() + listener.Close() + + errCh := make(chan error, 1) + go func() { + errCh <- s.ServeHTTP(ctx, addr) + }() + + time.Sleep(100 * time.Millisecond) + + // Request without token should be rejected + resp, err := http.Get(fmt.Sprintf("http://%s/mcp", addr)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() + if resp.StatusCode != 401 { + t.Errorf("expected 401 without token, got %d", resp.StatusCode) + } + + // Health endpoint should still work (no auth) + resp, err = http.Get(fmt.Sprintf("http://%s/health", addr)) + if err != nil { + t.Fatalf("health check failed: %v", err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Errorf("expected 200 for health, got %d", resp.StatusCode) + } + + cancel() + <-errCh +} + +func TestWithAuth_Good_ValidToken(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + }) + + wrapped := withAuth("my-token", handler) + + // Valid token + req, _ := http.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer my-token") + rr := &fakeResponseWriter{code: 200} + wrapped.ServeHTTP(rr, req) + if rr.code != 200 { + t.Errorf("expected 200 with valid token, got %d", rr.code) + } +} + +func TestWithAuth_Bad_InvalidToken(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + }) + + wrapped := withAuth("my-token", handler) + + // Wrong token + req, _ := http.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer wrong-token") + rr := &fakeResponseWriter{code: 200} + wrapped.ServeHTTP(rr, req) + if rr.code != 401 { + t.Errorf("expected 401 with wrong token, got %d", rr.code) + } +} + +func TestWithAuth_Bad_MissingToken(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + }) + + wrapped := withAuth("my-token", handler) + + // No Authorization header + req, _ := http.NewRequest("GET", "/", nil) + rr := &fakeResponseWriter{code: 200} + wrapped.ServeHTTP(rr, req) + if rr.code != 401 { + t.Errorf("expected 401 with missing token, got %d", rr.code) + } +} + +func TestWithAuth_Good_EmptyTokenPassthrough(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + }) + + // Empty token disables auth + wrapped := withAuth("", handler) + + req, _ := http.NewRequest("GET", "/", nil) + rr := &fakeResponseWriter{code: 200} + wrapped.ServeHTTP(rr, req) + if rr.code != 200 { + t.Errorf("expected 200 with auth disabled, got %d", rr.code) + } +} + +func TestRun_Good_HTTPTrigger(t *testing.T) { + s, err := New() + if err != nil { + t.Fatalf("Failed to create service: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Find a free port + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("Failed to find free port: %v", err) + } + addr := listener.Addr().String() + listener.Close() + + // MCP_HTTP_ADDR takes priority over MCP_ADDR + os.Setenv("MCP_HTTP_ADDR", addr) + os.Setenv("MCP_ADDR", "") + defer os.Unsetenv("MCP_HTTP_ADDR") + defer os.Unsetenv("MCP_ADDR") + + errCh := make(chan error, 1) + go func() { + errCh <- s.Run(ctx) + }() + + time.Sleep(100 * time.Millisecond) + + // Verify server is running + resp, err := http.Get(fmt.Sprintf("http://%s/health", addr)) + if err != nil { + t.Fatalf("health check failed: %v", err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + cancel() + <-errCh +} + +// fakeResponseWriter is a minimal http.ResponseWriter for unit testing withAuth. +type fakeResponseWriter struct { + code int + hdr http.Header +} + +func (f *fakeResponseWriter) Header() http.Header { + if f.hdr == nil { + f.hdr = make(http.Header) + } + return f.hdr +} + +func (f *fakeResponseWriter) Write(b []byte) (int, error) { return len(b), nil } +func (f *fakeResponseWriter) WriteHeader(code int) { f.code = code } -- 2.45.3