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)
");
}
}
}