Skip to content

Commit 8572633

Browse files
feat(mcp): add all_projects flag to mem_search (#408)
Adds an optional boolean argument to mem_search that bypasses project detection and runs a global FTS5 search. Resolves the case where an agent needs to recall context from a project whose key it does not know. When all_projects is true, the handler skips resolveReadProjectWithProcessOverride entirely and passes an empty project to Store.Search. The project argument is ignored in this mode; documented and tested. The response envelope reports project_source="all_projects" and an empty project so callers can distinguish cross-project results from a scoped query. Adds a new SourceAllProjects constant in internal/project to keep the envelope contract typed and explicit. Closes #303
1 parent 7af10b9 commit 8572633

4 files changed

Lines changed: 161 additions & 14 deletions

File tree

DOCS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,8 @@ Returns success even when cwd is ambiguous — empty `project` + non-empty `avai
760760

761761
Search persistent memory across all sessions. Supports FTS5 full-text search with type/project/scope/limit filters.
762762

763+
Set `all_projects: true` to search across every project instead of the resolved one. This bypasses project detection entirely and ignores the `project` argument, so an agent can recall a decision logged elsewhere without knowing the project key. The response envelope reports `project_source: "all_projects"` and an empty `project` to reflect the cross-project scope.
764+
763765
When an observation has judged relations in `memory_relations`, the result entry includes annotation lines immediately after the title/content block:
764766

765767
```

internal/mcp/mcp.go

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,10 @@ func registerTools(srv *server.MCPServer, s *store.Store, cfg MCPConfig, allowli
271271
mcp.Description("Filter by type: tool_use, file_change, command, file_read, search, manual, decision, architecture, bugfix, pattern"),
272272
),
273273
mcp.WithString("project",
274-
mcp.Description("Filter by project name"),
274+
mcp.Description("Filter by project name. Ignored when all_projects=true."),
275+
),
276+
mcp.WithBoolean("all_projects",
277+
mcp.Description("Search across every project instead of the current one. When true, the project argument is ignored and results may come from any project. Useful for recalling decisions logged elsewhere when you don't know the project key."),
275278
),
276279
mcp.WithString("scope",
277280
mcp.Description("Filter by scope: project (default) or personal"),
@@ -899,23 +902,35 @@ func handleSearch(s *store.Store, cfg MCPConfig, activity *SessionActivity) serv
899902
typ, _ := req.GetArguments()["type"].(string)
900903
projectOverride, _ := req.GetArguments()["project"].(string)
901904
scope, _ := req.GetArguments()["scope"].(string)
905+
allProjects := boolArg(req, "all_projects", false)
902906
limit := intArg(req, "limit", 10)
903907

904-
// Resolve project: validate override or auto-detect (REQ-310, REQ-311)
905-
detRes, err := resolveReadProjectWithProcessOverride(s, projectOverride, cfg.DefaultProject)
906-
if err != nil {
907-
var upe *unknownProjectError
908-
if errors.As(err, &upe) {
909-
return errorWithMeta("unknown_project",
910-
fmt.Sprintf("Project %q not found in store", upe.Name),
911-
upe.AvailableProjects,
912-
), nil
908+
// all_projects=true short-circuits project resolution: we search globally
909+
// regardless of the project override or any auto-detected project. This
910+
// keeps the cross-project flow independent of cwd-based detection so the
911+
// agent can recall context from any project without knowing its key.
912+
var detRes projectpkg.DetectionResult
913+
var project string
914+
if allProjects {
915+
detRes = projectpkg.DetectionResult{Source: projectpkg.SourceAllProjects}
916+
} else {
917+
// Resolve project: validate override or auto-detect (REQ-310, REQ-311)
918+
res, err := resolveReadProjectWithProcessOverride(s, projectOverride, cfg.DefaultProject)
919+
if err != nil {
920+
var upe *unknownProjectError
921+
if errors.As(err, &upe) {
922+
return errorWithMeta("unknown_project",
923+
fmt.Sprintf("Project %q not found in store", upe.Name),
924+
upe.AvailableProjects,
925+
), nil
926+
}
927+
return mcp.NewToolResultError(fmt.Sprintf("Project resolution failed: %s", err)), nil
913928
}
914-
return mcp.NewToolResultError(fmt.Sprintf("Project resolution failed: %s", err)), nil
929+
detRes = res
930+
project = detRes.Project
931+
project, _ = store.NormalizeProject(project)
932+
detRes.Project = project // JR2-1: keep envelope in sync with normalized query project
915933
}
916-
project := detRes.Project
917-
project, _ = store.NormalizeProject(project)
918-
detRes.Project = project // JR2-1: keep envelope in sync with normalized query project
919934

920935
sessionID := defaultSessionID(project)
921936
activity.RecordToolCall(sessionID)

internal/mcp/mcp_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6519,3 +6519,132 @@ func TestProcessOverrideSaveHandlerWritesToDefaultProject(t *testing.T) {
65196519
t.Fatalf("results in trusted project = %d; want 1", len(results))
65206520
}
65216521
}
6522+
6523+
// seedCrossProjectMemories inserts one observation per project so cross-project
6524+
// search tests have something to find. Returns the session IDs created.
6525+
func seedCrossProjectMemories(t *testing.T, s *store.Store) {
6526+
t.Helper()
6527+
type seed struct {
6528+
session string
6529+
project string
6530+
title string
6531+
content string
6532+
}
6533+
seeds := []seed{
6534+
{"s-alpha", "alpha", "Auth middleware in alpha", "JWT auth middleware decided here"},
6535+
{"s-beta", "beta", "Auth middleware in beta", "Different auth approach for beta"},
6536+
{"s-gamma", "gamma", "Logging only", "Nothing about authentication here"},
6537+
}
6538+
for _, sd := range seeds {
6539+
if err := s.CreateSession(sd.session, sd.project, "/tmp/"+sd.project); err != nil {
6540+
t.Fatalf("create session %s: %v", sd.session, err)
6541+
}
6542+
if _, err := s.AddObservation(store.AddObservationParams{
6543+
SessionID: sd.session,
6544+
Type: "decision",
6545+
Title: sd.title,
6546+
Content: sd.content,
6547+
Project: sd.project,
6548+
Scope: "project",
6549+
}); err != nil {
6550+
t.Fatalf("add observation %s: %v", sd.project, err)
6551+
}
6552+
}
6553+
}
6554+
6555+
func TestHandleSearchAllProjectsReturnsResultsFromEveryProject(t *testing.T) {
6556+
s := newMCPTestStore(t)
6557+
seedCrossProjectMemories(t, s)
6558+
6559+
search := handleSearch(s, MCPConfig{}, NewSessionActivity(10*time.Minute))
6560+
req := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
6561+
"query": "auth middleware",
6562+
"all_projects": true,
6563+
"limit": 5.0,
6564+
}}}
6565+
6566+
res, err := search(context.Background(), req)
6567+
if err != nil {
6568+
t.Fatalf("search handler error: %v", err)
6569+
}
6570+
if res.IsError {
6571+
t.Fatalf("unexpected search error: %s", callResultText(t, res))
6572+
}
6573+
6574+
text := callResultText(t, res)
6575+
if !strings.Contains(text, "alpha") {
6576+
t.Fatalf("expected result from alpha, got: %s", text)
6577+
}
6578+
if !strings.Contains(text, "beta") {
6579+
t.Fatalf("expected result from beta, got: %s", text)
6580+
}
6581+
6582+
// Envelope must reflect cross-project search, not a single resolved project.
6583+
var envelope map[string]any
6584+
if err := json.Unmarshal([]byte(text), &envelope); err != nil {
6585+
t.Fatalf("envelope is not JSON: %v\n%s", err, text)
6586+
}
6587+
if got := envelope["project_source"]; got != project.SourceAllProjects {
6588+
t.Fatalf("project_source = %v; want %q", got, project.SourceAllProjects)
6589+
}
6590+
if got := envelope["project"]; got != "" {
6591+
t.Fatalf("project = %v; want empty string for cross-project search", got)
6592+
}
6593+
}
6594+
6595+
func TestHandleSearchAllProjectsOverridesProjectArg(t *testing.T) {
6596+
s := newMCPTestStore(t)
6597+
seedCrossProjectMemories(t, s)
6598+
6599+
search := handleSearch(s, MCPConfig{}, NewSessionActivity(10*time.Minute))
6600+
// Pass both project="alpha" and all_projects=true: all_projects must win.
6601+
req := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
6602+
"query": "auth middleware",
6603+
"project": "alpha",
6604+
"all_projects": true,
6605+
"limit": 5.0,
6606+
}}}
6607+
6608+
res, err := search(context.Background(), req)
6609+
if err != nil {
6610+
t.Fatalf("search handler error: %v", err)
6611+
}
6612+
if res.IsError {
6613+
t.Fatalf("unexpected search error: %s", callResultText(t, res))
6614+
}
6615+
6616+
text := callResultText(t, res)
6617+
if !strings.Contains(text, "beta") {
6618+
t.Fatalf("expected result from beta even when project=alpha was supplied; got: %s", text)
6619+
}
6620+
}
6621+
6622+
func TestHandleSearchWithoutAllProjectsStillScopesToCurrentProject(t *testing.T) {
6623+
s := newMCPTestStore(t)
6624+
seedCrossProjectMemories(t, s)
6625+
6626+
search := handleSearch(s, MCPConfig{}, NewSessionActivity(10*time.Minute))
6627+
// Default behavior: project="alpha", no all_projects flag → only alpha matches.
6628+
req := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{
6629+
"query": "auth middleware",
6630+
"project": "alpha",
6631+
"limit": 5.0,
6632+
}}}
6633+
6634+
res, err := search(context.Background(), req)
6635+
if err != nil {
6636+
t.Fatalf("search handler error: %v", err)
6637+
}
6638+
if res.IsError {
6639+
t.Fatalf("unexpected search error: %s", callResultText(t, res))
6640+
}
6641+
6642+
text := callResultText(t, res)
6643+
if !strings.Contains(text, "alpha") {
6644+
t.Fatalf("expected result from alpha, got: %s", text)
6645+
}
6646+
if strings.Contains(text, "beta") {
6647+
t.Fatalf("beta result should not leak into a scoped search; got: %s", text)
6648+
}
6649+
}
6650+

internal/project/detect.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const (
4040
SourceUserSelectedAfterAmbiguousProject = "user_selected_after_ambiguous_project"
4141
SourceRequestBody = "request_body" // REQ-414: project came from the request body (server-side, no filesystem path)
4242
SourceConfig = "config" // derived from .engram/config.json project_name
43+
SourceAllProjects = "all_projects" // caller asked for cross-project search (no single project resolved)
4344
)
4445

4546
// noiseSet lists directory names that are skipped during child-repo scanning.

0 commit comments

Comments
 (0)