diff --git a/screenshots/.gitkeep b/screenshots/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index 242600b..2b4afb1 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -16,6 +16,7 @@
using Avalonia.Controls.Templates;
using Avalonia.Platform.Storage;
using PlanViewer.App.Helpers;
+using PlanViewer.App.Mcp;
using PlanViewer.Core.Models;
using PlanViewer.Core.Services;
@@ -52,6 +53,7 @@ private static string FormatDuration(long ms)
public partial class PlanViewerControl : UserControl
{
+ private readonly string _mcpSessionId = Guid.NewGuid().ToString();
private ParsedPlan? _currentPlan;
private PlanStatement? _currentStatement;
private string? _queryText;
@@ -175,10 +177,36 @@ public void LoadPlan(string planXml, string label, string? queryText = null)
PopulateStatementsGrid(allStatements);
ShowStatementsPanel();
StatementsGrid.SelectedIndex = 0;
+
+ // Register with MCP session manager for AI tool access
+ // Count warnings from both statement-level PlanWarnings and all node Warnings
+ int warningCount = 0, criticalCount = 0;
+ foreach (var s in allStatements)
+ {
+ warningCount += s.PlanWarnings.Count;
+ criticalCount += s.PlanWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical);
+ if (s.RootNode != null)
+ CountNodeWarnings(s.RootNode, ref warningCount, ref criticalCount);
+ }
+
+ PlanSessionManager.Instance.Register(_mcpSessionId, new PlanSession
+ {
+ SessionId = _mcpSessionId,
+ Label = label,
+ Source = "file",
+ Plan = _currentPlan,
+ QueryText = queryText,
+ StatementCount = allStatements.Count,
+ HasActualStats = allStatements.Any(s => s.QueryTimeStats != null),
+ WarningCount = warningCount,
+ CriticalWarningCount = criticalCount,
+ MissingIndexCount = _currentPlan.AllMissingIndexes.Count
+ });
}
public void Clear()
{
+ PlanSessionManager.Instance.Unregister(_mcpSessionId);
PlanCanvas.Children.Clear();
_nodeBorderMap.Clear();
_currentPlan = null;
@@ -195,6 +223,14 @@ public void Clear()
ClosePropertiesPanel();
}
+ private static void CountNodeWarnings(PlanNode node, ref int total, ref int critical)
+ {
+ total += node.Warnings.Count;
+ critical += node.Warnings.Count(w => w.Severity == PlanWarningSeverity.Critical);
+ foreach (var child in node.Children)
+ CountNodeWarnings(child, ref total, ref critical);
+ }
+
private void RenderStatement(PlanStatement statement)
{
_currentStatement = statement;
diff --git a/src/PlanViewer.App/MainWindow.axaml b/src/PlanViewer.App/MainWindow.axaml
index 64073a0..f6d1309 100644
--- a/src/PlanViewer.App/MainWindow.axaml
+++ b/src/PlanViewer.App/MainWindow.axaml
@@ -26,6 +26,8 @@
diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs
index b848b93..beddd21 100644
--- a/src/PlanViewer.App/MainWindow.axaml.cs
+++ b/src/PlanViewer.App/MainWindow.axaml.cs
@@ -18,6 +18,7 @@
using PlanViewer.App.Services;
using PlanViewer.Core.Interfaces;
using PlanViewer.Core.Models;
+using PlanViewer.App.Mcp;
using PlanViewer.Core.Output;
using PlanViewer.Core.Services;
@@ -30,6 +31,8 @@ public partial class MainWindow : Window
private readonly ICredentialService _credentialService;
private readonly ConnectionStore _connectionStore;
private readonly CancellationTokenSource _pipeCts = new();
+ private McpHostService? _mcpHost;
+ private CancellationTokenSource? _mcpCts;
private int _queryCounter;
public MainWindow()
@@ -95,6 +98,9 @@ public MainWindow()
// Open with a query editor so toolbar buttons are visible on startup
NewQuery_Click(this, new RoutedEventArgs());
}
+
+ // Start MCP server if enabled in settings
+ StartMcpServer();
}
private void StartPipeServer()
@@ -136,9 +142,34 @@ await Dispatcher.UIThread.InvokeAsync(() =>
}, token);
}
- protected override void OnClosed(EventArgs e)
+ private void StartMcpServer()
+ {
+ var settings = McpSettings.Load();
+ if (!settings.Enabled)
+ {
+ McpStatusMenuItem.Header = "MCP Server: Off";
+ return;
+ }
+
+ _mcpCts = new CancellationTokenSource();
+ _mcpHost = new McpHostService(
+ PlanSessionManager.Instance, _connectionStore, _credentialService, settings.Port);
+
+ _ = _mcpHost.StartAsync(_mcpCts.Token);
+ McpStatusMenuItem.Header = $"MCP Server: Running (port {settings.Port})";
+ }
+
+ protected override async void OnClosed(EventArgs e)
{
_pipeCts.Cancel();
+
+ if (_mcpHost != null && _mcpCts != null)
+ {
+ _mcpCts.Cancel();
+ await _mcpHost.StopAsync(CancellationToken.None);
+ _mcpHost = null;
+ }
+
base.OnClosed(e);
}
diff --git a/src/PlanViewer.App/Mcp/McpHelpers.cs b/src/PlanViewer.App/Mcp/McpHelpers.cs
new file mode 100644
index 0000000..6bd2807
--- /dev/null
+++ b/src/PlanViewer.App/Mcp/McpHelpers.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Text.Json;
+
+namespace PlanViewer.App.Mcp;
+
+internal static class McpHelpers
+{
+ public const int MaxTop = 100;
+
+ public static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ public static string? Truncate(string? value, int maxLength)
+ {
+ if (value == null || value.Length <= maxLength) return value;
+ return value[..maxLength] + "... (truncated)";
+ }
+
+ public static string? ValidateTop(int top, string paramName = "top")
+ {
+ if (top <= 0)
+ return $"Invalid {paramName} value '{top}'. Must be a positive integer (1-{MaxTop}).";
+ if (top > MaxTop)
+ return $"{paramName} value '{top}' exceeds maximum of {MaxTop}. Use a smaller value.";
+ return null;
+ }
+
+ public static string FormatError(string operation, Exception ex) =>
+ $"Error during {operation}: {ex.Message}";
+}
diff --git a/src/PlanViewer.App/Mcp/McpHostService.cs b/src/PlanViewer.App/Mcp/McpHostService.cs
new file mode 100644
index 0000000..e8380b4
--- /dev/null
+++ b/src/PlanViewer.App/Mcp/McpHostService.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.AspNetCore;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Interfaces;
+
+namespace PlanViewer.App.Mcp;
+
+///
+/// Background service that hosts an MCP server over Streamable HTTP transport.
+/// Allows LLM clients to discover and call plan analysis tools via http://localhost:{port}.
+///
+public sealed class McpHostService : BackgroundService
+{
+ private readonly PlanSessionManager _sessionManager;
+ private readonly ConnectionStore _connectionStore;
+ private readonly ICredentialService _credentialService;
+ private readonly int _port;
+ private WebApplication? _app;
+
+ public McpHostService(
+ PlanSessionManager sessionManager,
+ ConnectionStore connectionStore,
+ ICredentialService credentialService,
+ int port)
+ {
+ _sessionManager = sessionManager;
+ _connectionStore = connectionStore;
+ _credentialService = credentialService;
+ _port = port;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ try
+ {
+ var builder = WebApplication.CreateBuilder();
+
+ builder.WebHost.ConfigureKestrel(options =>
+ {
+ options.ListenLocalhost(_port);
+ });
+
+ /* Suppress ASP.NET Core console logging */
+ builder.Logging.ClearProviders();
+ builder.Logging.SetMinimumLevel(LogLevel.Warning);
+
+ /* Register services that MCP tools need via dependency injection */
+ builder.Services.AddSingleton(_sessionManager);
+ builder.Services.AddSingleton(_connectionStore);
+ builder.Services.AddSingleton(_credentialService);
+
+ /* Register MCP server with all tool classes */
+ builder.Services
+ .AddMcpServer(options =>
+ {
+ options.ServerInfo = new()
+ {
+ Name = "SQLPerformanceStudio",
+ Version = "0.7.0"
+ };
+ options.ServerInstructions = McpInstructions.Text;
+ })
+ .WithHttpTransport()
+ .WithTools()
+ .WithTools();
+
+ _app = builder.Build();
+ _app.MapMcp();
+
+ await _app.RunAsync(stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ /* Normal shutdown */
+ }
+ catch (Exception)
+ {
+ /* MCP server failed to start — app continues without it */
+ }
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ if (_app != null)
+ {
+ await _app.StopAsync(cancellationToken);
+ await _app.DisposeAsync();
+ _app = null;
+ }
+
+ await base.StopAsync(cancellationToken);
+ }
+}
diff --git a/src/PlanViewer.App/Mcp/McpInstructions.cs b/src/PlanViewer.App/Mcp/McpInstructions.cs
new file mode 100644
index 0000000..981788a
--- /dev/null
+++ b/src/PlanViewer.App/Mcp/McpInstructions.cs
@@ -0,0 +1,122 @@
+namespace PlanViewer.App.Mcp;
+
+internal static class McpInstructions
+{
+ public const string Text = """
+ You are connected to SQL Performance Studio, a SQL Server execution plan analyzer.
+
+ ## CRITICAL: Read-Only Access
+
+ This MCP server provides READ-ONLY access to execution plans and Query Store data. You CANNOT:
+ - Execute arbitrary or ad-hoc SQL queries against any server
+ - Modify any server configuration or settings
+ - Write or modify any files
+ - Change application settings
+
+ The only server-side query this MCP can run is the built-in Query Store fetch query
+ (via `get_query_store_top`), which reads from `sys.query_store_*` DMVs. No other
+ queries can be executed.
+
+ ## How Plans Get Loaded
+
+ Plans are loaded into the application by the user through:
+ - Opening .sqlplan files (File > Open)
+ - Pasting XML from the clipboard (Ctrl+V or File > Paste Plan XML)
+ - Executing queries from the built-in query editor (estimated or actual plans)
+ - Fetching from Query Store (via the Query Store dialog in the app)
+
+ Each loaded plan gets a unique `session_id`. Use `list_plans` to see all loaded plans and their session IDs.
+
+ ## Tool Reference
+
+ ### Discovery
+ | Tool | Purpose |
+ |------|---------|
+ | `list_plans` | Lists all loaded plans with session IDs, labels, and summary stats |
+ | `get_connections` | Lists saved SQL Server connections (names only, no credentials) |
+
+ ### Plan Analysis (works on loaded plans)
+ | Tool | Purpose |
+ |------|---------|
+ | `analyze_plan` | Full JSON analysis: statements, warnings, operators, parameters, memory grants |
+ | `get_plan_summary` | Concise text summary for quick assessment |
+ | `get_plan_warnings` | Warnings only, filterable by severity |
+ | `get_missing_indexes` | Missing index suggestions with CREATE INDEX statements |
+ | `get_plan_parameters` | Parameter details with compiled vs runtime value comparison |
+ | `get_expensive_operators` | Top N costly operators by cost or actual elapsed time |
+ | `get_plan_xml` | Raw showplan XML |
+ | `compare_plans` | Side-by-side comparison of two plans |
+ | `get_repro_script` | Generates paste-ready T-SQL reproduction script |
+
+ ### Query Store (uses built-in read-only query only)
+ | Tool | Purpose |
+ |------|---------|
+ | `check_query_store` | Checks if Query Store is enabled on a database |
+ | `get_query_store_top` | Fetches top N plans from Query Store; auto-loads them for analysis |
+
+ ## Recommended Workflow
+
+ ### Analyzing loaded plans
+ 1. `list_plans` — see what plans are loaded in the application
+ 2. `analyze_plan` with the target session_id — get full analysis
+ 3. Focus on critical issues: `get_plan_warnings` with severity="Critical"
+ 4. Check for parameter sniffing: `get_plan_parameters`
+ 5. Review index suggestions: `get_missing_indexes`
+ 6. Find bottlenecks: `get_expensive_operators`
+ 7. For comparison: `compare_plans` with two session_ids
+ 8. For reproduction: `get_repro_script` to generate runnable T-SQL
+
+ ### Fetching from Query Store
+ 1. `get_connections` — see available saved connections
+ 2. `check_query_store` — verify Query Store is enabled on the target database
+ 3. `get_query_store_top` — fetch top queries (auto-loads plans into the app)
+ 4. Use plan analysis tools above with the returned session_ids
+
+ ## Analysis Rules
+
+ The analyzer runs 30 rules covering:
+ - Memory: Large grants, grant vs used ratio, spills to TempDB (sort, hash, exchange)
+ - Estimates: Row estimate mismatches (10x+), zero-row actuals, row goals
+ - Indexes: Missing index suggestions, key lookups, RID lookups, scan with residual predicates
+ - Parallelism: Serial plan reasons, thread skew, ineffective parallelism, DOP reporting
+ - Joins: Nested loop high executions, many-to-many merge join worktables
+ - Filters: Late filter operators, function-wrapped predicates
+ - Functions: Scalar UDF detection (T-SQL and CLR)
+ - Parameters: Compiled vs runtime values, sniffing issue detection, local variables
+ - Patterns: Leading wildcards, implicit conversions, OPTIMIZE FOR UNKNOWN, NOT IN with nullable columns
+ - Compilation: High compile CPU, compile memory exceeded, early abort
+ - Objects: Table variables, table-valued functions, CTE multiple references, spools
+
+ Warnings have three severity levels: Critical, Warning, Info.
+
+ ## Data Characteristics
+
+ - Plans can be **estimated** (no runtime stats) or **actual** (with row counts, elapsed time, I/O stats)
+ - Estimated plans show expected costs and row estimates only
+ - Actual plans additionally show per-thread runtime data, elapsed times, logical/physical reads, wait stats
+ - Memory grant analysis is only meaningful in actual plans (when GrantedKB > 0)
+ - Wait stats are only present in actual plans captured with SET STATISTICS XML ON
+ - Query Store plans are always estimated (plan cache snapshots)
+
+ ## MCP Client Configuration
+
+ For Claude Code, add to your MCP config:
+ ```json
+ {
+ "mcpServers": {
+ "sql-performance-studio": {
+ "type": "streamable-http",
+ "url": "http://localhost:5152/mcp"
+ }
+ }
+ }
+ ```
+
+ ## Key Limitations
+
+ - Plans must be loaded in the application before MCP tools can access them
+ - Query Store tools require a saved connection with valid credentials
+ - Plan XML in `get_plan_xml` is truncated at 500KB
+ - The full operator tree in `analyze_plan` can be large for complex queries
+ """;
+}
diff --git a/src/PlanViewer.App/Mcp/McpPlanTools.cs b/src/PlanViewer.App/Mcp/McpPlanTools.cs
new file mode 100644
index 0000000..8e80b85
--- /dev/null
+++ b/src/PlanViewer.App/Mcp/McpPlanTools.cs
@@ -0,0 +1,345 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Text.Json;
+using ModelContextProtocol.Server;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
+
+#pragma warning disable CA1707 // Identifiers should not contain underscores (MCP snake_case convention)
+
+namespace PlanViewer.App.Mcp;
+
+[McpServerToolType]
+public sealed class McpPlanTools
+{
+ [McpServerTool(Name = "list_plans")]
+ [Description("Lists all execution plans currently loaded in the application. Returns session IDs, labels, " +
+ "statement counts, warning counts, and source type. Use this first to discover available plans.")]
+ public static string ListPlans(PlanSessionManager sessionManager)
+ {
+ var sessions = sessionManager.GetAllSessions();
+ if (sessions.Count == 0)
+ return "No plans are currently loaded in the application. Open a .sqlplan file or paste plan XML to get started.";
+
+ return JsonSerializer.Serialize(new { plans = sessions }, McpHelpers.JsonOptions);
+ }
+
+ [McpServerTool(Name = "get_connections")]
+ [Description("Lists saved SQL Server connections. Returns server names and authentication types only — " +
+ "credentials are never exposed. Use connection names with Query Store tools.")]
+ public static string GetConnections(ConnectionStore connectionStore)
+ {
+ var connections = connectionStore.Load();
+ if (connections.Count == 0)
+ return "No saved connections. Add a connection in the application via the query editor toolbar.";
+
+ var safe = connections.Select(c => new
+ {
+ name = c.ServerName,
+ display_name = string.IsNullOrEmpty(c.DisplayName) ? c.ServerName : c.DisplayName,
+ auth_type = c.AuthenticationDisplay
+ });
+
+ return JsonSerializer.Serialize(new { connections = safe }, McpHelpers.JsonOptions);
+ }
+
+ [McpServerTool(Name = "analyze_plan")]
+ [Description("Returns the full JSON analysis result for a loaded plan. Includes all statements, warnings, " +
+ "missing indexes, parameters, operator tree, memory grants, and wait stats. " +
+ "This is the primary tool for understanding plan quality. Use list_plans first to get session_id values.")]
+ public static string AnalyzePlan(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ try
+ {
+ var result = ResultMapper.Map(session.Plan, session.Source);
+ return JsonSerializer.Serialize(result, McpHelpers.JsonOptions);
+ }
+ catch (Exception ex)
+ {
+ return McpHelpers.FormatError("analyze_plan", ex);
+ }
+ }
+
+ [McpServerTool(Name = "get_plan_summary")]
+ [Description("Returns a concise human-readable text summary of a loaded plan: statement count, warnings, " +
+ "missing indexes, cost, DOP, memory grants. Faster than analyze_plan for quick assessment.")]
+ public static string GetPlanSummary(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ try
+ {
+ var result = ResultMapper.Map(session.Plan, session.Source);
+ return TextFormatter.Format(result);
+ }
+ catch (Exception ex)
+ {
+ return McpHelpers.FormatError("get_plan_summary", ex);
+ }
+ }
+
+ [McpServerTool(Name = "get_plan_warnings")]
+ [Description("Returns only the warnings and analysis findings for a loaded plan. " +
+ "Optionally filter by severity (Critical, Warning, or Info).")]
+ public static string GetPlanWarnings(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id,
+ [Description("Optional severity filter: Critical, Warning, or Info.")] string? severity = null)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ try
+ {
+ var result = ResultMapper.Map(session.Plan, session.Source);
+ var allWarnings = result.Statements
+ .SelectMany(s => s.Warnings.Select(w => new
+ {
+ severity = w.Severity,
+ type = w.Type,
+ message = w.Message,
+ node_id = w.NodeId,
+ @operator = w.Operator,
+ statement = McpHelpers.Truncate(s.StatementText, 200)
+ }))
+ .Where(w => severity == null ||
+ w.severity.Equals(severity, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (allWarnings.Count == 0)
+ {
+ return severity != null
+ ? $"No {severity} warnings found in this plan."
+ : "No warnings found in this plan.";
+ }
+
+ return JsonSerializer.Serialize(new { warning_count = allWarnings.Count, warnings = allWarnings },
+ McpHelpers.JsonOptions);
+ }
+ catch (Exception ex)
+ {
+ return McpHelpers.FormatError("get_plan_warnings", ex);
+ }
+ }
+
+ [McpServerTool(Name = "get_missing_indexes")]
+ [Description("Returns missing index suggestions from a loaded plan with impact scores and " +
+ "ready-to-run CREATE INDEX statements.")]
+ public static string GetMissingIndexes(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ var indexes = session.Plan.AllMissingIndexes;
+ if (indexes.Count == 0)
+ return "No missing index suggestions in this plan.";
+
+ var result = indexes.Select(idx => new
+ {
+ database = idx.Database,
+ schema_name = idx.Schema,
+ table = idx.Table,
+ impact = idx.Impact,
+ equality_columns = idx.EqualityColumns,
+ inequality_columns = idx.InequalityColumns,
+ include_columns = idx.IncludeColumns,
+ create_statement = idx.CreateStatement
+ });
+
+ return JsonSerializer.Serialize(new { missing_index_count = indexes.Count, indexes = result },
+ McpHelpers.JsonOptions);
+ }
+
+ [McpServerTool(Name = "get_plan_parameters")]
+ [Description("Returns parameter details from a loaded plan including names, data types, " +
+ "compiled values, and runtime values. Highlights parameter sniffing when compiled and runtime values differ.")]
+ public static string GetPlanParameters(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ var statements = session.Plan.Batches
+ .SelectMany(b => b.Statements)
+ .Where(s => s.Parameters.Count > 0)
+ .Select(s => new
+ {
+ statement = McpHelpers.Truncate(s.StatementText, 200),
+ 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
+ })
+ })
+ .ToList();
+
+ if (statements.Count == 0)
+ return "No parameters found in this plan (ad-hoc query or local variables only).";
+
+ return JsonSerializer.Serialize(new { statements }, McpHelpers.JsonOptions);
+ }
+
+ [McpServerTool(Name = "get_expensive_operators")]
+ [Description("Returns the top N most expensive operators from a loaded plan, ranked by cost percentage " +
+ "or actual elapsed time (if available). Useful for quickly finding bottleneck operators.")]
+ public static string GetExpensiveOperators(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id,
+ [Description("Number of operators to return. Default 10.")] int top = 10)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ var topError = McpHelpers.ValidateTop(top);
+ if (topError != null) return topError;
+
+ var allNodes = new List<(PlanNode Node, string Statement)>();
+ foreach (var stmt in session.Plan.Batches.SelectMany(b => b.Statements))
+ {
+ if (stmt.RootNode == null) continue;
+ CollectNodes(stmt.RootNode, McpHelpers.Truncate(stmt.StatementText, 100) ?? "", allNodes);
+ }
+
+ var hasActuals = allNodes.Any(n => n.Node.ActualElapsedMs > 0);
+ var ranked = hasActuals
+ ? allNodes.OrderByDescending(n => n.Node.ActualElapsedMs)
+ : allNodes.OrderByDescending(n => n.Node.CostPercent);
+
+ var result = ranked.Take(top).Select(n => new
+ {
+ node_id = n.Node.NodeId,
+ physical_op = n.Node.PhysicalOp,
+ logical_op = n.Node.LogicalOp,
+ cost_percent = n.Node.CostPercent,
+ estimated_rows = n.Node.EstimateRows,
+ actual_rows = n.Node.ActualRows,
+ actual_elapsed_ms = n.Node.ActualElapsedMs,
+ actual_cpu_ms = n.Node.ActualCPUMs,
+ logical_reads = n.Node.ActualLogicalReads,
+ physical_reads = n.Node.ActualPhysicalReads,
+ object_name = n.Node.ObjectName,
+ statement = n.Statement
+ });
+
+ return JsonSerializer.Serialize(new { ranked_by = hasActuals ? "actual_elapsed_ms" : "cost_percent", operators = result },
+ McpHelpers.JsonOptions);
+ }
+
+ [McpServerTool(Name = "get_plan_xml")]
+ [Description("Returns the raw showplan XML for a loaded plan. Useful when you need to examine " +
+ "plan details not captured in the structured analysis. Truncated at 500KB.")]
+ public static string GetPlanXml(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ return McpHelpers.Truncate(session.Plan.RawXml, 512_000) ?? "No plan XML available.";
+ }
+
+ [McpServerTool(Name = "compare_plans")]
+ [Description("Compares two loaded plans side by side. Returns differences in cost, DOP, warnings, " +
+ "memory grants, runtime stats, and operator shapes.")]
+ public static string ComparePlans(
+ PlanSessionManager sessionManager,
+ [Description("Session ID of the first plan (from list_plans).")] string session_id_a,
+ [Description("Session ID of the second plan (from list_plans).")] string session_id_b)
+ {
+ var sessionA = sessionManager.GetSession(session_id_a);
+ if (sessionA == null)
+ return SessionNotFound(sessionManager, session_id_a);
+
+ var sessionB = sessionManager.GetSession(session_id_b);
+ if (sessionB == null)
+ return SessionNotFound(sessionManager, session_id_b);
+
+ try
+ {
+ var resultA = ResultMapper.Map(sessionA.Plan, sessionA.Source);
+ var resultB = ResultMapper.Map(sessionB.Plan, sessionB.Source);
+ return ComparisonFormatter.Compare(resultA, resultB, sessionA.Label, sessionB.Label);
+ }
+ catch (Exception ex)
+ {
+ return McpHelpers.FormatError("compare_plans", ex);
+ }
+ }
+
+ [McpServerTool(Name = "get_repro_script")]
+ [Description("Generates a paste-ready T-SQL reproduction script from a loaded plan. " +
+ "Extracts parameters, SET options, and database context into a runnable sp_executesql call.")]
+ public static string GetReproScript(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ try
+ {
+ var stmt = session.Plan.Batches
+ .SelectMany(b => b.Statements)
+ .FirstOrDefault(s => s.RootNode != null);
+
+ if (stmt == null)
+ return "No executable statement found in this plan.";
+
+ var queryText = session.QueryText ?? stmt.StatementText ?? "";
+
+ // Extract database from first operator node's DatabaseName property
+ string? databaseName = null;
+ if (stmt.RootNode?.DatabaseName != null)
+ databaseName = stmt.RootNode.DatabaseName;
+
+ return ReproScriptBuilder.BuildReproScript(
+ queryText, databaseName, session.Plan.RawXml, null);
+ }
+ catch (Exception ex)
+ {
+ return McpHelpers.FormatError("get_repro_script", ex);
+ }
+ }
+
+ private static string SessionNotFound(PlanSessionManager sessionManager, string sessionId)
+ {
+ var available = sessionManager.GetAllSessions();
+ if (available.Count == 0)
+ return "No plans are currently loaded in the application.";
+ return $"Session '{sessionId}' not found. Use list_plans to see available sessions.";
+ }
+
+ private static void CollectNodes(PlanNode node, string statement, List<(PlanNode, string)> nodes)
+ {
+ nodes.Add((node, statement));
+ foreach (var child in node.Children)
+ CollectNodes(child, statement, nodes);
+ }
+}
diff --git a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs
new file mode 100644
index 0000000..d37c5bb
--- /dev/null
+++ b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs
@@ -0,0 +1,204 @@
+using System;
+using System.ComponentModel;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using ModelContextProtocol.Server;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Interfaces;
+using PlanViewer.Core.Services;
+
+#pragma warning disable CA1707 // Identifiers should not contain underscores (MCP snake_case convention)
+
+namespace PlanViewer.App.Mcp;
+
+[McpServerToolType]
+public sealed class McpQueryStoreTools
+{
+ [McpServerTool(Name = "check_query_store")]
+ [Description("Checks whether Query Store is enabled and accessible on a database. " +
+ "Use this before calling get_query_store_top to verify the target database supports Query Store.")]
+ public static async Task CheckQueryStore(
+ ConnectionStore connectionStore,
+ ICredentialService credentialService,
+ [Description("Server name from get_connections.")] string connection_name,
+ [Description("Database name to check.")] string database)
+ {
+ try
+ {
+ var conn = FindConnection(connectionStore, connection_name);
+ if (conn == null)
+ return ConnectionNotFound(connectionStore, connection_name);
+
+ var connectionString = conn.GetConnectionString(credentialService, database);
+ var (enabled, state) = await QueryStoreService.CheckEnabledAsync(connectionString);
+
+ return JsonSerializer.Serialize(new
+ {
+ server = conn.ServerName,
+ database,
+ query_store_enabled = enabled,
+ state
+ }, McpHelpers.JsonOptions);
+ }
+ catch (Exception ex)
+ {
+ return McpHelpers.FormatError("check_query_store", ex);
+ }
+ }
+
+ [McpServerTool(Name = "get_query_store_top")]
+ [Description("Fetches the top N queries from Query Store ranked by the specified metric. " +
+ "Uses the application's built-in Query Store query — no arbitrary SQL is executed. " +
+ "Each fetched plan is automatically loaded into the application for further analysis " +
+ "with analyze_plan, get_plan_warnings, etc. Returns summary stats and session IDs.")]
+ public static async Task GetQueryStoreTop(
+ PlanSessionManager sessionManager,
+ ConnectionStore connectionStore,
+ ICredentialService credentialService,
+ [Description("Server name from get_connections.")] string connection_name,
+ [Description("Database name to query.")] string database,
+ [Description("Number of top queries to return. Default 10, max 50.")] int top = 10,
+ [Description("Ranking metric: cpu, avg-cpu, duration, avg-duration, reads, avg-reads, " +
+ "writes, avg-writes, physical-reads, avg-physical-reads, memory, avg-memory, executions. " +
+ "Default: cpu.")] string order_by = "cpu",
+ [Description("Hours of history to include. Default 24, max 168.")] int hours_back = 24)
+ {
+ try
+ {
+ var conn = FindConnection(connectionStore, connection_name);
+ if (conn == null)
+ return ConnectionNotFound(connectionStore, connection_name);
+
+ // Validate parameters
+ if (top < 1 || top > 50)
+ return "Invalid top value. Must be between 1 and 50.";
+ if (hours_back < 1 || hours_back > 168)
+ return "Invalid hours_back value. Must be between 1 and 168.";
+
+ var connectionString = conn.GetConnectionString(credentialService, database);
+
+ // Check Query Store is enabled first
+ var (enabled, state) = await QueryStoreService.CheckEnabledAsync(connectionString);
+ if (!enabled)
+ return $"Query Store is not enabled on [{database}]. State: {state ?? "unknown"}.";
+
+ // Fetch plans using the app's built-in query
+ var plans = await QueryStoreService.FetchTopPlansAsync(
+ connectionString, top, order_by, hours_back);
+
+ if (plans.Count == 0)
+ return $"No Query Store data found in [{database}] for the last {hours_back} hours.";
+
+ // Parse and register each plan with PlanSessionManager
+ var results = plans.Select(qsPlan =>
+ {
+ var sessionId = Guid.NewGuid().ToString();
+ var label = $"QS:{database} Q{qsPlan.QueryId} P{qsPlan.PlanId}";
+
+ try
+ {
+ var xml = qsPlan.PlanXml
+ .Replace("encoding=\"utf-16\"", "encoding=\"utf-8\"");
+ var parsed = ShowPlanParser.Parse(xml);
+ PlanAnalyzer.Analyze(parsed);
+
+ var allStatements = parsed.Batches.SelectMany(b => b.Statements).ToList();
+
+ sessionManager.Register(sessionId, new PlanSession
+ {
+ SessionId = sessionId,
+ Label = label,
+ Source = "query-store",
+ Plan = parsed,
+ QueryText = qsPlan.QueryText,
+ ConnectionInfo = conn.ServerName,
+ StatementCount = allStatements.Count,
+ HasActualStats = false, // Query Store plans are always estimated
+ WarningCount = allStatements.Sum(s => s.PlanWarnings.Count),
+ CriticalWarningCount = allStatements.Sum(s =>
+ s.PlanWarnings.Count(w => w.Severity == Core.Models.PlanWarningSeverity.Critical)),
+ MissingIndexCount = parsed.AllMissingIndexes.Count
+ });
+
+ return new
+ {
+ session_id = sessionId,
+ query_id = qsPlan.QueryId,
+ plan_id = qsPlan.PlanId,
+ label,
+ query_text = McpHelpers.Truncate(qsPlan.QueryText, 500),
+ executions = qsPlan.CountExecutions,
+ total_cpu_ms = qsPlan.TotalCpuTimeUs / 1000.0,
+ avg_cpu_ms = qsPlan.AvgCpuTimeUs / 1000.0,
+ total_duration_ms = qsPlan.TotalDurationUs / 1000.0,
+ avg_duration_ms = qsPlan.AvgDurationUs / 1000.0,
+ total_logical_reads = qsPlan.TotalLogicalIoReads,
+ avg_logical_reads = qsPlan.AvgLogicalIoReads,
+ warning_count = allStatements.Sum(s => s.PlanWarnings.Count),
+ missing_index_count = parsed.AllMissingIndexes.Count,
+ last_executed_utc = qsPlan.LastExecutedUtc.ToString("yyyy-MM-dd HH:mm:ss"),
+ loaded = true
+ };
+ }
+ catch
+ {
+ // Plan XML couldn't be parsed — return stats without loading
+ return new
+ {
+ session_id = (string)"",
+ query_id = qsPlan.QueryId,
+ plan_id = qsPlan.PlanId,
+ label,
+ query_text = McpHelpers.Truncate(qsPlan.QueryText, 500),
+ executions = qsPlan.CountExecutions,
+ total_cpu_ms = qsPlan.TotalCpuTimeUs / 1000.0,
+ avg_cpu_ms = qsPlan.AvgCpuTimeUs / 1000.0,
+ total_duration_ms = qsPlan.TotalDurationUs / 1000.0,
+ avg_duration_ms = qsPlan.AvgDurationUs / 1000.0,
+ total_logical_reads = qsPlan.TotalLogicalIoReads,
+ avg_logical_reads = qsPlan.AvgLogicalIoReads,
+ warning_count = 0,
+ missing_index_count = 0,
+ last_executed_utc = qsPlan.LastExecutedUtc.ToString("yyyy-MM-dd HH:mm:ss"),
+ loaded = false
+ };
+ }
+ }).ToList();
+
+ return JsonSerializer.Serialize(new
+ {
+ server = conn.ServerName,
+ database,
+ order_by,
+ hours_back,
+ plan_count = results.Count,
+ plans = results
+ }, McpHelpers.JsonOptions);
+ }
+ catch (Exception ex)
+ {
+ return McpHelpers.FormatError("get_query_store_top", ex);
+ }
+ }
+
+ private static Core.Models.ServerConnection? FindConnection(
+ ConnectionStore store, string name)
+ {
+ var connections = store.Load();
+ return connections.FirstOrDefault(c =>
+ c.ServerName.Equals(name, StringComparison.OrdinalIgnoreCase) ||
+ (!string.IsNullOrEmpty(c.DisplayName) &&
+ c.DisplayName.Equals(name, StringComparison.OrdinalIgnoreCase)));
+ }
+
+ private static string ConnectionNotFound(ConnectionStore store, string name)
+ {
+ var connections = store.Load();
+ if (connections.Count == 0)
+ return "No saved connections. Add a connection in the application via the query editor toolbar.";
+ var available = string.Join(", ", connections.Select(c =>
+ string.IsNullOrEmpty(c.DisplayName) ? c.ServerName : $"{c.DisplayName} ({c.ServerName})"));
+ return $"Connection '{name}' not found. Available: {available}";
+ }
+}
diff --git a/src/PlanViewer.App/Mcp/McpSettings.cs b/src/PlanViewer.App/Mcp/McpSettings.cs
new file mode 100644
index 0000000..7caf431
--- /dev/null
+++ b/src/PlanViewer.App/Mcp/McpSettings.cs
@@ -0,0 +1,37 @@
+using System;
+using System.IO;
+using System.Text.Json;
+
+namespace PlanViewer.App.Mcp;
+
+internal sealed class McpSettings
+{
+ public bool Enabled { get; set; }
+ public int Port { get; set; } = 5152;
+
+ public static McpSettings Load()
+ {
+ var path = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".planview", "settings.json");
+
+ if (!File.Exists(path))
+ return new McpSettings();
+
+ try
+ {
+ var json = File.ReadAllText(path);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ return new McpSettings
+ {
+ Enabled = root.TryGetProperty("mcp_enabled", out var e) && e.GetBoolean(),
+ Port = root.TryGetProperty("mcp_port", out var p) ? p.GetInt32() : 5152
+ };
+ }
+ catch
+ {
+ return new McpSettings();
+ }
+ }
+}
diff --git a/src/PlanViewer.App/Mcp/PlanSessionManager.cs b/src/PlanViewer.App/Mcp/PlanSessionManager.cs
new file mode 100644
index 0000000..65da26a
--- /dev/null
+++ b/src/PlanViewer.App/Mcp/PlanSessionManager.cs
@@ -0,0 +1,70 @@
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using PlanViewer.Core.Models;
+
+namespace PlanViewer.App.Mcp;
+
+///
+/// Thread-safe bridge between UI plan state and MCP tools.
+/// The UI registers/unregisters plans as tabs are opened/closed.
+/// MCP tools read plan data without touching the UI thread.
+///
+public sealed class PlanSessionManager
+{
+ public static PlanSessionManager Instance { get; } = new();
+
+ private readonly ConcurrentDictionary _sessions = new();
+
+ public void Register(string sessionId, PlanSession session) =>
+ _sessions[sessionId] = session;
+
+ public void Unregister(string sessionId) =>
+ _sessions.TryRemove(sessionId, out _);
+
+ public PlanSession? GetSession(string sessionId) =>
+ _sessions.TryGetValue(sessionId, out var session) ? session : null;
+
+ public IReadOnlyList GetAllSessions() =>
+ _sessions.Values.Select(s => new PlanSessionSummary
+ {
+ SessionId = s.SessionId,
+ Label = s.Label,
+ Source = s.Source,
+ StatementCount = s.StatementCount,
+ WarningCount = s.WarningCount,
+ CriticalWarningCount = s.CriticalWarningCount,
+ MissingIndexCount = s.MissingIndexCount,
+ HasActualStats = s.HasActualStats
+ }).ToList();
+}
+
+///
+/// Immutable snapshot of a loaded plan, safe for cross-thread reads by MCP tools.
+///
+public sealed class PlanSession
+{
+ public required string SessionId { get; init; }
+ public required string Label { get; init; }
+ public required string Source { get; init; }
+ public required ParsedPlan Plan { get; init; }
+ public string? QueryText { get; init; }
+ public string? ConnectionInfo { get; init; }
+ public int StatementCount { get; init; }
+ public bool HasActualStats { get; init; }
+ public int WarningCount { get; init; }
+ public int CriticalWarningCount { get; init; }
+ public int MissingIndexCount { get; init; }
+}
+
+public sealed class PlanSessionSummary
+{
+ public string SessionId { get; set; } = "";
+ public string Label { get; set; } = "";
+ public string Source { get; set; } = "";
+ public int StatementCount { get; set; }
+ public int WarningCount { get; set; }
+ public int CriticalWarningCount { get; set; }
+ public int MissingIndexCount { get; set; }
+ public bool HasActualStats { get; set; }
+}
diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj
index 01e9422..3ef9b21 100644
--- a/src/PlanViewer.App/PlanViewer.App.csproj
+++ b/src/PlanViewer.App/PlanViewer.App.csproj
@@ -27,6 +27,8 @@
+
+