Empty interface; prefer specific types or generics for type safety
func jsonMarshal(v interface{}) ([]byte, error) {
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- bytes: total size in bytes4445Also returns COCOMO cost/schedule estimates and optionally LOCOMO (LLM cost) estimates.4647Use by_file with sort=complexity to find the most complex files in a project.`),48 mcp.WithString("path",49 mcp.Description("Directory or file path to analyze. Defaults to current directory."),50 ),51 mcp.WithString("sort",52 mcp.Description("Column to sort results by: files, name, lines, blank, code, comment, complexity, bytes. Default: files."),53 ),54 mcp.WithBoolean("by_file",55 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."),56 ),57 mcp.WithNumber("limit",58 mcp.Description("Maximum number of files to return per language when by_file is true. Defaults to 10. Set to -1 for unlimited."),59 ),60 mcp.WithString("include_ext",61 mcp.Description("Comma-separated list of file extensions to include (e.g. 'go,java,js')."),62 ),63 mcp.WithString("exclude_ext",64 mcp.Description("Comma-separated list of file extensions to exclude (e.g. 'json,xml')."),65 ),66 mcp.WithBoolean("no_duplicates",67 mcp.Description("Remove duplicate files from stats."),68 ),69 mcp.WithBoolean("no_min_gen",70 mcp.Description("Exclude minified or generated files."),71 ),72 mcp.WithBoolean("locomo",73 mcp.Description("Include LOCOMO (LLM Output COst MOdel) cost estimation in results."),74 ),75 mcp.WithString("locomo_preset",76 mcp.Description("LOCOMO model preset: large (GPT-4/Opus class), medium (Sonnet class), small (Haiku class), local (local LLM). Default: medium."),77 ),78 )7980 mcpServer.AddTool(analyzeTool, mcpAnalyzeHandler)8182 errLogger := log.New(os.Stderr, "scc-mcp: ", log.LstdFlags)83 if err := server.ServeStdio(mcpServer, server.WithErrorLogger(errLogger)); err != nil {84 _, _ = fmt.Fprintf(os.Stderr, "scc-mcp: server error: %v\n", err)85 os.Exit(1)86 }87}8889type mcpAnalyzeResponse struct {90 Path string `json:"path"`91 Languages []mcpLanguageResult `json:"languages"`92 Totals mcpTotals `json:"totals"`93 COCOMO *mcpCOCOMO `json:"cocomo,omitempty"`94 LOCOMO *mcpLOCOMO `json:"locomo,omitempty"`95 FileCount int64 `json:"totalFiles"`96 TotalLines int64 `json:"totalLines"`97 TotalCode int64 `json:"totalCode"`98}99100type mcpLanguageResult struct {101 Name string `json:"name"`102 Files int64 `json:"files"`103 Lines int64 `json:"lines"`104 Code int64 `json:"code"`105 Comment int64 `json:"comment"`106 Blank int64 `json:"blank"`107 Complexity int64 `json:"complexity"`108 Bytes int64 `json:"bytes"`109 FileList []mcpFileResult `json:"fileList,omitempty"`110}111112type mcpFileResult struct {113 Location string `json:"location"`114 Filename string `json:"filename"`115 Language string `json:"language"`116 Lines int64 `json:"lines"`117 Code int64 `json:"code"`118 Comment int64 `json:"comment"`119 Blank int64 `json:"blank"`120 Complexity int64 `json:"complexity"`121 Bytes int64 `json:"bytes"`122}123124type mcpTotals struct {125 Files int64 `json:"files"`126 Lines int64 `json:"lines"`127 Code int64 `json:"code"`128 Comment int64 `json:"comment"`129 Blank int64 `json:"blank"`130 Complexity int64 `json:"complexity"`131 Bytes int64 `json:"bytes"`132}133134type mcpCOCOMO struct {135 EstimatedCost float64 `json:"estimatedCost"`136 EstimatedScheduleMonths float64 `json:"estimatedScheduleMonths"`137 EstimatedPeople float64 `json:"estimatedPeople"`138}139140type mcpLOCOMO struct {141 Cost float64 `json:"cost"`142 InputTokens float64 `json:"inputTokens"`143 OutputTokens float64 `json:"outputTokens"`144 GenerationSeconds float64 `json:"generationSeconds"`145 ReviewHours float64 `json:"reviewHours"`146 Preset string `json:"preset"`147 AverageComplexityMult float64 `json:"averageComplexityMultiplier"`148 Cycles float64 `json:"cycles"`149}150151func mcpAnalyzeHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {152 args := request.GetArguments()153154 // Extract parameters155 path := "."156 if p, ok := args["path"].(string); ok && p != "" {157 path = p158 }159160 // Resolve to absolute path161 absPath, err := filepath.Abs(path)162 if err != nil {163 return mcp.NewToolResultError(fmt.Sprintf("invalid path: %v", err)), nil164 }165166 // Verify path can be accessed167 if _, err := os.Stat(absPath); err != nil {168 return mcp.NewToolResultError(fmt.Sprintf("path cannot be accessed: %s: %v", absPath, err)), nil169 }170171 // Serialize access to processor globals so concurrent MCP172 // requests don't race on shared state.173 mcpMu.Lock()174 defer mcpMu.Unlock()175176 // Configure processor globals for this request.177 // Some defaults are normally set by cobra flags which the MCP path178 // bypasses, so we set them explicitly here.179 processor.DirFilePaths = []string{absPath}180 processor.Format = "json"181 processor.Cocomo = false182 processor.Size = false183 processor.Files = false184 processor.PathDenyList = []string{".git", ".hg", ".svn"}185 processor.ExcludeFilename = []string{"package-lock.json", "Cargo.lock", "yarn.lock", "pubspec.lock", "Podfile.lock", "pnpm-lock.yaml"}186187 if sortBy, ok := args["sort"].(string); ok && sortBy != "" {188 processor.SortBy = sortBy189 } else {190 processor.SortBy = "files"191 }192193 if byFile, ok := args["by_file"].(bool); ok && byFile {194 processor.Files = true195 }196197 fileLimit := 10 // default limit when by_file is true198 if l, ok := args["limit"].(float64); ok {199 if l < 0 {200 fileLimit = 0 // -1 (or any negative) means unlimited201 } else {202 fileLimit = int(l)203 }204 }205206 if includeExt, ok := args["include_ext"].(string); ok && includeExt != "" {207 processor.AllowListExtensions = splitAndTrimExtensions(includeExt)208 } else {209 processor.AllowListExtensions = []string{}210 }211212 if excludeExt, ok := args["exclude_ext"].(string); ok && excludeExt != "" {213 processor.ExcludeListExtensions = splitAndTrimExtensions(excludeExt)214 } else {215 processor.ExcludeListExtensions = []string{}216 }217218 if noDups, ok := args["no_duplicates"].(bool); ok && noDups {219 processor.Duplicates = true220 } else {221 processor.Duplicates = false222 }223224 if noMinGen, ok := args["no_min_gen"].(bool); ok && noMinGen {225 processor.IgnoreMinifiedGenerate = true226 // GeneratedMarkers is normally set by cobra flag defaults which227 // the MCP path bypasses, so set them here.228 if len(processor.GeneratedMarkers) == 0 {229 processor.GeneratedMarkers = []string{"do not edit", "<auto-generated />"}230 }231 } else {232 processor.IgnoreMinifiedGenerate = false233 }234235 if locomo, ok := args["locomo"].(bool); ok && locomo {236 processor.Locomo = true237 } else {238 processor.Locomo = false239 }240241 if locomoPreset, ok := args["locomo_preset"].(string); ok && locomoPreset != "" {242 processor.LocomoPresetName = locomoPreset243 } else {244 processor.LocomoPresetName = "medium"245 }246247 processor.ConfigureLazy(true)248249 // Run the analysis250 language, err := processor.ProcessResult()251 if err != nil {252 return mcp.NewToolResultError(fmt.Sprintf("analysis failed: %v", err)), nil253 }254255 // Build response256 var totals mcpTotals257 langs := make([]mcpLanguageResult, 0, len(language))258259 for _, l := range language {260 lr := mcpLanguageResult{261 Name: l.Name,262 Files: l.Count,263 Lines: l.Lines,264 Code: l.Code,265 Comment: l.Comment,266 Blank: l.Blank,267 Complexity: l.Complexity,268 Bytes: l.Bytes,269 }270271 if processor.Files && len(l.Files) > 0 {272 files := l.Files273 // Sort files within each language by the same criteria274 // used for languages so per-file output is ordered and275 // limit returns the top N rather than an arbitrary slice.276 sortFileJobs(files)277 if fileLimit > 0 && len(files) > fileLimit {278 files = files[:fileLimit]279 }280 lr.FileList = make([]mcpFileResult, 0, len(files))281 for _, f := range files {282 lr.FileList = append(lr.FileList, mcpFileResult{283 Location: f.Location,284 Filename: f.Filename,285 Language: f.Language,286 Lines: f.Lines,287 Code: f.Code,288 Comment: f.Comment,289 Blank: f.Blank,290 Complexity: f.Complexity,291 Bytes: f.Bytes,292 })293 }294 }295296 langs = append(langs, lr)297298 totals.Files += l.Count299 totals.Lines += l.Lines300 totals.Code += l.Code301 totals.Comment += l.Comment302 totals.Blank += l.Blank303 totals.Complexity += l.Complexity304 totals.Bytes += l.Bytes305 }306307 resp := mcpAnalyzeResponse{308 Path: absPath,309 Languages: langs,310 Totals: totals,311 FileCount: totals.Files,312 TotalLines: totals.Lines,313 TotalCode: totals.Code,314 }315316 // COCOMO estimate317 estimatedEffort := processor.EstimateEffort(totals.Code, processor.EAF)318 estimatedCost := processor.EstimateCost(estimatedEffort, processor.AverageWage, processor.Overhead)319 estimatedScheduleMonths := processor.EstimateScheduleMonths(estimatedEffort)320 estimatedPeople := 0.0321 if estimatedScheduleMonths > 0 {322 estimatedPeople = estimatedEffort / estimatedScheduleMonths323 }324 resp.COCOMO = &mcpCOCOMO{325 EstimatedCost: estimatedCost,326 EstimatedScheduleMonths: estimatedScheduleMonths,327 EstimatedPeople: estimatedPeople,328 }329330 // LOCOMO estimate if requested331 if processor.Locomo {332 result := processor.LocomoEstimate(totals.Code, totals.Complexity)333 resp.LOCOMO = &mcpLOCOMO{334 Cost: result.Cost,335 InputTokens: result.InputTokens,336 OutputTokens: result.OutputTokens,337 GenerationSeconds: result.GenerationSeconds,338 ReviewHours: result.ReviewHours,339 Preset: result.Preset,340 AverageComplexityMult: result.AverageComplexityMult,341 Cycles: result.IterationFactor,342 }343 }344345 // Serialize to JSON346 jsonBytes, err := jsonMarshal(resp)347 if err != nil {348 return mcp.NewToolResultError(fmt.Sprintf("failed to serialize results: %v", err)), nil349 }350351 return mcp.NewToolResultText(string(jsonBytes)), nil352}353354func jsonMarshal(v interface{}) ([]byte, error) {355 return json.MarshalIndent(v, "", " ")356}357358// sortFileJobs sorts a slice of FileJob pointers using the current359// processor.SortBy value so that the most relevant files come first.360func sortFileJobs(files []*processor.FileJob) {361 switch processor.SortBy {362 case "name", "names", "language", "languages", "lang", "langs":363 slices.SortFunc(files, func(a, b *processor.FileJob) int {364 return strings.Compare(a.Filename, b.Filename)365 })366 case "line", "lines":367 slices.SortFunc(files, func(a, b *processor.FileJob) int {368 return cmp.Compare(b.Lines, a.Lines)369 })370 case "blank", "blanks":371 slices.SortFunc(files, func(a, b *processor.FileJob) int {372 return cmp.Compare(b.Blank, a.Blank)373 })374 case "code", "codes":375 slices.SortFunc(files, func(a, b *processor.FileJob) int {376 return cmp.Compare(b.Code, a.Code)377 })378 case "comment", "comments":379 slices.SortFunc(files, func(a, b *processor.FileJob) int {380 return cmp.Compare(b.Comment, a.Comment)381 })382 case "complexity", "complexitys":383 slices.SortFunc(files, func(a, b *processor.FileJob) int {384 return cmp.Compare(b.Complexity, a.Complexity)385 })386 case "byte", "bytes":387 slices.SortFunc(files, func(a, b *processor.FileJob) int {388 return cmp.Compare(b.Bytes, a.Bytes)389 })390 default:391 slices.SortFunc(files, func(a, b *processor.FileJob) int {392 return cmp.Compare(b.Lines, a.Lines)393 })394 }395}396397// splitAndTrimExtensions splits a comma-separated string into398// trimmed, non-empty extension entries.399func splitAndTrimExtensions(s string) []string {400 parts := strings.Split(s, ",")401 result := make([]string, 0, len(parts))402 for _, p := range parts {403 p = strings.TrimSpace(p)404 if p != "" {405 result = append(result, p)406 }407 }408 return result409}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.