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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/ProfileExplorer.Mcp/IMcpActionExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ public interface IMcpActionExecutor
/// </summary>
/// <param name="profileFilePath">Path to the ETL trace file to open</param>
/// <param name="processIdentifier">Process ID (e.g., "12345") or process name (e.g., "chrome.exe", "POWERPNT") to select and load from the trace</param>
/// <param name="useManagedIdentity">If true, authenticates symbol downloads using a managed identity credential instead of interactive login</param>
/// <param name="symbolPath">Optional semicolon-separated symbol search path (e.g., "https://symweb.azurefd.net;C:\Symbols"). Prepended to any paths already configured.</param>
/// <returns>Task that completes when the trace is fully loaded, with detailed result information</returns>
Task<OpenTraceResult> OpenTraceAsync(string profileFilePath, string processIdentifier);
Task<OpenTraceResult> OpenTraceAsync(string profileFilePath, string processIdentifier, bool useManagedIdentity = false, string? symbolPath = null);

/// <summary>
/// Get the current status of the Profile Explorer UI
Expand Down
9 changes: 5 additions & 4 deletions src/ProfileExplorer.Mcp/ProfileExplorerMcpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ 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")]
public static async Task<string> OpenTrace(string profileFilePath, string processNameOrId)
public static async Task<string> OpenTrace(string profileFilePath, string processNameOrId,
bool useManagedIdentity = false, string? symbolPath = null)
{
if (string.IsNullOrWhiteSpace(profileFilePath))
throw new ArgumentException("Profile file path cannot be empty", nameof(profileFilePath));
Expand All @@ -87,7 +88,7 @@ public static async Task<string> OpenTrace(string profileFilePath, string proces
if (exactIdMatch != null)
{
// Direct match by ID - proceed with OpenTrace
OpenTraceResult result = await _executor.OpenTraceAsync(profileFilePath, processNameOrId);
OpenTraceResult result = await _executor.OpenTraceAsync(profileFilePath, processNameOrId, useManagedIdentity, symbolPath);
return SerializeOpenTraceResult(result, profileFilePath, processNameOrId);
}
}
Expand All @@ -101,7 +102,7 @@ public static async Task<string> OpenTrace(string profileFilePath, string proces
if (exactNameMatches.Length == 1)
{
// Single exact match - proceed with OpenTrace
OpenTraceResult result = await _executor.OpenTraceAsync(profileFilePath, processNameOrId);
OpenTraceResult result = await _executor.OpenTraceAsync(profileFilePath, processNameOrId, useManagedIdentity, symbolPath);
return SerializeOpenTraceResult(result, profileFilePath, processNameOrId);
}

Expand Down Expand Up @@ -129,7 +130,7 @@ public static async Task<string> OpenTrace(string profileFilePath, string proces
}

// Fallback to direct OpenTrace call if we can't get the process list
OpenTraceResult directResult = await _executor.OpenTraceAsync(profileFilePath, processNameOrId);
OpenTraceResult directResult = await _executor.OpenTraceAsync(profileFilePath, processNameOrId, useManagedIdentity, symbolPath);
return SerializeOpenTraceResult(directResult, profileFilePath, processNameOrId);
}
catch (Exception ex)
Expand Down
2 changes: 1 addition & 1 deletion src/ProfileExplorer.Mcp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public static async Task Main(string[] args)
/// Provides mock responses for all MCP tools to enable standalone testing
/// </summary>
public class MockMcpActionExecutor : IMcpActionExecutor {
public Task<OpenTraceResult> OpenTraceAsync(string profileFilePath, string processIdentifier) {
public Task<OpenTraceResult> OpenTraceAsync(string profileFilePath, string processIdentifier, bool useManagedIdentity = false, string? symbolPath = null) {
Console.WriteLine($"Mock: OpenTraceAsync called with process identifier: {processIdentifier}");
return Task.FromResult(new OpenTraceResult { Success = true });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,8 @@
<Copy SourceFiles="@(NativeDependency)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" />
</Target>

<Target Name="CopyNativeDependenciesOnPublish" AfterTargets="Publish" Inputs="@(NativeDependency)" Outputs="@(NativeDependency -> '$(PublishDir)%(Filename)%(Extension)')">
<Copy SourceFiles="@(NativeDependency)" DestinationFolder="$(PublishDir)" SkipUnchangedFiles="true" />
</Target>

</Project>
72 changes: 64 additions & 8 deletions src/ProfileExplorer.McpServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using ProfileExplorer.Core.Profile.CallTree;
using ProfileExplorer.Core.Profile.Data;
using ProfileExplorer.Core.Profile.ETW;
using ProfileExplorer.Core.Providers;
using ProfileExplorer.Core.Settings;
using ProfileExplorer.Core.Utilities;

Expand All @@ -24,6 +25,18 @@ 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");

// Allow caller to specify the log directory via --log-dir <path>.
// Must be applied before the first DiagnosticLogger call so Initialize() picks it up.
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i].Equals("--log-dir", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrEmpty(args[i + 1]))
{
Environment.SetEnvironmentVariable("PROFILE_EXPLORER_LOG_DIR", args[i + 1]);
break;
}
}

var builder = Host.CreateDefaultBuilder()
.ConfigureLogging(logging =>
{
Expand Down Expand Up @@ -64,6 +77,10 @@ public static class ProfileSession
// Concurrency guard — only one trace load at a time.
public static readonly SemaphoreSlim LoadSemaphore = new(1, 1);

// Lazy-built lookup: demangled function name → IRTextFunction.
// Populated on first FindFunction call after a trace loads.
public static Dictionary<string, IRTextFunction>? DemangledFunctionLookup { get; set; }

/// <summary>
/// Resets all session state and static caches for a clean trace load.
/// </summary>
Expand All @@ -81,6 +98,7 @@ public static void Reset()
PendingFilePath = null;
PendingProcessId = null;
LoadException = null;
DemangledFunctionLookup = null;

// Clear static resolution caches so each trace starts fresh.
PDBDebugInfoProvider.ClearResolvedCache();
Expand Down Expand Up @@ -170,9 +188,11 @@ public static string OpenTrace(
[Description("Optional additional symbol search path (e.g. 'd:\\temp\\landy' for custom kernel symbols)")]
string? symbolPath = null,
[Description("Optional additional binary search path (e.g. 'd:\\temp' for loose binaries like storport.sys). Required for assembly-level disassembly via GetFunctionAssembly when the binary isn't on the symbol server.")]
string? binaryPath = null)
string? binaryPath = null,
[Description("Enable Azure Managed Identity for symbol server authentication. Use in headless/Azure environments where interactive browser auth is unavailable.")]
bool useManagedIdentity = false)
{
DiagnosticLogger.LogInfo($"[MCP] OpenTrace called: profileFilePath={profileFilePath}, processNameOrId={processNameOrId}, symbolPath={symbolPath ?? "(none)"}, binaryPath={binaryPath ?? "(none)"}");
DiagnosticLogger.LogInfo($"[MCP] OpenTrace called: profileFilePath={profileFilePath}, processNameOrId={processNameOrId}, symbolPath={symbolPath ?? "(none)"}, binaryPath={binaryPath ?? "(none)"}, useManagedIdentity={useManagedIdentity}");

if (!File.Exists(profileFilePath))
return Error("OpenTrace", $"File not found: {profileFilePath}");
Expand All @@ -197,6 +217,7 @@ public static string OpenTrace(
var options = new ProfileDataProviderOptions();
var symbolSettings = new SymbolFileSourceSettings();
symbolSettings.UseEnvironmentVarSymbolPaths = true;
symbolSettings.ManagedIdentityEnabled = useManagedIdentity;
if (!string.IsNullOrWhiteSpace(symbolPath))
symbolSettings.InsertSymbolPath(symbolPath);
if (!string.IsNullOrWhiteSpace(binaryPath))
Expand All @@ -206,7 +227,10 @@ public static string OpenTrace(
}
ProfileSession.SymbolSettings = symbolSettings;

DiagnosticLogger.LogInfo($"[MCP] SymbolSettings: UseEnvVar=true, CustomPath={symbolPath ?? "(none)"}, EnvVar={symbolSettings.EnvironmentVarSymbolPath ?? "(not set)"}");
// Reinitialize credential chain to pick up ManagedIdentityEnabled flag.
PDBDebugInfoProvider.ReinitializeCredentials(symbolSettings);

DiagnosticLogger.LogInfo($"[MCP] SymbolSettings: UseEnvVar=true, CustomPath={symbolPath ?? "(none)"}, EnvVar={symbolSettings.EnvironmentVarSymbolPath ?? "(not set)"}, ManagedIdentity={useManagedIdentity}");
DiagnosticLogger.LogInfo($"[MCP] SymbolPaths: {string.Join("; ", symbolSettings.SymbolPaths)}");
DiagnosticLogger.LogInfo($"[MCP] BinarySearchPaths: {(options.HasBinarySearchPaths ? string.Join("; ", options.BinarySearchPaths) : "(none)")}");

Expand Down Expand Up @@ -812,12 +836,44 @@ private static string ResolveFunctionName(ProfileCallTreeNode node)

private static IRTextFunction? FindFunction(ProfileData profile, string functionName)
{
// Search by resolved PDB name first, then by IRTextFunction.Name (hex placeholder)
// 1. Exact match on PDB-resolved (possibly decorated) name.
var exactMatch = profile.FunctionProfiles.Keys
.FirstOrDefault(f => ResolveFunctionName(f).Equals(functionName, StringComparison.OrdinalIgnoreCase));
if (exactMatch != null) return exactMatch;

// 2. Exact match on demangled name (callers pass human-readable names; PE stores decorated MSVC names).
if (ProfileSession.DemangledFunctionLookup == null)
{
// Build once per trace load — demangling is not thread-safe so do it lazily here.
var lookup = new Dictionary<string, IRTextFunction>(StringComparer.OrdinalIgnoreCase);
foreach (var f in profile.FunctionProfiles.Keys)
{
var raw = ResolveFunctionName(f);
var demangled = PDBDebugInfoProvider.DemangleFunctionName(raw,
FunctionNameDemanglingOptions.OnlyName | FunctionNameDemanglingOptions.NoReturnType |
FunctionNameDemanglingOptions.NoSpecialKeywords);
lookup.TryAdd(demangled, f);
lookup.TryAdd(raw, f); // also keep decorated so we re-use this dict for all lookups
}
ProfileSession.DemangledFunctionLookup = lookup;
}

if (ProfileSession.DemangledFunctionLookup.TryGetValue(functionName, out var demangledMatch))
return demangledMatch;

// 3. Exact match on IRTextFunction.Name (hex placeholder when no symbols).
var hexMatch = profile.FunctionProfiles.Keys
.FirstOrDefault(f => f.Name.Equals(functionName, StringComparison.OrdinalIgnoreCase));
if (hexMatch != null) return hexMatch;

// 4. Contains on demangled name (partial match).
var containsMatch = ProfileSession.DemangledFunctionLookup.Keys
.FirstOrDefault(k => k.Contains(functionName, StringComparison.OrdinalIgnoreCase));
if (containsMatch != null && ProfileSession.DemangledFunctionLookup.TryGetValue(containsMatch, out var partialMatch))
return partialMatch;

// 5. Contains on decorated or hex placeholder name.
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));
Expand Down
113 changes: 77 additions & 36 deletions src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ public sealed class PDBDebugInfoProvider : IDebugInfoProvider {

private static ConcurrentDictionary<SymbolFileDescriptor, DebugFileSearchResult> resolvedSymbolsCache_ = new();
private static readonly StringWriter authLogWriter_;
private static readonly SymwebHandler authSymwebHandler_;
private static readonly AzureDevOpsSourceHandler authAzDevOpsHandler_;
private static SymwebHandler authSymwebHandler_;
private static AzureDevOpsSourceHandler authAzDevOpsHandler_;
private static readonly string authRecordPath_ = Path.Combine(Path.GetTempPath(), "ProfileExplorer", "auth_record.bin");
private static readonly object credentialLock_ = new();
private static object undecorateLock_ = new(); // Global lock for undname.
private ConcurrentDictionary<long, SourceFileDebugInfo> sourceFileByRvaCache_ = new();
private ConcurrentDictionary<string, SourceFileDebugInfo> sourceFileByNameCache_ = new();
Expand Down Expand Up @@ -84,53 +85,90 @@ public static void ClearResolvedCache() {
public static string DiaRegistrationError => diaRegistrationError_;

static PDBDebugInfoProvider() {
authLogWriter_ = new StringWriter();
BuildAndSetCredentialChain(managedIdentityEnabled: false);
}

/// <summary>
/// Rebuilds the token credential chain based on the provided settings.
/// This exists because the static constructor runs at class-load time, before
/// application settings are available. Call this after settings are loaded to
/// pick up the ManagedIdentityEnabled flag.
/// Thread-safe — uses credentialLock_.
/// </summary>
public static void ReinitializeCredentials(SymbolFileSourceSettings settings) {
BuildAndSetCredentialChain(settings?.ManagedIdentityEnabled ?? false);
}

private static void BuildAndSetCredentialChain(bool managedIdentityEnabled) {
// Create a single instance of the Symweb handler so that
// when concurrent requests are made and login must be done,
// the login page is displayed a single time, with other requests waiting for a token.

// DefaultAzureCredential is not allowed per SFI. Mimic behavior while continuing
// to exclude the managed identity credential.
// to exclude the managed identity credential unless explicitly opted in.
// NOTE: ChainedTokenCredential only falls through on CredentialUnavailableException.
// Other exceptions (like "needs re-authentication") stop the chain. We wrap each
// credential to catch auth failures and convert them to CredentialUnavailableException
// so the chain continues to InteractiveBrowserCredential which prompts the user.

// Enable token cache persistence for browser credential so tokens survive process restarts.
// Additionally, load existing AuthenticationRecord for true silent auth across sessions.
// This prevents re-authentication on every trace load if VS credential fails.
var authRecord = LoadAuthenticationRecord();
var browserCredentialOptions = new InteractiveBrowserCredentialOptions {
TokenCachePersistenceOptions = new TokenCachePersistenceOptions {
Name = "ProfileExplorer" // Unique cache name for this app
},
AuthenticationRecord = authRecord // Enable silent auth with cached identity
};

TokenCredential browserCredential = new InteractiveBrowserCredential(browserCredentialOptions);

// If no auth record exists yet, the user will be prompted on first symbol download.
// After first successful auth, capture and save the AuthenticationRecord for future sessions.
if (authRecord == null) {
browserCredential = new CaptureAuthRecordCredential((InteractiveBrowserCredential)browserCredential);
List<TokenCredential> credentials;

if (managedIdentityEnabled) {
// In Azure/headless environments use Managed Identity exclusively.
// Developer credentials (VS, CLI, browser) are unnecessary and can be
// slow or misleading when running in a cloud context.
// For user-assigned managed identities, the client ID must be passed explicitly.
// Read MANAGED_IDENTITY_CLIENT_ID from the environment.
var managedIdentityClientId = Environment.GetEnvironmentVariable("MANAGED_IDENTITY_CLIENT_ID");
var managedIdentityCredential = string.IsNullOrEmpty(managedIdentityClientId)
? new ManagedIdentityCredential()
: new ManagedIdentityCredential(managedIdentityClientId);
Trace.WriteLine($"[Auth] Building credential chain: ManagedIdentity-only (clientId={managedIdentityClientId ?? "system-assigned"})");
credentials = new List<TokenCredential>
{
WrapCredential(managedIdentityCredential),
};
}
else {
// Developer/interactive chain. Enable token cache persistence for browser
// credential so tokens survive process restarts. Load any existing
// AuthenticationRecord for true silent auth across sessions.
var authRecord = LoadAuthenticationRecord();
var browserCredentialOptions = new InteractiveBrowserCredentialOptions {
TokenCachePersistenceOptions = new TokenCachePersistenceOptions {
Name = "ProfileExplorer"
},
AuthenticationRecord = authRecord
};

TokenCredential browserCredential = new InteractiveBrowserCredential(browserCredentialOptions);

// If no auth record exists yet, the user will be prompted on first symbol download.
// After first successful auth, capture and save the record for future sessions.
if (authRecord == null) {
browserCredential = new CaptureAuthRecordCredential((InteractiveBrowserCredential)browserCredential);
}

var credentials = new List<TokenCredential>
{
WrapCredential(new EnvironmentCredential()),
WrapCredential(new WorkloadIdentityCredential()),
WrapCredential(new SharedTokenCacheCredential()),
WrapCredential(new VisualStudioCredential()),
WrapCredential(new AzureCliCredential()),
WrapCredential(new AzurePowerShellCredential()),
WrapCredential(new AzureDeveloperCliCredential()),
browserCredential // Don't wrap - final fallback should show errors
};
credentials = new List<TokenCredential>
{
WrapCredential(new EnvironmentCredential()),
WrapCredential(new WorkloadIdentityCredential()),
WrapCredential(new SharedTokenCacheCredential()),
WrapCredential(new VisualStudioCredential()),
WrapCredential(new AzureCliCredential()),
WrapCredential(new AzurePowerShellCredential()),
WrapCredential(new AzureDeveloperCliCredential()),
browserCredential // Don't wrap - final fallback should show errors
};
}

var authCredential = new ChainedTokenCredential(credentials.ToArray());

authLogWriter_ = new StringWriter();
authSymwebHandler_ = new SymwebHandler(authLogWriter_, authCredential);
authAzDevOpsHandler_ = new AzureDevOpsSourceHandler(authLogWriter_, authCredential);
lock (credentialLock_) {
authSymwebHandler_ = new SymwebHandler(authLogWriter_, authCredential);
authAzDevOpsHandler_ = new AzureDevOpsSourceHandler(authLogWriter_, authCredential);
}
}

/// <summary>
Expand Down Expand Up @@ -425,8 +463,11 @@ public static async Task<DebugFileSearchResult>

public static SymbolReaderAuthenticationHandler CreateAuthHandler(SymbolFileSourceSettings settings) {
var authHandler = new SymbolReaderAuthenticationHandler();
authHandler.AddHandler(authSymwebHandler_);
authHandler.AddHandler(authAzDevOpsHandler_);

lock (credentialLock_) {
authHandler.AddHandler(authSymwebHandler_);
authHandler.AddHandler(authAzDevOpsHandler_);
}

if (settings.AuthorizationTokenEnabled) {
authHandler.AddHandler(new BasicAuthenticationHandler(settings, authLogWriter_));
Expand Down
Loading
Loading