From c843d0c9b199b5a6f62ceac121939dfe1a8c5c05 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Tue, 24 Mar 2026 11:06:25 -0400 Subject: [PATCH] feat(debugger): add startup project tools and project-specific debug launch Add startup_project_get and startup_project_set tools for managing the startup project. Add optional projectName parameter to debugger_launch and debugger_launch_without_debugging that launches a specific project via IVsDebuggableProjectCfg without changing the startup project. --- .../RpcClient.cs | 3 + .../Tools/DebuggerTools.cs | 38 ++++- .../Tools/SolutionTools.cs | 17 ++ .../RpcContracts.cs | 3 + .../Services/IVisualStudioService.cs | 3 + .../Services/RpcServer.cs | 3 + .../Services/VisualStudioService.cs | 151 ++++++++++++++++++ 7 files changed, 210 insertions(+), 8 deletions(-) diff --git a/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs b/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs index 0194885..9bfd881 100644 --- a/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs +++ b/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs @@ -137,7 +137,10 @@ public Task FindReferencesAsync(string path, int line, int col => Proxy.FindReferencesAsync(path, line, column, maxResults); public Task GetDebuggerStatusAsync() => Proxy.GetDebuggerStatusAsync(); + public Task GetStartupProjectAsync() => Proxy.GetStartupProjectAsync(); + public Task SetStartupProjectAsync(string projectName) => Proxy.SetStartupProjectAsync(projectName); public Task DebugLaunchAsync() => Proxy.DebugLaunchAsync(); + public Task DebugLaunchProjectAsync(string projectName, bool noDebug) => Proxy.DebugLaunchProjectAsync(projectName, noDebug); public Task DebugLaunchWithoutDebuggingAsync() => Proxy.DebugLaunchWithoutDebuggingAsync(); public Task DebugContinueAsync() => Proxy.DebugContinueAsync(); public Task DebugBreakAsync() => Proxy.DebugBreakAsync(); diff --git a/src/CodingWithCalvin.MCPServer.Server/Tools/DebuggerTools.cs b/src/CodingWithCalvin.MCPServer.Server/Tools/DebuggerTools.cs index 89373af..e938983 100644 --- a/src/CodingWithCalvin.MCPServer.Server/Tools/DebuggerTools.cs +++ b/src/CodingWithCalvin.MCPServer.Server/Tools/DebuggerTools.cs @@ -26,19 +26,41 @@ public async Task GetDebuggerStatusAsync() } [McpServerTool(Name = "debugger_launch", Destructive = false)] - [Description("Start debugging the current startup project (equivalent to F5). A solution must be open with a valid startup project configured. Use debugger_status to check the resulting state.")] - public async Task DebugLaunchAsync() + [Description("Start debugging a project (equivalent to F5). If projectName is specified, launches that specific project without changing the startup project. Otherwise debugs the current startup project. A solution must be open. Use debugger_status to check the resulting state.")] + public async Task DebugLaunchAsync( + [Description("Optional: The display name of the project to debug (e.g., 'MyProject'). Launches this project directly without changing the startup project. Use project_list to see available project names.")] string? projectName = null) { - var success = await _rpcClient.DebugLaunchAsync(); - return success ? "Debugging started" : "Failed to start debugging (is a solution open with a startup project configured?)"; + if (projectName != null) + { + var success = await _rpcClient.DebugLaunchProjectAsync(projectName, noDebug: false); + return success + ? $"Debugging started for project: {projectName}" + : $"Failed to start debugging for project '{projectName}'. Use project_list to verify the project name."; + } + else + { + var success = await _rpcClient.DebugLaunchAsync(); + return success ? "Debugging started" : "Failed to start debugging (is a solution open with a startup project configured?)"; + } } [McpServerTool(Name = "debugger_launch_without_debugging", Destructive = false)] - [Description("Start the current startup project without the debugger attached (equivalent to Ctrl+F5). The application runs normally without breakpoints or stepping. A solution must be open with a valid startup project configured.")] - public async Task DebugLaunchWithoutDebuggingAsync() + [Description("Start a project without the debugger attached (equivalent to Ctrl+F5). If projectName is specified, launches that specific project without changing the startup project. Otherwise runs the current startup project. The application runs normally without breakpoints or stepping. A solution must be open.")] + public async Task DebugLaunchWithoutDebuggingAsync( + [Description("Optional: The display name of the project to run (e.g., 'MyProject'). Launches this project directly without changing the startup project. Use project_list to see available project names.")] string? projectName = null) { - var success = await _rpcClient.DebugLaunchWithoutDebuggingAsync(); - return success ? "Started without debugging" : "Failed to start without debugging (is a solution open with a startup project configured?)"; + if (projectName != null) + { + var success = await _rpcClient.DebugLaunchProjectAsync(projectName, noDebug: true); + return success + ? $"Started without debugging for project: {projectName}" + : $"Failed to start without debugging for project '{projectName}'. Use project_list to verify the project name."; + } + else + { + var success = await _rpcClient.DebugLaunchWithoutDebuggingAsync(); + return success ? "Started without debugging" : "Failed to start without debugging (is a solution open with a startup project configured?)"; + } } [McpServerTool(Name = "debugger_continue", Destructive = false)] diff --git a/src/CodingWithCalvin.MCPServer.Server/Tools/SolutionTools.cs b/src/CodingWithCalvin.MCPServer.Server/Tools/SolutionTools.cs index b18b931..7f9f57a 100644 --- a/src/CodingWithCalvin.MCPServer.Server/Tools/SolutionTools.cs +++ b/src/CodingWithCalvin.MCPServer.Server/Tools/SolutionTools.cs @@ -61,6 +61,23 @@ public async Task GetProjectListAsync() return JsonSerializer.Serialize(projects, _jsonOptions); } + [McpServerTool(Name = "startup_project_get", ReadOnly = true)] + [Description("Get the current startup project name. Returns the project that will be launched when debugging starts.")] + public async Task GetStartupProjectAsync() + { + var startupProject = await _rpcClient.GetStartupProjectAsync(); + return startupProject ?? "No startup project is set"; + } + + [McpServerTool(Name = "startup_project_set", Destructive = false)] + [Description("Set the startup project for debugging. Use project_list to get available project names.")] + public async Task SetStartupProjectAsync( + [Description("The display name of the project to set as the startup project (e.g., 'MyProject'). Use project_list to see available project names.")] string name) + { + var success = await _rpcClient.SetStartupProjectAsync(name); + return success ? $"Startup project set to: {name}" : $"Failed to set startup project: {name}"; + } + [McpServerTool(Name = "project_info", ReadOnly = true)] [Description("Get detailed information about a specific project by its display name.")] public async Task GetProjectInfoAsync( diff --git a/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs b/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs index b390d4c..875af1a 100644 --- a/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs +++ b/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs @@ -42,7 +42,10 @@ public interface IVisualStudioRpc Task FindReferencesAsync(string path, int line, int column, int maxResults = 100); Task GetDebuggerStatusAsync(); + Task GetStartupProjectAsync(); + Task SetStartupProjectAsync(string projectName); Task DebugLaunchAsync(); + Task DebugLaunchProjectAsync(string projectName, bool noDebug); Task DebugLaunchWithoutDebuggingAsync(); Task DebugContinueAsync(); Task DebugBreakAsync(); diff --git a/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs b/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs index a702a28..8a4e81d 100644 --- a/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs +++ b/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs @@ -38,7 +38,10 @@ public interface IVisualStudioService Task FindReferencesAsync(string path, int line, int column, int maxResults = 100); Task GetDebuggerStatusAsync(); + Task GetStartupProjectAsync(); + Task SetStartupProjectAsync(string projectName); Task DebugLaunchAsync(); + Task DebugLaunchProjectAsync(string projectName, bool noDebug); Task DebugLaunchWithoutDebuggingAsync(); Task DebugContinueAsync(); Task DebugBreakAsync(); diff --git a/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs b/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs index f079c2f..d8fb6e5 100644 --- a/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs +++ b/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs @@ -195,7 +195,10 @@ public Task FindReferencesAsync(string path, int line, int col => _vsService.FindReferencesAsync(path, line, column, maxResults); public Task GetDebuggerStatusAsync() => _vsService.GetDebuggerStatusAsync(); + public Task GetStartupProjectAsync() => _vsService.GetStartupProjectAsync(); + public Task SetStartupProjectAsync(string projectName) => _vsService.SetStartupProjectAsync(projectName); public Task DebugLaunchAsync() => _vsService.DebugLaunchAsync(); + public Task DebugLaunchProjectAsync(string projectName, bool noDebug) => _vsService.DebugLaunchProjectAsync(projectName, noDebug); public Task DebugLaunchWithoutDebuggingAsync() => _vsService.DebugLaunchWithoutDebuggingAsync(); public Task DebugContinueAsync() => _vsService.DebugContinueAsync(); public Task DebugBreakAsync() => _vsService.DebugBreakAsync(); diff --git a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs index 5efd6ca..9f5bc4c 100644 --- a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs +++ b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs @@ -1144,6 +1144,157 @@ public async Task GetDebuggerStatusAsync() return status; } + public async Task GetStartupProjectAsync() + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var dte = await GetDteAsync(); + + try + { + if (dte.Solution?.SolutionBuild?.StartupProjects is Array startupProjects && startupProjects.Length > 0) + { + return startupProjects.GetValue(0) as string; + } + + return null; + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + return null; + } + } + + public async Task SetStartupProjectAsync(string projectName) + { + using var activity = VsixTelemetry.Tracer.StartActivity("SetStartupProject"); + + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var dte = await GetDteAsync(); + + try + { + dte.Solution.SolutionBuild.StartupProjects = projectName; + return true; + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.RecordException(ex); + return false; + } + } + + public async Task DebugLaunchProjectAsync(string projectName, bool noDebug) + { + using var activity = VsixTelemetry.Tracer.StartActivity("DebugLaunchProject"); + + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var dte = await GetDteAsync(); + + try + { + EnvDTE.Project? targetProject = null; + + foreach (EnvDTE.Project project in dte.Solution.Projects) + { + targetProject = FindProjectByName(project, projectName); + if (targetProject != null) + { + break; + } + } + + if (targetProject == null) + { + return false; + } + + var solution = ServiceProvider.GetService(typeof(SVsSolution)) as IVsSolution; + if (solution == null) + { + return false; + } + + ErrorHandler.ThrowOnFailure( + solution.GetProjectOfUniqueName(targetProject.UniqueName, out var hierarchy)); + + if (hierarchy is not IVsGetCfgProvider getCfgProvider) + { + return false; + } + + ErrorHandler.ThrowOnFailure(getCfgProvider.GetCfgProvider(out var cfgProvider)); + + if (cfgProvider is not IVsCfgProvider2 cfgProvider2) + { + return false; + } + + var configName = targetProject.ConfigurationManager.ActiveConfiguration.ConfigurationName; + var platformName = targetProject.ConfigurationManager.ActiveConfiguration.PlatformName; + + ErrorHandler.ThrowOnFailure( + cfgProvider2.GetCfgOfName(configName, platformName, out var cfg)); + + if (cfg is not IVsDebuggableProjectCfg debuggableProjectCfg) + { + return false; + } + + var launchFlags = noDebug + ? (uint)__VSDBGLAUNCHFLAGS.DBGLAUNCH_NoDebug + : 0u; + + ErrorHandler.ThrowOnFailure(debuggableProjectCfg.DebugLaunch(launchFlags)); + + return true; + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.RecordException(ex); + return false; + } + } + + private static EnvDTE.Project? FindProjectByName(EnvDTE.Project project, string name) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + try + { + if (project.Kind == ProjectKinds.vsProjectKindSolutionFolder) + { + if (project.ProjectItems != null) + { + foreach (ProjectItem item in project.ProjectItems) + { + if (item.SubProject != null) + { + var found = FindProjectByName(item.SubProject, name); + if (found != null) + { + return found; + } + } + } + } + + return null; + } + + return string.Equals(project.Name, name, StringComparison.OrdinalIgnoreCase) + ? project + : null; + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + return null; + } + } + public async Task DebugLaunchAsync() { using var activity = VsixTelemetry.Tracer.StartActivity("DebugLaunch");