mcp.go GO 603 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- 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: 0100, 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), 0100  the blast-radius probability121- reverse: P(target changes | this file changed), 0100  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+commitsBshared), 0100129Pairs 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}

Code quality findings 1

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.