Skip to content

Commit 571b6a3

Browse files
Avoid starting web host in MCP stdio mode (#3676)
## Summary `dab start --mcp-stdio` communicates over stdin/stdout, but the stdio helper was calling `host.Start()`. That starts the web host and Kestrel, so stdio mode still tried to bind the configured HTTP port. When the port was occupied, the process exited before the MCP `initialize` handshake. The stdio path already initializes and registers MCP tools directly from DI, so it can run the stdio JSON-RPC loop without starting Kestrel. Fixes #3675. ## Changes - Avoid starting the ASP.NET Core host when running the engine in MCP stdio mode. - Keep MCP tool registry initialization in the stdio path before running the JSON-RPC loop. - Dispose the host after the stdio loop exits without calling `StartAsync()` or `StopAsync()`. - Add a regression test that proves `RunMcpStdioHost()` does not call `IHost.StartAsync()`/`StopAsync()`, still starts the stdio server loop, and disposes the host. ## Verification ```bash dotnet test src/Service.Tests/Azure.DataApiBuilder.Service.Tests.csproj --filter 'FullyQualifiedName~McpStdioHelperTests' -p:RuntimeIdentifiers= -p:UseAppHost=false dotnet test src/Service.Tests/Azure.DataApiBuilder.Service.Tests.csproj --filter 'FullyQualifiedName~McpStdioHelperTests|FullyQualifiedName~McpStdioServerRunAsyncTests|FullyQualifiedName~McpStdioServerInitializeTests|FullyQualifiedName~McpStdioServerContentBlockTests|FullyQualifiedName~McpToolRegistryTests' -p:RuntimeIdentifiers= -p:UseAppHost=false git diff --check ``` A runtime smoke also occupied `127.0.0.1:5155`, pointed `ASPNETCORE_URLS` at that occupied port, and verified that the service completed MCP `initialize` and `shutdown` over stdio without an address-in-use error. Co-authored-by: RubenCerna2079 <32799214+RubenCerna2079@users.noreply.github.com>
1 parent a4505f8 commit 571b6a3

2 files changed

Lines changed: 129 additions & 14 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
#nullable enable
5+
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Azure.DataApiBuilder.Mcp.Core;
9+
using Azure.DataApiBuilder.Service.Utilities;
10+
using Microsoft.Extensions.DependencyInjection;
11+
using Microsoft.Extensions.Hosting;
12+
using Microsoft.VisualStudio.TestTools.UnitTesting;
13+
14+
namespace Azure.DataApiBuilder.Service.Tests.UnitTests
15+
{
16+
[TestClass]
17+
public class McpStdioHelperTests
18+
{
19+
[TestMethod]
20+
public void RunMcpStdioHost_DoesNotStartWebHost()
21+
{
22+
ServiceCollection services = new();
23+
TestApplicationLifetime lifetime = new();
24+
TestMcpStdioServer stdioServer = new();
25+
26+
services.AddSingleton<McpToolRegistry>();
27+
services.AddSingleton<IHostApplicationLifetime>(lifetime);
28+
services.AddSingleton<IMcpStdioServer>(stdioServer);
29+
30+
using ServiceProvider serviceProvider = services.BuildServiceProvider();
31+
TestHost host = new(serviceProvider);
32+
33+
bool result = McpStdioHelper.RunMcpStdioHost(host);
34+
35+
Assert.IsTrue(result);
36+
Assert.AreEqual(0, host.StartAsyncCallCount,
37+
"MCP stdio mode should not start the ASP.NET Core web host because that binds HTTP ports.");
38+
Assert.AreEqual(0, host.StopAsyncCallCount,
39+
"MCP stdio mode should not stop a host that was never started.");
40+
Assert.AreEqual(1, stdioServer.RunAsyncCallCount,
41+
"MCP stdio mode should still run the stdio JSON-RPC loop.");
42+
Assert.AreEqual(lifetime.ApplicationStopping, stdioServer.CancellationToken,
43+
"The stdio loop should keep using the host lifetime cancellation token.");
44+
Assert.AreEqual(1, host.DisposeCallCount,
45+
"MCP stdio mode should dispose the host after the stdio loop exits.");
46+
}
47+
48+
private sealed class TestHost : IHost
49+
{
50+
public TestHost(System.IServiceProvider services)
51+
{
52+
Services = services;
53+
}
54+
55+
public System.IServiceProvider Services { get; }
56+
57+
public int StartAsyncCallCount { get; private set; }
58+
59+
public int StopAsyncCallCount { get; private set; }
60+
61+
public int DisposeCallCount { get; private set; }
62+
63+
public Task StartAsync(CancellationToken cancellationToken = default)
64+
{
65+
StartAsyncCallCount++;
66+
return Task.CompletedTask;
67+
}
68+
69+
public Task StopAsync(CancellationToken cancellationToken = default)
70+
{
71+
StopAsyncCallCount++;
72+
return Task.CompletedTask;
73+
}
74+
75+
public void Dispose()
76+
{
77+
DisposeCallCount++;
78+
}
79+
}
80+
81+
private sealed class TestApplicationLifetime : IHostApplicationLifetime
82+
{
83+
private readonly CancellationTokenSource _applicationStopping = new();
84+
85+
public CancellationToken ApplicationStarted => CancellationToken.None;
86+
87+
public CancellationToken ApplicationStopping => _applicationStopping.Token;
88+
89+
public CancellationToken ApplicationStopped => CancellationToken.None;
90+
91+
public void StopApplication()
92+
{
93+
_applicationStopping.Cancel();
94+
}
95+
}
96+
97+
private sealed class TestMcpStdioServer : IMcpStdioServer
98+
{
99+
public int RunAsyncCallCount { get; private set; }
100+
101+
public CancellationToken CancellationToken { get; private set; }
102+
103+
public Task RunAsync(CancellationToken cancellationToken)
104+
{
105+
RunAsyncCallCount++;
106+
CancellationToken = cancellationToken;
107+
return Task.CompletedTask;
108+
}
109+
}
110+
}
111+
}

src/Service/Utilities/McpStdioHelper.cs

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -76,24 +76,28 @@ public static void ConfigureMcpStdio(IConfigurationBuilder builder, string? mcpR
7676
/// <param name="host"> The host to run.</param>
7777
public static bool RunMcpStdioHost(IHost host)
7878
{
79-
host.Start();
80-
81-
Mcp.Core.McpToolRegistry registry =
82-
host.Services.GetRequiredService<Mcp.Core.McpToolRegistry>();
83-
IEnumerable<Mcp.Model.IMcpTool> tools =
84-
host.Services.GetServices<Mcp.Model.IMcpTool>();
79+
try
80+
{
81+
Mcp.Core.McpToolRegistry registry =
82+
host.Services.GetRequiredService<Mcp.Core.McpToolRegistry>();
83+
IEnumerable<Mcp.Model.IMcpTool> tools =
84+
host.Services.GetServices<Mcp.Model.IMcpTool>();
8585

86-
Mcp.Core.McpToolRegistry.InitializeAndRegisterTools(tools, registry, host.Services);
86+
Mcp.Core.McpToolRegistry.InitializeAndRegisterTools(tools, registry, host.Services);
8787

88-
IHostApplicationLifetime lifetime =
89-
host.Services.GetRequiredService<IHostApplicationLifetime>();
90-
Mcp.Core.IMcpStdioServer stdio =
91-
host.Services.GetRequiredService<Mcp.Core.IMcpStdioServer>();
88+
IHostApplicationLifetime lifetime =
89+
host.Services.GetRequiredService<IHostApplicationLifetime>();
90+
Mcp.Core.IMcpStdioServer stdio =
91+
host.Services.GetRequiredService<Mcp.Core.IMcpStdioServer>();
9292

93-
stdio.RunAsync(lifetime.ApplicationStopping).GetAwaiter().GetResult();
94-
host.StopAsync().GetAwaiter().GetResult();
93+
stdio.RunAsync(lifetime.ApplicationStopping).GetAwaiter().GetResult();
9594

96-
return true;
95+
return true;
96+
}
97+
finally
98+
{
99+
host.Dispose();
100+
}
97101
}
98102
}
99103
}

0 commit comments

Comments
 (0)