fix(mcp): path prefix false-match, optional task status, global limit, URI tests#148
fix(mcp): path prefix false-match, optional task status, global limit, URI tests#148
Conversation
…, URI tests - hook.go: extract lookupProject helper with corrected SQL — '? LIKE path || /%' instead of '? LIKE path || %' prevents /git/ghost-extra from matching /git/ghost - mcpserver: ghost_task_update status field is now optional (omit to preserve current), matching the existing pattern for priority and description - mcpserver: normalize ghost://memories/global resource limit to 15 (was 50), consistent with hook and buildProjectContext - mcpserver_test: add TestParseProjectIDFromURI covering plain, URL-encoded, and error cases; add TestTaskUpdate_EmptyStatusPreservesCurrentStatus - mcpinit/hook_test: 6 new tests for lookupProject including the regression case
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 42 minutes and 19 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThis diff refactors project matching logic in hook initialization to use a dedicated helper with stricter subdirectory semantics, makes the task update status parameter optional with fallback to current status, reduces global memory retrieval count, and adds comprehensive test coverage for both changes. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
internal/mcpserver/mcpserver.go (2)
972-980:⚠️ Potential issue | 🟡 MinorStale resource description: still says "Top 50" after lowering limit to 15.
The resource
Descriptionadvertises "Top 50 cross-project Ghost memories" but the handler now fetches only 15. MCP clients surface this text to users, so it should match actual behavior.📝 Proposed fix
- Description: "Top 50 cross-project Ghost memories: personal preferences, global conventions, " + + Description: "Top 15 cross-project Ghost memories: personal preferences, global conventions, " + "toolchain facts. These apply to all projects. " + "Add entries via the ghost_save_global tool.",🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/mcpserver/mcpserver.go` around lines 972 - 980, The resource Description in the s.mcp.AddResource call is stale—its text says "Top 50 cross-project Ghost memories" while the handler calls s.store.GetTopMemories(ctx, "_global", 15); update the Description string used in the Resource (in the AddResource invocation) to reflect the actual limit (e.g., "Top 15 cross-project Ghost memories") and ensure any adjacent wording (like "Top 50" elsewhere in that Description) is changed consistently so the user-facing text matches the s.store.GetTopMemories limit.
1128-1144:⚠️ Potential issue | 🟡 MinorDouble URL-decoding of
project_id.
url.Parsestoresu.Pathin decoded form, so splittingu.Pathand then callingurl.PathUnescape(parts[0])decodes the segment twice. For a URI likeghost://project/my%2520proj/context, the intended literal valuemy%20projwould be incorrectly unescaped tomy proj. Current tests only cover simple cases like%20, which happen to be idempotent.Use
u.EscapedPath()(oru.RawPathwhen non-empty) before splitting and unescaping, so decoding happens exactly once. This also correctly preserves encoded slashes (%2F) within the project_id if needed.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/mcpserver/mcpserver.go` around lines 1128 - 1144, parseProjectIDFromURI double-decodes the project_id because it uses u.Path (already decoded) then calls url.PathUnescape; change the function to use the raw/escaped path instead: obtain the escaped path via u.RawPath when non-empty or u.EscapedPath(), split that escaped path (strings.TrimPrefix(...), strings.SplitN) to extract the project segment, then call url.PathUnescape exactly once on that segment so encoded values (including %2F) are preserved and only decoded a single time.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@internal/mcpinit/hook_test.go`:
- Line 19: The t.Cleanup callback is ignoring the error returned by db.Close(),
causing golangci-lint failure; update the cleanup closure to capture the
returned error from db.Close() and report it (e.g., if err := db.Close(); err !=
nil { t.Fatalf("closing db: %v", err) }) so the test reports the failure instead
of discarding it. Use the existing t.Cleanup(func() { ... }) and replace the
body to check and handle the error from db.Close().
In `@internal/mcpinit/hook.go`:
- Around line 229-234: The current combined SQL (the row := db.QueryRow(...)
query using cwd and cwdBase) lets a name-matching row win due to ORDER BY
LENGTH(path), and LENGTH(path) > 10 also blocks short exact path matches; fix by
preferring path matches first: run a path-only query using the ((? = path OR ?
LIKE path || '/%')) condition (remove the LENGTH(path) > 10 check so exact short
paths are allowed) and if that returns no row, run a separate fallback query for
name = ? using cwdBase; update the code around row := db.QueryRow(...) to
attempt the path lookup first (with cwd, cwd) and only query by name (cwdBase)
if the first query yields no result.
In `@internal/mcpserver/mcpserver_test.go`:
- Around line 593-627: The test TestTaskUpdate_EmptyStatusPreservesCurrentStatus
is re-implementing the handler logic instead of exercising the actual
ghost_task_update handler; update the test to invoke the registered handler path
(or refactor the handler body into a callable function) so an empty args.Status
is passed through ghost_task_update and the handler reads current :=
store.GetTask(...) and calls store.UpdateTask(...) with current.Status;
specifically, replace the direct call to store.UpdateTask with a call that posts
args to the MCP server's ghost_task_update handler (or call the extracted
handler function) and assert the resulting task still has Status "pending" and
Priority 1.
---
Outside diff comments:
In `@internal/mcpserver/mcpserver.go`:
- Around line 972-980: The resource Description in the s.mcp.AddResource call is
stale—its text says "Top 50 cross-project Ghost memories" while the handler
calls s.store.GetTopMemories(ctx, "_global", 15); update the Description string
used in the Resource (in the AddResource invocation) to reflect the actual limit
(e.g., "Top 15 cross-project Ghost memories") and ensure any adjacent wording
(like "Top 50" elsewhere in that Description) is changed consistently so the
user-facing text matches the s.store.GetTopMemories limit.
- Around line 1128-1144: parseProjectIDFromURI double-decodes the project_id
because it uses u.Path (already decoded) then calls url.PathUnescape; change the
function to use the raw/escaped path instead: obtain the escaped path via
u.RawPath when non-empty or u.EscapedPath(), split that escaped path
(strings.TrimPrefix(...), strings.SplitN) to extract the project segment, then
call url.PathUnescape exactly once on that segment so encoded values (including
%2F) are preserved and only decoded a single time.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 18445116-ad6c-4a83-a8d7-cebdfe13055e
📒 Files selected for processing (4)
internal/mcpinit/hook.gointernal/mcpinit/hook_test.gointernal/mcpserver/mcpserver.gointernal/mcpserver/mcpserver_test.go
| if err != nil { | ||
| t.Fatalf("OpenDB: %v", err) | ||
| } | ||
| t.Cleanup(func() { db.Close() }) |
There was a problem hiding this comment.
Check the cleanup close error to unblock CI.
golangci-lint is failing on this unchecked db.Close() return value. Report it from the cleanup callback instead of discarding it.
✅ Proposed fix
- t.Cleanup(func() { db.Close() })
+ t.Cleanup(func() {
+ if err := db.Close(); err != nil {
+ t.Errorf("CloseDB: %v", err)
+ }
+ })📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| t.Cleanup(func() { db.Close() }) | |
| t.Cleanup(func() { | |
| if err := db.Close(); err != nil { | |
| t.Errorf("CloseDB: %v", err) | |
| } | |
| }) |
🧰 Tools
🪛 GitHub Actions: ci
[error] 19-19: golangci-lint (errcheck): Error return value of db.Close is not checked.
🪛 GitHub Check: build-and-test
[failure] 19-19:
Error return value of db.Close is not checked (errcheck)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/mcpinit/hook_test.go` at line 19, The t.Cleanup callback is ignoring
the error returned by db.Close(), causing golangci-lint failure; update the
cleanup closure to capture the returned error from db.Close() and report it
(e.g., if err := db.Close(); err != nil { t.Fatalf("closing db: %v", err) }) so
the test reports the failure instead of discarding it. Use the existing
t.Cleanup(func() { ... }) and replace the body to check and handle the error
from db.Close().
| row := db.QueryRow(` | ||
| SELECT id, name FROM projects | ||
| WHERE ((? = path OR ? LIKE path || '/%') AND LENGTH(path) > 10) | ||
| OR name = ? | ||
| ORDER BY LENGTH(path) DESC LIMIT 1 | ||
| `, cwd, cwd, cwdBase) |
There was a problem hiding this comment.
Make path matches take precedence over basename fallback.
The current OR name = ? participates in the same ORDER BY LENGTH(path) DESC, so an unrelated project with name == filepath.Base(cwd) and a longer stored path can beat a valid path match. Also, LENGTH(path) > 10 currently excludes exact matches for short-but-valid paths. Split the path lookup from the fallback, or rank path matches before fallback rows.
🐛 Proposed fix
func lookupProject(db *sql.DB, cwd string) (id, name string) {
cwdBase := filepath.Base(cwd)
row := db.QueryRow(`
SELECT id, name FROM projects
- WHERE ((? = path OR ? LIKE path || '/%') AND LENGTH(path) > 10)
- OR name = ?
+ WHERE ? = path OR (? LIKE path || '/%' AND LENGTH(path) > 10)
ORDER BY LENGTH(path) DESC LIMIT 1
- `, cwd, cwd, cwdBase)
- if err := row.Scan(&id, &name); err != nil {
+ `, cwd, cwd)
+ if err := row.Scan(&id, &name); err == nil {
+ return id, name
+ } else if err != sql.ErrNoRows {
return "", ""
}
+
+ row = db.QueryRow(`SELECT id, name FROM projects WHERE name = ? LIMIT 1`, cwdBase)
+ if err := row.Scan(&id, &name); err != nil {
+ return "", ""
+ }
return id, name
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/mcpinit/hook.go` around lines 229 - 234, The current combined SQL
(the row := db.QueryRow(...) query using cwd and cwdBase) lets a name-matching
row win due to ORDER BY LENGTH(path), and LENGTH(path) > 10 also blocks short
exact path matches; fix by preferring path matches first: run a path-only query
using the ((? = path OR ? LIKE path || '/%')) condition (remove the LENGTH(path)
> 10 check so exact short paths are allowed) and if that returns no row, run a
separate fallback query for name = ? using cwdBase; update the code around row
:= db.QueryRow(...) to attempt the path lookup first (with cwd, cwd) and only
query by name (cwdBase) if the first query yields no result.
| func TestTaskUpdate_EmptyStatusPreservesCurrentStatus(t *testing.T) { | ||
| store := testStore(t) | ||
| ctx := context.Background() | ||
|
|
||
| id, err := store.CreateTask(ctx, "abc123", "Refactor auth", "needs cleanup", 2) | ||
| if err != nil { | ||
| t.Fatalf("CreateTask: %v", err) | ||
| } | ||
|
|
||
| // Default status is "pending". Update priority only (no status change). | ||
| // The fixed handler fetches current task and uses current.Status when args.Status == "". | ||
| current, err := store.GetTask(ctx, id) | ||
| if err != nil { | ||
| t.Fatalf("GetTask: %v", err) | ||
| } | ||
| if current.Status != "pending" { | ||
| t.Fatalf("expected initial status=pending, got %q", current.Status) | ||
| } | ||
|
|
||
| // Simulate what the fixed ghost_task_update handler does: use current status. | ||
| if err := store.UpdateTask(ctx, id, current.Status, 1, current.Description); err != nil { | ||
| t.Fatalf("UpdateTask: %v", err) | ||
| } | ||
|
|
||
| after, err := store.GetTask(ctx, id) | ||
| if err != nil { | ||
| t.Fatalf("GetTask after update: %v", err) | ||
| } | ||
| if after.Status != "pending" { | ||
| t.Errorf("status should remain pending, got %q", after.Status) | ||
| } | ||
| if after.Priority != 1 { | ||
| t.Errorf("priority should be updated to 1, got %d", after.Priority) | ||
| } | ||
| } |
There was a problem hiding this comment.
Test doesn't exercise the ghost_task_update handler.
This test re-implements the handler's logic inline (reading current.Status, calling store.UpdateTask directly) rather than invoking the MCP tool handler. A regression in the handler — e.g. someone reintroducing the empty-status rejection or forgetting to substitute current.Status — would not be caught here.
Consider driving the actual tool via the registered MCP server (or extracting the handler body into a testable method), so the test verifies that an empty args.Status through the handler path preserves the current status.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/mcpserver/mcpserver_test.go` around lines 593 - 627, The test
TestTaskUpdate_EmptyStatusPreservesCurrentStatus is re-implementing the handler
logic instead of exercising the actual ghost_task_update handler; update the
test to invoke the registered handler path (or refactor the handler body into a
callable function) so an empty args.Status is passed through ghost_task_update
and the handler reads current := store.GetTask(...) and calls
store.UpdateTask(...) with current.Status; specifically, replace the direct call
to store.UpdateTask with a call that posts args to the MCP server's
ghost_task_update handler (or call the extracted handler function) and assert
the resulting task still has Status "pending" and Priority 1.
Summary
hook.goproject lookup usedLIKE path || '%'which caused/git/ghost-extrato incorrectly match a project at/git/ghost. Fixed by extractinglookupProjecthelper with corrected SQL (? = path OR ? LIKE path || '/%'). Regression covered by 6 new tests.ghost_task_updatenow treatsstatusas optional — omitting it preserves the current value, matching the existing behavior forpriorityanddescription.ghost://memories/globalresource now fetches 15 global memories (was 50), matching hook andbuildProjectContext.TestParseProjectIDFromURI(plain names, URL-encoded spaces, error cases) andTestTaskUpdate_EmptyStatusPreservesCurrentStatus.Test plan
go test ./internal/mcpinit/... ./internal/mcpserver/...— all passgo vet ./...— cleango test ./...— full suite greenSummary by CodeRabbit
Improvements
Tests