Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ dotnet build src/CodingWithCalvin.VSMCP/CodingWithCalvin.VSMCP.csproj
- `Services/ServerProcessManager.cs` - Server process lifecycle management
- `Services/TestExplorerInterop.cs` - Reflection bridge to Test Explorer (see the file's remarks for why it cannot be a compile-time reference)
- `Services/TerminalInterop.cs` - Reflection bridge to the integrated terminal, over the brokered service container
- `Services/CoverageInterop.cs` - Reflection bridge to the code coverage file reader
- `Server/Tools/*.cs` - MCP tool definitions

## MCP Tools Available
Expand Down Expand Up @@ -177,6 +178,14 @@ dotnet build src/CodingWithCalvin.VSMCP/CodingWithCalvin.VSMCP.csproj

Terminal output is not captured; the VS terminal is a raw PTY with no exit code.

### Coverage Tools
- `coverage_analyze` - Run all tests with coverage collection
- `coverage_report` - Module/class/method tree with line and block counts
- `coverage_show` - Open the Code Coverage Results window

Running coverage is edition-gated (Enterprise through VS 2022, all editions from VS 2026);
reading a .coverage file works everywhere.

## Technology Stack

- .NET Framework 4.8 (VSIX)
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,18 @@
| `test_stats` | Get passed, failed, skipped, and not-run counts |
| `test_status` | Get the run state plus current counts |

### 📊 Coverage Tools

| Tool | Description |
|------|-------------|
| `coverage_analyze` | Run all tests with code coverage collection |
| `coverage_report` | Read results as a module / class / method tree with line and block counts |
| `coverage_show` | Open the Code Coverage Results window |

> ℹ️ **Running** coverage needs an edition that supports it — Enterprise only through
> VS 2022, all editions from VS 2026. **Reading** an existing `.coverage` file with
> `coverage_report` works on every edition.

### 💻 Terminal Tools

| Tool | Description |
Expand Down
3 changes: 2 additions & 1 deletion src/CodingWithCalvin.MCPServer.Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ static async Task RunServerAsync(string pipeName, string host, int port, string
.WithTools<DiagnosticsTools>()
.WithTools<WindowTools>()
.WithTools<TestTools>()
.WithTools<TerminalTools>();
.WithTools<TerminalTools>()
.WithTools<CoverageTools>();

var app = builder.Build();

Expand Down
7 changes: 6 additions & 1 deletion src/CodingWithCalvin.MCPServer.Server/RpcClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public Task<List<ToolInfo>> GetAvailableToolsAsync()
}

var tools = new List<ToolInfo>();
var toolTypes = new[] { typeof(Tools.SolutionTools), typeof(Tools.DocumentTools), typeof(Tools.BuildTools), typeof(Tools.NavigationTools), typeof(Tools.DebuggerTools), typeof(Tools.DiagnosticsTools), typeof(Tools.WindowTools), typeof(Tools.TestTools), typeof(Tools.TerminalTools) };
var toolTypes = new[] { typeof(Tools.SolutionTools), typeof(Tools.DocumentTools), typeof(Tools.BuildTools), typeof(Tools.NavigationTools), typeof(Tools.DebuggerTools), typeof(Tools.DiagnosticsTools), typeof(Tools.WindowTools), typeof(Tools.TestTools), typeof(Tools.TerminalTools), typeof(Tools.CoverageTools) };

foreach (var toolType in toolTypes)
{
Expand Down Expand Up @@ -178,6 +178,11 @@ public Task<TestTargetResult> RunTestsInContextAsync(string target, bool debug)
public Task<TestRunStatus> GetTestRunStatusAsync() => Proxy.GetTestRunStatusAsync();
public Task<TestStats> GetTestStatsAsync() => Proxy.GetTestStatsAsync();

public Task<CoverageRunResult> AnalyzeCodeCoverageAsync() => Proxy.AnalyzeCodeCoverageAsync();
public Task<bool> ShowCoverageResultsAsync() => Proxy.ShowCoverageResultsAsync();
public Task<CoverageReportResult> GetCoverageReportAsync(string? coverageFile, string? detail, string? filter)
=> Proxy.GetCoverageReportAsync(coverageFile, detail, filter);

public Task<TerminalResult> CreateTerminalAsync(string? name, string? workingDirectory, string? command)
=> Proxy.CreateTerminalAsync(name, workingDirectory, command);
public Task<TerminalListResult> GetTerminalsAsync() => Proxy.GetTerminalsAsync();
Expand Down
62 changes: 62 additions & 0 deletions src/CodingWithCalvin.MCPServer.Server/Tools/CoverageTools.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.ComponentModel;
using System.Text.Json;
using System.Threading.Tasks;
using ModelContextProtocol.Server;

namespace CodingWithCalvin.MCPServer.Server.Tools;

[McpServerToolType]
public class CoverageTools
{
private readonly RpcClient _rpcClient;
private readonly JsonSerializerOptions _jsonOptions;

public CoverageTools(RpcClient rpcClient)
{
_rpcClient = rpcClient;
_jsonOptions = new JsonSerializerOptions { WriteIndented = true };
}

[McpServerTool(Name = "coverage_analyze", Destructive = false)]
[Description("Run all tests with code coverage collection enabled. The run starts asynchronously and this returns immediately; poll test_status until it reports 'Completed', then call coverage_report. Requires an edition of Visual Studio with code coverage: Enterprise only through VS 2022, all editions from VS 2026.")]
public async Task<string> AnalyzeCoverageAsync()
{
var result = await _rpcClient.AnalyzeCodeCoverageAsync();

if (!result.Supported)
{
return result.Message ?? "Code coverage is not supported in this edition of Visual Studio.";
}

return result.Started
? "Coverage run started. Poll test_status for completion, then call coverage_report."
: result.Message ?? "Failed to start the coverage run.";
}

[McpServerTool(Name = "coverage_report", ReadOnly = true)]
[Description("Read code coverage results as a module, class and method tree with covered/uncovered line and block counts. Defaults to the newest .coverage file under the solution's TestResults folder. Unlike coverage_analyze, this works on every edition of Visual Studio, so an existing .coverage file produced elsewhere can be read on VS 2022 Community.")]
public async Task<string> GetCoverageReportAsync(
[Description("Detail level: 'summary' for totals per module, 'class' (default) for per-class breakdown, or 'method' for the fully expanded tree.")] string? detail = null,
[Description("Only include modules or classes whose name contains this text, for example 'OrderService'. Case-insensitive.")] string? filter = null,
[Description("Explicit path to a .coverage file. Omit to use the newest one under the solution's TestResults folder.")] string? coverageFile = null)
{
var result = await _rpcClient.GetCoverageReportAsync(coverageFile, detail, filter);

if (!result.Available)
{
return result.Message ?? "No coverage results are available.";
}

return JsonSerializer.Serialize(result, _jsonOptions);
}

[McpServerTool(Name = "coverage_show", Destructive = false, Idempotent = true)]
[Description("Open the Visual Studio Code Coverage Results window so the user can see the results and the editor coverage coloring.")]
public async Task<string> ShowCoverageResultsAsync()
{
var shown = await _rpcClient.ShowCoverageResultsAsync();
return shown
? "Code Coverage Results window shown"
: "Could not open the Code Coverage Results window. This edition of Visual Studio may not support code coverage.";
}
}
87 changes: 87 additions & 0 deletions src/CodingWithCalvin.MCPServer.Shared/Models/CoverageModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System.Collections.Generic;

namespace CodingWithCalvin.MCPServer.Shared.Models;

/// <summary>
/// Covered and uncovered counts for one node of the coverage tree.
/// </summary>
public class CoverageSummary
{
public int LinesCovered { get; set; }
public int LinesPartiallyCovered { get; set; }
public int LinesNotCovered { get; set; }
public int BlocksCovered { get; set; }
public int BlocksNotCovered { get; set; }

/// <summary>
/// Percentage of lines fully covered. Partially covered lines count against the total, which
/// matches how Visual Studio reports line coverage.
/// </summary>
public double LineCoveragePercent { get; set; }

public double BlockCoveragePercent { get; set; }
}

public class CoverageMethod
{
public string Name { get; set; } = string.Empty;
public CoverageSummary Summary { get; set; } = new();
}

public class CoverageClass
{
public string Name { get; set; } = string.Empty;
public string Namespace { get; set; } = string.Empty;
public CoverageSummary Summary { get; set; } = new();

/// <summary>Populated only when method-level detail is requested.</summary>
public List<CoverageMethod> Methods { get; set; } = new();
}

public class CoverageModule
{
public string Name { get; set; } = string.Empty;
public string? Path { get; set; }
public string? TargetFramework { get; set; }
public CoverageSummary Summary { get; set; } = new();

/// <summary>Populated only when class-level or method-level detail is requested.</summary>
public List<CoverageClass> Classes { get; set; } = new();
}

/// <summary>
/// A parsed code coverage result set.
/// </summary>
public class CoverageReportResult
{
/// <summary>
/// False when no coverage data could be read. Distinct from a report whose counts are all
/// zero, which would mean coverage ran but exercised nothing.
/// </summary>
public bool Available { get; set; }

/// <summary>Path of the .coverage file the report was read from.</summary>
public string? CoverageFile { get; set; }

public CoverageSummary Summary { get; set; } = new();

public List<CoverageModule> Modules { get; set; } = new();

public string? Message { get; set; }
}

/// <summary>
/// Outcome of asking Visual Studio to start a coverage run.
/// </summary>
public class CoverageRunResult
{
public bool Started { get; set; }

/// <summary>
/// False when this edition of Visual Studio has no code coverage support at all, as opposed
/// to the command being momentarily disabled.
/// </summary>
public bool Supported { get; set; }

public string? Message { get; set; }
}
5 changes: 5 additions & 0 deletions src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ public interface IVisualStudioRpc
Task<bool> CloseTerminalAsync(string terminalId);
Task<bool> CloseAllTerminalsAsync();

// Code coverage tools
Task<CoverageRunResult> AnalyzeCodeCoverageAsync();
Task<bool> ShowCoverageResultsAsync();
Task<CoverageReportResult> GetCoverageReportAsync(string? coverageFile, string? detail, string? filter);

// Window management tools
Task<List<WindowInfo>> GetWindowsAsync();
Task<bool> ActivateWindowAsync(string caption);
Expand Down
34 changes: 34 additions & 0 deletions src/CodingWithCalvin.MCPServer.Tests/CoverageFileUtilityStandIn.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Microsoft.CodeCoverage.IO.Coverage;

// Declared separately from the data types because it sits in the parent namespace, matching the
// shipped assembly where CoverageFileUtility is in Microsoft.CodeCoverage.IO and the data types
// are in Microsoft.CodeCoverage.IO.Coverage.
namespace Microsoft.CodeCoverage.IO;

/// <summary>
/// Stand-in for the real reader. Mirrors the V1 shape the production code targets: a
/// parameterless constructor and a ReadCoverageFile(string) overload.
/// </summary>
public class CoverageFileUtility
{
/// <summary>
/// Data the next read returns. Static because CoverageInterop constructs the utility itself
/// via Activator, so a test has no other way to seed it. Tests within a class run
/// sequentially, so this is safe here.
/// </summary>
public static CoverageData? Fixture { get; set; }

public static string? LastPathRead { get; private set; }

public CoverageData ReadCoverageFile(string path)
{
LastPathRead = path;
return Fixture ?? new CoverageData();
}

// Present so the overload selection in CoverageInterop is exercised against a real ambiguity,
// as it is on the shipped type.
public CoverageData ReadCoverageFile(string path, bool readModules, bool readSkippedMessages)
=> throw new System.InvalidOperationException(
"The single-path ReadCoverageFile overload should be selected.");
}
Loading
Loading