Skip to content

Commit 9412582

Browse files
committed
feat: Add MCP server mode and token budget optimization for agentic coding
This major update transforms CodeContext from a simple file dumper into an intelligent context server optimized for agentic coding workflows. New Features: - MCP (Model Context Protocol) server implementation * Native integration with Claude Code, Cline, and other MCP clients * Four powerful MCP tools: GetCodeContext, GetProjectStructure, ListProjectFiles, GetFileContent * Stdio transport for seamless subprocess integration - Token Budget Optimization System * TokenCounter service for accurate token estimation * FileRelevanceScorer with multi-factor relevance scoring (filename, path, content, importance) * TokenBudgetOptimizer with three strategies: GreedyByScore, ValueOptimized, Balanced * Intelligent file selection to maximize relevance within token constraints - Enhanced Program Architecture * Dual-mode support: CLI mode (original) and MCP server mode (new) * Command-line flag --mcp/--server to enable MCP mode * Refactored CLI code into ProgramCli.cs for separation of concerns - Comprehensive Documentation * Updated README with MCP setup instructions * Detailed explanations of token optimization strategies * Example workflows and usage patterns * MCP configuration example file Technical Implementation: - Added ModelContextProtocol and Microsoft.Extensions.Hosting NuGet packages - Implemented MCP tools using attribute-based discovery pattern - Relevance scoring algorithm with configurable weights - Multiple selection strategies for different use cases - Task-specific context generation vs. whole-codebase dumps Benefits for Agentic Coding: - Token efficiency: Only send relevant files, not entire codebases - Task-specific context: Intelligent file selection based on task description - Scalable: Works with large codebases through smart sampling - Flexible: Multiple optimization strategies for different scenarios - Integration-ready: Native MCP support for modern AI coding tools This update positions CodeContext as essential infrastructure for agentic coding, similar to how LSP became fundamental for modern IDEs.
1 parent 25a8561 commit 9412582

9 files changed

Lines changed: 1097 additions & 74 deletions

CodeContext.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
</PropertyGroup>
88
<ItemGroup>
99
<PackageReference Include="DotNet.Glob" Version="3.1.3" />
10+
<PackageReference Include="ModelContextProtocol" Version="0.2.0-preview" />
11+
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
1012
</ItemGroup>
1113
<ItemGroup>
1214
<Compile Remove="CodeContext.Tests\**" />

