Skip to content

Commit 99d3018

Browse files
Merge pull request #307 from Gentleman-Programming/fix/mcp-ambiguous-project-choice
fix(mcp): recover ambiguous project writes
2 parents 9f697c8 + 31e7a5b commit 99d3018

4 files changed

Lines changed: 459 additions & 12 deletions

File tree

docs/AGENT-SETUP.md

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Engram works with **any MCP-compatible agent**. Pick your agent below.
2727

2828
### Project auto-detection (important)
2929

30-
**Do not pass `project` to write tools.** Engram auto-detects the project from the server's working directory (cwd) using `.engram/config.json`, git remote URL, repo root name, or directory basename. Agents that include `project` in `mem_save` or similar calls will have that argument silently discarded.
30+
**Do not pass `project` to write tools during normal operation.** Engram auto-detects the project from the server's working directory (cwd) using `.engram/config.json`, git remote URL, repo root name, or directory basename. Agents that include `project` in `mem_save` or similar calls will have that argument ignored unless they are using the explicit ambiguous-project recovery flow below.
3131

3232
To lock write tools to the canonical project for a repo, add `.engram/config.json` at the repo root:
3333

@@ -41,6 +41,76 @@ When present, `project_name` is used for writes from the repo and its subdirecto
4141

4242
**Recommended first call:** `mem_current_project` — confirms which project Engram detected before you start writing. Returns `project_source` (how it was detected) and `available_projects` (if cwd is ambiguous).
4343

44+
If a write tool returns `ambiguous_project`, the agent must not guess. This happens when the MCP server is started from a parent directory that contains multiple repositories, for example:
45+
46+
```text
47+
/Users/you/work
48+
├── alan-thegentleman/
49+
├── angular-18-jest-playwright/
50+
└── engram/
51+
```
52+
53+
The first write fails with an error like:
54+
55+
```json
56+
{
57+
"error_code": "ambiguous_project",
58+
"available_projects": [
59+
"alan-thegentleman",
60+
"angular-18-jest-playwright",
61+
"engram"
62+
]
63+
}
64+
```
65+
66+
Ask the user to choose exactly one value from `available_projects`, then retry only `mem_save` or `mem_save_prompt` with both recovery fields:
67+
68+
```json
69+
{
70+
"project": "chosen-project-from-available-projects",
71+
"project_choice_reason": "user_selected_after_ambiguous_project"
72+
}
73+
```
74+
75+
On success, Engram writes to the selected project and reports the recovery source:
76+
77+
```json
78+
{
79+
"project": "engram",
80+
"project_source": "user_selected_after_ambiguous_project",
81+
"project_path": "/Users/you/work/engram"
82+
}
83+
```
84+
85+
### Ambiguous-project recovery rules
86+
87+
This is a narrow rescue path, not a free-form project override:
88+
89+
- Recovery is accepted only after cwd detection failed with `ambiguous_project`.
90+
- `project_choice_reason` must be exactly `user_selected_after_ambiguous_project`.
91+
- `project`, after trimming surrounding whitespace, must exactly match one of the reported `available_projects`.
92+
- Normalized variants and guesses are rejected: if `available_projects` contains `foo--bar`, retry with `foo--bar`, not `foo-bar`.
93+
- Empty or whitespace-only choices are rejected.
94+
- In all non-ambiguous cases, `.engram/config.json`/git/cwd detection remains authoritative and the explicit `project` field is ignored.
95+
96+
Mental model:
97+
98+
```text
99+
mem_save fails with ambiguous_project
100+
101+
Engram returns available_projects
102+
103+
agent asks the user to choose one exact value
104+
105+
agent retries with project + project_choice_reason
106+
107+
Engram validates the choice came from ambiguity
108+
109+
Engram saves to the selected project
110+
```
111+
112+
Alternatives: `cd` into the target repo before starting the MCP server, or add repo `.engram/config.json`.
113+
44114
**Read tools** (`mem_search`, `mem_context`, `mem_get_observation`, `mem_stats`, `mem_timeline`) accept an optional `project` override validated against the store. Omit it to auto-detect.
45115

46116
---

internal/mcp/mcp.go

Lines changed: 121 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"errors"
2020
"fmt"
2121
"os"
22+
"path/filepath"
2223
"strings"
2324
"time"
2425

