Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public class McpStdioServer : IMcpStdioServer
private readonly McpToolRegistry _toolRegistry;
private readonly IServiceProvider _serviceProvider;
private readonly McpStdoutWriter _stdoutWriter;
private readonly TextReader? _inputReader;
private readonly string _protocolVersion;

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

public McpStdioServer(McpToolRegistry toolRegistry, IServiceProvider serviceProvider)
public McpStdioServer(McpToolRegistry toolRegistry, IServiceProvider serviceProvider, TextReader? inputReader = null)
{
_toolRegistry = toolRegistry ?? throw new ArgumentNullException(nameof(toolRegistry));
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_inputReader = inputReader;

// Resolve the shared stdout writer so JSON-RPC responses and
// notifications/message frames are serialized through one lock.
Expand All @@ -61,17 +63,20 @@ public McpStdioServer(McpToolRegistry toolRegistry, IServiceProvider serviceProv
/// <returns>A task representing the asynchronous operation.</returns>
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);
// By default read via Console.In so the loop honors the configured
// Console.InputEncoding in stdio mode.
TextReader reader = _inputReader ?? 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;
Expand Down
79 changes: 79 additions & 0 deletions src/Service.Tests/UnitTests/McpStdioServerRunAsyncTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

#nullable enable

using System;
using System.IO;
using System.Text.Json;
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()
{
// Empty input immediately yields EOF (ReadLineAsync returns null).
(McpStdioServer server, StringWriter stdoutCapture) =
CreateServerWithCapturedOutput(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.");
}

[TestMethod]
public async Task RunAsync_BlankLineThenShutdown_IgnoresBlankLineAndHandlesShutdown()
{
(McpStdioServer server, StringWriter stdoutCapture) =
CreateServerWithCapturedOutput(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.");

using JsonDocument response = JsonDocument.Parse(lines[0]);
JsonElement root = response.RootElement;

Assert.AreEqual("2.0", root.GetProperty("jsonrpc").GetString(),
"Expected jsonrpc version 2.0 in shutdown response.");
Assert.AreEqual(1, root.GetProperty("id").GetInt32(),
"Expected shutdown response id to match request id.");
Assert.IsTrue(root.GetProperty("result").GetProperty("ok").GetBoolean(),
"Expected shutdown response result.ok to be true.");
}

private static (McpStdioServer server, StringWriter stdoutCapture) CreateServerWithCapturedOutput(TextReader inputReader)
{
StringWriter stdoutCapture = new();
McpStdoutWriter stdoutWriter = new(stdoutCapture);

ServiceCollection services = new();
services.AddSingleton(stdoutWriter);
services.AddSingleton<McpToolRegistry>();
IServiceProvider serviceProvider = services.BuildServiceProvider();

McpStdioServer server = new(
serviceProvider.GetRequiredService<McpToolRegistry>(),
serviceProvider,
inputReader);

return (server, stdoutCapture);
}
}
}