[agent/claude:opus] DX audit and fix. 1) Review CLAUDE.md — update any outdate... #1
8 changed files with 559 additions and 13 deletions
18
CLAUDE.md
18
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
305
pkg/mcp/brain/direct_test.go
Normal file
305
pkg/mcp/brain/direct_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
234
pkg/mcp/transport_http_test.go
Normal file
234
pkg/mcp/transport_http_test.go
Normal file
|
|
@ -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 }
|
||||
Loading…
Add table
Reference in a new issue