@@ -328,6 +329,12 @@ Examples:
328329
mcp.WithString("topic_key",
329330
mcp.Description("Optional topic identifier for upserts (e.g. architecture/auth-model). Reuses and updates the latest observation in same project+scope."),
330331
),
332+
mcp.WithString("project",
333+
mcp.Description("Optional recovery target only after ambiguous_project. Ignored unless project_choice_reason is user_selected_after_ambiguous_project."),
334+
),
335+
mcp.WithString("project_choice_reason",
336+
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."),
337+
),
331338
),
332339
queuedWriteHandler(writeQueue, handleSave(s, cfg, activity)),
333340
)
@@ -433,6 +440,12 @@ Examples:
433440
mcp.WithString("session_id",
434441
mcp.Description("Session ID to associate with (default: manual-save-{project})"),
435442
),
443+
mcp.WithString("project",
444+
mcp.Description("Optional recovery target only after ambiguous_project. Ignored unless project_choice_reason is user_selected_after_ambiguous_project."),
445+
),
446+
mcp.WithString("project_choice_reason",
447+
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."),
448+
),
436449
),
437450
queuedWriteHandler(writeQueue, handleSavePrompt(s, cfg)),
438451
)
@@ -996,10 +1009,12 @@ func handleSave(s *store.Store, cfg MCPConfig, activity *SessionActivity) server
9961009
sessionID, _ := req.GetArguments()["session_id"].(string)
9971010
scope, _ := req.GetArguments()["scope"].(string)
9981011
topicKey, _ := req.GetArguments()["topic_key"].(string)
999-
// project field intentionally not read — auto-detect only (REQ-308)
1012+
projectChoice, _ := req.GetArguments()["project"].(string)
1013+
projectChoiceReason, _ := req.GetArguments()["project_choice_reason"].(string)
10001014

