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
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ dotnet build src/CodingWithCalvin.VSMCP/CodingWithCalvin.VSMCP.csproj
- `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)
- `Services/TerminalInterop.cs` - Reflection bridge to the integrated terminal, over the brokered service container
- `Server/Tools/*.cs` - MCP tool definitions

## MCP Tools Available
Expand Down Expand Up @@ -166,6 +167,16 @@ dotnet build src/CodingWithCalvin.VSMCP/CodingWithCalvin.VSMCP.csproj
- `test_status` - Get run state plus current counts
- `test_stats` - Get passed/failed/skipped/not-run counts

### Terminal Tools
- `terminal_run` - Run a command in a new VS terminal (developer environment)
- `terminal_create` - Open an empty terminal using the default profile
- `terminal_list` - List open terminal identifiers
- `terminal_show` - Bring a terminal into view
- `terminal_close` - Close a single terminal
- `terminal_close_all` - Close every terminal

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

## Technology Stack

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

### 💻 Terminal Tools

| Tool | Description |
|------|-------------|
| `terminal_close` | Close a single integrated terminal |
| `terminal_close_all` | Close every integrated terminal |
| `terminal_create` | Open an empty terminal using the default profile |
| `terminal_list` | List the open terminal identifiers |
| `terminal_run` | Run a command in a new terminal, inside the VS developer environment |
| `terminal_show` | Bring a terminal into view |

> ⚠️ **Terminal output is not captured.** The Visual Studio terminal is a raw PTY with no
> exit code or command boundaries, so `terminal_run` reports only whether the terminal opened.
> To read results, redirect output to a file and read it back with `document_read`.

### 🪟 Window Tools

| Tool | Description |
Expand Down Expand Up @@ -202,6 +217,10 @@ Configure the extension at **Tools > Options > MCP Server**:
| Log Level | Minimum log level for output | `Information` |
| Log Retention | Days to keep log files | `7` |

> ⚠️ **`terminal_run` executes commands on your machine.** Combined with a **Binding
> Address** other than `localhost`, this exposes command execution to your network. Leave the
> binding address at `localhost` unless you specifically need remote access.

## 🏗️ Architecture

```
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 @@ -102,7 +102,8 @@ static async Task RunServerAsync(string pipeName, string host, int port, string
.WithTools<DebuggerTools>()
.WithTools<DiagnosticsTools>()
.WithTools<WindowTools>()
.WithTools<TestTools>();
.WithTools<TestTools>()
.WithTools<TerminalTools>();

var app = builder.Build();

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

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

public Task<TerminalResult> CreateTerminalAsync(string? name, string? workingDirectory, string? command)
=> Proxy.CreateTerminalAsync(name, workingDirectory, command);
public Task<TerminalListResult> GetTerminalsAsync() => Proxy.GetTerminalsAsync();
public Task<bool> ShowTerminalAsync(string terminalId) => Proxy.ShowTerminalAsync(terminalId);
public Task<bool> CloseTerminalAsync(string terminalId) => Proxy.CloseTerminalAsync(terminalId);
public Task<bool> CloseAllTerminalsAsync() => Proxy.CloseAllTerminalsAsync();

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
85 changes: 85 additions & 0 deletions src/CodingWithCalvin.MCPServer.Server/Tools/TerminalTools.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System.ComponentModel;
using System.Text.Json;
using System.Threading.Tasks;
using ModelContextProtocol.Server;

namespace CodingWithCalvin.MCPServer.Server.Tools;

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

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

[McpServerTool(Name = "terminal_run", Destructive = true)]
[Description("Run a command in a new Visual Studio integrated terminal. The command is launched inside the Visual Studio developer environment, so msbuild, vstest.console and dotnet-coverage are on PATH. IMPORTANT: output is NOT returned - the Visual Studio terminal is a raw PTY with no exit code or command boundaries, so this tool reports only whether the terminal was opened. To read results, redirect the command's output to a file and then read it with document_read.")]
public async Task<string> RunInTerminalAsync(
[Description("Command line to run, for example 'dotnet-coverage collect -f cobertura -o coverage.xml dotnet test'.")] string command,
[Description("Working directory for the command. Defaults to the directory containing the open solution.")] string? workingDirectory = null,
[Description("Caption for the terminal tab. Defaults to a Visual Studio generated name.")] string? name = null)
{
if (string.IsNullOrWhiteSpace(command))
{
return "A command is required.";
}

var result = await _rpcClient.CreateTerminalAsync(name, workingDirectory, command);
return JsonSerializer.Serialize(result, _jsonOptions);
}

[McpServerTool(Name = "terminal_create", Destructive = false)]
[Description("Open a new empty Visual Studio integrated terminal using the user's default terminal profile, without running anything in it. Use terminal_run instead to launch a command.")]
public async Task<string> CreateTerminalAsync(
[Description("Caption for the terminal tab. Defaults to a Visual Studio generated name.")] string? name = null,
[Description("Working directory for the terminal. Defaults to the directory containing the open solution.")] string? workingDirectory = null)
{
var result = await _rpcClient.CreateTerminalAsync(name, workingDirectory, command: null);
return JsonSerializer.Serialize(result, _jsonOptions);
}

[McpServerTool(Name = "terminal_list", ReadOnly = true)]
[Description("List the identifiers of every integrated terminal currently open in Visual Studio. Use these identifiers with terminal_show and terminal_close.")]
public async Task<string> ListTerminalsAsync()
{
var result = await _rpcClient.GetTerminalsAsync();
return JsonSerializer.Serialize(result, _jsonOptions);
}

[McpServerTool(Name = "terminal_show", Destructive = false, Idempotent = true)]
[Description("Bring an integrated terminal into view. Get the identifier from terminal_run, terminal_create or terminal_list.")]
public async Task<string> ShowTerminalAsync(
[Description("Terminal identifier (a GUID) returned by terminal_run, terminal_create or terminal_list.")] string terminalId)
{
var shown = await _rpcClient.ShowTerminalAsync(terminalId);
return shown
? $"Terminal {terminalId} shown"
: $"Could not show terminal {terminalId}. Call terminal_list to see the open terminals.";
}

[McpServerTool(Name = "terminal_close", Destructive = true)]
[Description("Close a single integrated terminal, ending any process running in it. Get the identifier from terminal_list.")]
public async Task<string> CloseTerminalAsync(
[Description("Terminal identifier (a GUID) returned by terminal_run, terminal_create or terminal_list.")] string terminalId)
{
var closed = await _rpcClient.CloseTerminalAsync(terminalId);
return closed
? $"Terminal {terminalId} closed"
: $"Could not close terminal {terminalId}. Call terminal_list to see the open terminals.";
}

[McpServerTool(Name = "terminal_close_all", Destructive = true)]
[Description("Close every integrated terminal, ending any processes running in them.")]
public async Task<string> CloseAllTerminalsAsync()
{
var closed = await _rpcClient.CloseAllTerminalsAsync();
return closed
? "All integrated terminals closed"
: "Could not close the terminals. The Visual Studio terminal component may be unavailable.";
}
}
51 changes: 51 additions & 0 deletions src/CodingWithCalvin.MCPServer.Shared/Models/TerminalModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Collections.Generic;

namespace CodingWithCalvin.MCPServer.Shared.Models;

/// <summary>
/// Outcome of opening a Visual Studio integrated terminal.
/// </summary>
public class TerminalResult
{
public bool Created { get; set; }

/// <summary>
/// Identifier for the new terminal, used by the show and close tools.
/// </summary>
public string? TerminalId { get; set; }

/// <summary>
/// Command the terminal was launched with, when one was supplied.
/// </summary>
public string? Command { get; set; }

public string? WorkingDirectory { get; set; }

/// <summary>
/// True when the command was launched inside the Visual Studio developer environment, so
/// that tooling such as msbuild and dotnet-coverage is on PATH.
/// </summary>
public bool DeveloperEnvironment { get; set; }

/// <summary>
/// Populated when <see cref="Created"/> is false, or to carry a caveat about a terminal
/// that was created successfully.
/// </summary>
public string? Message { get; set; }
}

/// <summary>
/// The integrated terminals currently open in Visual Studio.
/// </summary>
public class TerminalListResult
{
/// <summary>
/// False when the terminal service could not be reached at all, which is distinct from
/// there being no terminals open.
/// </summary>
public bool Available { get; set; }

public List<string> TerminalIds { get; set; } = new();

public string? Message { get; set; }
}
7 changes: 7 additions & 0 deletions src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ public interface IVisualStudioRpc
Task<TestRunStatus> GetTestRunStatusAsync();
Task<TestStats> GetTestStatsAsync();

// Terminal tools
Task<TerminalResult> CreateTerminalAsync(string? name, string? workingDirectory, string? command);
Task<TerminalListResult> GetTerminalsAsync();
Task<bool> ShowTerminalAsync(string terminalId);
Task<bool> CloseTerminalAsync(string terminalId);
Task<bool> CloseAllTerminalsAsync();

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