Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions internal/mcp/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand All @@ -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
Expand Down
55 changes: 46 additions & 9 deletions internal/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -670,7 +671,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)),
)
Expand Down Expand Up @@ -1651,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)
Expand Down Expand Up @@ -1687,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{})
}
Expand Down Expand Up @@ -1794,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))
Expand Down Expand Up @@ -1843,25 +1855,44 @@ 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)
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 verifies if a recovery token matches the request.
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)
Expand Down Expand Up @@ -2366,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 {
Expand All @@ -2375,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)
Expand Down Expand Up @@ -2756,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) {
Expand Down Expand Up @@ -2871,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":
Expand Down
82 changes: 77 additions & 5 deletions internal/mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
}
}

Expand Down