| 1 | using System.Text; |
| 2 | using System.Text.Json; |
| 3 | using DotnetDebug.Core; |
| 4 | |
| 5 | namespace DotnetDebugMcp; |
| 6 | |
| 7 | internal static class DebugLaunchToolHandlers |
| 8 | { |
| 9 | internal static async Task<string> HandleDebugLaunch(IReadOnlyDictionary<string, JsonElement> args) |
| 10 | { |
| 11 | if (!McpArgumentHelpers.TryGetString(args, "workspace_path", out var workspacePath) || string.IsNullOrWhiteSpace(workspacePath)) |
| 12 | throw new ArgumentException("workspace_path is required."); |
| 13 | if (!McpArgumentHelpers.TryGetString(args, "target_path", out var targetPath) || string.IsNullOrWhiteSpace(targetPath)) |
| 14 | throw new ArgumentException("target_path is required."); |
| 15 | |
| 16 | var netcoredbgPath = Environment.GetEnvironmentVariable("NETCOREDBG_PATH")?.Trim(); |
| 17 | if (McpArgumentHelpers.TryGetString(args, "netcoredbg_path", out var customPath) && !string.IsNullOrWhiteSpace(customPath)) |
| 18 | netcoredbgPath = customPath; |
| 19 | if (string.IsNullOrWhiteSpace(netcoredbgPath)) |
| 20 | netcoredbgPath = "netcoredbg"; |
| 21 | |
| 22 | var workspaceRoot = Path.GetFullPath(workspacePath!.Trim()); |
| 23 | if (File.Exists(workspaceRoot)) |
| 24 | workspaceRoot = Path.GetDirectoryName(workspaceRoot) ?? workspaceRoot; |
| 25 | var programPath = Path.IsPathRooted(targetPath!.Trim()) |
| 26 | ? Path.GetFullPath(targetPath) |
| 27 | : Path.GetFullPath(Path.Combine(workspaceRoot, targetPath.Trim())); |
| 28 | if (!File.Exists(programPath)) |
| 29 | throw new ArgumentException($"Target not found: {programPath}"); |
| 30 | |
| 31 | var breakpoints = BreakpointsStorage.GetBreakpoints(workspacePath!, targetPath!).ToList(); |
| 32 | var byFile = breakpoints |
| 33 | .GroupBy(b => DapHelpers.ResolveBreakpointFilePath(workspaceRoot, b.File)) |
| 34 | .ToDictionary(g => g.Key, g => g.Select(b => (b.Line, b.Condition)).ToList()); |
| 35 | |
| 36 | IReadOnlyList<string>? programArgs = null; |
| 37 | if (args.TryGetValue("program_args", out var argsEl) && argsEl.ValueKind == JsonValueKind.Array) |
| 38 | { |
| 39 | var list = new List<string>(); |
| 40 | foreach (var e in argsEl.EnumerateArray()) |
| 41 | if (e.ValueKind == JsonValueKind.String && e.GetString() is { } s) |
| 42 | list.Add(s); |
| 43 | if (list.Count > 0) |
| 44 | programArgs = list; |
| 45 | } |
| 46 | |
| 47 | var client = await DapClient.StartAsync(netcoredbgPath, cancellationToken: default, clientId: "dotnet-debug-mcp", clientName: "DotnetDebugMcp").ConfigureAwait(false); |
| 48 | client.OnConnectionLost = () => |
| 49 | { |
| 50 | if (DebugSession.CurrentClient == client) |
| 51 | { |
| 52 | DebugSession.CurrentClient = null; |
| 53 | DebugSession.LastStoppedThreadId = 0; |
| 54 | DebugSession.LastExceptionText = null; |
| 55 | } |
| 56 | }; |
| 57 | DebugSession.PrepareStoppedWait(); |
| 58 | var stoppedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); |
| 59 | client.OnEvent = (eventName, body) => |
| 60 | { |
| 61 | if (eventName == "stopped" && body.TryGetProperty("threadId", out var tid)) |
| 62 | { |
| 63 | var reason = body.TryGetProperty("reason", out var r) ? r.GetString() : null; |
| 64 | var exceptionText = (reason == "exception" && body.TryGetProperty("text", out var txt)) ? txt.GetString() : null; |
| 65 | DebugSession.OnStopped(tid.GetInt32(), exceptionText); |
| 66 | stoppedTcs.TrySetResult(); |
| 67 | } |
| 68 | else if (eventName == "continued") |
| 69 | DebugSession.OnContinued(); |
| 70 | }; |
| 71 | var exceptionBpsOk = false; |
| 72 | try |
| 73 | { |
| 74 | await client.LaunchAsync(programPath, Path.GetDirectoryName(programPath), programArgs).ConfigureAwait(false); |
| 75 | foreach (var (file, list) in byFile) |
| 76 | { |
| 77 | if (list.Count > 0) |
| 78 | await client.SetBreakpointsAsync(file, list).ConfigureAwait(false); |
| 79 | } |
| 80 | exceptionBpsOk = await DapHelpers.TrySetUnhandledExceptionBreakpointsAsync(client).ConfigureAwait(false); |
| 81 | await client.ConfigurationDoneAsync().ConfigureAwait(false); |
| 82 | await stoppedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); |
| 83 | } |
| 84 | catch (TimeoutException) |
| 85 | { |
| 86 | stoppedTcs.TrySetResult(); |
| 87 | } |
| 88 | catch |
| 89 | { |
| 90 | await client.DisposeAsync().ConfigureAwait(false); |
| 91 | throw; |
| 92 | } |
| 93 | |
| 94 | DebugSession.CurrentClient = client; |
| 95 | |
| 96 | var stopped = DebugSession.LastStoppedThreadId != 0; |
| 97 | if (!stopped) |
| 98 | { |
| 99 | try |
| 100 | { |
| 101 | var threadsBody = await client.ThreadsAsync().ConfigureAwait(false); |
| 102 | if (threadsBody != null && threadsBody.Value.TryGetProperty("threads", out var threadsArr)) |
| 103 | { |
| 104 | foreach (var t in threadsArr.EnumerateArray()) |
| 105 | { |
| 106 | if (t.TryGetProperty("id", out var idEl) && idEl.TryGetInt32(out var tid)) |
| 107 | { |
| 108 | DebugSession.LastStoppedThreadId = tid; |
| 109 | stopped = true; |
| 110 | break; |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | } |
| 115 | catch { /* ignore */ } |
| 116 | } |
| 117 | |
| 118 | var sb = new StringBuilder(); |
| 119 | sb.AppendLine("# Debug session started"); |
| 120 | sb.AppendLine($"# Program: {programPath}"); |
| 121 | sb.AppendLine($"# Breakpoints: {breakpoints.Count} applied"); |
| 122 | sb.AppendLine(exceptionBpsOk |
| 123 | ? "# Exception breakpoints: unhandled (stop on throw)" |
| 124 | : "# Exception breakpoints: skipped (adapter rejected setExceptionBreakpoints; some netcoredbg builds return 0x80070057)"); |
| 125 | if (!stopped) |
| 126 | sb.AppendLine("# (Wait for breakpoint timed out — call debug_continue then use stack_trace/step_* after it stops, or check that the target hits a breakpoint. First thread id used as fallback if available.)"); |
| 127 | else if (DebugSession.LastExceptionText is { } exMsg) |
| 128 | sb.AppendLine($"# Stopped on exception: {exMsg}"); |
| 129 | sb.AppendLine("# Use debug_continue or debug_step_over to control execution."); |
| 130 | return sb.ToString(); |
| 131 | } |
| 132 | |
| 133 | internal static async Task<string> HandleDebugAttach(IReadOnlyDictionary<string, JsonElement> args) |
| 134 | { |
| 135 | if (!McpArgumentHelpers.TryGetString(args, "workspace_path", out var workspacePath) || string.IsNullOrWhiteSpace(workspacePath)) |
| 136 | throw new ArgumentException("workspace_path is required."); |
| 137 | if (!args.TryGetValue("process_id", out var pidEl) || !pidEl.TryGetInt32(out var processId) || processId <= 0) |
| 138 | throw new ArgumentException("process_id (positive integer) is required."); |
| 139 | |
| 140 | var netcoredbgPath = Environment.GetEnvironmentVariable("NETCOREDBG_PATH")?.Trim(); |
| 141 | if (McpArgumentHelpers.TryGetString(args, "netcoredbg_path", out var customPath) && !string.IsNullOrWhiteSpace(customPath)) |
| 142 | netcoredbgPath = customPath; |
| 143 | if (string.IsNullOrWhiteSpace(netcoredbgPath)) |
| 144 | netcoredbgPath = "netcoredbg"; |
| 145 | |
| 146 | var workspaceRoot = Path.GetFullPath(workspacePath!.Trim()); |
| 147 | if (File.Exists(workspaceRoot)) |
| 148 | workspaceRoot = Path.GetDirectoryName(workspaceRoot) ?? workspaceRoot; |
| 149 | |
| 150 | var breakpoints = new List<BreakpointsStorage.BreakpointEntry>(); |
| 151 | var byFile = new Dictionary<string, List<(int Line, string? Condition)>>(); |
| 152 | if (McpArgumentHelpers.TryGetString(args, "target_path", out var targetPath) && !string.IsNullOrWhiteSpace(targetPath)) |
| 153 | { |
| 154 | breakpoints = BreakpointsStorage.GetBreakpoints(workspacePath, targetPath).ToList(); |
| 155 | byFile = breakpoints |
| 156 | .GroupBy(b => DapHelpers.ResolveBreakpointFilePath(workspaceRoot, b.File)) |
| 157 | .ToDictionary(g => g.Key, g => g.Select(b => (b.Line, b.Condition)).ToList()); |
| 158 | } |
| 159 | |
| 160 | var client = await DapClient.StartAsync(netcoredbgPath, cancellationToken: default, clientId: "dotnet-debug-mcp", clientName: "DotnetDebugMcp").ConfigureAwait(false); |
| 161 | client.OnConnectionLost = () => |
| 162 | { |
| 163 | if (DebugSession.CurrentClient == client) |
| 164 | { |
| 165 | DebugSession.CurrentClient = null; |
| 166 | DebugSession.LastStoppedThreadId = 0; |
| 167 | DebugSession.LastExceptionText = null; |
| 168 | } |
| 169 | }; |
| 170 | DebugSession.PrepareStoppedWait(); |
| 171 | var stoppedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); |
| 172 | client.OnEvent = (eventName, body) => |
| 173 | { |
| 174 | if (eventName == "stopped" && body.TryGetProperty("threadId", out var tid)) |
| 175 | { |
| 176 | var reason = body.TryGetProperty("reason", out var r) ? r.GetString() : null; |
| 177 | var exceptionText = (reason == "exception" && body.TryGetProperty("text", out var txt)) ? txt.GetString() : null; |
| 178 | DebugSession.OnStopped(tid.GetInt32(), exceptionText); |
| 179 | stoppedTcs.TrySetResult(); |
| 180 | } |
| 181 | else if (eventName == "continued") |
| 182 | DebugSession.OnContinued(); |
| 183 | }; |
| 184 | var attachExceptionBpsOk = false; |
| 185 | try |
| 186 | { |
| 187 | await client.AttachAsync(processId).ConfigureAwait(false); |
| 188 | foreach (var (file, list) in byFile) |
| 189 | { |
| 190 | if (list.Count > 0) |
| 191 | await client.SetBreakpointsAsync(file, list).ConfigureAwait(false); |
| 192 | } |
| 193 | attachExceptionBpsOk = await DapHelpers.TrySetUnhandledExceptionBreakpointsAsync(client).ConfigureAwait(false); |
| 194 | await client.ConfigurationDoneAsync().ConfigureAwait(false); |
| 195 | await stoppedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); |
| 196 | } |
| 197 | catch (TimeoutException) |
| 198 | { |
| 199 | stoppedTcs.TrySetResult(); |
| 200 | } |
| 201 | catch |
| 202 | { |
| 203 | await client.DisposeAsync().ConfigureAwait(false); |
| 204 | throw; |
| 205 | } |
| 206 | |
| 207 | DebugSession.CurrentClient = client; |
| 208 | |
| 209 | var stopped = DebugSession.LastStoppedThreadId != 0; |
| 210 | if (!stopped) |
| 211 | { |
| 212 | try |
| 213 | { |
| 214 | var threadsBody = await client.ThreadsAsync().ConfigureAwait(false); |
| 215 | if (threadsBody != null && threadsBody.Value.TryGetProperty("threads", out var threadsArr)) |
| 216 | { |
| 217 | foreach (var t in threadsArr.EnumerateArray()) |
| 218 | { |
| 219 | if (t.TryGetProperty("id", out var idEl) && idEl.TryGetInt32(out var tid)) |
| 220 | { |
| 221 | DebugSession.LastStoppedThreadId = tid; |
| 222 | stopped = true; |
| 223 | break; |
| 224 | } |
| 225 | } |
| 226 | } |
| 227 | } |
| 228 | catch { /* ignore */ } |
| 229 | } |
| 230 | |
| 231 | var sb = new StringBuilder(); |
| 232 | sb.AppendLine("# Debug session started (attach)"); |
| 233 | sb.AppendLine($"# Process ID: {processId}"); |
| 234 | sb.AppendLine($"# Breakpoints: {breakpoints.Count} applied"); |
| 235 | sb.AppendLine(attachExceptionBpsOk |
| 236 | ? "# Exception breakpoints: unhandled (stop on throw)" |
| 237 | : "# Exception breakpoints: skipped (adapter rejected setExceptionBreakpoints; some netcoredbg builds return 0x80070057)"); |
| 238 | if (!stopped) |
| 239 | sb.AppendLine("# (Wait for breakpoint timed out — call debug_continue then use stack_trace/step_* after it stops.)"); |
| 240 | else if (DebugSession.LastExceptionText is { } exMsg) |
| 241 | sb.AppendLine($"# Stopped on exception: {exMsg}"); |
| 242 | sb.AppendLine("# Use debug_continue or debug_step_over to control execution."); |
| 243 | return sb.ToString(); |
| 244 | } |
| 245 | } |
| 246 | |