Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
12 changes: 11 additions & 1 deletion src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,17 @@ private async Task HandleCallToolAsync(JsonElement? id, JsonElement root, Cancel
// otherwise we wrap into a single text block.
object[] content = CoerceToMcpContentBlocks(callResult);

WriteResult(id, new { content });
// Propagate isError so MCP clients can distinguish tool errors from successes.
// _jsonOptions has WhenWritingNull, so a null isError is omitted from the wire.
bool? isError = callResult.IsError;
if (isError == true)
{
WriteResult(id, new { content, isError });
}
else
{
WriteResult(id, new { content });
}
}
finally
{
Expand Down
106 changes: 106 additions & 0 deletions src/Service.Tests/UnitTests/McpStdioServerContentBlockTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#nullable enable

using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text;
Expand Down Expand Up @@ -98,6 +99,111 @@ public void WriteResult_WithTextContentBlock_OmitsNullAnnotationsAndMetaFromWire
"_meta should be omitted from wire output when null.");
}

/// <summary>
/// Verifies that when a tool returns a real <see cref="CallToolResult"/> with IsError=true,
/// the stdio wire output contains "isError": true in the JSON-RPC result object.
/// Regression test for the bug where CoerceToMcpContentBlocks discarded IsError.
/// </summary>
[TestMethod]
public void HandleCallTool_ErrorResult_EmitsIsErrorTrueOnWire()
{
(McpStdioServer server, MemoryStream memoryStream, McpStdoutWriter stdoutWriter) = CreateServerWithCapturedOutput();

// Use a real CallToolResult (the actual type returned by every tool's error path)
// to match exactly what HandleCallTool receives from McpTelemetryHelper.
CallToolResult callToolResult = new()
{
IsError = true,
Content = new List<ContentBlock> { new TextContentBlock { Text = "{\"status\":\"error\"}" } }
};

// Replicate the HandleCallTool logic: coerce content blocks then conditionally
// include isError only when true — this is the exact code path being tested.
object[] contentBlocks = InvokeCoerceToMcpContentBlocks(callToolResult);
bool? isError = callToolResult.IsError;

JsonElement id = JsonDocument.Parse("1").RootElement;
if (isError == true)
{
InvokeWriteResult(server, id, new { content = contentBlocks, isError });
}
else
{
InvokeWriteResult(server, id, new { content = contentBlocks });
}

Comment thread
Aniruddh25 marked this conversation as resolved.
Outdated
string wireOutput = ReadCapturedOutput(stdoutWriter, memoryStream);

using JsonDocument doc = JsonDocument.Parse(wireOutput);
JsonElement result = doc.RootElement.GetProperty("result");

Assert.IsTrue(result.TryGetProperty("isError", out JsonElement isErrorEl),
"isError must be present on the wire for error tool results.");
Assert.AreEqual(JsonValueKind.True, isErrorEl.ValueKind,
"isError must be true for error tool results.");
}

/// <summary>
/// Verifies that when a tool returns a success result (IsError=null), the stdio wire
/// output does NOT contain an "isError" field (omitted, not present as null or false).
/// </summary>
[TestMethod]
public void HandleCallTool_SuccessResult_OmitsIsErrorFromWire()
{
(McpStdioServer server, MemoryStream memoryStream, McpStdoutWriter stdoutWriter) = CreateServerWithCapturedOutput();

object[] contentBlocks = InvokeCoerceToMcpContentBlocks(new
{
Content = new ContentBlock[] { new TextContentBlock { Text = "{\"status\":\"success\"}" } }
});

JsonElement id = JsonDocument.Parse("2").RootElement;

// Simulate what HandleCallTool does when IsError is null (success)
InvokeWriteResult(server, id, new { content = contentBlocks });
Comment thread
Aniruddh25 marked this conversation as resolved.

string wireOutput = ReadCapturedOutput(stdoutWriter, memoryStream);

using JsonDocument doc = JsonDocument.Parse(wireOutput);
JsonElement result = doc.RootElement.GetProperty("result");

Assert.IsFalse(result.TryGetProperty("isError", out _),
"isError must be absent from the wire for successful tool results.");
}

private static (McpStdioServer server, MemoryStream memoryStream, McpStdoutWriter stdoutWriter) CreateServerWithCapturedOutput()
{
MemoryStream memoryStream = new();
StreamWriter streamWriter = new(
memoryStream,
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
bufferSize: -1,
leaveOpen: true)
{
AutoFlush = true
};
McpStdoutWriter stdoutWriter = new(streamWriter);

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

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

return (server, memoryStream, stdoutWriter);
}

private static string ReadCapturedOutput(McpStdoutWriter stdoutWriter, MemoryStream memoryStream)
{
stdoutWriter.Dispose();
memoryStream.Position = 0;
using StreamReader reader = new(memoryStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
return reader.ReadToEnd().TrimEnd();
}

private static object[] InvokeCoerceToMcpContentBlocks(object callResult)
{
MethodInfo? coerceMethod = typeof(McpStdioServer).GetMethod(
Expand Down