Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

405 lines
14 KiB
Go

package mcp
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
)
// registerMultiRepoTools registers MCP tools for multi-repo management:
// track_repository, untrack_repository, set_active_project, get_active_project.
func (s *Server) registerMultiRepoTools() {
s.addTool(
mcp.NewTool("track_repository",
mcp.WithDescription("Add a repository to the tracked workspace at runtime. Indexes immediately and persists to config."),
mcp.WithString("path", mcp.Required(), mcp.Description("Absolute path to repository")),
mcp.WithString("name", mcp.Description("Optional repo prefix override")),
mcp.WithBoolean("as_worktree", mcp.Description("Track a linked git worktree as an independent instance (derived `<base>@<workspace>` prefix) even when its repo is already tracked elsewhere. Auto-detected when the worktree's .gortex.yaml declares a different workspace; set this to force it.")),
mcp.WithBoolean("force", mcp.Description("Track even when the path is the home directory or filesystem root (refused by default to avoid an unbounded crawl).")),
),
s.handleTrackRepository,
)
s.addTool(
mcp.NewTool("untrack_repository",
mcp.WithDescription("Remove a repository from the tracked workspace at runtime. Evicts nodes/edges and persists to config."),
mcp.WithString("path", mcp.Required(), mcp.Description("Path or repo prefix to remove")),
),
s.handleUntrackRepository,
)
s.addTool(
mcp.NewTool("set_active_project",
mcp.WithDescription("Switch the active project scope. Persists to config and re-scopes all subsequent queries."),
mcp.WithString("project", mcp.Required(), mcp.Description("Project name to activate")),
),
s.handleSetActiveProject,
)
s.addTool(
mcp.NewTool("get_active_project",
mcp.WithDescription("Return the current active project name and its list of member repositories."),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")),
mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes; truncation metadata rides on the response.")),
),
s.handleGetActiveProject,
)
s.addTool(
mcp.NewTool("query_project",
mcp.WithDescription("Search symbols in another project or repository without switching the "+
"active project. A read-only, one-shot cross-project lookup: it resolves the named "+
"project (or a bare tracked-repo prefix), searches it, and returns — the active project "+
"and the session scope are left unchanged. Use this instead of set_active_project for a "+
"quick look into another project."),
mcp.WithString("project", mcp.Required(),
mcp.Description("Project name, per-repo project tag, or tracked-repo prefix to search")),
mcp.WithString("query", mcp.Required(), mcp.Description("Symbol search query")),
mcp.WithNumber("limit", mcp.Description("Max results (default: 20)")),
),
s.handleQueryProject,
)
}
// handleTrackRepository validates the path, indexes the repo, and persists to GlobalConfig.
func (s *Server) handleTrackRepository(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
path, err := req.RequireString("path")
if err != nil {
return mcp.NewToolResultError("path is required"), nil
}
// Validate path exists and is a directory.
info, statErr := os.Stat(path)
if statErr != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid path: %s", path)), nil
}
if !info.IsDir() {
return mcp.NewToolResultError(fmt.Sprintf("invalid path: %s (not a directory)", path)), nil
}
if s.multiIndexer == nil {
return mcp.NewToolResultError("multi-repo indexing is not enabled"), nil
}
entry := config.RepoEntry{Path: path}
if name, ok := req.GetArguments()["name"].(string); ok && name != "" {
entry.Name = name
}
if asWT, ok := req.GetArguments()["as_worktree"].(bool); ok {
entry.AsWorktree = asWT
}
if force, ok := req.GetArguments()["force"].(bool); ok {
entry.Force = force
}
result, trackErr := s.multiIndexer.TrackRepoCtx(s.progressCtx(ctx, req), entry)
if trackErr != nil {
return mcp.NewToolResultError(trackErr.Error()), nil
}
// Already tracked — TrackRepo returns nil result when repo exists.
if result == nil {
return mcp.NewToolResultText("repository already tracked"), nil
}
// Persist updated config.
if s.configManager != nil {
if saveErr := s.configManager.Global().Save(); saveErr != nil {
s.logger.Warn("failed to persist config after tracking repo",
zap.String("path", path), zap.Error(saveErr))
}
}
// Re-run analysis after adding a new repo.
s.RunAnalysis()
prefix := result.RepoPrefix
if prefix == "" {
prefix = config.ResolvePrefix(entry)
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"status": "tracked",
"path": path,
"prefix": prefix,
"file_count": result.FileCount,
"node_count": result.NodeCount,
"edge_count": result.EdgeCount,
})
}
// handleUntrackRepository removes a repo from the workspace and persists to GlobalConfig.
func (s *Server) handleUntrackRepository(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
path, err := req.RequireString("path")
if err != nil {
return mcp.NewToolResultError("path is required"), nil
}
if s.multiIndexer == nil {
return mcp.NewToolResultError("multi-repo indexing is not enabled"), nil
}
// Try to find the repo by prefix or by path.
prefix := s.resolveRepoPrefix(path)
if prefix == "" {
// Untracking something already untracked is a no-op the agent can act
// on, not a session-ending failure — return success-shaped guidance.
return repoNotTrackedGuidance(path), nil
}
nodesRemoved, edgesRemoved := s.multiIndexer.UntrackRepo(prefix)
// Persist updated config.
if s.configManager != nil {
if saveErr := s.configManager.Global().Save(); saveErr != nil {
s.logger.Warn("failed to persist config after untracking repo",
zap.String("path", path), zap.Error(saveErr))
}
}
// Re-run analysis after removing a repo.
s.RunAnalysis()
return s.respondJSONOrTOON(ctx, req, map[string]any{
"status": "untracked",
"prefix": prefix,
"nodes_removed": nodesRemoved,
"edges_removed": edgesRemoved,
})
}
// handleSetActiveProject validates the project name, updates the active project,
// persists to GlobalConfig, and re-scopes queries.
func (s *Server) handleSetActiveProject(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
project, err := req.RequireString("project")
if err != nil {
return mcp.NewToolResultError("project is required"), nil
}
if s.configManager == nil {
return mcp.NewToolResultError("configuration manager is not available"), nil
}
gc := s.configManager.Global()
// Validate project exists.
repos, resolveErr := gc.ResolveRepos(project)
if resolveErr != nil {
// Build list of available projects for the error message.
available := make([]string, 0, len(gc.Projects))
for name := range gc.Projects {
available = append(available, name)
}
return mcp.NewToolResultError(fmt.Sprintf(
"project not found: %s (available: %s)", project, strings.Join(available, ", "),
)), nil
}
// Update active project in config and on server.
gc.ActiveProject = project
s.activeProject = project
// Persist to disk.
if saveErr := gc.Save(); saveErr != nil {
s.logger.Warn("failed to persist active project change",
zap.String("project", project), zap.Error(saveErr))
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"status": "active",
"project": project,
"repos": buildRepoList(repos),
})
}
// handleGetActiveProject returns the current active project name and its repo list.
func (s *Server) handleGetActiveProject(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return s.respondJSONOrTOON(ctx, req, s.buildActiveProjectPayload(ctx))
}
// buildActiveProjectPayload returns the same data the `get_active_project`
// tool emits. Shared with the `gortex://active-project` resource.
//
// For a workspace-bound session it reports the session's own resolved
// scope — the boundary the query tools actually enforce — rather than
// the process-global config default, which would mask whether scoping
// is in effect.
func (s *Server) buildActiveProjectPayload(ctx context.Context) map[string]any {
if sessWS, sessProj, bound := s.sessionScope(ctx); bound {
return map[string]any{
"workspace": sessWS,
"project": sessProj,
"bound": true,
"repos": s.sessionWorkspaceRepos(ctx),
}
}
if s.configManager == nil {
return map[string]any{
"project": "",
"repos": []any{},
}
}
gc := s.configManager.Global()
project := s.activeProject
if project == "" {
project = gc.ActiveProject
}
result := map[string]any{
"project": project,
}
if project == "" {
result["repos"] = buildRepoList(gc.Repos)
return result
}
repos, resolveErr := gc.ResolveRepos(project)
if resolveErr != nil {
// Common after the workspace bind drops to "unbound"
// while a stale active_project still points at a project
// the workspace no longer discovers. Fall back to the
// workspace-level repo list and record the drift in `note`.
result["project"] = ""
result["repos"] = buildRepoList(gc.Repos)
result["note"] = fmt.Sprintf("active_project %q not found in current workspace; returning top-level repos", project)
return result
}
result["repos"] = buildRepoList(repos)
return result
}
// resolveRepoPrefix resolves a path-or-prefix string to a repo prefix by
// consulting only the in-memory MultiIndexer state. Use
// resolveRepoPrefixOrReconcile when drift between persisted config and
// in-memory state could produce a false miss.
func (s *Server) resolveRepoPrefix(pathOrPrefix string) string {
if s.multiIndexer == nil || pathOrPrefix == "" {
return ""
}
// Check if it's a known prefix directly.
if meta := s.multiIndexer.GetMetadata(pathOrPrefix); meta != nil {
return pathOrPrefix
}
// Try to match as a path — check all tracked repos. Also try the
// absolute form since users may pass either.
absInput, _ := filepath.Abs(pathOrPrefix)
for prefix, meta := range s.multiIndexer.AllMetadata() {
if meta.RootPath == pathOrPrefix || (absInput != "" && meta.RootPath == absInput) {
return prefix
}
}
return ""
}
// diffJoinPrefix resolves the graph repo prefix used to join repo-relative
// diff / forge file paths to indexed nodes: multi-repo daemons key file
// paths as "<prefix>/<rel>" while git and forge APIs emit repo-relative
// paths. repoRoot is the already-resolved working-tree root. Returns "" in
// single-repo / unprefixed mode, where the raw lookup already matches.
func (s *Server) diffJoinPrefix(repoRoot string) string {
if repoRoot == "" {
return ""
}
if p := s.resolveRepoPrefix(repoRoot); p != "" {
return p
}
if s.indexer != nil && s.indexer.RootPath() == repoRoot {
return s.indexer.RepoPrefix()
}
return ""
}
// diffRepoScope resolves the working-tree root and graph repo prefix a
// diff-driven handler operates on. An explicit selector (a repo prefix or a
// filesystem path — the CLI defaults to the caller's working directory) is
// normalized and honoured exclusively: when it names nothing tracked the
// result is empty so the caller errors instead of silently diffing another
// repo. With no selector the lone tracked repo wins, then the session's
// cwd-bound repo (clients dial the daemon with their working directory).
// Both empty means no resolvable working tree.
func (s *Server) diffRepoScope(ctx context.Context, repo string) (repoRoot, repoPrefix string) {
if repo != "" {
if p := s.resolveRepoPrefix(repo); p != "" {
repo = p
}
root := pickRepoRoot(s.collectRepoRoots(repo), repo)
if root == "" {
return "", ""
}
return root, s.diffJoinPrefix(root)
}
if root := pickRepoRoot(s.collectRepoRoots(""), ""); root != "" {
return root, s.diffJoinPrefix(root)
}
if cwd := SessionCWDFromContext(ctx); cwd != "" && s.multiIndexer != nil {
if _, _, prefix, ok := s.multiIndexer.ScopeForCWD(cwd); ok && prefix != "" {
if root, ok := s.multiIndexer.RepoRoot(prefix); ok && root != "" {
return root, prefix
}
}
}
return "", ""
}
// resolveRepoPrefixOrReconcile resolves a path-or-prefix to a repo prefix
// and reconciles persisted-config state into the in-memory MultiIndexer on
// miss. Warmup can silently drop a repo (transient index failure, daemon
// restart with a stale snapshot, crash mid-warmup) and leave it listed
// under get_active_project but absent from mi.repos; the user's next
// operation then errors with "not a tracked repository" for something
// they can plainly see in the project list. Here, if the input matches a
// persisted config entry, we auto-track it before returning the prefix.
func (s *Server) resolveRepoPrefixOrReconcile(ctx context.Context, pathOrPrefix string) string {
if prefix := s.resolveRepoPrefix(pathOrPrefix); prefix != "" {
return prefix
}
if s.multiIndexer == nil || s.configManager == nil {
return ""
}
absInput, _ := filepath.Abs(pathOrPrefix)
for _, entry := range s.configManager.Global().Repos {
entryAbs, _ := filepath.Abs(entry.Path)
if entry.Path != pathOrPrefix && entryAbs != absInput &&
config.ResolvePrefix(entry) != pathOrPrefix {
continue
}
if _, err := s.multiIndexer.TrackRepoCtx(ctx, entry); err != nil {
s.logger.Warn("auto-track from config failed",
zap.String("path", entry.Path), zap.Error(err))
return ""
}
return s.resolveRepoPrefix(pathOrPrefix)
}
return ""
}
// buildRepoList converts a slice of RepoEntry to a JSON-friendly list.
func buildRepoList(repos []config.RepoEntry) []map[string]string {
list := make([]map[string]string, 0, len(repos))
for _, r := range repos {
entry := map[string]string{
"path": r.Path,
"prefix": config.ResolvePrefix(r),
}
if r.Ref != "" {
entry["ref"] = r.Ref
}
list = append(list, entry)
}
return list
}