From a8fb2777d590489ddbf3d736bc6a872b3177fe91 Mon Sep 17 00:00:00 2001 From: Daniel Quero Date: Mon, 15 Jun 2026 15:49:12 +0200 Subject: [PATCH 1/4] feat(mcp): support project override in mem_session_summary --- internal/mcp/mcp.go | 39 +++++++++++++++---- internal/mcp/mcp_test.go | 82 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 108 insertions(+), 13 deletions(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 336f334e..5854779e 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -670,7 +670,15 @@ GUIDELINES: mcp.WithString("session_id", mcp.Description("Session ID (default: manual-save-{project})"), ), - // project field intentionally omitted — auto-detect only (REQ-308 write-tool contract) + mcp.WithString("project", + mcp.Description("Optional explicit project for this memory. Accepted only when backed by known context (existing project, matching session, repo config, or ambiguous-project recovery); invalid or unbacked names fail loudly."), + ), + mcp.WithString("project_choice_reason", + mcp.Description("Must be user_selected_after_ambiguous_project, and only after the user explicitly chose one of available_projects from an ambiguous_project error."), + ), + mcp.WithString("recovery_token", + mcp.Description("Short-lived token returned by an ambiguous_project error. Required with project_choice_reason=user_selected_after_ambiguous_project."), + ), ), queuedWriteHandler(writeQueue, handleSessionSummary(s, cfg, activity)), ) @@ -1847,21 +1855,36 @@ func handleSessionSummary(s *store.Store, cfg MCPConfig, activity *SessionActivi return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { content, _ := req.GetArguments()["content"].(string) sessionID, _ := req.GetArguments()["session_id"].(string) - // project field intentionally not read — auto-detect only (REQ-308 write-tool contract) + projectChoice, _ := req.GetArguments()["project"].(string) + _, explicitProjectProvided := req.GetArguments()["project"] + projectChoiceReason, _ := req.GetArguments()["project_choice_reason"].(string) + recoveryToken, _ := req.GetArguments()["recovery_token"].(string) // Reject empty/whitespace-only content before any project resolution (#393). if strings.TrimSpace(content) == "" { return mcp.NewToolResultError("content is required for mem_session_summary"), nil } - // Honour process-level project override (cfg.DefaultProject) set via - // ENGRAM_PROJECT or `engram mcp --project` (#403/#413). Falls back to cwd - // detection when no override is configured. - detRes, err := resolveWriteProjectWithProcessOverride(cfg.DefaultProject) + recoverySessionID := sessionID + if strings.TrimSpace(recoverySessionID) == "" { + recoverySessionID = defaultSessionID("") + } + validateRecoveryToken := func(res projectpkg.DetectionResult, choice string) (bool, bool) { + if strings.TrimSpace(recoveryToken) == "" { + return false, false + } + return true, activity.ValidateAmbiguousProjectRecoveryToken(recoverySessionID, recoveryToken, strings.TrimSpace(choice), res.AvailableProjects, res.Path) + } + + // Resolve write project using the full MCP precedence: explicit request, + // existing session association, process override, repo config/directory detection, then cwd fallback. + detRes, err := resolveSaveWriteProjectWithProcessOverride(s, projectChoice, explicitProjectProvided, projectChoiceReason, sessionID, validateRecoveryToken, cfg.DefaultProject) if err != nil { - return writeProjectErrorResult(nil, "", detRes, err), nil + return writeProjectErrorResult(activity, recoverySessionID, detRes, err), nil } - project, _ := store.NormalizeProject(detRes.Project) + project := detRes.Project + + project, _ = store.NormalizeProject(project) if sessionID == "" { sessionID = resolveFallbackSessionID(s, project) diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index d09dbc92..de534cb9 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -5623,9 +5623,9 @@ func TestHandleTimeline_ExplicitUnknownProjectError(t *testing.T) { // ─── F2: mem_session_summary schema + auto-detect tests ────────────────────── -// TestMemSessionSummary_SchemaNoProjectField: mem_session_summary must NOT have -// 'project' in its input schema (mirrors REQ-308 write-tool contract). -func TestMemSessionSummary_SchemaNoProjectField(t *testing.T) { +// TestMemSessionSummary_SchemaIncludesProjectField: mem_session_summary must have +// 'project', 'project_choice_reason', and 'recovery_token' in its input schema. +func TestMemSessionSummary_SchemaIncludesProjectField(t *testing.T) { s := newMCPTestStore(t) srv := NewServer(s) @@ -5634,8 +5634,80 @@ func TestMemSessionSummary_SchemaNoProjectField(t *testing.T) { t.Fatal("mem_session_summary not registered") } props := st.Tool.InputSchema.Properties - if _, hasProject := props["project"]; hasProject { - t.Error("mem_session_summary must not have 'project' in schema (write tool — auto-detect only)") + if _, hasProject := props["project"]; !hasProject { + t.Error("mem_session_summary must have 'project' in schema") + } + if _, hasReason := props["project_choice_reason"]; !hasReason { + t.Error("mem_session_summary must have 'project_choice_reason' in schema") + } + if _, hasToken := props["recovery_token"]; !hasToken { + t.Error("mem_session_summary must have 'recovery_token' in schema") + } +} + +// TestMemSessionSummary_ExplicitProjectOverride: summary is stored under the explicit project provided. +func TestMemSessionSummary_ExplicitProjectOverride(t *testing.T) { + dir := t.TempDir() + initTestGitRepo(t, dir) + t.Chdir(dir) + + s := newMCPTestStore(t) + if err := s.EnrollProject("explicit-summary-project"); err != nil { + t.Fatal(err) + } + h := handleSessionSummary(s, MCPConfig{}, NewSessionActivity(10*time.Minute)) + + res, err := h(context.Background(), mcppkg.CallToolRequest{ + Params: mcppkg.CallToolParams{Arguments: map[string]any{ + "content": "## Goal\nTest explicit project override", + "project": "explicit-summary-project", + }}, + }) + if err != nil || res.IsError { + t.Fatalf("session summary: err=%v isError=%v text=%q", err, res.IsError, callResultText(t, res)) + } + + obs, err := s.RecentObservations("explicit-summary-project", "project", 5) + if err != nil || len(obs) == 0 { + t.Fatal("expected session_summary observation under explicit project 'explicit-summary-project'") + } + + m := callResultJSON(t, res) + if got := m["project"]; got != "explicit-summary-project" { + t.Errorf("response envelope project = %v; want 'explicit-summary-project'", got) + } +} + +// TestMemSessionSummary_ResolveViaSessionID: summary is stored under the project associated with the session. +func TestMemSessionSummary_ResolveViaSessionID(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + s := newMCPTestStore(t) + if err := s.CreateSession("active-sess", "session-linked-project", "/tmp"); err != nil { + t.Fatal(err) + } + + h := handleSessionSummary(s, MCPConfig{}, NewSessionActivity(10*time.Minute)) + + res, err := h(context.Background(), mcppkg.CallToolRequest{ + Params: mcppkg.CallToolParams{Arguments: map[string]any{ + "content": "## Goal\nTest project via session_id", + "session_id": "active-sess", + }}, + }) + if err != nil || res.IsError { + t.Fatalf("session summary: err=%v isError=%v text=%q", err, res.IsError, callResultText(t, res)) + } + + obs, err := s.RecentObservations("session-linked-project", "project", 5) + if err != nil || len(obs) == 0 { + t.Fatal("expected session_summary observation under session project 'session-linked-project'") + } + + m := callResultJSON(t, res) + if got := m["project"]; got != "session-linked-project" { + t.Errorf("response envelope project = %v; want 'session-linked-project'", got) } } From b0670c9c745cd09eee2fb6e39b634147da1397e1 Mon Sep 17 00:00:00 2001 From: Daniel Quero Date: Mon, 15 Jun 2026 16:23:10 +0200 Subject: [PATCH 2/4] docs: document project override in mem_session_summary --- DOCS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DOCS.md b/DOCS.md index a5b04ddc..25e38171 100644 --- a/DOCS.md +++ b/DOCS.md @@ -726,7 +726,7 @@ Exceptions: ### Write tools (explicit/session/cwd project resolution) -`mem_session_start` resolves from its explicit `directory` argument when supplied; otherwise it auto-detects from cwd. `mem_session_end`, `mem_session_summary`, and `mem_capture_passive` auto-detect project from cwd. Any `project` argument the LLM sends to these tools is ignored. +`mem_session_start` resolves from its explicit `directory` argument when supplied; otherwise it auto-detects from cwd. `mem_session_end` and `mem_capture_passive` auto-detect project from cwd; any `project` argument the LLM sends to them is ignored. `mem_session_summary` supports explicit project override (`project`, `project_choice_reason`, `recovery_token`) matching `mem_save`'s project resolution. `mem_update` uses ID-based updates and auto-detects project only for response envelope metadata. Its public schema does not expose `project`; raw legacy clients may still send a non-empty `project` argument, and the handler tolerates it as an observation project update for compatibility. From 066b11f5e1b4695f9d06a1288c88c23b867d8198 Mon Sep 17 00:00:00 2001 From: Daniel Quero Date: Mon, 15 Jun 2026 16:45:13 +0200 Subject: [PATCH 3/4] docs(mcp): add missing doc comments --- internal/mcp/activity.go | 4 ++++ internal/mcp/mcp.go | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/internal/mcp/activity.go b/internal/mcp/activity.go index 08365cb9..5f9e674e 100644 --- a/internal/mcp/activity.go +++ b/internal/mcp/activity.go @@ -82,6 +82,8 @@ func (a *SessionActivity) ClearSession(sessionID string) { delete(a.sessions, sessionID) } +// IssueAmbiguousProjectRecoveryToken generates and registers a new short-lived recovery token +// for resolving ambiguous projects. func (a *SessionActivity) IssueAmbiguousProjectRecoveryToken(sessionID string, availableProjects []string, contextPath string) string { if a == nil { return "" @@ -103,6 +105,8 @@ func (a *SessionActivity) IssueAmbiguousProjectRecoveryToken(sessionID string, a return token } +// ValidateAmbiguousProjectRecoveryToken verifies that a recovery token is valid, has not expired, +// and matches the expected session, project choices, and path. func (a *SessionActivity) ValidateAmbiguousProjectRecoveryToken(sessionID, token, selectedProject string, availableProjects []string, contextPath string) bool { if a == nil || token == "" || selectedProject == "" { return false diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 5854779e..a422eee0 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -254,6 +254,7 @@ func shouldRegister(name string, allowlist map[string]bool) bool { return allowlist[name] } +// registerTools registers all enabled MCP tools on the given server. func registerTools(srv *server.MCPServer, s *store.Store, cfg MCPConfig, allowlist map[string]bool, activity *SessionActivity) { writeQueue := newWriteQueue(defaultMCPWriteQueueSize) @@ -1659,6 +1660,7 @@ func handleContext(s *store.Store, cfg MCPConfig, activity *SessionActivity) ser } } +// handleStats returns a tool handler function for mem_stats. func handleStats(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { projectOverride, _ := req.GetArguments()["project"].(string) @@ -1695,6 +1697,7 @@ func handleStats(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc { } } +// DoctorToolHandler returns a tool handler function for mem_doctor. func DoctorToolHandler(s *store.Store) server.ToolHandlerFunc { return handleDoctor(s, MCPConfig{}) } @@ -1802,6 +1805,7 @@ func handleTimeline(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc { } } +// handleGetObservation returns a tool handler function for mem_get_observation. func handleGetObservation(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id := int64(intArg(req, "id", 0)) @@ -1851,6 +1855,9 @@ func handleGetObservation(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc } } +// handleSessionSummary returns a tool handler function that saves a comprehensive +// end-of-session summary memory. It supports explicit project override matching +// the precedence of mem_save. func handleSessionSummary(s *store.Store, cfg MCPConfig, activity *SessionActivity) server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { content, _ := req.GetArguments()["content"].(string) @@ -1869,6 +1876,7 @@ func handleSessionSummary(s *store.Store, cfg MCPConfig, activity *SessionActivi if strings.TrimSpace(recoverySessionID) == "" { recoverySessionID = defaultSessionID("") } + // validateRecoveryToken verifies if a recovery token matches the request. validateRecoveryToken := func(res projectpkg.DetectionResult, choice string) (bool, bool) { if strings.TrimSpace(recoveryToken) == "" { return false, false @@ -2389,6 +2397,8 @@ func resolveWriteProjectWithChoice(projectChoice, reason string, validateToken a return res, nil } +// resolveSaveWriteProjectWithProcessOverride resolves the write project target +// by applying the process-level project override before falling back to full precedence resolution. func resolveSaveWriteProjectWithProcessOverride(s *store.Store, projectChoice string, explicitProjectProvided bool, reason, sessionID string, validateToken ambiguousRecoveryTokenValidator, defaultProject string) (projectpkg.DetectionResult, error) { if !explicitProjectProvided && strings.TrimSpace(projectChoice) == "" && strings.TrimSpace(sessionID) == "" && strings.TrimSpace(reason) == "" { if processRes, ok := processProjectResult(defaultProject); ok { @@ -2398,6 +2408,8 @@ func resolveSaveWriteProjectWithProcessOverride(s *store.Store, projectChoice st return resolveSaveWriteProject(s, projectChoice, explicitProjectProvided, reason, sessionID, validateToken) } +// resolveSaveWriteProject resolves the write project target using the full MCP precedence: +// explicit request parameter, existing session association, or nearest configuration/directory detection. func resolveSaveWriteProject(s *store.Store, projectChoice string, explicitProjectProvided bool, reason, sessionID string, validateToken ambiguousRecoveryTokenValidator) (projectpkg.DetectionResult, error) { trimmedSessionID := strings.TrimSpace(sessionID) trimmedProjectChoice := strings.TrimSpace(projectChoice) @@ -2779,6 +2791,8 @@ func respondWithProject(res projectpkg.DetectionResult, text string, extra map[s return mcp.NewToolResultText(string(out)) } +// writeProjectErrorResult formats and returns a structured error result when project +// resolution fails. It handles ambiguous project errors and invalid configs. func writeProjectErrorResult(activity *SessionActivity, sessionID string, res projectpkg.DetectionResult, err error) *mcp.CallToolResult { code := "ambiguous_project" if errors.Is(err, projectpkg.ErrInvalidConfig) { From 19a3d7cf623abea31e8cf985d2da85891407210a Mon Sep 17 00:00:00 2001 From: Daniel Quero Date: Mon, 15 Jun 2026 19:52:36 +0200 Subject: [PATCH 4/4] fix(mcp): update ambiguous project hint to include mem_session_summary --- internal/mcp/mcp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index a422eee0..d726d20f 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -2908,7 +2908,7 @@ func errorWithMeta(code, msg string, availableProjects []string) *mcp.CallToolRe } switch code { case "ambiguous_project": - envelope["hint"] = "Ask the user to choose one of available_projects, then retry mem_save or mem_save_prompt with project and project_choice_reason=user_selected_after_ambiguous_project; alternatively cd into the target repo or add repo .engram/config.json." + envelope["hint"] = "Ask the user to choose one of available_projects, then retry the same write tool (mem_save, mem_save_prompt, or mem_session_summary) with project and project_choice_reason=user_selected_after_ambiguous_project; alternatively cd into the target repo or add repo .engram/config.json." case "invalid_project_choice": envelope["hint"] = "Use exactly one of available_projects after asking the user, or cd into the target repo, or add repo .engram/config.json." case "missing_recovery_token":