Skip to content
Open
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
51 changes: 51 additions & 0 deletions src/ProfileExplorer.Mcp/IMcpActionExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ public interface IMcpActionExecutor
/// <param name="topCount">Optional number to limit results to the top N binaries by performance (e.g., 10 for top 10 most time-consuming binaries)</param>
/// <returns>Task that completes with the list of available binaries in the currently loaded process</returns>
Task<GetAvailableBinariesResult> GetAvailableBinariesAsync(double? minTimePercentage = null, TimeSpan? minTime = null, int? topCount = null);

/// <summary>
/// Closes the currently loaded trace/profile session and releases its resources.
/// Idempotent: returns Success=true with WasLoaded=false when no profile session is loaded.
/// Only closes profile sessions; non-profile sessions (e.g. IR documents) are left untouched.
/// </summary>
/// <returns>Task that completes with details about what was closed</returns>
Task<CloseTraceResult> CloseTraceAsync();
}

/// <summary>
Expand Down Expand Up @@ -127,6 +135,49 @@ public enum OpenTraceFailureReason
ProcessListLoadTimeout,
ProfileLoadTimeout,
UIError,
UnknownError,
TraceAlreadyLoaded
}

/// <summary>
/// Result of a CloseTrace operation. Idempotent — Success=true with WasLoaded=false
/// is the expected response when no trace was loaded at the time of the call.
/// </summary>
public class CloseTraceResult
{
public bool Success { get; set; }
public CloseTraceFailureReason FailureReason { get; set; } = CloseTraceFailureReason.None;
public string? ErrorMessage { get; set; }

/// <summary>
/// True if a profile session was actually loaded and closed by this call.
/// False (with Success=true) when no profile was loaded — the call is a no-op.
/// </summary>
public bool WasLoaded { get; set; }

/// <summary>
/// Path of the trace that was closed, if any.
/// </summary>
public string? ClosedProfilePath { get; set; }

/// <summary>
/// Process id that was loaded in the closed trace, if known.
/// </summary>
public int? ClosedProcessId { get; set; }

/// <summary>
/// Friendly name of the process that was loaded in the closed trace, if known.
/// </summary>
public string? ClosedProcessName { get; set; }
}

/// <summary>
/// Specific reasons why a CloseTrace operation might fail.
/// </summary>
public enum CloseTraceFailureReason
{
None,
UIError,
UnknownError
}

Expand Down
72 changes: 71 additions & 1 deletion src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public static void SetExecutor(IMcpActionExecutor executor)

#region Simplified MCP Tool

[McpServerTool, Description("Open and load a trace file with a specific process by name or ID in one complete operation")]
[McpServerTool, Description("Open and load a trace file with a specific process by name or ID. Precondition: no trace may be loaded; call close_trace first if one is. Returns Failed/TraceAlreadyLoaded otherwise.")]
public static async Task<string> OpenTrace(string profileFilePath, string processNameOrId)
{
if (string.IsNullOrWhiteSpace(profileFilePath))
Expand All @@ -75,6 +75,17 @@ public static async Task<string> OpenTrace(string profileFilePath, string proces
throw new InvalidOperationException("MCP action executor is not initialized");
}

// If a profile is loaded, route directly to OpenTraceAsync so it can either
// succeed idempotently (same trace+process) or fail with TraceAlreadyLoaded
// (different trace). Skipping the ambiguity preflight ensures the agent gets
// a clear answer instead of a process list.
var status = await _executor.GetStatusAsync();
if (status.IsProfileLoaded)
{
OpenTraceResult preconditionResult = await _executor.OpenTraceAsync(profileFilePath, processNameOrId);
return SerializeOpenTraceResult(preconditionResult, profileFilePath, processNameOrId);
}

// First, check if this might be an ambiguous query by getting available processes
GetAvailableProcessesResult processesResult = await _executor.GetAvailableProcessesAsync(profileFilePath);

Expand Down Expand Up @@ -648,6 +659,60 @@ public static async Task<string> GetFunctionAssembly(string functionName)
}
}

