Skip to content

fix(mcp): path prefix false-match, optional task status, global limit, URI tests#148

Merged
wcatz merged 3 commits intomainfrom
fix/mcp-integration-issues
Apr 18, 2026
Merged

fix(mcp): path prefix false-match, optional task status, global limit, URI tests#148
wcatz merged 3 commits intomainfrom
fix/mcp-integration-issues

Conversation

@wcatz
Copy link
Copy Markdown
Owner

@wcatz wcatz commented Apr 18, 2026

Summary

  • Bug fix: hook.go project lookup used LIKE path || '%' which caused /git/ghost-extra to incorrectly match a project at /git/ghost. Fixed by extracting lookupProject helper with corrected SQL (? = path OR ? LIKE path || '/%'). Regression covered by 6 new tests.
  • Ergonomics: ghost_task_update now treats status as optional — omitting it preserves the current value, matching the existing behavior for priority and description.
  • Consistency: ghost://memories/global resource now fetches 15 global memories (was 50), matching hook and buildProjectContext.
  • Coverage: Added TestParseProjectIDFromURI (plain names, URL-encoded spaces, error cases) and TestTaskUpdate_EmptyStatusPreservesCurrentStatus.

Test plan

  • go test ./internal/mcpinit/... ./internal/mcpserver/... — all pass
  • go vet ./... — clean
  • go test ./... — full suite green

Summary by CodeRabbit

  • Improvements

    • Task updates now support omitting the status field to preserve existing task status
    • Global memories endpoint now provides more focused results with refined retrieval
    • Enhanced project matching logic with improved accuracy in subdirectory detection
  • Tests

    • Added comprehensive test coverage for task updates and project identification

…, 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
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 18, 2026

Warning

Rate limit exceeded

@wcatz has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 42 minutes and 19 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 31a1a20d-0141-4ddf-9390-81b5a34a130c

📥 Commits

Reviewing files that changed from the base of the PR and between 7b5a188 and a83c29e.

📒 Files selected for processing (3)
  • docs/superpowers/plans/2026-04-18-ghost-mcp-only.md
  • docs/superpowers/specs/2026-04-18-ghost-mcp-only-strip.md
  • internal/mcpinit/hook_test.go
📝 Walkthrough

Walkthrough

This 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

Cohort / File(s) Summary
Project Lookup Refactoring
internal/mcpinit/hook.go, internal/mcpinit/hook_test.go
Introduced lookupProject(db, cwd) helper with exact path matching, proper subdirectory matching (requiring / boundary), and name-based fallback. Refactored loadSessionContext to use the new helper. Added 6 test cases covering exact match, subdirectory match, prefix false-match regression, longest-path selection, name fallback, and no-match scenarios.
Task Update and Memory Changes
internal/mcpserver/mcpserver.go, internal/mcpserver/mcpserver_test.go
Made ghost_task_update status parameter optional with fallback to preserve current task status; removed pre-validation of empty status. Reduced global memories returned from 50 to 15. Added tests for empty-status preservation and URI parsing validation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 A lookup hops with proper bounds,
subdirectories safely found,
status falls back, tasks stay the same,
memories trim themselves with grace—hooray! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title concisely summarizes the four main changes: path prefix matching fix, optional task status, global memory limit adjustment, and added URI tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/mcp-integration-issues

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Stale resource description: still says "Top 50" after lowering limit to 15.

The resource Description advertises "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 | 🟡 Minor

Double URL-decoding of project_id.

url.Parse stores u.Path in decoded form, so splitting u.Path and then calling url.PathUnescape(parts[0]) decodes the segment twice. For a URI like ghost://project/my%2520proj/context, the intended literal value my%20proj would be incorrectly unescaped to my proj. Current tests only cover simple cases like %20, which happen to be idempotent.

Use u.EscapedPath() (or u.RawPath when 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

📥 Commits

Reviewing files that changed from the base of the PR and between b943c21 and 7b5a188.

📒 Files selected for processing (4)
  • internal/mcpinit/hook.go
  • internal/mcpinit/hook_test.go
  • internal/mcpserver/mcpserver.go
  • internal/mcpserver/mcpserver_test.go

Comment thread internal/mcpinit/hook_test.go Outdated
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { db.Close() })
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.

Suggested change
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().

Comment thread internal/mcpinit/hook.go
Comment on lines +229 to +234
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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +593 to +627
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)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

@wcatz wcatz merged commit e82a321 into main Apr 18, 2026
5 checks passed
@wcatz wcatz deleted the fix/mcp-integration-issues branch April 18, 2026 09:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant