From 152e51b335c2446882a76a38c853351a9c3115c5 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:58:12 -0500 Subject: [PATCH 1/3] Add MCP server for AI-assisted plan analysis (#6) Opt-in MCP server (disabled by default) that exposes loaded execution plans and Query Store data to AI assistants via read-only tools over Streamable HTTP. 13 tools total: - Discovery: list_plans, get_connections - Plan analysis: analyze_plan, get_plan_summary, get_plan_warnings, get_missing_indexes, get_plan_parameters, get_expensive_operators, get_plan_xml, compare_plans, get_repro_script - Query Store: check_query_store, get_query_store_top (uses built-in read-only DMV query only, no arbitrary SQL) Enable via ~/.planview/settings.json: {"mcp_enabled": true, "mcp_port": 5152} Co-authored-by: Claude Opus 4.6 --- screenshots/.gitkeep | 0 .../Controls/PlanViewerControl.axaml.cs | 36 ++ src/PlanViewer.App/MainWindow.axaml | 2 + src/PlanViewer.App/MainWindow.axaml.cs | 33 +- src/PlanViewer.App/Mcp/McpHelpers.cs | 29 ++ src/PlanViewer.App/Mcp/McpHostService.cs | 100 +++++ src/PlanViewer.App/Mcp/McpInstructions.cs | 122 +++++++ src/PlanViewer.App/Mcp/McpPlanTools.cs | 345 ++++++++++++++++++ src/PlanViewer.App/Mcp/McpQueryStoreTools.cs | 204 +++++++++++ src/PlanViewer.App/Mcp/McpSettings.cs | 37 ++ src/PlanViewer.App/Mcp/PlanSessionManager.cs | 70 ++++ src/PlanViewer.App/PlanViewer.App.csproj | 2 + 12 files changed, 979 insertions(+), 1 deletion(-) create mode 100644 screenshots/.gitkeep create mode 100644 src/PlanViewer.App/Mcp/McpHelpers.cs create mode 100644 src/PlanViewer.App/Mcp/McpHostService.cs create mode 100644 src/PlanViewer.App/Mcp/McpInstructions.cs create mode 100644 src/PlanViewer.App/Mcp/McpPlanTools.cs create mode 100644 src/PlanViewer.App/Mcp/McpQueryStoreTools.cs create mode 100644 src/PlanViewer.App/Mcp/McpSettings.cs create mode 100644 src/PlanViewer.App/Mcp/PlanSessionManager.cs 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 @@ + + From 1bed5fa85dab94a1f50857f089cf8bdc3f36c9fd Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:55:07 -0500 Subject: [PATCH 2/3] Rename to Performance Studio, add MCP docs and settings toggle (#7) - Rename "SQL Performance Studio" to "Performance Studio" across all user-facing strings: title bar, about dialog, help menu, app name, macOS bundle, csproj product, MCP server name, docs - Add MCP section to README with setup instructions, tool reference, and example questions - Add MCP enable/port toggle to About screen (interim until dedicated settings UI) - Fix MCP endpoint URL from /mcp to / in docs and instructions - Fix MCP server name from sql-performance-studio to performance-studio Co-authored-by: Claude Opus 4.6 --- CONTRIBUTING.md | 4 +- README.md | 57 ++++++++++++++++--- SECURITY.md | 4 +- src/PlanViewer.App/AboutWindow.axaml | 26 +++++++-- src/PlanViewer.App/AboutWindow.axaml.cs | 31 +++++++++- src/PlanViewer.App/App.axaml | 4 +- .../Controls/QuerySessionControl.axaml.cs | 4 +- src/PlanViewer.App/Info.plist | 4 +- src/PlanViewer.App/MainWindow.axaml | 6 +- src/PlanViewer.App/MainWindow.axaml.cs | 6 +- src/PlanViewer.App/Mcp/McpHostService.cs | 2 +- src/PlanViewer.App/Mcp/McpInstructions.cs | 6 +- src/PlanViewer.App/PlanViewer.App.csproj | 2 +- 13 files changed, 122 insertions(+), 34 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f8390e..ee7999f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to SQL Performance Studio +# Contributing to Performance Studio -Thank you for your interest in contributing to SQL Performance Studio! This guide will help you get started. +Thank you for your interest in contributing to Performance Studio! This guide will help you get started. ## Reporting Issues diff --git a/README.md b/README.md index f8f3da1..b6d582e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# SQL Performance Studio +# Performance Studio -A cross-platform SQL Server execution plan analyzer. Parses `.sqlplan` XML, identifies performance problems, suggests missing indexes, and provides actionable warnings — from the command line or a desktop GUI. +A cross-platform SQL Server execution plan analyzer with built-in MCP server for AI-assisted analysis. Parses `.sqlplan` XML, identifies performance problems, suggests missing indexes, and provides actionable warnings — from the command line or a desktop GUI. Built for developers and DBAs who want fast, automated plan analysis without clicking through SSMS. @@ -139,7 +139,7 @@ planview analyze ./queries/ --server sql2022 --database StackOverflow2013 \ ``` Batch mode produces three files per query: -- `query_name.sqlplan` — the raw execution plan XML (openable in SSMS or the SQL Performance Studio GUI) +- `query_name.sqlplan` — the raw execution plan XML (openable in SSMS or the Performance Studio GUI) - `query_name.analysis.json` — structured analysis with warnings, missing indexes, and operator tree - `query_name.analysis.txt` — human-readable text report @@ -240,6 +240,7 @@ Features: - **Copy Repro Script** — extracts parameters, SET options, and query text into a runnable `sp_executesql` script - **Get Actual Plan** — connect to a server and re-execute the query to capture runtime stats - **Query Store Analysis** — connect to a server and analyze top queries by CPU, duration, or reads +- **MCP Server** — built-in Model Context Protocol server for AI-assisted plan analysis (opt-in) - Dark theme ```bash @@ -248,14 +249,14 @@ dotnet run --project src/PlanViewer.App ## SSMS Extension -A VSIX extension that adds **"Open in SQL Performance Studio"** to the execution plan right-click context menu in SSMS 18-22. +A VSIX extension that adds **"Open in Performance Studio"** to the execution plan right-click context menu in SSMS 18-22. ### How it works 1. Right-click on any execution plan in SSMS -2. Click "Open in SQL Performance Studio" +2. Click "Open in Performance Studio" 3. The extension extracts the plan XML via reflection and saves it to a temp file -4. SQL Performance Studio opens with the plan loaded +4. Performance Studio opens with the plan loaded ### Installation @@ -267,13 +268,55 @@ A VSIX extension that adds **"Open in SQL Performance Studio"** to the execution ### First run -On first use, if SQL Performance Studio isn't found automatically, the extension will prompt you to locate `PlanViewer.App.exe`. The path is saved to the registry (`HKCU\SOFTWARE\DarlingData\SQLPerformanceStudio\InstallPath`) so you only need to do this once. +On first use, if Performance Studio isn't found automatically, the extension will prompt you to locate `PlanViewer.App.exe`. The path is saved to the registry (`HKCU\SOFTWARE\DarlingData\SQLPerformanceStudio\InstallPath`) so you only need to do this once. The extension searches for the app in this order: 1. Registry key (set automatically after first browse) 2. System PATH 3. Common install locations (`%LOCALAPPDATA%\Programs\SQLPerformanceStudio\`, `Program Files`, etc.) +## MCP Server (LLM Integration) + +The desktop GUI includes an embedded [Model Context Protocol](https://modelcontextprotocol.io) server that exposes loaded execution plans and Query Store data to LLM clients like Claude Code and Cursor. + +### Setup + +1. Enable the MCP server in `~/.planview/settings.json`: + +```json +{ + "mcp_enabled": true, + "mcp_port": 5152 +} +``` + +2. Register with Claude Code: + +``` +claude mcp add --transport streamable-http --scope user performance-studio http://localhost:5152/ +``` + +3. Open a new Claude Code session and ask questions like: + - "What plans are loaded in the application?" + - "Analyze the execution plan and tell me what's wrong" + - "Are there any missing index suggestions?" + - "Compare these two plans — which is better?" + - "Fetch the top 10 queries by CPU from Query Store" + +### Available Tools + +13 tools for plan analysis and Query Store data: + +| Category | Tools | +|---|---| +| Discovery | `list_plans`, `get_connections` | +| Plan Analysis | `analyze_plan`, `get_plan_summary`, `get_plan_warnings`, `get_missing_indexes`, `get_plan_parameters`, `get_expensive_operators`, `get_plan_xml`, `compare_plans`, `get_repro_script` | +| Query Store | `check_query_store`, `get_query_store_top` | + +Plan analysis tools work on plans loaded in the app (via file open, paste, query execution, or Query Store fetch). Query Store tools use a built-in read-only DMV query — no arbitrary SQL can be executed. + +The MCP server binds to `localhost` only and does not accept remote connections. Disabled by default. + ## Project Structure ``` diff --git a/SECURITY.md b/SECURITY.md index a02e642..5a54ea9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Reporting a Vulnerability -If you discover a security vulnerability in SQL Performance Studio, please report it responsibly. +If you discover a security vulnerability in Performance Studio, please report it responsibly. **Do not open a public GitHub issue for security vulnerabilities.** @@ -26,7 +26,7 @@ This policy applies to: ## Security Best Practices -When using SQL Performance Studio: +When using Performance Studio: - Use Windows Authentication where possible when connecting to SQL Server - Use dedicated accounts with minimal required permissions diff --git a/src/PlanViewer.App/AboutWindow.axaml b/src/PlanViewer.App/AboutWindow.axaml index 8c30608..8f5b51e 100644 --- a/src/PlanViewer.App/AboutWindow.axaml +++ b/src/PlanViewer.App/AboutWindow.axaml @@ -1,8 +1,8 @@ - @@ -46,8 +46,24 @@ TextDecorations="Underline"/> - - + + + + + + + + + +