Skip to content

mcp: deferred scan stuck in loading_snapshot when client returns no roots #384

@justrach

Description

@justrach

Problem

When codedb mcp is launched with no explicit root (the default — running from cwd), it enters deferred scan mode: scan state is set to loading_snapshot, and the actual filesystem walk only kicks off after the client responds to a roots/list request. The trigger is fired in parseRoots only when at least one root survives the root_policy.isIndexableRoot filter:

if (s.deferred_scan) |ds| {
    if (s.roots.items.len > 0 and !ds.triggered.swap(true, .acq_rel)) {
        const uri_raw = s.roots.items[0].uri;
        const abs_path = if (std.mem.startsWith(u8, uri_raw, "file://")) uri_raw[7..] else uri_raw;
        ds.triggerFn(ds, abs_path);
    }
}

Several real client behaviors leave the scan stuck in loading_snapshot forever:

  1. Client doesn't advertise capabilities.rootsrequestRoots is never sent, parseRoots is never reached.
  2. Client supports roots but never responds to roots/listpending_roots_id stays unmatched.
  3. Client returns an empty roots arrayparseRoots runs but s.roots.items.len == 0, so the trigger doesn't fire.
  4. Client returns roots that are all denied by root_policy.isIndexableRoot — same: s.roots.items.len == 0.

While stuck, every codedb_search/codedb_outline/etc. response gets the appended hint:

note: scan still in progress (state=loading_snapshot); results may be incomplete — retry shortly

…which never clears, because nothing transitions the state. CODEDB_ROOT is the only existing escape hatch.

Failing Test

test "issue-384: deferred scan falls back to cwd when client returns no indexable roots" {
    // [ records call to triggerFn ]
    var ds: mcp_mod.DeferredScan = .{ /* ...fields... */, .triggerFn = Recorder.cb };

    const empty_roots: []const mcp_mod.Root = &.{};
    const fired = mcp_mod.triggerDeferredScanWithFallback(&ds, empty_roots, "/Users/me/proj");
    try testing.expect(fired);
    try testing.expectEqualStrings("/Users/me/proj", Recorder.path_buf[0..Recorder.path_len]);

    // Idempotent — second call must not re-fire
    const again = mcp_mod.triggerDeferredScanWithFallback(&ds, empty_roots, "/elsewhere");
    try testing.expect(!again);
}

This helper does not exist on main, so the test fails to compile until the fix lands.

Expected

When no indexable roots arrive (empty roots, all denied, or no response within a few seconds), the deferred scan should fall back to scanning cwd — assuming cwd itself is policy-allowed — instead of sitting in loading_snapshot indefinitely.

Fix

  1. Add pub fn triggerDeferredScanWithFallback(ds, indexable_roots, fallback_cwd) bool in mcp.zig. It picks the first root if available, otherwise the policy-checked fallback_cwd, and fires the trigger atomically.
  2. Add fallback_cwd: []const u8 to DeferredScan. Populate it in main.zig (from the resolved abs_root at startup).
  3. Replace the inline trigger logic in parseRoots with a call to the helper, so empty-roots responses now resolve to cwd.
  4. Add a 3-second watchdog in watcherDeferredLoop (main.zig) that calls the same helper, covering the case where the client doesn't respond to roots/list (or doesn't support roots at all).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority:p0Highest priority

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions