From 46d3b0edebd5aaf8666eb37fe2b1c9286c8a501b Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:10:34 +0200 Subject: [PATCH 1/2] fix: automatic recognition of root --- README.md | 28 +++++-- docs/ai-assistant-guidance.md | 2 +- docs/codegraph-roadmap.md | 6 +- examples/README.md | 6 +- examples/claude-desktop-mcp.json | 8 +- examples/codex-mcp.toml | 2 +- examples/gemini-mcp.json | 8 +- internal/cli/app.go | 102 ++++++++++++++---------- internal/cli/commands.go | 6 +- internal/cli/mcpinstall.go | 2 +- internal/config/reporoot.go | 50 ++++++++++++ internal/config/reporoot_test.go | 124 ++++++++++++++++++++++++++++++ internal/mcp/server.go | 46 ++++++----- internal/mcp/server_bench_test.go | 2 +- internal/mcp/server_test.go | 8 +- 15 files changed, 306 insertions(+), 94 deletions(-) create mode 100644 internal/config/reporoot.go create mode 100644 internal/config/reporoot_test.go diff --git a/README.md b/README.md index cf6bf9c..a0b2e84 100644 --- a/README.md +++ b/README.md @@ -144,9 +144,11 @@ codegraph index . ### 4. Start the MCP server ```bash -codegraph serve --repo-root . +codegraph serve ``` +Repo root is auto-detected from git (or falls back to your current working directory). + That's it. Your AI assistant now has deep structural code understanding. --- @@ -169,7 +171,7 @@ codegraph install "mcpServers": { "codegraph": { "command": "codegraph", - "args": ["serve", "--repo-root", "."] + "args": ["serve"] } } } @@ -184,7 +186,7 @@ codegraph install "mcpServers": { "codegraph": { "command": "codegraph", - "args": ["serve", "--repo-root", "."] + "args": ["serve"] } } } @@ -197,7 +199,7 @@ codegraph install ```toml [mcp_servers.codegraph] command = "codegraph" -args = ["serve", "--repo-root", "/path/to/repo"] +args = ["serve"] startup_timeout_sec = 60 ``` @@ -278,7 +280,7 @@ codegraph watch # Watch and auto-reindex codegraph clean # Clean database # MCP Server -codegraph serve --repo-root # Start MCP server +codegraph serve [--repo-root ] # Start MCP server (auto-detects repo root) # Query codegraph stats # Graph statistics @@ -292,7 +294,7 @@ codegraph impact --symbol # Impact analysis codegraph affected-tests [--stdin] # Tests affected by changed files # Visualization & Export -codegraph visualize --repo-root # Interactive D3.js graph +codegraph visualize [--repo-root ] # Interactive D3.js graph (auto-detects repo root) codegraph graph export --format dot # Export as Graphviz DOT codegraph graph export --format json # Export as JSON @@ -307,10 +309,12 @@ codegraph benchmark # Token savings benchmark git diff --name-only | codegraph affected-tests --stdin # CI integration -TESTS=$(git diff --name-only HEAD~1 | codegraph affected-tests --stdin --repo-root .) +TESTS=$(git diff --name-only HEAD~1 | codegraph affected-tests --stdin) go test $TESTS ``` +Replace `--repo-root .` with nothing if you are already in the repo you want to inspect. + --- ## Optional: Embeddings & Agentic Mode @@ -397,6 +401,16 @@ Created with `codegraph config init --repo .` at `.codegraph/config.json`: } ``` +### Repo Root Resolution + +When `--repo-root` (CLI) or `repo_root` (MCP tool parameter) is omitted, codegraph resolves the repo root using: + +1. `--repo-root` CLI flag (explicit) +2. Per-call `repo_root` MCP tool parameter +3. `git rev-parse --show-toplevel` from the current working directory +4. `os.Getwd()` (current working directory) +5. Return error + ### Ignore file Create `.codegraphignore` in the repo root (same syntax as `.gitignore`): diff --git a/docs/ai-assistant-guidance.md b/docs/ai-assistant-guidance.md index b5e381b..a704583 100644 --- a/docs/ai-assistant-guidance.md +++ b/docs/ai-assistant-guidance.md @@ -6,7 +6,7 @@ This repository ships `codegraph`, a local-first code context engine and MCP ser - Run `codegraph install` once to set up local configuration and optional editor/tool integration. - Run `codegraph index ` to build the initial graph. -- Use `codegraph serve --repo-root ` for stdio MCP integration. +- Use `codegraph serve` for stdio MCP integration (repo root auto-detected). - Prefer MCP tools for repository context instead of repeated shelling out. ## Guidance For Tool-Driven Work diff --git a/docs/codegraph-roadmap.md b/docs/codegraph-roadmap.md index fcf3a68..e368f6c 100644 --- a/docs/codegraph-roadmap.md +++ b/docs/codegraph-roadmap.md @@ -37,11 +37,11 @@ Observed in `internal/cli/app.go:Run` dispatch and `printUsage`: - `doctor` - `config ` - `benchmark` -- `serve --repo-root ` +- `serve [--repo-root ]` - `watch ` - `graph export [--format json|dot] ...` -- `affected-tests [--repo-root PATH] [--stdin] [--json] [--limit N] ...` -- `visualize [--repo-root PATH] ...` +- `affected-tests [--repo-root PATH] [--stdin] [--json] [--limit N] ...` (repo root auto-detects when omitted) +- `visualize [--repo-root PATH] ...` (repo root auto-detects when omitted) - `clean [repo-path] [--vacuum]` Notes: diff --git a/examples/README.md b/examples/README.md index d2c7219..e668429 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,7 +13,7 @@ This directory contains minimal `codegraph` MCP server configuration examples fo All examples start the same server command: ```text -codegraph serve --repo-root /absolute/path/to/repo +codegraph serve ``` Replace `/absolute/path/to/repo` with the repository you want `codegraph` to serve. @@ -23,7 +23,7 @@ Replace `/absolute/path/to/repo` with the repository you want `codegraph` to ser `codex-mcp.toml` includes: - `command = "codegraph"` -- `args = ["serve", "--repo-root", "..."]` +- `args = ["serve"]` - `startup_timeout_sec = 60` Keep `startup_timeout_sec = 60` so Codex allows enough time for the server to open or migrate the local SQLite database on startup. @@ -33,7 +33,7 @@ Keep `startup_timeout_sec = 60` so Codex allows enough time for the server to op The JSON examples are intentionally minimal: - `command`: `codegraph` -- `args`: `["serve", "--repo-root", "/absolute/path/to/repo"]` +- `args`: `["serve"]` If `codegraph` is not on `PATH`, replace `command` and `args` with a form that launches it through `go run` from the repository checkout. diff --git a/examples/claude-desktop-mcp.json b/examples/claude-desktop-mcp.json index 10f043d..00bbad5 100644 --- a/examples/claude-desktop-mcp.json +++ b/examples/claude-desktop-mcp.json @@ -2,11 +2,9 @@ "mcpServers": { "codegraph": { "command": "codegraph", - "args": [ - "serve", - "--repo-root", - "/absolute/path/to/repo" - ] + "args": [ + "serve" + ] } } } diff --git a/examples/codex-mcp.toml b/examples/codex-mcp.toml index b7a318c..e1dd7bf 100644 --- a/examples/codex-mcp.toml +++ b/examples/codex-mcp.toml @@ -1,4 +1,4 @@ [mcp_servers.codegraph] command = "codegraph" -args = ["serve", "--repo-root", "/absolute/path/to/repo"] +args = ["serve"] startup_timeout_sec = 60 diff --git a/examples/gemini-mcp.json b/examples/gemini-mcp.json index 10f043d..00bbad5 100644 --- a/examples/gemini-mcp.json +++ b/examples/gemini-mcp.json @@ -2,11 +2,9 @@ "mcpServers": { "codegraph": { "command": "codegraph", - "args": [ - "serve", - "--repo-root", - "/absolute/path/to/repo" - ] + "args": [ + "serve" + ] } } } diff --git a/internal/cli/app.go b/internal/cli/app.go index 93876d1..69e696f 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -155,11 +155,11 @@ func runDoctor(ctx context.Context, cfg config.Config, stdout io.Writer, args [] deep := fs.Bool("deep", false, "run deeper (potentially slow) diagnostics, including integrity_check") repoRootFlag := fs.String("repo-root", "", "repository root to inspect (optional)") - defaultRepoRoot := "" - if config.IsRepoDBDir(cfg.DBDir) { - defaultRepoRoot = "." + repoRootCandidate, err := parseOptionalRepoRootArg(fs, args, repoRootFlag, "") + if err != nil { + return err } - repoRoot, err := parseOptionalRepoRootArg(fs, args, repoRootFlag, defaultRepoRoot) + repoRoot, err := config.ResolveRepoRoot(repoRootCandidate, "") if err != nil { return err } @@ -601,8 +601,8 @@ func runInstall(stdout io.Writer) error { }`) // Always print manual snippets as a fallback / reference. - codexSnippet := fmt.Sprintf("[mcp_servers.codegraph]\ncommand = %q\nargs = [\"serve\", \"--repo-root\", \"/absolute/path/to/repo\"]\nstartup_timeout_sec = 60", appname.BinaryName) - clientSnippet := fmt.Sprintf(`{"mcpServers":{"codegraph":{"command":%q,"args":["serve","--repo-root","/absolute/path/to/repo"]}}}`, appname.BinaryName) + codexSnippet := fmt.Sprintf("[mcp_servers.codegraph]\ncommand = %q\nargs = [\"serve\"]\nstartup_timeout_sec = 60", appname.BinaryName) + clientSnippet := fmt.Sprintf(`{"mcpServers":{"codegraph":{"command":%q,"args":["serve"]}}}`, appname.BinaryName) fmt.Fprintln(stdout) fmt.Fprintln(stdout, "Manual MCP snippets (if auto-configure did not apply):") fmt.Fprintln(stdout) @@ -639,9 +639,13 @@ func runIndex(ctx context.Context, cfg config.Config, stdout io.Writer, cmdName if err := fs.Parse(filtered); err != nil { return err } - repoRoot := "." + repoRootCandidate := "" if fs.NArg() > 0 { - repoRoot = fs.Arg(0) + repoRootCandidate = fs.Arg(0) + } + repoRoot, err := config.ResolveRepoRoot(repoRootCandidate, "") + if err != nil { + return err } app, repo, repoID, err := openApp(ctx, cfg, repoRoot) if err != nil { @@ -1150,11 +1154,13 @@ func runStats(ctx context.Context, cfg config.Config, stdout io.Writer, args []s if err := fs.Parse(args); err != nil { return err } - repoRoot := "." - if *repoRootFlag != "" { - repoRoot = *repoRootFlag - } else if fs.NArg() > 0 { - repoRoot = fs.Arg(0) + repoRootCandidate := strings.TrimSpace(*repoRootFlag) + if repoRootCandidate == "" && fs.NArg() > 0 { + repoRootCandidate = fs.Arg(0) + } + repoRoot, err := config.ResolveRepoRoot(repoRootCandidate, "") + if err != nil { + return err } app, _, repoID, err := openApp(ctx, cfg, repoRoot) if err != nil { @@ -1185,15 +1191,12 @@ func runQueryCommand(ctx context.Context, cfg config.Config, stdout io.Writer, q return err } - repoRoot := "." - if *repoRootFlag != "" { - repoRoot = *repoRootFlag - } + repoRootCandidate := strings.TrimSpace(*repoRootFlag) queryValue := *queryFlag symbol := "" rest := fs.Args() - if *repoRootFlag == "" && len(rest) > 0 { - repoRoot = rest[0] + if repoRootCandidate == "" && len(rest) > 0 { + repoRootCandidate = rest[0] rest = rest[1:] } if queryValue == "" && len(rest) > 0 { @@ -1209,6 +1212,10 @@ func runQueryCommand(ctx context.Context, cfg config.Config, stdout io.Writer, q symbol = queryValue } + repoRoot, err := config.ResolveRepoRoot(repoRootCandidate, "") + if err != nil { + return err + } app, _, repoID, err := openApp(ctx, cfg, repoRoot) if err != nil { return err @@ -1309,18 +1316,20 @@ func runServe(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, if err := fs.Parse(args); err != nil { return err } - repoRoot := "." - if *repoRootFlag != "" { - repoRoot = *repoRootFlag - } else if fs.NArg() > 0 { - repoRoot = fs.Arg(0) + repoRootCandidate := strings.TrimSpace(*repoRootFlag) + if repoRootCandidate == "" && fs.NArg() > 0 { + repoRootCandidate = fs.Arg(0) + } + repoRoot, err := config.ResolveRepoRoot(repoRootCandidate, "") + if err != nil { + return err } app, repo, repoID, err := openApp(ctx, cfg, repoRoot) if err != nil { return err } defer app.Close() - server := mcp.NewServer(repo.RootPath, repoID, app.Store, app.Indexer, app.Query, stderr) + server := mcp.NewServer(repoRootCandidate, repo.RootPath, repoID, app.Store, app.Indexer, app.Query, stderr) if repoCfg, err := config.LoadRepo(repo.RootPath); err == nil { if repoCfg.Agent.Enabled || repoCfg.Agent.BaseURL != "" || repoCfg.Agent.Model != "" { server.SetAgentConfig(repoCfg.Agent.BaseURL, repoCfg.Agent.Model) @@ -1337,11 +1346,13 @@ func runWatch(ctx context.Context, cfg config.Config, stdout io.Writer, args []s if err := fs.Parse(args); err != nil { return err } - repoRoot := "." - if *repoRootFlag != "" { - repoRoot = *repoRootFlag - } else if fs.NArg() > 0 { - repoRoot = fs.Arg(0) + repoRootCandidate := strings.TrimSpace(*repoRootFlag) + if repoRootCandidate == "" && fs.NArg() > 0 { + repoRootCandidate = fs.Arg(0) + } + repoRoot, err := config.ResolveRepoRoot(repoRootCandidate, "") + if err != nil { + return err } app, repo, repoID, err := openApp(ctx, cfg, repoRoot) if err != nil { @@ -1498,18 +1509,24 @@ func runGraph(ctx context.Context, cfg config.Config, stdout io.Writer, args []s func runVisualize(ctx context.Context, cfg config.Config, stdout io.Writer, args []string) error { fs := flag.NewFlagSet("visualize", flag.ContinueOnError) fs.SetOutput(io.Discard) - repoRoot := fs.String("repo-root", ".", "repository root") + repoRootFlag := fs.String("repo-root", "", "repository root") symbol := fs.String("symbol", "", "focus on a specific symbol") output := fs.String("output", "", "write HTML to this file instead of opening a browser") depth := fs.Int("depth", 2, "traversal depth from focus symbol") if err := fs.Parse(args); err != nil { return err } - if fs.NArg() > 0 && *repoRoot == "." { - *repoRoot = fs.Arg(0) + + repoRootCandidate := strings.TrimSpace(*repoRootFlag) + if repoRootCandidate == "" && fs.NArg() > 0 { + repoRootCandidate = fs.Arg(0) + } + repoRoot, err := config.ResolveRepoRoot(repoRootCandidate, "") + if err != nil { + return err } - app, _, repoID, err := openApp(ctx, cfg, *repoRoot) + app, _, repoID, err := openApp(ctx, cfg, repoRoot) if err != nil { return err } @@ -1571,11 +1588,11 @@ func runClean(ctx context.Context, cfg config.Config, stdout io.Writer, args []s incrementalVacuum := fs.Bool("incremental-vacuum", false, "run PRAGMA incremental_vacuum (requires auto_vacuum=INCREMENTAL)") repoRootFlag := fs.String("repo-root", "", "repository root to clean") - defaultRepoRoot := "" - if config.IsRepoDBDir(cfg.DBDir) { - defaultRepoRoot = "." + repoRootCandidate, err := parseOptionalRepoRootArg(fs, args, repoRootFlag, "") + if err != nil { + return err } - repoRoot, err := parseOptionalRepoRootArg(fs, args, repoRootFlag, defaultRepoRoot) + repoRoot, err := config.ResolveRepoRoot(repoRootCandidate, "") if err != nil { return err } @@ -1962,9 +1979,10 @@ func runAffectedTests(ctx context.Context, cfg config.Config, stdout io.Writer, return err } - repoRoot := "." - if *repoRootFlag != "" { - repoRoot = *repoRootFlag + repoRootCandidate := strings.TrimSpace(*repoRootFlag) + repoRoot, err := config.ResolveRepoRoot(repoRootCandidate, "") + if err != nil { + return err } var files []string @@ -2049,7 +2067,7 @@ func printRootHelp(w io.Writer) { fmt.Fprintf(w, " %s index .\n", appname.BinaryName) fmt.Fprintf(w, " %s stats .\n", appname.BinaryName) fmt.Fprintf(w, " %s find_symbol . MySymbol\n", appname.BinaryName) - fmt.Fprintf(w, " %s serve --repo-root .\n", appname.BinaryName) + fmt.Fprintf(w, " %s serve\n", appname.BinaryName) } func formatCommandExample(ex string) string { diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 6440054..b1697fe 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -319,7 +319,7 @@ func newCommandList() []*command { { name: "serve", description: "start MCP server", - usageLines: []string{" serve --repo-root "}, + usageLines: []string{" serve [--repo-root ]"}, run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, invokedName string, args []string) error { return runServe(ctx, cfg, stdout, stderr, args) }, @@ -386,8 +386,8 @@ func newCommandList() []*command { " find_related_tests [--repo-root PATH] [--stdin] [--json] [--limit N] ...", }, examples: []string{ - "codegraph find_related_tests --repo-root . main.go", - "git diff --name-only | codegraph find_related_tests --stdin --repo-root .", + "codegraph find_related_tests main.go", + "git diff --name-only | codegraph find_related_tests --stdin", }, run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, invokedName string, args []string) error { return runAffectedTests(ctx, cfg, stdout, invokedName, args) diff --git a/internal/cli/mcpinstall.go b/internal/cli/mcpinstall.go index 88a08dd..9eaca07 100644 --- a/internal/cli/mcpinstall.go +++ b/internal/cli/mcpinstall.go @@ -45,7 +45,7 @@ func discoverTools(home string) []mcpTool { func mcpServerEntry(binaryPath string) map[string]any { return map[string]any{ "command": binaryPath, - "args": []string{"serve", "--repo-root", "."}, + "args": []string{"serve"}, } } diff --git a/internal/config/reporoot.go b/internal/config/reporoot.go new file mode 100644 index 0000000..bbafc64 --- /dev/null +++ b/internal/config/reporoot.go @@ -0,0 +1,50 @@ +package config + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strings" +) + +var ( + execCommand = exec.Command + getwd = os.Getwd +) + +// ResolveRepoRoot resolves the repository root directory using a stable fallback chain: +// 1. Explicit CLI flag value (flagValue) +// 2. Per-call MCP tool parameter (toolParam) +// 3. `git rev-parse --show-toplevel` from the current working directory +// 4. os.Getwd() +// 5. Return error +// +// Empty inputs are treated as "not provided". +func ResolveRepoRoot(flagValue, toolParam string) (string, error) { + if v := strings.TrimSpace(flagValue); v != "" { + return v, nil + } + if v := strings.TrimSpace(toolParam); v != "" { + return v, nil + } + + cmd := execCommand("git", "rev-parse", "--show-toplevel") + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = new(bytes.Buffer) + if err := cmd.Run(); err == nil { + if v := strings.TrimSpace(stdout.String()); v != "" { + return v, nil + } + } + + wd, err := getwd() + if err == nil { + if v := strings.TrimSpace(wd); v != "" { + return v, nil + } + } + + return "", fmt.Errorf("unable to resolve repo root: provide --repo-root or repo_root, or run from a git repo") +} diff --git a/internal/config/reporoot_test.go b/internal/config/reporoot_test.go new file mode 100644 index 0000000..4fd38dc --- /dev/null +++ b/internal/config/reporoot_test.go @@ -0,0 +1,124 @@ +package config + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestResolveRepoRoot_ExplicitFlagWins(t *testing.T) { + t.Cleanup(resetRepoRootTestHooks(t)) + + execCommand = func(name string, args ...string) *exec.Cmd { + t.Fatalf("execCommand should not be called when flag is set") + return nil + } + getwd = func() (string, error) { + t.Fatalf("getwd should not be called when flag is set") + return "", nil + } + + got, err := ResolveRepoRoot(" /explicit ", "/tool") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/explicit" { + t.Fatalf("got %q, want %q", got, "/explicit") + } +} + +func TestResolveRepoRoot_GitRootDetected(t *testing.T) { + t.Cleanup(resetRepoRootTestHooks(t)) + + dir := t.TempDir() + gitRoot := filepath.Join(dir, "repo") + if err := os.MkdirAll(gitRoot, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + // Use real git for this test to validate integration with `git rev-parse`. + execCommand = realExecCommand + + old, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(gitRoot); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(old) }) + + if err := runGit(gitRoot, "init"); err != nil { + t.Fatalf("git init: %v", err) + } + + got, err := ResolveRepoRoot("", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if filepath.Clean(got) != filepath.Clean(gitRoot) { + t.Fatalf("got %q, want %q", got, gitRoot) + } +} + +func TestResolveRepoRoot_CwdFallbackWhenNotGitRepo(t *testing.T) { + t.Cleanup(resetRepoRootTestHooks(t)) + + dir := t.TempDir() + + // Ensure git detection fails. + execCommand = func(name string, args ...string) *exec.Cmd { + cmd := realExecCommand(name, args...) + // Point at an empty temp dir with no .git to ensure rev-parse fails. + cmd.Dir = dir + return cmd + } + getwd = func() (string, error) { return dir, nil } + + got, err := ResolveRepoRoot("", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if filepath.Clean(got) != filepath.Clean(dir) { + t.Fatalf("got %q, want %q", got, dir) + } +} + +func TestResolveRepoRoot_ErrorWhenCwdUnavailable(t *testing.T) { + t.Cleanup(resetRepoRootTestHooks(t)) + + execCommand = func(name string, args ...string) *exec.Cmd { + // Simulate git failing. + cmd := realExecCommand(name, args...) + cmd.Dir = t.TempDir() + return cmd + } + getwd = func() (string, error) { return "", os.ErrNotExist } + + _, err := ResolveRepoRoot("", "") + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +// --- test helpers --- + +func resetRepoRootTestHooks(t *testing.T) func() { + origExec := execCommand + origGetwd := getwd + return func() { + execCommand = origExec + getwd = origGetwd + } +} + +func realExecCommand(name string, args ...string) *exec.Cmd { + return exec.Command(name, args...) +} + +func runGit(dir string, args ...string) error { + cmd := exec.Command("git", args...) + cmd.Dir = dir + return cmd.Run() +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index d5baeb3..fb3ea5e 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -13,6 +13,7 @@ import ( "sync" "github.com/isink17/codegraph/internal/agent" + "github.com/isink17/codegraph/internal/config" "github.com/isink17/codegraph/internal/framework" "github.com/isink17/codegraph/internal/indexer" "github.com/isink17/codegraph/internal/query" @@ -21,17 +22,21 @@ import ( ) type Server struct { - repoRoot string - repoID int64 - store *store.Store - indexer *indexer.Indexer - query *query.Service - errOut io.Writer - agentCfg agent.OllamaLLMConfig + cliRepoRoot string + repoRoot string + repoID int64 + store *store.Store + indexer *indexer.Indexer + query *query.Service + errOut io.Writer + agentCfg agent.OllamaLLMConfig } -func NewServer(repoRoot string, repoID int64, s *store.Store, idx *indexer.Indexer, q *query.Service, errOut io.Writer) *Server { - return &Server{repoRoot: repoRoot, repoID: repoID, store: s, indexer: idx, query: q, errOut: errOut} +// NewServer builds an MCP server. cliRepoRoot is the raw `--repo-root` flag value +// provided to `codegraph serve` (empty when omitted). repoRoot is the resolved +// active repository root for tools that operate on the initially opened repo. +func NewServer(cliRepoRoot, repoRoot string, repoID int64, s *store.Store, idx *indexer.Indexer, q *query.Service, errOut io.Writer) *Server { + return &Server{cliRepoRoot: cliRepoRoot, repoRoot: repoRoot, repoID: repoID, store: s, indexer: idx, query: q, errOut: errOut} } // SetAgentConfig configures the LLM backend for the agentic_query tool. @@ -478,6 +483,7 @@ func (s *Server) callTool(ctx context.Context, name string, raw json.RawMessage) func (s *Server) handleIndex(ctx context.Context, raw json.RawMessage, update bool) (map[string]any, error) { var req struct { + RepoRoot string `json:"repo_root"` RepoPath string `json:"repo_path"` Force bool `json:"force"` Paths []string `json:"paths"` @@ -485,16 +491,20 @@ func (s *Server) handleIndex(ctx context.Context, raw json.RawMessage, update bo if err := json.Unmarshal(raw, &req); err != nil { return nil, err } + toolParam := strings.TrimSpace(req.RepoRoot) + if toolParam == "" { + toolParam = strings.TrimSpace(req.RepoPath) + } + resolved, err := config.ResolveRepoRoot(s.cliRepoRoot, toolParam) + if err != nil { + return nil, err + } opts := indexer.Options{ - RepoRoot: s.repoRoot, + RepoRoot: resolved, Force: req.Force, Paths: req.Paths, } - if req.RepoPath != "" { - opts.RepoRoot = req.RepoPath - } var summary store.ScanSummary - var err error if update { opts.ScanKind = "update" summary, err = s.indexer.Update(ctx, opts) @@ -600,8 +610,8 @@ func writeResponse(w io.Writer, resp rpcResponse) error { func buildToolDefinitions() []map[string]any { return []map[string]any{ - toolDef("index_repo", "Index a repository into the local code graph", []string{"repo_path", "force", "paths"}, nil), - toolDef("update_graph", "Update only changed repository files in the local graph", []string{"repo_path", "force", "paths"}, nil), + toolDef("index_repo", "Index a repository into the local code graph", []string{"repo_root", "repo_path", "force", "paths"}, nil), + toolDef("update_graph", "Update only changed repository files in the local graph", []string{"repo_root", "repo_path", "force", "paths"}, nil), toolDef("find_symbol", "Find symbols by exact or fuzzy query", []string{"query", "limit", "offset"}, []string{"query"}), toolDef("find_callers", "Find callers of a symbol", []string{"symbol", "symbol_id", "limit", "offset"}, nil), toolDef("find_callees", "Find callees of a symbol", []string{"symbol", "symbol_id", "limit", "offset"}, nil), @@ -674,8 +684,8 @@ func decodePageRequest(raw json.RawMessage) (pageRequest, error) { } var toolArgumentSpecs = map[string]toolArgSpec{ - "index_repo": {properties: map[string]string{"repo_path": "string", "force": "boolean", "paths": "array"}}, - "update_graph": {properties: map[string]string{"repo_path": "string", "force": "boolean", "paths": "array"}}, + "index_repo": {properties: map[string]string{"repo_root": "string", "repo_path": "string", "force": "boolean", "paths": "array"}}, + "update_graph": {properties: map[string]string{"repo_root": "string", "repo_path": "string", "force": "boolean", "paths": "array"}}, "find_symbol": {properties: map[string]string{"query": "string", "limit": "integer", "offset": "integer"}, required: []string{"query"}}, "find_callers": {properties: map[string]string{"symbol": "string", "symbol_id": "integer", "limit": "integer", "offset": "integer"}}, "find_callees": {properties: map[string]string{"symbol": "string", "symbol_id": "integer", "limit": "integer", "offset": "integer"}}, diff --git a/internal/mcp/server_bench_test.go b/internal/mcp/server_bench_test.go index 8b799f4..2f9d744 100644 --- a/internal/mcp/server_bench_test.go +++ b/internal/mcp/server_bench_test.go @@ -69,5 +69,5 @@ func setupMCPBenchServer(b *testing.B, ctx context.Context) *Server { if _, err := idx.Index(ctx, indexer.Options{RepoRoot: repoRoot}); err != nil { b.Fatalf("Index() error = %v", err) } - return NewServer(repoRoot, repo.ID, s, idx, query.New(s, nil), io.Discard) + return NewServer(repoRoot, repoRoot, repo.ID, s, idx, query.New(s, nil), io.Discard) } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 7491eb7..9ddc92c 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -38,7 +38,7 @@ func main() {} t.Fatalf("Index() error = %v", err) } - server := NewServer(repoRoot, repo.ID, s, idx, query.New(s, nil), io.Discard) + server := NewServer(repoRoot, repoRoot, repo.ID, s, idx, query.New(s, nil), io.Discard) input := bytes.NewBuffer(nil) writeFrameToBuffer(t, input, map[string]any{ "jsonrpc": "2.0", @@ -101,7 +101,7 @@ func TestSupportedLanguagesTool(t *testing.T) { if err != nil { t.Fatalf("UpsertRepo() error = %v", err) } - server := NewServer(repoRoot, repo.ID, s, idx, query.New(s, nil), io.Discard) + server := NewServer(repoRoot, repoRoot, repo.ID, s, idx, query.New(s, nil), io.Discard) input := bytes.NewBuffer(nil) writeFrameToBuffer(t, input, map[string]any{ @@ -151,7 +151,7 @@ func TestServeMalformedFrameReturnsParseErrorAndContinues(t *testing.T) { if err != nil { t.Fatalf("UpsertRepo() error = %v", err) } - server := NewServer(repoRoot, repo.ID, s, idx, query.New(s, nil), io.Discard) + server := NewServer(repoRoot, repoRoot, repo.ID, s, idx, query.New(s, nil), io.Discard) var input bytes.Buffer input.WriteString("Content-Length: bad\r\n\r\n") @@ -194,7 +194,7 @@ func TestToolValidationRejectsMissingQuery(t *testing.T) { if err != nil { t.Fatalf("UpsertRepo() error = %v", err) } - server := NewServer(repoRoot, repo.ID, s, idx, query.New(s, nil), io.Discard) + server := NewServer(repoRoot, repoRoot, repo.ID, s, idx, query.New(s, nil), io.Discard) var input bytes.Buffer writeFrameToBuffer(t, &input, map[string]any{ From 3f8ed7dcf31e50b01d3336ec4f2a175a38b5fca3 Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:33:18 +0200 Subject: [PATCH 2/2] fix: automatic recognition of root --- README.md | 4 ++-- internal/config/reporoot.go | 8 +++---- internal/config/reporoot_test.go | 36 +++++++++++++++++++++++--------- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a0b2e84..e8d0e84 100644 --- a/README.md +++ b/README.md @@ -405,8 +405,8 @@ Created with `codegraph config init --repo .` at `.codegraph/config.json`: When `--repo-root` (CLI) or `repo_root` (MCP tool parameter) is omitted, codegraph resolves the repo root using: -1. `--repo-root` CLI flag (explicit) -2. Per-call `repo_root` MCP tool parameter +1. Per-call `repo_root` MCP tool parameter +2. `--repo-root` CLI flag (process-level default) 3. `git rev-parse --show-toplevel` from the current working directory 4. `os.Getwd()` (current working directory) 5. Return error diff --git a/internal/config/reporoot.go b/internal/config/reporoot.go index bbafc64..d224870 100644 --- a/internal/config/reporoot.go +++ b/internal/config/reporoot.go @@ -14,18 +14,18 @@ var ( ) // ResolveRepoRoot resolves the repository root directory using a stable fallback chain: -// 1. Explicit CLI flag value (flagValue) -// 2. Per-call MCP tool parameter (toolParam) +// 1. Per-call MCP tool parameter (toolParam) (most specific override) +// 2. Explicit CLI flag value (flagValue) (process-level default) // 3. `git rev-parse --show-toplevel` from the current working directory // 4. os.Getwd() // 5. Return error // // Empty inputs are treated as "not provided". func ResolveRepoRoot(flagValue, toolParam string) (string, error) { - if v := strings.TrimSpace(flagValue); v != "" { + if v := strings.TrimSpace(toolParam); v != "" { return v, nil } - if v := strings.TrimSpace(toolParam); v != "" { + if v := strings.TrimSpace(flagValue); v != "" { return v, nil } diff --git a/internal/config/reporoot_test.go b/internal/config/reporoot_test.go index 4fd38dc..88b714b 100644 --- a/internal/config/reporoot_test.go +++ b/internal/config/reporoot_test.go @@ -7,24 +7,24 @@ import ( "testing" ) -func TestResolveRepoRoot_ExplicitFlagWins(t *testing.T) { +func TestResolveRepoRoot_ToolParamWinsOverFlag(t *testing.T) { t.Cleanup(resetRepoRootTestHooks(t)) execCommand = func(name string, args ...string) *exec.Cmd { - t.Fatalf("execCommand should not be called when flag is set") + t.Fatalf("execCommand should not be called when toolParam is set") return nil } getwd = func() (string, error) { - t.Fatalf("getwd should not be called when flag is set") + t.Fatalf("getwd should not be called when toolParam is set") return "", nil } - got, err := ResolveRepoRoot(" /explicit ", "/tool") + got, err := ResolveRepoRoot(" /explicit ", " /tool ") if err != nil { t.Fatalf("unexpected error: %v", err) } - if got != "/explicit" { - t.Fatalf("got %q, want %q", got, "/explicit") + if got != "/tool" { + t.Fatalf("got %q, want %q", got, "/tool") } } @@ -57,8 +57,16 @@ func TestResolveRepoRoot_GitRootDetected(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if filepath.Clean(got) != filepath.Clean(gitRoot) { - t.Fatalf("got %q, want %q", got, gitRoot) + gotEval, err := filepath.EvalSymlinks(got) + if err != nil { + t.Fatalf("evalsymlinks(got): %v", err) + } + wantEval, err := filepath.EvalSymlinks(gitRoot) + if err != nil { + t.Fatalf("evalsymlinks(want): %v", err) + } + if filepath.Clean(gotEval) != filepath.Clean(wantEval) { + t.Fatalf("got %q, want %q", gotEval, wantEval) } } @@ -80,8 +88,16 @@ func TestResolveRepoRoot_CwdFallbackWhenNotGitRepo(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if filepath.Clean(got) != filepath.Clean(dir) { - t.Fatalf("got %q, want %q", got, dir) + gotEval, err := filepath.EvalSymlinks(got) + if err != nil { + t.Fatalf("evalsymlinks(got): %v", err) + } + wantEval, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatalf("evalsymlinks(want): %v", err) + } + if filepath.Clean(gotEval) != filepath.Clean(wantEval) { + t.Fatalf("got %q, want %q", gotEval, wantEval) } }