Handle stdin EOF gracefully in MCP stdio mode (#3428)#3647
Conversation
There was a problem hiding this comment.
Pull request overview
This PR improves the robustness of DAB’s MCP stdio hosting path by treating stdin EOF (client closes the pipe) as a normal shutdown signal rather than an error condition that terminates the process with a noisy non-zero exit code.
Changes:
- Updated
McpStdioServer.RunAsyncto read fromConsole.Inand to exit cleanly whenReadLineAsyncreturnsnull(EOF). - Added unit tests validating clean exit on EOF and correct handling of blank lines followed by a
shutdownrequest.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| src/Service.Tests/UnitTests/McpStdioServerRunAsyncTests.cs | Adds unit coverage for EOF-on-stdin and blank-line handling in the MCP stdio loop. |
| src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs | Treats stdin EOF as graceful shutdown by returning from the read loop when ReadLineAsync yields null. |
|
|
||
| using Stream stdin = Console.OpenStandardInput(); | ||
| using StreamReader reader = new(stdin, utf8NoBom); | ||
| // Read through Console.In so tests can inject stdin and the process |
There was a problem hiding this comment.
nit: leaks test concerns into production code. from a production standpoint, the rationale here I think is that we want to honor the configured Console.In ie:
// Read via Console.In so the loop honors the configured Console.InputEncoding
// (set to UTF-8 by Program.Main for stdio mode) and shares the SyncTextReader.
| try | ||
| { | ||
| // Empty input immediately yields EOF (ReadLineAsync returns null). | ||
| Console.SetIn(new StringReader(string.Empty)); |
There was a problem hiding this comment.
nit: Console.SetIn is process-global, if we ever parallelize this test it could break other tests that read Console.In. Can instead extract the read source, and then tests can pass a StringReader directly.
|
|
||
| Assert.AreEqual(1, lines.Length, | ||
| "Expected a single response line for shutdown request."); | ||
| StringAssert.Contains(lines[0], "\"id\":1"); |
There was a problem hiding this comment.
nit: parsing this line as JSON and then asserting on jsonrpc/id/result.ok would align more closely with the other mcp tests while being a bit more robust.
aaronburtle
left a comment
There was a problem hiding this comment.
Looks good, just a couple nits.
| 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; |
There was a problem hiding this comment.
Do we no longer need utf8NoBom for stdin? Why was it needed before and what changed?
Aniruddh25
left a comment
There was a problem hiding this comment.
Approving with some questions to avoid regression around UTFNoBOM
Why make this change?
When
dab start --mcp-stdiowas launched by a client (such as MCP Inspector or VS Code) and the client closed the stdin pipe (EOF), the process crashed with exit code4294967295instead of exiting cleanly. This caused noisy error signals in any tool or host that expected a clean0exit on shutdown.What is this change?
Modified
McpStdioServer.RunAsyncto handle stdin EOF as a normal shutdown signal:Console.OpenStandardInput()+StreamReaderwithConsole.In(testable, injectable)nullcheck on the return value ofReadLineAsync—nullmeans EOF, which now triggers a cleanreturninstead of an unhandled exceptionHow was this tested?
Unit Tests
Added
McpStdioServerRunAsyncTests:RunAsync_EofOnStdin_ExitsGracefullyWithoutOutput— feeds empty stdin, verifies clean exit with no stdout outputRunAsync_BlankLineThenShutdown_IgnoresBlankLineAndHandlesShutdown— feeds blank line + shutdown request, verifies single valid response and clean exitManual Testing
initializerequest then closed stdin — verifiedEXIT_CODE=0EXIT_CODE=0for normalshutdownrequest pathread_records,describe_entities) continue to work after the change