|
| 1 | +package mcp |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "fmt" |
| 6 | + "sort" |
| 7 | + "strings" |
| 8 | + |
| 9 | + "github.com/mark3labs/mcp-go/mcp" |
| 10 | + "github.com/tae2089/trace" |
| 11 | + |
| 12 | + "github.com/imtaebin/code-context-graph/internal/analysis/changes" |
| 13 | + "github.com/imtaebin/code-context-graph/internal/ctxns" |
| 14 | + "github.com/imtaebin/code-context-graph/internal/model" |
| 15 | +) |
| 16 | + |
| 17 | +func (h *handlers) getMinimalContext(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { |
| 18 | + ctx = h.applyWorkspace(ctx, request) |
| 19 | + log := h.logger() |
| 20 | + |
| 21 | + task := request.GetString("task", "") |
| 22 | + repoRoot := request.GetString("repo_root", "") |
| 23 | + base := request.GetString("base", "HEAD~1") |
| 24 | + |
| 25 | + log.Info("get_minimal_context called", "task", task, "repo_root", repoRoot, "base", base) |
| 26 | + |
| 27 | + return finalizeToolResult(h.cachedExecute("get_minimal_context:", map[string]any{ |
| 28 | + "task": task, |
| 29 | + "repo_root": repoRoot, |
| 30 | + "base": base, |
| 31 | + "workspace": request.GetString("workspace", ""), |
| 32 | + }, func() (string, error) { |
| 33 | + ns := ctxns.FromContext(ctx) |
| 34 | + |
| 35 | + nodeQ := h.deps.DB.WithContext(ctx).Model(&model.Node{}) |
| 36 | + if ns != "" { |
| 37 | + nodeQ = nodeQ.Where("namespace = ?", ns) |
| 38 | + } |
| 39 | + var nodeCount, edgeCount int64 |
| 40 | + if err := nodeQ.Count(&nodeCount).Error; err != nil { |
| 41 | + return "", trace.Wrap(err, "count nodes") |
| 42 | + } |
| 43 | + if err := h.deps.DB.WithContext(ctx).Model(&model.Edge{}).Count(&edgeCount).Error; err != nil { |
| 44 | + return "", trace.Wrap(err, "count edges") |
| 45 | + } |
| 46 | + |
| 47 | + type fileCount struct{ Count int64 } |
| 48 | + var fc fileCount |
| 49 | + fileQ := h.deps.DB.WithContext(ctx).Model(&model.Node{}).Select("COUNT(DISTINCT file_path) as count") |
| 50 | + if ns != "" { |
| 51 | + fileQ = fileQ.Where("namespace = ?", ns) |
| 52 | + } |
| 53 | + if err := fileQ.Scan(&fc).Error; err != nil { |
| 54 | + return "", trace.Wrap(err, "count files") |
| 55 | + } |
| 56 | + |
| 57 | + summary := fmt.Sprintf("%d nodes, %d edges, %d files", nodeCount, edgeCount, fc.Count) |
| 58 | + |
| 59 | + risk := "unknown" |
| 60 | + var riskScore float64 |
| 61 | + var keyEntities []string |
| 62 | + var testGaps int |
| 63 | + |
| 64 | + if repoRoot != "" && h.deps.ChangesGitClient != nil { |
| 65 | + chSvc := changes.New(h.deps.DB, h.deps.ChangesGitClient) |
| 66 | + risks, err := chSvc.Analyze(ctx, repoRoot, base) |
| 67 | + if err == nil && len(risks) > 0 { |
| 68 | + var maxRisk float64 |
| 69 | + var totalRisk float64 |
| 70 | + for _, r := range risks { |
| 71 | + if r.RiskScore > maxRisk { |
| 72 | + maxRisk = r.RiskScore |
| 73 | + } |
| 74 | + totalRisk += r.RiskScore |
| 75 | + keyEntities = append(keyEntities, r.Node.QualifiedName) |
| 76 | + } |
| 77 | + riskScore = totalRisk / float64(len(risks)) |
| 78 | + |
| 79 | + switch { |
| 80 | + case maxRisk >= 0.7: |
| 81 | + risk = "high" |
| 82 | + case maxRisk >= 0.4: |
| 83 | + risk = "medium" |
| 84 | + default: |
| 85 | + risk = "low" |
| 86 | + } |
| 87 | + |
| 88 | + if len(keyEntities) > 5 { |
| 89 | + keyEntities = keyEntities[:5] |
| 90 | + } |
| 91 | + |
| 92 | + for _, r := range risks { |
| 93 | + hasTest := false |
| 94 | + var testEdges int64 |
| 95 | + h.deps.DB.WithContext(ctx).Model(&model.Edge{}). |
| 96 | + Where("to_node_id = ? AND kind = ?", r.Node.ID, model.EdgeKindTestedBy). |
| 97 | + Count(&testEdges) |
| 98 | + if testEdges > 0 { |
| 99 | + hasTest = true |
| 100 | + } |
| 101 | + if !hasTest { |
| 102 | + testGaps++ |
| 103 | + } |
| 104 | + } |
| 105 | + } |
| 106 | + } |
| 107 | + if keyEntities == nil { |
| 108 | + keyEntities = []string{} |
| 109 | + } |
| 110 | + |
| 111 | + type commCount struct { |
| 112 | + CommunityID uint |
| 113 | + Count int |
| 114 | + } |
| 115 | + var ccRows []commCount |
| 116 | + if err := h.deps.DB.WithContext(ctx). |
| 117 | + Model(&model.CommunityMembership{}). |
| 118 | + Select("community_id, COUNT(*) as count"). |
| 119 | + Group("community_id"). |
| 120 | + Scan(&ccRows).Error; err != nil { |
| 121 | + return "", trace.Wrap(err, "group community memberships") |
| 122 | + } |
| 123 | + ccMap := make(map[uint]int, len(ccRows)) |
| 124 | + for _, r := range ccRows { |
| 125 | + ccMap[r.CommunityID] = r.Count |
| 126 | + } |
| 127 | + |
| 128 | + var communities []model.Community |
| 129 | + if err := h.deps.DB.WithContext(ctx).Find(&communities).Error; err != nil { |
| 130 | + return "", trace.Wrap(err, "find communities") |
| 131 | + } |
| 132 | + |
| 133 | + type commInfo struct { |
| 134 | + Label string `json:"label"` |
| 135 | + NodeCount int `json:"node_count"` |
| 136 | + } |
| 137 | + commInfos := make([]commInfo, len(communities)) |
| 138 | + for i, c := range communities { |
| 139 | + commInfos[i] = commInfo{Label: c.Label, NodeCount: ccMap[c.ID]} |
| 140 | + } |
| 141 | + sort.Slice(commInfos, func(i, j int) bool { |
| 142 | + return commInfos[i].NodeCount > commInfos[j].NodeCount |
| 143 | + }) |
| 144 | + if len(commInfos) > 3 { |
| 145 | + commInfos = commInfos[:3] |
| 146 | + } |
| 147 | + |
| 148 | + type flowCount struct { |
| 149 | + FlowID uint |
| 150 | + Count int |
| 151 | + } |
| 152 | + var fcRows []flowCount |
| 153 | + if err := h.deps.DB.WithContext(ctx). |
| 154 | + Model(&model.FlowMembership{}). |
| 155 | + Select("flow_id, COUNT(*) as count"). |
| 156 | + Group("flow_id"). |
| 157 | + Scan(&fcRows).Error; err != nil { |
| 158 | + return "", trace.Wrap(err, "group flow memberships") |
| 159 | + } |
| 160 | + fcMap := make(map[uint]int, len(fcRows)) |
| 161 | + for _, r := range fcRows { |
| 162 | + fcMap[r.FlowID] = r.Count |
| 163 | + } |
| 164 | + |
| 165 | + var flowList []model.Flow |
| 166 | + if err := h.deps.DB.WithContext(ctx).Find(&flowList).Error; err != nil { |
| 167 | + return "", trace.Wrap(err, "find flows") |
| 168 | + } |
| 169 | + |
| 170 | + type flowInfo struct { |
| 171 | + Name string `json:"name"` |
| 172 | + NodeCount int `json:"node_count"` |
| 173 | + } |
| 174 | + flowInfos := make([]flowInfo, len(flowList)) |
| 175 | + for i, f := range flowList { |
| 176 | + flowInfos[i] = flowInfo{Name: f.Name, NodeCount: fcMap[f.ID]} |
| 177 | + } |
| 178 | + sort.Slice(flowInfos, func(i, j int) bool { |
| 179 | + return flowInfos[i].NodeCount > flowInfos[j].NodeCount |
| 180 | + }) |
| 181 | + if len(flowInfos) > 3 { |
| 182 | + flowInfos = flowInfos[:3] |
| 183 | + } |
| 184 | + |
| 185 | + suggestedTools := suggestTools(task) |
| 186 | + |
| 187 | + resp := map[string]any{ |
| 188 | + "summary": summary, |
| 189 | + "risk": risk, |
| 190 | + "risk_score": riskScore, |
| 191 | + "key_entities": keyEntities, |
| 192 | + "test_gaps": testGaps, |
| 193 | + "top_communities": commInfos, |
| 194 | + "top_flows": flowInfos, |
| 195 | + "suggested_tools": suggestedTools, |
| 196 | + } |
| 197 | + |
| 198 | + result, err := marshalJSON(resp) |
| 199 | + if err != nil { |
| 200 | + return "", trace.Wrap(err, "marshal result") |
| 201 | + } |
| 202 | + return result, nil |
| 203 | + })) |
| 204 | +} |
| 205 | + |
| 206 | +func suggestTools(task string) []string { |
| 207 | + lower := strings.ToLower(task) |
| 208 | + |
| 209 | + reviewKeywords := []string{"review", "pr", "merge", "diff"} |
| 210 | + debugKeywords := []string{"debug", "bug", "error", "fix"} |
| 211 | + refactorKeywords := []string{"refactor", "rename", "dead", "clean"} |
| 212 | + onboardKeywords := []string{"onboard", "understand", "explore", "arch"} |
| 213 | + |
| 214 | + for _, kw := range reviewKeywords { |
| 215 | + if strings.Contains(lower, kw) { |
| 216 | + return []string{"detect_changes", "get_affected_flows", "search"} |
| 217 | + } |
| 218 | + } |
| 219 | + for _, kw := range debugKeywords { |
| 220 | + if strings.Contains(lower, kw) { |
| 221 | + return []string{"search", "query_graph", "trace_flow"} |
| 222 | + } |
| 223 | + } |
| 224 | + for _, kw := range refactorKeywords { |
| 225 | + if strings.Contains(lower, kw) { |
| 226 | + return []string{"find_dead_code", "find_large_functions", "get_architecture_overview"} |
| 227 | + } |
| 228 | + } |
| 229 | + for _, kw := range onboardKeywords { |
| 230 | + if strings.Contains(lower, kw) { |
| 231 | + return []string{"get_architecture_overview", "list_communities", "list_flows"} |
| 232 | + } |
| 233 | + } |
| 234 | + |
| 235 | + return []string{"detect_changes", "search", "get_architecture_overview"} |
| 236 | +} |
0 commit comments