Mcp/CodeContextTools.cs

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
using System.ComponentModel;
2+
using System.Text;
3+
using CodeContext.Configuration;
4+
using CodeContext.Interfaces;
5+
using CodeContext.Services;
6+
using CodeContext.Utils;
7+
using ModelContextProtocol.Server;
8+
9+
namespace CodeContext.Mcp;
10+
11+
/// <summary>
12+
/// MCP server tools for CodeContext functionality.
13+
/// Provides intelligent code context generation with token budget optimization.
14+
/// </summary>
15+
[McpServerToolType]
16+
public class CodeContextTools
17+
{
18+
private readonly IConsoleWriter _console;
19+
20+
public CodeContextTools(IConsoleWriter console)
21+
{
22+
_console = console;
23+
}
24+
25+
/// <summary>
26+
/// Gets optimized code context for a specific task within a token budget.
27+
/// </summary>
28+
[McpServerTool]
29+
[Description("Get optimized code context for a task. Intelligently selects most relevant files within token budget.")]
30+
public async Task<string> GetCodeContext(
31+
[Description("Path to the project directory to analyze")] string projectPath,
32+
[Description("Description of the task (e.g., 'fix authentication bug', 'add payment feature')")] string taskDescription,
33+
[Description("Maximum number of tokens to use (default: 50000)")] int tokenBudget = 50000,
34+
[Description("Include project structure in output (default: true)")] bool includeStructure = true,
35+
[Description("Selection strategy: GreedyByScore, ValueOptimized, or Balanced (default: ValueOptimized)")]
36+
string strategy = "ValueOptimized")
37+
{
38+
try
39+
{
40+
// Validate inputs
41+
Guard.DirectoryExists(projectPath, nameof(projectPath));
42+
43+
// Initialize services
44+
var filterConfig = new FilterConfiguration();
45+
var gitIgnoreParser = GitHelper.FindRepositoryRoot(projectPath) switch
46+
{
47+
null => GitIgnoreParser.Empty,
48+
var gitRoot => GitIgnoreParser.FromFile(Path.Combine(gitRoot, ".gitignore"))
49+
};
50+
51+
var fileChecker = new FileFilterService(filterConfig, gitIgnoreParser);
52+
var scanner = new ProjectScanner(fileChecker, _console);
53+
var scorer = new FileRelevanceScorer(projectPath);
54+
var optimizer = new TokenBudgetOptimizer();
55+
56+
// Parse strategy
57+
var strategyEnum = Enum.TryParse<TokenBudgetOptimizer.SelectionStrategy>(strategy, true, out var s)
58+
? s
59+
: TokenBudgetOptimizer.SelectionStrategy.ValueOptimized;
60+
61+
// Scan and score files
62+
var files = await Task.Run(() => GetAllProjectFiles(scanner, projectPath));
63+
var scoredFiles = files
64+
.Select(f => scorer.ScoreFile(f.path, f.content, taskDescription))
65+
.ToList();
66+
67+
// Optimize selection
68+
var result = optimizer.OptimizeSelection(
69+
scoredFiles,
70+
tokenBudget,
71+
strategyEnum,
72+
includeStructure);
73+
74+
// Build output
75+
var output = new StringBuilder();
76+
77+
output.AppendLine("# Code Context");
78+
output.AppendLine($"Project: {Path.GetFileName(projectPath)}");
79+
output.AppendLine($"Task: {taskDescription}");
80+
output.AppendLine();
81+
82+
output.AppendLine(TokenBudgetOptimizer.GenerateSummary(result));
83+
output.AppendLine();
84+
output.AppendLine(new string('=', 80));
85+
output.AppendLine();
86+
87+
// Include structure if requested
88+
if (includeStructure)
89+
{
90+
output.AppendLine("## Project Structure");
91+
output.AppendLine();
92+
var structure = scanner.GetProjectStructure(projectPath);
93+
output.AppendLine(structure);
94+
output.AppendLine();
95+
output.AppendLine(new string('=', 80));
96+
output.AppendLine();
97+
}
98+
99+
// Include selected files
100+
output.AppendLine("## Selected Files");
101+
output.AppendLine();
102+
103+
foreach (var file in result.SelectedFiles.OrderByDescending(f => f.RelevanceScore))
104+
{
105+
output.AppendLine($"### {file.FilePath}");
106+
output.AppendLine($"Relevance: {file.RelevanceScore:F3} | Tokens: {file.TokenCount:N0}");
107+
output.AppendLine(new string('-', 80));
108+
output.AppendLine(file.Content);
109+
output.AppendLine();
110+
}
111+
112+
return output.ToString();
113+
}
114+
catch (Exception ex)
115+
{
116+
return $"Error: {ex.Message}";
117+
}
118+
}
119+
120+
/// <summary>
121+
/// Gets the project structure (directory tree).
122+
/// </summary>
123+
[McpServerTool]
124+
[Description("Get the hierarchical directory structure of a project")]
125+
public string GetProjectStructure(
126+
[Description("Path to the project directory")] string projectPath)
127+
{
128+
try
129+
{
130+
Guard.DirectoryExists(projectPath, nameof(projectPath));
131+
132+
var filterConfig = new FilterConfiguration();
133+
var gitIgnoreParser = GitHelper.FindRepositoryRoot(projectPath) switch
134+
{
135+
null => GitIgnoreParser.Empty,
136+
var gitRoot => GitIgnoreParser.FromFile(Path.Combine(gitRoot, ".gitignore"))
137+
};
138+
139+
var fileChecker = new FileFilterService(filterConfig, gitIgnoreParser);
140+
var scanner = new ProjectScanner(fileChecker, _console);
141+
142+
return scanner.GetProjectStructure(projectPath);
143+
}
144+
catch (Exception ex)
145+
{
146+
return $"Error: {ex.Message}";
147+
}
148+
}
149+
150+
/// <summary>
151+
/// Lists all files in a project with metadata.
152+
/// </summary>
153+
[McpServerTool]
154+
[Description("List all files in a project with token counts and basic metadata")]
155+
public async Task<string> ListProjectFiles(
156+
[Description("Path to the project directory")] string projectPath,
157+
[Description("Optional query to filter/rank files")] string? query = null)
158+
{
159+
try
160+
{
161+
Guard.DirectoryExists(projectPath, nameof(projectPath));
162+
163+
var filterConfig = new FilterConfiguration();
164+
var gitIgnoreParser = GitHelper.FindRepositoryRoot(projectPath) switch
165+
{
166+
null => GitIgnoreParser.Empty,
167+
var gitRoot => GitIgnoreParser.FromFile(Path.Combine(gitRoot, ".gitignore"))
168+
};
169+
170+
var fileChecker = new FileFilterService(filterConfig, gitIgnoreParser);
171+
var scanner = new ProjectScanner(fileChecker, _console);
172+
173+
var files = await Task.Run(() => GetAllProjectFiles(scanner, projectPath));
174+
175+
var output = new StringBuilder();
176+
output.AppendLine($"# Project Files: {Path.GetFileName(projectPath)}");
177+
output.AppendLine();
178+
179+
if (!string.IsNullOrWhiteSpace(query))
180+
{
181+
// Score and sort by relevance
182+
var scorer = new FileRelevanceScorer(projectPath);
183+
var scored = files
184+
.Select(f => scorer.ScoreFile(f.path, f.content, query))
185+
.OrderByDescending(f => f.RelevanceScore)
186+
.ToList();
187+
188+
output.AppendLine($"Filtered by: {query}");
189+
output.AppendLine($"Total files: {scored.Count}");
190+
output.AppendLine();
191+
output.AppendLine("Path | Relevance | Tokens");
192+
output.AppendLine(new string('-', 80));
193+
194+
foreach (var file in scored)
195+
{
196+
output.AppendLine($"{file.FilePath} | {file.RelevanceScore:F3} | {file.TokenCount:N0}");
197+
}
198+
}
199+
else
200+
{
201+
// Just list all files
202+
output.AppendLine($"Total files: {files.Count}");
203+
output.AppendLine();
204+
output.AppendLine("Path | Tokens");
205+
output.AppendLine(new string('-', 80));
206+
207+
foreach (var (path, content) in files)
208+
{
209+
var tokens = TokenCounter.EstimateTokensForFile(path, content);
210+
output.AppendLine($"{path} | {tokens:N0}");
211+
}
212+
}
213+
214+
return output.ToString();
215+
}
216+
catch (Exception ex)
217+
{
218+
return $"Error: {ex.Message}";
219+
}
220+
}
221+
222+
/// <summary>
223+
/// Gets the content of specific files.
224+
/// </summary>
225+
[McpServerTool]
226+
[Description("Get the content of specific files by path")]
227+
public string GetFileContent(
228+
[Description("Path to the project directory")] string projectPath,
229+
[Description("Comma-separated list of file paths relative to project root")] string filePaths)
230+
{
231+
try
232+
{
233+
Guard.DirectoryExists(projectPath, nameof(projectPath));
234+
235+
var paths = filePaths.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
236+
var output = new StringBuilder();
237+
238+
output.AppendLine("# File Contents");
239+
output.AppendLine();
240+
241+
foreach (var relativePath in paths)
242+
{
243+
var fullPath = Path.Combine(projectPath, relativePath);
244+
245+
if (!File.Exists(fullPath))
246+
{
247+
output.AppendLine($"## {relativePath}");
248+
output.AppendLine("❌ File not found");
249+
output.AppendLine();
250+
continue;
251+
}
252+
253+
var content = File.ReadAllText(fullPath);
254+
var tokens = TokenCounter.EstimateTokensForFile(relativePath, content);
255+
256+
output.AppendLine($"## {relativePath}");
257+
output.AppendLine($"Tokens: {tokens:N0}");
258+
output.AppendLine(new string('-', 80));
259+
output.AppendLine(content);
260+
output.AppendLine();
261+
}
262+
263+
return output.ToString();
264+
}
265+
catch (Exception ex)
266+
{
267+
return $"Error: {ex.Message}";
268+
}
269+
}
270+
271+
/// <summary>
272+
/// Helper method to get all project files with content.
273+
/// </summary>
274+
private static List<(string path, string content)> GetAllProjectFiles(
275+
ProjectScanner scanner,
276+
string projectPath)
277+
{
278+
var files = new List<(string path, string content)>();
279+
var context = GitHelper.FindRepositoryRoot(projectPath) ?? projectPath;
280+
281+
CollectFiles(scanner, projectPath, context, files);
282+
283+
return files;
284+
}
285+
286+
private static void CollectFiles(
287+
ProjectScanner scanner,
288+
string currentPath,
289+
string rootPath,
290+
List<(string path, string content)> files)
291+
{
292+
try
293+
{
294+
var entries = Directory.EnumerateFileSystemEntries(currentPath)
295+
.Where(e => !scanner.GetType()
296+
.GetField("_fileChecker", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?
297+
.GetValue(scanner) is IFileChecker checker ||
298+
!checker.ShouldSkip(new FileInfo(e), rootPath))
299+
.ToList();
300+
301+
foreach (var entry in entries)
302+
{
303+
if (Directory.Exists(entry))
304+
{
305+
CollectFiles(scanner, entry, rootPath, files);
306+
}
307+
else if (File.Exists(entry))
308+
{
309+
try
310+
{
311+
var content = File.ReadAllText(entry);
312+
var relativePath = Path.GetRelativePath(rootPath, entry);
313+
files.Add((relativePath, content));
314+
}
315+
catch
316+
{
317+
// Skip files that can't be read
318+
}
319+
}
320+
}
321+
}
322+
catch
323+
{
324+
// Skip directories that can't be accessed
325+
}
326+
}
327+
}

0 commit comments

Comments
 (0)