# Directory Structure ``` ├── .gitignore ├── build-on-windows.bat ├── Build-OnWindows.ps1 ├── claude-mcp-config-wsl.json ├── CLAUDE.md ├── DotNetFrameworkMCP.sln ├── LICENSE ├── mcp-config-example.json ├── ProjectPlan.md ├── README.md ├── run-tcp-server-vs2022.bat ├── run-tcp-server.bat ├── src │ └── DotNetFrameworkMCP.Server │ ├── appsettings.json │ ├── Configuration │ │ └── McpServerConfiguration.cs │ ├── DotNetFrameworkMCP.Server.csproj │ ├── Executors │ │ ├── BaseTestExecutor.cs │ │ ├── DotNetBuildExecutor.cs │ │ ├── DotNetTestExecutor.cs │ │ ├── ExecutorFactory.cs │ │ ├── IBuildExecutor.cs │ │ ├── ITestExecutor.cs │ │ ├── MSBuildExecutor.cs │ │ └── VSTestExecutor.cs │ ├── Models │ │ ├── AnalyzeSolutionRequest.cs │ │ ├── BuildProjectRequest.cs │ │ ├── RunProjectRequest.cs │ │ └── RunTestsRequest.cs │ ├── Program.cs │ ├── Protocol │ │ ├── McpMessage.cs │ │ └── ToolDefinition.cs │ ├── Services │ │ ├── ProcessBasedBuildService.cs │ │ ├── TcpMcpServer.cs │ │ └── TestRunnerService.cs │ └── Tools │ ├── BuildProjectHandler.cs │ ├── IToolHandler.cs │ └── RunTestsHandler.cs ├── start-tcp-server.bat ├── test-dotnet-cli.bat ├── test-messages.json ├── test-tcp-server.sh ├── tests │ └── DotNetFrameworkMCP.Server.Tests │ ├── DotNetFrameworkMCP.Server.Tests.csproj │ ├── Executors │ │ ├── ExecutorFactoryTests.cs │ │ └── MSBuildExecutorTests.cs │ ├── GlobalUsings.cs │ ├── Services │ │ ├── ProcessBasedBuildServiceTests.cs │ │ └── TestRunnerServiceTests.cs │ └── Tools │ ├── BuildProjectHandlerTests.cs │ └── RunTestsHandlerTests.cs └── wsl-mcp-bridge.sh ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore # User-specific files *.rsuser *.suo *.user *.userosscache *.sln.docstates # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs # Mono auto generated files mono_crash.* # Build results [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ x64/ x86/ [Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ [Ll]og/ [Ll]ogs/ # Visual Studio 2015/2017 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ # Visual Studio 2017 auto generated files Generated\ Files/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* # NUnit *.VisualState.xml TestResult.xml nunit-*.xml # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ dlldata.c # Benchmark Results BenchmarkDotNet.Artifacts/ # .NET project.lock.json project.fragment.lock.json artifacts/ # ASP.NET Scaffolding ScaffoldingReadMe.txt # StyleCop StyleCopReport.xml # Files built by Visual Studio *_i.c *_p.c *_h.h *.ilk *.meta *.obj *.iobj *.pch *.pdb *.ipdb *.pgc *.pgd *.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj *_wpftmp.csproj *.log *.tlog *.vspscc *.vssscc .builds *.pidb *.svclog *.scc # Chutzpah Test files _Chutzpah* # Visual C++ cache files ipch/ *.aps *.ncb *.opendb *.opensdf *.sdf *.cachefile *.VC.db *.VC.VC.opendb # Visual Studio profiler *.psess *.vsp *.vspx *.sap # Visual Studio Trace Files *.e2e # TFS 2012 Local Workspace $tf/ # Guidance Automation Toolkit *.gpState # ReSharper is a .NET coding add-in _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user # TeamCity is a build add-in _TeamCity* # DotCover is a Code Coverage Tool *.dotCover # AxoCover is a Code Coverage Tool .axoCover/* !.axoCover/settings.json # Coverlet is a free, cross platform Code Coverage Tool coverage*.json coverage*.xml coverage*.info # Visual Studio code coverage results *.coverage *.coveragexml # NCrunch _NCrunch_* .*crunch*.local.xml nCrunchTemp_* # MightyMoose *.mm.* AutoTest.Net/ # Web workbench (sass) .sass-cache/ # Installshield output folder [Ee]xpress/ # DocProject is a documentation generator add-in DocProject/buildhelp/ DocProject/Help/*.HxT DocProject/Help/*.HxC DocProject/Help/*.hhc DocProject/Help/*.hhk DocProject/Help/*.hhp DocProject/Help/Html2 DocProject/Help/html # Click-Once directory publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml # Note: Comment the next line if you want to checkin your web deploy settings, # but database connection strings (with potential passwords) will be unencrypted *.pubxml *.publishproj # Microsoft Azure Web App publish settings. Comment the next line if you want to # checkin your Azure Web App publish settings, but sensitive information contained # in these scripts will be unencrypted PublishScripts/ # NuGet Packages *.nupkg # NuGet Symbol Packages *.snupkg # The packages folder can be ignored because of Package Restore **/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files *.nuget.props *.nuget.targets # Microsoft Azure Build Output csx/ *.build.csdef # Microsoft Azure Emulator ecf/ rcf/ # Windows Store app package directories and files AppPackages/ BundleArtifacts/ Package.StoreAssociation.xml _pkginfo.txt *.appx *.appxbundle *.appxupload # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !?*.[Cc]ache/ # Others ClientBin/ ~$* *~ *.dbmdl *.dbproj.schemaview *.jfm *.pfx *.publishsettings orleans.codegen.cs # Including strong name files can present a security risk # (https://github.com/github/gitignore/pull/2483#issue-259490424) #*.snk # Since there are multiple workflows, uncomment next line to ignore bower_components # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) #bower_components/ # RIA/Silverlight projects Generated_Code/ # Backup & report files from converting an old project file # to a newer Visual Studio version. Backup files are not needed, # because we have git ;-) _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML UpgradeLog*.htm ServiceFabricBackup/ *.rptproj.bak # SQL Server files *.mdf *.ldf *.ndf # Business Intelligence projects *.rdl.data *.bim.layout *.bim_*.settings *.rptproj.rsuser *- [Bb]ackup.rdl *- [Bb]ackup ([0-9]).rdl *- [Bb]ackup ([0-9][0-9]).rdl # Microsoft Fakes FakesAssemblies/ # GhostDoc plugin setting file *.GhostDoc.xml # Node.js Tools for Visual Studio .ntvs_analysis.dat node_modules/ # Visual Studio 6 build log *.plg # Visual Studio 6 workspace options file *.opt # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw # Visual Studio 6 auto-generated project file (contains which files were open etc.) *.vbp # Visual Studio 6 workspace and project file (working project files containing files to include in project) *.dsw *.dsp # Visual Studio 6 technical files *.ncb *.aps # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts **/*.DesktopClient/ModelManifest.xml **/*.Server/GeneratedArtifacts **/*.Server/ModelManifest.xml _Pvt_Extensions # Paket dependency manager .paket/paket.exe paket-files/ # FAKE - F# Make .fake/ # CodeRush personal settings .cr/personal # Python Tools for Visual Studio (PTVS) __pycache__/ *.pyc # Cake - Uncomment if you are using it # tools/** # !tools/packages.config # Tabs Studio *.tss # Telerik's JustMock configuration file *.jmconfig # BizTalk build output *.btp.cs *.btm.cs *.odx.cs *.xsd.cs # OpenCover UI analysis results OpenCover/ # Azure Stream Analytics local run output ASALocalRun/ # MSBuild Binary and Structured Log *.binlog # NVidia Nsight GPU debugger configuration file *.nvuser # MFractors (Xamarin productivity tool) working folder .mfractor/ # Local History for Visual Studio .localhistory/ # Visual Studio History (VSHistory) files .vshistory/ # BeatPulse healthcheck temp database healthchecksdb # Backup folder for Package Reference Convert tool in Visual Studio 2017 MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ # Fody - auto-generated XML schema FodyWeavers.xsd # VS Code files for those working on multiple tools .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json *.code-workspace # Local History for Visual Studio Code .history/ # Windows Installer files from build outputs *.cab *.msi *.msix *.msm *.msp # JetBrains Rider *.sln.iml .idea/ # macOS .DS_Store # Published output publish/ ``` -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- ```markdown # Claude Development Guidelines ## Project Information This is a .NET Framework MCP (Model Context Protocol) server for Windows that enables building, testing, and running .NET Framework projects remotely. ## Testing Framework **Use NUnit for all tests** - This project uses NUnit, not xUnit or MSTest. ### Test Structure: ```csharp using NUnit.Framework; [TestFixture] public class MyServiceTests { [SetUp] public void SetUp() { // Initialize test dependencies } [TearDown] public void TearDown() { // Cleanup resources } [Test] public void MyMethod_WithValidInput_ReturnsExpectedResult() { // Arrange // Act // Assert Assert.That(result, Is.EqualTo(expected)); } [TestCase("value1", "expected1")] [TestCase("value2", "expected2")] public void MyMethod_WithDifferentInputs_ReturnsExpectedResults(string input, string expected) { // Test implementation } } ``` ### NUnit Assertions: - Use `Assert.That(actual, Is.EqualTo(expected))` instead of `Assert.Equal()` - Use `Assert.That(collection, Has.Count.EqualTo(3))` for collections - Use `Assert.That(text, Does.Contain("substring"))` for string checks - Use `Times.Once` with Moq for verifications ## Build Commands ### Development: ```bash # Build the server dotnet build src/DotNetFrameworkMCP.Server # Run tests dotnet test # Run with dotnet CLI enabled set MCPSERVER__UseDotNetCli=true dotnet run --project src/DotNetFrameworkMCP.Server -- --port 3001 ``` ### IMPORTANT: Pre-Commit Workflow **ALWAYS build and test before committing!** ```bash # 1. Build to ensure no compilation errors dotnet build # 2. Run tests to ensure functionality works dotnet test # 3. Only commit if both succeed git add -A git commit -m "Your commit message" ``` If build or tests fail, fix the issues before committing. Never commit broken code. ### Production: ```bash # Build self-contained executable build-on-windows.bat # Run the compiled executable run-tcp-server.bat ``` ## Architecture Notes - Uses Strategy pattern for build/test executors - MSBuild vs dotnet CLI selection via configuration - Factory pattern for executor creation - Services delegate to executors, no conditional logic - All executors implement `IBuildExecutor` or `ITestExecutor` ## Configuration Key settings in `appsettings.json`: - `UseDotNetCli`: Switch between MSBuild and dotnet CLI - `PreferredVSVersion`: VS version preference for MSBuild - `BuildTimeout`: Build operation timeout - `TestTimeout`: Test operation timeout ``` -------------------------------------------------------------------------------- /tests/DotNetFrameworkMCP.Server.Tests/GlobalUsings.cs: -------------------------------------------------------------------------------- ```csharp global using NUnit.Framework; ``` -------------------------------------------------------------------------------- /claude-mcp-config-wsl.json: -------------------------------------------------------------------------------- ```json { "mcpServers": { "dotnet-framework": { "command": "/mnt/c/Users/work-tower/Projects/Open/MCP For .Net Framework/wsl-mcp-bridge.sh", "env": { "MCP_DEBUG": "true" } } } } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Tools/IToolHandler.cs: -------------------------------------------------------------------------------- ```csharp using System.Text.Json; using DotNetFrameworkMCP.Server.Protocol; namespace DotNetFrameworkMCP.Server.Tools; public interface IToolHandler { string Name { get; } ToolDefinition GetDefinition(); Task<object> ExecuteAsync(JsonElement arguments); } ``` -------------------------------------------------------------------------------- /mcp-config-example.json: -------------------------------------------------------------------------------- ```json { "mcpServers": { "dotnet-framework": { "command": "dotnet", "args": [ "run", "--project", "/mnt/c/Users/work-tower/Projects/Open/MCP For .Net Framework/src/DotNetFrameworkMCP.Server" ], "env": { "MCPSERVER_EnableDetailedLogging": "true" } } } } ``` -------------------------------------------------------------------------------- /test-dotnet-cli.bat: -------------------------------------------------------------------------------- ``` @echo off echo Testing dotnet CLI support... echo. echo Starting server with dotnet CLI enabled... REM Build the server first cd src\DotNetFrameworkMCP.Server dotnet build REM Run with dotnet CLI enabled set MCPSERVER__UseDotNetCli=true set MCPSERVER__EnableDetailedLogging=true echo. echo Running server with dotnet CLI support enabled... dotnet run -- --port 3001 ``` -------------------------------------------------------------------------------- /run-tcp-server.bat: -------------------------------------------------------------------------------- ``` @echo off echo Starting .NET Framework MCP Server in TCP mode... echo Server will listen on port 3001 echo Press Ctrl+C to stop echo. cd /d "%~dp0" if exist "publish\DotNetFrameworkMCP.Server.exe" ( publish\DotNetFrameworkMCP.Server.exe --port 3001 ) else ( echo ERROR: Server executable not found! echo Please run build-on-windows.bat first to build the project. pause exit /b 1 ) ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/appsettings.json: -------------------------------------------------------------------------------- ```json { "McpServer": { "MsBuildPath": "auto", "DefaultConfiguration": "Debug", "DefaultPlatform": "Any CPU", "TestTimeout": 300000, "BuildTimeout": 1200000, "EnableDetailedLogging": true, "PreferredVSVersion": "2022", "UseDotNetCli": false, "DotNetPath": "dotnet" }, "Logging": { "LogLevel": { "Default": "Information", "DotNetFrameworkMCP.Server": "Debug" } } } ``` -------------------------------------------------------------------------------- /start-tcp-server.bat: -------------------------------------------------------------------------------- ``` @echo off echo Starting .NET Framework MCP Server in TCP mode... echo Server will listen on port 3001 echo Press Ctrl+C to stop echo. cd /d "%~dp0" if exist "publish\DotNetFrameworkMCP.Server.exe" ( echo Using compiled executable... publish\DotNetFrameworkMCP.Server.exe --port 3001 ) else ( echo Using dotnet run (building if needed)... dotnet run --project "src\DotNetFrameworkMCP.Server" -- --port 3001 ) pause ``` -------------------------------------------------------------------------------- /wsl-mcp-bridge.sh: -------------------------------------------------------------------------------- ```bash #!/bin/bash # MCP Bridge Script for WSL # This script connects to the Windows TCP MCP server from WSL # Configuration WINDOWS_HOST="localhost" # Or use the actual Windows IP if needed MCP_PORT="3001" # Check if netcat is available if ! command -v nc &> /dev/null; then echo "Error: netcat (nc) is required but not installed." >&2 echo "Install it with: sudo apt install netcat-openbsd" >&2 exit 1 fi # Connect to the Windows MCP server via TCP exec nc "$WINDOWS_HOST" "$MCP_PORT" ``` -------------------------------------------------------------------------------- /test-messages.json: -------------------------------------------------------------------------------- ```json // Example MCP messages to test the server // 1. Initialize the server {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}} // 2. List available tools {"jsonrpc": "2.0", "id": 2, "method": "tools/list"} // 3. Call the build_project tool {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "build_project", "arguments": {"path": "./src/DotNetFrameworkMCP.Server/DotNetFrameworkMCP.Server.csproj", "configuration": "Debug", "restore": true}}} ``` -------------------------------------------------------------------------------- /build-on-windows.bat: -------------------------------------------------------------------------------- ``` @echo off echo Building .NET Framework MCP Server... echo. cd /d "%~dp0" echo Restoring packages... dotnet restore echo. echo Building Release configuration... dotnet build -c Release echo. echo Publishing self-contained executable... dotnet publish src\DotNetFrameworkMCP.Server -c Release -r win-x64 --self-contained -o publish echo. echo Build complete! echo Executable location: publish\DotNetFrameworkMCP.Server.exe echo. echo You can now run the server with: echo publish\DotNetFrameworkMCP.Server.exe --tcp --port 3001 echo. pause ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Models/RunProjectRequest.cs: -------------------------------------------------------------------------------- ```csharp using System.Text.Json.Serialization; namespace DotNetFrameworkMCP.Server.Models; public class RunProjectRequest { [JsonPropertyName("path")] public string Path { get; set; } = string.Empty; [JsonPropertyName("args")] public List<string>? Args { get; set; } [JsonPropertyName("workingDirectory")] public string? WorkingDirectory { get; set; } } public class RunResult { [JsonPropertyName("exitCode")] public int ExitCode { get; set; } [JsonPropertyName("output")] public string Output { get; set; } = string.Empty; [JsonPropertyName("error")] public string Error { get; set; } = string.Empty; [JsonPropertyName("duration")] public double Duration { get; set; } } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Configuration/McpServerConfiguration.cs: -------------------------------------------------------------------------------- ```csharp namespace DotNetFrameworkMCP.Server.Configuration; public class McpServerConfiguration { public string MsBuildPath { get; set; } = "auto"; public string DefaultConfiguration { get; set; } = "Debug"; public string DefaultPlatform { get; set; } = "Any CPU"; public int TestTimeout { get; set; } = 300000; public int BuildTimeout { get; set; } = 1200000; // 20 minutes for large solutions public bool EnableDetailedLogging { get; set; } = false; public string PreferredVSVersion { get; set; } = "2022"; // Options: "2022", "2019", "auto" public bool UseDotNetCli { get; set; } = false; // Use dotnet CLI instead of MSBuild public string DotNetPath { get; set; } = "dotnet"; // Path to dotnet CLI executable } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Executors/ITestExecutor.cs: -------------------------------------------------------------------------------- ```csharp using DotNetFrameworkMCP.Server.Models; namespace DotNetFrameworkMCP.Server.Executors; /// <summary> /// Interface for test execution strategies /// </summary> public interface ITestExecutor { /// <summary> /// Executes tests for the specified project /// </summary> /// <param name="projectPath">Path to the test project</param> /// <param name="filter">Optional test filter expression</param> /// <param name="verbose">Whether to enable verbose output</param> /// <param name="cancellationToken">Cancellation token</param> /// <returns>Test result with total, passed, failed, skipped counts and details</returns> Task<TestResult> ExecuteTestsAsync( string projectPath, string? filter, bool verbose, CancellationToken cancellationToken = default); } ``` -------------------------------------------------------------------------------- /test-tcp-server.sh: -------------------------------------------------------------------------------- ```bash #!/bin/bash echo "Testing TCP MCP Server..." # Start the server in background cd "/mnt/c/Users/work-tower/Projects/Open/MCP For .Net Framework" dotnet run --project src/DotNetFrameworkMCP.Server -- --port 3001 & SERVER_PID=$! # Wait a moment for server to start sleep 2 echo "Server started with PID $SERVER_PID" echo "Testing connection..." # Test the server { echo '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}' sleep 0.1 echo '{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}' sleep 0.1 echo '{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "build_project", "arguments": {"path": "./src/DotNetFrameworkMCP.Server/DotNetFrameworkMCP.Server.csproj", "configuration": "Debug", "restore": true}}}' sleep 1 } | nc localhost 3001 echo "Test completed. Stopping server..." kill $SERVER_PID wait $SERVER_PID 2>/dev/null echo "Server stopped." ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Executors/IBuildExecutor.cs: -------------------------------------------------------------------------------- ```csharp using DotNetFrameworkMCP.Server.Models; namespace DotNetFrameworkMCP.Server.Executors; /// <summary> /// Interface for build execution strategies /// </summary> public interface IBuildExecutor { /// <summary> /// Executes a build for the specified project /// </summary> /// <param name="projectPath">Path to the project or solution file</param> /// <param name="configuration">Build configuration (e.g., Debug, Release)</param> /// <param name="platform">Target platform (e.g., Any CPU, x86, x64)</param> /// <param name="restore">Whether to restore NuGet packages</param> /// <param name="cancellationToken">Cancellation token</param> /// <returns>Build result with success status, errors, warnings, and output</returns> Task<BuildResult> ExecuteBuildAsync( string projectPath, string configuration, string platform, bool restore, CancellationToken cancellationToken = default); } ``` -------------------------------------------------------------------------------- /Build-OnWindows.ps1: -------------------------------------------------------------------------------- ``` #!/usr/bin/env pwsh Write-Host "Building .NET Framework MCP Server..." -ForegroundColor Green Write-Host "" # Change to script directory Set-Location $PSScriptRoot Write-Host "Restoring packages..." -ForegroundColor Yellow dotnet restore Write-Host "" Write-Host "Building Release configuration..." -ForegroundColor Yellow dotnet build -c Release if ($LASTEXITCODE -ne 0) { Write-Host "Build failed!" -ForegroundColor Red exit 1 } Write-Host "" Write-Host "Publishing self-contained executable..." -ForegroundColor Yellow dotnet publish src\DotNetFrameworkMCP.Server -c Release -r win-x64 --self-contained -o publish if ($LASTEXITCODE -ne 0) { Write-Host "Publish failed!" -ForegroundColor Red exit 1 } Write-Host "" Write-Host "Build complete!" -ForegroundColor Green Write-Host "Executable location: publish\DotNetFrameworkMCP.Server.exe" -ForegroundColor Cyan Write-Host "" Write-Host "You can now run the server with:" -ForegroundColor Yellow Write-Host " .\publish\DotNetFrameworkMCP.Server.exe --tcp --port 3001" -ForegroundColor White Write-Host "" ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Executors/ExecutorFactory.cs: -------------------------------------------------------------------------------- ```csharp using DotNetFrameworkMCP.Server.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace DotNetFrameworkMCP.Server.Executors; /// <summary> /// Factory for creating build and test executors based on configuration /// </summary> public interface IExecutorFactory { IBuildExecutor CreateBuildExecutor(); ITestExecutor CreateTestExecutor(); } public class ExecutorFactory : IExecutorFactory { private readonly IServiceProvider _serviceProvider; private readonly McpServerConfiguration _configuration; public ExecutorFactory(IServiceProvider serviceProvider, IOptions<McpServerConfiguration> configuration) { _serviceProvider = serviceProvider; _configuration = configuration.Value; } public IBuildExecutor CreateBuildExecutor() { return _configuration.UseDotNetCli ? _serviceProvider.GetRequiredService<DotNetBuildExecutor>() : _serviceProvider.GetRequiredService<MSBuildExecutor>(); } public ITestExecutor CreateTestExecutor() { return _configuration.UseDotNetCli ? _serviceProvider.GetRequiredService<DotNetTestExecutor>() : _serviceProvider.GetRequiredService<VSTestExecutor>(); } } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Protocol/ToolDefinition.cs: -------------------------------------------------------------------------------- ```csharp using System.Text.Json.Serialization; namespace DotNetFrameworkMCP.Server.Protocol; public class ToolDefinition { [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; [JsonPropertyName("inputSchema")] public JsonSchema InputSchema { get; set; } = new(); } public class JsonSchema { [JsonPropertyName("type")] public string Type { get; set; } = "object"; [JsonPropertyName("properties")] public Dictionary<string, SchemaProperty> Properties { get; set; } = new(); [JsonPropertyName("required")] public List<string> Required { get; set; } = new(); } public class SchemaProperty { [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; [JsonPropertyName("description")] public string? Description { get; set; } [JsonPropertyName("enum")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List<string>? Enum { get; set; } [JsonPropertyName("default")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public object? Default { get; set; } [JsonPropertyName("items")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public SchemaProperty? Items { get; set; } } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Models/BuildProjectRequest.cs: -------------------------------------------------------------------------------- ```csharp using System.Text.Json.Serialization; namespace DotNetFrameworkMCP.Server.Models; public class BuildProjectRequest { [JsonPropertyName("path")] public string Path { get; set; } = string.Empty; [JsonPropertyName("configuration")] public string Configuration { get; set; } = "Debug"; [JsonPropertyName("platform")] public string Platform { get; set; } = "Any CPU"; [JsonPropertyName("restore")] public bool Restore { get; set; } = true; } public class BuildResult { [JsonPropertyName("success")] public bool Success { get; set; } [JsonPropertyName("errors")] public List<BuildMessage> Errors { get; set; } = new(); [JsonPropertyName("warnings")] public List<BuildMessage> Warnings { get; set; } = new(); [JsonPropertyName("buildTime")] public double BuildTime { get; set; } [JsonPropertyName("output")] public string Output { get; set; } = string.Empty; } public class BuildMessage { [JsonPropertyName("file")] public string? File { get; set; } [JsonPropertyName("line")] public int Line { get; set; } [JsonPropertyName("column")] public int Column { get; set; } [JsonPropertyName("code")] public string? Code { get; set; } [JsonPropertyName("message")] public string Message { get; set; } = string.Empty; [JsonPropertyName("project")] public string? Project { get; set; } } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Protocol/McpMessage.cs: -------------------------------------------------------------------------------- ```csharp using System.Text.Json.Serialization; namespace DotNetFrameworkMCP.Server.Protocol; public class McpMessage { [JsonPropertyName("jsonrpc")] public string JsonRpc { get; set; } = "2.0"; [JsonPropertyName("id")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public object? Id { get; set; } [JsonPropertyName("method")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Method { get; set; } [JsonPropertyName("params")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public object? Params { get; set; } [JsonPropertyName("result")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public object? Result { get; set; } [JsonPropertyName("error")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public McpError? Error { get; set; } } public class McpError { [JsonPropertyName("code")] public int Code { get; set; } [JsonPropertyName("message")] public string Message { get; set; } = string.Empty; [JsonPropertyName("data")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public object? Data { get; set; } } public static class McpErrorCodes { public const int ParseError = -32700; public const int InvalidRequest = -32600; public const int MethodNotFound = -32601; public const int InvalidParams = -32602; public const int InternalError = -32603; } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Models/RunTestsRequest.cs: -------------------------------------------------------------------------------- ```csharp using System.Text.Json.Serialization; namespace DotNetFrameworkMCP.Server.Models; public class RunTestsRequest { [JsonPropertyName("path")] public string Path { get; set; } = string.Empty; [JsonPropertyName("filter")] public string? Filter { get; set; } [JsonPropertyName("verbose")] public bool Verbose { get; set; } = false; } public class TestResult { [JsonPropertyName("totalTests")] public int TotalTests { get; set; } [JsonPropertyName("passedTests")] public int PassedTests { get; set; } [JsonPropertyName("failedTests")] public int FailedTests { get; set; } [JsonPropertyName("skippedTests")] public int SkippedTests { get; set; } [JsonPropertyName("duration")] public double Duration { get; set; } [JsonPropertyName("testDetails")] public List<TestDetail> TestDetails { get; set; } = new(); [JsonPropertyName("output")] public string Output { get; set; } = string.Empty; } public class TestDetail { [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; [JsonPropertyName("className")] public string ClassName { get; set; } = string.Empty; [JsonPropertyName("result")] public string Result { get; set; } = string.Empty; [JsonPropertyName("duration")] public double Duration { get; set; } [JsonPropertyName("errorMessage")] public string? ErrorMessage { get; set; } [JsonPropertyName("stackTrace")] public string? StackTrace { get; set; } } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Models/AnalyzeSolutionRequest.cs: -------------------------------------------------------------------------------- ```csharp using System.Text.Json.Serialization; namespace DotNetFrameworkMCP.Server.Models; public class AnalyzeSolutionRequest { [JsonPropertyName("path")] public string Path { get; set; } = string.Empty; } public class SolutionAnalysis { [JsonPropertyName("solutionName")] public string SolutionName { get; set; } = string.Empty; [JsonPropertyName("projects")] public List<ProjectInfo> Projects { get; set; } = new(); [JsonPropertyName("totalProjects")] public int TotalProjects { get; set; } } public class ProjectInfo { [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; [JsonPropertyName("path")] public string Path { get; set; } = string.Empty; [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; [JsonPropertyName("targetFramework")] public string TargetFramework { get; set; } = string.Empty; [JsonPropertyName("outputType")] public string OutputType { get; set; } = string.Empty; [JsonPropertyName("dependencies")] public List<string> Dependencies { get; set; } = new(); [JsonPropertyName("packages")] public List<PackageInfo> Packages { get; set; } = new(); } public class ListPackagesRequest { [JsonPropertyName("path")] public string Path { get; set; } = string.Empty; } public class PackageInfo { [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; [JsonPropertyName("version")] public string Version { get; set; } = string.Empty; } ``` -------------------------------------------------------------------------------- /run-tcp-server-vs2022.bat: -------------------------------------------------------------------------------- ``` @echo off echo Starting .NET Framework MCP Server with Visual Studio 2022 MSBuild... echo Server will listen on port 3001 echo Press Ctrl+C to stop echo. cd /d "%~dp0" REM Set MSBuild path to VS2022 (adjust path as needed) setlocal EnableDelayedExpansion set "paths[0]=C:\Program Files (x86)\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin" set "paths[1]=C:\Program Files (x86)\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin" set "paths[2]=C:\Program Files (x86)\Microsoft Visual Studio\2022\Preview\MSBuild\Current\Bin" set "paths[3]=C:\Program Files (x86)\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin" set "paths[4]=C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin" set "paths[5]=C:\Program Files\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin" set "paths[6]=C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin" set "paths[7]=C:\Program Files\Microsoft Visual Studio\2022\Preview\MSBuild\Current\Bin" set "paths[8]=C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin" set "paths[9]=C:\Program Files\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin" set "MSBUILD_PATH=" for /L %%i in (0,1,9) do ( call set "currentPath=%%paths[%%i]%%" if exist "!currentPath!" ( set "MSBUILD_PATH=!currentPath!" goto :found ) ) :found if defined MSBUILD_PATH ( echo Using MSBuild from: %MSBUILD_PATH% ) else ( echo Error: MSBuild path not found. pause ) echo. REM Run DotNetFramework MCP Server if exist "publish\DotNetFrameworkMCP.Server.exe" ( echo Using compiled executable... "publish\DotNetFrameworkMCP.Server.exe" --port 3001 ) else ( echo Using dotnet run, building if needed... dotnet run --project "src\DotNetFrameworkMCP.Server" -- --port 3001 ) pause ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Services/ProcessBasedBuildService.cs: -------------------------------------------------------------------------------- ```csharp using DotNetFrameworkMCP.Server.Executors; using DotNetFrameworkMCP.Server.Models; using Microsoft.Extensions.Logging; namespace DotNetFrameworkMCP.Server.Services; public interface IProcessBasedBuildService { Task<Models.BuildResult> BuildProjectAsync(string projectPath, string configuration, string platform, bool restore, CancellationToken cancellationToken = default); } public class ProcessBasedBuildService : IProcessBasedBuildService { private readonly ILogger<ProcessBasedBuildService> _logger; private readonly IExecutorFactory _executorFactory; public ProcessBasedBuildService( ILogger<ProcessBasedBuildService> logger, IExecutorFactory executorFactory) { _logger = logger; _executorFactory = executorFactory; } public async Task<Models.BuildResult> BuildProjectAsync( string projectPath, string configuration, string platform, bool restore, CancellationToken cancellationToken = default) { try { _logger.LogInformation("Starting build for project: {ProjectPath}", projectPath); var buildExecutor = _executorFactory.CreateBuildExecutor(); return await buildExecutor.ExecuteBuildAsync(projectPath, configuration, platform, restore, cancellationToken); } catch (Exception ex) { _logger.LogError(ex, "Failed to create build executor or execute build for project: {ProjectPath}", projectPath); return new Models.BuildResult { Success = false, Errors = new List<BuildMessage> { new BuildMessage { Message = ex.Message, File = projectPath } }, Warnings = new List<BuildMessage>(), BuildTime = 0, Output = $"Build service failed: {ex.Message}" }; } } } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Services/TestRunnerService.cs: -------------------------------------------------------------------------------- ```csharp using DotNetFrameworkMCP.Server.Executors; using DotNetFrameworkMCP.Server.Models; using Microsoft.Extensions.Logging; namespace DotNetFrameworkMCP.Server.Services; public interface ITestRunnerService { Task<TestResult> RunTestsAsync(string projectPath, string? filter, bool verbose, CancellationToken cancellationToken = default); } public class TestRunnerService : ITestRunnerService { private readonly ILogger<TestRunnerService> _logger; private readonly IExecutorFactory _executorFactory; public TestRunnerService( ILogger<TestRunnerService> logger, IExecutorFactory executorFactory) { _logger = logger; _executorFactory = executorFactory; } public async Task<TestResult> RunTestsAsync( string projectPath, string? filter, bool verbose, CancellationToken cancellationToken = default) { try { _logger.LogInformation("Starting test run for project: {ProjectPath}", projectPath); var testExecutor = _executorFactory.CreateTestExecutor(); return await testExecutor.ExecuteTestsAsync(projectPath, filter, verbose, cancellationToken); } catch (Exception ex) { _logger.LogError(ex, "Failed to create test executor or execute tests for project: {ProjectPath}", projectPath); return new TestResult { TotalTests = 0, PassedTests = 0, FailedTests = 0, SkippedTests = 0, Duration = 0, TestDetails = new List<TestDetail> { new TestDetail { Name = "Test Execution Error", ClassName = "System", Result = "Failed", Duration = 0, ErrorMessage = ex.Message, StackTrace = ex.StackTrace } }, Output = $"Test service failed: {ex.Message}" }; } } } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Tools/RunTestsHandler.cs: -------------------------------------------------------------------------------- ```csharp using System.Text.Json; using DotNetFrameworkMCP.Server.Configuration; using DotNetFrameworkMCP.Server.Models; using DotNetFrameworkMCP.Server.Protocol; using DotNetFrameworkMCP.Server.Services; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace DotNetFrameworkMCP.Server.Tools; public class RunTestsHandler : IToolHandler { private readonly ILogger<RunTestsHandler> _logger; private readonly McpServerConfiguration _configuration; private readonly ITestRunnerService _testRunnerService; public RunTestsHandler( ILogger<RunTestsHandler> logger, IOptions<McpServerConfiguration> configuration, ITestRunnerService testRunnerService) { _logger = logger; _configuration = configuration.Value; _testRunnerService = testRunnerService; } public string Name => "run_tests"; public ToolDefinition GetDefinition() { return new ToolDefinition { Name = Name, Description = "Run tests in a .NET test project", InputSchema = new JsonSchema { Type = "object", Properties = new Dictionary<string, SchemaProperty> { ["path"] = new SchemaProperty { Type = "string", Description = "Path to test project (.csproj file)" }, ["filter"] = new SchemaProperty { Type = "string", Description = "Test filter expression (optional)" }, ["verbose"] = new SchemaProperty { Type = "boolean", Description = "Enable verbose output", Default = false } }, Required = new List<string> { "path" } } }; } public async Task<object> ExecuteAsync(JsonElement arguments) { var request = JsonSerializer.Deserialize<RunTestsRequest>(arguments.GetRawText()); if (request == null || string.IsNullOrEmpty(request.Path)) { throw new ArgumentException("Invalid test run request"); } _logger.LogInformation("Running tests for project: {Path}", request.Path); if (!string.IsNullOrEmpty(request.Filter)) { _logger.LogInformation("Using test filter: {Filter}", request.Filter); } // Create cancellation token with timeout using var cts = new CancellationTokenSource(_configuration.TestTimeout); try { return await _testRunnerService.RunTestsAsync( request.Path, request.Filter, request.Verbose, cts.Token); } catch (OperationCanceledException) { throw new TimeoutException($"Test run timed out after {_configuration.TestTimeout}ms"); } } } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Program.cs: -------------------------------------------------------------------------------- ```csharp using DotNetFrameworkMCP.Server.Configuration; using DotNetFrameworkMCP.Server.Executors; using DotNetFrameworkMCP.Server.Services; using DotNetFrameworkMCP.Server.Tools; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; var host = Host.CreateDefaultBuilder(args) .ConfigureAppConfiguration((context, config) => { config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true); config.AddEnvironmentVariables(prefix: "MCPSERVER_"); }) .ConfigureServices((context, services) => { // Configuration services.Configure<McpServerConfiguration>( context.Configuration.GetSection("McpServer")); // Register executors services.AddSingleton<MSBuildExecutor>(); services.AddSingleton<DotNetBuildExecutor>(); services.AddSingleton<VSTestExecutor>(); services.AddSingleton<DotNetTestExecutor>(); services.AddSingleton<IExecutorFactory, ExecutorFactory>(); // Register services services.AddSingleton<IProcessBasedBuildService, ProcessBasedBuildService>(); services.AddSingleton<ITestRunnerService, TestRunnerService>(); // Register tool handlers services.AddSingleton<IToolHandler, BuildProjectHandler>(); services.AddSingleton<IToolHandler, RunTestsHandler>(); // Register the TCP MCP server services.AddSingleton<TcpMcpServer>(); // Configure logging services.AddLogging(builder => { builder.ClearProviders(); // Only log to stderr to keep stdout clean for MCP messages builder.AddConsole(options => { options.LogToStandardErrorThreshold = LogLevel.Trace; }); builder.SetMinimumLevel(LogLevel.Information); if (context.Configuration.GetValue<bool>("McpServer:EnableDetailedLogging")) { builder.SetMinimumLevel(LogLevel.Debug); } }); }) .UseConsoleLifetime() .Build(); var logger = host.Services.GetRequiredService<ILogger<Program>>(); try { logger.LogInformation("Starting .NET Framework MCP Server"); // Create cancellation token that responds to Ctrl+C using var cts = new CancellationTokenSource(); Console.CancelKeyPress += (sender, e) => { e.Cancel = true; cts.Cancel(); }; // Parse port from command line arguments var port = 3001; if (args.Contains("--port")) { var portIndex = Array.IndexOf(args, "--port"); if (portIndex + 1 < args.Length && int.TryParse(args[portIndex + 1], out var parsedPort)) { port = parsedPort; } } var tcpServer = host.Services.GetRequiredService<TcpMcpServer>(); logger.LogInformation("Starting TCP MCP Server on port {Port}", port); await tcpServer.RunAsync(port, cts.Token); } catch (Exception ex) { logger.LogCritical(ex, "Server failed to start"); Environment.Exit(1); } ``` -------------------------------------------------------------------------------- /tests/DotNetFrameworkMCP.Server.Tests/Executors/MSBuildExecutorTests.cs: -------------------------------------------------------------------------------- ```csharp using DotNetFrameworkMCP.Server.Configuration; using DotNetFrameworkMCP.Server.Executors; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; namespace DotNetFrameworkMCP.Server.Tests.Executors; [TestFixture] public class MSBuildExecutorTests { private Mock<ILogger<MSBuildExecutor>> _mockLogger; private Mock<IOptions<McpServerConfiguration>> _mockOptions; private McpServerConfiguration _configuration; private MSBuildExecutor _executor; [SetUp] public void SetUp() { _mockLogger = new Mock<ILogger<MSBuildExecutor>>(); _mockOptions = new Mock<IOptions<McpServerConfiguration>>(); _configuration = new McpServerConfiguration { BuildTimeout = 60000, PreferredVSVersion = "2022" }; _mockOptions.Setup(x => x.Value).Returns(_configuration); _executor = new MSBuildExecutor(_mockLogger.Object, _mockOptions.Object); } [Test] public async Task ExecuteBuildAsync_WithNonExistentProject_ReturnsFailedResult() { // Arrange var nonExistentPath = "/path/to/nonexistent/project.csproj"; // Act var result = await _executor.ExecuteBuildAsync(nonExistentPath, "Debug", "Any CPU", true); // Assert Assert.That(result.Success, Is.False); Assert.That(result.Errors, Has.Count.EqualTo(1)); Assert.That(result.Errors[0].Message, Does.Contain("Project file not found")); } [Test] public async Task ExecuteBuildAsync_WithTimeout_ReturnsFailedResult() { // Arrange _configuration.BuildTimeout = 1; // 1ms timeout to force timeout var tempProjectFile = Path.GetTempFileName(); File.WriteAllText(tempProjectFile, "<Project></Project>"); try { // Act var result = await _executor.ExecuteBuildAsync(tempProjectFile, "Debug", "Any CPU", true); // Assert // Should return a failed result due to timeout or MSBuild not found Assert.That(result.Success, Is.False); } finally { // Cleanup if (File.Exists(tempProjectFile)) File.Delete(tempProjectFile); } } [Test] public void Constructor_WithValidParameters_CreatesInstance() { // Act & Assert Assert.That(_executor, Is.Not.Null); } [TestCase("Debug", "Any CPU", true)] [TestCase("Release", "x64", false)] [TestCase("Debug", "x86", true)] public async Task ExecuteBuildAsync_WithDifferentConfigurations_HandlesGracefully( string configuration, string platform, bool restore) { // Arrange var tempProjectFile = Path.GetTempFileName(); File.WriteAllText(tempProjectFile, "<Project></Project>"); try { // Act var result = await _executor.ExecuteBuildAsync(tempProjectFile, configuration, platform, restore); // Assert // Result should be non-null (even if build fails due to no MSBuild) Assert.That(result, Is.Not.Null); } finally { // Cleanup if (File.Exists(tempProjectFile)) File.Delete(tempProjectFile); } } } ``` -------------------------------------------------------------------------------- /tests/DotNetFrameworkMCP.Server.Tests/Executors/ExecutorFactoryTests.cs: -------------------------------------------------------------------------------- ```csharp using DotNetFrameworkMCP.Server.Configuration; using DotNetFrameworkMCP.Server.Executors; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; namespace DotNetFrameworkMCP.Server.Tests.Executors; [TestFixture] public class ExecutorFactoryTests { private IServiceProvider _serviceProvider; private Mock<IOptions<McpServerConfiguration>> _mockOptions; private McpServerConfiguration _configuration; [SetUp] public void SetUp() { _configuration = new McpServerConfiguration(); _mockOptions = new Mock<IOptions<McpServerConfiguration>>(); _mockOptions.Setup(x => x.Value).Returns(_configuration); var services = new ServiceCollection(); // Register executors services.AddSingleton<MSBuildExecutor>(); services.AddSingleton<DotNetBuildExecutor>(); services.AddSingleton<VSTestExecutor>(); services.AddSingleton<DotNetTestExecutor>(); // Register configuration services.AddSingleton(_mockOptions.Object); // Register loggers services.AddSingleton<ILogger<MSBuildExecutor>>(Mock.Of<ILogger<MSBuildExecutor>>()); services.AddSingleton<ILogger<DotNetBuildExecutor>>(Mock.Of<ILogger<DotNetBuildExecutor>>()); services.AddSingleton<ILogger<VSTestExecutor>>(Mock.Of<ILogger<VSTestExecutor>>()); services.AddSingleton<ILogger<DotNetTestExecutor>>(Mock.Of<ILogger<DotNetTestExecutor>>()); _serviceProvider = services.BuildServiceProvider(); } [TearDown] public void TearDown() { if (_serviceProvider is IDisposable disposable) { disposable.Dispose(); } } [Test] public void CreateBuildExecutor_WhenUseDotNetCliIsFalse_ReturnsMSBuildExecutor() { // Arrange _configuration.UseDotNetCli = false; var factory = new ExecutorFactory(_serviceProvider, _mockOptions.Object); // Act var executor = factory.CreateBuildExecutor(); // Assert Assert.That(executor, Is.TypeOf<MSBuildExecutor>()); } [Test] public void CreateBuildExecutor_WhenUseDotNetCliIsTrue_ReturnsDotNetBuildExecutor() { // Arrange _configuration.UseDotNetCli = true; var factory = new ExecutorFactory(_serviceProvider, _mockOptions.Object); // Act var executor = factory.CreateBuildExecutor(); // Assert Assert.That(executor, Is.TypeOf<DotNetBuildExecutor>()); } [Test] public void CreateTestExecutor_WhenUseDotNetCliIsFalse_ReturnsVSTestExecutor() { // Arrange _configuration.UseDotNetCli = false; var factory = new ExecutorFactory(_serviceProvider, _mockOptions.Object); // Act var executor = factory.CreateTestExecutor(); // Assert Assert.That(executor, Is.TypeOf<VSTestExecutor>()); } [Test] public void CreateTestExecutor_WhenUseDotNetCliIsTrue_ReturnsDotNetTestExecutor() { // Arrange _configuration.UseDotNetCli = true; var factory = new ExecutorFactory(_serviceProvider, _mockOptions.Object); // Act var executor = factory.CreateTestExecutor(); // Assert Assert.That(executor, Is.TypeOf<DotNetTestExecutor>()); } [Test] public void Constructor_WithValidParameters_CreatesInstance() { // Act var factory = new ExecutorFactory(_serviceProvider, _mockOptions.Object); // Assert Assert.That(factory, Is.Not.Null); } } ``` -------------------------------------------------------------------------------- /tests/DotNetFrameworkMCP.Server.Tests/Tools/BuildProjectHandlerTests.cs: -------------------------------------------------------------------------------- ```csharp using System.Text.Json; using DotNetFrameworkMCP.Server.Configuration; using DotNetFrameworkMCP.Server.Models; using DotNetFrameworkMCP.Server.Services; using DotNetFrameworkMCP.Server.Tools; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace DotNetFrameworkMCP.Server.Tests.Tools; [TestFixture] public class BuildProjectHandlerTests { private BuildProjectHandler _handler; private ILogger<BuildProjectHandler> _logger; private IOptions<McpServerConfiguration> _configuration; private MockProcessBasedBuildService _buildService; [SetUp] public void Setup() { _logger = new TestLogger<BuildProjectHandler>(); _configuration = Options.Create(new McpServerConfiguration { DefaultConfiguration = "Debug", DefaultPlatform = "Any CPU", BuildTimeout = 600000 }); _buildService = new MockProcessBasedBuildService(); _handler = new BuildProjectHandler(_logger, _configuration, _buildService); } [Test] public void GetDefinition_ReturnsCorrectToolDefinition() { var definition = _handler.GetDefinition(); Assert.That(definition.Name, Is.EqualTo("build_project")); Assert.That(definition.Description, Is.EqualTo("Build a .NET project or solution")); Assert.That(definition.InputSchema, Is.Not.Null); Assert.That(definition.InputSchema.Type, Is.EqualTo("object")); Assert.That(definition.InputSchema.Properties, Has.Count.EqualTo(4)); Assert.That(definition.InputSchema.Properties.ContainsKey("path"), Is.True); Assert.That(definition.InputSchema.Required, Contains.Item("path")); } [Test] public async Task ExecuteAsync_WithValidRequest_ReturnsSuccessResult() { var request = new BuildProjectRequest { Path = "test.csproj", Configuration = "Debug", Platform = "Any CPU", Restore = true }; var json = JsonSerializer.Serialize(request); var element = JsonDocument.Parse(json).RootElement; var result = await _handler.ExecuteAsync(element); Assert.That(result, Is.Not.Null); Assert.That(result, Is.TypeOf<BuildResult>()); var buildResult = (BuildResult)result; Assert.That(buildResult.Success, Is.True); Assert.That(buildResult.Errors, Is.Empty); Assert.That(buildResult.Warnings, Is.Empty); Assert.That(buildResult.Output, Is.EqualTo("Build succeeded.")); } [Test] public void ExecuteAsync_WithInvalidRequest_ThrowsArgumentException() { var invalidJson = "{}"; var element = JsonDocument.Parse(invalidJson).RootElement; Assert.ThrowsAsync<ArgumentException>(async () => await _handler.ExecuteAsync(element)); } private class TestLogger<T> : ILogger<T> { public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { } } private class MockProcessBasedBuildService : IProcessBasedBuildService { public Task<Models.BuildResult> BuildProjectAsync(string projectPath, string configuration, string platform, bool restore, CancellationToken cancellationToken = default) { return Task.FromResult(new Models.BuildResult { Success = true, Errors = new List<BuildMessage>(), Warnings = new List<BuildMessage>(), BuildTime = 2.45, Output = "Build succeeded." }); } } } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Executors/DotNetTestExecutor.cs: -------------------------------------------------------------------------------- ```csharp using DotNetFrameworkMCP.Server.Configuration; using DotNetFrameworkMCP.Server.Models; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace DotNetFrameworkMCP.Server.Executors; /// <summary> /// dotnet test-based test executor /// </summary> public class DotNetTestExecutor : BaseTestExecutor { public DotNetTestExecutor( ILogger<DotNetTestExecutor> logger, IOptions<McpServerConfiguration> configuration) : base(logger, configuration) { } public override async Task<TestResult> ExecuteTestsAsync( string projectPath, string? filter, bool verbose, CancellationToken cancellationToken = default) { var stopwatch = System.Diagnostics.Stopwatch.StartNew(); try { // Validate project path if (!File.Exists(projectPath)) { throw new FileNotFoundException($"Test project file not found: {projectPath}"); } var arguments = new List<string> { "test", $"\"{projectPath}\"", "--no-build" // Assume project is already built }; if (!string.IsNullOrEmpty(filter)) { arguments.Add("--filter"); arguments.Add($"\"{filter}\""); } if (verbose) { arguments.Add("--verbosity"); arguments.Add("detailed"); } else { arguments.Add("--verbosity"); arguments.Add("normal"); } // Add logger for structured output var trxFileName = $"TestResults_{Guid.NewGuid():N}.trx"; var trxFilePath = Path.Combine(Path.GetTempPath(), trxFileName); arguments.Add($"--logger"); arguments.Add($"trx;LogFileName=\"{trxFilePath}\""); var argumentString = string.Join(" ", arguments); _logger.LogInformation("Running dotnet test: {Arguments}", argumentString); var result = await RunProcessAsync(_configuration.DotNetPath, argumentString, cancellationToken); // Parse results from TRX file var testResult = await ParseTrxFileAsync(trxFilePath, result.Output); // Clean up TRX file try { if (File.Exists(trxFilePath)) File.Delete(trxFilePath); } catch (Exception ex) { _logger.LogDebug("Failed to delete TRX file: {Error}", ex.Message); } stopwatch.Stop(); testResult.Duration = stopwatch.Elapsed.TotalSeconds; return testResult; } catch (Exception ex) { _logger.LogError(ex, "Error running dotnet test for project: {ProjectPath}", projectPath); stopwatch.Stop(); return new TestResult { TotalTests = 0, PassedTests = 0, FailedTests = 0, SkippedTests = 0, Duration = stopwatch.Elapsed.TotalSeconds, TestDetails = new List<TestDetail> { new TestDetail { Name = "Test Execution Error", ClassName = "DotNetTest", Result = "Failed", Duration = 0, ErrorMessage = ex.Message, StackTrace = ex.StackTrace } }, Output = $"Dotnet test execution failed: {ex.Message}\n{ex.StackTrace}" }; } } } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Tools/BuildProjectHandler.cs: -------------------------------------------------------------------------------- ```csharp using System.Text.Json; using DotNetFrameworkMCP.Server.Configuration; using DotNetFrameworkMCP.Server.Models; using DotNetFrameworkMCP.Server.Protocol; using DotNetFrameworkMCP.Server.Services; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace DotNetFrameworkMCP.Server.Tools; public class BuildProjectHandler : IToolHandler { private readonly ILogger<BuildProjectHandler> _logger; private readonly McpServerConfiguration _configuration; private readonly IProcessBasedBuildService _buildService; public BuildProjectHandler( ILogger<BuildProjectHandler> logger, IOptions<McpServerConfiguration> configuration, IProcessBasedBuildService buildService) { _logger = logger; _configuration = configuration.Value; _buildService = buildService; } public string Name => "build_project"; public ToolDefinition GetDefinition() { return new ToolDefinition { Name = Name, Description = "Build a .NET project or solution", InputSchema = new JsonSchema { Type = "object", Properties = new Dictionary<string, SchemaProperty> { ["path"] = new SchemaProperty { Type = "string", Description = "Path to .csproj or .sln file" }, ["configuration"] = new SchemaProperty { Type = "string", Description = "Build configuration", Enum = new List<string> { "Debug", "Release" }, Default = "Debug" }, ["platform"] = new SchemaProperty { Type = "string", Description = "Target platform", Enum = new List<string> { "Any CPU", "x86", "x64" }, Default = "Any CPU" }, ["restore"] = new SchemaProperty { Type = "boolean", Description = "Restore NuGet packages", Default = true } }, Required = new List<string> { "path" } } }; } public async Task<object> ExecuteAsync(JsonElement arguments) { var request = JsonSerializer.Deserialize<BuildProjectRequest>(arguments.GetRawText()); if (request == null || string.IsNullOrEmpty(request.Path)) { throw new ArgumentException("Invalid build request"); } _logger.LogInformation("Building project: {Path}", request.Path); // Use default values from configuration if not specified var configuration = string.IsNullOrEmpty(request.Configuration) ? _configuration.DefaultConfiguration : request.Configuration; var platform = string.IsNullOrEmpty(request.Platform) ? _configuration.DefaultPlatform : request.Platform; // Create cancellation token with timeout using var cts = new CancellationTokenSource(_configuration.BuildTimeout); try { _logger.LogDebug("Building project using process-based MSBuild approach"); return await _buildService.BuildProjectAsync( request.Path, configuration, platform, request.Restore, cts.Token); } catch (OperationCanceledException) { throw new TimeoutException($"Build timed out after {_configuration.BuildTimeout}ms"); } } } ``` -------------------------------------------------------------------------------- /tests/DotNetFrameworkMCP.Server.Tests/Tools/RunTestsHandlerTests.cs: -------------------------------------------------------------------------------- ```csharp using System.Text.Json; using DotNetFrameworkMCP.Server.Configuration; using DotNetFrameworkMCP.Server.Models; using DotNetFrameworkMCP.Server.Services; using DotNetFrameworkMCP.Server.Tools; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; namespace DotNetFrameworkMCP.Server.Tests.Tools; [TestFixture] public class RunTestsHandlerTests { private Mock<ILogger<RunTestsHandler>> _mockLogger; private Mock<ITestRunnerService> _mockTestRunnerService; private IOptions<McpServerConfiguration> _configuration; private RunTestsHandler _handler; [SetUp] public void SetUp() { _mockLogger = new Mock<ILogger<RunTestsHandler>>(); _mockTestRunnerService = new Mock<ITestRunnerService>(); _configuration = Options.Create(new McpServerConfiguration { TestTimeout = 300000 }); _handler = new RunTestsHandler(_mockLogger.Object, _configuration, _mockTestRunnerService.Object); } [Test] public void Name_ShouldReturnCorrectName() { Assert.That(_handler.Name, Is.EqualTo("run_tests")); } [Test] public void GetDefinition_ShouldReturnValidDefinition() { var definition = _handler.GetDefinition(); Assert.That(definition.Name, Is.EqualTo("run_tests")); Assert.That(definition.Description, Is.EqualTo("Run tests in a .NET test project")); Assert.That(definition.InputSchema.Type, Is.EqualTo("object")); Assert.That(definition.InputSchema.Properties, Contains.Key("path")); Assert.That(definition.InputSchema.Properties["path"].Type, Is.EqualTo("string")); Assert.That(definition.InputSchema.Required, Contains.Item("path")); } [Test] public async Task ExecuteAsync_WithValidRequest_ShouldCallTestRunnerService() { // Arrange var expectedResult = new TestResult { TotalTests = 5, PassedTests = 4, FailedTests = 1, SkippedTests = 0, Duration = 2.5, TestDetails = new List<TestDetail>() }; _mockTestRunnerService .Setup(x => x.RunTestsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>())) .ReturnsAsync(expectedResult); var request = new RunTestsRequest { Path = "TestProject.csproj", Filter = "Category=Unit", Verbose = true }; var jsonElement = JsonSerializer.SerializeToElement(request); // Act var result = await _handler.ExecuteAsync(jsonElement); // Assert Assert.That(result, Is.EqualTo(expectedResult)); _mockTestRunnerService.Verify( x => x.RunTestsAsync("TestProject.csproj", "Category=Unit", true, It.IsAny<CancellationToken>()), Times.Once); } [Test] public async Task ExecuteAsync_WithMinimalRequest_ShouldUseDefaults() { // Arrange var expectedResult = new TestResult(); _mockTestRunnerService .Setup(x => x.RunTestsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>())) .ReturnsAsync(expectedResult); var request = new RunTestsRequest { Path = "TestProject.csproj" }; var jsonElement = JsonSerializer.SerializeToElement(request); // Act var result = await _handler.ExecuteAsync(jsonElement); // Assert _mockTestRunnerService.Verify( x => x.RunTestsAsync("TestProject.csproj", null, false, It.IsAny<CancellationToken>()), Times.Once); } [Test] public void ExecuteAsync_WithNullRequest_ShouldThrowArgumentException() { // Arrange var jsonElement = JsonSerializer.SerializeToElement((object?)null); // Act & Assert Assert.ThrowsAsync<ArgumentException>(() => _handler.ExecuteAsync(jsonElement)); } [Test] public void ExecuteAsync_WithEmptyPath_ShouldThrowArgumentException() { // Arrange var request = new RunTestsRequest { Path = "" }; var jsonElement = JsonSerializer.SerializeToElement(request); // Act & Assert Assert.ThrowsAsync<ArgumentException>(() => _handler.ExecuteAsync(jsonElement)); } [Test] public void ExecuteAsync_WhenServiceTimesOut_ShouldThrowTimeoutException() { // Arrange _mockTestRunnerService .Setup(x => x.RunTestsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>())) .Throws<OperationCanceledException>(); var request = new RunTestsRequest { Path = "TestProject.csproj" }; var jsonElement = JsonSerializer.SerializeToElement(request); // Act & Assert Assert.ThrowsAsync<TimeoutException>(() => _handler.ExecuteAsync(jsonElement)); } } ``` -------------------------------------------------------------------------------- /tests/DotNetFrameworkMCP.Server.Tests/Services/TestRunnerServiceTests.cs: -------------------------------------------------------------------------------- ```csharp using DotNetFrameworkMCP.Server.Executors; using DotNetFrameworkMCP.Server.Models; using DotNetFrameworkMCP.Server.Services; using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; namespace DotNetFrameworkMCP.Server.Tests.Services; [TestFixture] public class TestRunnerServiceTests { private Mock<ILogger<TestRunnerService>> _mockLogger; private Mock<IExecutorFactory> _mockExecutorFactory; private Mock<ITestExecutor> _mockTestExecutor; private TestRunnerService _service; private string _tempProjectFile; [SetUp] public void SetUp() { _mockLogger = new Mock<ILogger<TestRunnerService>>(); _mockExecutorFactory = new Mock<IExecutorFactory>(); _mockTestExecutor = new Mock<ITestExecutor>(); _mockExecutorFactory.Setup(x => x.CreateTestExecutor()) .Returns(_mockTestExecutor.Object); _service = new TestRunnerService(_mockLogger.Object, _mockExecutorFactory.Object); // Create a temporary project file for testing _tempProjectFile = Path.GetTempFileName(); File.WriteAllText(_tempProjectFile, @" <Project Sdk=""Microsoft.NET.Sdk""> <PropertyGroup> <TargetFramework>net48</TargetFramework> <IsPackable>false</IsPackable> </PropertyGroup> <ItemGroup> <PackageReference Include=""Microsoft.NET.Test.Sdk"" Version=""17.0.0"" /> <PackageReference Include=""MSTest.TestFramework"" Version=""2.2.7"" /> <PackageReference Include=""MSTest.TestAdapter"" Version=""2.2.7"" /> </ItemGroup> </Project>"); } [TearDown] public void TearDown() { if (File.Exists(_tempProjectFile)) { File.Delete(_tempProjectFile); } } [Test] public async Task RunTestsAsync_CallsExecutorFactory() { // Arrange var expectedResult = new TestResult { TotalTests = 5, PassedTests = 4, FailedTests = 1, SkippedTests = 0, Duration = 2.5, TestDetails = new List<TestDetail>(), Output = "Test output" }; _mockTestExecutor.Setup(x => x.ExecuteTestsAsync( It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>())) .ReturnsAsync(expectedResult); // Act var result = await _service.RunTestsAsync(_tempProjectFile, null, false); // Assert Assert.That(result, Is.EqualTo(expectedResult)); _mockExecutorFactory.Verify(x => x.CreateTestExecutor(), Times.Once); _mockTestExecutor.Verify(x => x.ExecuteTestsAsync( _tempProjectFile, null, false, It.IsAny<CancellationToken>()), Times.Once); } [Test] public async Task RunTestsAsync_WithFilter_PassesFilterToExecutor() { // Arrange var filter = "TestCategory=Unit"; var expectedResult = new TestResult { TotalTests = 1, PassedTests = 1 }; _mockTestExecutor.Setup(x => x.ExecuteTestsAsync( It.IsAny<string>(), filter, It.IsAny<bool>(), It.IsAny<CancellationToken>())) .ReturnsAsync(expectedResult); // Act var result = await _service.RunTestsAsync(_tempProjectFile, filter, true); // Assert _mockTestExecutor.Verify(x => x.ExecuteTestsAsync( _tempProjectFile, filter, true, It.IsAny<CancellationToken>()), Times.Once); } [Test] public async Task RunTestsAsync_WhenExecutorThrows_ReturnsFailedResult() { // Arrange _mockTestExecutor.Setup(x => x.ExecuteTestsAsync( It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>())) .ThrowsAsync(new InvalidOperationException("Test executor failed")); // Act var result = await _service.RunTestsAsync(_tempProjectFile, null, false); // Assert Assert.That(result.TotalTests, Is.EqualTo(0)); Assert.That(result.PassedTests, Is.EqualTo(0)); Assert.That(result.FailedTests, Is.EqualTo(0)); Assert.That(result.SkippedTests, Is.EqualTo(0)); Assert.That(result.TestDetails, Has.Count.EqualTo(1)); Assert.That(result.TestDetails[0].Result, Is.EqualTo("Failed")); Assert.That(result.TestDetails[0].ErrorMessage, Does.Contain("Test executor failed")); } [Test] public async Task RunTestsAsync_WhenFactoryThrows_ReturnsFailedResult() { // Arrange _mockExecutorFactory.Setup(x => x.CreateTestExecutor()) .Throws(new InvalidOperationException("Factory failed")); // Act var result = await _service.RunTestsAsync(_tempProjectFile, null, false); // Assert Assert.That(result.TotalTests, Is.EqualTo(0)); Assert.That(result.TestDetails, Has.Count.EqualTo(1)); Assert.That(result.TestDetails[0].Result, Is.EqualTo("Failed")); Assert.That(result.Output, Does.Contain("Test service failed")); } } ``` -------------------------------------------------------------------------------- /tests/DotNetFrameworkMCP.Server.Tests/Services/ProcessBasedBuildServiceTests.cs: -------------------------------------------------------------------------------- ```csharp using DotNetFrameworkMCP.Server.Executors; using DotNetFrameworkMCP.Server.Models; using DotNetFrameworkMCP.Server.Services; using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; namespace DotNetFrameworkMCP.Server.Tests.Services; [TestFixture] public class ProcessBasedBuildServiceTests { private Mock<ILogger<ProcessBasedBuildService>> _mockLogger; private Mock<IExecutorFactory> _mockExecutorFactory; private Mock<IBuildExecutor> _mockBuildExecutor; private ProcessBasedBuildService _service; private string _tempProjectFile; [SetUp] public void SetUp() { _mockLogger = new Mock<ILogger<ProcessBasedBuildService>>(); _mockExecutorFactory = new Mock<IExecutorFactory>(); _mockBuildExecutor = new Mock<IBuildExecutor>(); _mockExecutorFactory.Setup(x => x.CreateBuildExecutor()) .Returns(_mockBuildExecutor.Object); _service = new ProcessBasedBuildService(_mockLogger.Object, _mockExecutorFactory.Object); // Create a temporary project file for testing _tempProjectFile = Path.GetTempFileName(); File.WriteAllText(_tempProjectFile, @" <Project Sdk=""Microsoft.NET.Sdk""> <PropertyGroup> <TargetFramework>net48</TargetFramework> </PropertyGroup> </Project>"); } [TearDown] public void TearDown() { if (File.Exists(_tempProjectFile)) { File.Delete(_tempProjectFile); } } [Test] public async Task BuildProjectAsync_CallsExecutorFactory() { // Arrange var expectedResult = new BuildResult { Success = true, BuildTime = 5.2, Errors = new List<BuildMessage>(), Warnings = new List<BuildMessage>(), Output = "Build succeeded" }; _mockBuildExecutor.Setup(x => x.ExecuteBuildAsync( It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>())) .ReturnsAsync(expectedResult); // Act var result = await _service.BuildProjectAsync(_tempProjectFile, "Debug", "Any CPU", true); // Assert Assert.That(result, Is.EqualTo(expectedResult)); _mockExecutorFactory.Verify(x => x.CreateBuildExecutor(), Times.Once); _mockBuildExecutor.Verify(x => x.ExecuteBuildAsync( _tempProjectFile, "Debug", "Any CPU", true, It.IsAny<CancellationToken>()), Times.Once); } [Test] public async Task BuildProjectAsync_WithDifferentParameters_PassesToExecutor() { // Arrange var expectedResult = new BuildResult { Success = true }; var configuration = "Release"; var platform = "x64"; var restore = false; _mockBuildExecutor.Setup(x => x.ExecuteBuildAsync( It.IsAny<string>(), configuration, platform, restore, It.IsAny<CancellationToken>())) .ReturnsAsync(expectedResult); // Act var result = await _service.BuildProjectAsync(_tempProjectFile, configuration, platform, restore); // Assert _mockBuildExecutor.Verify(x => x.ExecuteBuildAsync( _tempProjectFile, configuration, platform, restore, It.IsAny<CancellationToken>()), Times.Once); } [Test] public async Task BuildProjectAsync_WhenExecutorThrows_ReturnsFailedResult() { // Arrange _mockBuildExecutor.Setup(x => x.ExecuteBuildAsync( It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>())) .ThrowsAsync(new InvalidOperationException("Build executor failed")); // Act var result = await _service.BuildProjectAsync(_tempProjectFile, "Debug", "Any CPU", true); // Assert Assert.That(result.Success, Is.False); Assert.That(result.Errors, Has.Count.EqualTo(1)); Assert.That(result.Errors[0].Message, Does.Contain("Build executor failed")); Assert.That(result.Output, Does.Contain("Build service failed")); } [Test] public async Task BuildProjectAsync_WhenFactoryThrows_ReturnsFailedResult() { // Arrange _mockExecutorFactory.Setup(x => x.CreateBuildExecutor()) .Throws(new InvalidOperationException("Factory failed")); // Act var result = await _service.BuildProjectAsync(_tempProjectFile, "Debug", "Any CPU", true); // Assert Assert.That(result.Success, Is.False); Assert.That(result.Errors, Has.Count.EqualTo(1)); Assert.That(result.Errors[0].Message, Does.Contain("Factory failed")); Assert.That(result.BuildTime, Is.EqualTo(0)); } [Test] public async Task BuildProjectAsync_WithCancellationToken_PassesToExecutor() { // Arrange var expectedResult = new BuildResult { Success = true }; var cancellationTokenSource = new CancellationTokenSource(); _mockBuildExecutor.Setup(x => x.ExecuteBuildAsync( It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(), cancellationTokenSource.Token)) .ReturnsAsync(expectedResult); // Act var result = await _service.BuildProjectAsync(_tempProjectFile, "Debug", "Any CPU", true, cancellationTokenSource.Token); // Assert _mockBuildExecutor.Verify(x => x.ExecuteBuildAsync( _tempProjectFile, "Debug", "Any CPU", true, cancellationTokenSource.Token), Times.Once); } } ``` -------------------------------------------------------------------------------- /ProjectPlan.md: -------------------------------------------------------------------------------- ```markdown # .NET Framework MCP Service Project Plan ## Project Overview Build a Model Context Protocol (MCP) service that enables Claude Code to build, test, and run .NET Framework projects. The service will provide a standardized interface for interacting with MSBuild, test runners, and other .NET tooling. ## Core Requirements ### 1. Build Operations - Trigger MSBuild for solutions and projects - Support multiple build configurations (Debug/Release) - Support multiple platforms (Any CPU, x86, x64) - Handle NuGet package restoration - Parse and return structured build output (errors, warnings, success status) ### 2. Test Operations - Discover test projects and test methods - Execute all tests or specific test selections - Support multiple test frameworks (MSTest, NUnit, xUnit) - Parse and return test results with pass/fail counts and error details ### 3. Run Operations - Execute console applications with arguments - Capture and return console output - Handle process termination and timeouts ### 4. Project Analysis - List projects in a solution - Show project dependencies - Display project properties and configurations - List NuGet packages and versions ## Technical Architecture ### Technology Stack - **Language**: C# (.NET 6+ for the MCP service itself) - **MCP SDK**: Use official MCP SDK for C# (if available) or implement protocol directly - **Dependencies**: - Microsoft.Build for MSBuild integration - Microsoft.Build.Locator for finding MSBuild installations - Test framework APIs for test discovery/execution ### MCP Methods to Implement ``` tools: - name: build_project description: Build a .NET project or solution inputSchema: type: object properties: path: { type: string, description: "Path to .csproj or .sln file" } configuration: { type: string, enum: ["Debug", "Release"], default: "Debug" } platform: { type: string, enum: ["Any CPU", "x86", "x64"], default: "Any CPU" } restore: { type: boolean, default: true, description: "Restore NuGet packages" } - name: run_tests description: Run tests in a .NET test project inputSchema: type: object properties: path: { type: string, description: "Path to test project" } filter: { type: string, description: "Test filter expression" } verbose: { type: boolean, default: false } - name: run_project description: Execute a .NET console application inputSchema: type: object properties: path: { type: string, description: "Path to project" } args: { type: array, items: { type: string } } workingDirectory: { type: string } - name: analyze_solution description: Get information about a solution structure inputSchema: type: object properties: path: { type: string, description: "Path to .sln file" } - name: list_packages description: List NuGet packages in a project inputSchema: type: object properties: path: { type: string, description: "Path to project" } ``` ## Implementation Phases ### Phase 1: Core Infrastructure (Week 1) ✅ COMPLETED - [x] Set up C# project structure - [x] Implement MCP protocol handling with JSON-RPC support - [x] Create basic server lifecycle (start, stop, health check) - [x] Implement logging framework with configurable verbosity - [x] Add configuration management with environment variable support - [x] Switch test framework to NUnit - [x] Create initial unit tests ### Phase 2: Build Functionality (Week 2) ✅ COMPLETED - [x] Implement MSBuild locator logic with Visual Studio version selection - [x] Create build_project method - [x] Add build output parsing with intelligent truncation - [x] Implement error/warning extraction - [x] Add NuGet restore functionality - [x] Add TCP server support for cross-platform communication - [x] Create WSL-to-Windows bridge for Claude Code integration - [x] Implement build cancellation and timeout handling - [x] Add MCP token limit compliance (25k token limit) ### Phase 3: Test Runner Integration (Week 3) ✅ COMPLETED - [x] Implement test discovery logic - [x] Add support for MSTest runner - [x] Add support for NUnit runner - [x] Add support for xUnit runner - [x] Create test result parsing with TRX file support - [x] Implement test filtering - [x] Add comprehensive error message and stack trace extraction - [x] Implement test adapter discovery and integration - [x] Add solution-based building for proper test configuration ### Phase 4: Project Execution (Week 4) - [ ] Implement run_project method - [ ] Add process management - [ ] Implement output capture - [ ] Add timeout handling - [ ] Handle process termination - [ ] Write unit tests for execution operations ### Phase 5: Analysis Features (Week 5) - [ ] Implement solution analysis - [ ] Add project dependency mapping - [ ] Create package listing functionality - [ ] Add project property inspection - [ ] Write unit tests for analysis operations ### Phase 6: Polish & Documentation (Week 6) - [ ] Comprehensive error handling - [ ] Performance optimization - [ ] Add integration tests - [ ] Write user documentation - [ ] Create example usage scenarios - [ ] Package for distribution ## Key Implementation Details ### MSBuild Integration ```csharp // Use Microsoft.Build.Locator to find MSBuild MSBuildLocator.RegisterDefaults(); // Use Microsoft.Build API for building var projectCollection = new ProjectCollection(); var project = projectCollection.LoadProject(projectPath); ``` ### Output Parsing Strategy - Use MSBuild loggers to capture structured output - Implement custom logger for JSON-formatted results - Parse compiler error format: `file(line,col): error CODE: message` ### Test Runner Integration - Use VSTest.Console.exe for universal test execution - Parse TRX files for structured results - Support test filtering using standard syntax ### Error Handling - Graceful handling of missing MSBuild installations - Clear error messages for missing dependencies - Timeout handling for long-running operations - Process cleanup on service shutdown ## Configuration Schema ```json { "McpServer": { "MsBuildPath": "auto", "DefaultConfiguration": "Debug", "DefaultPlatform": "Any CPU", "TestTimeout": 300000, "BuildTimeout": 1200000, "EnableDetailedLogging": false, "PreferredVSVersion": "2022" } } ``` ## Testing Strategy ### Unit Tests - Mock MSBuild API calls - Test output parsing logic - Verify error handling paths - Test configuration management ### Integration Tests - Use sample .NET projects - Test full build/test/run cycles - Verify cross-framework compatibility - Test error scenarios (missing files, bad syntax) ## Distribution Plan 1. **NuGet Package**: Primary distribution as a .NET tool 2. **GitHub Releases**: Compiled binaries with installation script 3. **Docker Image**: Containerized version with MSBuild pre-installed ## Success Criteria - Successfully builds complex multi-project solutions - Accurately reports build errors and warnings - Runs tests from all major test frameworks - Provides clear, actionable error messages - Performs operations within reasonable time limits - Maintains stability during long-running operations ## Future Enhancements ### Security & Authentication - API key or token-based authentication - Role-based access control for different operations - Audit logging for security compliance - Network access restrictions and whitelisting ### Extended Platform Support - Support for .NET Core/.NET 5+ projects - Web project launching with browser integration - Support for F# and VB.NET projects ### Advanced Development Features - Code coverage reporting - Incremental build support - Watch mode for continuous building/testing - Integration with code analyzers - Performance profiling and diagnostics ## Resources & References - [MCP Specification](https://modelcontextprotocol.io/docs) - [MSBuild API Documentation](https://docs.microsoft.com/en-us/dotnet/api/microsoft.build) - [VSTest Documentation](https://docs.microsoft.com/en-us/visualstudio/test/vstest-console-options) - [NuGet API Reference](https://docs.microsoft.com/en-us/nuget/api/overview) ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Executors/BaseTestExecutor.cs: -------------------------------------------------------------------------------- ```csharp using System.Diagnostics; using System.Text; using System.Xml.Linq; using DotNetFrameworkMCP.Server.Configuration; using DotNetFrameworkMCP.Server.Models; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace DotNetFrameworkMCP.Server.Executors; /// <summary> /// Base class for test executors with common functionality /// </summary> public abstract class BaseTestExecutor : ITestExecutor { protected readonly ILogger _logger; protected readonly McpServerConfiguration _configuration; protected BaseTestExecutor(ILogger logger, IOptions<McpServerConfiguration> configuration) { _logger = logger; _configuration = configuration.Value; } public abstract Task<TestResult> ExecuteTestsAsync( string projectPath, string? filter, bool verbose, CancellationToken cancellationToken = default); protected async Task<(string Output, int ExitCode)> RunProcessAsync( string fileName, string arguments, CancellationToken cancellationToken) { _logger.LogDebug("Running: {FileName} {Arguments}", fileName, arguments); using var process = new Process(); process.StartInfo.FileName = fileName; process.StartInfo.Arguments = arguments; process.StartInfo.UseShellExecute = false; process.StartInfo.RedirectStandardOutput = true; process.StartInfo.RedirectStandardError = true; process.StartInfo.CreateNoWindow = true; var output = new StringBuilder(); process.OutputDataReceived += (sender, e) => { if (e.Data != null) { output.AppendLine(e.Data); } }; process.ErrorDataReceived += (sender, e) => { if (e.Data != null) { output.AppendLine(e.Data); } }; process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); // Wait for completion with timeout and cancellation support var timeoutMs = _configuration.TestTimeout; using var timeoutCts = new CancellationTokenSource(timeoutMs); using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); try { await process.WaitForExitAsync(combinedCts.Token); } catch (OperationCanceledException) when (timeoutCts.Token.IsCancellationRequested) { _logger.LogWarning("Test execution timed out after {TimeoutMs}ms, killing process", timeoutMs); process.Kill(); throw new TimeoutException($"Test execution timed out after {timeoutMs}ms"); } catch (OperationCanceledException) { _logger.LogInformation("Test execution cancelled by user, killing process"); process.Kill(); throw; } return (output.ToString(), process.ExitCode); } protected async Task<TestResult> ParseTrxFileAsync(string trxFilePath, string consoleOutput) { var testDetails = new List<TestDetail>(); try { if (File.Exists(trxFilePath)) { var trxContent = await File.ReadAllTextAsync(trxFilePath); _logger.LogDebug("Parsing TRX file: {TrxFilePath}", trxFilePath); var doc = XDocument.Parse(trxContent); var ns = doc.Root?.GetDefaultNamespace(); if (ns != null) { // Parse test results var unitTestResults = doc.Descendants(ns + "UnitTestResult"); foreach (var result in unitTestResults) { var testId = result.Attribute("testId")?.Value; var testName = result.Attribute("testName")?.Value ?? "Unknown Test"; var outcome = result.Attribute("outcome")?.Value ?? "Unknown"; var duration = result.Attribute("duration")?.Value; // Try to find the test definition to get class information var className = "Unknown"; if (!string.IsNullOrEmpty(testId)) { var testDefinition = doc.Descendants(ns + "UnitTest") .FirstOrDefault(t => t.Attribute("id")?.Value == testId); if (testDefinition != null) { var testMethod = testDefinition.Descendants(ns + "TestMethod").FirstOrDefault(); className = testMethod?.Attribute("className")?.Value ?? "Unknown"; } } // Parse duration var durationSeconds = 0.0; if (!string.IsNullOrEmpty(duration) && TimeSpan.TryParse(duration, out var durationTimeSpan)) { durationSeconds = durationTimeSpan.TotalSeconds; } // Get error message and stack trace for failed tests string? errorMessage = null; string? stackTrace = null; if (outcome == "Failed") { var output = result.Element(ns + "Output"); var errorInfo = output?.Element(ns + "ErrorInfo"); errorMessage = errorInfo?.Element(ns + "Message")?.Value; stackTrace = errorInfo?.Element(ns + "StackTrace")?.Value; } testDetails.Add(new TestDetail { Name = testName, ClassName = className, Result = outcome, Duration = durationSeconds, ErrorMessage = errorMessage, StackTrace = stackTrace }); } } } // If no tests were parsed from TRX, fall back to console output parsing if (testDetails.Count == 0) { _logger.LogWarning("No tests found in TRX file, falling back to console output parsing"); ParseConsoleOutput(consoleOutput, testDetails); } } catch (Exception ex) { _logger.LogError(ex, "Error parsing TRX file: {TrxFilePath}", trxFilePath); // Fall back to console output parsing ParseConsoleOutput(consoleOutput, testDetails); } return new TestResult { TotalTests = testDetails.Count, PassedTests = testDetails.Count(t => t.Result == "Passed"), FailedTests = testDetails.Count(t => t.Result == "Failed"), SkippedTests = testDetails.Count(t => t.Result == "Skipped"), TestDetails = testDetails, Output = consoleOutput }; } private void ParseConsoleOutput(string output, List<TestDetail> testDetails) { if (string.IsNullOrEmpty(output)) return; var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); foreach (var line in lines) { // Simple pattern matching for common test output formats if (line.Contains("Passed") || line.Contains("Failed") || line.Contains("Skipped")) { var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length >= 2) { var result = parts.FirstOrDefault(p => p == "Passed" || p == "Failed" || p == "Skipped"); if (result != null) { testDetails.Add(new TestDetail { Name = line.Trim(), ClassName = "ParsedFromConsole", Result = result, Duration = 0, ErrorMessage = result == "Failed" ? "Failed (parsed from console output)" : null, StackTrace = null }); } } } } } } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Services/TcpMcpServer.cs: -------------------------------------------------------------------------------- ```csharp using System.Net; using System.Net.Sockets; using System.Text; using System.Text.Json; using DotNetFrameworkMCP.Server.Configuration; using DotNetFrameworkMCP.Server.Protocol; using DotNetFrameworkMCP.Server.Tools; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace DotNetFrameworkMCP.Server.Services; public class TcpMcpServer { private readonly ILogger<TcpMcpServer> _logger; private readonly McpServerConfiguration _configuration; private readonly Dictionary<string, IToolHandler> _toolHandlers; private readonly JsonSerializerOptions _jsonOptions; private TcpListener? _listener; public TcpMcpServer( ILogger<TcpMcpServer> logger, IOptions<McpServerConfiguration> configuration, IEnumerable<IToolHandler> toolHandlers) { _logger = logger; _configuration = configuration.Value; _toolHandlers = toolHandlers.ToDictionary(h => h.Name); _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; } public async Task RunAsync(int port = 3001, CancellationToken cancellationToken = default) { _listener = new TcpListener(IPAddress.Any, port); _listener.Start(); _logger.LogInformation("TCP MCP Server listening on port {Port}", port); // Register cancellation callback to stop the listener using var registration = cancellationToken.Register(() => { _logger.LogInformation("Cancellation requested, stopping TCP listener"); _listener?.Stop(); }); try { while (!cancellationToken.IsCancellationRequested) { var client = await _listener.AcceptTcpClientAsync().WaitAsync(cancellationToken); _logger.LogInformation("Client connected from {RemoteEndPoint}", client.Client.RemoteEndPoint); // Handle each client in a separate task _ = Task.Run(async () => await HandleClientAsync(client, cancellationToken), cancellationToken); } } catch (OperationCanceledException) { // Expected when cancellation occurs _logger.LogInformation("TCP server shutdown requested"); } catch (ObjectDisposedException) { // Expected when cancellation occurs } finally { _listener?.Stop(); _listener = null; _logger.LogInformation("TCP MCP Server stopped"); } } private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken) { try { using (client) { var stream = client.GetStream(); var buffer = new byte[4096]; var messageBuffer = new List<byte>(); while (!cancellationToken.IsCancellationRequested && client.Connected) { var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); if (bytesRead == 0) break; messageBuffer.AddRange(buffer.Take(bytesRead)); // Process complete messages (look for newline delimiters) while (true) { var newlineIndex = messageBuffer.IndexOf((byte)'\n'); if (newlineIndex == -1) break; var messageBytes = messageBuffer.Take(newlineIndex).ToArray(); messageBuffer.RemoveRange(0, newlineIndex + 1); if (messageBytes.Length > 0) { var response = await ProcessMessageAsync(messageBytes); if (response != null) { var responseJson = JsonSerializer.Serialize(response, _jsonOptions); var responseBytes = Encoding.UTF8.GetBytes(responseJson + "\n"); await stream.WriteAsync(responseBytes, 0, responseBytes.Length, cancellationToken); await stream.FlushAsync(cancellationToken); } } } } } } catch (Exception ex) { _logger.LogError(ex, "Error handling client"); } } private async Task<McpMessage?> ProcessMessageAsync(byte[] messageBytes) { try { var message = JsonSerializer.Deserialize<McpMessage>(messageBytes, _jsonOptions); if (message == null) { return CreateErrorResponse(null, McpErrorCodes.ParseError, "Failed to parse message"); } _logger.LogDebug("Received message: {Method}", message.Method); // Handle different MCP methods switch (message.Method) { case "initialize": return await HandleInitializeAsync(message); case "tools/list": return await HandleToolsListAsync(message); case "tools/call": return await HandleToolCallAsync(message); case "notifications/cancelled": // Handle cancellation notification from client _logger.LogInformation("Received cancellation notification from client"); return null; // Notifications don't require responses case null when message.Id != null: // This is likely a response to a request we made return null; default: _logger.LogWarning("Unknown method received: {Method}", message.Method); return CreateErrorResponse( message.Id, McpErrorCodes.MethodNotFound, $"Method '{message.Method}' not found"); } } catch (JsonException ex) { _logger.LogError(ex, "Failed to parse JSON message"); return CreateErrorResponse(null, McpErrorCodes.ParseError, "Invalid JSON"); } catch (Exception ex) { _logger.LogError(ex, "Error processing message"); return CreateErrorResponse(null, McpErrorCodes.InternalError, ex.Message); } } private Task<McpMessage> HandleInitializeAsync(McpMessage request) { var response = new McpMessage { Id = request.Id, Result = new { protocolVersion = "2025-06-18", capabilities = new { tools = new { } }, serverInfo = new { name = "dotnet-framework-mcp-tcp", version = "1.0.0" } } }; return Task.FromResult(response); } private Task<McpMessage> HandleToolsListAsync(McpMessage request) { var tools = _toolHandlers.Values.Select(h => h.GetDefinition()).ToList(); var response = new McpMessage { Id = request.Id, Result = new { tools } }; return Task.FromResult(response); } private async Task<McpMessage> HandleToolCallAsync(McpMessage request) { try { var toolCallParams = JsonSerializer.Deserialize<ToolCallParams>( JsonSerializer.Serialize(request.Params), _jsonOptions); if (toolCallParams == null || string.IsNullOrEmpty(toolCallParams.Name)) { return CreateErrorResponse( request.Id, McpErrorCodes.InvalidParams, "Invalid tool call parameters"); } if (!_toolHandlers.TryGetValue(toolCallParams.Name, out var handler)) { return CreateErrorResponse( request.Id, McpErrorCodes.InvalidParams, $"Tool '{toolCallParams.Name}' not found"); } var result = await handler.ExecuteAsync(toolCallParams.Arguments); return new McpMessage { Id = request.Id, Result = new { content = new[] { new { type = "text", text = JsonSerializer.Serialize(result, _jsonOptions) } } } }; } catch (Exception ex) { _logger.LogError(ex, "Error executing tool"); return CreateErrorResponse( request.Id, McpErrorCodes.InternalError, $"Tool execution failed: {ex.Message}"); } } private McpMessage CreateErrorResponse(object? id, int code, string message) { return new McpMessage { Id = id, Error = new McpError { Code = code, Message = message } }; } private class ToolCallParams { public string Name { get; set; } = string.Empty; public JsonElement Arguments { get; set; } } } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Executors/DotNetBuildExecutor.cs: -------------------------------------------------------------------------------- ```csharp using System.Diagnostics; using System.Text; using System.Text.RegularExpressions; using DotNetFrameworkMCP.Server.Configuration; using DotNetFrameworkMCP.Server.Models; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace DotNetFrameworkMCP.Server.Executors; /// <summary> /// dotnet CLI-based build executor /// </summary> public class DotNetBuildExecutor : IBuildExecutor { private readonly ILogger<DotNetBuildExecutor> _logger; private readonly McpServerConfiguration _configuration; public DotNetBuildExecutor( ILogger<DotNetBuildExecutor> logger, IOptions<McpServerConfiguration> configuration) { _logger = logger; _configuration = configuration.Value; } public async Task<BuildResult> ExecuteBuildAsync( string projectPath, string configuration, string platform, bool restore, CancellationToken cancellationToken = default) { var stopwatch = Stopwatch.StartNew(); var errors = new List<BuildMessage>(); var warnings = new List<BuildMessage>(); try { // Validate project path if (!File.Exists(projectPath)) { throw new FileNotFoundException($"Project file not found: {projectPath}"); } _logger.LogInformation("Using dotnet CLI for build"); // Build the project using dotnet CLI var result = await RunDotNetBuildAsync(projectPath, configuration, platform, restore, cancellationToken); // Parse the output for errors and warnings ParseBuildOutput(result.Output, errors, warnings); stopwatch.Stop(); return new BuildResult { Success = result.ExitCode == 0, Errors = errors, Warnings = warnings, BuildTime = stopwatch.Elapsed.TotalSeconds, Output = TruncateOutput(result.Output, result.ExitCode != 0) }; } catch (Exception ex) { _logger.LogError(ex, "Build failed for project: {ProjectPath}", projectPath); stopwatch.Stop(); return new BuildResult { Success = false, Errors = new List<BuildMessage> { new BuildMessage { Message = ex.Message, File = projectPath } }, Warnings = warnings, BuildTime = stopwatch.Elapsed.TotalSeconds, Output = TruncateOutput($"Build failed with exception: {ex.Message}\n{ex.StackTrace}", true) }; } } private async Task<(int ExitCode, string Output)> RunDotNetBuildAsync( string projectPath, string configuration, string platform, bool restore, CancellationToken cancellationToken) { var arguments = new List<string> { "build", $"\"{projectPath}\"", $"--configuration", configuration, "--verbosity", "normal" }; // Platform is typically handled differently in dotnet CLI // For .NET Framework projects, it's often part of the runtime identifier if (!string.IsNullOrEmpty(platform) && platform != "Any CPU") { arguments.Add($"-p:Platform=\"{platform}\""); } if (!restore) { arguments.Add("--no-restore"); } var argumentString = string.Join(" ", arguments); _logger.LogDebug("Running: {DotNetPath} {Arguments}", _configuration.DotNetPath, argumentString); var psi = new ProcessStartInfo { FileName = _configuration.DotNetPath, Arguments = argumentString, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, WorkingDirectory = Path.GetDirectoryName(projectPath) ?? Environment.CurrentDirectory }; var output = new StringBuilder(); using var process = new Process { StartInfo = psi }; process.OutputDataReceived += (sender, e) => { if (e.Data != null) { output.AppendLine(e.Data); } }; process.ErrorDataReceived += (sender, e) => { if (e.Data != null) { output.AppendLine(e.Data); } }; try { process.Start(); } catch (Exception ex) { throw new InvalidOperationException($"Failed to start dotnet CLI. Make sure .NET SDK is installed and '{_configuration.DotNetPath}' is in PATH.", ex); } process.BeginOutputReadLine(); process.BeginErrorReadLine(); // Wait for completion with timeout and cancellation support var timeoutMs = _configuration.BuildTimeout; using var timeoutCts = new CancellationTokenSource(timeoutMs); using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); try { await process.WaitForExitAsync(combinedCts.Token); } catch (OperationCanceledException) when (timeoutCts.Token.IsCancellationRequested) { _logger.LogWarning("Build timed out after {TimeoutMs}ms, killing process", timeoutMs); process.Kill(); throw new TimeoutException($"Build timed out after {timeoutMs}ms"); } catch (OperationCanceledException) { _logger.LogInformation("Build cancelled by user, killing process"); process.Kill(); throw; } return (process.ExitCode, output.ToString()); } private void ParseBuildOutput(string output, List<BuildMessage> errors, List<BuildMessage> warnings) { if (string.IsNullOrEmpty(output)) return; var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); // Regex patterns for dotnet build error and warning messages var errorPattern = new Regex(@"^(.+?)\((\d+),(\d+)\):\s+error\s+([A-Z]+\d+):\s+(.+)$", RegexOptions.Multiline); var warningPattern = new Regex(@"^(.+?)\((\d+),(\d+)\):\s+warning\s+([A-Z]+\d+):\s+(.+)$", RegexOptions.Multiline); var generalErrorPattern = new Regex(@"^(.+?):\s+error\s+([A-Z]+\d+):\s+(.+)$", RegexOptions.Multiline); foreach (var line in lines) { var trimmedLine = line.Trim(); // Try specific error pattern first (file with line/column) var errorMatch = errorPattern.Match(trimmedLine); if (errorMatch.Success) { errors.Add(new BuildMessage { File = errorMatch.Groups[1].Value, Line = int.TryParse(errorMatch.Groups[2].Value, out var errorLine) ? errorLine : 0, Column = int.TryParse(errorMatch.Groups[3].Value, out var errorCol) ? errorCol : 0, Code = errorMatch.Groups[4].Value, Message = errorMatch.Groups[5].Value }); continue; } // Try warning pattern var warningMatch = warningPattern.Match(trimmedLine); if (warningMatch.Success) { warnings.Add(new BuildMessage { File = warningMatch.Groups[1].Value, Line = int.TryParse(warningMatch.Groups[2].Value, out var warningLine) ? warningLine : 0, Column = int.TryParse(warningMatch.Groups[3].Value, out var warningCol) ? warningCol : 0, Code = warningMatch.Groups[4].Value, Message = warningMatch.Groups[5].Value }); continue; } // Try general error pattern (no line/column) var generalErrorMatch = generalErrorPattern.Match(trimmedLine); if (generalErrorMatch.Success) { errors.Add(new BuildMessage { File = generalErrorMatch.Groups[1].Value, Code = generalErrorMatch.Groups[2].Value, Message = generalErrorMatch.Groups[3].Value }); } } } private string TruncateOutput(string output, bool isFailed) { const int maxChars = 15000; // Conservative limit to stay under 25k tokens if (string.IsNullOrEmpty(output) || output.Length <= maxChars) { return output; } if (isFailed) { // For failed builds, prioritize the end of the output (where errors typically appear) var lines = output.Split('\n'); var importantLines = new List<string>(); var currentLength = 0; // Add summary line if present for (int i = 0; i < Math.Min(10, lines.Length); i++) { if (lines[i].Contains("Build FAILED") || lines[i].Contains("error") || lines[i].Contains("Error")) { importantLines.Add(lines[i]); currentLength += lines[i].Length + 1; break; } } // Add errors from the end for (int i = lines.Length - 1; i >= 0 && currentLength < maxChars - 100; i--) { var line = lines[i]; if (currentLength + line.Length + 1 > maxChars - 100) break; if (line.Contains("error") || line.Contains("Error") || line.Contains("warning") || line.Contains("Warning") || line.Contains("Build FAILED") || line.Contains("Time Elapsed")) { importantLines.Insert(importantLines.Count == 0 ? 0 : 1, line); currentLength += line.Length + 1; } } var result = string.Join("\n", importantLines); if (result.Length < maxChars - 200) { // Add some context from the end var remaining = maxChars - result.Length - 100; var endPortion = output.Substring(Math.Max(0, output.Length - remaining)); result += "\n...\n" + endPortion; } return $"[Output truncated - showing errors and summary]\n{result}"; } else { // For successful builds, show beginning and end var halfMax = maxChars / 2 - 50; var start = output.Substring(0, Math.Min(halfMax, output.Length)); var end = output.Length > halfMax ? output.Substring(output.Length - halfMax) : ""; return start + "\n\n[... middle portion truncated ...]\n\n" + end; } } } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Executors/VSTestExecutor.cs: -------------------------------------------------------------------------------- ```csharp using System.Text; using DotNetFrameworkMCP.Server.Configuration; using DotNetFrameworkMCP.Server.Models; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace DotNetFrameworkMCP.Server.Executors; /// <summary> /// VSTest.Console.exe-based test executor /// </summary> public class VSTestExecutor : BaseTestExecutor { public VSTestExecutor( ILogger<VSTestExecutor> logger, IOptions<McpServerConfiguration> configuration) : base(logger, configuration) { } public override async Task<TestResult> ExecuteTestsAsync( string projectPath, string? filter, bool verbose, CancellationToken cancellationToken = default) { var stopwatch = System.Diagnostics.Stopwatch.StartNew(); try { // Validate project path if (!File.Exists(projectPath)) { throw new FileNotFoundException($"Test project file not found: {projectPath}"); } var vstestPath = FindVSTestConsoleExecutable(); if (string.IsNullOrEmpty(vstestPath)) { throw new InvalidOperationException("VSTest.Console.exe not found. Please install Visual Studio or Build Tools."); } // Find test assembly var testAssembly = FindTestAssembly(projectPath); if (string.IsNullOrEmpty(testAssembly)) { return new TestResult { TotalTests = 0, PassedTests = 0, FailedTests = 0, SkippedTests = 0, Duration = 0, TestDetails = new List<TestDetail> { new TestDetail { Name = "Assembly Not Found", ClassName = "VSTest", Result = "Failed", Duration = 0, ErrorMessage = $"No test assembly found for project {Path.GetFileNameWithoutExtension(projectPath)}", StackTrace = null } }, Output = $"No test assemblies found for project {projectPath} in bin directories" }; } _logger.LogInformation("Running tests from assembly: {TestAssembly}", testAssembly); var args = new StringBuilder($"\"{testAssembly}\""); if (!string.IsNullOrEmpty(filter)) { args.Append($" /TestCaseFilter:\"{filter}\""); } // Always use detailed console output to capture error messages args.Append(" /logger:console;verbosity=detailed"); // Try to find test adapters for better framework support var testAdapterPath = FindTestAdapterPath(projectPath); if (!string.IsNullOrEmpty(testAdapterPath)) { args.Append($" /TestAdapterPath:\"{testAdapterPath}\""); _logger.LogDebug("Using test adapter path: {TestAdapterPath}", testAdapterPath); } // Add TRX logger for structured output var trxFileName = $"TestResults_{Guid.NewGuid():N}.trx"; var trxFilePath = Path.Combine(Path.GetTempPath(), trxFileName); args.Append($" /logger:trx;LogFileName=\"{trxFilePath}\""); var result = await RunProcessAsync(vstestPath, args.ToString(), cancellationToken); // Parse results from TRX file var testResult = await ParseTrxFileAsync(trxFilePath, result.Output); // Clean up TRX file try { if (File.Exists(trxFilePath)) File.Delete(trxFilePath); } catch (Exception ex) { _logger.LogDebug("Failed to delete TRX file: {Error}", ex.Message); } stopwatch.Stop(); testResult.Duration = stopwatch.Elapsed.TotalSeconds; return testResult; } catch (Exception ex) { _logger.LogError(ex, "Error running VSTest for project: {ProjectPath}", projectPath); stopwatch.Stop(); return new TestResult { TotalTests = 0, PassedTests = 0, FailedTests = 0, SkippedTests = 0, Duration = stopwatch.Elapsed.TotalSeconds, TestDetails = new List<TestDetail> { new TestDetail { Name = "Test Execution Error", ClassName = "VSTest", Result = "Failed", Duration = 0, ErrorMessage = ex.Message, StackTrace = ex.StackTrace } }, Output = $"VSTest execution failed: {ex.Message}\n{ex.StackTrace}" }; } } private string? FindVSTestConsoleExecutable() { // Check environment variable first var envPath = Environment.GetEnvironmentVariable("VSTEST_CONSOLE_PATH"); if (!string.IsNullOrEmpty(envPath) && File.Exists(envPath)) { return envPath; } // Get preferred VS version from configuration var preferredVersion = _configuration.PreferredVSVersion?.ToLower() ?? "2022"; var possiblePaths = new List<string>(); // Add paths based on preferred version first if (preferredVersion == "2022" || preferredVersion == "auto") { possiblePaths.AddRange(new[] { @"C:\Program Files (x86)\Microsoft Visual Studio\2022\Professional\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", @"C:\Program Files (x86)\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", @"C:\Program Files (x86)\Microsoft Visual Studio\2022\Enterprise\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", @"C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", @"C:\Program Files\Microsoft Visual Studio\2022\Professional\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", @"C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", @"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", @"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe" }); } if (preferredVersion == "2019" || preferredVersion == "auto") { possiblePaths.AddRange(new[] { @"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", @"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", @"C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", @"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe" }); } var foundPath = possiblePaths.FirstOrDefault(File.Exists); if (foundPath != null) { var version = foundPath.Contains("2022") ? "2022" : foundPath.Contains("2019") ? "2019" : "Unknown"; _logger.LogInformation("Found VSTest.Console.exe version {Version} at: {Path}", version, foundPath); } else { _logger.LogWarning("VSTest.Console.exe not found in standard locations"); } return foundPath; } private string? FindTestAssembly(string projectPath) { var projectDir = Path.GetDirectoryName(projectPath); var projectName = Path.GetFileNameWithoutExtension(projectPath); // Look for test assemblies in bin directories var possiblePaths = new[] { Path.Combine(projectDir!, "bin", "Debug", $"{projectName}.dll"), Path.Combine(projectDir!, "bin", "Release", $"{projectName}.dll"), Path.Combine(projectDir!, "bin", "Debug", "net48", $"{projectName}.dll"), Path.Combine(projectDir!, "bin", "Release", "net48", $"{projectName}.dll"), Path.Combine(projectDir!, "bin", "Debug", "net472", $"{projectName}.dll"), Path.Combine(projectDir!, "bin", "Release", "net472", $"{projectName}.dll") }; var testAssembly = possiblePaths.FirstOrDefault(File.Exists); if (string.IsNullOrEmpty(testAssembly)) { // Fallback: search recursively var assemblyFiles = Directory.GetFiles(Path.Combine(projectDir!, "bin"), "*.dll", SearchOption.AllDirectories) .Where(f => Path.GetFileName(f).Equals($"{projectName}.dll", StringComparison.OrdinalIgnoreCase)) .ToList(); testAssembly = assemblyFiles.FirstOrDefault(); } return testAssembly; } private string? FindTestAdapterPath(string projectPath) { try { var projectDir = Path.GetDirectoryName(projectPath); if (string.IsNullOrEmpty(projectDir)) return null; // Look for test adapters in packages folder (packages.config style) var currentDir = new DirectoryInfo(projectDir); while (currentDir != null) { var packagesDir = Path.Combine(currentDir.FullName, "packages"); if (Directory.Exists(packagesDir)) { _logger.LogDebug("Searching for test adapters in packages directory: {PackagesDir}", packagesDir); // Look for NUnit test adapter (prioritize NUnit3TestAdapter) var nunitAdapterPatterns = new[] { "NUnit3TestAdapter.*", "NUnitTestAdapter.*" }; foreach (var pattern in nunitAdapterPatterns) { var nunitAdapterDirs = Directory.GetDirectories(packagesDir, pattern, SearchOption.TopDirectoryOnly); foreach (var adapterDir in nunitAdapterDirs) { var possiblePaths = new[] { Path.Combine(adapterDir, "build"), Path.Combine(adapterDir, "build", "net35"), Path.Combine(adapterDir, "build", "net40"), adapterDir }; foreach (var path in possiblePaths) { if (Directory.Exists(path)) { var adapterDlls = Directory.GetFiles(path, "*TestAdapter*.dll", SearchOption.AllDirectories); if (adapterDlls.Length > 0) { _logger.LogInformation("Found NUnit test adapter at: {Path}", path); return path; } } } } } // Look for xUnit test adapter as fallback var xunitAdapterDirs = Directory.GetDirectories(packagesDir, "xunit.runner.visualstudio.*", SearchOption.TopDirectoryOnly); foreach (var adapterDir in xunitAdapterDirs) { var buildPath = Path.Combine(adapterDir, "build"); if (Directory.Exists(buildPath)) { _logger.LogInformation("Found xUnit test adapter at: {Path}", buildPath); return buildPath; } } break; // Only check the first packages directory found } currentDir = currentDir.Parent; } return null; } catch (Exception ex) { _logger.LogWarning(ex, "Error searching for test adapter path for project: {ProjectPath}", projectPath); return null; } } } ``` -------------------------------------------------------------------------------- /src/DotNetFrameworkMCP.Server/Executors/MSBuildExecutor.cs: -------------------------------------------------------------------------------- ```csharp using System.Diagnostics; using System.Text; using System.Text.RegularExpressions; using DotNetFrameworkMCP.Server.Configuration; using DotNetFrameworkMCP.Server.Models; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace DotNetFrameworkMCP.Server.Executors; /// <summary> /// MSBuild-based build executor /// </summary> public class MSBuildExecutor : IBuildExecutor { private readonly ILogger<MSBuildExecutor> _logger; private readonly McpServerConfiguration _configuration; public MSBuildExecutor( ILogger<MSBuildExecutor> logger, IOptions<McpServerConfiguration> configuration) { _logger = logger; _configuration = configuration.Value; } public async Task<BuildResult> ExecuteBuildAsync( string projectPath, string configuration, string platform, bool restore, CancellationToken cancellationToken = default) { var stopwatch = Stopwatch.StartNew(); var errors = new List<BuildMessage>(); var warnings = new List<BuildMessage>(); try { // Validate project path if (!File.Exists(projectPath)) { throw new FileNotFoundException($"Project file not found: {projectPath}"); } // Find MSBuild.exe var msbuildPath = FindMSBuildExecutable(); if (string.IsNullOrEmpty(msbuildPath)) { _logger.LogError("Could not find MSBuild.exe in any standard locations"); throw new InvalidOperationException("Could not find MSBuild.exe. Please install Visual Studio or Build Tools for Visual Studio, or set MSBUILD_EXE_PATH environment variable."); } _logger.LogInformation("Using MSBuild.exe: {MSBuildPath}", msbuildPath); // Build the project using MSBuild.exe process var result = await RunMSBuildAsync(msbuildPath, projectPath, configuration, platform, restore, cancellationToken); // Parse the output for errors and warnings ParseBuildOutput(result.Output, errors, warnings); stopwatch.Stop(); return new BuildResult { Success = result.ExitCode == 0, Errors = errors, Warnings = warnings, BuildTime = stopwatch.Elapsed.TotalSeconds, Output = TruncateOutput(result.Output, result.ExitCode != 0) }; } catch (Exception ex) { _logger.LogError(ex, "Build failed for project: {ProjectPath}", projectPath); stopwatch.Stop(); return new BuildResult { Success = false, Errors = new List<BuildMessage> { new BuildMessage { Message = ex.Message, File = projectPath } }, Warnings = warnings, BuildTime = stopwatch.Elapsed.TotalSeconds, Output = TruncateOutput($"Build failed with exception: {ex.Message}\n{ex.StackTrace}", true) }; } } private string? FindMSBuildExecutable() { // Check environment variable first var envPath = Environment.GetEnvironmentVariable("MSBUILD_EXE_PATH"); if (!string.IsNullOrEmpty(envPath) && File.Exists(envPath)) { return envPath; } // Get preferred VS version from configuration var preferredVersion = _configuration.PreferredVSVersion?.ToLower() ?? "2022"; // Look for MSBuild.exe in standard Visual Studio locations var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); var possiblePaths = new List<string>(); // Add paths based on preferred version first if (preferredVersion == "2022" || preferredVersion == "auto") { possiblePaths.AddRange(new[] { Path.Combine(programFilesX86, @"Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"Microsoft Visual Studio\2022\Preview\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFiles, @"Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFiles, @"Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFiles, @"Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFiles, @"Microsoft Visual Studio\2022\Preview\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFiles, @"Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe") }); } if (preferredVersion == "2019" || preferredVersion == "auto") { possiblePaths.AddRange(new[] { Path.Combine(programFilesX86, @"Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe") }); } // If not auto mode and preferred version is not 2022 or 2019, add all versions as fallback if (preferredVersion != "auto" && preferredVersion != "2022" && preferredVersion != "2019") { _logger.LogWarning("Unknown PreferredVSVersion '{Version}', falling back to auto detection", preferredVersion); possiblePaths.AddRange(new[] { Path.Combine(programFilesX86, @"Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"Microsoft Visual Studio\2022\Preview\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFiles, @"Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFiles, @"Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFiles, @"Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFiles, @"Microsoft Visual Studio\2022\Preview\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFiles, @"Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"Microsoft Visual Studio\2019\Enterprise\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"Microsoft Visual Studio\2019\Community\MSBuild\Current\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\MSBuild.exe") }); } // Add legacy paths as final fallback possiblePaths.AddRange(new[] { Path.Combine(programFilesX86, @"MSBuild\14.0\Bin\MSBuild.exe"), Path.Combine(programFilesX86, @"MSBuild\15.0\Bin\MSBuild.exe"), @"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe", @"C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe" }); var foundPath = possiblePaths.FirstOrDefault(File.Exists); if (foundPath != null) { var version = foundPath.Contains("2022") ? "2022" : foundPath.Contains("2019") ? "2019" : "Legacy"; _logger.LogInformation("Found MSBuild.exe version {Version} at: {Path}", version, foundPath); } return foundPath; } private async Task<(int ExitCode, string Output)> RunMSBuildAsync( string msbuildPath, string projectPath, string configuration, string platform, bool restore, CancellationToken cancellationToken) { var arguments = new List<string> { $"\"{projectPath}\"", $"/p:Configuration={configuration}", $"/p:Platform=\"{platform}\"", "/v:normal", // Normal verbosity "/nologo" }; if (restore) { arguments.Add("/restore"); } var argumentString = string.Join(" ", arguments); _logger.LogDebug("Running: {MSBuildPath} {Arguments}", msbuildPath, argumentString); var psi = new ProcessStartInfo { FileName = msbuildPath, Arguments = argumentString, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, WorkingDirectory = Path.GetDirectoryName(projectPath) ?? Environment.CurrentDirectory }; var output = new StringBuilder(); using var process = new Process { StartInfo = psi }; process.OutputDataReceived += (sender, e) => { if (e.Data != null) { output.AppendLine(e.Data); } }; process.ErrorDataReceived += (sender, e) => { if (e.Data != null) { output.AppendLine(e.Data); } }; process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); // Wait for completion with timeout and cancellation support var timeoutMs = _configuration.BuildTimeout; using var timeoutCts = new CancellationTokenSource(timeoutMs); using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); try { await process.WaitForExitAsync(combinedCts.Token); } catch (OperationCanceledException) when (timeoutCts.Token.IsCancellationRequested) { _logger.LogWarning("Build timed out after {TimeoutMs}ms, killing process", timeoutMs); process.Kill(); throw new TimeoutException($"Build timed out after {timeoutMs}ms"); } catch (OperationCanceledException) { _logger.LogInformation("Build cancelled by user, killing process"); process.Kill(); throw; } return (process.ExitCode, output.ToString()); } private void ParseBuildOutput(string output, List<BuildMessage> errors, List<BuildMessage> warnings) { if (string.IsNullOrEmpty(output)) return; var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); // Regex patterns for MSBuild error and warning messages var errorPattern = new Regex(@"^(.+?)\((\d+),(\d+)\):\s+error\s+([A-Z]+\d+):\s+(.+)$", RegexOptions.Multiline); var warningPattern = new Regex(@"^(.+?)\((\d+),(\d+)\):\s+warning\s+([A-Z]+\d+):\s+(.+)$", RegexOptions.Multiline); var generalErrorPattern = new Regex(@"^(.+?):\s+error\s+([A-Z]+\d+):\s+(.+)$", RegexOptions.Multiline); foreach (var line in lines) { var trimmedLine = line.Trim(); // Try specific error pattern first (file with line/column) var errorMatch = errorPattern.Match(trimmedLine); if (errorMatch.Success) { errors.Add(new BuildMessage { File = errorMatch.Groups[1].Value, Line = int.TryParse(errorMatch.Groups[2].Value, out var errorLine) ? errorLine : 0, Column = int.TryParse(errorMatch.Groups[3].Value, out var errorCol) ? errorCol : 0, Code = errorMatch.Groups[4].Value, Message = errorMatch.Groups[5].Value }); continue; } // Try warning pattern var warningMatch = warningPattern.Match(trimmedLine); if (warningMatch.Success) { warnings.Add(new BuildMessage { File = warningMatch.Groups[1].Value, Line = int.TryParse(warningMatch.Groups[2].Value, out var warningLine) ? warningLine : 0, Column = int.TryParse(warningMatch.Groups[3].Value, out var warningCol) ? warningCol : 0, Code = warningMatch.Groups[4].Value, Message = warningMatch.Groups[5].Value }); continue; } // Try general error pattern (no line/column) var generalErrorMatch = generalErrorPattern.Match(trimmedLine); if (generalErrorMatch.Success) { errors.Add(new BuildMessage { File = generalErrorMatch.Groups[1].Value, Code = generalErrorMatch.Groups[2].Value, Message = generalErrorMatch.Groups[3].Value }); } } } private string TruncateOutput(string output, bool isFailed) { const int maxChars = 15000; // Conservative limit to stay under 25k tokens if (string.IsNullOrEmpty(output) || output.Length <= maxChars) { return output; } if (isFailed) { // For failed builds, prioritize the end of the output (where errors typically appear) var lines = output.Split('\n'); var importantLines = new List<string>(); var currentLength = 0; // Add summary line if present for (int i = 0; i < Math.Min(10, lines.Length); i++) { if (lines[i].Contains("Build FAILED") || lines[i].Contains("error") || lines[i].Contains("Error")) { importantLines.Add(lines[i]); currentLength += lines[i].Length + 1; break; } } // Add errors from the end for (int i = lines.Length - 1; i >= 0 && currentLength < maxChars - 100; i--) { var line = lines[i]; if (currentLength + line.Length + 1 > maxChars - 100) break; if (line.Contains("error") || line.Contains("Error") || line.Contains("warning") || line.Contains("Warning") || line.Contains("Build FAILED") || line.Contains("Time Elapsed")) { importantLines.Insert(importantLines.Count == 0 ? 0 : 1, line); currentLength += line.Length + 1; } } var result = string.Join("\n", importantLines); if (result.Length < maxChars - 200) { // Add some context from the end var remaining = maxChars - result.Length - 100; var endPortion = output.Substring(Math.Max(0, output.Length - remaining)); result += "\n...\n" + endPortion; } return $"[Output truncated - showing errors and summary]\n{result}"; } else { // For successful builds, show beginning and end var halfMax = maxChars / 2 - 50; var start = output.Substring(0, Math.Min(halfMax, output.Length)); var end = output.Length > halfMax ? output.Substring(output.Length - halfMax) : ""; return start + "\n\n[... middle portion truncated ...]\n\n" + end; } } } ```