Skip to content

Commit bfc4f3d

Browse files
authored
Handle stdin EOF gracefully in MCP stdio mode (#3428) (#3647)
## Why make this change? When `dab start --mcp-stdio` was launched by a client (such as MCP Inspector or VS Code) and the client closed the stdin pipe (EOF), the process crashed with exit code `4294967295` instead of exiting cleanly. This caused noisy error signals in any tool or host that expected a clean `0` exit on shutdown. ## What is this change? Modified `McpStdioServer.RunAsync` to handle stdin EOF as a normal shutdown signal: - Replaced direct `Console.OpenStandardInput()` + `StreamReader` with `Console.In` (testable, injectable) - Added explicit `null` check on the return value of `ReadLineAsync` — `null` means EOF, which now triggers a clean `return` instead of an unhandled exception - Blank lines continue to be skipped as before ## How was this tested? ### Unit Tests Added `McpStdioServerRunAsyncTests`: - `RunAsync_EofOnStdin_ExitsGracefullyWithoutOutput` — feeds empty stdin, verifies clean exit with no stdout output - `RunAsync_BlankLineThenShutdown_IgnoresBlankLineAndHandlesShutdown` — feeds blank line + shutdown request, verifies single valid response and clean exit ### Manual Testing - Reproduced the exact reported scenario: piped an `initialize` request then closed stdin — verified `EXIT_CODE=0` ``` echo '{"jsonrpc":"2.0","method":"initialize",...,"id":1}' | dab start --config dab-config.json --mcp-stdio echo "Exit code: $?" # was 4294967295, now 0 ``` - Verified `EXIT_CODE=0` for normal `shutdown` request path - Verified successful MCP tool calls (`read_records`, `describe_entities`) continue to work after the change
1 parent 56f0fc2 commit bfc4f3d

2 files changed

Lines changed: 92 additions & 8 deletions

File tree

src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public class McpStdioServer : IMcpStdioServer
2828
private readonly McpToolRegistry _toolRegistry;
2929
private readonly IServiceProvider _serviceProvider;
3030
private readonly McpStdoutWriter _stdoutWriter;
31+
private readonly TextReader? _inputReader;
3132
private readonly string _protocolVersion;
3233

3334
private const int MAX_LINE_LENGTH = 1024 * 1024; // 1 MB limit for incoming JSON-RPC requests
@@ -39,10 +40,11 @@ public class McpStdioServer : IMcpStdioServer
3940
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
4041
};
4142

42-
public McpStdioServer(McpToolRegistry toolRegistry, IServiceProvider serviceProvider)
43+
public McpStdioServer(McpToolRegistry toolRegistry, IServiceProvider serviceProvider, TextReader? inputReader = null)
4344
{
4445
_toolRegistry = toolRegistry ?? throw new ArgumentNullException(nameof(toolRegistry));
4546
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
47+
_inputReader = inputReader;
4648

4749
// Resolve the shared stdout writer so JSON-RPC responses and
4850
// notifications/message frames are serialized through one lock.
@@ -61,17 +63,20 @@ public McpStdioServer(McpToolRegistry toolRegistry, IServiceProvider serviceProv
6163
/// <returns>A task representing the asynchronous operation.</returns>
6264
public async Task RunAsync(CancellationToken cancellationToken)
6365
{
64-
// Use UTF-8 WITHOUT BOM for stdin. Stdout is owned by McpStdoutWriter,
65-
// which serializes all writes from McpStdioServer and the MCP logging
66-
// pipeline so JSON-RPC frames cannot interleave at the byte level.
67-
UTF8Encoding utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
68-
69-
using Stream stdin = Console.OpenStandardInput();
70-
using StreamReader reader = new(stdin, utf8NoBom);
66+
// By default read via Console.In so the loop honors the configured
67+
// Console.InputEncoding in stdio mode.
68+
TextReader reader = _inputReader ?? Console.In;
7169

7270
while (!cancellationToken.IsCancellationRequested)
7371
{
7472
string? line = await reader.ReadLineAsync(cancellationToken);
73+
74+
// EOF (stdin pipe closed) is a normal shutdown signal for stdio mode.
75+
if (line is null)
76+
{
77+
return;
78+
}
79+
7580
if (string.IsNullOrWhiteSpace(line))
7681
{
7782
continue;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
#nullable enable
5+
6+
using System;
7+
using System.IO;
8+
using System.Text.Json;
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
using Azure.DataApiBuilder.Mcp.Core;
12+
using Microsoft.Extensions.DependencyInjection;
13+
using Microsoft.VisualStudio.TestTools.UnitTesting;
14+
15+
namespace Azure.DataApiBuilder.Service.Tests.UnitTests
16+
{
17+
[TestClass]
18+
public class McpStdioServerRunAsyncTests
19+
{
20+
[TestMethod]
21+
public async Task RunAsync_EofOnStdin_ExitsGracefullyWithoutOutput()
22+
{
23+
// Empty input immediately yields EOF (ReadLineAsync returns null).
24+
(McpStdioServer server, StringWriter stdoutCapture) =
25+
CreateServerWithCapturedOutput(new StringReader(string.Empty));
26+
27+
await server.RunAsync(CancellationToken.None);
28+
29+
Assert.AreEqual(string.Empty, stdoutCapture.ToString(),
30+
"Server should exit cleanly on EOF without emitting protocol output.");
31+
}
32+
33+
[TestMethod]
34+
public async Task RunAsync_BlankLineThenShutdown_IgnoresBlankLineAndHandlesShutdown()
35+
{
36+
(McpStdioServer server, StringWriter stdoutCapture) =
37+
CreateServerWithCapturedOutput(new StringReader(Environment.NewLine +
38+
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"shutdown\"}" +
39+
Environment.NewLine));
40+
41+
await server.RunAsync(CancellationToken.None);
42+
43+
string[] lines = stdoutCapture
44+
.ToString()
45+
.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
46+
47+
Assert.AreEqual(1, lines.Length,
48+
"Expected a single response line for shutdown request.");
49+
50+
using JsonDocument response = JsonDocument.Parse(lines[0]);
51+
JsonElement root = response.RootElement;
52+
53+
Assert.AreEqual("2.0", root.GetProperty("jsonrpc").GetString(),
54+
"Expected jsonrpc version 2.0 in shutdown response.");
55+
Assert.AreEqual(1, root.GetProperty("id").GetInt32(),
56+
"Expected shutdown response id to match request id.");
57+
Assert.IsTrue(root.GetProperty("result").GetProperty("ok").GetBoolean(),
58+
"Expected shutdown response result.ok to be true.");
59+
}
60+
61+
private static (McpStdioServer server, StringWriter stdoutCapture) CreateServerWithCapturedOutput(TextReader inputReader)
62+
{
63+
StringWriter stdoutCapture = new();
64+
McpStdoutWriter stdoutWriter = new(stdoutCapture);
65+
66+
ServiceCollection services = new();
67+
services.AddSingleton(stdoutWriter);
68+
services.AddSingleton<McpToolRegistry>();
69+
IServiceProvider serviceProvider = services.BuildServiceProvider();
70+
71+
McpStdioServer server = new(
72+
serviceProvider.GetRequiredService<McpToolRegistry>(),
73+
serviceProvider,
74+
inputReader);
75+
76+
return (server, stdoutCapture);
77+
}
78+
}
79+
}

0 commit comments

Comments
 (0)