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 @@ + +