From 485e7f475ddd9bc5e05a4c848e295e5f6bb0c93a Mon Sep 17 00:00:00 2001 From: Carlos Mora Date: Fri, 24 Jul 2026 07:09:27 -0500 Subject: [PATCH 1/3] feat(mcp): add minimal mem_find_project tool --- internal/mcp/mcp.go | 56 +++++++++++++++++++++++++++ internal/mcp/mcp_test.go | 24 ++++++------ internal/store/store.go | 59 ++++++++++++++++++++++++++++ internal/store/store_test.go | 74 ++++++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 12 deletions(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index e1fb4d161..36540ac4f 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -98,6 +98,7 @@ func ensureImplicitSessionWithCWD(s *store.Store, sessionID, project string) err var ProfileAgent = map[string]bool{ "mem_save": true, // proactive save — referenced 17 times across protocols "mem_search": true, // search past memories — referenced 6 times + "mem_find_project": true, // find projects by memory content "mem_context": true, // recent context from previous sessions — referenced 10 times "mem_session_summary": true, // end-of-session summary — referenced 16 times "mem_session_start": true, // register session start @@ -295,6 +296,28 @@ func registerTools(srv *server.MCPServer, s *store.Store, cfg MCPConfig, allowli ) } + // ─── mem_find_project ───────────────────────────────────────────── + if shouldRegister("mem_find_project", allowlist) { + srv.AddTool( + mcp.NewTool("mem_find_project", + mcp.WithDescription("Search for projects containing relevant memories. Use this when you don't know which project holds a past decision. It returns the top matching projects, their match counts, and rank. You can then use mem_search with a specific project name to read those memories."), + mcp.WithTitleAnnotation("Find Projects"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString("query", + mcp.Required(), + mcp.Description("Search query — natural language or keywords to find across all projects"), + ), + mcp.WithString("match_mode", + mcp.Description("Token matching: \"all\" (default — every token must match, FTS5 AND) or \"any\" (any token matches)."), + ), + ), + handleFindProject(s, cfg), + ) + } + // ─── mem_save (profile: agent, core — always in context) ─────────── if shouldRegister("mem_save", allowlist) { srv.AddTool( @@ -1148,6 +1171,39 @@ func handleSearch(s *store.Store, cfg MCPConfig, activity *SessionActivity) serv } } +func handleFindProject(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query, _ := req.GetArguments()["query"].(string) + matchMode, _ := req.GetArguments()["match_mode"].(string) + + if query == "" { + return mcp.NewToolResultError("query is required"), nil + } + if matchMode != "" && matchMode != "all" && matchMode != "any" { + return mcp.NewToolResultError(fmt.Sprintf("invalid match_mode %q: must be \"all\" or \"any\"", matchMode)), nil + } + + limit := 10 // Fix limit as requested by minimalist approach + matches, err := s.SearchProjects(query, matchMode, limit) + if err != nil { + return mcp.NewToolResultError("Project search failed: " + err.Error()), nil + } + + if len(matches) == 0 { + return mcp.NewToolResultText(fmt.Sprintf("No projects found matching %q.", query)), nil + } + + var b strings.Builder + fmt.Fprintf(&b, "Found %d project(s) matching %q:\n", len(matches), query) + for _, m := range matches { + fmt.Fprintf(&b, "- %s (%d matches, rank: %.2f)\n", m.Project, m.MatchCount, m.TopRank) + } + b.WriteString("\nUse mem_search with project: \"\" to explore these memories.") + + return mcp.NewToolResultText(b.String()), nil + } +} + func handlePin(s *store.Store, pinned bool) server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id := int64(intArg(req, "id", 0)) diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 89fc21ed8..9e6b643b3 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -1611,7 +1611,7 @@ func TestResolveToolsAgentProfile(t *testing.T) { } expectedTools := []string{ - "mem_save", "mem_search", "mem_context", "mem_session_summary", + "mem_save", "mem_search", "mem_find_project", "mem_context", "mem_session_summary", "mem_session_start", "mem_session_end", "mem_get_observation", "mem_suggest_topic_key", "mem_capture_passive", "mem_save_prompt", "mem_update", // skills explicitly say "use mem_update when you have an exact ID to correct" @@ -2254,7 +2254,7 @@ func TestNewServerWithToolsNilRegistersAll(t *testing.T) { tools := srv.ListTools() allTools := []string{ - "mem_save", "mem_search", "mem_context", "mem_session_summary", + "mem_save", "mem_search", "mem_find_project", "mem_context", "mem_session_summary", "mem_session_start", "mem_session_end", "mem_get_observation", "mem_suggest_topic_key", "mem_capture_passive", "mem_save_prompt", "mem_update", "mem_delete", "mem_stats", "mem_timeline", "mem_merge_projects", @@ -2364,14 +2364,14 @@ func TestNewServerBackwardsCompatible(t *testing.T) { srv := NewServer(s) tools := srv.ListTools() - // 18 agent + 4 admin = 22 total. - if len(tools) != 22 { - t.Errorf("NewServer should register all 22 tools, got %d", len(tools)) + // 19 agent + 4 admin = 23 total. + if len(tools) != 23 { + t.Errorf("NewServer should register all 23 tools, got %d", len(tools)) } } func TestProfileConsistency(t *testing.T) { - // Verify that agent + admin = all 22 tools + // Verify that agent + admin = all 23 tools combined := make(map[string]bool) for tool := range ProfileAgent { combined[tool] = true @@ -2380,9 +2380,9 @@ func TestProfileConsistency(t *testing.T) { combined[tool] = true } - // 18 agent + 4 admin = 22 total. - if len(combined) != 22 { - t.Errorf("agent + admin should cover all 22 tools, got %d", len(combined)) + // 19 agent + 4 admin = 23 total. + if len(combined) != 23 { + t.Errorf("agent + admin should cover all 23 tools, got %d", len(combined)) } // Verify no overlap between profiles @@ -2710,9 +2710,9 @@ func TestNewServerWithConfig(t *testing.T) { t.Fatal("expected MCP server instance") } tools := srv.ListTools() - // Should have all 22 tools (18 agent + 4 admin). - if len(tools) != 22 { - t.Errorf("NewServerWithConfig should register all 22 tools, got %d", len(tools)) + // Should have all 23 tools (19 agent + 4 admin). + if len(tools) != 23 { + t.Errorf("NewServerWithConfig should register all 23 tools, got %d", len(tools)) } } diff --git a/internal/store/store.go b/internal/store/store.go index 9c6537b98..83049f948 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -182,6 +182,12 @@ type SearchOptions struct { MatchMode string `json:"match_mode,omitempty"` // "all" (default) | "any" } +type ProjectMatch struct { + Project string + MatchCount int + TopRank float64 +} + type AddObservationParams struct { SessionID string `json:"session_id"` Type string `json:"type"` @@ -3235,6 +3241,59 @@ func (s *Store) Search(query string, opts SearchOptions) ([]SearchResult, error) return results, nil } +// ─── Search Projects ──────────────────────────────────────────────────────── + +// SearchProjects groups FTS5 search results by project to help route ambiguous searches. +func (s *Store) SearchProjects(query string, matchMode string, limit int) ([]ProjectMatch, error) { + if limit <= 0 { + limit = 10 + } + if limit > 50 { + limit = 50 + } + + var ftsQuery string + if matchMode == "any" { + ftsQuery = sanitizeFTSCandidates(query) + } else { + ftsQuery = sanitizeFTS(query) + } + if ftsQuery == "" { + return []ProjectMatch{}, nil + } + + sqlQ := ` + SELECT project, COUNT(id) as match_count, MIN(rank) as top_rank + FROM ( + SELECT o.project, o.id, observations_fts.rank as rank + FROM observations_fts + JOIN observations o ON o.id = observations_fts.rowid + WHERE observations_fts MATCH ? AND o.deleted_at IS NULL AND o.project != '' + ) + GROUP BY project + ORDER BY top_rank ASC, match_count DESC, project ASC + LIMIT ? + ` + rows, err := s.queryItHook(s.db, sqlQ, ftsQuery, limit) + if err != nil { + return nil, fmt.Errorf("search projects: %w", err) + } + defer rows.Close() + + var matches []ProjectMatch + for rows.Next() { + var p ProjectMatch + if err := rows.Scan(&p.Project, &p.MatchCount, &p.TopRank); err != nil { + return nil, err + } + matches = append(matches, p) + } + if err := rows.Err(); err != nil { + return nil, err + } + return matches, nil +} + // ─── Stats ─────────────────────────────────────────────────────────────────── func (s *Store) Stats() (*Stats, error) { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 5ed55ca63..5a7eca76a 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -8830,3 +8830,77 @@ func TestSanitizeFTS(t *testing.T) { }) } } + +func TestSearchProjects(t *testing.T) { + st := newTestStore(t) + + st.CreateSession("s1", "project-a", "/tmp/a") + st.CreateSession("s2", "project-b", "/tmp/b") + st.CreateSession("s3", "project-c", "/tmp/c") + + // Seed 3 observations for project A + _, err := st.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "bugfix", Title: "Fix auth token expiration", + Content: "The auth middleware was dropping tokens", Project: "project-a", + }) + if err != nil { t.Fatal(err) } + _, err = st.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "bugfix", Title: "Auth token validation", + Content: "Middleware should validate auth tokens", Project: "project-a", + }) + if err != nil { t.Fatal(err) } + _, err = st.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "bugfix", Title: "Minor fix", + Content: "Just a minor auth fix in the middleware", Project: "project-a", + }) + if err != nil { t.Fatal(err) } + + // Seed 1 highly relevant observation for project B + _, err = st.AddObservation(AddObservationParams{ + SessionID: "s2", Type: "bugfix", Title: "Auth middleware completely rewritten", + Content: "Auth middleware auth middleware auth middleware tokens", Project: "project-b", + }) + if err != nil { t.Fatal(err) } + + // Seed an irrelevant observation for project C + _, err = st.AddObservation(AddObservationParams{ + SessionID: "s3", Type: "feature", Title: "Database migration", + Content: "Added new tables", Project: "project-c", + }) + if err != nil { t.Fatal(err) } + + // Force FTS sync if async (test setup normally does this, but just in case) + // We'll just search directly. + + matches, err := st.SearchProjects("auth middleware", "all", 10) + if err != nil { + t.Fatalf("SearchProjects failed: %v", err) + } + + if len(matches) != 2 { + t.Fatalf("Expected 2 projects, got %d: %+v", len(matches), matches) + } + + // project-a should have 3 matches. project-b should have 1 match. + // project-b has more occurrences of the terms, so its top_rank might be better (more negative). + // Let's assert on the project names and counts. + hasA := false + hasB := false + for _, m := range matches { + if m.Project == "project-a" { + hasA = true + if m.MatchCount != 3 { + t.Errorf("project-a: expected 3 matches, got %d", m.MatchCount) + } + } + if m.Project == "project-b" { + hasB = true + if m.MatchCount != 1 { + t.Errorf("project-b: expected 1 match, got %d", m.MatchCount) + } + } + } + if !hasA || !hasB { + t.Errorf("Missing expected projects in results: %+v", matches) + } +} From c537a47cdfdb737a2d855a67fd7054cd6b4d2ebd Mon Sep 17 00:00:00 2001 From: Carlos Mora Date: Thu, 6 Aug 2026 06:26:04 -0500 Subject: [PATCH 2/3] test(mcp): expand project search coverage and fix rank formatting --- internal/mcp/mcp.go | 2 +- internal/mcp/mcp_test.go | 110 +++++++++++++++++++++++++++ internal/store/store_test.go | 142 ++++++++++++++++++++++++++++------- 3 files changed, 227 insertions(+), 27 deletions(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 36540ac4f..464937dcb 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -1196,7 +1196,7 @@ func handleFindProject(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc { var b strings.Builder fmt.Fprintf(&b, "Found %d project(s) matching %q:\n", len(matches), query) for _, m := range matches { - fmt.Fprintf(&b, "- %s (%d matches, rank: %.2f)\n", m.Project, m.MatchCount, m.TopRank) + fmt.Fprintf(&b, "- %s (%d matches, rank: %g)\n", m.Project, m.MatchCount, m.TopRank) } b.WriteString("\nUse mem_search with project: \"\" to explore these memories.") diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 9e6b643b3..1e7fe66a7 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -7452,3 +7452,113 @@ func TestHandleSearch_MatchModeInvalidError(t *testing.T) { t.Fatalf("parameter-validation error must not contain query-advice suffix \"Try simpler keywords\", got: %s", text) } } + +func TestHandleFindProject(t *testing.T) { + s := newMCPTestStore(t) + + s.CreateSession("s1", "project-one", "/tmp/one") + s.CreateSession("s2", "project-two", "/tmp/two") + + // Insert some observations in different projects to test SearchProjects + _, err := s.AddObservation(store.AddObservationParams{ + SessionID: "s1", Type: "bugfix", Title: "search test one", + Content: "project one search test", Project: "project-one", + }) + if err != nil { t.Fatal(err) } + _, err = s.AddObservation(store.AddObservationParams{ + SessionID: "s2", Type: "bugfix", Title: "search test two", + Content: "project two search test", Project: "project-two", + }) + if err != nil { t.Fatal(err) } + + h := handleFindProject(s, MCPConfig{}) + + tests := []struct { + name string + query string + matchMode string + expectError bool + errorContains string + expectText string + }{ + { + name: "success exact match", + query: "project one", + matchMode: "", + expectText: "Found 1 project(s)", + }, + { + name: "success match any", + query: "project one test", + matchMode: "any", + expectText: "project-two", // Both contain test + }, + { + name: "missing query", + query: "", + matchMode: "", + expectError: true, + errorContains: "query is required", + }, + { + name: "invalid match_mode", + query: "test", + matchMode: "invalid-mode", + expectError: true, + errorContains: "invalid match_mode", + }, + { + name: "no results", + query: "nonexistentstringthatwillnevermatch", + matchMode: "", + expectText: "No projects found matching", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := map[string]any{"query": tc.query} + if tc.matchMode != "" { + args["match_mode"] = tc.matchMode + } + req := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: args}} + res, err := h(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tc.expectError { + if !res.IsError { + t.Fatalf("expected error, got success") + } + text := callResultText(t, res) + if !strings.Contains(text, tc.errorContains) { + t.Fatalf("expected error containing %q, got %q", tc.errorContains, text) + } + } else { + if res.IsError { + t.Fatalf("expected success, got error: %s", callResultText(t, res)) + } + text := callResultText(t, res) + if tc.expectText != "" && !strings.Contains(text, tc.expectText) { + t.Fatalf("expected text containing %q, got %q", tc.expectText, text) + } + } + }) + } + + // Test error from store (e.g., closed db) + s.Close() + req := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{"query": "test"}}} + res, err := h(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.IsError { + t.Fatalf("expected tool error due to closed store") + } + text := callResultText(t, res) + if !strings.Contains(text, "Project search failed") { + t.Fatalf("expected error text for store failure, got: %s", text) + } +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 5a7eca76a..a7fb56256 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -8837,6 +8837,7 @@ func TestSearchProjects(t *testing.T) { st.CreateSession("s1", "project-a", "/tmp/a") st.CreateSession("s2", "project-b", "/tmp/b") st.CreateSession("s3", "project-c", "/tmp/c") + st.CreateSession("s4", "", "/tmp/d") // Seed 3 observations for project A _, err := st.AddObservation(AddObservationParams{ @@ -8869,38 +8870,127 @@ func TestSearchProjects(t *testing.T) { }) if err != nil { t.Fatal(err) } - // Force FTS sync if async (test setup normally does this, but just in case) - // We'll just search directly. + // Seed observation with blank project + _, err = st.AddObservation(AddObservationParams{ + SessionID: "s4", Type: "bugfix", Title: "Auth issue in blank project", + Content: "The auth middleware failed here too", Project: "", + }) + if err != nil { t.Fatal(err) } - matches, err := st.SearchProjects("auth middleware", "all", 10) - if err != nil { - t.Fatalf("SearchProjects failed: %v", err) - } + // Seed observation in project-d and then delete it + st.CreateSession("s5", "project-d", "/tmp/e") + obsID, err := st.AddObservation(AddObservationParams{ + SessionID: "s5", Type: "bugfix", Title: "Auth issue to be deleted", + Content: "The auth middleware will be deleted", Project: "project-d", + }) + if err != nil { t.Fatal(err) } + err = st.DeleteObservation(obsID, false) + if err != nil { t.Fatal(err) } - if len(matches) != 2 { - t.Fatalf("Expected 2 projects, got %d: %+v", len(matches), matches) + tests := []struct { + name string + query string + matchMode string + limit int + expectCounts map[string]int // project -> expected match count + expectErr bool + expectLen int + }{ + { + name: "match all default limit", + query: "auth middleware", + matchMode: "all", + limit: 10, + expectCounts: map[string]int{ + "project-a": 3, + "project-b": 1, + }, + expectLen: 2, + }, + { + name: "match any", + query: "auth tables", // auth matches a, b. tables matches c. + matchMode: "any", + limit: 10, + expectCounts: map[string]int{ + "project-a": 3, + "project-b": 1, + "project-c": 1, + }, + expectLen: 3, + }, + { + name: "empty query", + query: "", + matchMode: "all", + limit: 10, + expectLen: 0, + }, + { + name: "zero or negative limit gets default 10", + query: "auth", + matchMode: "all", + limit: -5, + expectLen: 2, + }, + { + name: "limit upper bound 50", + query: "auth", + matchMode: "all", + limit: 100, // Should be clamped to 50 + expectLen: 2, + }, + { + name: "limit restricts results", + query: "auth", + matchMode: "all", + limit: 1, // Should return only top 1 project + expectLen: 1, + }, + { + name: "no match", + query: "nonexistentstring", + matchMode: "all", + limit: 10, + expectLen: 0, + }, } - // project-a should have 3 matches. project-b should have 1 match. - // project-b has more occurrences of the terms, so its top_rank might be better (more negative). - // Let's assert on the project names and counts. - hasA := false - hasB := false - for _, m := range matches { - if m.Project == "project-a" { - hasA = true - if m.MatchCount != 3 { - t.Errorf("project-a: expected 3 matches, got %d", m.MatchCount) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + matches, err := st.SearchProjects(tc.query, tc.matchMode, tc.limit) + if tc.expectErr { + if err == nil { + t.Fatalf("expected error but got nil") + } + return } - } - if m.Project == "project-b" { - hasB = true - if m.MatchCount != 1 { - t.Errorf("project-b: expected 1 match, got %d", m.MatchCount) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - } + if len(matches) != tc.expectLen { + t.Fatalf("expected %d projects, got %d: %+v", tc.expectLen, len(matches), matches) + } + for _, m := range matches { + if m.Project == "" { + t.Errorf("expected no blank projects, got one") + } + if m.Project == "project-d" { + t.Errorf("expected deleted project-d to be excluded") + } + if expected, ok := tc.expectCounts[m.Project]; ok { + if m.MatchCount != expected { + t.Errorf("project %s: expected %d matches, got %d", m.Project, expected, m.MatchCount) + } + } + } + }) } - if !hasA || !hasB { - t.Errorf("Missing expected projects in results: %+v", matches) + + // Test error propagation + st.Close() + _, err = st.SearchProjects("auth", "all", 10) + if err == nil { + t.Fatalf("expected error from closed store, got nil") } } From d9896819a47401897915407d63337833f5921aea Mon Sep 17 00:00:00 2001 From: Carlos Mora Date: Fri, 14 Aug 2026 22:33:58 -0500 Subject: [PATCH 3/3] feat(mcp): add scope filtering to mem_find_project Add support for 'scope' filtering (all, project, personal) to the mem_find_project MCP tool and SearchProjects store method. This allows agents to filter project discovery by scope, ensuring symmetry with the existing mem_search scope filtering (REQ-391). --- internal/mcp/mcp.go | 9 ++++++++- internal/mcp/mcp_test.go | 33 ++++++++++++++++++++++++++++++++ internal/store/store.go | 22 ++++++++++++++++----- internal/store/store_test.go | 37 +++++++++++++++++++++++++++++++++--- 4 files changed, 92 insertions(+), 9 deletions(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 464937dcb..8bc0bafc4 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -313,6 +313,9 @@ func registerTools(srv *server.MCPServer, s *store.Store, cfg MCPConfig, allowli mcp.WithString("match_mode", mcp.Description("Token matching: \"all\" (default — every token must match, FTS5 AND) or \"any\" (any token matches)."), ), + mcp.WithString("scope", + mcp.Description("Filter search results by scope: \"project\" (only team/project workspace memories), \"personal\" (personal logs/diary), or \"all\" (default — search across both)."), + ), ), handleFindProject(s, cfg), ) @@ -1175,6 +1178,7 @@ func handleFindProject(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { query, _ := req.GetArguments()["query"].(string) matchMode, _ := req.GetArguments()["match_mode"].(string) + scope, _ := req.GetArguments()["scope"].(string) if query == "" { return mcp.NewToolResultError("query is required"), nil @@ -1182,9 +1186,12 @@ func handleFindProject(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc { if matchMode != "" && matchMode != "all" && matchMode != "any" { return mcp.NewToolResultError(fmt.Sprintf("invalid match_mode %q: must be \"all\" or \"any\"", matchMode)), nil } + if scope != "" && scope != "all" && scope != "project" && scope != "personal" { + return mcp.NewToolResultError(fmt.Sprintf("invalid scope %q: must be \"all\", \"project\", or \"personal\"", scope)), nil + } limit := 10 // Fix limit as requested by minimalist approach - matches, err := s.SearchProjects(query, matchMode, limit) + matches, err := s.SearchProjects(query, matchMode, scope, limit) if err != nil { return mcp.NewToolResultError("Project search failed: " + err.Error()), nil } diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 1e7fe66a7..4243ca6b6 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -7471,12 +7471,20 @@ func TestHandleFindProject(t *testing.T) { }) if err != nil { t.Fatal(err) } + // Insert a personal observation in project-one to test scope filtering + _, err = s.AddObservation(store.AddObservationParams{ + SessionID: "s1", Type: "bugfix", Title: "personal search test", + Content: "project one personal thoughts", Project: "project-one", Scope: "personal", + }) + if err != nil { t.Fatal(err) } + h := handleFindProject(s, MCPConfig{}) tests := []struct { name string query string matchMode string + scope string expectError bool errorContains string expectText string @@ -7507,12 +7515,34 @@ func TestHandleFindProject(t *testing.T) { expectError: true, errorContains: "invalid match_mode", }, + { + name: "invalid scope", + query: "test", + matchMode: "", + scope: "invalid-scope", + expectError: true, + errorContains: "invalid scope", + }, { name: "no results", query: "nonexistentstringthatwillnevermatch", matchMode: "", expectText: "No projects found matching", }, + { + name: "scope project filters out personal ones", + query: "thoughts", + matchMode: "", + scope: "project", + expectText: "No projects found matching", + }, + { + name: "scope personal finds only personal ones", + query: "thoughts", + matchMode: "", + scope: "personal", + expectText: "project-one", + }, } for _, tc := range tests { @@ -7521,6 +7551,9 @@ func TestHandleFindProject(t *testing.T) { if tc.matchMode != "" { args["match_mode"] = tc.matchMode } + if tc.scope != "" { + args["scope"] = tc.scope + } req := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: args}} res, err := h(context.Background(), req) if err != nil { diff --git a/internal/store/store.go b/internal/store/store.go index 83049f948..b42ac22c8 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -3244,7 +3244,7 @@ func (s *Store) Search(query string, opts SearchOptions) ([]SearchResult, error) // ─── Search Projects ──────────────────────────────────────────────────────── // SearchProjects groups FTS5 search results by project to help route ambiguous searches. -func (s *Store) SearchProjects(query string, matchMode string, limit int) ([]ProjectMatch, error) { +func (s *Store) SearchProjects(query string, matchMode string, scope string, limit int) ([]ProjectMatch, error) { if limit <= 0 { limit = 10 } @@ -3262,19 +3262,31 @@ func (s *Store) SearchProjects(query string, matchMode string, limit int) ([]Pro return []ProjectMatch{}, nil } - sqlQ := ` + var args []any + args = append(args, ftsQuery) + + scopeFilter := "" + if scope != "" && scope != "all" { + scopeFilter = " AND o.scope = ?" + args = append(args, normalizeScope(scope)) + } + + args = append(args, limit) + + sqlQ := fmt.Sprintf(` SELECT project, COUNT(id) as match_count, MIN(rank) as top_rank FROM ( SELECT o.project, o.id, observations_fts.rank as rank FROM observations_fts JOIN observations o ON o.id = observations_fts.rowid - WHERE observations_fts MATCH ? AND o.deleted_at IS NULL AND o.project != '' + WHERE observations_fts MATCH ? AND o.deleted_at IS NULL AND o.project != ''%s ) GROUP BY project ORDER BY top_rank ASC, match_count DESC, project ASC LIMIT ? - ` - rows, err := s.queryItHook(s.db, sqlQ, ftsQuery, limit) + `, scopeFilter) + + rows, err := s.queryItHook(s.db, sqlQ, args...) if err != nil { return nil, fmt.Errorf("search projects: %w", err) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index a7fb56256..2392b33ea 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -8887,11 +8887,19 @@ func TestSearchProjects(t *testing.T) { err = st.DeleteObservation(obsID, false) if err != nil { t.Fatal(err) } + // Seed a personal observation for project-a to test scope filtering + _, err = st.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "bugfix", Title: "Personal observation", + Content: "My personal auth thoughts", Project: "project-a", Scope: "personal", + }) + if err != nil { t.Fatal(err) } + tests := []struct { name string query string matchMode string limit int + scope string expectCounts map[string]int // project -> expected match count expectErr bool expectLen int @@ -8913,7 +8921,7 @@ func TestSearchProjects(t *testing.T) { matchMode: "any", limit: 10, expectCounts: map[string]int{ - "project-a": 3, + "project-a": 4, // 3 project + 1 personal "project-b": 1, "project-c": 1, }, @@ -8954,11 +8962,34 @@ func TestSearchProjects(t *testing.T) { limit: 10, expectLen: 0, }, + { + name: "scope project filters out personal ones", + query: "auth", + matchMode: "all", + scope: "project", + limit: 10, + expectCounts: map[string]int{ + "project-a": 3, // personal one excluded + "project-b": 1, + }, + expectLen: 2, + }, + { + name: "scope personal filters only personal ones", + query: "auth", + matchMode: "all", + scope: "personal", + limit: 10, + expectCounts: map[string]int{ + "project-a": 1, // only personal one included + }, + expectLen: 1, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - matches, err := st.SearchProjects(tc.query, tc.matchMode, tc.limit) + matches, err := st.SearchProjects(tc.query, tc.matchMode, tc.scope, tc.limit) if tc.expectErr { if err == nil { t.Fatalf("expected error but got nil") @@ -8989,7 +9020,7 @@ func TestSearchProjects(t *testing.T) { // Test error propagation st.Close() - _, err = st.SearchProjects("auth", "all", 10) + _, err = st.SearchProjects("auth", "all", "", 10) if err == nil { t.Fatalf("expected error from closed store, got nil") }