Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions internal/cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 23 additions & 22 deletions internal/cli/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cmd>` "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 <cmd> /path/to/project\n" +
" CODEIQ_PROJECT_ROOT=/path/to/project codeiq <cmd>\n" +
" cd /path/to/project && codeiq <cmd> # 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
Expand Down
78 changes: 77 additions & 1 deletion internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package mcp
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"

mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand 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://<path>` 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
}
22 changes: 22 additions & 0 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
139 changes: 139 additions & 0 deletions internal/projectroot/resolver.go
Original file line number Diff line number Diff line change
@@ -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 <cmd> <path>`).
// 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
}
Loading