diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
index 690098384b..7553a1d8e7 100644
--- a/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
+++ b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
@@ -61,17 +61,20 @@ public McpStdioServer(McpToolRegistry toolRegistry, IServiceProvider serviceProv
/// A task representing the asynchronous operation.
public async Task RunAsync(CancellationToken cancellationToken)
{
- // Use UTF-8 WITHOUT BOM for stdin. Stdout is owned by McpStdoutWriter,
- // which serializes all writes from McpStdioServer and the MCP logging
- // pipeline so JSON-RPC frames cannot interleave at the byte level.
- UTF8Encoding utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
-
- using Stream stdin = Console.OpenStandardInput();
- using StreamReader reader = new(stdin, utf8NoBom);
+ // Read through Console.In so tests can inject stdin and the process
+ // still follows the configured console input encoding in stdio mode.
+ TextReader reader = Console.In;
while (!cancellationToken.IsCancellationRequested)
{
string? line = await reader.ReadLineAsync(cancellationToken);
+
+ // EOF (stdin pipe closed) is a normal shutdown signal for stdio mode.
+ if (line is null)
+ {
+ return;
+ }
+
if (string.IsNullOrWhiteSpace(line))
{
continue;
diff --git a/src/Service.Tests/UnitTests/McpStdioServerRunAsyncTests.cs b/src/Service.Tests/UnitTests/McpStdioServerRunAsyncTests.cs
new file mode 100644
index 0000000000..dda2f348f3
--- /dev/null
+++ b/src/Service.Tests/UnitTests/McpStdioServerRunAsyncTests.cs
@@ -0,0 +1,87 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+#nullable enable
+
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.DataApiBuilder.Mcp.Core;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ [TestClass]
+ public class McpStdioServerRunAsyncTests
+ {
+ [TestMethod]
+ public async Task RunAsync_EofOnStdin_ExitsGracefullyWithoutOutput()
+ {
+ (McpStdioServer server, StringWriter stdoutCapture) = CreateServerWithCapturedOutput();
+ TextReader originalIn = Console.In;
+
+ try
+ {
+ // Empty input immediately yields EOF (ReadLineAsync returns null).
+ Console.SetIn(new StringReader(string.Empty));
+
+ await server.RunAsync(CancellationToken.None);
+
+ Assert.AreEqual(string.Empty, stdoutCapture.ToString(),
+ "Server should exit cleanly on EOF without emitting protocol output.");
+ }
+ finally
+ {
+ Console.SetIn(originalIn);
+ }
+ }
+
+ [TestMethod]
+ public async Task RunAsync_BlankLineThenShutdown_IgnoresBlankLineAndHandlesShutdown()
+ {
+ (McpStdioServer server, StringWriter stdoutCapture) = CreateServerWithCapturedOutput();
+ TextReader originalIn = Console.In;
+
+ try
+ {
+ Console.SetIn(new StringReader(Environment.NewLine +
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"shutdown\"}" +
+ Environment.NewLine));
+
+ await server.RunAsync(CancellationToken.None);
+
+ string[] lines = stdoutCapture
+ .ToString()
+ .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
+
+ Assert.AreEqual(1, lines.Length,
+ "Expected a single response line for shutdown request.");
+ StringAssert.Contains(lines[0], "\"id\":1");
+ StringAssert.Contains(lines[0], "\"ok\":true");
+ }
+ finally
+ {
+ Console.SetIn(originalIn);
+ }
+ }
+
+ private static (McpStdioServer server, StringWriter stdoutCapture) CreateServerWithCapturedOutput()
+ {
+ StringWriter stdoutCapture = new();
+ McpStdoutWriter stdoutWriter = new(stdoutCapture);
+
+ ServiceCollection services = new();
+ services.AddSingleton(stdoutWriter);
+ services.AddSingleton();
+ IServiceProvider serviceProvider = services.BuildServiceProvider();
+
+ McpStdioServer server = new(
+ serviceProvider.GetRequiredService(),
+ serviceProvider);
+
+ return (server, stdoutCapture);
+ }
+ }
+}