using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Reflection; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using D365MetadataBridge.Protocol; namespace D365MetadataBridge { /// /// D365 Finance & Operations Metadata Bridge /// /// Provides access to D365FO metadata via Microsoft's official Dev Tools APIs /// (IMetadataProvider, ICrossReferenceProvider) over a stdin/stdout JSON protocol. /// /// This bridge is spawned by the Node.js MCP server as a child process. /// All JSON messages are newline-delimited on stdout. /// All diagnostic/log messages go to stderr (never stdout). /// /// IMPORTANT: Main() must NOT reference any D365FO-dependent types /// (MetadataReadService, MetadataWriteService, CrossReferenceService) even as /// local variables. The CLR JIT-compiles the entire async state machine for /// Main before the first instruction executes — which means it tries to load /// Microsoft.Dynamics.AX.Metadata.dll before SetupAssemblyResolution has been /// called. All D365FO-dependent code lives in RunBridge(), which is marked /// [MethodImpl(NoInlining)] so the JIT defers its compilation until after /// the AssemblyResolve handler is registered. /// static class Program { private static string _packagesPath = string.Empty; private static string? _referencePackagesPath = null; // UDE: Microsoft FrameworkDirectory packages path private static string? _binPath = null; // Explicit bin path (UDE: microsoftPackagesPath/bin) private static string _xrefServer = "localhost"; private static string _xrefDatabase = string.Empty; private static readonly Models.AxDbSqlOptions AxDbOptions = new Models.AxDbSqlOptions(); private static string? _logFile = null; private static readonly TextWriter Log = Console.Error; static async Task Main(string[] args) { // A separate one-shot mode: analyse an ETW capture and exit. It runs in its own process // (started by the MCP server), so a multi-minute analysis never holds the bridge. if (args.Length > 0 && args[0] == "--analyze-trace") return Services.Trace.TraceAnalysisCommand.Run(args); // Parse command-line arguments — NO D365FO type references allowed here! for (int i = 0; i < args.Length; i++) { switch (args[i]) { case "--packages-path" when i + 1 < args.Length: _packagesPath = args[++i]; break; case "--reference-packages-path" when i + 1 < args.Length: _referencePackagesPath = args[++i]; break; case "--bin-path" when i + 1 < args.Length: _binPath = args[++i]; break; case "--xref-server" when i + 1 < args.Length: _xrefServer = args[++i]; break; case "--xref-database" when i + 1 < args.Length: _xrefDatabase = args[++i]; break; case "--log-file" when i + 1 < args.Length: _logFile = args[++i]; break; case "--axdb-server" when i + 1 < args.Length: AxDbOptions.Server = args[++i]; break; case "--axdb-database" when i + 1 < args.Length: AxDbOptions.Database = args[++i]; break; case "--axdb-trust-certificate": AxDbOptions.TrustCertificate = true; break; case "--axdb-timeout" when i + 1 < args.Length: if (!int.TryParse(args[++i], out var timeout) || timeout < 1 || timeout > 30) { Log.WriteLine("[FATAL] --axdb-timeout must be 1..30 seconds."); return 1; } AxDbOptions.TimeoutSeconds = timeout; break; case "--axdb-max-rows" when i + 1 < args.Length: if (!int.TryParse(args[++i], out var maxRows) || maxRows < 1 || maxRows > 1000) { Log.WriteLine("[FATAL] --axdb-max-rows must be 1..1000."); return 1; } AxDbOptions.MaxRows = maxRows; break; case "--help": PrintUsage(); return 0; } } // Setup file-based logging (tee stderr → file) BEFORE anything else runs. // Configured via --log-file or bridgeLogFile in .mcp.json. if (_logFile != null) { try { var logDir = Path.GetDirectoryName(_logFile); if (!string.IsNullOrEmpty(logDir) && !Directory.Exists(logDir)) Directory.CreateDirectory(logDir); var fileWriter = new StreamWriter(_logFile, append: true) { AutoFlush = true }; var teeWriter = new TeeTextWriter(Console.Error, fileWriter); Console.SetError(teeWriter); Console.Error.WriteLine($"\n{new string('─', 72)}"); Console.Error.WriteLine($"[Bridge] Log started at {DateTime.Now:O} pid={System.Diagnostics.Process.GetCurrentProcess().Id}"); Console.Error.WriteLine(new string('─', 72)); } catch (Exception ex) { Console.Error.WriteLine($"[WARN] Cannot open log file {_logFile}: {ex.Message}"); } } // Setup assembly resolution for D365FO DLLs BEFORE any D365FO type is touched. // Traditional: {packagesPath}/bin // UDE: --bin-path points to microsoftPackagesPath/bin (MS framework DLLs) // while --packages-path points to the custom packages root for metadata. var primaryBinPath = _binPath ?? Path.Combine(_packagesPath, "bin"); if (!Directory.Exists(primaryBinPath)) { Log.WriteLine($"[FATAL] D365FO bin path not found: {primaryBinPath}"); return 1; } // In UDE mode both bin directories may contain needed DLLs var fallbackBinPath = _binPath != null ? Path.Combine(_packagesPath, "bin") : null; SetupAssemblyResolution(primaryBinPath, fallbackBinPath); Log.WriteLine($"[INFO] Assembly resolution configured for: {primaryBinPath}"); if (fallbackBinPath != null && Directory.Exists(fallbackBinPath)) Log.WriteLine($"[INFO] Additional assembly search path: {fallbackBinPath}"); ReportMetamodelVersion(primaryBinPath, fallbackBinPath); // Delegate to RunBridge — a separate method whose JIT compilation is // deferred (NoInlining) until AFTER AssemblyResolve is registered. return await RunBridge(); } /// /// All D365FO-dependent code lives here so the JIT doesn't try to resolve /// Microsoft.Dynamics.AX.Metadata types during Main's state machine compilation. /// [MethodImpl(MethodImplOptions.NoInlining)] private static async Task RunBridge() { // Late-import: Services namespace references D365FO assemblies var metadataService = InitMetadataService(); var xrefService = InitCrossReferenceService(); // Create write service (shares the same provider as read service) Services.MetadataWriteService? writeService = null; if (metadataService != null) { try { writeService = new Services.MetadataWriteService(metadataService.Provider, _packagesPath); // Keep write service provider in sync when read service refreshes metadataService.OnProviderRefreshed = (newProvider) => writeService.UpdateProvider(newProvider); Log.WriteLine("[INFO] MetadataWriteService initialized"); } catch (Exception ex) { Log.WriteLine($"[WARN] Failed to initialize MetadataWriteService: {ex.Message}"); } } // Create request dispatcher var axdbService = new Services.AxDbSqlService(AxDbOptions); var dispatcher = new RequestDispatcher(metadataService, writeService, xrefService, axdbService); // Send ready signal var readyResponse = new BridgeResponse { Id = "ready", Result = JsonSerializer.SerializeToElement(new { version = "1.0.0", status = "ready", packagesPath = _packagesPath, referencePackagesPath = _referencePackagesPath, metadataAvailable = metadataService != null, xrefAvailable = xrefService != null, axdbSqlAvailable = axdbService.Enabled }) }; await WriteResponse(readyResponse); Log.WriteLine("[INFO] Bridge ready, entering stdin/stdout loop"); // Enter stdin/stdout loop return await RunStdioLoop(dispatcher); } [MethodImpl(MethodImplOptions.NoInlining)] private static Services.MetadataReadService? InitMetadataService() { try { Log.WriteLine($"[INFO] Initializing MetadataProvider from: {_packagesPath}"); if (_referencePackagesPath != null) Log.WriteLine($"[INFO] Reference packages path (UDE): {_referencePackagesPath}"); var svc = new Services.MetadataReadService(_packagesPath, _referencePackagesPath); Log.WriteLine("[INFO] MetadataProvider initialized successfully"); return svc; } catch (Exception ex) { Log.WriteLine($"[ERROR] Failed to initialize MetadataProvider: {ex.Message}"); Log.WriteLine($"[ERROR] Stack: {ex.StackTrace}"); return null; } } [MethodImpl(MethodImplOptions.NoInlining)] private static Services.CrossReferenceService? InitCrossReferenceService() { // Cross-references require the DYNAMICSXREFDB SQL database. This is an // OPTIONAL capability — metadata read/write works fully without it. When // no xref database is configured, skip silently instead of attempting a // doomed SQL connection that otherwise logs an alarming [WARN] on every // startup (the TS side forwards [WARN]/[ERROR] to the MCP client). if (string.IsNullOrWhiteSpace(_xrefDatabase)) { Log.WriteLine("[INFO] Cross-reference DB not configured — xref features disabled (metadata operations unaffected)"); return null; } try { Log.WriteLine($"[INFO] Initializing CrossReferenceProvider: {_xrefServer}\\{_xrefDatabase}"); var svc = new Services.CrossReferenceService(_xrefServer, _xrefDatabase); Log.WriteLine("[INFO] CrossReferenceProvider initialized successfully"); return svc; } catch (Exception ex) { // Optional capability — a SQL connection failure is not a server error. // Log at [INFO] so it is not surfaced as a client-facing warning; the // ready payload already reports xrefAvailable=false for callers that care. Log.WriteLine($"[INFO] Cross-reference DB unavailable — xref features disabled ({ex.Message})"); return null; } } /// /// How many reads may be inside the provider at once. Bounded on purpose: the /// win is in overlapping a handful of fan-out reads, not in handing an unbounded /// number of threads to a metadata provider whose thread-safety is undocumented. /// private const int ReadSlots = 6; /// /// A read takes one slot; anything else takes ALL of them, which is what makes a /// write or a provider refresh exclusive against every in-flight read. Simple /// enough to reason about without an async reader/writer lock. /// /// A writer waits for the slots, so a continuous stream of reads could delay it. /// In practice requests come from one MCP client that awaits its own calls, so /// there is no such stream. /// private static readonly SemaphoreSlim _slots = new SemaphoreSlim(ReadSlots, ReadSlots); /// stdout is one stream: concurrent handlers must not interleave lines on it. private static readonly SemaphoreSlim _stdoutLock = new SemaphoreSlim(1, 1); private static readonly StreamWriter _stdout = new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }; private static async Task RunStdioLoop(RequestDispatcher dispatcher) { var reader = new StreamReader(Console.OpenStandardInput()); var inFlight = new List(); string? line; while ((line = await reader.ReadLineAsync()) != null) { if (string.IsNullOrWhiteSpace(line)) continue; BridgeRequest? request = null; try { request = JsonSerializer.Deserialize(line, JsonOptions.Default); } catch (JsonException ex) { Log.WriteLine($"[ERROR] JSON parse error: {ex.Message}"); await WriteResponse(BridgeResponse.CreateError("?", -32700, $"Parse error: {ex.Message}")); continue; } if (request == null || string.IsNullOrEmpty(request.Method)) { await WriteResponse(BridgeResponse.CreateError("?", -32600, "Invalid request")); continue; } // Reads go to the thread pool so the loop can read the next line at once; // everything else keeps the original behaviour of running to completion // before the next request is even parsed. if (RequestDispatcher.IsConcurrentSafeRead(request.Method)) { // Touch Exception on a faulted task before dropping it, so the fault // is observed rather than left for the finalizer — a task pruned here // is never awaited anywhere else. inFlight.RemoveAll(t => { if (t.IsFaulted) { var _ = t.Exception; } return t.IsCompleted; }); inFlight.Add(HandleConcurrently(dispatcher, request)); } else { // Drain the reads already running, then take every slot: a write or a // provider rebuild must not overlap a read of the provider it replaces. if (inFlight.Count > 0) { // Guarded. DispatchGuarded turns any handler escape into a // response, so the only realistic thrower left is WriteResponse on // a broken stdout — and an unguarded WhenAll rethrows that HERE, // out of RunStdioLoop and out of Main, killing the bridge because // an unrelated read could not print. The final drain below was // already written this way; this one was not. try { await Task.WhenAll(inFlight); } catch { /* each read already answered, or failed on its own */ } inFlight.Clear(); } for (var i = 0; i < ReadSlots; i++) await _slots.WaitAsync(); try { await WriteResponse(await DispatchGuarded(dispatcher, request)); } finally { _slots.Release(ReadSlots); } } } if (inFlight.Count > 0) { try { await Task.WhenAll(inFlight); } catch { /* each response was already sent */ } } Log.WriteLine("[INFO] stdin closed, bridge exiting"); return 0; } /// One read: take a slot, dispatch, answer. Never throws back into the loop. private static async Task HandleConcurrently(RequestDispatcher dispatcher, BridgeRequest request) { await _slots.WaitAsync(); try { // Task.Run, not a bare await: every read handler returns // Task.FromResult(...), so Dispatch runs to completion SYNCHRONOUSLY on // whichever thread calls it. Awaiting it here left the work on the stdio // loop thread and measured 1.06x against 1.04x for the serial build — // the change did nothing until the work actually left this thread. var response = await Task.Run(() => DispatchGuarded(dispatcher, request)); await WriteResponse(response); } finally { _slots.Release(); } } /// Dispatch, turning any escape into a response rather than a lost request. private static async Task DispatchGuarded(RequestDispatcher dispatcher, BridgeRequest request) { try { Log.WriteLine($"[DEBUG] → {request.Method} (id={request.Id})"); var response = await dispatcher.Dispatch(request); Log.WriteLine($"[DEBUG] ← {request.Method} OK (id={request.Id})"); return response; } catch (Exception ex) { Log.WriteLine($"[ERROR] Unhandled: {ex.Message}\n{ex.StackTrace}"); return BridgeResponse.CreateError(request.Id, -32603, $"Internal error: {ex.Message}"); } } private static async Task WriteResponse(BridgeResponse response) { var json = JsonSerializer.Serialize(response, JsonOptions.Default); // Serialized, and on ONE cached writer: concurrent handlers each opening their // own StreamWriter over the same handle is how a JSON-RPC stream gets interleaved. await _stdoutLock.WaitAsync(); try { await _stdout.WriteLineAsync(json); await _stdout.FlushAsync(); } finally { _stdoutLock.Release(); } } /// /// Log the metamodel build we compiled against vs. the one we are about to load, /// and warn when they differ. /// /// Every D365FO platform build stamps assembly version 7.0.0.0 and varies only in /// FileVersion, so the CLR binder cannot tell two metamodels apart — and the /// AssemblyResolve → LoadFrom path below does not enforce identity either. A bridge /// built against 7.0.7996 therefore loads 7.0.7690 without complaint and fails much /// later with MissingMethodException / TypeLoadException at JIT time. This turns that /// silent divergence into one line in the log. See issue #703. /// /// Diagnostic only — never fails startup, since a mismatch is usually harmless. /// Touches no D365FO types, so it is safe to call from Main. /// private static void ReportMetamodelVersion(string primaryBinPath, string? fallbackBinPath) { const string MetadataDll = "Microsoft.Dynamics.AX.Metadata.dll"; try { string? runtimeDll = null; foreach (var searchPath in new[] { primaryBinPath, fallbackBinPath }) { if (searchPath == null) continue; var candidate = Path.Combine(searchPath, MetadataDll); if (File.Exists(candidate)) { runtimeDll = candidate; break; } } if (runtimeDll == null) { Log.WriteLine($"[WARN] {MetadataDll} not found under {primaryBinPath} — metadata operations will fail."); return; } var runtimeVersion = FileVersionInfo.GetVersionInfo(runtimeDll).FileVersion ?? "unknown"; Log.WriteLine($"[INFO] Metamodel runtime: {runtimeVersion} ({runtimeDll})"); var builtAgainst = BuildInfo.MetamodelFileVersion; if (string.IsNullOrEmpty(builtAgainst)) { Log.WriteLine("[INFO] Metamodel build version unknown — version check skipped."); } else if (builtAgainst != runtimeVersion) { Log.WriteLine($"[WARN] Metamodel version mismatch: built against {builtAgainst} " + $"(from {BuildInfo.MetamodelBinPath}), loading {runtimeVersion}."); Log.WriteLine("[WARN] Usually harmless, but if metadata calls fail with MissingMethodException " + "or TypeLoadException, rebuild the bridge against this environment: " + $"dotnet build -c Release -p:D365BinPath=\"{Path.GetDirectoryName(runtimeDll)}\""); } } catch (Exception ex) { Log.WriteLine($"[WARN] Metamodel version check failed: {ex.Message}"); } } private static void SetupAssemblyResolution(string primaryBinPath, string? fallbackBinPath = null) { AppDomain.CurrentDomain.AssemblyResolve += (sender, args) => { var assemblyName = new AssemblyName(args.Name); var dllName = assemblyName.Name + ".dll"; // Search primary bin path first, then fallback (UDE: both MS + custom) foreach (var searchPath in new[] { primaryBinPath, fallbackBinPath }) { if (searchPath == null || !Directory.Exists(searchPath)) continue; var dllPath = Path.Combine(searchPath, dllName); if (File.Exists(dllPath)) { Log.WriteLine($"[ASSEMBLY] Resolving {assemblyName.Name} from {dllPath}"); try { return Assembly.LoadFrom(dllPath); } catch (Exception ex) { Log.WriteLine($"[ASSEMBLY] Failed to load {dllPath}: {ex.Message}"); } } } return null; }; } /// /// TextWriter that writes to two underlying writers simultaneously (tee). /// Used to mirror Console.Error to both the original stderr pipe AND a log file. /// private sealed class TeeTextWriter : TextWriter { private readonly TextWriter _primary; private readonly TextWriter _secondary; public TeeTextWriter(TextWriter primary, TextWriter secondary) { _primary = primary; _secondary = secondary; } public override System.Text.Encoding Encoding => _primary.Encoding; public override void Write(char value) { _primary.Write(value); _secondary.Write(value); } public override void Write(string? value) { _primary.Write(value); _secondary.Write(value); } public override void WriteLine(string? value) { _primary.WriteLine(value); _secondary.WriteLine(value); } public override void Flush() { _primary.Flush(); _secondary.Flush(); } protected override void Dispose(bool disposing) { if (disposing) { _secondary.Flush(); _secondary.Dispose(); } base.Dispose(disposing); } } private static void PrintUsage() { Console.Error.WriteLine(@" D365 Metadata Bridge — stdin/stdout JSON protocol for D365FO metadata access Usage: D365MetadataBridge.exe [options] D365MetadataBridge.exe --analyze-trace --out [--focus A;B] [--pid N] [--scope thread|subtree] [--top N] [--repeat N] [--progress ] One-shot: analyse an ETW capture (X++ and SQL time) and exit. Options: --packages-path Path to primary PackagesLocalDirectory (required; the server detects it per machine) --reference-packages-path UDE: secondary packages path (Microsoft FrameworkDirectory). Objects not found in the primary path are looked up here, enabling resolution of both custom and Microsoft-shipped metadata in UDE environments. --bin-path Explicit DLL directory (UDE: microsoftPackagesPath\bin). If omitted, uses {packages-path}\bin. --xref-server SQL Server for cross-reference DB (default: localhost) --xref-database Cross-reference database name, e.g. DYNAMICSXREFDB (no default: without it cross-references are off) --axdb-server Enable separate, lazy, read-only AxDB SQL debug access (Windows integrated authentication) --axdb-database SQL debug database (default: AxDB) --axdb-trust-certificate Trust the SQL certificate (encryption remains enabled) --axdb-timeout Per-command timeout, 1..30 (default: 30) --axdb-max-rows Default SQL query row cap, 1..1000 (default: 100) --log-file Write all diagnostic logs to this file (append mode) --help Show this help Protocol: Send JSON requests as single lines to stdin: {""id"":""1"",""method"":""ping"",""params"":{}} Receive JSON responses on stdout (one per line): {""id"":""1"",""result"":""pong""} Methods: ping Health check readTable {tableName} Read table metadata (fields, indexes, relations) readClass {className} Read class metadata (methods, declaration) readEnum {enumName} Read enum metadata (values) readEdt {edtName} Read EDT metadata (base type, properties) readForm {formName} Read form metadata (datasources, controls) searchObjects {type, query} Search for objects by name pattern findReferences {objectPath} Find cross-references (requires DYNAMICSXREFDB) "); } } }