From 7fbb90927dd2414bebeb16abca5fb096a192c4b5 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Thu, 3 Sep 2026 13:05:07 -0400 Subject: [PATCH] feat(tools): add Visual Studio terminal tools Adds six MCP tools over the integrated terminal: terminal_run, terminal_create, terminal_list, terminal_show, terminal_close and terminal_close_all. The brokered service plumbing is ordinary compile-time code. IBrokeredServiceContainer, IServiceBroker, ServiceRpcDescriptor and ServiceActivationOptions all come from Microsoft.ServiceHub.Framework, a real NuGet package the VSIX already references. Microsoft.VisualStudio.Terminal.dll is the part that cannot be referenced. It supplies ITerminalService, ProfileConfig and TerminalServiceDescriptors, 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. TerminalInterop loads it by strong name at version 17.0.0.0, which the binding redirect and codeBase entry shipped by both VS 2022 and VS 2026 resolve correctly, and reaches those three types reflectively. Commands run through cmd.exe with /k so the window stays readable. When VsDevCmd.bat can be located from VSAPPIDDIR it is chained in first, so msbuild, vstest.console and dotnet-coverage are on PATH, which is what the original request in #95 needed. Output is deliberately not captured. The terminal is a raw PTY with no exit code and no command boundaries, so inferring success from its text would be unreliable. The tool descriptions say so and point at redirecting to a file and reading it back with document_read. Working directory defaults to the open solution's directory rather than devenv's own, which points into the Visual Studio install. --- CLAUDE.md | 11 + README.md | 19 + .../Program.cs | 3 +- .../RpcClient.cs | 9 +- .../Tools/TerminalTools.cs | 85 ++++ .../Models/TerminalModels.cs | 51 ++ .../RpcContracts.cs | 7 + .../TerminalInteropTests.cs | 342 +++++++++++++ .../TerminalStandIns.cs | 59 +++ .../Services/IVisualStudioService.cs | 7 + .../Services/RpcServer.cs | 7 + .../Services/TerminalInterop.cs | 448 ++++++++++++++++++ .../Services/VisualStudioService.cs | 123 +++++ 13 files changed, 1169 insertions(+), 2 deletions(-) create mode 100644 src/CodingWithCalvin.MCPServer.Server/Tools/TerminalTools.cs create mode 100644 src/CodingWithCalvin.MCPServer.Shared/Models/TerminalModels.cs create mode 100644 src/CodingWithCalvin.MCPServer.Tests/TerminalInteropTests.cs create mode 100644 src/CodingWithCalvin.MCPServer.Tests/TerminalStandIns.cs create mode 100644 src/CodingWithCalvin.MCPServer/Services/TerminalInterop.cs 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