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); + } + } +}