Skip to content

fix: route MCP requests per project root#65

Merged
PatrickSys merged 3 commits intomasterfrom
fix/issue-63-multi-project-routing
Mar 7, 2026
Merged

fix: route MCP requests per project root#65
PatrickSys merged 3 commits intomasterfrom
fix/issue-63-multi-project-routing

Conversation

@PatrickSys
Copy link
Owner

Summary

  • resolve MCP tool calls per request instead of hardcoding the startup root
  • add optional project_directory support across tools and return deterministic ambiguity errors for multi-root sessions
  • switch multi-root startup from eager prewarming to lazy per-project initialization and add routing regression coverage

Verification

  • pnpm type-check
  • pnpm vitest run tests/project-state.test.ts tests/tools/dispatch.test.ts tests/multi-project-routing.test.ts tests/search-codebase-auto-heal.test.ts tests/index-versioning-migration.test.ts
  • pre-push hook run: prettier check + full vitest suite (50 files / 292 tests)

Closes #63

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2189ba0141

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

src/index.ts Outdated
Comment on lines +824 to +828
if (
project.indexState.status === 'indexing' ||
project.indexState.status === 'ready' ||
project.stopWatcher
) {

Choose a reason for hiding this comment

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

P1 Badge Serialize project initialization before starting watcher/indexing

initProject can be entered concurrently (e.g., main() calls it after server.connect, and a tool request can call it through resolveProjectForTool at the same time), but this guard only checks status/stopWatcher before any lock is set. Because the function then does awaited migration/reindex checks while state is still idle, two callers can both pass this check and each start performIndexing and startFileWatcher, which can trigger duplicate full indexes and leave an extra watcher running.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link

greptile-apps bot commented Mar 7, 2026

Greptile Summary

This PR replaces the single global indexState/ROOT_PATH model with a per-project ProjectState registry, enabling deterministic multi-root MCP sessions. It switches auto-heal indexing from blocking-synchronous to fire-and-forget background, adds project_directory routing to all tool schemas, and converts startup initialization from eager pre-warming to lazy per-request initProject calls.

Key findings:

  • src/project-state.ts (new): Clean extraction of project state (paths, indexState, autoRefresh, watcher handle) into a module-scoped Map with proper watcher cleanup. Cross-platform path normalization handles Windows casing and trailing separators correctly.
  • src/index.ts — Concurrent Race in initProject: The guard check evaluates before several await points, allowing two concurrent tool calls targeting the same newly-registered root to both pass the guard and launch two indexers in parallel. The second startFileWatcher call then leaks the first watcher.
  • src/index.ts — Dead Code: The rebuild-failed action check at line 771 is unreachable; ensureValidIndexOrAutoHeal only ever generates rebuild-started.
  • src/index.ts — Permanent knownRoots Accumulation: When clientRootsEnabled is false, every distinct project_directory value is permanently added to knownRoots. After a second root is registered, all subsequent calls without project_directory return ambiguous_project for the rest of the session. This behavior is tested and functional but not documented in the parameter schema.
  • src/index.ts — No Path Validation: Arbitrary paths (including non-existent directories) are accepted when clientRootsEnabled is false, causing fire-and-forget indexing to fail silently and producing confusing downstream errors instead of a clear unknown_project response.
  • Test Coverage: New multi-project-routing.test.ts and project-state.test.ts provide solid regression coverage. Existing tests correctly updated for async auto-heal semantics.

Confidence Score: 2/5

  • Significant correctness issues in multi-root path; concurrent race in initProject can spawn overlapping indexers, missing path validation can produce silent failures with confusing errors, and permanent knownRoots accumulation locks sessions without clear user documentation.
  • The PR introduces three fixable but non-trivial correctness issues in the multi-root request routing path: (1) the concurrent-initialization race in initProject is a concurrency bug that can manifest under load and leak file watchers, (2) the missing project_directory path validation allows broken project states to silently propagate, (3) the permanent knownRoots accumulation without bounds or documentation can trap users in sessions with permanent ambiguity. Single-root clients are unaffected and the architecture is sound, but multi-root sessions require fixes before wide rollout. The dead rebuild-failed branch is a minor cleanup issue.
  • src/index.ts — requires three fixes: synchronous status marker in initProject to prevent concurrent double-init, removal of dead rebuild-failed branch, addition of path validation for project_directory, and documentation/scoping of dynamic root accumulation behavior.

Sequence Diagram

sequenceDiagram
    participant C as MCP Client
    participant S as Server (index.ts)
    participant PS as project-state.ts
    participant IP as initProject()
    participant T as Tool Handler

    C->>S: tools/call { name, arguments: { project_directory?, ... } }
    S->>S: normalizeArgs()

    alt tool not in TOOLS registry
        S->>T: dispatchTool(name, args, primaryProject)
        T-->>C: result
    else known tool
        S->>S: resolveProjectForTool(args)
        alt project_directory provided
            S->>S: parseProjectDirectory(args.project_directory)
            alt clientRootsEnabled && not in knownRoots
                S-->>C: error { errorCode: "unknown_project" }
            else
                S->>PS: registerKnownRoot(path) [if new]
                S->>PS: getOrCreateProject(rootPath)
                PS-->>S: ProjectState
                S->>IP: initProject(rootPath, debounceMs)
                IP->>IP: guard check (status/stopWatcher)
                IP->>IP: migrateToNewStructure()
                IP->>IP: shouldReindex()
                IP->>IP: void performIndexing(project) [if needed]
                IP->>IP: startFileWatcher()
                IP-->>S: void
            end
        else no project_directory
            alt availableRoots.length !== 1
                S-->>C: error { errorCode: "ambiguous_project", availableRoots: [...] }
            else single root
                S->>PS: getOrCreateProject(rootPath)
                S->>IP: initProject(rootPath, debounceMs)
                IP-->>S: void
            end
        end

        alt INDEX_CONSUMING tool
            S->>S: ensureValidIndexOrAutoHeal(project)
            alt IndexCorruptedError
                S->>S: void performIndexing(project) [background]
                S-->>C: { status: "indexing", message: "retry shortly" }
            end
        end

        S->>T: dispatchTool(name, args, createToolContext(project))
        T-->>S: ToolResponse
        S->>S: inject indexSignal into response [if present]
        S-->>C: ToolResponse
    end
Loading

Last reviewed commit: 2189ba0

src/index.ts Outdated
Comment on lines +820 to +882
async function initProject(rootPath: string, debounceMs: number): Promise<void> {
const project = getOrCreateProject(rootPath);

// Skip if already initialized
if (
project.indexState.status === 'indexing' ||
project.indexState.status === 'ready' ||
project.stopWatcher
) {
return;
}

// Migrate legacy structure
try {
const legacyPaths = makeLegacyPaths(project.rootPath);
const migrated = await migrateToNewStructure(project.paths, legacyPaths);
if (migrated && process.env.CODEBASE_CONTEXT_DEBUG) {
console.error(`[DEBUG] Migrated to .codebase-context/ structure: ${project.rootPath}`);
}
} catch {
// Non-fatal
}

// Check if indexing is needed
const needsIndex = await shouldReindex(project.paths);
if (needsIndex) {
if (process.env.CODEBASE_CONTEXT_DEBUG) {
console.error(`[DEBUG] Starting indexing: ${project.rootPath}`);
}
void performIndexing(project);
} else {
if (process.env.CODEBASE_CONTEXT_DEBUG) {
console.error(`[DEBUG] Index found. Ready: ${project.rootPath}`);
}
project.indexState.status = 'ready';
project.indexState.lastIndexed = new Date();
}

// Start file watcher
project.stopWatcher = startFileWatcher({
rootPath: project.rootPath,
debounceMs,
onChanged: () => {
const shouldRunNow = project.autoRefresh.onFileChange(
project.indexState.status === 'indexing'
);
if (!shouldRunNow) {
if (process.env.CODEBASE_CONTEXT_DEBUG) {
console.error(
`[file-watcher] Index in progress — queueing auto-refresh: ${project.rootPath}`
);
}
return;
}
if (process.env.CODEBASE_CONTEXT_DEBUG) {
console.error(
`[file-watcher] Changes detected — incremental reindex starting: ${project.rootPath}`
);
}
void performIndexing(project, true);
}
});
}
Copy link

Choose a reason for hiding this comment

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

initProject has a concurrent-initialization race condition. The guard check at lines 824-829 evaluates before several await points (migration at line 835, shouldReindex at line 844). If two tool calls arrive concurrently for the same newly-registered root, both will pass the guard (status is 'idle', stopWatcher is undefined), proceed through the async section, and both will fire void performIndexing(project), launching two overlapping indexers against the same root. The second startFileWatcher call then overwrites project.stopWatcher, leaking the first watcher.

Fix by setting project.indexState.status = 'indexing' synchronously before the first await, so concurrent calls see a non-idle status and skip initialization:

Suggested change
async function initProject(rootPath: string, debounceMs: number): Promise<void> {
const project = getOrCreateProject(rootPath);
// Skip if already initialized
if (
project.indexState.status === 'indexing' ||
project.indexState.status === 'ready' ||
project.stopWatcher
) {
return;
}
// Migrate legacy structure
try {
const legacyPaths = makeLegacyPaths(project.rootPath);
const migrated = await migrateToNewStructure(project.paths, legacyPaths);
if (migrated && process.env.CODEBASE_CONTEXT_DEBUG) {
console.error(`[DEBUG] Migrated to .codebase-context/ structure: ${project.rootPath}`);
}
} catch {
// Non-fatal
}
// Check if indexing is needed
const needsIndex = await shouldReindex(project.paths);
if (needsIndex) {
if (process.env.CODEBASE_CONTEXT_DEBUG) {
console.error(`[DEBUG] Starting indexing: ${project.rootPath}`);
}
void performIndexing(project);
} else {
if (process.env.CODEBASE_CONTEXT_DEBUG) {
console.error(`[DEBUG] Index found. Ready: ${project.rootPath}`);
}
project.indexState.status = 'ready';
project.indexState.lastIndexed = new Date();
}
// Start file watcher
project.stopWatcher = startFileWatcher({
rootPath: project.rootPath,
debounceMs,
onChanged: () => {
const shouldRunNow = project.autoRefresh.onFileChange(
project.indexState.status === 'indexing'
);
if (!shouldRunNow) {
if (process.env.CODEBASE_CONTEXT_DEBUG) {
console.error(
`[file-watcher] Index in progress — queueing auto-refresh: ${project.rootPath}`
);
}
return;
}
if (process.env.CODEBASE_CONTEXT_DEBUG) {
console.error(
`[file-watcher] Changes detected — incremental reindex starting: ${project.rootPath}`
);
}
void performIndexing(project, true);
}
});
}
async function initProject(rootPath: string, debounceMs: number): Promise<void> {
const project = getOrCreateProject(rootPath);
// Skip if already initialized
if (
project.indexState.status === 'indexing' ||
project.indexState.status === 'ready' ||
project.stopWatcher
) {
return;
}
// Mark immediately before any await so concurrent calls see a non-idle status
project.indexState.status = 'indexing';
// Migrate legacy structure
try {
const legacyPaths = makeLegacyPaths(project.rootPath);
const migrated = await migrateToNewStructure(project.paths, legacyPaths);

src/index.ts Outdated
indexSignal = await ensureValidIndexOrAutoHeal();
if (indexSignal.action === 'rebuild-failed') {
indexSignal = await ensureValidIndexOrAutoHeal(project);
if (indexSignal.action === 'rebuild-started' || indexSignal.action === 'rebuild-failed') {
Copy link

Choose a reason for hiding this comment

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

ensureValidIndexOrAutoHeal only ever returns action: 'rebuild-started' (or 'served'); it never generates 'rebuild-failed'. This condition is unreachable dead code and should be removed to avoid confusing future readers.

Suggested change
if (indexSignal.action === 'rebuild-started' || indexSignal.action === 'rebuild-failed') {
if (indexSignal.action === 'rebuild-started') {

Comment on lines +674 to +692
if (requestedProjectDirectory) {
const requestedRootKey = normalizeRootKey(requestedProjectDirectory);
const knownRootPath = knownRoots.get(requestedRootKey);

if (clientRootsEnabled && availableRoots.length > 0 && !knownRootPath) {
return {
ok: false,
response: buildProjectSelectionError(
'unknown_project',
'Requested project is not part of the active MCP roots.'
)
};
}

const rootPath = knownRootPath ?? registerKnownRoot(requestedProjectDirectory);
const project = getOrCreateProject(rootPath);
await initProject(project.rootPath, watcherDebounceMs);
return { ok: true, project };
}
Copy link

Choose a reason for hiding this comment

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

Dynamically registered project roots via project_directory are permanently added to knownRoots and never evicted during the session (unless syncKnownRoots is called by the MCP client). Once a second distinct root is registered this way, availableRoots.length > 1, causing all subsequent tool calls without project_directory to fail with ambiguous_project error — locking the session into ambiguity mode for the remainder of its lifetime.

This behavior is tested and functional, but the session-lock behavior is not surfaced in the project_directory parameter description. Consider either:

  1. Documenting this clearly in the project_directory schema description (in src/tools/index.ts), or
  2. Scoping dynamically registered roots to the current request only — i.e., skip calling registerKnownRoot for ad-hoc project_directory lookups and resolve them in-process without mutating session state.

Comment on lines +670 to +692
async function resolveProjectForTool(args: Record<string, unknown>): Promise<ProjectResolution> {
const requestedProjectDirectory = parseProjectDirectory(args.project_directory);
const availableRoots = getKnownRootPaths();

if (requestedProjectDirectory) {
const requestedRootKey = normalizeRootKey(requestedProjectDirectory);
const knownRootPath = knownRoots.get(requestedRootKey);

if (clientRootsEnabled && availableRoots.length > 0 && !knownRootPath) {
return {
ok: false,
response: buildProjectSelectionError(
'unknown_project',
'Requested project is not part of the active MCP roots.'
)
};
}

const rootPath = knownRootPath ?? registerKnownRoot(requestedProjectDirectory);
const project = getOrCreateProject(rootPath);
await initProject(project.rootPath, watcherDebounceMs);
return { ok: true, project };
}
Copy link

Choose a reason for hiding this comment

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

No existence check is performed on user-supplied project_directory before registering and initializing it. If the path doesn't exist:

  1. migrateToNewStructure silently fails (caught and ignored)
  2. shouldReindex returns true (fs.access on a non-existent path throws)
  3. Fire-and-forget performIndexing fails and sets project.indexState.status = 'error'
  4. startFileWatcher is invoked on a non-existent path

The tool call then returns success with a broken project state, producing confusing downstream errors instead of a clear unknown_project response.

Add validation after parseProjectDirectory and before registerKnownRoot:

if (requestedProjectDirectory) {
  try {
    const stat = await fs.stat(requestedProjectDirectory);
    if (!stat.isDirectory()) {
      return {
        ok: false,
        response: buildProjectSelectionError('unknown_project',
          `project_directory is not a directory: ${requestedProjectDirectory}`)
      };
    }
  } catch {
    return {
      ok: false,
      response: buildProjectSelectionError('unknown_project',
        `project_directory does not exist: ${requestedProjectDirectory}`)
    };
  }
  // ... rest of the method
}

@PatrickSys PatrickSys merged commit b0c2d04 into master Mar 7, 2026
3 checks passed
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.

Single MCP server instance should work across multiple projects

1 participant