From 5b648babd6bdbc5fa85b8da646529fe37cc3337d Mon Sep 17 00:00:00 2001 From: Christos Date: Wed, 26 Aug 2026 23:20:29 +0300 Subject: [PATCH] [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 =