|
| 1 | +using System; |
| 2 | +using System.Collections.Generic; |
| 3 | +using System.ComponentModel; |
| 4 | +using System.Linq; |
| 5 | +using System.Text.Json; |
| 6 | +using System.Threading.Tasks; |
| 7 | +using ModelContextProtocol.Server; |
| 8 | +using PerformanceMonitorDashboard.Models; |
| 9 | +using PerformanceMonitorDashboard.Services; |
| 10 | + |
| 11 | +#pragma warning disable CA1707 // MCP tools use snake_case naming convention |
| 12 | + |
| 13 | +namespace PerformanceMonitorDashboard.Mcp; |
| 14 | + |
| 15 | +[McpServerToolType] |
| 16 | +public sealed class McpPlanTools |
| 17 | +{ |
| 18 | + [McpServerTool(Name = "analyze_query_plan"), Description( |
| 19 | + "Analyzes an execution plan from query stats (plan cache) by query_hash. " + |
| 20 | + "Use after get_top_queries_by_cpu to understand why a query is expensive. " + |
| 21 | + "Returns warnings, missing indexes, parameters, memory grants, and top operators.")] |
| 22 | + public static async Task<string> AnalyzeQueryPlan( |
| 23 | + ServerManager serverManager, |
| 24 | + DatabaseServiceRegistry registry, |
| 25 | + [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, |
| 26 | + [Description("Server name or display name.")] string? server_name = null) |
| 27 | + { |
| 28 | + var resolved = ServerResolver.Resolve(serverManager, registry, server_name); |
| 29 | + if (resolved == null) |
| 30 | + return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; |
| 31 | + |
| 32 | + try |
| 33 | + { |
| 34 | + var xml = await resolved.Value.Service.GetPlanXmlByQueryHashAsync(query_hash); |
| 35 | + if (string.IsNullOrEmpty(xml)) |
| 36 | + return $"No plan found for query_hash '{query_hash}'. The query may have been evicted from the plan cache since the last collection."; |
| 37 | + |
| 38 | + return BuildAnalysisResult(xml, resolved.Value.ServerName, "query_stats", query_hash); |
| 39 | + } |
| 40 | + catch (Exception ex) |
| 41 | + { |
| 42 | + return McpHelpers.FormatError("analyze_query_plan", ex); |
| 43 | + } |
| 44 | + } |
| 45 | + |
| 46 | + [McpServerTool(Name = "analyze_procedure_plan"), Description( |
| 47 | + "Analyzes an execution plan from procedure stats by sql_handle. " + |
| 48 | + "Use after get_top_procedures_by_cpu to understand why a procedure is expensive. " + |
| 49 | + "Returns warnings, missing indexes, parameters, memory grants, and top operators.")] |
| 50 | + public static async Task<string> AnalyzeProcedurePlan( |
| 51 | + ServerManager serverManager, |
| 52 | + DatabaseServiceRegistry registry, |
| 53 | + [Description("The sql_handle value from get_top_procedures_by_cpu.")] string sql_handle, |
| 54 | + [Description("Server name or display name.")] string? server_name = null) |
| 55 | + { |
| 56 | + var resolved = ServerResolver.Resolve(serverManager, registry, server_name); |
| 57 | + if (resolved == null) |
| 58 | + return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; |
| 59 | + |
| 60 | + try |
| 61 | + { |
| 62 | + var xml = await resolved.Value.Service.GetProcedurePlanXmlBySqlHandleAsync(sql_handle); |
| 63 | + if (string.IsNullOrEmpty(xml)) |
| 64 | + return $"No plan found for sql_handle '{sql_handle}'. The procedure may have been evicted from the plan cache since the last collection."; |
| 65 | + |
| 66 | + return BuildAnalysisResult(xml, resolved.Value.ServerName, "procedure_stats", sql_handle); |
| 67 | + } |
| 68 | + catch (Exception ex) |
| 69 | + { |
| 70 | + return McpHelpers.FormatError("analyze_procedure_plan", ex); |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + [McpServerTool(Name = "analyze_query_store_plan"), Description( |
| 75 | + "Analyzes an execution plan from Query Store by database name and query ID. " + |
| 76 | + "Use after get_query_store_top to understand why a query is expensive. " + |
| 77 | + "Returns warnings, missing indexes, parameters, memory grants, and top operators.")] |
| 78 | + public static async Task<string> AnalyzeQueryStorePlan( |
| 79 | + ServerManager serverManager, |
| 80 | + DatabaseServiceRegistry registry, |
| 81 | + [Description("The database_name from get_query_store_top.")] string database_name, |
| 82 | + [Description("The query_id from get_query_store_top.")] long query_id, |
| 83 | + [Description("Server name or display name.")] string? server_name = null) |
| 84 | + { |
| 85 | + var resolved = ServerResolver.Resolve(serverManager, registry, server_name); |
| 86 | + if (resolved == null) |
| 87 | + return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; |
| 88 | + |
| 89 | + try |
| 90 | + { |
| 91 | + var xml = await resolved.Value.Service.GetQueryStorePlanXmlAsync(database_name, query_id); |
| 92 | + if (string.IsNullOrEmpty(xml)) |
| 93 | + return $"No plan found for query_id {query_id} in database '{database_name}'. Query Store may not be enabled or the query may have been purged."; |
| 94 | + |
| 95 | + return BuildAnalysisResult(xml, resolved.Value.ServerName, "query_store", $"{database_name}:{query_id}"); |
| 96 | + } |
| 97 | + catch (Exception ex) |
| 98 | + { |
| 99 | + return McpHelpers.FormatError("analyze_query_store_plan", ex); |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + [McpServerTool(Name = "analyze_plan_xml"), Description( |
| 104 | + "Analyzes raw showplan XML directly. Use when you have plan XML from any source " + |
| 105 | + "(clipboard, file, another tool). Returns warnings, missing indexes, parameters, " + |
| 106 | + "memory grants, and top operators.")] |
| 107 | + public static string AnalyzePlanXml( |
| 108 | + [Description("Raw showplan XML content.")] string plan_xml) |
| 109 | + { |
| 110 | + if (string.IsNullOrWhiteSpace(plan_xml)) |
| 111 | + return "No plan XML provided."; |
| 112 | + |
| 113 | + try |
| 114 | + { |
| 115 | + return BuildAnalysisResult(plan_xml, null, "xml", null); |
| 116 | + } |
| 117 | + catch (Exception ex) |
| 118 | + { |
| 119 | + return McpHelpers.FormatError("analyze_plan_xml", ex); |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + [McpServerTool(Name = "get_plan_xml"), Description( |
| 124 | + "Returns the raw showplan XML for a query identified by query_hash. " + |
| 125 | + "Use when you need to inspect plan details not captured in the structured analysis. " + |
| 126 | + "Truncated at 500KB.")] |
| 127 | + public static async Task<string> GetPlanXml( |
| 128 | + ServerManager serverManager, |
| 129 | + DatabaseServiceRegistry registry, |
| 130 | + [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, |
| 131 | + [Description("Server name or display name.")] string? server_name = null) |
| 132 | + { |
| 133 | + var resolved = ServerResolver.Resolve(serverManager, registry, server_name); |
| 134 | + if (resolved == null) |
| 135 | + return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; |
| 136 | + |
| 137 | + try |
| 138 | + { |
| 139 | + var xml = await resolved.Value.Service.GetPlanXmlByQueryHashAsync(query_hash); |
| 140 | + if (string.IsNullOrEmpty(xml)) |
| 141 | + return $"No plan found for query_hash '{query_hash}'."; |
| 142 | + |
| 143 | + return McpHelpers.Truncate(xml, 512_000) ?? "No plan XML available."; |
| 144 | + } |
| 145 | + catch (Exception ex) |
| 146 | + { |
| 147 | + return McpHelpers.FormatError("get_plan_xml", ex); |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + /// <summary> |
| 152 | + /// Parses plan XML, runs the analyzer, and builds a structured JSON result. |
| 153 | + /// </summary> |
| 154 | + private static string BuildAnalysisResult(string xml, string? serverName, string source, string? identifier) |
| 155 | + { |
| 156 | + var plan = ShowPlanParser.Parse(xml); |
| 157 | + PlanAnalyzer.Analyze(plan); |
| 158 | + |
| 159 | + var statements = plan.Batches |
| 160 | + .SelectMany(b => b.Statements) |
| 161 | + .Where(s => s.RootNode != null) |
| 162 | + .Select(s => |
| 163 | + { |
| 164 | + var allNodes = new List<PlanNode>(); |
| 165 | + CollectNodes(s.RootNode!, allNodes); |
| 166 | + |
| 167 | + var nodeWarnings = allNodes |
| 168 | + .SelectMany(n => n.Warnings) |
| 169 | + .ToList(); |
| 170 | + var stmtWarnings = s.PlanWarnings; |
| 171 | + var allWarnings = stmtWarnings.Concat(nodeWarnings).ToList(); |
| 172 | + |
| 173 | + var hasActuals = allNodes.Any(n => n.HasActualStats); |
| 174 | + var topOps = (hasActuals |
| 175 | + ? allNodes.OrderByDescending(n => n.ActualElapsedMs) |
| 176 | + : allNodes.OrderByDescending(n => n.CostPercent)) |
| 177 | + .Take(10) |
| 178 | + .Select(n => new |
| 179 | + { |
| 180 | + node_id = n.NodeId, |
| 181 | + physical_op = n.PhysicalOp, |
| 182 | + logical_op = n.LogicalOp, |
| 183 | + cost_percent = n.CostPercent, |
| 184 | + estimated_rows = n.EstimateRows, |
| 185 | + actual_rows = n.HasActualStats ? n.ActualRows : (long?)null, |
| 186 | + actual_elapsed_ms = n.HasActualStats ? n.ActualElapsedMs : (long?)null, |
| 187 | + actual_cpu_ms = n.HasActualStats ? n.ActualCPUMs : (long?)null, |
| 188 | + logical_reads = n.HasActualStats ? n.ActualLogicalReads : (long?)null, |
| 189 | + object_name = n.ObjectName, |
| 190 | + index_name = n.IndexName, |
| 191 | + predicate = McpHelpers.Truncate(n.Predicate, 500), |
| 192 | + seek_predicates = McpHelpers.Truncate(n.SeekPredicates, 500), |
| 193 | + warning_count = n.Warnings.Count |
| 194 | + }); |
| 195 | + |
| 196 | + return new |
| 197 | + { |
| 198 | + statement_text = McpHelpers.Truncate(s.StatementText, 2000), |
| 199 | + statement_type = s.StatementType, |
| 200 | + estimated_cost = Math.Round(s.StatementSubTreeCost, 4), |
| 201 | + dop = s.DegreeOfParallelism, |
| 202 | + serial_reason = s.NonParallelPlanReason, |
| 203 | + compile_cpu_ms = s.CompileCPUMs, |
| 204 | + compile_memory_kb = s.CompileMemoryKB, |
| 205 | + cardinality_model = s.CardinalityEstimationModelVersion, |
| 206 | + query_hash = s.QueryHash, |
| 207 | + query_plan_hash = s.QueryPlanHash, |
| 208 | + has_actual_stats = hasActuals, |
| 209 | + warnings = allWarnings.Select(w => new |
| 210 | + { |
| 211 | + severity = w.Severity.ToString(), |
| 212 | + type = w.WarningType, |
| 213 | + message = w.Message |
| 214 | + }), |
| 215 | + warning_count = allWarnings.Count, |
| 216 | + critical_count = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical), |
| 217 | + missing_indexes = s.MissingIndexes.Select(idx => new |
| 218 | + { |
| 219 | + table = $"{idx.Schema}.{idx.Table}", |
| 220 | + database = idx.Database, |
| 221 | + impact = idx.Impact, |
| 222 | + equality_columns = idx.EqualityColumns, |
| 223 | + inequality_columns = idx.InequalityColumns, |
| 224 | + include_columns = idx.IncludeColumns, |
| 225 | + create_statement = idx.CreateStatement |
| 226 | + }), |
| 227 | + parameters = s.Parameters.Select(p => new |
| 228 | + { |
| 229 | + name = p.Name, |
| 230 | + data_type = p.DataType, |
| 231 | + compiled_value = p.CompiledValue, |
| 232 | + runtime_value = p.RuntimeValue, |
| 233 | + sniffing_mismatch = p.CompiledValue != null && p.RuntimeValue != null |
| 234 | + && p.CompiledValue != p.RuntimeValue |
| 235 | + }), |
| 236 | + memory_grant = s.MemoryGrant == null ? null : new |
| 237 | + { |
| 238 | + requested_kb = s.MemoryGrant.RequestedMemoryKB, |
| 239 | + granted_kb = s.MemoryGrant.GrantedMemoryKB, |
| 240 | + max_used_kb = s.MemoryGrant.MaxUsedMemoryKB, |
| 241 | + desired_kb = s.MemoryGrant.DesiredMemoryKB, |
| 242 | + grant_wait_ms = s.MemoryGrant.GrantWaitTimeMs, |
| 243 | + feedback = s.MemoryGrant.IsMemoryGrantFeedbackAdjusted |
| 244 | + }, |
| 245 | + top_operators = topOps |
| 246 | + }; |
| 247 | + }) |
| 248 | + .ToList(); |
| 249 | + |
| 250 | + var totalWarnings = statements.Sum(s => s.warning_count); |
| 251 | + var totalCritical = statements.Sum(s => s.critical_count); |
| 252 | + var totalMissing = statements.Sum(s => s.missing_indexes.Count()); |
| 253 | + |
| 254 | + var result = new |
| 255 | + { |
| 256 | + server = serverName, |
| 257 | + source, |
| 258 | + identifier, |
| 259 | + statement_count = statements.Count, |
| 260 | + total_warnings = totalWarnings, |
| 261 | + total_critical = totalCritical, |
| 262 | + total_missing_indexes = totalMissing, |
| 263 | + statements |
| 264 | + }; |
| 265 | + |
| 266 | + return JsonSerializer.Serialize(result, McpHelpers.JsonOptions); |
| 267 | + } |
| 268 | + |
| 269 | + private static void CollectNodes(PlanNode node, List<PlanNode> nodes) |
| 270 | + { |
| 271 | + nodes.Add(node); |
| 272 | + foreach (var child in node.Children) |
| 273 | + CollectNodes(child, nodes); |
| 274 | + } |
| 275 | +} |
0 commit comments