Skip to content

Commit c8e1738

Browse files
Merge pull request #542 from erikdarlingdata/feature/mcp-plan-tools
Add execution plan analysis MCP tools to Dashboard and Lite
2 parents 93948e4 + d8069a9 commit c8e1738

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)