Skip to content

Commit d8069a9

Browse files
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 <noreply@anthropic.com>
1 parent 93948e4 commit d8069a9

7 files changed

Lines changed: 653 additions & 2 deletions

File tree

Dashboard/Mcp/McpHostService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
8181
.WithTools<McpTempDbTools>()
8282
.WithTools<McpPerfmonTools>()
8383
.WithTools<McpAlertTools>()
84-
.WithTools<McpJobTools>();
84+
.WithTools<McpJobTools>()
85+
.WithTools<McpPlanTools>();
8586

8687
_app = builder.Build();
8788
_app.MapMcp();

Dashboard/Mcp/McpInstructions.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,25 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo
103103
|------|---------|----------------|
104104
| `get_running_jobs` | Currently running SQL Agent jobs with duration vs historical average/p95 | `server_name` |
105105
106+
### Execution Plan Analysis Tools
107+
| Tool | Purpose | Key Parameters |
108+
|------|---------|----------------|
109+
| `analyze_query_plan` | Analyze plan from plan cache by query_hash | `query_hash` (required), `server_name` |
110+
| `analyze_procedure_plan` | Analyze procedure plan by sql_handle | `sql_handle` (required), `server_name` |
111+
| `analyze_query_store_plan` | Analyze plan from Query Store by database + query_id | `database_name` (required), `query_id` (required), `server_name` |
112+
| `analyze_plan_xml` | Analyze raw showplan XML directly | `plan_xml` (required) |
113+
| `get_plan_xml` | Get raw showplan XML by query_hash | `query_hash` (required), `server_name` |
114+
115+
Plan analysis detects 31 performance anti-patterns including:
116+
- Missing indexes with CREATE statements and impact scores
117+
- Non-SARGable predicates, implicit conversions, data type mismatches
118+
- Memory grant issues, spills to TempDB
119+
- Parallelism problems: serial plan reasons, thread skew, ineffective parallelism
120+
- Parameter sniffing (compiled vs runtime value mismatches)
121+
- Expensive operators: key lookups, scans with residual predicates, eager spools
122+
- Join issues: OR clauses, high nested loop executions, many-to-many merge joins
123+
- UDF execution overhead, table variable usage, CTE multiple references
124+
106125
## Recommended Workflow
107126
108127
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
117136
- I/O latency → `get_file_io_stats` → `get_file_io_trend`
118137
- TempDB pressure → `get_tempdb_trend`
119138
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
139+
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
120140
121141
## Wait Type to Tool Mapping
122142

Dashboard/Mcp/McpPlanTools.cs

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
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+
}

Dashboard/Services/DatabaseService.QueryPerformance.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2445,6 +2445,58 @@ FROM collect.query_stats AS qs
24452445
return result == DBNull.Value || result == null ? null : (string)result;
24462446
}
24472447

2448+
/// <summary>
2449+
/// Fetches the most recent plan XML for a query identified by query_hash.
2450+
/// Used by MCP plan analysis tools.
2451+
/// </summary>
2452+
public async Task<string?> GetPlanXmlByQueryHashAsync(string queryHash)
2453+
{
2454+
await using var tc = await OpenThrottledConnectionAsync();
2455+
var connection = tc.Connection;
2456+
2457+
string query = @"
2458+
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
2459+
2460+
SELECT TOP (1)
2461+
CAST(DECOMPRESS(qs.query_plan_text) AS nvarchar(max))
2462+
FROM collect.query_stats AS qs
2463+
WHERE qs.query_hash = CONVERT(binary(8), @queryHash, 1)
2464+
ORDER BY qs.last_execution_time DESC;";
2465+
2466+
using var command = new SqlCommand(query, connection);
2467+
command.CommandTimeout = 120;
2468+
command.Parameters.Add(new SqlParameter("@queryHash", SqlDbType.NVarChar, 20) { Value = queryHash });
2469+
2470+
var result = await command.ExecuteScalarAsync();
2471+
return result == DBNull.Value || result == null ? null : (string)result;
2472+
}
2473+
2474+
/// <summary>
2475+
/// Fetches the most recent plan XML for a procedure identified by sql_handle.
2476+
/// Used by MCP plan analysis tools.
2477+
/// </summary>
2478+
public async Task<string?> GetProcedurePlanXmlBySqlHandleAsync(string sqlHandle)
2479+
{
2480+
await using var tc = await OpenThrottledConnectionAsync();
2481+
var connection = tc.Connection;
2482+
2483+
string query = @"
2484+
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
2485+
2486+
SELECT TOP (1)
2487+
CAST(DECOMPRESS(ps.query_plan_text) AS nvarchar(max))
2488+
FROM collect.procedure_stats AS ps
2489+
WHERE ps.sql_handle = CONVERT(varbinary(64), @sqlHandle, 1)
2490+
ORDER BY ps.last_execution_time DESC;";
2491+
2492+
using var command = new SqlCommand(query, connection);
2493+
command.CommandTimeout = 120;
2494+
command.Parameters.Add(new SqlParameter("@sqlHandle", SqlDbType.NVarChar, 130) { Value = sqlHandle });
2495+
2496+
var result = await command.ExecuteScalarAsync();
2497+
return result == DBNull.Value || result == null ? null : (string)result;
2498+
}
2499+
24482500
/// <summary>
24492501
/// Gets execution count trends from query stats deltas, aggregated by collection time.
24502502
/// </summary>

Lite/Mcp/McpHostService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
7171
.WithTools<McpTempDbTools>()
7272
.WithTools<McpPerfmonTools>()
7373
.WithTools<McpAlertTools>()
74-
.WithTools<McpJobTools>();
74+
.WithTools<McpJobTools>()
75+
.WithTools<McpPlanTools>();
7576

7677
_app = builder.Build();
7778
_app.MapMcp();

0 commit comments

Comments
 (0)