Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added screenshots/.gitkeep
Empty file.
36 changes: 36 additions & 0 deletions src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/PlanViewer.App/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
</MenuItem>
<MenuItem Header="_Help">
<MenuItem Header="_About SQL Performance Studio" Click="About_Click"/>
<Separator/>
<MenuItem x:Name="McpStatusMenuItem" Header="MCP Server: Off" IsEnabled="False"/>
</MenuItem>
</Menu>

Expand Down
33 changes: 32 additions & 1 deletion src/PlanViewer.App/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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);
}

Expand Down
29 changes: 29 additions & 0 deletions src/PlanViewer.App/Mcp/McpHelpers.cs
Original file line number Diff line number Diff line change
@@ -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}";
}
100 changes: 100 additions & 0 deletions src/PlanViewer.App/Mcp/McpHostService.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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}.
/// </summary>
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<McpPlanTools>()
.WithTools<McpQueryStoreTools>();

_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);
}
}
122 changes: 122 additions & 0 deletions src/PlanViewer.App/Mcp/McpInstructions.cs
Original file line number Diff line number Diff line change
@@ -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
""";
}
Loading