diff --git a/Dashboard/Mcp/McpHostService.cs b/Dashboard/Mcp/McpHostService.cs index 1fab4cee..e00f699e 100644 --- a/Dashboard/Mcp/McpHostService.cs +++ b/Dashboard/Mcp/McpHostService.cs @@ -81,7 +81,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) .WithTools() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithTools(); _app = builder.Build(); _app.MapMcp(); diff --git a/Dashboard/Mcp/McpInstructions.cs b/Dashboard/Mcp/McpInstructions.cs index fc6c57c6..4f471f68 100644 --- a/Dashboard/Mcp/McpInstructions.cs +++ b/Dashboard/Mcp/McpInstructions.cs @@ -103,6 +103,25 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo |------|---------|----------------| | `get_running_jobs` | Currently running SQL Agent jobs with duration vs historical average/p95 | `server_name` | + ### Execution Plan Analysis Tools + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `analyze_query_plan` | Analyze plan from plan cache by query_hash | `query_hash` (required), `server_name` | + | `analyze_procedure_plan` | Analyze procedure plan by sql_handle | `sql_handle` (required), `server_name` | + | `analyze_query_store_plan` | Analyze plan from Query Store by database + query_id | `database_name` (required), `query_id` (required), `server_name` | + | `analyze_plan_xml` | Analyze raw showplan XML directly | `plan_xml` (required) | + | `get_plan_xml` | Get raw showplan XML by query_hash | `query_hash` (required), `server_name` | + + Plan analysis detects 31 performance anti-patterns including: + - Missing indexes with CREATE statements and impact scores + - Non-SARGable predicates, implicit conversions, data type mismatches + - Memory grant issues, spills to TempDB + - Parallelism problems: serial plan reasons, thread skew, ineffective parallelism + - Parameter sniffing (compiled vs runtime value mismatches) + - Expensive operators: key lookups, scans with residual predicates, eager spools + - Join issues: OR clauses, high nested loop executions, many-to-many merge joins + - UDF execution overhead, table variable usage, CTE multiple references + ## Recommended Workflow 1. **Start**: `list_servers` — see what's monitored and which servers are online @@ -117,6 +136,7 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo - I/O latency → `get_file_io_stats` → `get_file_io_trend` - TempDB pressure → `get_tempdb_trend` 5. **Query investigation**: After finding a problematic query via `get_top_queries_by_cpu`, `get_query_store_top`, or `get_expensive_queries`, use `get_query_trend` with its `query_hash` to see performance history + 6. **Plan analysis**: Use `analyze_query_plan` with the `query_hash` from step 5 to get detailed plan analysis with warnings, missing indexes, and optimization recommendations ## Wait Type to Tool Mapping diff --git a/Dashboard/Mcp/McpPlanTools.cs b/Dashboard/Mcp/McpPlanTools.cs new file mode 100644 index 00000000..36138d17 --- /dev/null +++ b/Dashboard/Mcp/McpPlanTools.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using PerformanceMonitorDashboard.Models; +using PerformanceMonitorDashboard.Services; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitorDashboard.Mcp; + +[McpServerToolType] +public sealed class McpPlanTools +{ + [McpServerTool(Name = "analyze_query_plan"), Description( + "Analyzes an execution plan from query stats (plan cache) by query_hash. " + + "Use after get_top_queries_by_cpu to understand why a query is expensive. " + + "Returns warnings, missing indexes, parameters, memory grants, and top operators.")] + public static async Task AnalyzeQueryPlan( + ServerManager serverManager, + DatabaseServiceRegistry registry, + [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, + [Description("Server name or display name.")] string? server_name = null) + { + var resolved = ServerResolver.Resolve(serverManager, registry, server_name); + if (resolved == null) + return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + + try + { + var xml = await resolved.Value.Service.GetPlanXmlByQueryHashAsync(query_hash); + if (string.IsNullOrEmpty(xml)) + return $"No plan found for query_hash '{query_hash}'. The query may have been evicted from the plan cache since the last collection."; + + return BuildAnalysisResult(xml, resolved.Value.ServerName, "query_stats", query_hash); + } + catch (Exception ex) + { + return McpHelpers.FormatError("analyze_query_plan", ex); + } + } + + [McpServerTool(Name = "analyze_procedure_plan"), Description( + "Analyzes an execution plan from procedure stats by sql_handle. " + + "Use after get_top_procedures_by_cpu to understand why a procedure is expensive. " + + "Returns warnings, missing indexes, parameters, memory grants, and top operators.")] + public static async Task AnalyzeProcedurePlan( + ServerManager serverManager, + DatabaseServiceRegistry registry, + [Description("The sql_handle value from get_top_procedures_by_cpu.")] string sql_handle, + [Description("Server name or display name.")] string? server_name = null) + { + var resolved = ServerResolver.Resolve(serverManager, registry, server_name); + if (resolved == null) + return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + + try + { + var xml = await resolved.Value.Service.GetProcedurePlanXmlBySqlHandleAsync(sql_handle); + if (string.IsNullOrEmpty(xml)) + return $"No plan found for sql_handle '{sql_handle}'. The procedure may have been evicted from the plan cache since the last collection."; + + return BuildAnalysisResult(xml, resolved.Value.ServerName, "procedure_stats", sql_handle); + } + catch (Exception ex) + { + return McpHelpers.FormatError("analyze_procedure_plan", ex); + } + } + + [McpServerTool(Name = "analyze_query_store_plan"), Description( + "Analyzes an execution plan from Query Store by database name and query ID. " + + "Use after get_query_store_top to understand why a query is expensive. " + + "Returns warnings, missing indexes, parameters, memory grants, and top operators.")] + public static async Task AnalyzeQueryStorePlan( + ServerManager serverManager, + DatabaseServiceRegistry registry, + [Description("The database_name from get_query_store_top.")] string database_name, + [Description("The query_id from get_query_store_top.")] long query_id, + [Description("Server name or display name.")] string? server_name = null) + { + var resolved = ServerResolver.Resolve(serverManager, registry, server_name); + if (resolved == null) + return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + + try + { + var xml = await resolved.Value.Service.GetQueryStorePlanXmlAsync(database_name, query_id); + if (string.IsNullOrEmpty(xml)) + 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."; + + return BuildAnalysisResult(xml, resolved.Value.ServerName, "query_store", $"{database_name}:{query_id}"); + } + catch (Exception ex) + { + return McpHelpers.FormatError("analyze_query_store_plan", ex); + } + } + + [McpServerTool(Name = "analyze_plan_xml"), Description( + "Analyzes raw showplan XML directly. Use when you have plan XML from any source " + + "(clipboard, file, another tool). Returns warnings, missing indexes, parameters, " + + "memory grants, and top operators.")] + public static string AnalyzePlanXml( + [Description("Raw showplan XML content.")] string plan_xml) + { + if (string.IsNullOrWhiteSpace(plan_xml)) + return "No plan XML provided."; + + try + { + return BuildAnalysisResult(plan_xml, null, "xml", null); + } + catch (Exception ex) + { + return McpHelpers.FormatError("analyze_plan_xml", ex); + } + } + + [McpServerTool(Name = "get_plan_xml"), Description( + "Returns the raw showplan XML for a query identified by query_hash. " + + "Use when you need to inspect plan details not captured in the structured analysis. " + + "Truncated at 500KB.")] + public static async Task GetPlanXml( + ServerManager serverManager, + DatabaseServiceRegistry registry, + [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, + [Description("Server name or display name.")] string? server_name = null) + { + var resolved = ServerResolver.Resolve(serverManager, registry, server_name); + if (resolved == null) + return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + + try + { + var xml = await resolved.Value.Service.GetPlanXmlByQueryHashAsync(query_hash); + if (string.IsNullOrEmpty(xml)) + return $"No plan found for query_hash '{query_hash}'."; + + return McpHelpers.Truncate(xml, 512_000) ?? "No plan XML available."; + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_plan_xml", ex); + } + } + + /// + /// Parses plan XML, runs the analyzer, and builds a structured JSON result. + /// + private static string BuildAnalysisResult(string xml, string? serverName, string source, string? identifier) + { + var plan = ShowPlanParser.Parse(xml); + PlanAnalyzer.Analyze(plan); + + var statements = plan.Batches + .SelectMany(b => b.Statements) + .Where(s => s.RootNode != null) + .Select(s => + { + var allNodes = new List(); + CollectNodes(s.RootNode!, allNodes); + + var nodeWarnings = allNodes + .SelectMany(n => n.Warnings) + .ToList(); + var stmtWarnings = s.PlanWarnings; + var allWarnings = stmtWarnings.Concat(nodeWarnings).ToList(); + + var hasActuals = allNodes.Any(n => n.HasActualStats); + var topOps = (hasActuals + ? allNodes.OrderByDescending(n => n.ActualElapsedMs) + : allNodes.OrderByDescending(n => n.CostPercent)) + .Take(10) + .Select(n => new + { + node_id = n.NodeId, + physical_op = n.PhysicalOp, + logical_op = n.LogicalOp, + cost_percent = n.CostPercent, + estimated_rows = n.EstimateRows, + actual_rows = n.HasActualStats ? n.ActualRows : (long?)null, + actual_elapsed_ms = n.HasActualStats ? n.ActualElapsedMs : (long?)null, + actual_cpu_ms = n.HasActualStats ? n.ActualCPUMs : (long?)null, + logical_reads = n.HasActualStats ? n.ActualLogicalReads : (long?)null, + object_name = n.ObjectName, + index_name = n.IndexName, + predicate = McpHelpers.Truncate(n.Predicate, 500), + seek_predicates = McpHelpers.Truncate(n.SeekPredicates, 500), + warning_count = n.Warnings.Count + }); + + return new + { + statement_text = McpHelpers.Truncate(s.StatementText, 2000), + statement_type = s.StatementType, + estimated_cost = Math.Round(s.StatementSubTreeCost, 4), + dop = s.DegreeOfParallelism, + serial_reason = s.NonParallelPlanReason, + compile_cpu_ms = s.CompileCPUMs, + compile_memory_kb = s.CompileMemoryKB, + cardinality_model = s.CardinalityEstimationModelVersion, + query_hash = s.QueryHash, + query_plan_hash = s.QueryPlanHash, + has_actual_stats = hasActuals, + warnings = allWarnings.Select(w => new + { + severity = w.Severity.ToString(), + type = w.WarningType, + message = w.Message + }), + warning_count = allWarnings.Count, + critical_count = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical), + missing_indexes = s.MissingIndexes.Select(idx => new + { + table = $"{idx.Schema}.{idx.Table}", + database = idx.Database, + impact = idx.Impact, + equality_columns = idx.EqualityColumns, + inequality_columns = idx.InequalityColumns, + include_columns = idx.IncludeColumns, + create_statement = idx.CreateStatement + }), + parameters = s.Parameters.Select(p => new + { + name = p.Name, + data_type = p.DataType, + compiled_value = p.CompiledValue, + runtime_value = p.RuntimeValue, + sniffing_mismatch = p.CompiledValue != null && p.RuntimeValue != null + && p.CompiledValue != p.RuntimeValue + }), + memory_grant = s.MemoryGrant == null ? null : new + { + requested_kb = s.MemoryGrant.RequestedMemoryKB, + granted_kb = s.MemoryGrant.GrantedMemoryKB, + max_used_kb = s.MemoryGrant.MaxUsedMemoryKB, + desired_kb = s.MemoryGrant.DesiredMemoryKB, + grant_wait_ms = s.MemoryGrant.GrantWaitTimeMs, + feedback = s.MemoryGrant.IsMemoryGrantFeedbackAdjusted + }, + top_operators = topOps + }; + }) + .ToList(); + + var totalWarnings = statements.Sum(s => s.warning_count); + var totalCritical = statements.Sum(s => s.critical_count); + var totalMissing = statements.Sum(s => s.missing_indexes.Count()); + + var result = new + { + server = serverName, + source, + identifier, + statement_count = statements.Count, + total_warnings = totalWarnings, + total_critical = totalCritical, + total_missing_indexes = totalMissing, + statements + }; + + return JsonSerializer.Serialize(result, McpHelpers.JsonOptions); + } + + private static void CollectNodes(PlanNode node, List nodes) + { + nodes.Add(node); + foreach (var child in node.Children) + CollectNodes(child, nodes); + } +} diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.cs b/Dashboard/Services/DatabaseService.QueryPerformance.cs index a3ca9555..3fd861e0 100644 --- a/Dashboard/Services/DatabaseService.QueryPerformance.cs +++ b/Dashboard/Services/DatabaseService.QueryPerformance.cs @@ -2445,6 +2445,58 @@ FROM collect.query_stats AS qs return result == DBNull.Value || result == null ? null : (string)result; } + /// + /// Fetches the most recent plan XML for a query identified by query_hash. + /// Used by MCP plan analysis tools. + /// + public async Task GetPlanXmlByQueryHashAsync(string queryHash) + { + await using var tc = await OpenThrottledConnectionAsync(); + var connection = tc.Connection; + + string query = @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + SELECT TOP (1) + CAST(DECOMPRESS(qs.query_plan_text) AS nvarchar(max)) + FROM collect.query_stats AS qs + WHERE qs.query_hash = CONVERT(binary(8), @queryHash, 1) + ORDER BY qs.last_execution_time DESC;"; + + using var command = new SqlCommand(query, connection); + command.CommandTimeout = 120; + command.Parameters.Add(new SqlParameter("@queryHash", SqlDbType.NVarChar, 20) { Value = queryHash }); + + var result = await command.ExecuteScalarAsync(); + return result == DBNull.Value || result == null ? null : (string)result; + } + + /// + /// Fetches the most recent plan XML for a procedure identified by sql_handle. + /// Used by MCP plan analysis tools. + /// + public async Task GetProcedurePlanXmlBySqlHandleAsync(string sqlHandle) + { + await using var tc = await OpenThrottledConnectionAsync(); + var connection = tc.Connection; + + string query = @" + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + + SELECT TOP (1) + CAST(DECOMPRESS(ps.query_plan_text) AS nvarchar(max)) + FROM collect.procedure_stats AS ps + WHERE ps.sql_handle = CONVERT(varbinary(64), @sqlHandle, 1) + ORDER BY ps.last_execution_time DESC;"; + + using var command = new SqlCommand(query, connection); + command.CommandTimeout = 120; + command.Parameters.Add(new SqlParameter("@sqlHandle", SqlDbType.NVarChar, 130) { Value = sqlHandle }); + + var result = await command.ExecuteScalarAsync(); + return result == DBNull.Value || result == null ? null : (string)result; + } + /// /// Gets execution count trends from query stats deltas, aggregated by collection time. /// diff --git a/Lite/Mcp/McpHostService.cs b/Lite/Mcp/McpHostService.cs index b1c9afbf..9e4972a4 100644 --- a/Lite/Mcp/McpHostService.cs +++ b/Lite/Mcp/McpHostService.cs @@ -71,7 +71,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) .WithTools() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithTools(); _app = builder.Build(); _app.MapMcp(); diff --git a/Lite/Mcp/McpInstructions.cs b/Lite/Mcp/McpInstructions.cs index 6042f90f..b7202f5f 100644 --- a/Lite/Mcp/McpInstructions.cs +++ b/Lite/Mcp/McpInstructions.cs @@ -107,6 +107,25 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo |------|---------|----------------| | `get_running_jobs` | Currently running SQL Agent jobs with duration vs historical average/p95 | `server_name` | + ### Execution Plan Analysis Tools + | Tool | Purpose | Key Parameters | + |------|---------|----------------| + | `analyze_query_plan` | Analyze plan from plan cache by query_hash | `query_hash` (required), `server_name` | + | `analyze_procedure_plan` | Analyze procedure plan by plan_handle | `plan_handle` (required), `server_name` | + | `analyze_query_store_plan` | Analyze plan from Query Store (fetches on-demand from SQL Server) | `database_name` (required), `plan_id` (required), `server_name` | + | `analyze_plan_xml` | Analyze raw showplan XML directly | `plan_xml` (required) | + | `get_plan_xml` | Get raw showplan XML by query_hash | `query_hash` (required), `server_name` | + + Plan analysis detects 31 performance anti-patterns including: + - Missing indexes with CREATE statements and impact scores + - Non-SARGable predicates, implicit conversions, data type mismatches + - Memory grant issues, spills to TempDB + - Parallelism problems: serial plan reasons, thread skew, ineffective parallelism + - Parameter sniffing (compiled vs runtime value mismatches) + - Expensive operators: key lookups, scans with residual predicates, eager spools + - Join issues: OR clauses, high nested loop executions, many-to-many merge joins + - UDF execution overhead, table variable usage, CTE multiple references + ## Recommended Workflow 1. **Start**: `list_servers` — see what's monitored and which servers are online @@ -120,6 +139,7 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo - I/O latency → `get_file_io_stats` → `get_file_io_trend` - TempDB pressure → `get_tempdb_trend` 5. **Query investigation**: After finding a problematic query via `get_top_queries_by_cpu`, use `get_query_trend` with its `query_hash` to see performance history + 6. **Plan analysis**: Use `analyze_query_plan` with the `query_hash` from step 5 to get detailed plan analysis with warnings, missing indexes, and optimization recommendations ## Wait Type to Tool Mapping diff --git a/Lite/Mcp/McpPlanTools.cs b/Lite/Mcp/McpPlanTools.cs new file mode 100644 index 00000000..c2d60133 --- /dev/null +++ b/Lite/Mcp/McpPlanTools.cs @@ -0,0 +1,282 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using PerformanceMonitorLite.Models; +using PerformanceMonitorLite.Services; + +#pragma warning disable CA1707 // MCP tools use snake_case naming convention + +namespace PerformanceMonitorLite.Mcp; + +[McpServerToolType] +public sealed class McpPlanTools +{ + [McpServerTool(Name = "analyze_query_plan"), Description( + "Analyzes an execution plan from the plan cache by query_hash. " + + "Use after get_top_queries_by_cpu to understand why a query is expensive. " + + "Returns warnings, missing indexes, parameters, memory grants, and top operators.")] + public static async Task AnalyzeQueryPlan( + LocalDataService dataService, + ServerManager serverManager, + [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, + [Description("Server name or display name.")] string? server_name = null) + { + var resolved = ServerResolver.Resolve(serverManager, server_name); + if (resolved == null) + return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + + try + { + var xml = await dataService.GetCachedQueryPlanAsync(resolved.Value.ServerId, query_hash); + if (string.IsNullOrEmpty(xml)) + return $"No plan found for query_hash '{query_hash}'. The query may have been evicted from the plan cache since the last collection."; + + return BuildAnalysisResult(xml, resolved.Value.ServerName, "query_stats", query_hash); + } + catch (Exception ex) + { + return McpHelpers.FormatError("analyze_query_plan", ex); + } + } + + [McpServerTool(Name = "analyze_procedure_plan"), Description( + "Analyzes an execution plan from procedure stats by plan_handle. " + + "Use after get_top_procedures_by_cpu to understand why a procedure is expensive. " + + "Returns warnings, missing indexes, parameters, memory grants, and top operators.")] + public static async Task AnalyzeProcedurePlan( + LocalDataService dataService, + ServerManager serverManager, + [Description("The plan_handle value from get_top_procedures_by_cpu.")] string plan_handle, + [Description("Server name or display name.")] string? server_name = null) + { + var resolved = ServerResolver.Resolve(serverManager, server_name); + if (resolved == null) + return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + + try + { + var xml = await dataService.GetCachedProcedurePlanAsync(resolved.Value.ServerId, plan_handle); + if (string.IsNullOrEmpty(xml)) + return $"No plan found for plan_handle '{plan_handle}'. The procedure may have been evicted from the plan cache since the last collection."; + + return BuildAnalysisResult(xml, resolved.Value.ServerName, "procedure_stats", plan_handle); + } + catch (Exception ex) + { + return McpHelpers.FormatError("analyze_procedure_plan", ex); + } + } + + [McpServerTool(Name = "analyze_query_store_plan"), Description( + "Analyzes an execution plan from Query Store by database name and plan ID. " + + "Fetches the plan on-demand from the monitored SQL Server instance. " + + "Use after get_query_store_top to understand why a query is expensive.")] + public static async Task AnalyzeQueryStorePlan( + ServerManager serverManager, + [Description("The database_name from get_query_store_top.")] string database_name, + [Description("The plan_id from get_query_store_top.")] long plan_id, + [Description("Server name or display name.")] string? server_name = null) + { + var resolved = ServerResolver.Resolve(serverManager, server_name); + if (resolved == null) + return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + + try + { + /* Find the server connection to build a connection string */ + var server = serverManager.GetEnabledServers().Find(s => + { + var storageName = RemoteCollectorService.GetServerNameForStorage(s); + return string.Equals(storageName, resolved.Value.ServerName, StringComparison.OrdinalIgnoreCase); + }); + + if (server == null) + return $"Could not find connection details for server '{resolved.Value.ServerName}'."; + + var connectionString = server.GetConnectionString(serverManager.CredentialService); + var xml = await LocalDataService.FetchQueryStorePlanAsync(connectionString, database_name, plan_id); + + if (string.IsNullOrEmpty(xml)) + return $"No plan found for plan_id {plan_id} in database '{database_name}'. Query Store may not be enabled or the plan may have been purged."; + + return BuildAnalysisResult(xml, resolved.Value.ServerName, "query_store", $"{database_name}:{plan_id}"); + } + catch (Exception ex) + { + return McpHelpers.FormatError("analyze_query_store_plan", ex); + } + } + + [McpServerTool(Name = "analyze_plan_xml"), Description( + "Analyzes raw showplan XML directly. Use when you have plan XML from any source " + + "(clipboard, file, another tool). Returns warnings, missing indexes, parameters, " + + "memory grants, and top operators.")] + public static string AnalyzePlanXml( + [Description("Raw showplan XML content.")] string plan_xml) + { + if (string.IsNullOrWhiteSpace(plan_xml)) + return "No plan XML provided."; + + try + { + return BuildAnalysisResult(plan_xml, null, "xml", null); + } + catch (Exception ex) + { + return McpHelpers.FormatError("analyze_plan_xml", ex); + } + } + + [McpServerTool(Name = "get_plan_xml"), Description( + "Returns the raw showplan XML for a query identified by query_hash. " + + "Use when you need to inspect plan details not captured in the structured analysis. " + + "Truncated at 500KB.")] + public static async Task GetPlanXml( + LocalDataService dataService, + ServerManager serverManager, + [Description("The query_hash value from get_top_queries_by_cpu.")] string query_hash, + [Description("Server name or display name.")] string? server_name = null) + { + var resolved = ServerResolver.Resolve(serverManager, server_name); + if (resolved == null) + return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + + try + { + var xml = await dataService.GetCachedQueryPlanAsync(resolved.Value.ServerId, query_hash); + if (string.IsNullOrEmpty(xml)) + return $"No plan found for query_hash '{query_hash}'."; + + return McpHelpers.Truncate(xml, 512_000) ?? "No plan XML available."; + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_plan_xml", ex); + } + } + + /// + /// Parses plan XML, runs the analyzer, and builds a structured JSON result. + /// + private static string BuildAnalysisResult(string xml, string? serverName, string source, string? identifier) + { + var plan = ShowPlanParser.Parse(xml); + PlanAnalyzer.Analyze(plan); + + var statements = plan.Batches + .SelectMany(b => b.Statements) + .Where(s => s.RootNode != null) + .Select(s => + { + var allNodes = new List(); + CollectNodes(s.RootNode!, allNodes); + + var nodeWarnings = allNodes + .SelectMany(n => n.Warnings) + .ToList(); + var stmtWarnings = s.PlanWarnings; + var allWarnings = stmtWarnings.Concat(nodeWarnings).ToList(); + + var hasActuals = allNodes.Any(n => n.HasActualStats); + var topOps = (hasActuals + ? allNodes.OrderByDescending(n => n.ActualElapsedMs) + : allNodes.OrderByDescending(n => n.CostPercent)) + .Take(10) + .Select(n => new + { + node_id = n.NodeId, + physical_op = n.PhysicalOp, + logical_op = n.LogicalOp, + cost_percent = n.CostPercent, + estimated_rows = n.EstimateRows, + actual_rows = n.HasActualStats ? n.ActualRows : (long?)null, + actual_elapsed_ms = n.HasActualStats ? n.ActualElapsedMs : (long?)null, + actual_cpu_ms = n.HasActualStats ? n.ActualCPUMs : (long?)null, + logical_reads = n.HasActualStats ? n.ActualLogicalReads : (long?)null, + object_name = n.ObjectName, + index_name = n.IndexName, + predicate = McpHelpers.Truncate(n.Predicate, 500), + seek_predicates = McpHelpers.Truncate(n.SeekPredicates, 500), + warning_count = n.Warnings.Count + }); + + return new + { + statement_text = McpHelpers.Truncate(s.StatementText, 2000), + statement_type = s.StatementType, + estimated_cost = Math.Round(s.StatementSubTreeCost, 4), + dop = s.DegreeOfParallelism, + serial_reason = s.NonParallelPlanReason, + compile_cpu_ms = s.CompileCPUMs, + compile_memory_kb = s.CompileMemoryKB, + cardinality_model = s.CardinalityEstimationModelVersion, + query_hash = s.QueryHash, + query_plan_hash = s.QueryPlanHash, + has_actual_stats = hasActuals, + warnings = allWarnings.Select(w => new + { + severity = w.Severity.ToString(), + type = w.WarningType, + message = w.Message + }), + warning_count = allWarnings.Count, + critical_count = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical), + missing_indexes = s.MissingIndexes.Select(idx => new + { + table = $"{idx.Schema}.{idx.Table}", + database = idx.Database, + impact = idx.Impact, + equality_columns = idx.EqualityColumns, + inequality_columns = idx.InequalityColumns, + include_columns = idx.IncludeColumns, + create_statement = idx.CreateStatement + }), + parameters = s.Parameters.Select(p => new + { + name = p.Name, + data_type = p.DataType, + compiled_value = p.CompiledValue, + runtime_value = p.RuntimeValue, + sniffing_mismatch = p.CompiledValue != null && p.RuntimeValue != null + && p.CompiledValue != p.RuntimeValue + }), + memory_grant = s.MemoryGrant == null ? null : new + { + requested_kb = s.MemoryGrant.RequestedMemoryKB, + granted_kb = s.MemoryGrant.GrantedMemoryKB, + max_used_kb = s.MemoryGrant.MaxUsedMemoryKB, + desired_kb = s.MemoryGrant.DesiredMemoryKB, + grant_wait_ms = s.MemoryGrant.GrantWaitTimeMs, + feedback = s.MemoryGrant.IsMemoryGrantFeedbackAdjusted + }, + top_operators = topOps + }; + }) + .ToList(); + + var totalWarnings = statements.Sum(s => s.warning_count); + var totalCritical = statements.Sum(s => s.critical_count); + var totalMissing = statements.Sum(s => s.missing_indexes.Count()); + + var result = new + { + server = serverName, + source, + identifier, + statement_count = statements.Count, + total_warnings = totalWarnings, + total_critical = totalCritical, + total_missing_indexes = totalMissing, + statements + }; + + return JsonSerializer.Serialize(result, McpHelpers.JsonOptions); + } + + private static void CollectNodes(PlanNode node, List nodes) + { + nodes.Add(node); + foreach (var child in node.Children) + CollectNodes(child, nodes); + } +}