package mcp import ( "context" "fmt" "sort" "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/query" ) // handleQueryProject runs a read-only symbol search against another // project (or a bare tracked-repo prefix) without switching the active // project. It deliberately bypasses the session-workspace clamp — this // is the sanctioned cross-project read — and never mutates the active // project or persists config. func (s *Server) handleQueryProject(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { project, err := req.RequireString("project") if err != nil { return mcp.NewToolResultError("project is required"), nil } q, err := req.RequireString("query") if err != nil { return mcp.NewToolResultError("query is required"), nil } if s.configManager == nil { return mcp.NewToolResultError("multi-project configuration is not available"), nil } limit := req.GetInt("limit", 20) if limit <= 0 { limit = 20 } gc := s.configManager.Global() // Resolve the target as a project name / per-repo project tag, or // fall back to treating it as a bare tracked-repo prefix. var prefixes []string if repos, rErr := gc.ResolveRepos(project); rErr == nil { for _, r := range repos { prefixes = append(prefixes, config.ResolvePrefix(r)) } } else if prefix := s.resolveRepoPrefix(project); prefix != "" { prefixes = []string{prefix} } if len(prefixes) == 0 { available := make([]string, 0, len(gc.Projects)) for name := range gc.Projects { available = append(available, name) } sort.Strings(available) return NewStructuredErrorResult(StructuredError{ ErrorCode: ErrCodeProjectUnknown, Message: fmt.Sprintf("no project or tracked repo named %q", project), Data: map[string]any{"project": project, "available_projects": available}, }), nil } allowed := make(map[string]bool, len(prefixes)) for _, p := range prefixes { allowed[p] = true } // Search the base graph unscoped, then confine to the target's // repos. Using s.engine (not engineFor) keeps this off the session // overlay and clamp — query_project is an explicit cross-project read. nodes := s.engine.SearchSymbolsScoped(q, limit*5, query.QueryOptions{}) nodes = filterNodes(nodes, allowed) if len(nodes) > limit { nodes = nodes[:limit] } nodes = s.withAbsPaths(nodes) results := make([]map[string]any, 0, len(nodes)) for _, n := range nodes { results = append(results, n.Brief()) } sort.Strings(prefixes) return s.respondJSONOrTOON(ctx, req, map[string]any{ "project": project, "query": q, "repos": prefixes, "total": len(results), "results": results, }) }