Multiple appends without pre-allocation; use make() with capacity when size is known
lr.FileList = append(lr.FileList, mcpFileResult{
1// SPDX-License-Identifier: MIT23package main45import (6 "cmp"7 "context"8 "encoding/json"9 "fmt"10 "log"11 "os"12 "path/filepath"13 "slices"14 "strings"15 "sync"1617 "github.com/boyter/scc/v3/processor"18 "github.com/mark3labs/mcp-go/mcp"19 "github.com/mark3labs/mcp-go/server"20)2122// mcpMu serializes MCP tool calls so concurrent requests23// don't race on processor package globals.24var mcpMu sync.Mutex2526func startMCPServer() {27 mcpServer := server.NewMCPServer(28 "scc",29 processor.Version,30 server.WithToolCapabilities(false),31 )3233 analyzeTool := mcp.NewTool("analyze",34 mcp.WithDescription(`Count lines of code, comments, blanks and estimate complexity for a project directory or file. Supports 200+ languages.3536Returns per-language summary with:37- files: number of source files38- lines: total lines39- code: lines of actual code40- comment: lines of comments41- blank: blank lines42- complexity: estimated cyclomatic complexity43- cognitive: nesting-weighted cognitive complexity (only when cognitive=true; 0 otherwise)44- bytes: total size in bytes4546Also returns COCOMO cost/schedule estimates and optionally LOCOMO (LLM cost) estimates.4748Use by_file with sort=complexity to find the most complex files in a project.`),49 mcp.WithString("path",50 mcp.Description("Directory or file path to analyze. Defaults to current directory."),51 ),52 mcp.WithString("sort",53 mcp.Description("Column to sort results by: files, name, lines, blank, code, comment, complexity, bytes. Default: files."),54 ),55 mcp.WithBoolean("by_file",56 mcp.Description("If true, return per-file results instead of per-language summary. Useful with sort to find e.g. the most complex or largest files. Use with limit to control response size."),57 ),58 mcp.WithNumber("limit",59 mcp.Description("Maximum number of files to return per language when by_file is true. Defaults to 10. Set to -1 for unlimited."),60 ),61 mcp.WithString("include_ext",62 mcp.Description("Comma-separated list of file extensions to include (e.g. 'go,java,js')."),63 ),64 mcp.WithString("exclude_ext",65 mcp.Description("Comma-separated list of file extensions to exclude (e.g. 'json,xml')."),66 ),67 mcp.WithBoolean("no_duplicates",68 mcp.Description("Remove duplicate files from stats."),69 ),70 mcp.WithBoolean("no_min_gen",71 mcp.Description("Exclude minified or generated files."),72 ),73 mcp.WithBoolean("cognitive",74 mcp.Description("Compute nesting-weighted cognitive complexity in addition to cyclomatic complexity. Adds a 'cognitive' field to per-language, per-file and totals results. Off by default; when off the field is 0."),75 ),76 mcp.WithBoolean("locomo",77 mcp.Description("Include LOCOMO (LLM Output COst MOdel) cost estimation in results."),78 ),79 mcp.WithString("locomo_preset",80 mcp.Description("LOCOMO model preset: large (GPT-4/Opus class), medium (Sonnet class), small (Haiku class), local (local LLM). Default: medium."),81 ),82 )8384 mcpServer.AddTool(analyzeTool, mcpAnalyzeHandler)8586 hotspotsTool := mcp.NewTool("hotspots",87 mcp.WithDescription(`Rank the files in a git repository by "hotspot" score: complexity × change-frequency over recent history. Hotspots are the files most likely to need refactoring or the most attention during review — high complexity that also changes often.8889Walks the repo's git log (most recent commits first) and returns, per surviving file:90- file: path relative to the repo root91- language: detected language92- complexity: estimated cyclomatic complexity at HEAD93- commits: number of commits touching the file within the window94- linesChanged: total added + removed lines across the window95- authors: distinct authors (folded via .mailmap)96- codeChurn / commentChurn: added lines classified as code vs comment97- score: 0–100, normalised complexity × commits (higher = hotter)9899Also returns the history window walked (depth, commit count, date range). Requires path to be inside a git repository.`),100 mcp.WithString("path",101 mcp.Description("Directory inside the git repository to analyze. Defaults to current directory."),102 ),103 mcp.WithNumber("depth",104 mcp.Description("Maximum number of recent commits to walk. Defaults to 1000. Set to 0 for unlimited (slower on large repos)."),105 ),106 mcp.WithNumber("limit",107 mcp.Description("Maximum number of files to return, highest-scoring first. Defaults to 50. Set to -1 for unlimited."),108 ),109 )110111 mcpServer.AddTool(hotspotsTool, mcpHotspotsHandler)112113 couplingTool := mcp.NewTool("coupling",114 mcp.WithDescription(`Return change-coupling from a git repository's history — files that historically change together. Has two modes, selected by whether the 'file' argument is set.115116WITH 'file' — the target file's "blast radius": the other files that change together with it. Use this before editing a file to discover what else you will likely need to read or change. For the target, returns each coupled file with:117- file: path relative to the repo root118- shared: number of commits that changed BOTH the target and this file119- partnerCommits: this file's own commit count in the window120- couple: P(this file changes | you changed the target), 0–100 — the blast-radius probability121- reverse: P(target changes | this file changed), 0–100 — a large gap from couple marks a hub-style (asymmetric) link rather than a true peer coupling122Partners are ranked by degree (highest first; base-rate corrected). Also returns the target's own commit count (targetCommits) and the history window walked.123124WITHOUT 'file' — the repo-wide all-pairs overview: every file pair that changes together, strongest first. For each pair, returns:125- fileA, fileB: the two coupled paths, relative to the repo root126- shared: number of commits that changed BOTH files127- commitsA, commitsB: each file's own commit count in the window128- degree: symmetric coupling ratio shared/(commitsA+commitsB−shared), 0–100129Pairs are ranked strongest first (most shared commits). Also returns the history window walked.130131Requires path to be inside a git repository.`),132 mcp.WithString("file",133 mcp.Description("Optional target file path (relative to the repo, as it appears at HEAD). Set it for the per-file blast-radius view; omit it for the repo-wide all-pairs report."),134 ),135 mcp.WithString("path",136 mcp.Description("Directory inside the git repository to analyze. Defaults to current directory."),137 ),138 mcp.WithNumber("depth",139 mcp.Description("Maximum number of recent commits to walk. Defaults to 1000. Set to 0 for unlimited (slower on large repos)."),140 ),141 mcp.WithNumber("limit",142 mcp.Description("Maximum rows to return, strongest first — coupled files in per-file mode, file pairs in all-pairs mode. Defaults to 50. Set to -1 for unlimited."),143 ),144 )145146 mcpServer.AddTool(couplingTool, mcpCouplingHandler)147148 errLogger := log.New(os.Stderr, "scc-mcp: ", log.LstdFlags)149 if err := server.ServeStdio(mcpServer, server.WithErrorLogger(errLogger)); err != nil {150 _, _ = fmt.Fprintf(os.Stderr, "scc-mcp: server error: %v\n", err)151 os.Exit(1)152 }153}154155type mcpAnalyzeResponse struct {156 Path string `json:"path"`157 Languages []mcpLanguageResult `json:"languages"`158 Totals mcpTotals `json:"totals"`159 COCOMO *mcpCOCOMO `json:"cocomo,omitempty"`160 LOCOMO *mcpLOCOMO `json:"locomo,omitempty"`161 FileCount int64 `json:"totalFiles"`162 TotalLines int64 `json:"totalLines"`163 TotalCode int64 `json:"totalCode"`164}165166type mcpLanguageResult struct {167 Name string `json:"name"`168 Files int64 `json:"files"`169 Lines int64 `json:"lines"`170 Code int64 `json:"code"`171 Comment int64 `json:"comment"`172 Blank int64 `json:"blank"`173 Complexity int64 `json:"complexity"`174 Cognitive int64 `json:"cognitive"`175 Bytes int64 `json:"bytes"`176 FileList []mcpFileResult `json:"fileList,omitempty"`177}178179type mcpFileResult struct {180 Location string `json:"location"`181 Filename string `json:"filename"`182 Language string `json:"language"`183 Lines int64 `json:"lines"`184 Code int64 `json:"code"`185 Comment int64 `json:"comment"`186 Blank int64 `json:"blank"`187 Complexity int64 `json:"complexity"`188 Cognitive int64 `json:"cognitive"`189 Bytes int64 `json:"bytes"`190}191192type mcpTotals struct {193 Files int64 `json:"files"`194 Lines int64 `json:"lines"`195 Code int64 `json:"code"`196 Comment int64 `json:"comment"`197 Blank int64 `json:"blank"`198 Complexity int64 `json:"complexity"`199 Cognitive int64 `json:"cognitive"`200 Bytes int64 `json:"bytes"`201}202203type mcpCOCOMO struct {204 EstimatedCost float64 `json:"estimatedCost"`205 EstimatedScheduleMonths float64 `json:"estimatedScheduleMonths"`206 EstimatedPeople float64 `json:"estimatedPeople"`207}208209type mcpLOCOMO struct {210 Cost float64 `json:"cost"`211 InputTokens float64 `json:"inputTokens"`212 OutputTokens float64 `json:"outputTokens"`213 GenerationSeconds float64 `json:"generationSeconds"`214 ReviewHours float64 `json:"reviewHours"`215 Preset string `json:"preset"`216 AverageComplexityMult float64 `json:"averageComplexityMultiplier"`217 Cycles float64 `json:"cycles"`218}219220func mcpAnalyzeHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {221 args := request.GetArguments()222223 // Extract parameters224 path := "."225 if p, ok := args["path"].(string); ok && p != "" {226 path = p227 }228229 // Resolve to absolute path230 absPath, err := filepath.Abs(path)231 if err != nil {232 return mcp.NewToolResultError(fmt.Sprintf("invalid path: %v", err)), nil233 }234235 // Verify path can be accessed236 if _, err := os.Stat(absPath); err != nil {237 return mcp.NewToolResultError(fmt.Sprintf("path cannot be accessed: %s: %v", absPath, err)), nil238 }239240 // Serialize access to processor globals so concurrent MCP241 // requests don't race on shared state.242 mcpMu.Lock()243 defer mcpMu.Unlock()244245 // Configure processor globals for this request.246 // Some defaults are normally set by cobra flags which the MCP path247 // bypasses, so we set them explicitly here.248 processor.DirFilePaths = []string{absPath}249 processor.Format = "json"250 processor.Cocomo = false251 processor.Size = false252 processor.Files = false253 processor.PathDenyList = []string{".git", ".hg", ".svn"}254 processor.ExcludeFilename = []string{"package-lock.json", "Cargo.lock", "yarn.lock", "pubspec.lock", "Podfile.lock", "pnpm-lock.yaml"}255256 if sortBy, ok := args["sort"].(string); ok && sortBy != "" {257 processor.SortBy = sortBy258 } else {259 processor.SortBy = "files"260 }261262 if byFile, ok := args["by_file"].(bool); ok && byFile {263 processor.Files = true264 }265266 fileLimit := 10 // default limit when by_file is true267 if l, ok := args["limit"].(float64); ok {268 if l < 0 {269 fileLimit = 0 // -1 (or any negative) means unlimited270 } else {271 fileLimit = int(l)272 }273 }274275 if includeExt, ok := args["include_ext"].(string); ok && includeExt != "" {276 processor.AllowListExtensions = splitAndTrimExtensions(includeExt)277 } else {278 processor.AllowListExtensions = []string{}279 }280281 if excludeExt, ok := args["exclude_ext"].(string); ok && excludeExt != "" {282 processor.ExcludeListExtensions = splitAndTrimExtensions(excludeExt)283 } else {284 processor.ExcludeListExtensions = []string{}285 }286287 if noDups, ok := args["no_duplicates"].(bool); ok && noDups {288 processor.Duplicates = true289 } else {290 processor.Duplicates = false291 }292293 if noMinGen, ok := args["no_min_gen"].(bool); ok && noMinGen {294 processor.IgnoreMinifiedGenerate = true295 // GeneratedMarkers is normally set by cobra flag defaults which296 // the MCP path bypasses, so set them here.297 if len(processor.GeneratedMarkers) == 0 {298 processor.GeneratedMarkers = []string{"do not edit", "<auto-generated />"}299 }300 } else {301 processor.IgnoreMinifiedGenerate = false302 }303304 if cognitive, ok := args["cognitive"].(bool); ok && cognitive {305 processor.Cognitive = true306 } else {307 processor.Cognitive = false308 }309310 if locomo, ok := args["locomo"].(bool); ok && locomo {311 processor.Locomo = true312 } else {313 processor.Locomo = false314 }315316 if locomoPreset, ok := args["locomo_preset"].(string); ok && locomoPreset != "" {317 processor.LocomoPresetName = locomoPreset318 } else {319 processor.LocomoPresetName = "medium"320 }321322 processor.ConfigureLazy(true)323324 // Run the analysis325 language, err := processor.ProcessResult()326 if err != nil {327 return mcp.NewToolResultError(fmt.Sprintf("analysis failed: %v", err)), nil328 }329330 // Build response331 var totals mcpTotals332 langs := make([]mcpLanguageResult, 0, len(language))333334 for _, l := range language {335 lr := mcpLanguageResult{336 Name: l.Name,337 Files: l.Count,338 Lines: l.Lines,339 Code: l.Code,340 Comment: l.Comment,341 Blank: l.Blank,342 Complexity: l.Complexity,343 Cognitive: l.Cognitive,344 Bytes: l.Bytes,345 }346347 if processor.Files && len(l.Files) > 0 {348 files := l.Files349 // Sort files within each language by the same criteria350 // used for languages so per-file output is ordered and351 // limit returns the top N rather than an arbitrary slice.352 sortFileJobs(files)353 if fileLimit > 0 && len(files) > fileLimit {354 files = files[:fileLimit]355 }356 lr.FileList = make([]mcpFileResult, 0, len(files))357 for _, f := range files {358 lr.FileList = append(lr.FileList, mcpFileResult{359 Location: f.Location,360 Filename: f.Filename,361 Language: f.Language,362 Lines: f.Lines,363 Code: f.Code,364 Comment: f.Comment,365 Blank: f.Blank,366 Complexity: f.Complexity,367 Cognitive: f.Cognitive,368 Bytes: f.Bytes,369 })370 }371 }372373 langs = append(langs, lr)374375 totals.Files += l.Count376 totals.Lines += l.Lines377 totals.Code += l.Code378 totals.Comment += l.Comment379 totals.Blank += l.Blank380 totals.Complexity += l.Complexity381 totals.Cognitive += l.Cognitive382 totals.Bytes += l.Bytes383 }384385 resp := mcpAnalyzeResponse{386 Path: absPath,387 Languages: langs,388 Totals: totals,389 FileCount: totals.Files,390 TotalLines: totals.Lines,391 TotalCode: totals.Code,392 }393394 // COCOMO estimate395 estimatedEffort := processor.EstimateEffort(totals.Code, processor.EAF)396 estimatedCost := processor.EstimateCost(estimatedEffort, processor.AverageWage, processor.Overhead)397 estimatedScheduleMonths := processor.EstimateScheduleMonths(estimatedEffort)398 estimatedPeople := 0.0399 if estimatedScheduleMonths > 0 {400 estimatedPeople = estimatedEffort / estimatedScheduleMonths401 }402 resp.COCOMO = &mcpCOCOMO{403 EstimatedCost: estimatedCost,404 EstimatedScheduleMonths: estimatedScheduleMonths,405 EstimatedPeople: estimatedPeople,406 }407408 // LOCOMO estimate if requested409 if processor.Locomo {410 result := processor.LocomoEstimate(totals.Code, totals.Complexity)411 resp.LOCOMO = &mcpLOCOMO{412 Cost: result.Cost,413 InputTokens: result.InputTokens,414 OutputTokens: result.OutputTokens,415 GenerationSeconds: result.GenerationSeconds,416 ReviewHours: result.ReviewHours,417 Preset: result.Preset,418 AverageComplexityMult: result.AverageComplexityMult,419 Cycles: result.IterationFactor,420 }421 }422423 // Serialize to JSON424 jsonBytes, err := jsonMarshal(resp)425 if err != nil {426 return mcp.NewToolResultError(fmt.Sprintf("failed to serialize results: %v", err)), nil427 }428429 return mcp.NewToolResultText(string(jsonBytes)), nil430}431432func mcpHotspotsHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {433 args := request.GetArguments()434435 path := "."436 if p, ok := args["path"].(string); ok && p != "" {437 path = p438 }439440 absPath, err := filepath.Abs(path)441 if err != nil {442 return mcp.NewToolResultError(fmt.Sprintf("invalid path: %v", err)), nil443 }444445 if _, err := os.Stat(absPath); err != nil {446 return mcp.NewToolResultError(fmt.Sprintf("path cannot be accessed: %s: %v", absPath, err)), nil447 }448449 // Serialize access to processor globals so concurrent MCP450 // requests don't race on shared state.451 mcpMu.Lock()452 defer mcpMu.Unlock()453454 // HistoryDepth is normally set by a cobra flag default which the MCP path455 // bypasses, so set it explicitly. Default mirrors the CLI's 1000.456 processor.HistoryDepth = 1000457 if d, ok := args["depth"].(float64); ok {458 if d < 0 {459 d = 0460 }461 processor.HistoryDepth = int(d)462 }463464 fileLimit := 50 // default cap so responses stay bounded for the model465 if l, ok := args["limit"].(float64); ok {466 if l < 0 {467 fileLimit = 0 // -1 (or any negative) means unlimited468 } else {469 fileLimit = int(l)470 }471 }472473 // Build the language database (ExtensionToLanguage etc.) and enable lazy474 // per-language feature loading. Process() does this before the hotspots475 // report on the cobra path; the analyze handler gets it via ProcessResult.476 // Without ProcessConstants the HEAD tree can't be classified, so every477 // file is dropped and the report comes back empty.478 processor.ProcessConstants()479 processor.ConfigureLazy(true)480481 out, err := processor.HotspotsJSONReport(absPath, fileLimit)482 if err != nil {483 return mcp.NewToolResultError(fmt.Sprintf("hotspots analysis failed: %v", err)), nil484 }485486 return mcp.NewToolResultText(out), nil487}488489func mcpCouplingHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {490 args := request.GetArguments()491492 target, _ := args["file"].(string)493494 path := "."495 if p, ok := args["path"].(string); ok && p != "" {496 path = p497 }498499 absPath, err := filepath.Abs(path)500 if err != nil {501 return mcp.NewToolResultError(fmt.Sprintf("invalid path: %v", err)), nil502 }503504 if _, err := os.Stat(absPath); err != nil {505 return mcp.NewToolResultError(fmt.Sprintf("path cannot be accessed: %s: %v", absPath, err)), nil506 }507508 // Serialize access to processor globals so concurrent MCP requests don't509 // race on shared state.510 mcpMu.Lock()511 defer mcpMu.Unlock()512513 processor.HistoryDepth = 1000514 if d, ok := args["depth"].(float64); ok {515 if d < 0 {516 d = 0517 }518 processor.HistoryDepth = int(d)519 }520521 fileLimit := 50522 if l, ok := args["limit"].(float64); ok {523 if l < 0 {524 fileLimit = 0525 } else {526 fileLimit = int(l)527 }528 }529530 processor.ProcessConstants()531 processor.ConfigureLazy(true)532533 // file set → per-file blast radius; file omitted → repo-wide all-pairs.534 var out string535 if target != "" {536 out, err = processor.CouplingForJSONReport(absPath, target, fileLimit)537 } else {538 out, err = processor.CouplingJSONReport(absPath, fileLimit)539 }540 if err != nil {541 return mcp.NewToolResultError(fmt.Sprintf("coupling analysis failed: %v", err)), nil542 }543544 return mcp.NewToolResultText(out), nil545}546547func jsonMarshal(v any) ([]byte, error) {548 return json.MarshalIndent(v, "", " ")549}550551// sortFileJobs sorts a slice of FileJob pointers using the current552// processor.SortBy value so that the most relevant files come first.553func sortFileJobs(files []*processor.FileJob) {554 switch processor.SortBy {555 case "name", "names", "language", "languages", "lang", "langs":556 slices.SortFunc(files, func(a, b *processor.FileJob) int {557 return strings.Compare(a.Filename, b.Filename)558 })559 case "line", "lines":560 slices.SortFunc(files, func(a, b *processor.FileJob) int {561 return cmp.Compare(b.Lines, a.Lines)562 })563 case "blank", "blanks":564 slices.SortFunc(files, func(a, b *processor.FileJob) int {565 return cmp.Compare(b.Blank, a.Blank)566 })567 case "code", "codes":568 slices.SortFunc(files, func(a, b *processor.FileJob) int {569 return cmp.Compare(b.Code, a.Code)570 })571 case "comment", "comments":572 slices.SortFunc(files, func(a, b *processor.FileJob) int {573 return cmp.Compare(b.Comment, a.Comment)574 })575 case "complexity", "complexitys":576 slices.SortFunc(files, func(a, b *processor.FileJob) int {577 return cmp.Compare(b.Complexity, a.Complexity)578 })579 case "byte", "bytes":580 slices.SortFunc(files, func(a, b *processor.FileJob) int {581 return cmp.Compare(b.Bytes, a.Bytes)582 })583 default:584 slices.SortFunc(files, func(a, b *processor.FileJob) int {585 return cmp.Compare(b.Lines, a.Lines)586 })587 }588}589590// splitAndTrimExtensions splits a comma-separated string into591// trimmed, non-empty extension entries.592func splitAndTrimExtensions(s string) []string {593 parts := strings.Split(s, ",")594 result := make([]string, 0, len(parts))595 for _, p := range parts {596 p = strings.TrimSpace(p)597 if p != "" {598 result = append(result, p)599 }600 }601 return result602}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.