mcp.go GO 410 lines View on github.com → Search inside
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}

Code quality findings 2

Empty interface; prefer specific types or generics for type safety
empty-interface
func jsonMarshal(v interface{}) ([]byte, error) {
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
lr.FileList = append(lr.FileList, mcpFileResult{

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.