diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 3fe7874b..7aebd773 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -117,8 +117,9 @@ To register with Claude Code, add to .mcp.json at the repo root: MaxDepth: maxDepth, } srv, err := mcp.NewServer(mcp.ServerOptions{ - Name: "CODE MCP", - Version: buildinfo.Version, + Name: "CODE MCP", + Version: buildinfo.Version, + ResolvedRoot: root, }) if err != nil { return err diff --git a/internal/cli/util.go b/internal/cli/util.go index fb3a851d..19b6e429 100644 --- a/internal/cli/util.go +++ b/internal/cli/util.go @@ -2,38 +2,39 @@ package cli import ( "encoding/json" - "fmt" + "errors" "io" - "os" - "path/filepath" + "github.com/randomcodespace/codeiq/internal/projectroot" "github.com/randomcodespace/codeiq/internal/query" ) // resolvePath turns the optional [path] positional that most subcommands -// accept into an absolute, directory-validated path. An empty args slice is -// the current working directory. A non-empty args slice uses args[0]. +// accept into an absolute, directory-validated project root. // -// Returns a usageError when the resolved path does not exist or is not a -// directory — that path-type problem is a user-input issue (exit code 1) per -// root.go's exit-code mapping. +// Resolution order (highest wins): explicit positional argument, then the +// CODEIQ_PROJECT_ROOT environment variable, then walking up from the current +// working directory looking for `.codeiq/graph/codeiq.kuzu` (already-indexed), +// then `.git/` (repo root). The walk-up makes `codeiq ` "just work" when +// invoked from inside an indexed project — most relevant for MCP-client +// configs that previously needed a hardcoded path arg. +// +// Returns a usageError on any resolution failure (no path arg, no env, and +// the walk-up found nothing) — exit code 1 per root.go's exit-code mapping. func resolvePath(args []string) (string, error) { - path := "." - if len(args) >= 1 && args[0] != "" { - path = args[0] - } - abs, err := filepath.Abs(path) + root, err := projectroot.FromArgs(args) if err != nil { - return "", fmt.Errorf("resolve %q: %w", path, err) - } - st, err := os.Stat(abs) - if err != nil { - return "", newUsageError("path %q does not exist", abs) - } - if !st.IsDir() { - return "", newUsageError("path %q is not a directory", abs) + if errors.Is(err, projectroot.ErrNotFound) { + return "", newUsageError( + "could not resolve project root.\n" + + " Try one of:\n" + + " codeiq /path/to/project\n" + + " CODEIQ_PROJECT_ROOT=/path/to/project codeiq \n" + + " cd /path/to/project && codeiq # walk-up finds .codeiq/ or .git/") + } + return "", newUsageError("%s", err.Error()) } - return abs, nil + return root, nil } // printOrdered writes a query.OrderedMap (or any other deterministic diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 1343740b..ca8d04ce 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -3,6 +3,8 @@ package mcp import ( "context" "fmt" + "os" + "path/filepath" "sync" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -15,6 +17,13 @@ type ServerOptions struct { Name string // Version of the codeiq binary (build-info Version string). Version string + // ResolvedRoot is the project root the server already resolved at boot + // (via projectroot.Resolve). When the connected MCP client also exposes + // roots via ListRoots, we compare the two and log a warning to stderr + // if they disagree — but we do not swap the open Kuzu store mid-flight + // (that's a larger refactor; tracked as a follow-up). Empty string + // disables the ListRoots check. + ResolvedRoot string } // 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 { Name: s.opts.Name, Version: s.opts.Version, } - sdkSrv := mcpsdk.NewServer(impl, nil) + // Wire an InitializedHandler so we can ask the client for its workspace + // roots once the session is initialised. compareRootsWithClient runs + // best-effort: ListRoots may be unsupported by the client, in which case + // we silently keep our boot-time resolution. Mismatches go to stderr as + // a warning but do not swap the open Kuzu handle (out of scope for this + // PR; tracked as a follow-up). + sdkOpts := &mcpsdk.ServerOptions{} + if s.opts.ResolvedRoot != "" { + expected := s.opts.ResolvedRoot + sdkOpts.InitializedHandler = func(ctx context.Context, req *mcpsdk.InitializedRequest) { + compareRootsWithClient(ctx, req.Session, expected) + } + } + sdkSrv := mcpsdk.NewServer(impl, sdkOpts) s.mu.Lock() for _, t := range s.registry.All() { @@ -76,3 +98,57 @@ func (s *Server) Serve(ctx context.Context, transport mcpsdk.Transport) error { return sdkSrv.Run(ctx, transport) } + +// compareRootsWithClient calls session.ListRoots and emits a stderr warning +// when the client's roots do not include the boot-resolved root. Best-effort: +// errors are swallowed (the client may not advertise roots capability). +// +// The path comparison normalises with filepath.Abs+Clean to absorb trailing +// slashes and symlink-equivalent prefixes. The `file://` URI shape is also +// supported because some MCP clients (Claude Code) emit roots as file URIs. +func compareRootsWithClient(ctx context.Context, ss *mcpsdk.ServerSession, expected string) { + expectedAbs, err := filepath.Abs(expected) + if err != nil { + return + } + expectedAbs = filepath.Clean(expectedAbs) + + res, err := ss.ListRoots(ctx, nil) + if err != nil || res == nil || len(res.Roots) == 0 { + return // client didn't expose roots — keep our boot resolution + } + var clientRoots []string + matched := false + for _, r := range res.Roots { + p := uriToPath(r.URI) + abs, err := filepath.Abs(p) + if err != nil { + continue + } + abs = filepath.Clean(abs) + clientRoots = append(clientRoots, abs) + if abs == expectedAbs { + matched = true + break + } + } + if !matched { + fmt.Fprintf(os.Stderr, + "codeiq mcp: WARNING — boot-resolved project root %q is not among "+ + "the client's workspace roots %v. The MCP server will keep using %q. "+ + "To switch, restart codeiq with that path as the positional arg or "+ + "set CODEIQ_PROJECT_ROOT.\n", + expectedAbs, clientRoots, expectedAbs) + } +} + +// uriToPath unwraps `file://` URIs into bare filesystem paths. MCP +// roots are declared as URIs per the spec; clients that send a bare path are +// also accepted as a kindness. +func uriToPath(uri string) string { + const prefix = "file://" + if len(uri) >= len(prefix) && uri[:len(prefix)] == prefix { + return uri[len(prefix):] + } + return uri +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 200c2394..4537109f 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -160,3 +160,25 @@ func TestRegistryRejectsDuplicateAndEmpty(t *testing.T) { t.Fatalf("expected nil-handler error") } } + +// TestServerWithResolvedRootInitializesCleanly verifies the InitializedHandler +// path doesn't break the handshake when ResolvedRoot is set. The client in +// this test doesn't expose Roots capability, so compareRootsWithClient hits +// its silent-skip branch (ListRoots returns an error). +func TestServerWithResolvedRootInitializesCleanly(t *testing.T) { + srv, err := mcp.NewServer(mcp.ServerOptions{ + Name: "codeiq-rooted", + Version: "0", + ResolvedRoot: t.TempDir(), + }) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + sess, cleanup := connectInMemory(t, srv) + defer cleanup() + + got := sess.InitializeResult() + if got == nil || got.ServerInfo == nil || got.ServerInfo.Name != "codeiq-rooted" { + t.Fatalf("handshake did not complete: %+v", got) + } +} diff --git a/internal/projectroot/resolver.go b/internal/projectroot/resolver.go new file mode 100644 index 00000000..a0554881 --- /dev/null +++ b/internal/projectroot/resolver.go @@ -0,0 +1,139 @@ +// Package projectroot is the layered project-root resolver used by every +// CLI subcommand and the MCP server. +// +// Resolution order (highest wins): +// +// 1. Explicit positional argument (the legacy behavior; `codeiq `). +// 2. `CODEIQ_PROJECT_ROOT` environment variable. Useful for wrappers and CI. +// 3. Walk up from the current working directory looking for `.codeiq/` +// (already-indexed project; strongest signal that this is the root). +// 4. Walk up from the current working directory looking for `.git/` (repo root). +// 5. Error with an actionable message. +// +// The MCP server adds a sixth signal at the top of the chain — the MCP +// client's `ListRoots` response — wired separately in `internal/mcp` because +// it requires an active session. +package projectroot + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// EnvVar is the environment variable consulted by Resolve. +const EnvVar = "CODEIQ_PROJECT_ROOT" + +// Markers walked up the directory tree. +const ( + graphMarker = ".codeiq/graph/codeiq.kuzu" // strongest signal + gitMarker = ".git" // fallback +) + +// ErrNotFound is returned when no resolution succeeds. +var ErrNotFound = errors.New("project root could not be resolved from arg, " + EnvVar + ", or filesystem walk-up") + +// Options bundles the resolution inputs. Pass empty strings to skip a layer. +// - Arg: the positional argument (or "" if the user didn't supply one). +// - EnvValue: the value of CODEIQ_PROJECT_ROOT (or "" if unset). +// - CWD: the current working directory (typically os.Getwd()). +type Options struct { + Arg string + EnvValue string + CWD string +} + +// Resolve runs the layered resolution chain. Returns an absolute, validated +// directory path on success. +// +// Any non-empty Arg or EnvValue that points at a non-directory is an error +// (we don't silently fall through user-supplied paths — it's almost always +// a typo we want surfaced). +func Resolve(opts Options) (string, error) { + if opts.Arg != "" { + return validateDir(opts.Arg, "argument") + } + if opts.EnvValue != "" { + return validateDir(opts.EnvValue, EnvVar) + } + if opts.CWD == "" { + return "", ErrNotFound + } + if root, ok := WalkUp(opts.CWD); ok { + return root, nil + } + return "", ErrNotFound +} + +// WalkUp walks up from start looking for `.codeiq/graph/codeiq.kuzu` first +// (already-indexed), then `.git` (repo root). Returns the matching ancestor +// directory and true, or ("", false). +// +// start must be an absolute path; if not, it's resolved against the current +// working directory at call time. +func WalkUp(start string) (string, bool) { + abs, err := filepath.Abs(start) + if err != nil { + return "", false + } + // First pass: prefer .codeiq/ because it tells us the project has been + // indexed (the user almost certainly meant THIS root). Second pass: fall + // back to .git/ because nearly every codebase has one. + for _, marker := range []string{graphMarker, gitMarker} { + if hit, ok := walkUpFor(abs, marker); ok { + return hit, true + } + } + return "", false +} + +// walkUpFor walks dir → dir/.. → dir/../.. looking for marker. Stops at +// filesystem root. +func walkUpFor(dir, marker string) (string, bool) { + for { + candidate := filepath.Join(dir, marker) + if _, err := os.Stat(candidate); err == nil { + return dir, true + } + parent := filepath.Dir(dir) + if parent == dir { // hit filesystem root + return "", false + } + dir = parent + } +} + +// FromArgs is the call-site sugar used by every CLI subcommand. It bundles +// args (the cobra positional slice), the env, and the cwd into Options and +// runs Resolve. Cobra's `MaximumNArgs(1)` plus this helper means subcommands +// stay tiny. +func FromArgs(args []string) (string, error) { + cwd, _ := os.Getwd() // best-effort; if it fails Resolve falls through to ErrNotFound + arg := "" + if len(args) > 0 { + arg = args[0] + } + return Resolve(Options{ + Arg: arg, + EnvValue: os.Getenv(EnvVar), + CWD: cwd, + }) +} + +// validateDir absolute-izes p and confirms it's an existing directory. +// label is for the error message ("argument" / "CODEIQ_PROJECT_ROOT"). +func validateDir(p, label string) (string, error) { + abs, err := filepath.Abs(p) + if err != nil { + return "", fmt.Errorf("resolve %s %q: %w", label, p, err) + } + st, err := os.Stat(abs) + if err != nil { + return "", fmt.Errorf("%s %q does not exist", label, abs) + } + if !st.IsDir() { + return "", fmt.Errorf("%s %q is not a directory", label, abs) + } + return abs, nil +} diff --git a/internal/projectroot/resolver_test.go b/internal/projectroot/resolver_test.go new file mode 100644 index 00000000..30e1819f --- /dev/null +++ b/internal/projectroot/resolver_test.go @@ -0,0 +1,157 @@ +package projectroot + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestResolve_ArgWinsOverEnvAndCWD(t *testing.T) { + dir := t.TempDir() + cwdMarker := filepath.Join(t.TempDir(), ".codeiq", "graph", "codeiq.kuzu") + _ = os.MkdirAll(cwdMarker, 0o755) + + got, err := Resolve(Options{ + Arg: dir, + EnvValue: "/should/be/ignored", + CWD: filepath.Dir(filepath.Dir(filepath.Dir(cwdMarker))), + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != dir { + t.Fatalf("got %q, want %q", got, dir) + } +} + +func TestResolve_EnvWinsOverCWD(t *testing.T) { + envDir := t.TempDir() + cwdMarker := filepath.Join(t.TempDir(), ".codeiq", "graph", "codeiq.kuzu") + _ = os.MkdirAll(cwdMarker, 0o755) + + got, err := Resolve(Options{ + Arg: "", + EnvValue: envDir, + CWD: filepath.Dir(filepath.Dir(filepath.Dir(cwdMarker))), + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != envDir { + t.Fatalf("got %q, want %q", got, envDir) + } +} + +func TestResolve_WalkUpFindsCodeIQ(t *testing.T) { + root := t.TempDir() + nested := filepath.Join(root, "src", "deep", "nested") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, ".codeiq", "graph", "codeiq.kuzu"), 0o755); err != nil { + t.Fatal(err) + } + got, err := Resolve(Options{CWD: nested}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != root { + t.Fatalf("got %q, want %q", got, root) + } +} + +func TestResolve_WalkUpFallsBackToGit(t *testing.T) { + root := t.TempDir() + nested := filepath.Join(root, "a", "b") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + got, err := Resolve(Options{CWD: nested}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != root { + t.Fatalf("got %q, want %q", got, root) + } +} + +func TestResolve_PrefersCodeIQOverGitWhenBothExist(t *testing.T) { + // Outer git root with an inner .codeiq subdirectory containing a graph. + // Walk-up should stop at the inner .codeiq marker because it's a stronger + // signal than the outer .git. + gitRoot := t.TempDir() + codeiqRoot := filepath.Join(gitRoot, "sub") + if err := os.MkdirAll(filepath.Join(gitRoot, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(codeiqRoot, ".codeiq", "graph", "codeiq.kuzu"), 0o755); err != nil { + t.Fatal(err) + } + got, err := Resolve(Options{CWD: codeiqRoot}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != codeiqRoot { + t.Fatalf("got %q, want %q (preferred .codeiq over outer .git)", got, codeiqRoot) + } +} + +func TestResolve_NoSignals(t *testing.T) { + // CWD with no .codeiq and no .git anywhere up to /. + // t.TempDir() can sit under various roots; the test asserts on the error + // shape rather than literally walking the host filesystem. + dir, err := os.MkdirTemp("", "projectroot-nosignals-*") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + _, err = Resolve(Options{CWD: dir}) + if err == nil { + t.Fatal("expected ErrNotFound, got nil") + } + if !errors.Is(err, ErrNotFound) { + // May fail on hosts where the tempdir's ancestors include .git or .codeiq. + // Skip rather than fail in that case. + t.Skipf("host tempdir ancestor has a marker: %v", err) + } +} + +func TestResolve_ArgPointingAtMissingPathErrors(t *testing.T) { + _, err := Resolve(Options{Arg: "/this/path/really/does/not/exist/9c1f4"}) + if err == nil { + t.Fatal("expected error for missing arg path, got nil") + } +} + +func TestResolve_ArgPointingAtFileErrors(t *testing.T) { + f, err := os.CreateTemp("", "projectroot-isfile-*") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(f.Name()) }) + f.Close() + _, err = Resolve(Options{Arg: f.Name()}) + if err == nil { + t.Fatal("expected error for arg pointing at a file, got nil") + } +} + +func TestWalkUp_StopsAtFilesystemRoot(t *testing.T) { + // Calling WalkUp on a path with no markers anywhere up to / must return + // ("", false) rather than loop forever. + dir, err := os.MkdirTemp("", "projectroot-walkup-root-*") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + _, ok := WalkUp(dir) + // May be true if /tmp is under a git/.codeiq ancestor on this host — + // don't assert false; just confirm no hang. + _ = ok +}