From 845fe2986d42afb65ba8921664746eab37c6c4a3 Mon Sep 17 00:00:00 2001 From: Jeff Bromberger Date: Mon, 16 Feb 2026 18:46:41 -0600 Subject: [PATCH 1/7] Add headless MCP server and gate embedded MCP behind --mcp flag Add ProfileExplorer.McpServer - a standalone headless MCP server that calls ProfileExplorerCore directly without WPF dependencies. Provides 8 tools: process enumeration, async trace loading with multi-process selection, function/binary listing, instruction-level assembly with Capstone disassembly, source line mapping, inline function info, and caller/callee analysis. Add public methods to ETWProfileDataProvider for MCP consumption: GetDebugInfoForModule, GetDebugInfoForFunction, DisassembleFunctionAsync. Adjust for CPU IP skid in GetFunctionAssembly: sampled instruction pointers often land on the instruction after the one that was executing. Shift each sample to the preceding valid instruction offset using the disassembly map, matching the UI's TryFindElementForOffset behavior. Gate the existing embedded MCP server (ProfileExplorer.Mcp) behind a --mcp command-line flag so it no longer starts unconditionally with the UI. Update ARCHITECTURE.md to document both MCP servers and their differences. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ARCHITECTURE.md | 291 ++++++++ .../ProfileExplorer.McpServer.csproj | 22 + src/ProfileExplorer.McpServer/Program.cs | 705 ++++++++++++++++++ .../Profile/ETW/ETWProfileDataProvider.cs | 49 ++ src/ProfileExplorerUI/App.xaml.cs | 7 +- 5 files changed, 1072 insertions(+), 2 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj create mode 100644 src/ProfileExplorer.McpServer/Program.cs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..c124eeba --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,291 @@ +# Profile Explorer — Architecture + +## Summary + +Profile Explorer is a Windows desktop application (WPF) for viewing CPU profiling traces collected via Event Tracing for Windows (ETW). It presents the hottest parts of a profiled application through an interactive UI: function list, flame graph, call tree, timeline, assembly view, and source view. A companion headless MCP server exposes the same profiling engine to AI assistants over the Model Context Protocol. + +The application was originally a compiler IR viewer and retains the ability to parse assembly into an internal IR, enabling control-flow graph visualization and interactive disassembly navigation. + +--- + +## High-Level Architecture + +``` ++-------------------------+ stdio (JSON-RPC) +-----------------------------+ +| GitHub Copilot CLI | <--------------------> | ProfileExplorer.McpServer | +| (AI Assistant) | | (headless, .NET 8) | ++-------------------------+ +-------------+---------------+ + | + ProfileExplorerCore (engine) + | + +----------+-----------+-----------+ + | | | | + ETW Symbols Disasm CallTree + (TraceEvent) (PDB/DIA) (Capstone) (in-mem) + | + v + +-------------+ + | .etl trace | + | files | + +-------------+ + ++-------------------------+ +| ProfileExplorerUI | Same engine (ProfileExplorerCore) +| (WPF desktop app) |--> with full interactive UI: flame graph, +| | call tree, assembly, source, timeline ++-------------------------+ +``` + +--- + +## Projects + +| Project | Type | Description | +|---------|------|-------------| +| **ProfileExplorerUI** | WPF app (.NET 8) | Main desktop application. All UI panels, session management, profiling views, scripting, VS extension integration. | +| **ProfileExplorerCore** | Class library | UI-independent engine: ETW trace loading, profile data model, call tree construction, IR parsing, binary analysis, symbol resolution, Capstone disassembly, Tree-sitter parsing, graph algorithms. | +| **ProfileExplorer.McpServer** | Console app (.NET 8) | Headless MCP server exposing profiling tools over stdio. Uses `ProfileExplorerCore` directly — no UI dependency. **This is the active MCP server.** | +| **ProfileExplorer.Mcp** | Class library + mock exe | Original MCP server designed to be embedded inside the WPF app via `IMcpActionExecutor`. Not used by the headless server. See [MCP: Embedded vs Headless](#mcp-embedded-vs-headless) below. | +| **ProfileExplorerCoreTests** | xUnit tests | Unit tests for the core library. | +| **ProfileExplorerUITests** | xUnit tests | Unit tests for UI logic. | +| **ManagedProfiler** | .NET profiler | JIT profiler extension for capturing JIT output assembly. | +| **PDBViewer** | WinForms utility | Small tool for displaying PDB debug info file contents. | +| **GrpcLib** | Protobuf library | GRPC protocol definitions for VS extension communication. | +| **VSExtension** | VSIX | Visual Studio extension that connects to the desktop app. | + +### External Dependencies (built from source) + +| Submodule | Purpose | +|-----------|---------| +| `src/external/capstone` | Capstone disassembly framework (x64/ARM64 instruction decoding) | +| `src/external/graphviz` | Graphviz graph layout engine (control-flow graph visualization) | +| `src/external/tree-sitter` | Tree-sitter parser generator (C/C++, C#, Rust source parsing) | +| `src/external/TreeListView` | WPF tree list view control | + +--- + +## Core Engine (ProfileExplorerCore) + +### Profile Data Pipeline + +``` +.etl file + | + v +ETWEventProcessor Reads raw ETW events via TraceEvent library + | Builds per-process sample lists and stack traces + v +ETWProfileDataProvider Orchestrates full profile loading: + | - Process enumeration (BuildProcessSummary) + | - Trace loading (LoadTraceAsync) + | - Symbol resolution (parallel PDB download) + | - Source line mapping + v +ProfileData In-memory profile model: + | - FunctionProfiles (per-function weight/time) + | - InstructionWeight (per-instruction hotspots) + | - Modules list + | - CallTree (rooted call graph) + v +ProfileCallTree Full call tree with: + | - GetSortedCallTreeNodes (all instances of a function) + | - GetCombinedCallTreeNode (merged view) + | - GetBacktrace (full stack for a node) + | - Caller/callee aggregation + v +UI views / MCP tools Consume the profile model for display or JSON output +``` + +### Key Data Types + +| Type | Location | Role | +|------|----------|------| +| `ProfileData` | Profile/Data/ProfileData.cs | Root container: function profiles, modules, call tree, metadata | +| `FunctionProfileData` | Profile/Data/FunctionProfileData.cs | Per-function: inclusive/exclusive weight, instruction weights, debug info | +| `ProfileCallTree` | Profile/CallTree/ProfileCallTree.cs | Full call tree with aggregation and backtrace support | +| `ProfileCallTreeNode` | Profile/CallTree/ProfileCallTreeNode.cs | Single node: function ref, weight, children, callers | +| `ETWEventProcessor` | Profile/ETW/ETWEventProcessor.cs | Low-level ETW event reading and sample extraction | +| `ETWProfileDataProvider` | Profile/ETW/ETWProfileDataProvider.cs | High-level trace loading orchestration (~1000 lines) | +| `IRTextFunction` | IRTextFunction.cs | Function identity (name + module) used as dictionary key | + +### Symbol Resolution + +Symbols are resolved in parallel during trace loading: +1. `_NT_SYMBOL_PATH` environment variable is read for symbol server configuration +2. A bellwether test probes symbol server health before bulk downloads +3. PDB files are downloaded in parallel with configurable timeouts (normal, degraded) +4. Failed downloads are negatively cached to avoid retry storms +5. Source line info is resolved on-demand per function (not bulk-loaded) + +Key settings in `SymbolFileSourceSettings`: +- `SymbolServerTimeoutSeconds` (default 10s) +- `BellwetherTestEnabled` / `BellwetherTimeoutSeconds` (5s) +- `DegradedTimeoutSeconds` (3s) +- `RejectPreviouslyFailedFiles` (negative cache) +- `WindowsPathFilterEnabled`, `CompanyFilterEnabled` (skip irrelevant binaries) + +--- + +## MCP: Embedded vs Headless + +There are two MCP-related projects in the repo. Only the headless server is actively used. + +| Aspect | `ProfileExplorer.Mcp` (embedded) | `ProfileExplorer.McpServer` (headless) | +|--------|----------------------------------|----------------------------------------| +| **Architecture** | Library defining `IMcpActionExecutor` interface. The WPF app implements this interface (`McpActionExecutor.cs`) and routes MCP calls through the UI dispatcher. | Standalone console exe. Calls `ProfileExplorerCore` APIs directly — no WPF, no dispatcher. | +| **Status** | **Not used.** Deadlocks when the WPF `Dispatcher.Invoke` blocks the MCP thread waiting for UI operations. | **Active.** All AI assistant workflows use this server. | +| **Coupling** | Loose — interface-based. Requires UI to implement the executor. | Tight — directly uses `ETWProfileDataProvider`, `ProfileData`, `CallTree`, `Disassembler`. | +| **Tools** | 6 tools via interface (`OpenTrace`, `GetStatus`, `GetAvailableProcesses`, `GetAvailableFunctions`, `GetAvailableBinaries`, `GetFunctionAssemblyToFile`) | 8 tools with richer features: multi-process selection, caller/callee analysis, Capstone disassembly with per-instruction source lines and inline function info. | +| **State** | Stateless — each call is dispatched to the UI which holds state. | Stateful — `ProfileSession` singleton holds loaded profile, provider, and symbol settings. | + +The embedded approach (`ProfileExplorer.Mcp`) was the repo's original attempt at MCP support. It works conceptually but in practice the WPF dispatcher serialization creates deadlocks: MCP tool calls block on `Dispatcher.Invoke`, but the dispatcher is waiting for previous work to complete. The headless server (`ProfileExplorer.McpServer`) solves this by bypassing the UI entirely. + +--- + +## MCP Server (ProfileExplorer.McpServer) + +The active MCP server. Exposes the profiling engine over MCP stdio transport. No WPF, no GUI — just the core engine. + +### Registration + +Registered in `~/.copilot/mcp-config.json` as `profile-explorer`: +```json +{ + "type": "stdio", + "command": "D:\\repos\\profile-explorer\\src\\ProfileExplorer.McpServer\\bin\\Release\\net8.0\\ProfileExplorer.McpServer.exe", + "env": { + "_NT_SYMBOL_PATH": "cache*c:\\programdata\\dbg\\sym;SRV*c:\\programdata\\dbg\\sym*https://symweb.azurefd.net" + } +} +``` + +### Tools + +| Tool | Description | +|------|-------------| +| `GetAvailableProcesses` | List processes in a trace file with weight percentages. Filtering by min weight %, top N. | +| `OpenTrace` | Start async loading of a trace for a specific process. Returns immediately. | +| `GetTraceLoadStatus` | Poll loading progress. Returns Loading/Complete/Failed. | +| `GetAvailableFunctions` | List functions with self-time/total-time %. Filter by module, min %, top N. | +| `GetAvailableBinaries` | List modules/DLLs aggregated by CPU time. Filter by min %, top N. | +| `GetFunctionAssembly` | Instruction-level hotspots with Capstone disassembly, source line mapping, and inline function info. | +| `GetFunctionCallerCallee` | Callers, callees, and full backtraces for a function. | +| `GetHelp` | Usage workflow documentation. | + +### Session Model + +`ProfileSession` is a static singleton holding loaded state: +- `LoadedProfile` — the `ProfileData` after successful load +- `Provider` — kept alive for on-demand source line resolution +- `PendingLoad` — `Task` for async loading +- `TotalWeight` — pre-computed total weight for percentage calculations + +### Typical Workflow + +1. `GetAvailableProcesses(filePath)` — discover processes in a trace +2. `OpenTrace(filePath, processId)` — start loading (async) +3. `GetTraceLoadStatus()` — poll until Complete +4. `GetAvailableFunctions(topCount: 20)` — find hot functions +5. `GetFunctionAssembly(name)` — drill into instruction-level hotspots +6. `GetFunctionCallerCallee(name)` — understand call context + +--- + +## Desktop Application (ProfileExplorerUI) + +The WPF application provides the full interactive experience: + +### UI Architecture + +- **MainWindow** — split across partial classes for different concerns: + - `MainWindow.xaml.cs` — core window logic + - `MainWindowProfiling.cs` — profile loading and view management + - `MainWindowSession.cs` — session save/restore + - `MainWindowPanels.cs` — panel layout management + - `MainWindowDiff.cs` — diff/comparison mode + - `MainWindowDebugRpc.cs` — VS extension RPC handling +- **Panels/** — individual UI views (flame graph, call tree, function list, assembly, source, timeline, etc.) +- **Profile/** — profiling-specific UI components +- **Session/** — session persistence +- **Mcp/** — `McpActionExecutor.cs` bridges MCP commands to UI actions (used by the embedded MCP path) +- **Scripting/** — user scripting support + +### Performance Design + +The app is designed for near-instant interaction even with very large traces (10+ GB ETL files): +- Multi-threaded trace processing and filtering +- Async UI updates that don't block the main thread +- On-demand symbol resolution (only for viewed functions) +- Parallel PDB downloads with health-aware timeouts +- Incremental rendering for large data sets + +--- + +## Build System + +| Command | Description | +|---------|-------------| +| `build.cmd [debug\|release]` | x64 build (main project + external deps). Requires admin for msdia140.dll registration. | +| `build-arm64.cmd [debug\|release]` | Native ARM64 build. | +| `installer\x64\prepare.cmd` | Publish + create x64 installer (InnoSetup). | +| `installer\arm64\prepare.cmd` | Publish + create ARM64 installer. | +| `dotnet build src\ProfileExplorerUI\ProfileExplorerUI.csproj -c Release` | Build UI project only (after initial `build.cmd`). | +| `dotnet build src\ProfileExplorerCore\ProfileExplorerCore.csproj -c Release` | Build core library only (fast, ~2-3s). | + +**Prerequisites**: Visual Studio 2022 (.NET desktop + C++ desktop workloads), .NET 8.0 SDK. + +Output: `src\ProfileExplorerUI\bin\[Debug|Release]\net8.0-windows\ProfileExplorer.exe` + +--- + +## Dependencies + +| Package | Version | Used By | Purpose | +|---------|---------|---------|---------| +| `ModelContextProtocol` | 0.3.0-preview.4 | McpServer | MCP protocol (stdio transport, tool registration) | +| `Microsoft.Extensions.Hosting` | 9.0.0 | McpServer | .NET hosting / DI | +| `Microsoft.Extensions.Logging` | 9.0.0 | McpServer | Logging infrastructure | + +The core and UI projects have additional dependencies (TraceEvent for ETW, DIA SDK for PDB reading, etc.) managed through the solution and build scripts. + +--- + +## Known Limitations + +| Limitation | Impact | +|-----------|--------| +| Windows only | ETW and WPF are Windows-specific technologies | +| One trace at a time (MCP) | The headless MCP server holds a single `ProfileSession` — loading a new trace replaces the previous one | +| Symbol server latency | First load of a trace can be slow if PDBs need downloading from symbol servers | +| Large trace memory | Very large traces consume significant memory for the in-memory call tree and profile data | +| Release builds required | The user's MCP config points to Release output; Debug builds won't be picked up | + +--- + +## Repository Layout + +``` +profile-explorer/ + ARCHITECTURE.md This file + AGENTS.md Agent/Copilot instructions + CLAUDE.md Claude Code instructions + README.md User-facing documentation + build.cmd x64 build script + build-arm64.cmd ARM64 build script + src/ + ProfileExplorer.sln Solution file + ProfileExplorerUI/ WPF desktop application + ProfileExplorerCore/ Engine library (UI-independent) + ProfileExplorer.McpServer/ Headless MCP server (active) + ProfileExplorer.Mcp/ Embedded MCP server (original, not used — deadlocks on WPF dispatcher) + ProfileExplorerCoreTests/ Core unit tests + ProfileExplorerUITests/ UI unit tests + ManagedProfiler/ .NET JIT profiler + PDBViewer/ PDB viewer utility + GrpcLib/ VS extension protocol + VSExtension/ VS extension + external/ Git submodules (capstone, graphviz, tree-sitter) + docs/ Documentation site source + installer/ Installer scripts (InnoSetup) + scripts/ Utility scripts (log analysis, etc.) +``` diff --git a/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj b/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj new file mode 100644 index 00000000..72fcaf6f --- /dev/null +++ b/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0 + enable + enable + ProfileExplorer.McpServer + + + + + + + + + + + + + + diff --git a/src/ProfileExplorer.McpServer/Program.cs b/src/ProfileExplorer.McpServer/Program.cs new file mode 100644 index 00000000..959ec827 --- /dev/null +++ b/src/ProfileExplorer.McpServer/Program.cs @@ -0,0 +1,705 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System.ComponentModel; +using System.Diagnostics; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using ProfileExplorer.Core; +using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.Profile.Data; +using ProfileExplorer.Core.Profile.ETW; +using ProfileExplorer.Core.Settings; +using ProfileExplorer.Core.Utilities; + +namespace ProfileExplorer.McpServer; + +public class Program +{ + public static async Task Main(string[] args) + { + var builder = Host.CreateDefaultBuilder() + .ConfigureLogging(logging => + { + logging.ClearProviders(); + }) + .ConfigureServices(services => + { + services.AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(); + }); + + await builder.Build().RunAsync(); + } +} + +/// +/// Holds the loaded profile state for the current session. +/// +public static class ProfileSession +{ + public static ProfileData? LoadedProfile { get; set; } + public static ETWProfileDataProvider? Provider { get; set; } + public static SymbolFileSourceSettings? SymbolSettings { get; set; } + public static string? LoadedFilePath { get; set; } + public static string? LoadedProcessName { get; set; } + public static List LoadedProcessIds { get; set; } = new(); + public static TimeSpan TotalWeight { get; set; } + + // Async loading state + public static Task? PendingLoad { get; set; } + public static string? PendingFilePath { get; set; } + public static string? PendingProcessId { get; set; } + public static Exception? LoadException { get; set; } +} + +[McpServerToolType] +public static class ProfileTools +{ + private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true }; + + [McpServerTool, Description("Get the list of available processes from a trace file with optional weight filtering")] + public static string GetAvailableProcesses( + string profileFilePath, + [Description("Minimum weight percentage threshold to filter processes (e.g. 1.0 for >=1% weight)")] + double? minWeightPercentage = null, + [Description("Limit results to top N heaviest processes")] + int? topCount = null) + { + if (!File.Exists(profileFilePath)) + return Error("GetAvailableProcesses", $"File not found: {profileFilePath}"); + + try + { + var options = new ProfileDataProviderOptions(); + using var cancelTask = new CancelableTask(); + using var processor = new ETWEventProcessor(profileFilePath, options); + var summaries = processor.BuildProcessSummary( + (ProcessListProgress progress) => { }, cancelTask); + + // Compute total weight for percentages + var totalWeight = TimeSpan.Zero; + foreach (var s in summaries) + totalWeight += s.Weight; + + // Compute percentages and duration + foreach (var s in summaries) + { + s.WeightPercentage = totalWeight > TimeSpan.Zero + ? Math.Round(s.Weight / totalWeight * 100, 2) + : 0; + } + + // Sort by weight desc + summaries.Sort((a, b) => -a.Weight.CompareTo(b.Weight)); + + IEnumerable filtered = summaries; + if (minWeightPercentage.HasValue) + filtered = filtered.Where(p => p.WeightPercentage >= minWeightPercentage.Value); + if (topCount.HasValue) + filtered = filtered.Take(topCount.Value); + + var result = new + { + Action = "GetAvailableProcesses", + ProfileFilePath = profileFilePath, + Status = "Success", + TotalProcessCount = summaries.Count, + Processes = filtered.Select(p => new + { + ProcessId = p.Process.ProcessId, + Name = p.Process.Name ?? "", + ImageFileName = p.Process.ImageFileName ?? "", + Weight = p.Weight.ToString(), + WeightPercentage = p.WeightPercentage + }).ToArray(), + Timestamp = DateTime.UtcNow + }; + return JsonSerializer.Serialize(result, JsonOpts); + } + catch (Exception ex) + { + return Error("GetAvailableProcesses", ex.Message); + } + } + + [McpServerTool, Description("Start loading a trace file with a specific process. Returns immediately. Use GetTraceLoadStatus to poll for completion.")] + public static string OpenTrace( + string profileFilePath, + [Description("Process name or ID. A name like 'diskspd' selects ALL matching processes. Comma-separated IDs (e.g. '9492,9500') select specific ones.")] + string processNameOrId, + [Description("Optional additional symbol search path (e.g. 'd:\\temp\\landy' for custom kernel symbols)")] + string? symbolPath = null) + { + if (!File.Exists(profileFilePath)) + return Error("OpenTrace", $"File not found: {profileFilePath}"); + + // Reset state + ProfileSession.LoadedProfile = null; + ProfileSession.LoadException = null; + ProfileSession.PendingFilePath = profileFilePath; + ProfileSession.PendingProcessId = processNameOrId; + + ProfileSession.PendingLoad = Task.Run(async () => + { + try + { + var options = new ProfileDataProviderOptions(); + var symbolSettings = new SymbolFileSourceSettings(); + symbolSettings.UseEnvironmentVarSymbolPaths = true; + if (!string.IsNullOrWhiteSpace(symbolPath)) + symbolSettings.InsertSymbolPath(symbolPath); + ProfileSession.SymbolSettings = symbolSettings; + var report = new ProfileDataReport(); + var provider = new ETWProfileDataProvider(); + + // Resolve process IDs — supports comma-separated IDs or name-based matching (all matches) + var processIds = new List(); + var parts = processNameOrId.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (parts.All(p => int.TryParse(p, out _))) + { + // All parts are numeric — explicit PID list + processIds = parts.Select(int.Parse).ToList(); + } + else + { + // Name-based: find ALL matching processes + using var cancelTask2 = new CancelableTask(); + using var proc = new ETWEventProcessor(profileFilePath, options); + var summaries = proc.BuildProcessSummary( + (ProcessListProgress _) => { }, cancelTask2); + var matches = summaries.Where(s => + (s.Process.Name?.Equals(processNameOrId, StringComparison.OrdinalIgnoreCase) ?? false) || + (s.Process.ImageFileName?.Contains(processNameOrId, StringComparison.OrdinalIgnoreCase) ?? false)) + .ToList(); + + if (matches.Count == 0) + throw new Exception($"Process '{processNameOrId}' not found in trace"); + + processIds = matches.Select(s => s.Process.ProcessId).ToList(); + } + + ProfileSession.LoadedProcessIds = processIds; + + var cancelTask3 = new CancelableTask(); + var profile = await provider.LoadTraceAsync( + profileFilePath, + processIds, + options, + symbolSettings, + report, + progress => { Trace.WriteLine($"Load progress: {progress}"); }, + cancelTask3); + + if (profile == null) + throw new Exception("LoadTraceAsync returned null — trace loading failed"); + + // Compute total weight + var totalWeight = TimeSpan.Zero; + foreach (var kvp in profile.FunctionProfiles) + totalWeight += kvp.Value.ExclusiveWeight; + + ProfileSession.TotalWeight = totalWeight; + ProfileSession.LoadedProfile = profile; + ProfileSession.LoadedFilePath = profileFilePath; + ProfileSession.Provider = provider; + return profile; + } + catch (Exception ex) + { + ProfileSession.LoadException = ex; + Trace.TraceError($"OpenTrace failed: {ex}"); + return null; + } + }); + + var loadingResult = new + { + Action = "OpenTrace", + ProfileFilePath = profileFilePath, + ProcessNameOrId = processNameOrId, + Status = "Loading", + Description = "Trace loading started asynchronously. Call GetTraceLoadStatus() to poll for completion.", + Timestamp = DateTime.UtcNow + }; + return JsonSerializer.Serialize(loadingResult, JsonOpts); + } + + [McpServerTool, Description("Poll the status of an in-progress trace load started by OpenTrace. Call repeatedly until Status is 'Complete' or 'Failed'.")] + public static string GetTraceLoadStatus() + { + if (ProfileSession.PendingLoad == null) + return Error("GetTraceLoadStatus", "No trace load in progress. Call OpenTrace first."); + + if (!ProfileSession.PendingLoad.IsCompleted) + { + return JsonSerializer.Serialize(new + { + Action = "GetTraceLoadStatus", + Status = "Loading", + Description = "Trace is still loading (symbol resolution, profile processing). Poll again in 10-15 seconds.", + Timestamp = DateTime.UtcNow + }, JsonOpts); + } + + if (ProfileSession.LoadException != null) + { + var err = ProfileSession.LoadException.Message; + ProfileSession.PendingLoad = null; + return Error("GetTraceLoadStatus", err); + } + + if (ProfileSession.LoadedProfile != null) + { + ProfileSession.PendingLoad = null; + return JsonSerializer.Serialize(new + { + Action = "GetTraceLoadStatus", + Status = "Complete", + Description = $"Trace loaded successfully. {ProfileSession.LoadedProcessIds.Count} process(es), {ProfileSession.LoadedProfile.FunctionProfiles.Count} functions found.", + ProcessCount = ProfileSession.LoadedProcessIds.Count, + ProcessIds = ProfileSession.LoadedProcessIds, + FunctionCount = ProfileSession.LoadedProfile.FunctionProfiles.Count, + ModuleCount = ProfileSession.LoadedProfile.Modules?.Count ?? 0, + Timestamp = DateTime.UtcNow + }, JsonOpts); + } + + ProfileSession.PendingLoad = null; + return Error("GetTraceLoadStatus", "Load completed but no profile data available"); + } + + [McpServerTool, Description("Get the list of available functions from the currently loaded process/trace")] + public static string GetAvailableFunctions( + [Description("Filter by module/DLL name (e.g. 'ntdll.dll', 'kernel32.dll'). Supports partial matching.")] + string? moduleName = null, + [Description("Minimum self-time percentage threshold (e.g. 0.1 for >=0.1% CPU usage).")] + double? minSelfTimePercentage = null, + [Description("Minimum total-time percentage threshold (e.g. 0.5 for >=0.5% total impact).")] + double? minTotalTimePercentage = null, + [Description("Limit results to top N functions (e.g. 10).")] + int? topCount = null, + [Description("Sort by self-time (true, default) or total-time (false).")] + bool sortBySelfTime = true) + { + var profile = ProfileSession.LoadedProfile; + if (profile == null) + return Error("GetAvailableFunctions", "No profile loaded. Call OpenTrace and wait for completion first."); + + var totalWeight = ProfileSession.TotalWeight; + var totalWeightMs = totalWeight.TotalMilliseconds; + + // Build function list + var functions = profile.FunctionProfiles.Select(kvp => + { + var func = kvp.Key; + var data = kvp.Value; + double selfPct = totalWeightMs > 0 ? data.ExclusiveWeight.TotalMilliseconds / totalWeightMs * 100 : 0; + double totalPct = totalWeightMs > 0 ? data.Weight.TotalMilliseconds / totalWeightMs * 100 : 0; + return new + { + Name = func.Name, + ModuleName = func.ModuleName ?? "Unknown", + SelfTimePercentage = Math.Round(selfPct, 2), + TotalTimePercentage = Math.Round(totalPct, 2), + SelfTime = data.ExclusiveWeight.ToString(), + TotalTime = data.Weight.ToString() + }; + }); + + // Apply filters + if (!string.IsNullOrWhiteSpace(moduleName)) + functions = functions.Where(f => f.ModuleName.Contains(moduleName, StringComparison.OrdinalIgnoreCase)); + if (minSelfTimePercentage.HasValue) + functions = functions.Where(f => f.SelfTimePercentage >= minSelfTimePercentage.Value); + if (minTotalTimePercentage.HasValue) + functions = functions.Where(f => f.TotalTimePercentage >= minTotalTimePercentage.Value); + + // Sort and limit + var resultList = sortBySelfTime + ? functions.OrderByDescending(f => f.SelfTimePercentage).ToList() + : functions.OrderByDescending(f => f.TotalTimePercentage).ToList(); + + if (topCount.HasValue) + resultList = resultList.Take(topCount.Value).ToList(); + + var result = new + { + Action = "GetAvailableFunctions", + Status = "Success", + TotalFunctionCount = profile.FunctionProfiles.Count, + FilteredFunctionCount = resultList.Count, + Functions = resultList.ToArray(), + Timestamp = DateTime.UtcNow + }; + return JsonSerializer.Serialize(result, JsonOpts); + } + + [McpServerTool, Description("Get the list of available binaries/DLLs from the currently loaded process/trace")] + public static string GetAvailableBinaries( + [Description("Minimum time percentage threshold to filter binaries.")] + double? minTimePercentage = null, + [Description("Minimum absolute time in milliseconds to filter binaries.")] + double? minTimeMs = null, + [Description("Limit results to top N binaries.")] + int? topCount = null) + { + var profile = ProfileSession.LoadedProfile; + if (profile == null) + return Error("GetAvailableBinaries", "No profile loaded. Call OpenTrace and wait for completion first."); + + var totalWeight = ProfileSession.TotalWeight; + var totalWeightMs = totalWeight.TotalMilliseconds; + + // Aggregate by module + var moduleAgg = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in profile.FunctionProfiles) + { + var modName = kvp.Key.ModuleName ?? "Unknown"; + if (!moduleAgg.TryGetValue(modName, out var existing)) + existing = TimeSpan.Zero; + moduleAgg[modName] = existing + kvp.Value.ExclusiveWeight; + } + + var binaries = moduleAgg.Select(kvp => + { + double pct = totalWeightMs > 0 ? kvp.Value.TotalMilliseconds / totalWeightMs * 100 : 0; + return new + { + Name = kvp.Key, + TimePercentage = Math.Round(pct, 2), + Time = kvp.Value.ToString(), + TimeMs = kvp.Value.TotalMilliseconds + }; + }); + + if (minTimePercentage.HasValue) + binaries = binaries.Where(b => b.TimePercentage >= minTimePercentage.Value); + if (minTimeMs.HasValue) + binaries = binaries.Where(b => b.TimeMs >= minTimeMs.Value); + + var sorted = binaries.OrderByDescending(b => b.TimePercentage); + var resultList = topCount.HasValue ? sorted.Take(topCount.Value).ToArray() : sorted.ToArray(); + + var result = new + { + Action = "GetAvailableBinaries", + Status = "Success", + TotalBinaryCount = moduleAgg.Count, + FilteredBinaryCount = resultList.Length, + Binaries = resultList, + Timestamp = DateTime.UtcNow + }; + return JsonSerializer.Serialize(result, JsonOpts); + } + + [McpServerTool, Description("Get assembly code for a specific function by name")] + public static async Task GetFunctionAssembly(string functionName) + { + var profile = ProfileSession.LoadedProfile; + if (profile == null) + return Error("GetFunctionAssembly", "No profile loaded. Call OpenTrace and wait for completion first."); + + var match = FindFunction(profile, functionName); + + if (match == null) + return Error("GetFunctionAssembly", $"Function '{functionName}' not found"); + + var data = profile.FunctionProfiles[match]; + var debugInfo = data.FunctionDebugInfo; + + // Try to resolve source lines via the provider's debug info + IDebugInfoProvider? moduleDebugInfo = null; + if (ProfileSession.Provider != null) + moduleDebugInfo = ProfileSession.Provider.GetDebugInfoForFunction(match); + + if (moduleDebugInfo != null && debugInfo != null && !debugInfo.HasSourceLines) + moduleDebugInfo.PopulateSourceLines(debugInfo); + + // Try to disassemble the function using capstone + Dictionary? disasmMap = null; + long imageBase = 0; + if (ProfileSession.Provider != null && debugInfo != null && ProfileSession.SymbolSettings != null) + { + try + { + string? asmText = await ProfileSession.Provider.DisassembleFunctionAsync( + match, debugInfo, ProfileSession.SymbolSettings); + if (!string.IsNullOrEmpty(asmText)) + { + // Parse disassembly text: "{absoluteAddr:X}: {mnemonic} {operands}" + // Convert absolute addresses to function-relative offsets + disasmMap = new Dictionary(); + foreach (var line in asmText.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + int colonIdx = line.IndexOf(':'); + if (colonIdx > 0 && long.TryParse(line[..colonIdx].Trim(), + System.Globalization.NumberStyles.HexNumber, null, out long absAddr)) + { + if (imageBase == 0) imageBase = absAddr - debugInfo.RVA; + long funcOffset = absAddr - imageBase - debugInfo.RVA; + string instruction = line[(colonIdx + 1)..].Trim(); + disasmMap[funcOffset] = instruction; + } + } + } + } + catch (Exception ex) + { + Trace.TraceError($"Disassembly failed for {functionName}: {ex.Message}"); + } + } + + // Adjust for CPU IP skid: sampled IPs often point to the instruction AFTER the + // one that was actually executing. When we have a disassembly map, shift each + // sample's weight to the preceding valid instruction offset (same as UI's + // TryFindElementForOffset with InitialMultiplier=1). + var adjustedWeights = data.InstructionWeight; + long[]? sortedOffsets = null; + if (disasmMap != null && data.InstructionWeight != null) + { + sortedOffsets = disasmMap.Keys.OrderBy(k => k).ToArray(); + var adjusted = new Dictionary(); + foreach (var kv in data.InstructionWeight) + { + long adjustedOffset = kv.Key; + // Search backwards (up to 16 bytes) for the nearest valid instruction + for (long delta = 1; delta <= 16; delta++) + { + long candidate = kv.Key - delta; + if (candidate >= 0 && disasmMap.ContainsKey(candidate)) + { + adjustedOffset = candidate; + break; + } + } + if (adjusted.ContainsKey(adjustedOffset)) + adjusted[adjustedOffset] += kv.Value; + else + adjusted[adjustedOffset] = kv.Value; + } + adjustedWeights = adjusted; + } + + var totalWeightMs = ProfileSession.TotalWeight.TotalMilliseconds; + + var instructionWeights = adjustedWeights?.OrderByDescending(kv => kv.Value) + .Take(30) + .Select(kv => + { + string? sourceFile = null; + int? sourceLine = null; + + // Use per-RVA DIA lookup (same as UI) for accurate source line mapping + object[]? inlinees = null; + if (moduleDebugInfo != null && debugInfo != null) + { + long rva = kv.Key + debugInfo.RVA; + var lineInfo = moduleDebugInfo.FindSourceLineByRVA(rva, includeInlinees: true); + if (!lineInfo.IsUnknown) + { + sourceLine = lineInfo.Line; + sourceFile = lineInfo.FilePath; + } + if (lineInfo.Inlinees is { Count: > 0 }) + { + inlinees = lineInfo.Inlinees.Select(i => (object)new + { + Function = i.Function, + FilePath = i.FilePath, + Line = i.Line + }).ToArray(); + } + } + + // Look up disassembly text for this offset + string? asmInstruction = null; + disasmMap?.TryGetValue(kv.Key, out asmInstruction); + + double pctOfFunc = data.ExclusiveWeight.TotalMilliseconds > 0 + ? kv.Value.TotalMilliseconds / data.ExclusiveWeight.TotalMilliseconds * 100 : 0; + + return new + { + Offset = $"0x{kv.Key:X}", + Assembly = asmInstruction, + WeightMs = Math.Round(kv.Value.TotalMilliseconds, 1), + PctOfFunction = Math.Round(pctOfFunc, 1), + PctOfTrace = totalWeightMs > 0 ? Math.Round(kv.Value.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + SourceFile = sourceFile, + SourceLine = sourceLine, + Inlinees = inlinees + }; + }) + .ToArray(); + + var result = new + { + Action = "GetFunctionAssembly", + Status = "Success", + FunctionName = match.Name, + ModuleName = match.ModuleName ?? "Unknown", + SelfTime = data.ExclusiveWeight.ToString(), + TotalTime = data.Weight.ToString(), + SelfPct = totalWeightMs > 0 ? Math.Round(data.ExclusiveWeight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + HasDisassembly = disasmMap != null, + InstructionCount = data.InstructionWeight?.Count ?? 0, + InstructionWeights = instructionWeights, + Timestamp = DateTime.UtcNow + }; + return JsonSerializer.Serialize(result, JsonOpts); + } + + [McpServerTool, Description("Get callers and callees for a function, showing who calls it and what it calls, with call stack traces")] + public static string GetFunctionCallerCallee( + string functionName, + [Description("Max number of callers to return (default 10)")] + int? maxCallers = null, + [Description("Max number of callees to return (default 10)")] + int? maxCallees = null, + [Description("Max number of full back-traces (call stacks) to return (default 5)")] + int? maxBacktraces = null) + { + var profile = ProfileSession.LoadedProfile; + if (profile == null) + return Error("GetFunctionCallerCallee", "No profile loaded. Call OpenTrace and wait for completion first."); + + if (profile.CallTree == null) + return Error("GetFunctionCallerCallee", "No call tree data available in this profile."); + + var match = FindFunction(profile, functionName); + if (match == null) + return Error("GetFunctionCallerCallee", $"Function '{functionName}' not found"); + + var data = profile.FunctionProfiles[match]; + var totalWeightMs = ProfileSession.TotalWeight.TotalMilliseconds; + int callerLimit = maxCallers ?? 10; + int calleeLimit = maxCallees ?? 10; + int backtraceLimit = maxBacktraces ?? 5; + + // Get all call tree instances of this function, sorted by weight + var instances = profile.CallTree.GetSortedCallTreeNodes(match); + if (instances == null || instances.Count == 0) + return Error("GetFunctionCallerCallee", $"Function '{functionName}' has no call tree nodes"); + + // Aggregate callers across all instances + var callerAgg = new Dictionary(); + foreach (var inst in instances) + { + foreach (var caller in inst.Callers) + { + if (caller?.Function == null) continue; + var key = $"{caller.Function.ModuleName}!{caller.Function.Name}"; + if (!callerAgg.TryGetValue(key, out var existing)) + existing = (TimeSpan.Zero, TimeSpan.Zero, caller.Function.ModuleName ?? "Unknown"); + callerAgg[key] = (existing.weight + caller.Weight, existing.exclusiveWeight + caller.ExclusiveWeight, existing.module); + } + } + + var callers = callerAgg + .OrderByDescending(kv => kv.Value.weight) + .Take(callerLimit) + .Select(kv => new + { + Function = kv.Key, + InclusiveTimeMs = Math.Round(kv.Value.weight.TotalMilliseconds, 2), + InclusivePct = totalWeightMs > 0 ? Math.Round(kv.Value.weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0 + }).ToArray(); + + // Aggregate callees from the combined node's children + var combined = profile.CallTree.GetCombinedCallTreeNode(match); + var callees = Array.Empty(); + if (combined != null && combined.HasChildren) + { + callees = combined.Children + .Where(c => c?.Function != null) + .OrderByDescending(c => c.Weight) + .Take(calleeLimit) + .Select(c => new + { + Function = $"{c.Function.ModuleName}!{c.Function.Name}", + InclusiveTimeMs = Math.Round(c.Weight.TotalMilliseconds, 2), + InclusivePct = totalWeightMs > 0 ? Math.Round(c.Weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + SelfTimeMs = Math.Round(c.ExclusiveWeight.TotalMilliseconds, 2), + SelfPct = totalWeightMs > 0 ? Math.Round(c.ExclusiveWeight.TotalMilliseconds / totalWeightMs * 100, 2) : 0 + }).ToArray(); + } + + // Get top backtraces (full call stacks leading to this function) + var backtraces = instances + .Take(backtraceLimit) + .Select(inst => + { + var bt = profile.CallTree.GetBacktrace(inst); + return new + { + WeightMs = Math.Round(inst.Weight.TotalMilliseconds, 2), + WeightPct = totalWeightMs > 0 ? Math.Round(inst.Weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + Stack = bt.Select(n => $"{n.Function?.ModuleName}!{n.Function?.Name}").ToArray() + }; + }).ToArray(); + + var result = new + { + Action = "GetFunctionCallerCallee", + Status = "Success", + FunctionName = match.Name, + ModuleName = match.ModuleName ?? "Unknown", + SelfTime = data.ExclusiveWeight.ToString(), + TotalTime = data.Weight.ToString(), + SelfPct = totalWeightMs > 0 ? Math.Round(data.ExclusiveWeight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + TotalPct = totalWeightMs > 0 ? Math.Round(data.Weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + InstanceCount = instances.Count, + Callers = callers, + Callees = callees, + TopBacktraces = backtraces, + Timestamp = DateTime.UtcNow + }; + return JsonSerializer.Serialize(result, JsonOpts); + } + + [McpServerTool, Description("Get help information about available MCP commands")] + public static string GetHelp() + { + var help = new + { + ServerName = "Profile Explorer Headless MCP Server", + Version = "2.0.0", + Description = "Headless MCP server for Profile Explorer — no GUI, direct engine access", + Workflow = new[] + { + "1. GetAvailableProcesses(filePath) — discover processes in a trace", + "2. OpenTrace(filePath, processNameOrId) — start loading (async). Name matches ALL processes (e.g. 'diskspd' loads all 4). Comma-separated IDs also supported.", + "3. GetTraceLoadStatus() — poll until 'Complete'", + "4. GetAvailableFunctions/GetAvailableBinaries — query the loaded profile", + "5. GetFunctionAssembly(name) — get instruction-level hotspot data", + "6. GetFunctionCallerCallee(name) — get callers, callees, and full call stacks" + } + }; + return JsonSerializer.Serialize(help, JsonOpts); + } + + // Prefer exact name match, then fall back to Contains + private static IRTextFunction? FindFunction(ProfileData profile, string functionName) + { + return profile.FunctionProfiles.Keys + .FirstOrDefault(f => f.Name.Equals(functionName, StringComparison.OrdinalIgnoreCase)) + ?? profile.FunctionProfiles.Keys + .FirstOrDefault(f => f.Name.Contains(functionName, StringComparison.OrdinalIgnoreCase)); + } + + private static string Error(string action, string message) + { + return JsonSerializer.Serialize(new + { + Action = action, + Status = "Error", + Error = message, + Timestamp = DateTime.UtcNow + }, JsonOpts); + } +} diff --git a/src/ProfileExplorerCore/Profile/ETW/ETWProfileDataProvider.cs b/src/ProfileExplorerCore/Profile/ETW/ETWProfileDataProvider.cs index bf0369c9..cf4bf2ec 100644 --- a/src/ProfileExplorerCore/Profile/ETW/ETWProfileDataProvider.cs +++ b/src/ProfileExplorerCore/Profile/ETW/ETWProfileDataProvider.cs @@ -92,6 +92,55 @@ public void Dispose() { // ProfileModuleBuilder instances should live as long as the profile data is being used. } + /// + /// Get the debug info provider for a module by its image ID. + /// Used by headless MCP server for source line resolution. + /// + public IDebugInfoProvider GetDebugInfoForModule(int imageId) { + return imageModuleMap_.TryGetValue(imageId, out var builder) ? builder.DebugInfo : null; + } + + /// + /// Get the debug info provider for a function's parent module. + /// + public IDebugInfoProvider GetDebugInfoForFunction(IRTextFunction function) { + if (function?.ParentSummary == null) return null; + foreach (var builder in imageModuleMap_.Values) { + if (builder.Summary == function.ParentSummary) { + return builder.DebugInfo; + } + } + return null; + } + + /// + /// Disassemble a function to text using capstone. Locates the binary on-demand + /// via symbol server if needed. Returns null if binary cannot be found. + /// + public async Task DisassembleFunctionAsync(IRTextFunction function, FunctionDebugInfo funcDebugInfo, + SymbolFileSourceSettings symbolSettings) { + if (function?.ParentSummary == null || funcDebugInfo == null) return null; + + ProfileModuleBuilder moduleBuilder = null; + foreach (var builder in imageModuleMap_.Values) { + if (builder.Summary == function.ParentSummary) { + moduleBuilder = builder; + break; + } + } + if (moduleBuilder == null) return null; + + // Find the binary file (may download from symbol server) + var binFile = await moduleBuilder.FindBinaryFilePath(symbolSettings); + if (binFile == null || !binFile.Found) return null; + + // Create disassembler and decode + using var disasm = Disassembler.CreateForBinary(binFile.FilePath, moduleBuilder.DebugInfo, null); + if (disasm == null) return null; + + return disasm.DisassembleToText(funcDebugInfo); + } + public async Task LoadTraceAsync(string tracePath, List processIds, ProfileDataProviderOptions options, SymbolFileSourceSettings symbolSettings, diff --git a/src/ProfileExplorerUI/App.xaml.cs b/src/ProfileExplorerUI/App.xaml.cs index 65f10f6b..161e23b1 100644 --- a/src/ProfileExplorerUI/App.xaml.cs +++ b/src/ProfileExplorerUI/App.xaml.cs @@ -628,8 +628,11 @@ protected override void OnStartup(StartupEventArgs e) { var mainWindow = new MainWindow(); mainWindow.Show(); - // Initialize MCP server if enabled - InitializeMcpServerAsync(mainWindow); + // Initialize embedded MCP server only when explicitly requested. + // The headless ProfileExplorer.McpServer is preferred for AI assistant workflows. + if (Array.Exists(e.Args, a => a.Equals("--mcp", StringComparison.OrdinalIgnoreCase))) { + InitializeMcpServerAsync(mainWindow); + } } private void InitializeMcpServerAsync(MainWindow mainWindow) From 1ddfdcb7ba0e69abbce5ea82309ce013d1819e89 Mon Sep 17 00:00:00 2001 From: Jeff Bromberger Date: Wed, 18 Feb 2026 18:32:52 -0600 Subject: [PATCH 2/7] PR updates from copilot feedback - Wrap CancelableTask in using statement to prevent resource leak - Dispose previous ETWProfileDataProvider when loading a new trace - Remove unused sortedOffsets variable (dead code) - Remove unused LoadedProcessName property (dead code) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ProfileExplorer.McpServer/Program.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/ProfileExplorer.McpServer/Program.cs b/src/ProfileExplorer.McpServer/Program.cs index 959ec827..595f70b5 100644 --- a/src/ProfileExplorer.McpServer/Program.cs +++ b/src/ProfileExplorer.McpServer/Program.cs @@ -45,7 +45,6 @@ public static class ProfileSession public static ETWProfileDataProvider? Provider { get; set; } public static SymbolFileSourceSettings? SymbolSettings { get; set; } public static string? LoadedFilePath { get; set; } - public static string? LoadedProcessName { get; set; } public static List LoadedProcessIds { get; set; } = new(); public static TimeSpan TotalWeight { get; set; } @@ -185,7 +184,7 @@ public static string OpenTrace( ProfileSession.LoadedProcessIds = processIds; - var cancelTask3 = new CancelableTask(); + using var cancelTask3 = new CancelableTask(); var profile = await provider.LoadTraceAsync( profileFilePath, processIds, @@ -206,6 +205,7 @@ public static string OpenTrace( ProfileSession.TotalWeight = totalWeight; ProfileSession.LoadedProfile = profile; ProfileSession.LoadedFilePath = profileFilePath; + (ProfileSession.Provider as IDisposable)?.Dispose(); ProfileSession.Provider = provider; return profile; } @@ -459,10 +459,8 @@ public static async Task GetFunctionAssembly(string functionName) // sample's weight to the preceding valid instruction offset (same as UI's // TryFindElementForOffset with InitialMultiplier=1). var adjustedWeights = data.InstructionWeight; - long[]? sortedOffsets = null; if (disasmMap != null && data.InstructionWeight != null) { - sortedOffsets = disasmMap.Keys.OrderBy(k => k).ToArray(); var adjusted = new Dictionary(); foreach (var kv in data.InstructionWeight) { From 76b787668d3303ff8a22a6e9ff7ada56a0c67e52 Mon Sep 17 00:00:00 2001 From: Jeff Bromberger Date: Wed, 18 Feb 2026 20:07:23 -0600 Subject: [PATCH 3/7] MCP server: clean state, concurrency guard, logging, symbol report - Add ClearResolvedCache() to PDBDebugInfoProvider and PEBinaryInfoProvider to clear static symbol/binary caches between trace loads - Add ProfileSession.Reset() that clears all session state and caches - Add SemaphoreSlim concurrency guard to prevent overlapping OpenTrace calls - Add CloseTrace tool to abandon stuck loads and reset state - Force-enable DiagnosticLogger (always-on for headless MCP server) - Add [MCP] tagged DiagnosticLogger calls for tool invocations, params, process matching, symbol path setup, task lifecycle, and errors - Surface per-module symbol resolution report in GetTraceLoadStatus (SymbolsLoaded, BinaryState, PdbPath, BinaryPath per module) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ProfileExplorer.McpServer/Program.cs | 107 +++++++++++++++++- .../Binary/PDBDebugInfoProvider.cs | 10 ++ .../Binary/PEBinaryInfoProvider.cs | 10 ++ 3 files changed, 121 insertions(+), 6 deletions(-) diff --git a/src/ProfileExplorer.McpServer/Program.cs b/src/ProfileExplorer.McpServer/Program.cs index 595f70b5..234c7d73 100644 --- a/src/ProfileExplorer.McpServer/Program.cs +++ b/src/ProfileExplorer.McpServer/Program.cs @@ -20,6 +20,9 @@ public class Program { public static async Task Main(string[] args) { + // Force-enable diagnostic logging — the MCP server is headless, always-on logging is essential. + Environment.SetEnvironmentVariable("PROFILE_EXPLORER_DEBUG", "1"); + var builder = Host.CreateDefaultBuilder() .ConfigureLogging(logging => { @@ -32,6 +35,8 @@ public static async Task Main(string[] args) .WithToolsFromAssembly(); }); + DiagnosticLogger.LogInfo("[MCP] ProfileExplorer MCP Server starting"); + DiagnosticLogger.LogInfo($"[MCP] Log file: {DiagnosticLogger.LogFilePath}"); await builder.Build().RunAsync(); } } @@ -47,12 +52,39 @@ public static class ProfileSession public static string? LoadedFilePath { get; set; } public static List LoadedProcessIds { get; set; } = new(); public static TimeSpan TotalWeight { get; set; } + public static ProfileDataReport? Report { get; set; } // Async loading state public static Task? PendingLoad { get; set; } public static string? PendingFilePath { get; set; } public static string? PendingProcessId { get; set; } public static Exception? LoadException { get; set; } + + // Concurrency guard — only one trace load at a time. + public static readonly SemaphoreSlim LoadSemaphore = new(1, 1); + + /// + /// Resets all session state and static caches for a clean trace load. + /// + public static void Reset() + { + (Provider as IDisposable)?.Dispose(); + LoadedProfile = null; + Provider = null; + SymbolSettings = null; + LoadedFilePath = null; + LoadedProcessIds = new(); + TotalWeight = TimeSpan.Zero; + Report = null; + PendingLoad = null; + PendingFilePath = null; + PendingProcessId = null; + LoadException = null; + + // Clear static resolution caches so each trace starts fresh. + PDBDebugInfoProvider.ClearResolvedCache(); + PEBinaryInfoProvider.ClearResolvedCache(); + } } [McpServerToolType] @@ -68,6 +100,9 @@ public static string GetAvailableProcesses( [Description("Limit results to top N heaviest processes")] int? topCount = null) { + DiagnosticLogger.LogInfo($"[MCP] GetAvailableProcesses called: profileFilePath={profileFilePath}, minWeightPercentage={minWeightPercentage}, topCount={topCount}"); + var sw = Stopwatch.StartNew(); + if (!File.Exists(profileFilePath)) return Error("GetAvailableProcesses", $"File not found: {profileFilePath}"); @@ -121,6 +156,7 @@ public static string GetAvailableProcesses( } catch (Exception ex) { + DiagnosticLogger.LogError($"[MCP] GetAvailableProcesses failed: {ex.Message}", ex); return Error("GetAvailableProcesses", ex.Message); } } @@ -133,14 +169,23 @@ public static string OpenTrace( [Description("Optional additional symbol search path (e.g. 'd:\\temp\\landy' for custom kernel symbols)")] string? symbolPath = null) { + DiagnosticLogger.LogInfo($"[MCP] OpenTrace called: profileFilePath={profileFilePath}, processNameOrId={processNameOrId}, symbolPath={symbolPath ?? "(none)"}"); + if (!File.Exists(profileFilePath)) return Error("OpenTrace", $"File not found: {profileFilePath}"); - // Reset state - ProfileSession.LoadedProfile = null; - ProfileSession.LoadException = null; + // Concurrency guard — only one trace load at a time. + if (!ProfileSession.LoadSemaphore.Wait(0)) + { + DiagnosticLogger.LogWarning("[MCP] OpenTrace rejected — another trace is currently loading"); + return Error("OpenTrace", "A trace is already loading. Wait for it to complete or call CloseTrace first."); + } + + // Reset all session state and static caches for a clean load. + ProfileSession.Reset(); ProfileSession.PendingFilePath = profileFilePath; ProfileSession.PendingProcessId = processNameOrId; + var loadStopwatch = Stopwatch.StartNew(); ProfileSession.PendingLoad = Task.Run(async () => { @@ -152,6 +197,10 @@ public static string OpenTrace( if (!string.IsNullOrWhiteSpace(symbolPath)) symbolSettings.InsertSymbolPath(symbolPath); ProfileSession.SymbolSettings = symbolSettings; + + DiagnosticLogger.LogInfo($"[MCP] SymbolSettings: UseEnvVar=true, CustomPath={symbolPath ?? "(none)"}, EnvVar={symbolSettings.EnvironmentVarSymbolPath ?? "(not set)"}"); + DiagnosticLogger.LogInfo($"[MCP] SymbolPaths: {string.Join("; ", symbolSettings.SymbolPaths)}"); + var report = new ProfileDataReport(); var provider = new ETWProfileDataProvider(); @@ -163,6 +212,7 @@ public static string OpenTrace( { // All parts are numeric — explicit PID list processIds = parts.Select(int.Parse).ToList(); + DiagnosticLogger.LogInfo($"[MCP] Using explicit PIDs: {string.Join(", ", processIds)}"); } else { @@ -180,6 +230,7 @@ public static string OpenTrace( throw new Exception($"Process '{processNameOrId}' not found in trace"); processIds = matches.Select(s => s.Process.ProcessId).ToList(); + DiagnosticLogger.LogInfo($"[MCP] Matched process '{processNameOrId}' to {matches.Count} PID(s): {string.Join(", ", processIds)}"); } ProfileSession.LoadedProcessIds = processIds; @@ -205,16 +256,22 @@ public static string OpenTrace( ProfileSession.TotalWeight = totalWeight; ProfileSession.LoadedProfile = profile; ProfileSession.LoadedFilePath = profileFilePath; - (ProfileSession.Provider as IDisposable)?.Dispose(); ProfileSession.Provider = provider; + ProfileSession.Report = report; + + DiagnosticLogger.LogInfo($"[MCP] OpenTrace completed in {loadStopwatch.ElapsedMilliseconds}ms — {profile.FunctionProfiles.Count} functions, {report.Modules?.Count ?? 0} modules"); return profile; } catch (Exception ex) { ProfileSession.LoadException = ex; - Trace.TraceError($"OpenTrace failed: {ex}"); + DiagnosticLogger.LogError($"[MCP] OpenTrace failed after {loadStopwatch.ElapsedMilliseconds}ms: {ex.Message}", ex); return null; } + finally + { + ProfileSession.LoadSemaphore.Release(); + } }); var loadingResult = new @@ -256,6 +313,23 @@ public static string GetTraceLoadStatus() if (ProfileSession.LoadedProfile != null) { ProfileSession.PendingLoad = null; + + // Build symbol resolution summary from the report. + object[]? moduleReport = null; + if (ProfileSession.Report?.Modules != null) + { + moduleReport = ProfileSession.Report.Modules + .OrderByDescending(m => m.HasDebugInfoLoaded ? 0 : 1) + .Select(m => (object)new + { + Module = m.ImageFileInfo?.ImageName ?? "Unknown", + SymbolsLoaded = m.HasDebugInfoLoaded, + BinaryState = m.State.ToString(), + PdbPath = m.DebugInfoFile?.FilePath, + BinaryPath = m.BinaryFileInfo?.FilePath + }).ToArray(); + } + return JsonSerializer.Serialize(new { Action = "GetTraceLoadStatus", @@ -265,6 +339,7 @@ public static string GetTraceLoadStatus() ProcessIds = ProfileSession.LoadedProcessIds, FunctionCount = ProfileSession.LoadedProfile.FunctionProfiles.Count, ModuleCount = ProfileSession.LoadedProfile.Modules?.Count ?? 0, + SymbolResolution = moduleReport, Timestamp = DateTime.UtcNow }, JsonOpts); } @@ -400,6 +475,9 @@ public static string GetAvailableBinaries( [McpServerTool, Description("Get assembly code for a specific function by name")] public static async Task GetFunctionAssembly(string functionName) { + DiagnosticLogger.LogInfo($"[MCP] GetFunctionAssembly called: functionName={functionName}"); + var sw = Stopwatch.StartNew(); + var profile = ProfileSession.LoadedProfile; if (profile == null) return Error("GetFunctionAssembly", "No profile loaded. Call OpenTrace and wait for completion first."); @@ -562,6 +640,8 @@ public static string GetFunctionCallerCallee( [Description("Max number of full back-traces (call stacks) to return (default 5)")] int? maxBacktraces = null) { + DiagnosticLogger.LogInfo($"[MCP] GetFunctionCallerCallee called: functionName={functionName}, maxCallers={maxCallers}, maxCallees={maxCallees}, maxBacktraces={maxBacktraces}"); + var profile = ProfileSession.LoadedProfile; if (profile == null) return Error("GetFunctionCallerCallee", "No profile loaded. Call OpenTrace and wait for completion first."); @@ -660,6 +740,20 @@ public static string GetFunctionCallerCallee( return JsonSerializer.Serialize(result, JsonOpts); } + [McpServerTool, Description("Close the currently loaded trace and reset all session state. Use this to abandon a stuck load or free resources before loading a new trace.")] + public static string CloseTrace() + { + DiagnosticLogger.LogInfo("[MCP] CloseTrace called"); + ProfileSession.Reset(); + return JsonSerializer.Serialize(new + { + Action = "CloseTrace", + Status = "Success", + Description = "Trace closed and all session state reset.", + Timestamp = DateTime.UtcNow + }, JsonOpts); + } + [McpServerTool, Description("Get help information about available MCP commands")] public static string GetHelp() { @@ -675,7 +769,8 @@ public static string GetHelp() "3. GetTraceLoadStatus() — poll until 'Complete'", "4. GetAvailableFunctions/GetAvailableBinaries — query the loaded profile", "5. GetFunctionAssembly(name) — get instruction-level hotspot data", - "6. GetFunctionCallerCallee(name) — get callers, callees, and full call stacks" + "6. GetFunctionCallerCallee(name) — get callers, callees, and full call stacks", + "7. CloseTrace() — close trace and reset state (required before loading a new trace)" } }; return JsonSerializer.Serialize(help, JsonOpts); diff --git a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs index 9a04b354..09ea8f6f 100644 --- a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs +++ b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs @@ -62,6 +62,16 @@ public sealed class PDBDebugInfoProvider : IDebugInfoProvider { private static bool diaRegistrationFailed_; private static string diaRegistrationError_; + /// + /// Clears the static resolved symbols cache and resets logging state. + /// Call between trace loads to ensure a clean resolution state. + /// + public static void ClearResolvedCache() { + resolvedSymbolsCache_.Clear(); + loggedSymbolPathDetails_ = false; + lastLoggedAuthFailedState_ = false; + } + /// /// Returns true if DIA SDK (msdia140.dll) failed to load due to COM registration issues. /// diff --git a/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs b/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs index 709c71ad..b8edc6e6 100644 --- a/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs +++ b/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs @@ -65,6 +65,16 @@ public override string ToString() { public sealed class PEBinaryInfoProvider : IBinaryInfoProvider, IDisposable { private static ConcurrentDictionary resolvedBinariesCache_ = new(); private static ConcurrentDictionary versionInfoCache_ = new(); + + /// + /// Clears the static resolved binaries and version info caches. + /// Call between trace loads to ensure a clean resolution state. + /// + public static void ClearResolvedCache() { + resolvedBinariesCache_.Clear(); + versionInfoCache_.Clear(); + } + private string filePath_; private PEReader reader_; From adf503cba15d3befa9a0124d141546fcc46a3af5 Mon Sep 17 00:00:00 2001 From: Jeff Bromberger Date: Thu, 19 Feb 2026 08:18:02 -0600 Subject: [PATCH 4/7] Add FunctionPct to caller/callee/backtrace output GetFunctionCallerCallee now returns both InclusivePct (percentage of total trace time) and FunctionPct (percentage of this function's time). FunctionPct matches the GUI's drill-down behavior where percentages are relative to the selected function, not the entire trace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ProfileExplorer.McpServer/Program.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ProfileExplorer.McpServer/Program.cs b/src/ProfileExplorer.McpServer/Program.cs index 234c7d73..e33be18d 100644 --- a/src/ProfileExplorer.McpServer/Program.cs +++ b/src/ProfileExplorer.McpServer/Program.cs @@ -655,6 +655,7 @@ public static string GetFunctionCallerCallee( var data = profile.FunctionProfiles[match]; var totalWeightMs = ProfileSession.TotalWeight.TotalMilliseconds; + var functionWeightMs = data.Weight.TotalMilliseconds; int callerLimit = maxCallers ?? 10; int calleeLimit = maxCallees ?? 10; int backtraceLimit = maxBacktraces ?? 5; @@ -685,7 +686,8 @@ public static string GetFunctionCallerCallee( { Function = kv.Key, InclusiveTimeMs = Math.Round(kv.Value.weight.TotalMilliseconds, 2), - InclusivePct = totalWeightMs > 0 ? Math.Round(kv.Value.weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0 + InclusivePct = totalWeightMs > 0 ? Math.Round(kv.Value.weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + FunctionPct = functionWeightMs > 0 ? Math.Round(kv.Value.weight.TotalMilliseconds / functionWeightMs * 100, 2) : 0 }).ToArray(); // Aggregate callees from the combined node's children @@ -702,6 +704,7 @@ public static string GetFunctionCallerCallee( Function = $"{c.Function.ModuleName}!{c.Function.Name}", InclusiveTimeMs = Math.Round(c.Weight.TotalMilliseconds, 2), InclusivePct = totalWeightMs > 0 ? Math.Round(c.Weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + FunctionPct = functionWeightMs > 0 ? Math.Round(c.Weight.TotalMilliseconds / functionWeightMs * 100, 2) : 0, SelfTimeMs = Math.Round(c.ExclusiveWeight.TotalMilliseconds, 2), SelfPct = totalWeightMs > 0 ? Math.Round(c.ExclusiveWeight.TotalMilliseconds / totalWeightMs * 100, 2) : 0 }).ToArray(); @@ -717,6 +720,7 @@ public static string GetFunctionCallerCallee( { WeightMs = Math.Round(inst.Weight.TotalMilliseconds, 2), WeightPct = totalWeightMs > 0 ? Math.Round(inst.Weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, + FunctionPct = functionWeightMs > 0 ? Math.Round(inst.Weight.TotalMilliseconds / functionWeightMs * 100, 2) : 0, Stack = bt.Select(n => $"{n.Function?.ModuleName}!{n.Function?.Name}").ToArray() }; }).ToArray(); From a7e0d91b9481077de43cc4a0f9ddac90411c95ca Mon Sep 17 00:00:00 2001 From: Jeff Bromberger Date: Thu, 19 Feb 2026 09:08:09 -0600 Subject: [PATCH 5/7] Fix function name resolution to use PDB-resolved names ResolveFunctionName helpers look up FunctionDebugInfo.Name from profile data instead of using IRTextFunction.Name (which is frozen as a hex placeholder during ETL parsing). FindFunction now searches by resolved PDB name first. All display points updated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ProfileExplorer.McpServer/Program.cs | 37 ++++++++++++++++++++---- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/ProfileExplorer.McpServer/Program.cs b/src/ProfileExplorer.McpServer/Program.cs index e33be18d..a185d10a 100644 --- a/src/ProfileExplorer.McpServer/Program.cs +++ b/src/ProfileExplorer.McpServer/Program.cs @@ -9,6 +9,7 @@ using ModelContextProtocol.Server; using ProfileExplorer.Core; using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.Profile.CallTree; using ProfileExplorer.Core.Profile.Data; using ProfileExplorer.Core.Profile.ETW; using ProfileExplorer.Core.Settings; @@ -377,7 +378,7 @@ public static string GetAvailableFunctions( double totalPct = totalWeightMs > 0 ? data.Weight.TotalMilliseconds / totalWeightMs * 100 : 0; return new { - Name = func.Name, + Name = ResolveFunctionName(func), ModuleName = func.ModuleName ?? "Unknown", SelfTimePercentage = Math.Round(selfPct, 2), TotalTimePercentage = Math.Round(totalPct, 2), @@ -617,7 +618,7 @@ public static async Task GetFunctionAssembly(string functionName) { Action = "GetFunctionAssembly", Status = "Success", - FunctionName = match.Name, + FunctionName = ResolveFunctionName(match), ModuleName = match.ModuleName ?? "Unknown", SelfTime = data.ExclusiveWeight.ToString(), TotalTime = data.Weight.ToString(), @@ -672,7 +673,7 @@ public static string GetFunctionCallerCallee( foreach (var caller in inst.Callers) { if (caller?.Function == null) continue; - var key = $"{caller.Function.ModuleName}!{caller.Function.Name}"; + var key = $"{caller.Function.ModuleName}!{ResolveFunctionName(caller)}"; if (!callerAgg.TryGetValue(key, out var existing)) existing = (TimeSpan.Zero, TimeSpan.Zero, caller.Function.ModuleName ?? "Unknown"); callerAgg[key] = (existing.weight + caller.Weight, existing.exclusiveWeight + caller.ExclusiveWeight, existing.module); @@ -701,7 +702,7 @@ public static string GetFunctionCallerCallee( .Take(calleeLimit) .Select(c => new { - Function = $"{c.Function.ModuleName}!{c.Function.Name}", + Function = $"{c.Function.ModuleName}!{ResolveFunctionName(c)}", InclusiveTimeMs = Math.Round(c.Weight.TotalMilliseconds, 2), InclusivePct = totalWeightMs > 0 ? Math.Round(c.Weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, FunctionPct = functionWeightMs > 0 ? Math.Round(c.Weight.TotalMilliseconds / functionWeightMs * 100, 2) : 0, @@ -721,7 +722,7 @@ public static string GetFunctionCallerCallee( WeightMs = Math.Round(inst.Weight.TotalMilliseconds, 2), WeightPct = totalWeightMs > 0 ? Math.Round(inst.Weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, FunctionPct = functionWeightMs > 0 ? Math.Round(inst.Weight.TotalMilliseconds / functionWeightMs * 100, 2) : 0, - Stack = bt.Select(n => $"{n.Function?.ModuleName}!{n.Function?.Name}").ToArray() + Stack = bt.Select(n => $"{n.Function?.ModuleName}!{ResolveFunctionName(n)}").ToArray() }; }).ToArray(); @@ -729,7 +730,7 @@ public static string GetFunctionCallerCallee( { Action = "GetFunctionCallerCallee", Status = "Success", - FunctionName = match.Name, + FunctionName = ResolveFunctionName(match), ModuleName = match.ModuleName ?? "Unknown", SelfTime = data.ExclusiveWeight.ToString(), TotalTime = data.Weight.ToString(), @@ -781,11 +782,35 @@ public static string GetHelp() } // Prefer exact name match, then fall back to Contains + /// + /// Returns the PDB-resolved name for a function, falling back to IRTextFunction.Name (hex placeholder). + /// + private static string ResolveFunctionName(IRTextFunction func) + { + var data = ProfileSession.LoadedProfile?.FunctionProfiles.GetValueOrDefault(func); + var debugName = data?.FunctionDebugInfo?.Name; + return !string.IsNullOrEmpty(debugName) ? debugName : func.Name; + } + + /// + /// Returns the PDB-resolved name for a call tree node. + /// + private static string ResolveFunctionName(ProfileCallTreeNode node) + { + var debugName = node.FunctionDebugInfo?.Name; + return !string.IsNullOrEmpty(debugName) ? debugName : node.Function?.Name ?? "Unknown"; + } + private static IRTextFunction? FindFunction(ProfileData profile, string functionName) { + // Search by resolved PDB name first, then by IRTextFunction.Name (hex placeholder) return profile.FunctionProfiles.Keys + .FirstOrDefault(f => ResolveFunctionName(f).Equals(functionName, StringComparison.OrdinalIgnoreCase)) + ?? profile.FunctionProfiles.Keys .FirstOrDefault(f => f.Name.Equals(functionName, StringComparison.OrdinalIgnoreCase)) ?? profile.FunctionProfiles.Keys + .FirstOrDefault(f => ResolveFunctionName(f).Contains(functionName, StringComparison.OrdinalIgnoreCase)) + ?? profile.FunctionProfiles.Keys .FirstOrDefault(f => f.Name.Contains(functionName, StringComparison.OrdinalIgnoreCase)); } From e6de8e0ef8e4b8d41f86d3920bae07ea5725270a Mon Sep 17 00:00:00 2001 From: Jeff Bromberger Date: Thu, 19 Feb 2026 09:22:24 -0600 Subject: [PATCH 6/7] Update ARCHITECTURE.md for CloseTrace, FunctionPct, name resolution, logging Document new CloseTrace tool, expanded ProfileSession fields, ResolveFunctionName helpers, FunctionPct vs WeightPct distinction, diagnostic logging, custom symbol path support, and updated tool count from 8 to 9. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ARCHITECTURE.md | 46 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c124eeba..822eec4c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -135,7 +135,7 @@ There are two MCP-related projects in the repo. Only the headless server is acti | **Architecture** | Library defining `IMcpActionExecutor` interface. The WPF app implements this interface (`McpActionExecutor.cs`) and routes MCP calls through the UI dispatcher. | Standalone console exe. Calls `ProfileExplorerCore` APIs directly — no WPF, no dispatcher. | | **Status** | **Not used.** Deadlocks when the WPF `Dispatcher.Invoke` blocks the MCP thread waiting for UI operations. | **Active.** All AI assistant workflows use this server. | | **Coupling** | Loose — interface-based. Requires UI to implement the executor. | Tight — directly uses `ETWProfileDataProvider`, `ProfileData`, `CallTree`, `Disassembler`. | -| **Tools** | 6 tools via interface (`OpenTrace`, `GetStatus`, `GetAvailableProcesses`, `GetAvailableFunctions`, `GetAvailableBinaries`, `GetFunctionAssemblyToFile`) | 8 tools with richer features: multi-process selection, caller/callee analysis, Capstone disassembly with per-instruction source lines and inline function info. | +| **Tools** | 6 tools via interface (`OpenTrace`, `GetStatus`, `GetAvailableProcesses`, `GetAvailableFunctions`, `GetAvailableBinaries`, `GetFunctionAssemblyToFile`) | 9 tools with richer features: multi-process selection, custom symbol paths, caller/callee with FunctionPct, CloseTrace for state reset, Capstone disassembly with per-instruction source lines and inline function info. | | **State** | Stateless — each call is dispatched to the UI which holds state. | Stateful — `ProfileSession` singleton holds loaded profile, provider, and symbol settings. | The embedded approach (`ProfileExplorer.Mcp`) was the repo's original attempt at MCP support. It works conceptually but in practice the WPF dispatcher serialization creates deadlocks: MCP tool calls block on `Dispatcher.Invoke`, but the dispatcher is waiting for previous work to complete. The headless server (`ProfileExplorer.McpServer`) solves this by bypassing the UI entirely. @@ -164,30 +164,60 @@ Registered in `~/.copilot/mcp-config.json` as `profile-explorer`: | Tool | Description | |------|-------------| | `GetAvailableProcesses` | List processes in a trace file with weight percentages. Filtering by min weight %, top N. | -| `OpenTrace` | Start async loading of a trace for a specific process. Returns immediately. | +| `OpenTrace` | Start async loading of a trace for a specific process. Optional `symbolPath` for custom/private PDBs. Returns immediately. | | `GetTraceLoadStatus` | Poll loading progress. Returns Loading/Complete/Failed. | -| `GetAvailableFunctions` | List functions with self-time/total-time %. Filter by module, min %, top N. | +| `CloseTrace` | Close the current trace and fully reset session state (including symbol caches). Required before loading a new trace. | +| `GetAvailableFunctions` | List functions with self-time/total-time %. Filter by module, min %, top N. Uses PDB-resolved names. | | `GetAvailableBinaries` | List modules/DLLs aggregated by CPU time. Filter by min %, top N. | | `GetFunctionAssembly` | Instruction-level hotspots with Capstone disassembly, source line mapping, and inline function info. | -| `GetFunctionCallerCallee` | Callers, callees, and full backtraces for a function. | +| `GetFunctionCallerCallee` | Callers, callees, and full backtraces for a function. Includes both trace-relative (`WeightPct`) and function-relative (`FunctionPct`) percentages. | | `GetHelp` | Usage workflow documentation. | ### Session Model `ProfileSession` is a static singleton holding loaded state: - `LoadedProfile` — the `ProfileData` after successful load -- `Provider` — kept alive for on-demand source line resolution +- `Provider` — kept alive for on-demand source line resolution and disassembly - `PendingLoad` — `Task` for async loading - `TotalWeight` — pre-computed total weight for percentage calculations +- `LoadSemaphore` — concurrency guard preventing overlapping trace loads +- `SymbolSettings` — `SymbolFileSourceSettings` used for the current load (needed for disassembly) +- `LoadException` — captured exception if loading fails +- `LoadedFilePath` / `PendingFilePath` / `PendingProcessId` — track current and pending load targets +- `LoadedProcessIds` — list of process IDs included in the loaded profile +- `Report` — symbol resolution report with per-module resolution stats + +`Reset()` clears all state including static symbol caches (`PDBDebugInfoProvider.ClearResolvedCache()`), ensuring no stale negative caching between loads with different symbol paths. + +### Function Name Resolution + +`IRTextFunction.Name` is frozen as a hex placeholder (e.g., `28BC63`) during ETL parsing when PDB symbols are not yet loaded. The actual resolved name (e.g., `ExpWaitForSpinLockSharedAndAcquire`) lives on `FunctionDebugInfo.Name`, accessed via `FunctionProfileData.FunctionDebugInfo`. + +Two `ResolveFunctionName` helpers handle the lookup: +- `ResolveFunctionName(IRTextFunction)` — looks up `profile.FunctionProfiles[func].FunctionDebugInfo?.Name` +- `ResolveFunctionName(ProfileCallTreeNode)` — uses `node.FunctionDebugInfo?.Name` + +`FindFunction` searches by resolved PDB name first, falling back to the hex name. + +### FunctionPct vs WeightPct + +Call stack data includes two percentage types: +- `WeightPct` / `InclusivePct` — percentage of entire trace time (weight ÷ totalTraceWeight × 100) +- `FunctionPct` — percentage of the target function's time (weight ÷ functionWeight × 100), matching the GUI's drill-down view + +### Diagnostic Logging + +Structured diagnostic logging via `DiagnosticLogger` traces MCP operations, function resolution, and symbol loading. Log files are written alongside the trace file with timestamps. ### Typical Workflow 1. `GetAvailableProcesses(filePath)` — discover processes in a trace -2. `OpenTrace(filePath, processId)` — start loading (async) +2. `OpenTrace(filePath, processId, symbolPath?)` — start loading (async), optionally with custom symbol path 3. `GetTraceLoadStatus()` — poll until Complete 4. `GetAvailableFunctions(topCount: 20)` — find hot functions 5. `GetFunctionAssembly(name)` — drill into instruction-level hotspots -6. `GetFunctionCallerCallee(name)` — understand call context +6. `GetFunctionCallerCallee(name)` — understand call context (with FunctionPct for drill-down) +7. `CloseTrace()` — reset state before loading a different trace --- @@ -255,7 +285,7 @@ The core and UI projects have additional dependencies (TraceEvent for ETW, DIA S | Limitation | Impact | |-----------|--------| | Windows only | ETW and WPF are Windows-specific technologies | -| One trace at a time (MCP) | The headless MCP server holds a single `ProfileSession` — loading a new trace replaces the previous one | +| One trace at a time (MCP) | The headless MCP server holds a single `ProfileSession` — call `CloseTrace()` before loading a new trace | | Symbol server latency | First load of a trace can be slow if PDBs need downloading from symbol servers | | Large trace memory | Very large traces consume significant memory for the in-memory call tree and profile data | | Release builds required | The user's MCP config points to Release output; Debug builds won't be picked up | From 98588a1e13eb79b6e13eec6ef61c1bc3878673c4 Mon Sep 17 00:00:00 2001 From: Jeff Bromberger Date: Tue, 24 Mar 2026 17:41:12 -0500 Subject: [PATCH 7/7] Address PR review feedback - Revert --mcp flag gate: embedded MCP server starts unconditionally again (per ivberg) - Clarify ARCHITECTURE.md: 2 MCP servers (embedded UI + headless console), not 3 - Bump ModelContextProtocol NuGet package from 0.3.0-preview.4 to 1.1.0 (stable) in both ProfileExplorer.McpServer and ProfileExplorer.Mcp projects (per trgibeau) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ARCHITECTURE.md | 36 ++++++++++++------- .../ProfileExplorer.Mcp.csproj | 2 +- .../ProfileExplorer.McpServer.csproj | 2 +- src/ProfileExplorerUI/App.xaml.cs | 7 ++-- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 822eec4c..3dfa6219 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -44,8 +44,8 @@ The application was originally a compiler IR viewer and retains the ability to p |---------|------|-------------| | **ProfileExplorerUI** | WPF app (.NET 8) | Main desktop application. All UI panels, session management, profiling views, scripting, VS extension integration. | | **ProfileExplorerCore** | Class library | UI-independent engine: ETW trace loading, profile data model, call tree construction, IR parsing, binary analysis, symbol resolution, Capstone disassembly, Tree-sitter parsing, graph algorithms. | -| **ProfileExplorer.McpServer** | Console app (.NET 8) | Headless MCP server exposing profiling tools over stdio. Uses `ProfileExplorerCore` directly — no UI dependency. **This is the active MCP server.** | -| **ProfileExplorer.Mcp** | Class library + mock exe | Original MCP server designed to be embedded inside the WPF app via `IMcpActionExecutor`. Not used by the headless server. See [MCP: Embedded vs Headless](#mcp-embedded-vs-headless) below. | +| **ProfileExplorer.McpServer** | Console app (.NET 8) | Headless MCP server exposing profiling tools over stdio. Uses `ProfileExplorerCore` directly — no UI dependency. See [MCP Servers](#mcp-servers) below. | +| **ProfileExplorer.Mcp** | Class library | Shared MCP library used by the UI's embedded MCP server. Defines `IMcpActionExecutor` interface, tool routing (`ProfileExplorerTools`), and server configuration. Also includes a mock executor (`Program.cs`) for standalone testing of the MCP plumbing. | | **ProfileExplorerCoreTests** | xUnit tests | Unit tests for the core library. | | **ProfileExplorerUITests** | xUnit tests | Unit tests for UI logic. | | **ManagedProfiler** | .NET profiler | JIT profiler extension for capturing JIT output assembly. | @@ -126,19 +126,31 @@ Key settings in `SymbolFileSourceSettings`: --- -## MCP: Embedded vs Headless +## MCP Servers -There are two MCP-related projects in the repo. Only the headless server is actively used. +Profile Explorer exposes two MCP (Model Context Protocol) servers for AI assistant integration: -| Aspect | `ProfileExplorer.Mcp` (embedded) | `ProfileExplorer.McpServer` (headless) | -|--------|----------------------------------|----------------------------------------| -| **Architecture** | Library defining `IMcpActionExecutor` interface. The WPF app implements this interface (`McpActionExecutor.cs`) and routes MCP calls through the UI dispatcher. | Standalone console exe. Calls `ProfileExplorerCore` APIs directly — no WPF, no dispatcher. | -| **Status** | **Not used.** Deadlocks when the WPF `Dispatcher.Invoke` blocks the MCP thread waiting for UI operations. | **Active.** All AI assistant workflows use this server. | -| **Coupling** | Loose — interface-based. Requires UI to implement the executor. | Tight — directly uses `ETWProfileDataProvider`, `ProfileData`, `CallTree`, `Disassembler`. | -| **Tools** | 6 tools via interface (`OpenTrace`, `GetStatus`, `GetAvailableProcesses`, `GetAvailableFunctions`, `GetAvailableBinaries`, `GetFunctionAssemblyToFile`) | 9 tools with richer features: multi-process selection, custom symbol paths, caller/callee with FunctionPct, CloseTrace for state reset, Capstone disassembly with per-instruction source lines and inline function info. | -| **State** | Stateless — each call is dispatched to the UI which holds state. | Stateful — `ProfileSession` singleton holds loaded profile, provider, and symbol settings. | +### 1. Embedded MCP (UI) -The embedded approach (`ProfileExplorer.Mcp`) was the repo's original attempt at MCP support. It works conceptually but in practice the WPF dispatcher serialization creates deadlocks: MCP tool calls block on `Dispatcher.Invoke`, but the dispatcher is waiting for previous work to complete. The headless server (`ProfileExplorer.McpServer`) solves this by bypassing the UI entirely. +The WPF desktop app (`ProfileExplorerUI`) starts an embedded MCP stdio server on launch. This uses the `ProfileExplorer.Mcp` library: + +- `ProfileExplorer.Mcp` defines the `IMcpActionExecutor` interface and tool routing +- `ProfileExplorerUI` implements `McpActionExecutor`, which translates MCP tool calls into UI actions via the WPF dispatcher +- This enables "co-investigation" — a user interacts with the UI while an AI assistant simultaneously drives actions + +The library also includes a `MockMcpActionExecutor` (`Program.cs`) for standalone testing of the MCP plumbing without the full UI. + +### 2. Headless MCP (Console) + +`ProfileExplorer.McpServer` is a standalone console application that calls `ProfileExplorerCore` APIs directly — no WPF, no dispatcher, no UI dependency. It is the preferred server for pure AI-driven analysis workflows. + +| Aspect | Embedded (UI) | Headless (Console) | +|--------|---------------|-------------------| +| **Binary** | `ProfileExplorerUI.exe` | `ProfileExplorer.McpServer.exe` | +| **Engine** | `ProfileExplorer.Mcp` library → `IMcpActionExecutor` → WPF dispatcher | `ProfileExplorerCore` directly | +| **Coupling** | Loose — interface-based, requires UI | Tight — direct API calls, no UI | +| **Tools** | 6 tools via interface (`OpenTrace`, `GetStatus`, `GetAvailableProcesses`, `GetAvailableFunctions`, `GetAvailableBinaries`, `GetFunctionAssemblyToFile`) | 9 tools with richer features: multi-process selection, custom symbol paths, caller/callee with FunctionPct, CloseTrace for state reset, Capstone disassembly with per-instruction source lines and inline function info | +| **State** | Stateless — each call dispatched to UI which holds state | Stateful — `ProfileSession` singleton holds loaded profile, provider, and symbol settings | --- diff --git a/src/ProfileExplorer.Mcp/ProfileExplorer.Mcp.csproj b/src/ProfileExplorer.Mcp/ProfileExplorer.Mcp.csproj index 5deafdda..8fc360ce 100644 --- a/src/ProfileExplorer.Mcp/ProfileExplorer.Mcp.csproj +++ b/src/ProfileExplorer.Mcp/ProfileExplorer.Mcp.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj b/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj index 72fcaf6f..738246f7 100644 --- a/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj +++ b/src/ProfileExplorer.McpServer/ProfileExplorer.McpServer.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/ProfileExplorerUI/App.xaml.cs b/src/ProfileExplorerUI/App.xaml.cs index 161e23b1..65f10f6b 100644 --- a/src/ProfileExplorerUI/App.xaml.cs +++ b/src/ProfileExplorerUI/App.xaml.cs @@ -628,11 +628,8 @@ protected override void OnStartup(StartupEventArgs e) { var mainWindow = new MainWindow(); mainWindow.Show(); - // Initialize embedded MCP server only when explicitly requested. - // The headless ProfileExplorer.McpServer is preferred for AI assistant workflows. - if (Array.Exists(e.Args, a => a.Equals("--mcp", StringComparison.OrdinalIgnoreCase))) { - InitializeMcpServerAsync(mainWindow); - } + // Initialize MCP server if enabled + InitializeMcpServerAsync(mainWindow); } private void InitializeMcpServerAsync(MainWindow mainWindow)