Skip to content

Commit 6dd90b5

Browse files
aksOpsclaude
andauthored
feat(mcp+cli): project-root auto-discovery + ListRoots integration (#173)
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>
1 parent 08ddadc commit 6dd90b5

6 files changed

Lines changed: 421 additions & 25 deletions

File tree

internal/cli/mcp.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,9 @@ To register with Claude Code, add to .mcp.json at the repo root:
117117
MaxDepth: maxDepth,
118118
}
119119
srv, err := mcp.NewServer(mcp.ServerOptions{
120-
Name: "CODE MCP",
121-
Version: buildinfo.Version,
120+
Name: "CODE MCP",
121+
Version: buildinfo.Version,
122+
ResolvedRoot: root,
122123
})
123124
if err != nil {
124125
return err

internal/cli/util.go

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,39 @@ package cli
22

33
import (
44
"encoding/json"
5-
"fmt"
5+
"errors"
66
"io"
7-
"os"
8-
"path/filepath"
97

8+
"github.com/randomcodespace/codeiq/internal/projectroot"
109
"github.com/randomcodespace/codeiq/internal/query"
1110
)
1211

1312
// resolvePath turns the optional [path] positional that most subcommands
14-
// accept into an absolute, directory-validated path. An empty args slice is
15-
// the current working directory. A non-empty args slice uses args[0].
13+
// accept into an absolute, directory-validated project root.
1614
//
17-
// Returns a usageError when the resolved path does not exist or is not a
18-
// directory — that path-type problem is a user-input issue (exit code 1) per
19-
// root.go's exit-code mapping.
15+
// Resolution order (highest wins): explicit positional argument, then the
16+
// CODEIQ_PROJECT_ROOT environment variable, then walking up from the current
17+
// working directory looking for `.codeiq/graph/codeiq.kuzu` (already-indexed),
18+
// then `.git/` (repo root). The walk-up makes `codeiq <cmd>` "just work" when
19+
// invoked from inside an indexed project — most relevant for MCP-client
20+
// configs that previously needed a hardcoded path arg.
21+
//
22+
// Returns a usageError on any resolution failure (no path arg, no env, and
23+
// the walk-up found nothing) — exit code 1 per root.go's exit-code mapping.
2024
func resolvePath(args []string) (string, error) {
21-
path := "."
22-
if len(args) >= 1 && args[0] != "" {
23-
path = args[0]
24-
}
25-
abs, err := filepath.Abs(path)
25+
root, err := projectroot.FromArgs(args)
2626
if err != nil {
27-
return "", fmt.Errorf("resolve %q: %w", path, err)
28-
}
29-
st, err := os.Stat(abs)
30-
if err != nil {
31-
return "", newUsageError("path %q does not exist", abs)
32-
}
33-
if !st.IsDir() {
34-
return "", newUsageError("path %q is not a directory", abs)
27+
if errors.Is(err, projectroot.ErrNotFound) {
28+
return "", newUsageError(
29+
"could not resolve project root.\n" +
30+
" Try one of:\n" +
31+
" codeiq <cmd> /path/to/project\n" +
32+
" CODEIQ_PROJECT_ROOT=/path/to/project codeiq <cmd>\n" +
33+
" cd /path/to/project && codeiq <cmd> # walk-up finds .codeiq/ or .git/")
34+
}
35+
return "", newUsageError("%s", err.Error())
3536
}
36-
return abs, nil
37+
return root, nil
3738
}
3839

3940
// printOrdered writes a query.OrderedMap (or any other deterministic

internal/mcp/server.go

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package mcp
33
import (
44
"context"
55
"fmt"
6+
"os"
7+
"path/filepath"
68
"sync"
79

810
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
@@ -15,6 +17,13 @@ type ServerOptions struct {
1517
Name string
1618
// Version of the codeiq binary (build-info Version string).
1719
Version string
20+
// ResolvedRoot is the project root the server already resolved at boot
21+
// (via projectroot.Resolve). When the connected MCP client also exposes
22+
// roots via ListRoots, we compare the two and log a warning to stderr
23+
// if they disagree — but we do not swap the open Kuzu store mid-flight
24+
// (that's a larger refactor; tracked as a follow-up). Empty string
25+
// disables the ListRoots check.
26+
ResolvedRoot string
1827
}
1928

2029
// Server is the stdio MCP server. One per `codeiq mcp` process. Tools
@@ -65,7 +74,20 @@ func (s *Server) Serve(ctx context.Context, transport mcpsdk.Transport) error {
6574
Name: s.opts.Name,
6675
Version: s.opts.Version,
6776
}
68-
sdkSrv := mcpsdk.NewServer(impl, nil)
77+
// Wire an InitializedHandler so we can ask the client for its workspace
78+
// roots once the session is initialised. compareRootsWithClient runs
79+
// best-effort: ListRoots may be unsupported by the client, in which case
80+
// we silently keep our boot-time resolution. Mismatches go to stderr as
81+
// a warning but do not swap the open Kuzu handle (out of scope for this
82+
// PR; tracked as a follow-up).
83+
sdkOpts := &mcpsdk.ServerOptions{}
84+
if s.opts.ResolvedRoot != "" {
85+
expected := s.opts.ResolvedRoot
86+
sdkOpts.InitializedHandler = func(ctx context.Context, req *mcpsdk.InitializedRequest) {
87+
compareRootsWithClient(ctx, req.Session, expected)
88+
}
89+
}
90+
sdkSrv := mcpsdk.NewServer(impl, sdkOpts)
6991

7092
s.mu.Lock()
7193
for _, t := range s.registry.All() {
@@ -76,3 +98,57 @@ func (s *Server) Serve(ctx context.Context, transport mcpsdk.Transport) error {
7698

7799
return sdkSrv.Run(ctx, transport)
78100
}
101+
102+
// compareRootsWithClient calls session.ListRoots and emits a stderr warning
103+
// when the client's roots do not include the boot-resolved root. Best-effort:
104+
// errors are swallowed (the client may not advertise roots capability).
105+
//
106+
// The path comparison normalises with filepath.Abs+Clean to absorb trailing
107+
// slashes and symlink-equivalent prefixes. The `file://` URI shape is also
108+
// supported because some MCP clients (Claude Code) emit roots as file URIs.
109+
func compareRootsWithClient(ctx context.Context, ss *mcpsdk.ServerSession, expected string) {
110+
expectedAbs, err := filepath.Abs(expected)
111+
if err != nil {
112+
return
113+
}
114+
expectedAbs = filepath.Clean(expectedAbs)
115+
116+
res, err := ss.ListRoots(ctx, nil)
117+
if err != nil || res == nil || len(res.Roots) == 0 {
118+
return // client didn't expose roots — keep our boot resolution
119+
}
120+
var clientRoots []string
121+
matched := false
122+
for _, r := range res.Roots {
123+
p := uriToPath(r.URI)
124+
abs, err := filepath.Abs(p)
125+
if err != nil {
126+
continue
127+
}
128+
abs = filepath.Clean(abs)
129+
clientRoots = append(clientRoots, abs)
130+
if abs == expectedAbs {
131+
matched = true
132+
break
133+
}
134+
}
135+
if !matched {
136+
fmt.Fprintf(os.Stderr,
137+
"codeiq mcp: WARNING — boot-resolved project root %q is not among "+
138+
"the client's workspace roots %v. The MCP server will keep using %q. "+
139+
"To switch, restart codeiq with that path as the positional arg or "+
140+
"set CODEIQ_PROJECT_ROOT.\n",
141+
expectedAbs, clientRoots, expectedAbs)
142+
}
143+
}
144+
145+
// uriToPath unwraps `file://<path>` URIs into bare filesystem paths. MCP
146+
// roots are declared as URIs per the spec; clients that send a bare path are
147+
// also accepted as a kindness.
148+
func uriToPath(uri string) string {
149+
const prefix = "file://"
150+
if len(uri) >= len(prefix) && uri[:len(prefix)] == prefix {
151+
return uri[len(prefix):]
152+
}
153+
return uri
154+
}

internal/mcp/server_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,25 @@ func TestRegistryRejectsDuplicateAndEmpty(t *testing.T) {
160160
t.Fatalf("expected nil-handler error")
161161
}
162162
}
163+
164+
// TestServerWithResolvedRootInitializesCleanly verifies the InitializedHandler
165+
// path doesn't break the handshake when ResolvedRoot is set. The client in
166+
// this test doesn't expose Roots capability, so compareRootsWithClient hits
167+
// its silent-skip branch (ListRoots returns an error).
168+
func TestServerWithResolvedRootInitializesCleanly(t *testing.T) {
169+
srv, err := mcp.NewServer(mcp.ServerOptions{
170+
Name: "codeiq-rooted",
171+
Version: "0",
172+
ResolvedRoot: t.TempDir(),
173+
})
174+
if err != nil {
175+
t.Fatalf("NewServer: %v", err)
176+
}
177+
sess, cleanup := connectInMemory(t, srv)
178+
defer cleanup()
179+
180+
got := sess.InitializeResult()
181+
if got == nil || got.ServerInfo == nil || got.ServerInfo.Name != "codeiq-rooted" {
182+
t.Fatalf("handshake did not complete: %+v", got)
183+
}
184+
}

internal/projectroot/resolver.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Package projectroot is the layered project-root resolver used by every
2+
// CLI subcommand and the MCP server.
3+
//
4+
// Resolution order (highest wins):
5+
//
6+
// 1. Explicit positional argument (the legacy behavior; `codeiq <cmd> <path>`).
7+
// 2. `CODEIQ_PROJECT_ROOT` environment variable. Useful for wrappers and CI.
8+
// 3. Walk up from the current working directory looking for `.codeiq/`
9+
// (already-indexed project; strongest signal that this is the root).
10+
// 4. Walk up from the current working directory looking for `.git/` (repo root).
11+
// 5. Error with an actionable message.
12+
//
13+
// The MCP server adds a sixth signal at the top of the chain — the MCP
14+
// client's `ListRoots` response — wired separately in `internal/mcp` because
15+
// it requires an active session.
16+
package projectroot
17+
18+
import (
19+
"errors"
20+
"fmt"
21+
"os"
22+
"path/filepath"
23+
)
24+
25+
// EnvVar is the environment variable consulted by Resolve.
26+
const EnvVar = "CODEIQ_PROJECT_ROOT"
27+
28+
// Markers walked up the directory tree.
29+
const (
30+
graphMarker = ".codeiq/graph/codeiq.kuzu" // strongest signal
31+
gitMarker = ".git" // fallback
32+
)
33+
34+
// ErrNotFound is returned when no resolution succeeds.
35+
var ErrNotFound = errors.New("project root could not be resolved from arg, " + EnvVar + ", or filesystem walk-up")
36+
37+
// Options bundles the resolution inputs. Pass empty strings to skip a layer.
38+
// - Arg: the positional argument (or "" if the user didn't supply one).
39+
// - EnvValue: the value of CODEIQ_PROJECT_ROOT (or "" if unset).
40+
// - CWD: the current working directory (typically os.Getwd()).
41+
type Options struct {
42+
Arg string
43+
EnvValue string
44+
CWD string
45+
}
46+
47+
// Resolve runs the layered resolution chain. Returns an absolute, validated
48+
// directory path on success.
49+
//
50+
// Any non-empty Arg or EnvValue that points at a non-directory is an error
51+
// (we don't silently fall through user-supplied paths — it's almost always
52+
// a typo we want surfaced).
53+
func Resolve(opts Options) (string, error) {
54+
if opts.Arg != "" {
55+
return validateDir(opts.Arg, "argument")
56+
}
57+
if opts.EnvValue != "" {
58+
return validateDir(opts.EnvValue, EnvVar)
59+
}
60+
if opts.CWD == "" {
61+
return "", ErrNotFound
62+
}
63+
if root, ok := WalkUp(opts.CWD); ok {
64+
return root, nil
65+
}
66+
return "", ErrNotFound
67+
}
68+
69+
// WalkUp walks up from start looking for `.codeiq/graph/codeiq.kuzu` first
70+
// (already-indexed), then `.git` (repo root). Returns the matching ancestor
71+
// directory and true, or ("", false).
72+
//
73+
// start must be an absolute path; if not, it's resolved against the current
74+
// working directory at call time.
75+
func WalkUp(start string) (string, bool) {
76+
abs, err := filepath.Abs(start)
77+
if err != nil {
78+
return "", false
79+
}
80+
// First pass: prefer .codeiq/ because it tells us the project has been
81+
// indexed (the user almost certainly meant THIS root). Second pass: fall
82+
// back to .git/ because nearly every codebase has one.
83+
for _, marker := range []string{graphMarker, gitMarker} {
84+
if hit, ok := walkUpFor(abs, marker); ok {
85+
return hit, true
86+
}
87+
}
88+
return "", false
89+
}
90+
91+
// walkUpFor walks dir → dir/.. → dir/../.. looking for marker. Stops at
92+
// filesystem root.
93+
func walkUpFor(dir, marker string) (string, bool) {
94+
for {
95+
candidate := filepath.Join(dir, marker)
96+
if _, err := os.Stat(candidate); err == nil {
97+
return dir, true
98+
}
99+
parent := filepath.Dir(dir)
100+
if parent == dir { // hit filesystem root
101+
return "", false
102+
}
103+
dir = parent
104+
}
105+
}
106+
107+
// FromArgs is the call-site sugar used by every CLI subcommand. It bundles
108+
// args (the cobra positional slice), the env, and the cwd into Options and
109+
// runs Resolve. Cobra's `MaximumNArgs(1)` plus this helper means subcommands
110+
// stay tiny.
111+
func FromArgs(args []string) (string, error) {
112+
cwd, _ := os.Getwd() // best-effort; if it fails Resolve falls through to ErrNotFound
113+
arg := ""
114+
if len(args) > 0 {
115+
arg = args[0]
116+
}
117+
return Resolve(Options{
118+
Arg: arg,
119+
EnvValue: os.Getenv(EnvVar),
120+
CWD: cwd,
121+
})
122+
}
123+
124+
// validateDir absolute-izes p and confirms it's an existing directory.
125+
// label is for the error message ("argument" / "CODEIQ_PROJECT_ROOT").
126+
func validateDir(p, label string) (string, error) {
127+
abs, err := filepath.Abs(p)
128+
if err != nil {
129+
return "", fmt.Errorf("resolve %s %q: %w", label, p, err)
130+
}
131+
st, err := os.Stat(abs)
132+
if err != nil {
133+
return "", fmt.Errorf("%s %q does not exist", label, abs)
134+
}
135+
if !st.IsDir() {
136+
return "", fmt.Errorf("%s %q is not a directory", label, abs)
137+
}
138+
return abs, nil
139+
}

0 commit comments

Comments
 (0)