1001-
// Auto-detect project from cwd; fail fast on ambiguous (REQ-308, REQ-309)
1002-
detRes, err := resolveWriteProject()
1015+
// Auto-detect project from cwd; only allow explicit user-selected recovery
1016+
// after ErrAmbiguousProject (issue #306).
1017+
detRes, err := resolveWriteProjectWithChoice(projectChoice, projectChoiceReason)
10031018
if err != nil {
10041019
return writeProjectErrorResult(detRes, err), nil
10051020
}
@@ -1232,9 +1247,10 @@ func handleSavePrompt(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc {
12321247
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
12331248
content, _ := req.GetArguments()["content"].(string)
12341249
sessionID, _ := req.GetArguments()["session_id"].(string)
1235-
// project field intentionally not read — auto-detect only (REQ-308)
1250+
projectChoice, _ := req.GetArguments()["project"].(string)
1251+
projectChoiceReason, _ := req.GetArguments()["project_choice_reason"].(string)
12361252

1237-
detRes, err := resolveWriteProject()
1253+
detRes, err := resolveWriteProjectWithChoice(projectChoice, projectChoiceReason)
12381254
if err != nil {
12391255
return writeProjectErrorResult(detRes, err), nil
12401256
}
@@ -1866,6 +1882,15 @@ func (e *unknownProjectError) Error() string {
18661882
return "unknown project: " + e.Name
18671883
}
18681884

1885+
type invalidProjectChoiceError struct {
1886+
Name string
1887+
AvailableProjects []string
1888+
}
1889+
1890+
func (e *invalidProjectChoiceError) Error() string {
1891+
return "invalid project choice: " + e.Name
1892+
}
1893+
18691894
// resolveWriteProject detects the current project from the process working
18701895
// directory. Returns ErrAmbiguousProject if cwd is a parent of multiple repos.
18711896
func resolveWriteProject() (projectpkg.DetectionResult, error) {
@@ -1880,6 +1905,81 @@ func resolveWriteProject() (projectpkg.DetectionResult, error) {
18801905
return res, nil
18811906
}
18821907

1908+
// resolveWriteProjectWithChoice preserves normal write resolution authority and
1909+
// only uses an explicit project choice as a recovery path from ErrAmbiguousProject.
1910+
func resolveWriteProjectWithChoice(projectChoice, reason string) (projectpkg.DetectionResult, error) {
1911+
res, err := resolveWriteProject()
1912+
if err == nil {
1913+
// Non-ambiguous config/git/autodetect remains authoritative. Ignore any
1914+
// supplied project choice so agents cannot drift writes to arbitrary buckets.
1915+
return res, nil
1916+
}
1917+
if !errors.Is(err, projectpkg.ErrAmbiguousProject) {
1918+
return res, err
1919+
}
1920+
1921+
if strings.TrimSpace(reason) != projectpkg.SourceUserSelectedAfterAmbiguousProject {
1922+
return res, err
1923+
}
1924+
1925+
choice := strings.TrimSpace(projectChoice)
1926+
if choice == "" || !containsProjectChoice(res.AvailableProjects, choice) {
1927+
return res, &invalidProjectChoiceError{
1928+
Name: choice,
1929+
AvailableProjects: res.AvailableProjects,
1930+
}
1931+
}
1932+
1933+
res.Project = choice
1934+
res.Source = projectpkg.SourceUserSelectedAfterAmbiguousProject
1935+
res.Path = resolveAmbiguousChoicePath(res.Path, choice)
1936+
res.Warning = "project selected by user after ambiguous_project recovery"
1937+
return res, nil
1938+
}
1939+
1940+
func containsProjectChoice(available []string, choice string) bool {
1941+
choice = strings.TrimSpace(choice)
1942+
for _, candidate := range available {
1943+
if strings.TrimSpace(candidate) == choice {
1944+
return true
1945+
}
1946+
}
1947+
return false
1948+
}
1949+
1950+
func resolveAmbiguousChoicePath(ambiguousParent, choice string) string {
1951+
parent := strings.TrimSpace(ambiguousParent)
1952+
if parent == "" || strings.TrimSpace(choice) == "" {
1953+
return ""
1954+
}
1955+
1956+
entries, err := os.ReadDir(parent)
1957+
if err != nil {
1958+
return ""
1959+
}
1960+
for _, entry := range entries {
1961+
if !entry.IsDir() {
1962+
continue
1963+
}
1964+
// Match the same name shape used by project.DetectProjectFull for
1965+
// available_projects: trim + lowercase only. Do not use store.NormalizeProject
1966+
// here because it collapses repeated '-'/'_' and can create collisions.
1967+
if strings.TrimSpace(strings.ToLower(entry.Name())) != choice {
1968+
continue
1969+
}
1970+
childPath := filepath.Join(parent, entry.Name())
1971+
if _, err := os.Stat(filepath.Join(childPath, ".git")); err != nil {
1972+
continue
1973+
}
1974+
absChild, err := filepath.Abs(childPath)
1975+
if err != nil {
1976+
return childPath
1977+
}
1978+
return absChild
1979+
}
1980+
return ""
1981+
}
1982+
18831983
// resolveReadProject validates an optional project override against the store.
18841984
// If override is empty, falls back to auto-detection from cwd.
18851985
// JW2: normalizes the override (lowercase+trim) before ProjectExists lookup so
@@ -1934,6 +2034,19 @@ func writeProjectErrorResult(res projectpkg.DetectionResult, err error) *mcp.Cal
19342034
if errors.Is(err, projectpkg.ErrInvalidConfig) {
19352035
code = "invalid_project_config"
19362036
}
2037+
var choiceErr *invalidProjectChoiceError
2038+
if errors.As(err, &choiceErr) {
2039+
if choiceErr.Name == "" {
2040+
return errorWithMeta("invalid_project_choice",
2041+
"Project choice is empty; choose exactly one value from available_projects and retry with project_choice_reason=user_selected_after_ambiguous_project",
2042+
choiceErr.AvailableProjects,
2043+
)
2044+
}
2045+
return errorWithMeta("invalid_project_choice",
2046+
fmt.Sprintf("Project choice %q is not one of available_projects", choiceErr.Name),
2047+
choiceErr.AvailableProjects,
2048+
)
2049+
}
19372050
return errorWithMeta(code, fmt.Sprintf("Cannot determine project: %s", err), res.AvailableProjects)
19382051
}
19392052

@@ -1947,7 +2060,9 @@ func errorWithMeta(code, msg string, availableProjects []string) *mcp.CallToolRe
19472060
}
19482061
switch code {
19492062
case "ambiguous_project":
1950-
envelope["hint"] = "Use mem_current_project to inspect detection results, or cd into one of the listed repositories."
2063+
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."
2064+
case "invalid_project_choice":
2065+
envelope["hint"] = "Use exactly one of available_projects after asking the user, or cd into the target repo, or add repo .engram/config.json."
19512066
case "unknown_project":
19522067
envelope["hint"] = "Use one of the available_projects values, or omit project to auto-detect."
19532068
case "invalid_project_config":

0 commit comments

Comments
 (0)