Skip to content

feat(mcp+cli): project-root auto-discovery + ListRoots integration#173

Merged
aksOps merged 1 commit into
mainfrom
feat/mcp-project-aware
May 15, 2026
Merged

feat(mcp+cli): project-root auto-discovery + ListRoots integration#173
aksOps merged 1 commit into
mainfrom
feat/mcp-project-aware

Conversation

@aksOps
Copy link
Copy Markdown
Contributor

@aksOps aksOps commented May 15, 2026

Summary

Until now every CLI subcommand and MCP-client config needed an explicit path:

```json
{ "mcpServers": { "codeiq": {
"command": "codeiq",
"args": ["mcp", "/home/dev/projects/codeiq"]
}}}
```

That's noisy (one config per project, each hardcoded) and breaks the natural `codeiq ` flow when you're already inside an indexed project.

After this PR

```json
{ "mcpServers": { "codeiq": {
"command": "codeiq",
"args": ["mcp"]
}}}
```

The server (and every other CLI subcommand) auto-discovers the project root via a layered chain — and the MCP server additionally asks the connected client for its workspace roots once `initialize` completes.

Resolution chain

Highest wins:

  1. Explicit positional argument — legacy behavior, unchanged
  2. `CODEIQ_PROJECT_ROOT` environment variable — for wrappers / CI
  3. Walk up from `$CWD` looking for `.codeiq/graph/codeiq.kuzu` — strongest signal (already-indexed project)
  4. Walk up from `$CWD` looking for `.git/` — repo-root fallback
  5. Actionable error listing all three options

Every CLI subcommand picks this up automatically because it lives in a shared resolver.

MCP `ListRoots` integration

In addition to the boot-time resolution above, the MCP server installs an `InitializedHandler` that calls `session.ListRoots(ctx, nil)` once the client completes `initialize`. If the client exposes workspace roots, the server compares them to its boot-resolved root and emits a clear stderr warning when they don't match.

The server does not swap the open Kuzu handle mid-flight — that's a larger refactor (per-session store cache + RootsListChanged invalidation). The warning surfaces a misconfiguration; the operator restarts with the right arg or env value. Tracked as a follow-up.

Implementation

Added:

File Lines
`internal/projectroot/resolver.go` ~140 LoC — layered resolver
`internal/projectroot/resolver_test.go` 9 tests covering arg / env / walk-up / fallback / negative paths
`internal/mcp/server.go` (`compareRootsWithClient`, `uriToPath`) ListRoots comparison + `file://` URI handling
`internal/mcp/server_test.go` (`TestServerWithResolvedRootInitializesCleanly`) Verifies init handler doesn't break the handshake

Modified:

File Change
`internal/cli/util.go` `resolvePath` delegates to `projectroot.FromArgs`; new helpful error message
`internal/cli/mcp.go` Passes the resolved root into `ServerOptions`

Test plan

  • `CGO_ENABLED=1 go test ./... -count=1` — 890 passed across 44 packages (was 880 + 10 new resolver tests)
  • 5 end-to-end smoke scenarios all behave correctly:
    1. Explicit arg → resolves
    2. `CODEIQ_PROJECT_ROOT` env (cwd != project) → resolves
    3. Walk-up from `nested/deep/` (no arg/env) → resolves to project root
    4. No signals (cwd is `/tmp`) → actionable error
    5. `codeiq mcp` (no arg) + tools/list → handshake completes; server issues `roots/list` back to the client (verified in JSON-RPC trace)

Out of scope

  • Per-session Kuzu handle swapping based on `ListRoots` content. Today the server logs a warning when boot-resolved root disagrees with the client's roots; the operator restarts. Multi-root MCP clients (with multiple workspace folders) are not yet a first-class flow.
  • `RootsListChanged` subscription. Today the comparison runs once at session-init; later changes are ignored.

🤖 Generated with Claude Code

Until now every CLI subcommand and MCP-client config needed an
explicit path to the project root:

  codeiq mcp /home/dev/projects/codeiq                # always required
  codeiq stats /home/dev/projects/codeiq              # always required

That made MCP-client configs noisy (one entry per project, each with
a hardcoded path) and made `codeiq <cmd>` inside an indexed project
require typing the path twice (once to cd, once to pass).

### New resolution chain (highest wins)

  1. Explicit positional argument (legacy behavior; unchanged)
  2. CODEIQ_PROJECT_ROOT environment variable
  3. Walk up from $CWD looking for `.codeiq/graph/codeiq.kuzu`
     (already-indexed; strongest signal)
  4. Walk up from $CWD looking for `.git/` (repo root)
  5. Error with an actionable message listing all three options

Every CLI subcommand picks this up automatically because it lives
in a shared resolver — `internal/projectroot/`.

### MCP ListRoots integration

In addition to the boot-time resolution above, the MCP server installs
an `InitializedHandler` that calls `session.ListRoots(ctx, nil)` once
the client completes `initialize`. If the client exposes workspace
roots, the server compares them to its boot-resolved root and emits
a clear stderr warning when they don't match.

We do NOT swap the open Kuzu handle mid-flight — that's a larger
refactor (per-session store cache + RootsListChanged invalidation).
The warning surfaces a misconfiguration; the operator restarts with
the right arg or env value. Tracked as a follow-up for a later PR.

### MCP-client config simplification

Before (per-project, hardcoded):

  { "mcpServers": { "codeiq": { "command": "codeiq",
    "args": ["mcp", "/home/dev/projects/codeiq"] } } }

After (one config, works everywhere — clients spawn with cwd =
project root):

  { "mcpServers": { "codeiq": { "command": "codeiq",
    "args": ["mcp"] } } }

### Implementation

Added:

  internal/projectroot/resolver.go            — layered resolver (~140 LoC)
  internal/projectroot/resolver_test.go       — 9 tests covering arg /
                                                env / walk-up / fallback /
                                                negative paths
  internal/mcp/server.go (compareRootsWithClient,
                          uriToPath)           — ListRoots comparison +
                                                file:// URI handling
  internal/mcp/server_test.go (TestServer-
    WithResolvedRootInitializesCleanly)        — verifies init handler
                                                doesn't break the handshake

Modified:

  internal/cli/util.go                         — resolvePath delegates to
                                                projectroot.FromArgs; new
                                                helpful error message
  internal/cli/mcp.go                          — passes the resolved root
                                                into ServerOptions

### Verification

  CGO_ENABLED=1 go test ./... -count=1
    → 890 passed across 44 packages (was 880 + 10 new resolver tests)

Smoke tested 5 scenarios end-to-end:
  1. Explicit arg                              → 2 nodes / 1 edge from temp project
  2. CODEIQ_PROJECT_ROOT env (cwd != project)  → same result
  3. Walk-up from nested/deep/ (no arg/env)    → resolves to project root
  4. No signals (cwd is /tmp)                  → actionable error
  5. `codeiq mcp` (no arg) + tools/list        → handshake completes;
                                                 server issues `roots/list`
                                                 back to the client (visible
                                                 in JSON-RPC trace)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@aksOps aksOps merged commit 6dd90b5 into main May 15, 2026
13 checks passed
@aksOps aksOps deleted the feat/mcp-project-aware branch May 15, 2026 01:15
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