diff --git a/src/Service.Tests/UnitTests/McpStdioHelperTests.cs b/src/Service.Tests/UnitTests/McpStdioHelperTests.cs index daf0c9e3b1..ecea06b236 100644 --- a/src/Service.Tests/UnitTests/McpStdioHelperTests.cs +++ b/src/Service.Tests/UnitTests/McpStdioHelperTests.cs @@ -3,9 +3,17 @@ #nullable enable +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; @@ -19,15 +27,10 @@ public class McpStdioHelperTests [TestMethod] public void RunMcpStdioHost_DoesNotStartWebHost() { - ServiceCollection services = new(); - TestApplicationLifetime lifetime = new(); TestMcpStdioServer stdioServer = new(); - - services.AddSingleton(); - services.AddSingleton(lifetime); - services.AddSingleton(stdioServer); - - using ServiceProvider serviceProvider = services.BuildServiceProvider(); + TestMetadataProviderFactory metadataProviderFactory = new(); + using ServiceProvider serviceProvider = + BuildServices(stdioServer, metadataProviderFactory, out TestApplicationLifetime lifetime); TestHost host = new(serviceProvider); bool result = McpStdioHelper.RunMcpStdioHost(host); @@ -43,6 +46,158 @@ 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.\""); + } + + /// + /// 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 InitializeAsyncException is null + ? Task.CompletedTask + : Task.FromException(InitializeAsyncException); + } + + 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..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,26 +87,65 @@ 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 { - Mcp.Core.McpToolRegistry registry = - host.Services.GetRequiredService(); - IEnumerable tools = - host.Services.GetServices(); + // 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." + IMetadataProviderFactory metadataProviderFactory = + host.Services.GetRequiredService(); + metadataProviderFactory.InitializeAsync().GetAwaiter().GetResult(); + + 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();