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
171 changes: 163 additions & 8 deletions src/Service.Tests/UnitTests/McpStdioHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,15 +27,10 @@ public class McpStdioHelperTests
[TestMethod]
public void RunMcpStdioHost_DoesNotStartWebHost()
{
ServiceCollection services = new();
TestApplicationLifetime lifetime = new();
TestMcpStdioServer stdioServer = new();

services.AddSingleton<McpToolRegistry>();
services.AddSingleton<IHostApplicationLifetime>(lifetime);
services.AddSingleton<IMcpStdioServer>(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);
Expand All @@ -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 '<name>' has not been inferred.\"");
}

/// <summary>
/// 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.
/// </summary>
[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.");
}

/// <summary>
/// --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.
/// </summary>
[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<McpToolRegistry>();
services.AddSingleton<IHostApplicationLifetime>(lifetime);
services.AddSingleton<IMcpStdioServer>(stdioServer);
services.AddSingleton<IMetadataProviderFactory>(metadataProviderFactory);

return services.BuildServiceProvider();
}

private sealed class TestMetadataProviderFactory : IMetadataProviderFactory
{
public int InitializeAsyncCallCount { get; private set; }

/// <summary>
/// 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.
/// </summary>
public Exception? InitializeAsyncException { get; init; }

public Task InitializeAsync()
{
InitializeAsyncCallCount++;
return InitializeAsyncException is null
? Task.CompletedTask
: Task.FromException(InitializeAsyncException);
}

public void InitializeAsync(
Dictionary<string, Dictionary<string, DatabaseObject>> entityToDatabaseObjectMap,
Dictionary<string, Dictionary<string, string>> graphQLStoredProcedureExposedNameToEntityNameMap)
=> InitializeAsyncCallCount++;

public ISqlMetadataProvider GetMetadataProvider(string dataSourceName)
=> throw new NotImplementedException();

public IEnumerable<ISqlMetadataProvider> ListMetadataProviders()
=> Array.Empty<ISqlMetadataProvider>();

public List<Exception> GetAllMetadataExceptions()
=> new();
}

private sealed class TestHost : IHost
Expand Down
66 changes: 59 additions & 7 deletions src/Service/Utilities/McpStdioHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,6 +20,14 @@ namespace Azure.DataApiBuilder.Service.Utilities
/// </summary>
internal static class McpStdioHelper
{
/// <summary>
/// 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.
/// </summary>
private const string STDIO_HOST_FAILED_MESSAGE =
"Unable to run the MCP stdio host. Refer to exception for error details.";

/// <summary>
/// Determines if MCP stdio mode should be run based on command line arguments.
/// </summary>
Expand Down Expand Up @@ -74,26 +87,65 @@ public static void ConfigureMcpStdio(IConfigurationBuilder builder, string? mcpR
/// Runs the MCP stdio host.
/// </summary>
/// <param name="host"> The host to run.</param>
/// <returns>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.</returns>
public static bool RunMcpStdioHost(IHost host)
{
try
{
Mcp.Core.McpToolRegistry registry =
host.Services.GetRequiredService<Mcp.Core.McpToolRegistry>();
IEnumerable<Mcp.Model.IMcpTool> tools =
host.Services.GetServices<Mcp.Model.IMcpTool>();
// 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 '<name>' has not been inferred."
IMetadataProviderFactory metadataProviderFactory =
host.Services.GetRequiredService<IMetadataProviderFactory>();
metadataProviderFactory.InitializeAsync().GetAwaiter().GetResult();
Comment thread
souvikghosh04 marked this conversation as resolved.

McpToolRegistry registry =
host.Services.GetRequiredService<McpToolRegistry>();
IEnumerable<IMcpTool> tools =
host.Services.GetServices<IMcpTool>();

Mcp.Core.McpToolRegistry.InitializeAndRegisterTools(tools, registry, host.Services);
McpToolRegistry.InitializeAndRegisterTools(tools, registry, host.Services);

IHostApplicationLifetime lifetime =
host.Services.GetRequiredService<IHostApplicationLifetime>();
Mcp.Core.IMcpStdioServer stdio =
host.Services.GetRequiredService<Mcp.Core.IMcpStdioServer>();
IMcpStdioServer stdio =
host.Services.GetRequiredService<IMcpStdioServer>();

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();
Expand Down