From d8069a9bf8f09c837c6081349a58684e1cc7cd20 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 13 Mar 2026 07:46:41 -0500 Subject: [PATCH] Add execution plan analysis MCP tools to Dashboard and Lite Ports the 5 plan analysis tools from PerformanceStudio to both apps: - analyze_query_plan: Analyze cached plan by query_hash - analyze_procedure_plan: Analyze procedure plan by sql_handle/plan_handle - analyze_query_store_plan: Analyze Query Store plan (fetched on-demand from SQL Server) - analyze_plan_xml: Analyze raw showplan XML directly - get_plan_xml: Retrieve raw showplan XML by query_hash Uses ShowPlanParser + PlanAnalyzer (31 anti-pattern rules) to return structured JSON with warnings, missing indexes, parameters, memory grants, and top operators. Dashboard fetches plans from SQL Server PerformanceMonitor database. Lite fetches from DuckDB cache, with Query Store as on-demand SQL Server fallback. Tested end-to-end on both apps against SQL2022. Co-Authored-By: Claude Opus 4.6 --- Dashboard/Mcp/McpHostService.cs | 3 +- Dashboard/Mcp/McpInstructions.cs | 20 ++ Dashboard/Mcp/McpPlanTools.cs | 275 +++++++++++++++++ .../DatabaseService.QueryPerformance.cs | 52 ++++ Lite/Mcp/McpHostService.cs | 3 +- Lite/Mcp/McpInstructions.cs | 20 ++ Lite/Mcp/McpPlanTools.cs | 282 ++++++++++++++++++ 7 files changed, 653 insertions(+), 2 deletions(-) create mode 100644 Dashboard/Mcp/McpPlanTools.cs create mode 100644 Lite/Mcp/McpPlanTools.cs 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); + } +}