[McpServerTool, Description("Close the currently loaded trace and release its resources. Use between iterations when capturing fresh traces (build → capture → close_trace → open_trace → analyze). Idempotent: succeeds with WasLoaded=false when no trace is loaded. Only closes profile/trace sessions; non-profile sessions are left untouched.")]
public static async Task<string> CloseTrace()
{
try
{
if (_executor == null)
{
throw new InvalidOperationException("MCP action executor is not initialized");
}

var result = await _executor.CloseTraceAsync();

if (!result.Success)
{
var errorResult = new
{
Action = "CloseTrace",
Status = "Failed",
FailureReason = result.FailureReason.ToString(),
Description = result.ErrorMessage ?? "Unknown error closing trace",
Timestamp = DateTime.UtcNow
};
return System.Text.Json.JsonSerializer.Serialize(errorResult, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
}

var successResult = new
{
Action = "CloseTrace",
Status = "Success",
WasLoaded = result.WasLoaded,
ClosedProfilePath = result.ClosedProfilePath,
ClosedProcessId = result.ClosedProcessId,
ClosedProcessName = result.ClosedProcessName,
Description = result.WasLoaded
? $"Closed trace '{result.ClosedProfilePath}' (PID: {result.ClosedProcessId})"
: "No trace was loaded; close was a no-op",
Timestamp = DateTime.UtcNow
};
return System.Text.Json.JsonSerializer.Serialize(successResult, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
}
catch (Exception ex)
{
var errorResult = new
{
Action = "CloseTrace",
Status = "Error",
Error = ex.Message,
Timestamp = DateTime.UtcNow
};

return System.Text.Json.JsonSerializer.Serialize(errorResult, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
}
}

#endregion

#region Help Tool
Expand Down Expand Up @@ -694,6 +759,11 @@ public static string GetHelp()
Description = "Get assembly code for a specific function by double-clicking on it in the Summary pane, and save it to a file for later contextual reference by Copilot",
Parameters = "functionName (string) - Name of the function to retrieve assembly for (supports partial matching)"
},
new {
Name = "CloseTrace",
Description = "Close the currently loaded trace and release its resources. Use between iterations when capturing fresh traces. Idempotent: succeeds even if no trace is loaded.",
Parameters = "none"
},
new {
Name = "GetHelp",
Description = "Get help information about available MCP commands",
Expand Down
10 changes: 10 additions & 0 deletions src/ProfileExplorer.Mcp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -428,4 +428,14 @@ public Task<GetAvailableBinariesResult> GetAvailableBinariesAsync(double? minTim
Binaries = filteredBinaries
});
}

public Task<CloseTraceResult> CloseTraceAsync()
{
Console.WriteLine("Mock: CloseTraceAsync called");
return Task.FromResult(new CloseTraceResult
{
Success = true,
WasLoaded = false
});
Comment thread
XiaoshiSha marked this conversation as resolved.
}
}
22 changes: 17 additions & 5 deletions src/ProfileExplorerUI/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ public partial class App : Application {
/// When true, suppresses UI dialogs (like source file prompts) during MCP/automation operations.
/// </summary>
public static bool SuppressDialogsForAutomation;
/// <summary>
/// True when launched with --mcp: skip showing the MainWindow at startup
/// (lazily shown on first MCP tool call that needs the UI).
/// </summary>
public static bool IsMcpMode;
private Task? mcpServerTask;
private static List<SyntaxFileInfo> cachedSyntaxHighlightingFiles_;
public static string ApplicationPath => Process.GetCurrentProcess().MainModule?.FileName;
Expand Down Expand Up @@ -590,14 +595,17 @@ protected override void OnStartup(StartupEventArgs e) {
AppStartTime = DateTime.UtcNow;
base.OnStartup(e);

IsMcpMode = Array.Exists(e.Args, a => string.Equals(a, "--mcp", StringComparison.OrdinalIgnoreCase));
SuppressDialogsForAutomation = IsMcpMode;

// Initialize UI-specific JSON converters
UIJsonUtils.Initialize();

// Register UI-specific type converters for settings system
RegisterSettingsTypeConverters();

if (!Debugger.IsAttached) {
SetupExceptionHandling();
SetupExceptionHandling(showUIPrompt: !IsMcpMode);
}

FixPopupPlacement();
Expand Down Expand Up @@ -626,10 +634,14 @@ protected override void OnStartup(StartupEventArgs e) {

// Create and show the main window manually
var mainWindow = new MainWindow();
mainWindow.Show();

// Initialize MCP server if enabled
InitializeMcpServerAsync(mainWindow);
if (IsMcpMode) {
// Stay alive without a visible window until MCP shuts us down or a tool shows the window.
ShutdownMode = ShutdownMode.OnExplicitShutdown;
InitializeMcpServerAsync(mainWindow);
Comment thread
XiaoshiSha marked this conversation as resolved.
}
else {
mainWindow.Show();
}
}

private void InitializeMcpServerAsync(MainWindow mainWindow)
Expand Down
67 changes: 42 additions & 25 deletions src/ProfileExplorerUI/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ public partial class MainWindow : Window, IUISession, INotifyPropertyChanged {
private DocumentSearchPanel documentSearchPanel_;
private bool initialDockLayoutRestored_;
private bool profileControlsVisible;
private readonly TaskCompletionSource<bool> initialLoadCompleted_ =
new(TaskCreationOptions.RunContinuationsAsynchronously);

public MainWindow() {
InitializeComponent();
Expand Down Expand Up @@ -127,6 +129,7 @@ public bool ProfileControlsVisible {

public event PropertyChangedEventHandler PropertyChanged;
public SessionStateManager SessionState => sessionState_;
public Task InitialLoadCompleted => initialLoadCompleted_.Task;

public IRTextSummary GetDocumentSummary(IRTextSection section) {
return sessionState_.FindLoadedDocument(section).Summary;
Expand Down Expand Up @@ -579,36 +582,50 @@ private void UpdateWindowTitle() {
}

private async void Window_Loaded(object sender, RoutedEventArgs e) {
await SetupCompilerTarget();
SectionPanel.OpenSection += SectionPanel_OpenSection;
SectionPanel.EnterDiffMode += SectionPanel_EnterDiffMode;
SectionPanel.SyncDiffedDocumentsChanged += SectionPanel_SyncDiffedDocumentsChanged;
SectionPanel.DisplayCallGraph += SectionPanel_DisplayCallGraph;
SearchResultsPanel.OpenSection += SectionPanel_OpenSection;

if (!RestoreDockLayout()) {
RegisterDefaultToolPanels();
}
try {
if (compilerInfo_ == null) {
await SetupCompilerTarget();
}
else {
SetupMainWindowCompilerTarget();
}

ResetStatusBar();
SectionPanel.OpenSection += SectionPanel_OpenSection;
SectionPanel.EnterDiffMode += SectionPanel_EnterDiffMode;
SectionPanel.SyncDiffedDocumentsChanged += SectionPanel_SyncDiffedDocumentsChanged;
SectionPanel.DisplayCallGraph += SectionPanel_DisplayCallGraph;
SearchResultsPanel.OpenSection += SectionPanel_OpenSection;

// Make help panel active on the first run.
if (App.IsFirstRun) {
await ShowPanel(ToolPanelKind.Help);
}
if (!RestoreDockLayout()) {
RegisterDefaultToolPanels();
}

await ProcessStartupArgs();
ResetStatusBar();

if (!IsSessionStarted) {
UpdatePanelEnabledState(false);
}
else {
// Hide the start page if a file was loaded on start.
HideStartPage();
}
// Make help panel active on the first run.
if (App.IsFirstRun) {
await ShowPanel(ToolPanelKind.Help);
}

if (App.Settings.GeneralSettings.CheckForUpdates) {
StartApplicationUpdateTimer();
await ProcessStartupArgs();

if (!IsSessionStarted) {
UpdatePanelEnabledState(false);
}
else {
// Hide the start page if a file was loaded on start.
HideStartPage();
}

if (App.Settings.GeneralSettings.CheckForUpdates) {
StartApplicationUpdateTimer();
}

initialLoadCompleted_.TrySetResult(true);
}
catch (Exception ex) {
initialLoadCompleted_.TrySetException(ex);
throw;
}
}

Expand Down
5 changes: 4 additions & 1 deletion src/ProfileExplorerUI/MainWindowProfiling.cs
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,10 @@ private async void LoadProfileExecuted(object sender, ExecutedRoutedEventArgs e)

private async Task LoadProfile() {
var window = new ProfileLoadWindow(this, false);
window.Owner = this;
if (IsVisible) {
window.Owner = this;
}

bool? result = window.ShowDialog();

if (result.HasValue && result.Value) {
Expand Down
8 changes: 8 additions & 0 deletions src/ProfileExplorerUI/MainWindowSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,14 @@ private void StartSession(string filePath, SessionKind sessionKind) {
HideStartPage();
}

/// <summary>
/// Public wrapper around <see cref="EndSession"/> for callers outside MainWindow
/// (in particular the MCP layer).
/// </summary>
public Task CloseSessionAsync(bool showStartPage = true) {
return EndSession(showStartPage);
}

private async Task EndSession(bool showStartPage = true) {
await BeginSessionStateChange();

Expand Down
Loading
Loading