diff --git a/CLAUDE.md b/CLAUDE.md index b6dbb20..3192272 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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) diff --git a/README.md b/README.md index 1acf91f..143db86 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 ``` diff --git a/src/CodingWithCalvin.MCPServer.Server/Program.cs b/src/CodingWithCalvin.MCPServer.Server/Program.cs index d410590..360689c 100644 --- a/src/CodingWithCalvin.MCPServer.Server/Program.cs +++ b/src/CodingWithCalvin.MCPServer.Server/Program.cs @@ -102,7 +102,8 @@ static async Task RunServerAsync(string pipeName, string host, int port, string .WithTools() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithTools(); var app = builder.Build(); diff --git a/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs b/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs index 2cfcc61..5fb9d2c 100644 --- a/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs +++ b/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs @@ -65,7 +65,7 @@ public Task> GetAvailableToolsAsync() } var tools = new List(); - 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) { @@ -178,6 +178,13 @@ public Task RunTestsInContextAsync(string target, bool debug) public Task GetTestRunStatusAsync() => Proxy.GetTestRunStatusAsync(); public Task GetTestStatsAsync() => Proxy.GetTestStatsAsync(); + public Task CreateTerminalAsync(string? name, string? workingDirectory, string? command) + => Proxy.CreateTerminalAsync(name, workingDirectory, command); + public Task GetTerminalsAsync() => Proxy.GetTerminalsAsync(); + public Task ShowTerminalAsync(string terminalId) => Proxy.ShowTerminalAsync(terminalId); + public Task CloseTerminalAsync(string terminalId) => Proxy.CloseTerminalAsync(terminalId); + public Task CloseAllTerminalsAsync() => Proxy.CloseAllTerminalsAsync(); + public Task> GetWindowsAsync() => Proxy.GetWindowsAsync(); public Task ActivateWindowAsync(string caption) => Proxy.ActivateWindowAsync(caption); public Task ShowToolWindowAsync(string name) => Proxy.ShowToolWindowAsync(name); diff --git a/src/CodingWithCalvin.MCPServer.Server/Tools/TerminalTools.cs b/src/CodingWithCalvin.MCPServer.Server/Tools/TerminalTools.cs new file mode 100644 index 0000000..bdf5778 --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Server/Tools/TerminalTools.cs @@ -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 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 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 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 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 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 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."; + } +} diff --git a/src/CodingWithCalvin.MCPServer.Shared/Models/TerminalModels.cs b/src/CodingWithCalvin.MCPServer.Shared/Models/TerminalModels.cs new file mode 100644 index 0000000..f8aac83 --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Shared/Models/TerminalModels.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; + +namespace CodingWithCalvin.MCPServer.Shared.Models; + +/// +/// Outcome of opening a Visual Studio integrated terminal. +/// +public class TerminalResult +{ + public bool Created { get; set; } + + /// + /// Identifier for the new terminal, used by the show and close tools. + /// + public string? TerminalId { get; set; } + + /// + /// Command the terminal was launched with, when one was supplied. + /// + public string? Command { get; set; } + + public string? WorkingDirectory { get; set; } + + /// + /// True when the command was launched inside the Visual Studio developer environment, so + /// that tooling such as msbuild and dotnet-coverage is on PATH. + /// + public bool DeveloperEnvironment { get; set; } + + /// + /// Populated when is false, or to carry a caveat about a terminal + /// that was created successfully. + /// + public string? Message { get; set; } +} + +/// +/// The integrated terminals currently open in Visual Studio. +/// +public class TerminalListResult +{ + /// + /// False when the terminal service could not be reached at all, which is distinct from + /// there being no terminals open. + /// + public bool Available { get; set; } + + public List TerminalIds { get; set; } = new(); + + public string? Message { get; set; } +} diff --git a/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs b/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs index 497b678..c553e0f 100644 --- a/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs +++ b/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs @@ -81,6 +81,13 @@ public interface IVisualStudioRpc Task GetTestRunStatusAsync(); Task GetTestStatsAsync(); + // Terminal tools + Task CreateTerminalAsync(string? name, string? workingDirectory, string? command); + Task GetTerminalsAsync(); + Task ShowTerminalAsync(string terminalId); + Task CloseTerminalAsync(string terminalId); + Task CloseAllTerminalsAsync(); + // Window management tools Task> GetWindowsAsync(); Task ActivateWindowAsync(string caption); diff --git a/src/CodingWithCalvin.MCPServer.Tests/TerminalInteropTests.cs b/src/CodingWithCalvin.MCPServer.Tests/TerminalInteropTests.cs new file mode 100644 index 0000000..fbb118b --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Tests/TerminalInteropTests.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using CodingWithCalvin.MCPServer.Services; +using Microsoft.ServiceHub.Framework; +using Microsoft.VisualStudio.Terminal; +using Xunit; + +namespace CodingWithCalvin.MCPServer.Tests; + +/// +/// Covers the reflection bridge to the Visual Studio integrated terminal. +/// +/// +/// +/// Microsoft.VisualStudio.Terminal.dll cannot be referenced at build time, so +/// loads it by strong name and reaches ITerminalService, +/// ProfileConfig and TerminalServiceDescriptors reflectively. These tests supply +/// the test assembly as the resolver and let the whole path run against stand-ins declared in +/// the genuine namespace, exercising type resolution, descriptor retrieval, the generic +/// GetProxyAsync<T> invocation with its ValueTask unwrapping, overload selection +/// and profile construction. +/// +/// +/// All tests live in one class deliberately: several manipulate the VSAPPIDDIR environment +/// variable, which is process-global, and xUnit runs tests within a class sequentially. +/// +/// +public class TerminalInteropTests +{ + private static readonly Func StandInAssembly = + () => typeof(ITerminalService).Assembly; + + [Fact] + public async Task CreateTerminal_WithCommand_LaunchesThroughCommandProcessor() + { + var service = new StubTerminalService(); + var interop = Build(service); + + var outcome = await interop.CreateTerminalAsync( + "MCP", + @"C:\src\app", + "dotnet-coverage collect dotnet test", + CancellationToken.None); + + Assert.True(outcome.Created); + Assert.Equal(service.NextTerminalId, outcome.TerminalId); + + var profile = Assert.IsType(service.LastProfile); + Assert.EndsWith("cmd.exe", profile.Location, StringComparison.OrdinalIgnoreCase); + Assert.StartsWith("/k", profile.Arguments); + Assert.Contains("dotnet-coverage collect dotnet test", profile.Arguments); + Assert.True(profile.CreatePTY); + } + + [Fact] + public async Task CreateTerminal_SelectsOverloadCarryingWorkingDirectory() + { + var service = new StubTerminalService(); + var interop = Build(service); + + await interop.CreateTerminalAsync("MCP", @"C:\src\app", "dir", CancellationToken.None); + + // The three-argument overload would silently drop the working directory. + Assert.Equal(@"C:\src\app", service.LastWorkingDirectory); + Assert.Equal("MCP", service.LastName); + } + + [Fact] + public async Task CreateTerminal_WithoutCommand_LeavesProfileToVisualStudio() + { + var service = new StubTerminalService(); + var interop = Build(service); + + var outcome = await interop.CreateTerminalAsync("MCP", null, null, CancellationToken.None); + + Assert.True(outcome.Created); + Assert.Null(service.LastProfile); + Assert.False(outcome.DeveloperEnvironment); + } + + [Fact] + public async Task CreateTerminal_ChainsDeveloperCommandScript_WhenAvailable() + { + using var vsDir = new FakeVsInstall(); + var service = new StubTerminalService(); + var interop = Build(service); + + var outcome = await interop.CreateTerminalAsync(null, null, "msbuild", CancellationToken.None); + + var profile = Assert.IsType(service.LastProfile); + Assert.True(outcome.DeveloperEnvironment); + Assert.Contains("VsDevCmd.bat", profile.Arguments); + Assert.Contains("&& msbuild", profile.Arguments); + } + + [Fact] + public async Task CreateTerminal_ReportsUnavailable_WhenTerminalAssemblyMissing() + { + var interop = new TerminalInterop(() => new StubServiceBroker(new StubTerminalService()), () => null); + + var outcome = await interop.CreateTerminalAsync(null, null, "dir", CancellationToken.None); + + Assert.False(outcome.Created); + Assert.Equal(TerminalInterop.UnavailableMessage, outcome.Message); + } + + [Fact] + public async Task CreateTerminal_ReportsUnavailable_WhenBrokerMissing() + { + var interop = new TerminalInterop(() => null, StandInAssembly); + + var outcome = await interop.CreateTerminalAsync(null, null, "dir", CancellationToken.None); + + Assert.False(outcome.Created); + Assert.Equal(TerminalInterop.UnavailableMessage, outcome.Message); + } + + [Fact] + public async Task GetTerminalIds_ReturnsOpenTerminals() + { + var service = new StubTerminalService(); + service.OpenTerminals.Add(Guid.NewGuid()); + service.OpenTerminals.Add(Guid.NewGuid()); + + var ids = await Build(service).GetTerminalIdsAsync(CancellationToken.None); + + Assert.NotNull(ids); + Assert.Equal(service.OpenTerminals, ids!); + } + + [Fact] + public async Task GetTerminalIds_ReturnsNull_WhenUnavailable() + { + var interop = new TerminalInterop(() => null, StandInAssembly); + + // Null is distinct from an empty list, which would mean "no terminals are open". + Assert.Null(await interop.GetTerminalIdsAsync(CancellationToken.None)); + } + + [Fact] + public async Task ShowTerminal_ForwardsIdentifier() + { + var service = new StubTerminalService(); + var id = Guid.NewGuid(); + + Assert.True(await Build(service).ShowTerminalAsync(id, CancellationToken.None)); + Assert.Equal(id, service.LastShown); + } + + [Fact] + public async Task CloseTerminal_ForwardsIdentifier() + { + var service = new StubTerminalService(); + var id = Guid.NewGuid(); + + Assert.True(await Build(service).CloseTerminalAsync(id, CancellationToken.None)); + Assert.Equal(id, service.LastClosed); + } + + [Fact] + public async Task CloseAllTerminals_InvokesService() + { + var service = new StubTerminalService(); + + Assert.True(await Build(service).CloseAllTerminalsAsync(CancellationToken.None)); + Assert.Equal(1, service.CloseAllCount); + } + + [Fact] + public void TryFindDeveloperCommandScript_ReturnsNull_WhenIdeDirectoryUnknown() + { + var previous = Environment.GetEnvironmentVariable("VSAPPIDDIR"); + try + { + Environment.SetEnvironmentVariable("VSAPPIDDIR", null); + Assert.Null(TerminalInterop.TryFindDeveloperCommandScript()); + } + finally + { + Environment.SetEnvironmentVariable("VSAPPIDDIR", previous); + } + } + + [Fact] + public void TryFindDeveloperCommandScript_ReturnsNull_WhenScriptAbsent() + { + var previous = Environment.GetEnvironmentVariable("VSAPPIDDIR"); + try + { + Environment.SetEnvironmentVariable("VSAPPIDDIR", Path.GetTempPath()); + Assert.Null(TerminalInterop.TryFindDeveloperCommandScript()); + } + finally + { + Environment.SetEnvironmentVariable("VSAPPIDDIR", previous); + } + } + + [Fact] + public void TryFindDeveloperCommandScript_LocatesScriptAlongsideIdeDirectory() + { + using var vsInstall = new FakeVsInstall(); + + var script = TerminalInterop.TryFindDeveloperCommandScript(); + + Assert.NotNull(script); + Assert.True(File.Exists(script)); + Assert.EndsWith(@"Tools\VsDevCmd.bat", script, StringComparison.OrdinalIgnoreCase); + } + + private static TerminalInterop Build(ITerminalService service) => + new(() => new StubServiceBroker(service), StandInAssembly); + + /// + /// Lays out Common7\IDE and Common7\Tools\VsDevCmd.bat in a temp directory and points + /// VSAPPIDDIR at the IDE folder, mirroring how devenv sets it. + /// + private sealed class FakeVsInstall : IDisposable + { + private readonly string _root; + private readonly string? _previous; + + internal FakeVsInstall() + { + _root = Path.Combine(Path.GetTempPath(), "vsmcp-" + Guid.NewGuid().ToString("N")); + var ide = Path.Combine(_root, "Common7", "IDE"); + var tools = Path.Combine(_root, "Common7", "Tools"); + + Directory.CreateDirectory(ide); + Directory.CreateDirectory(tools); + File.WriteAllText(Path.Combine(tools, "VsDevCmd.bat"), "@echo off"); + + _previous = Environment.GetEnvironmentVariable("VSAPPIDDIR"); + Environment.SetEnvironmentVariable("VSAPPIDDIR", ide); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable("VSAPPIDDIR", _previous); + + try + { + Directory.Delete(_root, recursive: true); + } + catch (IOException) + { + // A leftover temp directory is not worth failing a test over. + } + } + } + + private sealed class StubTerminalService : ITerminalService + { + internal Guid NextTerminalId { get; } = Guid.NewGuid(); + + internal List OpenTerminals { get; } = new(); + + internal string? LastName { get; private set; } + + internal ProfileConfig? LastProfile { get; private set; } + + internal string? LastWorkingDirectory { get; private set; } + + internal Guid? LastShown { get; private set; } + + internal Guid? LastClosed { get; private set; } + + internal int CloseAllCount { get; private set; } + + public Task CreateTerminalAsync(CancellationToken cancellationToken, string? name, ProfileConfig? profile) + => throw new InvalidOperationException( + "The overload without a working directory should not be selected."); + + public Task CreateTerminalAsync( + CancellationToken cancellationToken, + string? name, + ProfileConfig? profile, + string? workingDirectory) + { + LastName = name; + LastProfile = profile; + LastWorkingDirectory = workingDirectory; + return Task.FromResult(NextTerminalId); + } + + public Task> GetTerminalGuidsAsync(CancellationToken cancellationToken) + => Task.FromResult>(OpenTerminals.ToList()); + + public Task ShowAsync(Guid terminalGuid, CancellationToken cancellationToken) + { + LastShown = terminalGuid; + return Task.CompletedTask; + } + + public Task CloseAsync(Guid terminalGuid, CancellationToken cancellationToken) + { + LastClosed = terminalGuid; + return Task.CompletedTask; + } + + public Task CloseAllInstancesAsync(CancellationToken cancellationToken) + { + CloseAllCount++; + return Task.CompletedTask; + } + } + + private sealed class StubServiceBroker : IServiceBroker + { + private readonly object _service; + + internal StubServiceBroker(object service) + { + _service = service; + } + +#pragma warning disable CS0067 // Required by the interface; nothing under test raises it. + public event EventHandler? AvailabilityChanged; +#pragma warning restore CS0067 + + public ValueTask GetProxyAsync( + ServiceRpcDescriptor serviceDescriptor, + ServiceActivationOptions options = default, + CancellationToken cancellationToken = default) + where T : class + { + Assert.NotNull(serviceDescriptor); + return new ValueTask((T?)_service); + } + + public ValueTask GetPipeAsync( + ServiceMoniker serviceMoniker, + ServiceActivationOptions options = default, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + } +} diff --git a/src/CodingWithCalvin.MCPServer.Tests/TerminalStandIns.cs b/src/CodingWithCalvin.MCPServer.Tests/TerminalStandIns.cs new file mode 100644 index 0000000..c57d70b --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Tests/TerminalStandIns.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ServiceHub.Framework; + +// Stand-ins for the Visual Studio integrated terminal types, declared in the real namespace so +// that TerminalInterop's reflective type lookups resolve against them. +// +// Microsoft.VisualStudio.Terminal.dll ships only inside the Visual Studio installation and is +// absent from NuGet and from the Microsoft.VisualStudio.SDK metapackage, so the production code +// loads it by strong name and reads these types reflectively. Supplying the test assembly as the +// assembly resolver lets that whole path run without Visual Studio. +// +// Shapes are transcribed from the shipped assembly and are identical in VS 2022 17.14 and +// VS 2026 18.0. Only the members the interop touches are reproduced - including both +// CreateTerminalAsync overloads that differ solely by the trailing working-directory parameter, +// so the overload selection is genuinely exercised. +namespace Microsoft.VisualStudio.Terminal; + +public class ProfileConfig +{ + public string? Id { get; set; } + public string? DisplayName { get; set; } + public string? Location { get; set; } + public string? Arguments { get; set; } + public bool IsDefault { get; set; } + public bool CreatePTY { get; set; } +} + +public interface ITerminalService +{ + Task CreateTerminalAsync(CancellationToken cancellationToken, string? name, ProfileConfig? profile); + + Task CreateTerminalAsync( + CancellationToken cancellationToken, + string? name, + ProfileConfig? profile, + string? workingDirectory); + + Task> GetTerminalGuidsAsync(CancellationToken cancellationToken); + + Task ShowAsync(Guid terminalGuid, CancellationToken cancellationToken); + + Task CloseAsync(Guid terminalGuid, CancellationToken cancellationToken); + + Task CloseAllInstancesAsync(CancellationToken cancellationToken); +} + +public static class TerminalServiceDescriptors +{ + public const string TerminalMoniker = "Microsoft.VisualStudio.Terminal.TerminalService"; + + public static ServiceRpcDescriptor TerminalServiceDescriptor { get; } = + new ServiceJsonRpcDescriptor( + new ServiceMoniker(TerminalMoniker), + ServiceJsonRpcDescriptor.Formatters.UTF8, + ServiceJsonRpcDescriptor.MessageDelimiters.HttpLikeHeaders); +} diff --git a/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs b/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs index 67fd4dc..000f7c5 100644 --- a/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs +++ b/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs @@ -76,6 +76,13 @@ public interface IVisualStudioService Task GetTestRunStatusAsync(); Task GetTestStatsAsync(); + // Terminal tools + Task CreateTerminalAsync(string? name, string? workingDirectory, string? command); + Task GetTerminalsAsync(); + Task ShowTerminalAsync(string terminalId); + Task CloseTerminalAsync(string terminalId); + Task CloseAllTerminalsAsync(); + Task> GetWindowsAsync(); Task ActivateWindowAsync(string caption); Task ShowToolWindowAsync(string name); diff --git a/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs b/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs index 104401d..3d2b91c 100644 --- a/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs +++ b/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs @@ -287,6 +287,13 @@ public Task RunTestsInContextAsync(string target, bool debug) public Task GetTestRunStatusAsync() => _vsService.GetTestRunStatusAsync(); public Task GetTestStatsAsync() => _vsService.GetTestStatsAsync(); + public Task CreateTerminalAsync(string? name, string? workingDirectory, string? command) + => _vsService.CreateTerminalAsync(name, workingDirectory, command); + public Task GetTerminalsAsync() => _vsService.GetTerminalsAsync(); + public Task ShowTerminalAsync(string terminalId) => _vsService.ShowTerminalAsync(terminalId); + public Task CloseTerminalAsync(string terminalId) => _vsService.CloseTerminalAsync(terminalId); + public Task CloseAllTerminalsAsync() => _vsService.CloseAllTerminalsAsync(); + public Task> GetWindowsAsync() => _vsService.GetWindowsAsync(); public Task ActivateWindowAsync(string caption) => _vsService.ActivateWindowAsync(caption); public Task ShowToolWindowAsync(string name) => _vsService.ShowToolWindowAsync(name); diff --git a/src/CodingWithCalvin.MCPServer/Services/TerminalInterop.cs b/src/CodingWithCalvin.MCPServer/Services/TerminalInterop.cs new file mode 100644 index 0000000..6fe5f99 --- /dev/null +++ b/src/CodingWithCalvin.MCPServer/Services/TerminalInterop.cs @@ -0,0 +1,448 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using CodingWithCalvin.Otel4Vsix; +using Microsoft.ServiceHub.Framework; + +namespace CodingWithCalvin.MCPServer.Services; + +/// +/// Reflection bridge to the Visual Studio integrated terminal. +/// +/// +/// +/// The brokered service plumbing itself is ordinary compile-time code: +/// IBrokeredServiceContainer, IServiceBroker, ServiceRpcDescriptor and +/// ServiceActivationOptions all come from Microsoft.ServiceHub.Framework, which is a real +/// NuGet package the VSIX already references. +/// +/// +/// What cannot be referenced is Microsoft.VisualStudio.Terminal.dll, which supplies +/// ITerminalService, ProfileConfig and TerminalServiceDescriptors. It ships +/// only inside the Visual Studio installation, is absent from NuGet and from the +/// Microsoft.VisualStudio.SDK metapackage, and $(DevEnvDir) is undefined under +/// dotnet build - the same constraint documented on +/// . Those three types are therefore reached reflectively. +/// +/// +/// The assembly is loaded by strong name at version 17.0.0.0. Visual Studio ships a binding +/// redirect covering 0.0.0.0 through the installed version, plus a codeBase entry pointing at +/// CommonExtensions\Microsoft\Terminal, in both VS 2022 and VS 2026 - so one version number +/// resolves correctly on both. +/// +/// +/// Every failure path degrades to a message rather than throwing. The terminal component may be +/// absent, and on a future Visual Studio the shape could change; neither should take an MCP tool +/// call down with it. +/// +/// +internal sealed class TerminalInterop +{ + private const string TerminalAssemblyName = + "Microsoft.VisualStudio.Terminal, Version=17.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"; + private const string SimpleAssemblyName = "Microsoft.VisualStudio.Terminal"; + private const string DescriptorsTypeName = "Microsoft.VisualStudio.Terminal.TerminalServiceDescriptors"; + private const string ServiceTypeName = "Microsoft.VisualStudio.Terminal.ITerminalService"; + private const string ProfileConfigTypeName = "Microsoft.VisualStudio.Terminal.ProfileConfig"; + + internal const string UnavailableMessage = + "The Visual Studio integrated terminal component is unavailable in this instance."; + + private readonly Func _serviceBrokerAccessor; + private readonly Func _assemblyResolver; + + private bool _typesResolved; + private Type? _serviceType; + private Type? _profileConfigType; + private ServiceRpcDescriptor? _descriptor; + + internal TerminalInterop(Func serviceBrokerAccessor) + : this(serviceBrokerAccessor, TryLoadTerminalAssembly) + { + } + + /// + /// Overload taking an explicit assembly resolver, so that tests can supply stand-in terminal + /// types without a Visual Studio installation. + /// + internal TerminalInterop(Func serviceBrokerAccessor, Func assemblyResolver) + { + _serviceBrokerAccessor = serviceBrokerAccessor; + _assemblyResolver = assemblyResolver; + } + + /// + /// Opens a terminal, optionally launching a command in it. When a command is supplied it is + /// run through cmd.exe with /k so the window stays open and its output remains readable. + /// + internal async Task CreateTerminalAsync( + string? name, + string? workingDirectory, + string? command, + CancellationToken cancellationToken) + { + var proxy = await AcquireProxyAsync(cancellationToken).ConfigureAwait(false); + if (proxy == null) + { + return TerminalCreateOutcome.Unavailable(UnavailableMessage); + } + + using (proxy) + { + var developerEnvironment = false; + object? profile = null; + + if (!string.IsNullOrWhiteSpace(command)) + { + profile = BuildCommandProfile(command!, out developerEnvironment); + if (profile == null) + { + return TerminalCreateOutcome.Unavailable(UnavailableMessage); + } + } + + // Four CreateTerminalAsync overloads exist; this selects the ProfileConfig one that + // also takes a working directory. Passing a null profile leaves Visual Studio to use + // the user's default terminal profile. + var method = _serviceType!.GetMethods() + .FirstOrDefault(m => m.Name == "CreateTerminalAsync" && MatchesCreateSignature(m)); + if (method == null) + { + return TerminalCreateOutcome.Unavailable(UnavailableMessage); + } + + var result = await InvokeAsync( + method, + proxy.Service, + new[] { cancellationToken, name, profile, workingDirectory }).ConfigureAwait(false); + + return result is Guid id + ? TerminalCreateOutcome.Success(id, developerEnvironment) + : TerminalCreateOutcome.Unavailable("Visual Studio did not return a terminal identifier."); + } + } + + internal async Task?> GetTerminalIdsAsync(CancellationToken cancellationToken) + { + var proxy = await AcquireProxyAsync(cancellationToken).ConfigureAwait(false); + if (proxy == null) + { + return null; + } + + using (proxy) + { + var method = _serviceType!.GetMethod("GetTerminalGuidsAsync", new[] { typeof(CancellationToken) }); + if (method == null) + { + return null; + } + + var result = await InvokeAsync(method, proxy.Service, new object?[] { cancellationToken }) + .ConfigureAwait(false); + + return result is IEnumerable ids + ? ids.Cast().ToList() + : null; + } + } + + internal Task ShowTerminalAsync(Guid terminalId, CancellationToken cancellationToken) => + InvokeByIdAsync("ShowAsync", terminalId, cancellationToken); + + internal Task CloseTerminalAsync(Guid terminalId, CancellationToken cancellationToken) => + InvokeByIdAsync("CloseAsync", terminalId, cancellationToken); + + internal async Task CloseAllTerminalsAsync(CancellationToken cancellationToken) + { + var proxy = await AcquireProxyAsync(cancellationToken).ConfigureAwait(false); + if (proxy == null) + { + return false; + } + + using (proxy) + { + var method = _serviceType!.GetMethod("CloseAllInstancesAsync", new[] { typeof(CancellationToken) }); + if (method == null) + { + return false; + } + + await InvokeAsync(method, proxy.Service, new object?[] { cancellationToken }).ConfigureAwait(false); + return true; + } + } + + private async Task InvokeByIdAsync(string methodName, Guid terminalId, CancellationToken cancellationToken) + { + var proxy = await AcquireProxyAsync(cancellationToken).ConfigureAwait(false); + if (proxy == null) + { + return false; + } + + using (proxy) + { + var method = _serviceType!.GetMethod(methodName, new[] { typeof(Guid), typeof(CancellationToken) }); + if (method == null) + { + return false; + } + + await InvokeAsync(method, proxy.Service, new object?[] { terminalId, cancellationToken }) + .ConfigureAwait(false); + return true; + } + } + + /// + /// Builds a ProfileConfig that runs through cmd.exe. When the + /// Visual Studio developer command script can be located it is chained in first, so the + /// command sees the same PATH and environment as the Developer Command Prompt - which is + /// what tooling like msbuild, vstest.console and dotnet-coverage expects. + /// + private object? BuildCommandProfile(string command, out bool developerEnvironment) + { + developerEnvironment = false; + + var profile = Activator.CreateInstance(_profileConfigType!); + if (profile == null) + { + return null; + } + + var comSpec = Environment.GetEnvironmentVariable("COMSPEC"); + if (string.IsNullOrWhiteSpace(comSpec)) + { + comSpec = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "cmd.exe"); + } + + var devCmd = TryFindDeveloperCommandScript(); + string arguments; + if (devCmd != null) + { + developerEnvironment = true; + // The outer pair of quotes is what cmd.exe /k requires when the command line itself + // contains quoted paths. + arguments = $"/k \"\"{devCmd}\" && {command}\""; + } + else + { + arguments = $"/k {command}"; + } + + SetProperty(profile, "Location", comSpec); + SetProperty(profile, "Arguments", arguments); + SetProperty(profile, "CreatePTY", true); + + return profile; + } + + /// + /// Locates VsDevCmd.bat for the running instance. VSAPPIDDIR points at Common7\IDE inside + /// devenv, and the script sits alongside in Common7\Tools. + /// + internal static string? TryFindDeveloperCommandScript() + { + try + { + var ideDirectory = Environment.GetEnvironmentVariable("VSAPPIDDIR"); + if (string.IsNullOrWhiteSpace(ideDirectory)) + { + return null; + } + + var script = Path.GetFullPath( + Path.Combine(ideDirectory, "..", "Tools", "VsDevCmd.bat")); + + return File.Exists(script) ? script : null; + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + return null; + } + } + + private static bool MatchesCreateSignature(MethodInfo method) + { + var parameters = method.GetParameters(); + return parameters.Length == 4 + && parameters[0].ParameterType == typeof(CancellationToken) + && parameters[1].ParameterType == typeof(string) + && parameters[2].ParameterType.FullName == ProfileConfigTypeName + && parameters[3].ParameterType == typeof(string); + } + + private static void SetProperty(object target, string name, object value) => + target.GetType().GetProperty(name)?.SetValue(target, value); + + /// + /// Invokes a reflected async method and unwraps its result. The declared return type is used + /// rather than the runtime type, because an async method returning a non-generic Task is + /// backed by Task<VoidTaskResult> and would otherwise yield a meaningless value. + /// + private static async Task InvokeAsync(MethodInfo method, object target, object?[] arguments) + { + var pending = method.Invoke(target, arguments); + if (pending is not Task task) + { + return null; + } + + await task.ConfigureAwait(false); + + var returnType = method.ReturnType; + if (!returnType.IsGenericType || returnType.GetGenericTypeDefinition() != typeof(Task<>)) + { + return null; + } + + return returnType.GetProperty("Result")?.GetValue(task); + } + + /// + /// Acquires a brokered-service proxy to the terminal. The proxy is created per operation and + /// disposed with it; it is an RPC channel, so disposing it does not affect terminals that + /// were opened through it. + /// + private async Task AcquireProxyAsync(CancellationToken cancellationToken) + { + try + { + if (!TryResolveTypes()) + { + return null; + } + + var broker = _serviceBrokerAccessor(); + if (broker == null) + { + return null; + } + + var getProxy = typeof(IServiceBroker) + .GetMethod(nameof(IServiceBroker.GetProxyAsync)) + ?.MakeGenericMethod(_serviceType!); + if (getProxy == null) + { + return null; + } + + var pending = getProxy.Invoke( + broker, + new object?[] { _descriptor, default(ServiceActivationOptions), cancellationToken }); + + // GetProxyAsync returns ValueTask, which has to be turned into a Task before it + // can be awaited through reflection. + var asTask = pending?.GetType().GetMethod("AsTask", Type.EmptyTypes); + if (asTask?.Invoke(pending, null) is not Task task) + { + return null; + } + + await task.ConfigureAwait(false); + + var service = task.GetType().GetProperty("Result")?.GetValue(task); + return service == null ? null : new TerminalProxy(service); + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + return null; + } + } + + private bool TryResolveTypes() + { + if (_typesResolved) + { + return _serviceType != null && _profileConfigType != null && _descriptor != null; + } + + _typesResolved = true; + + var assembly = _assemblyResolver(); + if (assembly == null) + { + return false; + } + + _serviceType = assembly.GetType(ServiceTypeName); + _profileConfigType = assembly.GetType(ProfileConfigTypeName); + + var descriptors = assembly.GetType(DescriptorsTypeName); + _descriptor = descriptors + ?.GetProperty("TerminalServiceDescriptor", BindingFlags.Public | BindingFlags.Static) + ?.GetValue(null) as ServiceRpcDescriptor; + + return _serviceType != null && _profileConfigType != null && _descriptor != null; + } + + private static Assembly? TryLoadTerminalAssembly() + { + try + { + // Prefer an already-loaded copy: Visual Studio loads this itself once the terminal + // window has been used, and reusing it avoids any dependence on binding policy. + var loaded = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => string.Equals( + a.GetName().Name, + SimpleAssemblyName, + StringComparison.OrdinalIgnoreCase)); + + return loaded ?? Assembly.Load(TerminalAssemblyName); + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + return null; + } + } + + /// Owns the lifetime of a brokered-service proxy. + private sealed class TerminalProxy : IDisposable + { + internal TerminalProxy(object service) + { + Service = service; + } + + internal object Service { get; } + + public void Dispose() => (Service as IDisposable)?.Dispose(); + } +} + +/// Result of a terminal creation attempt, before it is shaped for the wire. +internal sealed class TerminalCreateOutcome +{ + private TerminalCreateOutcome() + { + } + + internal bool Created { get; private set; } + + internal Guid TerminalId { get; private set; } + + internal bool DeveloperEnvironment { get; private set; } + + internal string? Message { get; private set; } + + internal static TerminalCreateOutcome Success(Guid id, bool developerEnvironment) => new() + { + Created = true, + TerminalId = id, + DeveloperEnvironment = developerEnvironment + }; + + internal static TerminalCreateOutcome Unavailable(string message) => new() { Message = message }; +} diff --git a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs index 588689e..76c9b7c 100644 --- a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs +++ b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs @@ -5,16 +5,19 @@ using System.IO; using System.Linq; using System.Runtime.InteropServices; +using System.Threading; using System.Threading.Tasks; using CodingWithCalvin.MCPServer.Shared.Models; using CodingWithCalvin.Otel4Vsix; using EnvDTE; using EnvDTE80; +using Microsoft.ServiceHub.Framework; using Microsoft.VisualStudio; using Microsoft.VisualStudio.ComponentModelHost; using Microsoft.VisualStudio.Editor; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.Shell.ServiceBroker; using Microsoft.VisualStudio.Shell.TableManager; using Microsoft.VisualStudio.Shell.TableControl; using Microsoft.VisualStudio.TextManager.Interop; @@ -2789,6 +2792,126 @@ public async Task GetTestStatsAsync() return TestExplorer.GetStats(); } + private TerminalInterop? _terminal; + + private TerminalInterop Terminal => _terminal ??= new TerminalInterop(GetServiceBroker); + + private IServiceBroker? GetServiceBroker() + { + ThreadHelper.ThrowIfNotOnUIThread(); + + var container = ServiceProvider.GetService(typeof(SVsBrokeredServiceContainer)) + as IBrokeredServiceContainer; + + return container?.GetFullAccessServiceBroker(); + } + + public async Task CreateTerminalAsync( + string? name, + string? workingDirectory, + string? command) + { + using var activity = VsixTelemetry.Tracer.StartActivity("CreateTerminal"); + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + try + { + var resolvedDirectory = ResolveTerminalWorkingDirectory(workingDirectory); + + var outcome = await Terminal.CreateTerminalAsync( + name, + resolvedDirectory, + command, + CancellationToken.None); + + return new TerminalResult + { + Created = outcome.Created, + TerminalId = outcome.Created ? outcome.TerminalId.ToString() : null, + Command = command, + WorkingDirectory = resolvedDirectory, + DeveloperEnvironment = outcome.DeveloperEnvironment, + Message = outcome.Message + }; + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.RecordException(ex); + return new TerminalResult { Message = ex.Message }; + } + } + + /// + /// Falls back to the open solution's directory so that a caller who omits the working + /// directory lands somewhere useful rather than in the Visual Studio install directory, + /// which is where devenv's own current directory usually points. + /// + private string? ResolveTerminalWorkingDirectory(string? workingDirectory) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + if (!string.IsNullOrWhiteSpace(workingDirectory)) + { + return NormalizePath(workingDirectory!); + } + + try + { + var dte = ServiceProvider.GetService(typeof(DTE)) as DTE2; + var solutionFile = dte?.Solution?.FullName; + + return string.IsNullOrEmpty(solutionFile) + ? null + : Path.GetDirectoryName(solutionFile); + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + return null; + } + } + + public async Task GetTerminalsAsync() + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + var ids = await Terminal.GetTerminalIdsAsync(CancellationToken.None); + if (ids == null) + { + return new TerminalListResult { Message = TerminalInterop.UnavailableMessage }; + } + + return new TerminalListResult + { + Available = true, + TerminalIds = ids.Select(id => id.ToString()).ToList() + }; + } + + public async Task ShowTerminalAsync(string terminalId) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + return Guid.TryParse(terminalId, out var id) + && await Terminal.ShowTerminalAsync(id, CancellationToken.None); + } + + public async Task CloseTerminalAsync(string terminalId) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + return Guid.TryParse(terminalId, out var id) + && await Terminal.CloseTerminalAsync(id, CancellationToken.None); + } + + public async Task CloseAllTerminalsAsync() + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + return await Terminal.CloseAllTerminalsAsync(CancellationToken.None); + } + /// /// ExecuteCommand throws when a command is disabled or unknown, which for Test Explorer is /// the normal state before discovery finishes. Probing first keeps that an ordinary result