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
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ dotnet build src/CodingWithCalvin.VSMCP/CodingWithCalvin.VSMCP.csproj
- `Services/VisualStudioService.cs` - VS API wrapper for all VS operations
- `Services/RpcServer.cs` - Named pipe JSON-RPC server
- `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)
- `Server/Tools/*.cs` - MCP tool definitions

## MCP Tools Available
Expand Down Expand Up @@ -156,6 +157,15 @@ dotnet build src/CodingWithCalvin.VSMCP/CodingWithCalvin.VSMCP.csproj
- `build_cancel` - Cancel build
- `build_status` - Get build status

### Test Tools
- `test_run_all` - Run every test in the solution
- `test_debug_all` - Debug every test in the solution
- `test_run` - Run tests in a specific class or method
- `test_debug` - Debug tests in a specific class or method
- `test_cancel` - Cancel the test run in progress
- `test_status` - Get run state plus current counts
- `test_stats` - Get passed/failed/skipped/not-run counts

## 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 @@ -112,6 +112,18 @@
| `output_read` | Read content from an Output window pane |
| `output_write` | Write a message to an Output window pane |

### 🧪 Test Tools

| Tool | Description |
|------|-------------|
| `test_cancel` | Cancel the test run in progress |
| `test_debug` | Debug the tests in a single class or method |
| `test_debug_all` | Debug every test in the solution |
| `test_run` | Run the tests in a single class or method |
| `test_run_all` | Run every test in the solution |
| `test_stats` | Get passed, failed, skipped, and not-run counts |
| `test_status` | Get the run state plus current counts |

### 🪟 Window 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 @@ -101,7 +101,8 @@ static async Task RunServerAsync(string pipeName, string host, int port, string
.WithTools<NavigationTools>()
.WithTools<DebuggerTools>()
.WithTools<DiagnosticsTools>()
.WithTools<WindowTools>();
.WithTools<WindowTools>()
.WithTools<TestTools>();

var app = builder.Build();

Expand Down
10 changes: 9 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) };
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) };

foreach (var toolType in toolTypes)
{
Expand Down Expand Up @@ -170,6 +170,14 @@ public Task<bool> WriteOutputPaneAsync(string paneIdentifier, string message, bo
=> Proxy.WriteOutputPaneAsync(paneIdentifier, message, activate);
public Task<List<OutputPaneInfo>> GetOutputPanesAsync() => Proxy.GetOutputPanesAsync();

public Task<bool> RunAllTestsAsync() => Proxy.RunAllTestsAsync();
public Task<bool> DebugAllTestsAsync() => Proxy.DebugAllTestsAsync();
public Task<TestTargetResult> RunTestsInContextAsync(string target, bool debug)
=> Proxy.RunTestsInContextAsync(target, debug);
public Task<bool> CancelTestRunAsync() => Proxy.CancelTestRunAsync();
public Task<TestRunStatus> GetTestRunStatusAsync() => Proxy.GetTestRunStatusAsync();
public Task<TestStats> GetTestStatsAsync() => Proxy.GetTestStatsAsync();

public Task<List<WindowInfo>> GetWindowsAsync() => Proxy.GetWindowsAsync();
public Task<bool> ActivateWindowAsync(string caption) => Proxy.ActivateWindowAsync(caption);
public Task<bool> ShowToolWindowAsync(string name) => Proxy.ShowToolWindowAsync(name);
Expand Down
93 changes: 93 additions & 0 deletions src/CodingWithCalvin.MCPServer.Server/Tools/TestTools.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.ComponentModel;
using System.Text.Json;
using System.Threading.Tasks;
using ModelContextProtocol.Server;

namespace CodingWithCalvin.MCPServer.Server.Tools;

[McpServerToolType]
public class TestTools
{
private const string StatsUnavailableMessage =
"Test Explorer has not been initialized in this Visual Studio session, so no counts are "
+ "available. Open Test Explorer (window_show with 'TestExplorer') or start a run first. "
+ "Note this is not the same as the solution having no tests.";

private readonly RpcClient _rpcClient;
private readonly JsonSerializerOptions _jsonOptions;

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

[McpServerTool(Name = "test_run_all", Destructive = false)]
[Description("Run every test in the solution via Test Explorer. The run starts asynchronously and this returns immediately; poll test_status to observe completion. Requires a solution with discovered tests.")]
public async Task<string> RunAllTestsAsync()
{
var started = await _rpcClient.RunAllTestsAsync();
return started
? "Test run started. Poll test_status for progress."
: "Failed to start the test run. Test Explorer may still be discovering tests, or the solution has none.";
}

[McpServerTool(Name = "test_debug_all", Destructive = false)]
[Description("Debug every test in the solution via Test Explorer, stopping on any breakpoints that are set. The run starts asynchronously and this returns immediately; poll test_status to observe completion.")]
public async Task<string> DebugAllTestsAsync()
{
var started = await _rpcClient.DebugAllTestsAsync();
return started
? "Test debug run started. Poll test_status for progress."
: "Failed to start the test debug run. Test Explorer may still be discovering tests, or the solution has none.";
}

[McpServerTool(Name = "test_run", Destructive = false)]
[Description("Run the tests in a single test class or test method. Accepts a simple name ('CalculatorTests', 'Add_ReturnsSum') or a fully qualified one ('MyApp.Tests.CalculatorTests.Add_ReturnsSum'); a fully qualified name is matched first and avoids ambiguity. The run starts asynchronously; poll test_status to observe completion.")]
public async Task<string> RunTestAsync(
[Description("Test class or test method name to run. Fully qualified names are preferred when several types share a simple name.")] string target)
{
var result = await _rpcClient.RunTestsInContextAsync(target, debug: false);
return JsonSerializer.Serialize(result, _jsonOptions);
}

[McpServerTool(Name = "test_debug", Destructive = false)]
[Description("Debug the tests in a single test class or test method, stopping on any breakpoints that are set. Accepts a simple or fully qualified name; a fully qualified name is matched first and avoids ambiguity. The run starts asynchronously; poll test_status to observe completion.")]
public async Task<string> DebugTestAsync(
[Description("Test class or test method name to debug. Fully qualified names are preferred when several types share a simple name.")] string target)
{
var result = await _rpcClient.RunTestsInContextAsync(target, debug: true);
return JsonSerializer.Serialize(result, _jsonOptions);
}

[McpServerTool(Name = "test_cancel", Destructive = false, Idempotent = true)]
[Description("Cancel the test run that is currently in progress.")]
public async Task<string> CancelTestRunAsync()
{
var cancelled = await _rpcClient.CancelTestRunAsync();
return cancelled
? "Test run cancelled"
: "No test run is currently in progress";
}

[McpServerTool(Name = "test_status", ReadOnly = true)]
[Description("Get the Test Explorer run state plus current counts. State is one of 'NoRunObserved', 'Discovering', 'Running', 'Canceling', 'Completed', or 'Canceled'. Use this to poll for completion after test_run_all, test_run, or their debug equivalents. State is only tracked for runs started after this extension first contacted Test Explorer.")]
public async Task<string> GetTestStatusAsync()
{
var status = await _rpcClient.GetTestRunStatusAsync();
return JsonSerializer.Serialize(status, _jsonOptions);
}

[McpServerTool(Name = "test_stats", ReadOnly = true)]
[Description("Get the passed, failed, skipped and not-run test counts from Test Explorer. Returns counts only - Visual Studio exposes no public API for the names of the individual failed or not-run tests.")]
public async Task<string> GetTestStatsAsync()
{
var stats = await _rpcClient.GetTestStatsAsync();
if (!stats.Available)
{
return StatsUnavailableMessage;
}

return JsonSerializer.Serialize(stats, _jsonOptions);
}
}
64 changes: 64 additions & 0 deletions src/CodingWithCalvin.MCPServer.Shared/Models/TestModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
namespace CodingWithCalvin.MCPServer.Shared.Models;

/// <summary>
/// Aggregate test counts as reported by the Test Explorer window.
/// </summary>
public class TestStats
{
/// <summary>
/// False when Test Explorer has not been initialized in this Visual Studio session.
/// The counts are meaningless when this is false, and must not be read as "no tests".
/// </summary>
public bool Available { get; set; }

public int Passed { get; set; }
public int Failed { get; set; }
public int Skipped { get; set; }
public int NotRun { get; set; }
}

/// <summary>
/// Test Explorer run state, plus the counts as of the most recent state change.
/// </summary>
public class TestRunStatus
{
/// <summary>
/// One of "NoRunObserved", "Discovering", "Running", "Canceling", "Completed", or "Canceled".
/// </summary>
public string State { get; set; } = string.Empty;

/// <summary>
/// True while discovery or execution is still in flight.
/// </summary>
public bool IsRunning { get; set; }

/// <summary>
/// Raw Test Explorer operation name behind <see cref="State"/>, retained for diagnostics.
/// Null until a state change has been observed.
/// </summary>
public string? LastOperation { get; set; }

public TestStats Stats { get; set; } = new();
}

/// <summary>
/// Outcome of asking Test Explorer to start a run for a specific class or method.
/// </summary>
public class TestTargetResult
{
public bool Started { get; set; }

/// <summary>
/// Fully qualified name of the symbol the caret was placed on, when one was resolved.
/// </summary>
public string? ResolvedTarget { get; set; }

public string? FilePath { get; set; }
public int Line { get; set; }

/// <summary>
/// Populated when <see cref="Started"/> is false, or when the resolved target was
/// ambiguous and the first match was used.
/// </summary>
public string? Message { get; set; }
}
8 changes: 8 additions & 0 deletions src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ public interface IVisualStudioRpc
Task<bool> WriteOutputPaneAsync(string paneIdentifier, string message, bool activate = false);
Task<List<OutputPaneInfo>> GetOutputPanesAsync();

// Test Explorer tools
Task<bool> RunAllTestsAsync();
Task<bool> DebugAllTestsAsync();
Task<TestTargetResult> RunTestsInContextAsync(string target, bool debug);
Task<bool> CancelTestRunAsync();
Task<TestRunStatus> GetTestRunStatusAsync();
Task<TestStats> GetTestStatsAsync();

// Window management tools
Task<List<WindowInfo>> GetWindowsAsync();
Task<bool> ActivateWindowAsync(string caption);
Expand Down
Loading
Loading