From 5b648babd6bdbc5fa85b8da646529fe37cc3337d Mon Sep 17 00:00:00 2001 From: Christos Date: Wed, 26 Aug 2026 23:20:29 +0300 Subject: [PATCH 1/2] [MCP] Initialize metadata providers in stdio mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dab start --mcp-stdio returns from Program.StartEngine before host.Run(), so Startup.Configure never executes -- and with it PerformOnConfigChangeAsync, the only caller of IMetadataProviderFactory.InitializeAsync(). Entity names reach the tool registry from config, but no entity ever receives a database object, so every MCP tool call fails with: Database object for entity '' has not been inferred. The identical configuration serves the same entity correctly over REST, because the web path does call host.Run(). This is a side effect of #3676 (Avoid starting web host in MCP stdio mode). That change was correct in itself -- stdio mode should not bind an HTTP port -- but PerformOnConfigChangeAsync did more than serve HTTP, and nothing took over its metadata-initialization duty on the stdio path. RunMcpStdioHost now initializes the metadata providers itself, before registering tools. The existing assertions that StartAsync and StopAsync are never called still hold, so #3676 is preserved; the unit test gains a stub factory and an assertion that InitializeAsync is invoked exactly once. Verified against SQL Server: describe_entities and read_records both succeed on a one-entity and a twenty-eight-entity configuration, and REST is unchanged. Fixes #3783 Co-authored-by: Νύξ (Nyx) 🌑 --- .../UnitTests/McpStdioHelperTests.cs | 38 +++++++++++++++++++ src/Service/Utilities/McpStdioHelper.cs | 10 +++++ 2 files changed, 48 insertions(+) diff --git a/src/Service.Tests/UnitTests/McpStdioHelperTests.cs b/src/Service.Tests/UnitTests/McpStdioHelperTests.cs index daf0c9e3b1..5de547ab71 100644 --- a/src/Service.Tests/UnitTests/McpStdioHelperTests.cs +++ b/src/Service.Tests/UnitTests/McpStdioHelperTests.cs @@ -3,8 +3,13 @@ #nullable enable +using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Azure.DataApiBuilder.Config.DatabasePrimitives; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Mcp.Core; using Azure.DataApiBuilder.Service.Utilities; using Microsoft.Extensions.DependencyInjection; @@ -23,9 +28,12 @@ public void RunMcpStdioHost_DoesNotStartWebHost() TestApplicationLifetime lifetime = new(); TestMcpStdioServer stdioServer = new(); + TestMetadataProviderFactory metadataProviderFactory = new(); + services.AddSingleton(); services.AddSingleton(lifetime); services.AddSingleton(stdioServer); + services.AddSingleton(metadataProviderFactory); using ServiceProvider serviceProvider = services.BuildServiceProvider(); TestHost host = new(serviceProvider); @@ -43,6 +51,36 @@ public void RunMcpStdioHost_DoesNotStartWebHost() "The stdio loop should keep using the host lifetime cancellation token."); Assert.AreEqual(1, host.DisposeCallCount, "MCP stdio mode should dispose the host after the stdio loop exits."); + Assert.AreEqual(1, metadataProviderFactory.InitializeAsyncCallCount, + "MCP stdio mode must initialize the metadata providers itself: it never calls " + + "host.Run(), so Startup.Configure -- the only caller of PerformOnConfigChangeAsync " + + "-- never runs, and without this every tool call fails with " + + "\"Database object for entity '' has not been inferred.\""); + } + + private sealed class TestMetadataProviderFactory : IMetadataProviderFactory + { + public int InitializeAsyncCallCount { get; private set; } + + public Task InitializeAsync() + { + InitializeAsyncCallCount++; + return Task.CompletedTask; + } + + public void InitializeAsync( + Dictionary> entityToDatabaseObjectMap, + Dictionary> graphQLStoredProcedureExposedNameToEntityNameMap) + => InitializeAsyncCallCount++; + + public ISqlMetadataProvider GetMetadataProvider(string dataSourceName) + => throw new NotImplementedException(); + + public IEnumerable ListMetadataProviders() + => Array.Empty(); + + public List GetAllMetadataExceptions() + => new(); } private sealed class TestHost : IHost diff --git a/src/Service/Utilities/McpStdioHelper.cs b/src/Service/Utilities/McpStdioHelper.cs index 4ee403b98e..7af1ff7862 100644 --- a/src/Service/Utilities/McpStdioHelper.cs +++ b/src/Service/Utilities/McpStdioHelper.cs @@ -78,6 +78,16 @@ public static bool RunMcpStdioHost(IHost host) { try { + // Stdio mode never calls host.Run(), so Startup.Configure -- and with it + // PerformOnConfigChangeAsync, the only caller of IMetadataProviderFactory + // .InitializeAsync() -- never executes. Without this, entities are known to + // the tool registry (their names come from config) while no entity ever gets + // a database object, and every tool call fails with + // "Database object for entity '' has not been inferred." + Core.Services.MetadataProviders.IMetadataProviderFactory metadataProviderFactory = + host.Services.GetRequiredService(); + metadataProviderFactory.InitializeAsync().GetAwaiter().GetResult(); + Mcp.Core.McpToolRegistry registry = host.Services.GetRequiredService(); IEnumerable tools = From 6b2fea140a7ab30dde7e558b60f81af1247cce70 Mon Sep 17 00:00:00 2001 From: Christos Date: Sun, 6 Sep 2026 14:49:12 +0300 Subject: [PATCH 2/2] [MCP] Report stdio host failures instead of letting them escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunMcpStdioHost had a try/finally that only disposed the host, so any exception propagated out of a method whose contract is a bool. The catch goes on the existing outer try rather than around the metadata initialization alone, because the resolution one line above it is where the commonest failures land: GetRequiredService() activates MetadataProviderFactory, whose constructor calls ConfigureMetadataProviders() -> RuntimeConfigProvider .GetConfig(), which throws "Runtime config isn't setup." for a missing or unparseable config file. Guarding the initialization alone would have left that untouched. This follows Startup.PerformOnConfigChangeAsync: report, return false. Program .Main already maps false to ExitCode -1, the stdio analogue of that path's hostLifetime.StopApplication(), so no caller changes. The catch spans the stdio loop as well as startup, which is why the message says "run the MCP stdio host" rather than naming a phase. OperationCanceledException is filtered out so a normal shutdown is not relabelled as a failure; it continues to reach Program.StartEngine's dedicated handler unchanged. The report goes to stderr rather than through ILogger because no logger can reach anyone at that point: stdio clears every provider and leaves McpLoggerProvider, whose McpLogger stays disabled until the client sends logging/setLevel, which cannot happen before the JSON-RPC loop runs. Writing a notifications/message frame by hand would precede the initialize response the server contracts to send first. stderr itself may be suppressed. --mcp-stdio defaults to LogLevel.None, at which Program points both console streams at TextWriter.Null for "ZERO output", which is why the existing "Unable to launch the runtime" message is never seen in that mode either. This reports anyway, on the view that a refusal to run is not log output and an exit code alone is not diagnosable; it writes to the standard error stream directly rather than installing a replacement writer that would outlive the call. stdout is untouched and stays reserved for JSON-RPC. Measured against the built engine with a missing config file, default log level: before: exit 255, stdout 0 bytes, stderr 0 bytes after : exit 255, stdout 0 bytes, stderr 2635 bytes Imports Azure.DataApiBuilder.Core.Services.MetadataProviders so the factory type is not fully qualified twice, and extends the same treatment to the rest of the method: importing Azure.DataApiBuilder.Mcp.Core and .Mcp.Model removes seven further qualifications of McpToolRegistry, IMcpTool and IMcpStdioServer. Those seven sit on lines this PR did not introduce and can be dropped if the reviewer would rather the diff stayed on the lines it added. Both new tests were verified to fail when only the catch is reverted. Co-Authored-By: Νύξ (Nyx, AI) 🌑 --- .../UnitTests/McpStdioHelperTests.cs | 139 ++++++++++++++++-- src/Service/Utilities/McpStdioHelper.cs | 60 ++++++-- 2 files changed, 179 insertions(+), 20 deletions(-) diff --git a/src/Service.Tests/UnitTests/McpStdioHelperTests.cs b/src/Service.Tests/UnitTests/McpStdioHelperTests.cs index 5de547ab71..ecea06b236 100644 --- a/src/Service.Tests/UnitTests/McpStdioHelperTests.cs +++ b/src/Service.Tests/UnitTests/McpStdioHelperTests.cs @@ -5,12 +5,15 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Net; using System.Threading; using System.Threading.Tasks; using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Mcp.Core; +using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.Utilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -24,18 +27,10 @@ public class McpStdioHelperTests [TestMethod] public void RunMcpStdioHost_DoesNotStartWebHost() { - ServiceCollection services = new(); - TestApplicationLifetime lifetime = new(); TestMcpStdioServer stdioServer = new(); - TestMetadataProviderFactory metadataProviderFactory = new(); - - services.AddSingleton(); - services.AddSingleton(lifetime); - services.AddSingleton(stdioServer); - services.AddSingleton(metadataProviderFactory); - - using ServiceProvider serviceProvider = services.BuildServiceProvider(); + using ServiceProvider serviceProvider = + BuildServices(stdioServer, metadataProviderFactory, out TestApplicationLifetime lifetime); TestHost host = new(serviceProvider); bool result = McpStdioHelper.RunMcpStdioHost(host); @@ -58,14 +53,136 @@ public void RunMcpStdioHost_DoesNotStartWebHost() "\"Database object for entity '' has not been inferred.\""); } + /// + /// A startup failure must not escape RunMcpStdioHost, whose contract is a bool, and must stop + /// the server rather than let it serve entities that have no database object -- the failure + /// this initialization exists to prevent. stdout carries JSON-RPC, so it reports on stderr. + /// + [TestMethod] + public void RunMcpStdioHost_StartupFails_ReportsOnStandardErrorAndDoesNotServeTools() + { + TestMcpStdioServer stdioServer = new(); + TestMetadataProviderFactory metadataProviderFactory = new() + { + InitializeAsyncException = InferenceFailure() + }; + using ServiceProvider serviceProvider = + BuildServices(stdioServer, metadataProviderFactory, out _); + TestHost host = new(serviceProvider); + + TextWriter originalError = Console.Error; + TextWriter originalOut = Console.Out; + using StringWriter capturedError = new(); + using StringWriter capturedOut = new(); + bool result; + + try + { + Console.SetError(capturedError); + Console.SetOut(capturedOut); + result = McpStdioHelper.RunMcpStdioHost(host); + } + finally + { + Console.SetError(originalError); + Console.SetOut(originalOut); + } + + string reported = capturedError.ToString(); + + Assert.IsFalse(result, "A startup failure should be reported through the bool contract."); + Assert.AreEqual(0, stdioServer.RunAsyncCallCount, + "The stdio loop must not run: it would advertise entities that have no database object."); + Assert.AreEqual(1, host.DisposeCallCount, + "The host must still be disposed when startup fails."); + StringAssert.Contains(reported, "MCP stdio host", + "The operator needs to know which host failed, not only that one did."); + StringAssert.Contains(reported, "has not been inferred", + "GetAwaiter().GetResult() rethrows the original exception, so the cause must survive."); + Assert.AreEqual(string.Empty, capturedOut.ToString(), + "stdout is the JSON-RPC channel; a stray byte on it corrupts the protocol."); + } + + /// + /// --mcp-stdio defaults to LogLevel.None, at which Program points stderr at TextWriter.Null. + /// Reporting without handling that writes into a null sink, which is why the existing + /// "Unable to launch the runtime" message is never seen in that mode. The report goes to the + /// real stream without installing a writer that would outlive the call. + /// + [TestMethod] + public void RunMcpStdioHost_StartupFails_WhenStandardErrorSuppressed_LeavesConsoleUnchanged() + { + TestMcpStdioServer stdioServer = new(); + TestMetadataProviderFactory metadataProviderFactory = new() + { + InitializeAsyncException = InferenceFailure() + }; + using ServiceProvider serviceProvider = + BuildServices(stdioServer, metadataProviderFactory, out _); + TestHost host = new(serviceProvider); + + TextWriter originalError = Console.Error; + bool result; + bool consoleErrorUntouched; + + try + { + // Reproduces Program's LogLevel.None branch for --mcp-stdio. + Console.SetError(TextWriter.Null); + result = McpStdioHelper.RunMcpStdioHost(host); + consoleErrorUntouched = ReferenceEquals(Console.Error, TextWriter.Null); + } + finally + { + Console.SetError(originalError); + } + + Assert.IsFalse(result, "The bool contract holds whether or not stderr was suppressed."); + Assert.IsTrue(consoleErrorUntouched, + "The report must not leave a replacement writer installed on Console.Error."); + Assert.AreEqual(0, stdioServer.RunAsyncCallCount, + "The stdio loop must not run after startup failed."); + } + + private static DataApiBuilderException InferenceFailure() => new( + message: "Database object for entity 'Book' has not been inferred.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + + private static ServiceProvider BuildServices( + TestMcpStdioServer stdioServer, + TestMetadataProviderFactory metadataProviderFactory, + out TestApplicationLifetime lifetime) + { + lifetime = new TestApplicationLifetime(); + + ServiceCollection services = new(); + services.AddSingleton(); + services.AddSingleton(lifetime); + services.AddSingleton(stdioServer); + services.AddSingleton(metadataProviderFactory); + + return services.BuildServiceProvider(); + } + private sealed class TestMetadataProviderFactory : IMetadataProviderFactory { public int InitializeAsyncCallCount { get; private set; } + /// + /// When set, InitializeAsync() returns a faulted task carrying it, standing in for a + /// metadata inference failure such as an unreachable database or an entity that is + /// missing from the schema. A faulted task rather than a synchronous throw, because + /// the real MetadataProviderFactory.InitializeAsync is async. + /// + public Exception? InitializeAsyncException { get; init; } + public Task InitializeAsync() { InitializeAsyncCallCount++; - return Task.CompletedTask; + return InitializeAsyncException is null + ? Task.CompletedTask + : Task.FromException(InitializeAsyncException); } public void InitializeAsync( diff --git a/src/Service/Utilities/McpStdioHelper.cs b/src/Service/Utilities/McpStdioHelper.cs index 7af1ff7862..65307336e3 100644 --- a/src/Service/Utilities/McpStdioHelper.cs +++ b/src/Service/Utilities/McpStdioHelper.cs @@ -4,6 +4,11 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text; +using Azure.DataApiBuilder.Core.Services.MetadataProviders; +using Azure.DataApiBuilder.Mcp.Core; +using Azure.DataApiBuilder.Mcp.Model; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -15,6 +20,14 @@ namespace Azure.DataApiBuilder.Service.Utilities /// internal static class McpStdioHelper { + /// + /// Reported when the MCP stdio host fails, mirroring the single message the web path uses in + /// Startup.PerformOnConfigChangeAsync. Deliberately not "startup": the catch also covers the + /// stdio loop, so a mid-session failure reports through here too. + /// + private const string STDIO_HOST_FAILED_MESSAGE = + "Unable to run the MCP stdio host. Refer to exception for error details."; + /// /// Determines if MCP stdio mode should be run based on command line arguments. /// @@ -74,6 +87,8 @@ public static void ConfigureMcpStdio(IConfigurationBuilder builder, string? mcpR /// Runs the MCP stdio host. /// /// The host to run. + /// True when the stdio loop ran to completion; false when startup failed and was + /// reported, which Program.Main surfaces as a non-zero exit code. public static bool RunMcpStdioHost(IHost host) { try @@ -84,26 +99,53 @@ public static bool RunMcpStdioHost(IHost host) // the tool registry (their names come from config) while no entity ever gets // a database object, and every tool call fails with // "Database object for entity '' has not been inferred." - Core.Services.MetadataProviders.IMetadataProviderFactory metadataProviderFactory = - host.Services.GetRequiredService(); + IMetadataProviderFactory metadataProviderFactory = + host.Services.GetRequiredService(); metadataProviderFactory.InitializeAsync().GetAwaiter().GetResult(); - Mcp.Core.McpToolRegistry registry = - host.Services.GetRequiredService(); - IEnumerable tools = - host.Services.GetServices(); + McpToolRegistry registry = + host.Services.GetRequiredService(); + IEnumerable tools = + host.Services.GetServices(); - Mcp.Core.McpToolRegistry.InitializeAndRegisterTools(tools, registry, host.Services); + McpToolRegistry.InitializeAndRegisterTools(tools, registry, host.Services); IHostApplicationLifetime lifetime = host.Services.GetRequiredService(); - Mcp.Core.IMcpStdioServer stdio = - host.Services.GetRequiredService(); + IMcpStdioServer stdio = + host.Services.GetRequiredService(); stdio.RunAsync(lifetime.ApplicationStopping).GetAwaiter().GetResult(); return true; } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Mirrors Startup.PerformOnConfigChangeAsync: report and return false instead of letting + // the exception escape a method whose contract is a bool, and Program.Main turns that + // false into ExitCode -1. Cancellation is left to Program.StartEngine's own handler. + // ILogger reaches nobody this early -- stdio keeps only McpLoggerProvider, which stays + // disabled until the client sends logging/setLevel, impossible before the JSON-RPC loop + // runs -- so stderr is the only open channel. At the --mcp-stdio default of LogLevel.None + // Program has already pointed stderr at TextWriter.Null, so write the stream directly in + // that case rather than installing a writer that would outlive this call. stdout is left + // untouched for JSON-RPC. + string report = $"{STDIO_HOST_FAILED_MESSAGE} {ex}"; + + if (ReferenceEquals(Console.Error, TextWriter.Null)) + { + using StreamWriter standardError = new( + Console.OpenStandardError(), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + standardError.WriteLine(report); + } + else + { + Console.Error.WriteLine(report); + } + + return false; + } finally { host.Dispose();