package mcp import ( "bytes" "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/m/projax/store" ) // mustDBServer spins up a full MCP server bound to msupabase + the projax // schema. Tests skip cleanly when no DB env is set. func mustDBServer(t *testing.T) (*Server, *pgxpool.Pool) { t.Helper() dbURL := os.Getenv("PROJAX_DB_URL") if dbURL == "" { dbURL = os.Getenv("SUPABASE_DATABASE_URL") } if dbURL == "" { t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL — skipping MCP integration test") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() pool, err := pgxpool.New(ctx, dbURL) if err != nil { t.Fatalf("pool: %v", err) } if err := pool.Ping(ctx); err != nil { t.Skipf("db ping: %v", err) } st := store.New(pool) srv := New("projax-test", "0.0.1", "tok", slog.New(slog.NewTextHandler(io.Discard, nil))) // The MCP tests don't need a real timeline builder — passing nil keeps // the timeline tool unregistered without requiring a web.Server here. RegisterProjaxTools(srv, st, nil) t.Cleanup(func() { pool.Close() }) return srv, pool } func callTool(t *testing.T, srv *Server, name string, args any, token string) map[string]any { t.Helper() mux := http.NewServeMux() srv.Routes(mux) s := httptest.NewServer(mux) t.Cleanup(s.Close) body, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 42, "method": "tools/call", "params": map[string]any{"name": name, "arguments": args}, }) req, _ := http.NewRequest(http.MethodPost, s.URL+"/rpc", bytes.NewReader(body)) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("do %s: %v", name, err) } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("%s HTTP %d: %s", name, resp.StatusCode, raw) } var env struct { Result struct { Content []struct { Text string `json:"text"` } `json:"content"` IsError bool `json:"isError"` } `json:"result"` } if err := json.Unmarshal(raw, &env); err != nil { t.Fatalf("decode envelope: %v (%s)", err, raw) } if env.Result.IsError { t.Fatalf("tool %s returned isError=true: %s", name, env.Result.Content[0].Text) } if len(env.Result.Content) == 0 { t.Fatalf("tool %s returned no content blocks: %s", name, raw) } var out map[string]any if err := json.Unmarshal([]byte(env.Result.Content[0].Text), &out); err != nil { // Single-value (non-object) results are returned bare; wrap so the // caller can still introspect. return map[string]any{"raw": env.Result.Content[0].Text} } return out } func TestMCPListItemsIntegration(t *testing.T) { srv, _ := mustDBServer(t) got := callTool(t, srv, "list_items", map[string]any{"limit": 5}, "tok") count, _ := got["count"].(float64) if count <= 0 { t.Fatalf("expected at least one item, got %v", got) } } func TestMCPGetItemMultiParent(t *testing.T) { srv, pool := mustDBServer(t) // Discover any item that lives under multiple parents in the live DB, then // confirm both resolved paths return the same uuid. Avoids hard-coding a // row that may move. var id, p1, p2 string err := pool.QueryRow(context.Background(), `select id::text, paths[1], paths[2] from projax.items_unified where cardinality(paths) >= 2 order by paths[1] limit 1`).Scan(&id, &p1, &p2) if err != nil { t.Skipf("no multi-parent items in DB: %v", err) } a := callTool(t, srv, "get_item", map[string]any{"path": p1}, "tok") b := callTool(t, srv, "get_item", map[string]any{"path": p2}, "tok") if a["id"] != b["id"] || a["id"] != id { t.Fatalf("multi-parent mismatch: %s -> %v, %s -> %v (expected %s)", p1, a["id"], p2, b["id"], id) } } func TestMCPCreateAndDeleteItem(t *testing.T) { srv, pool := mustDBServer(t) slug := "mcp-roundtrip-" + time.Now().UTC().Format("20060102150405") created := callTool(t, srv, "create_item", map[string]any{ "slug": slug, "title": "MCP round-trip test", "parent_paths": []string{"dev"}, "kind": []string{"project"}, "tags": []string{"test"}, "content_md": "ephemeral", }, "tok") id, _ := created["id"].(string) if id == "" { t.Fatalf("create_item returned no id: %v", created) } // Cleanup uses direct SQL so a test-body delete that already succeeded // doesn't trip a t.Fatalf in the helper. Soft-delete is idempotent. t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `update projax.items set deleted_at = now() where id = $1 and deleted_at is null`, id) }) got := callTool(t, srv, "get_item", map[string]any{"id": id}, "tok") if got["title"] != "MCP round-trip test" { t.Errorf("get_item title mismatch: %v", got) } search := callTool(t, srv, "search", map[string]any{"query": slug, "limit": 5}, "tok") if count, _ := search["count"].(float64); count < 1 { t.Errorf("search did not find %q: %v", slug, search) } del := callTool(t, srv, "delete_item", map[string]any{"id": id}, "tok") if del["deleted"] != id { t.Errorf("delete_item returned %v", del) } } func TestMCPUnauthorized(t *testing.T) { srv, _ := mustDBServer(t) mux := http.NewServeMux() srv.Routes(mux) s := httptest.NewServer(mux) t.Cleanup(s.Close) body, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", }) resp, err := http.Post(s.URL+"/rpc", "application/json", strings.NewReader(string(body))) if err != nil { t.Fatalf("post: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401 without bearer, got %d", resp.StatusCode) } }